diff --git a/.agents/skills/tui-development/SKILL.md b/.agents/skills/tui-development/SKILL.md index 5abc534f77..edd1397d56 100644 --- a/.agents/skills/tui-development/SKILL.md +++ b/.agents/skills/tui-development/SKILL.md @@ -469,14 +469,20 @@ Proto types come from `openshell-core` which generates them from `OUT_DIR` via ` ```rust use openshell_core::proto::openshell_client::OpenShellClient; -use openshell_core::proto::{ListSandboxesRequest, GetSandboxLogsRequest, ...}; +use openshell_core::proto::{ + all_workspaces_selector, workspace_selector, GetSandboxLogsRequest, + ListSandboxesRequest, ... +}; ``` ### Proto field gotchas - `DeleteSandboxRequest` uses the `name` field (not `id`): ```rust - let req = openshell_core::proto::DeleteSandboxRequest { name: sandbox_name }; + let req = openshell_core::proto::DeleteSandboxRequest { + name: sandbox_name, + workspace_scope: Some(workspace_selector(workspace)), + }; ``` - `WatchSandboxRequest` has extra fields beyond what you might need — always use `..Default::default()`: ```rust @@ -490,12 +496,24 @@ use openshell_core::proto::{ListSandboxesRequest, GetSandboxLogsRequest, ...}; }; ``` - `SandboxLogLine` proto fields: `sandbox_id`, `timestamp_ms`, `level`, `target`, `message`, `source`, `fields` (HashMap). -- `GetSandboxLogsRequest` fields: `sandbox_id`, `lines` (u32), `since_ms` (i64), `sources` (Vec), `min_level` (String), `workspace` (String). -- `ListSandboxesRequest` fields: `limit` (i64), `offset` (i64), `label_selector` (String), `workspace` (String), `all_workspaces` (bool). -- `ListProvidersRequest` fields: `limit` (i64), `offset` (i64), `workspace` (String), `all_workspaces` (bool). -- `ListWorkspacesRequest` fields: `limit` (i64), `offset` (i64), `label_selector` (String). -- `UpdateConfigRequest` fields: `name` (String, sandbox name or empty for global), `setting_key`, `setting_value`, `delete_setting` (bool), `global` (bool), `workspace`. -- Most resource requests include a `workspace` field that scopes the operation to the current workspace. +- Workspace-scoped request fields use `workspace_scope: Option`. + Select one workspace with `Some(workspace_selector(name))`. List requests that + explicitly support cross-workspace access also accept + `Some(all_workspaces_selector())`; do not use that marker on other requests. +- `GetSandboxLogsRequest` fields: `sandbox_id`, `lines` (u32), `since_ms` (i64), + `sources` (Vec), `min_level` (String), `workspace_scope`. +- `ListSandboxesRequest` fields: `limit` (u32), `offset` (u32), + `label_selector` (String), `workspace_scope`. +- `ListProvidersRequest` fields: `limit` (u32), `offset` (u32), + `workspace_scope`. +- `ListWorkspacesRequest` fields: `limit` (u32), `offset` (u32), + `label_selector` (String). +- `UpdateConfigRequest` fields include `name` (String, sandbox name or empty for + global), `setting_key`, `setting_value`, `delete_setting` (bool), `global` + (bool), and `workspace_scope`. Sandbox-scoped updates require a named selector; + gateway-global updates must leave `workspace_scope` as `None`. +- Most resource requests require an explicit named `workspace_scope`, including + the `default` workspace. An omitted selector is not an implicit default. ### gRPC timeouts diff --git a/.github/workflows/publish-docs-website.yml b/.github/workflows/publish-docs-website.yml index dfb24b9cea..5c99492b6e 100644 --- a/.github/workflows/publish-docs-website.yml +++ b/.github/workflows/publish-docs-website.yml @@ -25,6 +25,7 @@ permissions: concurrency: group: docs-website cancel-in-progress: false + queue: max defaults: run: diff --git a/.github/workflows/release-dev.yml b/.github/workflows/release-dev.yml index 18b5f33a2e..cfb1a0b546 100644 --- a/.github/workflows/release-dev.yml +++ b/.github/workflows/release-dev.yml @@ -28,6 +28,7 @@ jobs: password: ${{ secrets.GITHUB_TOKEN }} outputs: python_version: ${{ steps.v.outputs.python }} + docs_version: ${{ steps.v.outputs.docs_version }} cargo_version: ${{ steps.v.outputs.cargo }} deb_version: ${{ steps.v.outputs.deb }} rpm_version: ${{ steps.v.outputs.rpm_version }} @@ -47,11 +48,15 @@ jobs: id: v run: | set -euo pipefail - 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" + python_version=$(uv run python tasks/scripts/release.py get-version --dev --python) + { + echo "python=${python_version}" + echo "docs_version=${python_version%%+*}" + echo "cargo=$(uv run python tasks/scripts/release.py get-version --dev --cargo)" + echo "deb=$(uv run python tasks/scripts/release.py get-version --dev --deb)" + echo "rpm_version=$(uv run python tasks/scripts/release.py get-version --dev --rpm-version)" + echo "rpm_release=$(uv run python tasks/scripts/release.py get-version --dev --rpm-release)" + } >> "$GITHUB_OUTPUT" build-cli: needs: compute-versions @@ -628,6 +633,22 @@ jobs: release-kind: dev pin-sha: ${{ github.sha }} + publish-fern-docs: + name: Sync and Publish Fern Docs + needs: [compute-versions, release-dev, release-helm, trigger-wheel-publish] + permissions: + contents: write + uses: ./.github/workflows/sync-docs.yml + with: + operation: sync + channel: dev + source_ref: ${{ github.sha }} + release_version: ${{ needs.compute-versions.outputs.docs_version }} + display_name: Dev + availability: beta + publish: true + secrets: inherit + trigger-wheel-publish: name: Trigger Wheel Publish needs: [compute-versions, release-dev] diff --git a/.github/workflows/release-tag.yml b/.github/workflows/release-tag.yml index b3c45c97fb..2fe62fa25b 100644 --- a/.github/workflows/release-tag.yml +++ b/.github/workflows/release-tag.yml @@ -664,31 +664,20 @@ jobs: if-no-files-found: error publish-fern-docs: - name: Publish Fern Docs - needs: [compute-versions, release] + name: Sync and Publish Fern Docs + needs: [compute-versions, release, publish-sdk-typescript, release-helm, trigger-wheel-publish] if: needs.compute-versions.outputs.is_prerelease != 'true' - runs-on: ubuntu-latest - timeout-minutes: 15 - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: ${{ inputs.tag || github.ref }} - - - name: Setup Node.js - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 - with: - node-version: "24" - - - name: Install Fern CLI - run: | - FERN_VERSION=$(node -p "require('./fern/fern.config.json').version") - npm install -g "fern-api@${FERN_VERSION}" - - - name: Publish Fern docs - env: - FERN_TOKEN: ${{ secrets.FERN_TOKEN }} - working-directory: ./fern - run: fern generate --docs + permissions: + contents: write + uses: ./.github/workflows/sync-docs.yml + with: + operation: sync + channel: latest + source_ref: ${{ needs.compute-versions.outputs.source_sha }} + release_version: ${{ needs.compute-versions.outputs.semver }} + display_name: Latest (v${{ needs.compute-versions.outputs.semver }}) + publish: true + secrets: inherit publish-sdk-typescript: name: Publish TypeScript SDK diff --git a/.github/workflows/sync-docs.yml b/.github/workflows/sync-docs.yml index db2f29f736..63c4a6b8b8 100644 --- a/.github/workflows/sync-docs.yml +++ b/.github/workflows/sync-docs.yml @@ -4,6 +4,49 @@ name: Sync Docs Website on: + workflow_call: + inputs: + operation: + description: "Whether to sync or remove a docs snapshot" + required: true + type: string + channel: + description: "Docs channel to update or remove" + required: true + type: string + source_ref: + description: "Source commit SHA, branch, or tag to snapshot when operation=sync" + required: false + type: string + release_version: + description: "Release version used to order mutable channels" + required: false + type: string + version_slug: + description: "Version slug when channel=version" + required: false + type: string + display_name: + description: "Optional version selector display name" + required: false + type: string + availability: + description: "Optional Fern availability status" + required: false + type: string + publish: + description: "Publish production docs after syncing" + required: false + default: false + type: boolean + allow_rollback: + description: "Allow an explicitly requested mutable-channel rollback" + required: false + default: false + type: boolean + secrets: + FERN_TOKEN: + required: false workflow_dispatch: inputs: operation: @@ -21,19 +64,38 @@ on: options: - dev - latest + - stable - version source_ref: description: "Source commit SHA, branch, or tag to snapshot when operation=sync" required: false type: string + release_version: + description: "Release version, e.g. 0.1.2 or 0.1.3.dev4" + required: false + type: string version_slug: description: "Version slug when channel=version, e.g. v0.0.36" required: false type: string display_name: - description: "Optional version selector display name" + description: "Optional selector name, e.g. Dev" + required: false + type: string + availability: + description: "Optional Fern status: beta, deprecated, ga, or stable" required: false type: string + publish: + description: "Publish production docs after syncing" + required: false + default: false + type: boolean + allow_rollback: + description: "Allow an explicitly requested mutable-channel rollback" + required: false + default: false + type: boolean permissions: contents: write @@ -41,6 +103,7 @@ permissions: concurrency: group: docs-website cancel-in-progress: false + queue: max defaults: run: @@ -65,6 +128,7 @@ jobs: OPERATION: ${{ inputs.operation }} CHANNEL: ${{ inputs.channel }} SOURCE_REF: ${{ inputs.source_ref }} + RELEASE_VERSION: ${{ inputs.release_version }} VERSION_SLUG: ${{ inputs.version_slug }} run: | set -euo pipefail @@ -72,8 +136,12 @@ jobs: echo "source_ref is required when operation=sync" >&2 exit 1 fi - if [[ "$CHANNEL" == "version" && -z "$VERSION_SLUG" ]]; then - echo "version_slug is required when channel=version" >&2 + if [[ "$CHANNEL" =~ ^(dev|latest|stable)$ && -z "$RELEASE_VERSION" ]]; then + echo "release_version is required for dev, latest, and stable channels" >&2 + exit 1 + fi + if [[ "$CHANNEL" =~ ^(stable|version)$ && -z "$VERSION_SLUG" ]]; then + echo "version_slug is required for stable and version channels" >&2 exit 1 fi @@ -96,6 +164,7 @@ jobs: uses: astral-sh/setup-uv@ae62891fec2bb8e7d6c99fc78c9fec3a63790f8d # v10.0.0 with: version: "0.10.12" + python-version: "3.13" - name: Update docs snapshot # Inputs flow in as quoted env vars to avoid shell injection; see the @@ -104,17 +173,32 @@ jobs: OPERATION: ${{ inputs.operation }} CHANNEL: ${{ inputs.channel }} SOURCE_REF: ${{ inputs.source_ref }} + RELEASE_VERSION: ${{ inputs.release_version }} VERSION_SLUG: ${{ inputs.version_slug }} DISPLAY_NAME: ${{ inputs.display_name }} + AVAILABILITY: ${{ inputs.availability }} + ALLOW_ROLLBACK: ${{ inputs.allow_rollback }} run: | + SOURCE_SHA="" + if [[ "$OPERATION" == "sync" ]]; then + SOURCE_SHA=$(git -C source rev-parse HEAD) + fi + rollback_args=() + if [[ "$ALLOW_ROLLBACK" == "true" ]]; then + rollback_args+=(--allow-rollback) + fi uv run automation/tasks/scripts/sync_docs_website.py \ --operation "$OPERATION" \ --source-root source \ --docs-website-root docs-website \ --channel "$CHANNEL" \ --source-ref "$SOURCE_REF" \ + --source-sha "$SOURCE_SHA" \ + --release-version "$RELEASE_VERSION" \ --version-slug "$VERSION_SLUG" \ - --display-name "$DISPLAY_NAME" + --display-name "$DISPLAY_NAME" \ + --availability "$AVAILABILITY" \ + "${rollback_args[@]}" - name: Setup Node.js uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 @@ -160,3 +244,10 @@ jobs: git commit -m "docs(website): remove ${target} docs" fi git push origin HEAD:docs-website + + - name: Publish Fern docs + if: ${{ inputs.publish }} + env: + FERN_TOKEN: ${{ secrets.FERN_TOKEN }} + working-directory: docs-website/fern + run: fern generate --docs diff --git a/AGENTS.md b/AGENTS.md index 533510737b..0b35bd02f7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -255,9 +255,9 @@ When behavior, commands, or development workflows change, review the related age - When making changes, update the relevant documentation in the `architecture/` directory. - When changes affect user-facing behavior, update the relevant published docs pages under `docs/` and navigation in `docs/index.yml`. - 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. +- `fern/` contains the Fern site config, components, preview workflow inputs, publish settings, and publishing documentation in `fern/README.md`. - 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` for stable release tags. +- Fern PR previews run through `.github/workflows/branch-docs.yml`. Release Dev publishes `dev`, and Release Tag publishes an immutable stable version plus `latest`. Both production paths call `.github/workflows/sync-docs.yml` once. - Use the `update-docs-from-commits` skill to scan recent commits and draft doc updates. ### Architecture Docs diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e0b69f8b6f..12fb5c0f4c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -488,9 +488,9 @@ 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 stable release tag is created. +Release Dev publishes the `dev` docs version from `main`. Release Tag publishes an immutable stable version and updates `latest`. See [fern/README.md](fern/README.md) for the source layout, version model, and publishing workflows. -`docs/` is the source-of-truth docs tree. `fern/` contains the site config, components, and theme assets that publish those pages. +`docs/` is the source-of-truth docs tree. `fern/` contains the site configuration, components, theme assets, and its README. See [docs/CONTRIBUTING.mdx](docs/CONTRIBUTING.mdx) for the current docs authoring guide. diff --git a/architecture/build.md b/architecture/build.md index 5f371f8f15..6d41db4198 100644 --- a/architecture/build.md +++ b/architecture/build.md @@ -487,13 +487,7 @@ settings, exceptions, and the contributor and maintainer workflows. ## Docs Site -Published docs live in `docs/`. Navigation lives in `docs/index.yml`. Fern site -configuration, components, theme assets, and publish settings live in `fern/`. - -Use `mise run docs` for strict validation and `mise run docs:serve` for local -preview. PR previews are produced by `.github/workflows/branch-docs.yml` when -Fern credentials are available. Production docs publish from the release tag -workflow. +Published docs live in `docs/`, and Fern site configuration lives in `fern/`. See [fern/README.md](../fern/README.md) for the source layout, local development commands, version model, and publishing workflows. ## Validation Expectations diff --git a/architecture/gateway.md b/architecture/gateway.md index 4b0e70c730..f9e0858a49 100644 --- a/architecture/gateway.md +++ b/architecture/gateway.md @@ -54,6 +54,21 @@ 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. +Public workspace-scoped RPCs carry a typed `WorkspaceSelector`. A request must +select one non-empty workspace explicitly; `default` is an ordinary explicit +name, not an omitted-value fallback. Every public sandbox-scoped RPC identifies +the sandbox with a string `sandbox_name` and carries its workspace selector as +a separate request field. Canonical sandbox IDs remain internal metadata used +at authentication, persistence, and compute-driver boundaries; public callers +do not use them as sandbox references. The gateway resolves the name to the +persisted sandbox record and authorizes that record's workspace. Missing and +unauthorized references use the same response within each principal class so +the resolver does not expose an object-existence oracle. Sandbox, sandbox +template, provider, and service list RPCs also accept an all-workspaces marker +after Platform Admin authorization. Single-workspace handlers reject that +marker. Platform-global policy operations require `sandbox_name` and +`workspace_scope` to be absent, while sandbox policy operations require both. + 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 diff --git a/architecture/sandbox.md b/architecture/sandbox.md index 8676ac9a56..6920112f59 100644 --- a/architecture/sandbox.md +++ b/architecture/sandbox.md @@ -157,7 +157,7 @@ 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. -Every `protocol: mcp` endpoint carries a canonical, nonempty `mcp.versions` allowlist drawn from OpenShell's exact revision registry: `2025-03-26`, `2025-06-18`, and `2025-11-25`. A policy author may omit the entire `mcp` object when using the other endpoint defaults, or omit `mcp.versions` while setting another MCP option. Both forms resolve immediately to the exact allowlist `["2025-11-25"]`; omission never means latest or all known revisions. Defaulting applies only when the corresponding YAML key is absent: `mcp: null`, `versions: null`, and an explicit `versions: []` are invalid. At protobuf ingress, an empty repeated field means omission and uses the same default because protobuf repeated fields do not preserve presence. Normalization stores and serializes the materialized allowlist in semantic order, so adding a supported revision to the registry never widens a previously normalized policy. An explicit nonempty allowlist remains available as an advanced compatibility or downgrade control. The registry is a closed set rather than a date range, so duplicate or padded values, unknown dates, and moving aliases such as `draft` or `latest` are rejected. The sessionless `2026-07-28` revision is not accepted until OpenShell supports its distinct per-request runtime contract. A version names a core protocol revision only; there is no policy syntax for layering a separately named SEP onto it. The registry owns immutable batch-shape metadata: `2025-03-26` permits nonempty same-side top-level JSON-RPC batches, which OpenShell's planned enforcement caps at 64 members, while `2025-06-18` and `2025-11-25` prohibit top-level arrays. These are declared profile facts, not current forwarding claims. The allowlist does not yet select request parsing or forwarding behavior. Later response-aware runtime state must observe the successful server response, require the selected revision to be in the allowlist, and apply that one exact profile without a union or fallback; OpenShell must not bind the client proposal in `initialize` as though it were the server-selected revision. +Every `protocol: mcp` endpoint carries a canonical, nonempty `mcp.versions` allowlist drawn from OpenShell's exact revision registry: `2025-03-26`, `2025-06-18`, and `2025-11-25`. A policy author may omit the entire `mcp` object when using the other endpoint defaults, or omit `mcp.versions` while setting another MCP option. Both forms resolve immediately to the exact allowlist `["2025-11-25"]`; omission never means latest or all known revisions. Defaulting applies only when the corresponding YAML key is absent: `mcp: null`, `versions: null`, and an explicit `versions: []` are invalid. At protobuf ingress, an empty repeated field means omission and uses the same default because protobuf repeated fields do not preserve presence. Normalization stores and serializes the materialized allowlist in semantic order, so adding a supported revision to the registry never widens a previously normalized policy. An explicit nonempty allowlist remains available as an advanced compatibility or downgrade control. The registry is a closed set rather than a date range, so duplicate or padded values, unknown dates, and moving aliases such as `draft` or `latest` are rejected. The sessionless `2026-07-28` revision is not accepted until OpenShell supports its distinct per-request contract. A version names a core protocol revision only; there is no policy syntax for layering a separately named SEP onto it. For every MCP HTTP request except a valid standalone `initialize`, the supervisor reads exactly one `MCP-Protocol-Version` header and requires that revision to appear in the endpoint allowlist. If the header is absent, the MCP transport specification defines `2025-03-26` as the compatibility fallback; OpenShell permits that fallback only when the allowlist contains it. Duplicate, empty, or unsupported header values receive `400 Bad Request`, while a supported revision outside the allowlist receives `403 Forbidden`. The supervisor repeats this check after middleware changes the request and before any upstream write. This check does not store session state or infer a version from a previous connection request. The registry also owns immutable batch-shape metadata: `2025-03-26` permits nonempty same-side top-level JSON-RPC batches, which OpenShell's planned enforcement caps at 64 members, while `2025-06-18` and `2025-11-25` prohibit top-level arrays. The current request parser does not yet apply these version-specific batch rules. For admitted HTTP requests, the proxy can run an ordered supervisor middleware chain after L7 policy evaluation and before credential injection. Destination diff --git a/crates/openshell-cli/src/commands/provider.rs b/crates/openshell-cli/src/commands/provider.rs index c903156af0..2ef121ac20 100644 --- a/crates/openshell-cli/src/commands/provider.rs +++ b/crates/openshell-cli/src/commands/provider.rs @@ -58,8 +58,8 @@ pub async fn sandbox_provider_list( let mut client = grpc_client(server, tls).await?; let response = client .list_sandbox_providers(ListSandboxProvidersRequest { - sandbox_name: name.to_string(), - workspace: workspace.to_string(), + sandbox_name: (name).to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector(workspace)), }) .await .into_diagnostic()?; @@ -90,8 +90,8 @@ pub async fn sandbox_provider_attach( // Fetch current sandbox to get resource_version for CAS let sandbox = client .get_sandbox(GetSandboxRequest { - name: name.to_string(), - workspace: workspace.to_string(), + sandbox_name: (name).to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector(workspace)), }) .await .into_diagnostic()? @@ -103,10 +103,10 @@ pub async fn sandbox_provider_attach( let response = match client .attach_sandbox_provider(AttachSandboxProviderRequest { - sandbox_name: name.to_string(), + sandbox_name: (name).to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector(workspace)), provider_name: provider.to_string(), expected_resource_version: resource_version, - workspace: workspace.to_string(), }) .await { @@ -146,8 +146,8 @@ pub async fn sandbox_provider_detach( // Fetch current sandbox to get resource_version for CAS let sandbox = client .get_sandbox(GetSandboxRequest { - name: name.to_string(), - workspace: workspace.to_string(), + sandbox_name: (name).to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector(workspace)), }) .await .into_diagnostic()? @@ -159,10 +159,10 @@ pub async fn sandbox_provider_detach( let response = match client .detach_sandbox_provider(DetachSandboxProviderRequest { - sandbox_name: name.to_string(), + sandbox_name: (name).to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector(workspace)), provider_name: provider.to_string(), expected_resource_version: resource_version, - workspace: workspace.to_string(), }) .await { @@ -305,8 +305,7 @@ pub async fn ensure_required_providers( .list_providers(ListProvidersRequest { limit, offset, - workspace: workspace.to_string(), - all_workspaces: false, + workspace_scope: Some(openshell_core::proto::workspace_selector(workspace)), }) .await .into_diagnostic()?; @@ -490,7 +489,7 @@ async fn auto_create_provider( profile_workspace: workspace.to_string(), credential_handles: HashMap::new(), }), - workspace: workspace.to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector(workspace)), }; let response = client.create_provider(request).await.map_err(|status| { @@ -538,7 +537,7 @@ async fn auto_create_provider( profile_workspace: workspace.to_string(), credential_handles: HashMap::new(), }), - workspace: workspace.to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector(workspace)), }; match client.create_provider(request).await { @@ -672,7 +671,7 @@ async fn rollback_provider_create_after_gcloud_adc_failure( match client .delete_provider(DeleteProviderRequest { name: provider_name.to_string(), - workspace: workspace.to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector(workspace)), }) .await { @@ -1119,7 +1118,7 @@ pub async fn provider_create_with_options(options: ProviderCreateOptions<'_>) -> profile_workspace: profile_workspace.to_string(), credential_handles: HashMap::new(), }), - workspace: workspace.to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector(workspace)), }) .await .into_diagnostic()?; @@ -1149,7 +1148,7 @@ pub async fn provider_create_with_options(options: ProviderCreateOptions<'_>) -> "refresh_token".to_string(), ], expires_at_ms: None, - workspace: workspace.to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector(workspace)), }) .await { @@ -1167,7 +1166,7 @@ pub async fn provider_create_with_options(options: ProviderCreateOptions<'_>) -> .rotate_provider_credential(RotateProviderCredentialRequest { provider: provider_name.clone(), credential_key: adc_credential_key, - workspace: workspace.to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector(workspace)), }) .await { @@ -1208,7 +1207,7 @@ pub async fn provider_get( let response = client .get_provider(GetProviderRequest { name: name.to_string(), - workspace: workspace.to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector(workspace)), }) .await .into_diagnostic()?; @@ -1339,12 +1338,11 @@ pub async fn provider_list( .list_providers(ListProvidersRequest { limit, offset, - workspace: if all_workspaces { - String::new() + workspace_scope: Some(if all_workspaces { + openshell_core::proto::all_workspaces_selector() } else { - workspace.to_string() - }, - all_workspaces, + openshell_core::proto::workspace_selector(workspace) + }), }) .await .into_diagnostic()?; @@ -1713,7 +1711,7 @@ pub async fn provider_refresh_status( .get_provider_refresh_status(GetProviderRefreshStatusRequest { provider: name.to_string(), credential_key: credential_key.unwrap_or_default().to_string(), - workspace: workspace.to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector(workspace)), }) .await .into_diagnostic()? @@ -1794,7 +1792,7 @@ pub async fn provider_refresh_config( material, secret_material_keys, expires_at_ms: input.credential_expires_at_ms, - workspace: workspace.to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector(workspace)), }) .await .into_diagnostic()? @@ -1823,7 +1821,7 @@ pub async fn provider_rotate( .rotate_provider_credential(RotateProviderCredentialRequest { provider: name.to_string(), credential_key: credential_key.to_string(), - workspace: workspace.to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector(workspace)), }) .await .into_diagnostic()? @@ -1860,7 +1858,7 @@ pub async fn provider_refresh_delete( .delete_provider_refresh(DeleteProviderRefreshRequest { provider: name.to_string(), credential_key: credential_key.to_string(), - workspace: workspace.to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector(workspace)), }) .await .into_diagnostic()? @@ -2236,7 +2234,7 @@ pub async fn provider_update(options: ProviderUpdateOptions<'_>) -> Result<()> { let existing = match client .get_provider(GetProviderRequest { name: name.to_string(), - workspace: workspace.to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector(workspace)), }) .await { @@ -2319,7 +2317,7 @@ pub async fn provider_update(options: ProviderUpdateOptions<'_>) -> Result<()> { credential_handles: HashMap::new(), }), credential_expires_at_ms, - workspace: workspace.to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector(workspace)), }) .await .into_diagnostic()?; @@ -2349,7 +2347,7 @@ pub async fn provider_delete( let response = match client .delete_provider(DeleteProviderRequest { name: name.clone(), - workspace: workspace.to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector(workspace)), }) .await { diff --git a/crates/openshell-cli/src/completers.rs b/crates/openshell-cli/src/completers.rs index 9d3b88d594..0778babbf1 100644 --- a/crates/openshell-cli/src/completers.rs +++ b/crates/openshell-cli/src/completers.rs @@ -39,8 +39,9 @@ pub fn complete_sandbox_names(_prefix: &OsStr) -> Vec { limit: 200, offset: 0, label_selector: String::new(), - workspace: workspace_from_args(), - all_workspaces: false, + workspace_scope: Some(openshell_core::proto::workspace_selector( + workspace_from_args(), + )), }) .await .ok()?; @@ -64,8 +65,9 @@ pub fn complete_provider_names(_prefix: &OsStr) -> Vec { .list_providers(ListProvidersRequest { limit: 200, offset: 0, - workspace: workspace_from_args(), - all_workspaces: false, + workspace_scope: Some(openshell_core::proto::workspace_selector( + workspace_from_args(), + )), }) .await .ok()?; diff --git a/crates/openshell-cli/src/main.rs b/crates/openshell-cli/src/main.rs index db18795b68..ca2ef325db 100644 --- a/crates/openshell-cli/src/main.rs +++ b/crates/openshell-cli/src/main.rs @@ -644,7 +644,7 @@ enum Commands { /// Two mutually exclusive modes: /// /// **Token mode** (used internally by `sandbox connect`): - /// `openshell ssh-proxy --gateway --sandbox-id --token ` + /// `openshell ssh-proxy --gateway --sandbox-name --token ` /// /// **Name mode** (for use in `~/.ssh/config`): /// `openshell ssh-proxy --gateway --name ` @@ -655,9 +655,9 @@ enum Commands { #[arg(long, short = 'g')] gateway: Option, - /// Sandbox id. Required in token mode. + /// Sandbox name. Required in token mode. #[arg(long)] - sandbox_id: Option, + sandbox_name: Option, /// SSH session token. Required in token mode. #[arg(long)] @@ -2137,7 +2137,7 @@ enum ServiceCommands { offset: u32, /// List services across all workspaces (overrides --workspace). - #[arg(long)] + #[arg(long, conflicts_with = "sandbox")] all_workspaces: bool, /// Output format. @@ -3778,15 +3778,15 @@ async fn run_async() -> Result<()> { } Some(Commands::SshProxy { gateway, - sandbox_id, + sandbox_name, token, server, gateway_name, name, }) => { - match (gateway, sandbox_id, token, server, gateway_name, name) { + match (gateway, sandbox_name, token, server, gateway_name, name) { // Token mode (existing behavior): pre-created session credentials. - (Some(gw), Some(sid), Some(tok), _, gateway_name_opt, _) => { + (Some(gw), Some(sandbox_name), Some(tok), _, gateway_name_opt, _) => { let mut effective_tls = match gateway_name_opt { Some(ref g) => tls.with_gateway_name(g), None => tls, @@ -3794,7 +3794,7 @@ async fn run_async() -> Result<()> { if let Some(ref g) = gateway_name_opt { apply_auth(&mut effective_tls, g)?; } - run::sandbox_ssh_proxy(&gw, &sid, &tok, &effective_tls).await?; + run::sandbox_ssh_proxy(&gw, &sandbox_name, &tok, &effective_tls).await?; } // Name mode with --gateway-name: resolve endpoint from metadata. (_, _, _, server_override, Some(g), Some(n)) => { @@ -3820,7 +3820,7 @@ async fn run_async() -> Result<()> { } _ => { return Err(miette::miette!( - "provide either --gateway/--sandbox-id/--token or --gateway-name/--name (or --server/--name)" + "provide either --gateway/--sandbox-name/--token or --gateway-name/--name (or --server/--name)" )); } } @@ -4470,8 +4470,8 @@ mod tests { "ssh-proxy", "--gateway", "https://gw.example.com:8080/proxy/connect", - "--sandbox-id", - "sbx-123", + "--sandbox-name", + "my-box", "--token", "tok-abc", "--gateway-name", @@ -4482,7 +4482,7 @@ mod tests { match cli.command { Some(Commands::SshProxy { gateway, - sandbox_id, + sandbox_name, token, gateway_name, .. @@ -4492,7 +4492,7 @@ mod tests { Some("https://gw.example.com:8080/proxy/connect"), "gateway URL must land in SshProxy.gateway, not the global flag" ); - assert_eq!(sandbox_id.as_deref(), Some("sbx-123")); + assert_eq!(sandbox_name.as_deref(), Some("my-box")); assert_eq!(token.as_deref(), Some("tok-abc")); assert_eq!(gateway_name.as_deref(), Some("my-gateway")); } @@ -6059,6 +6059,19 @@ mod tests { } } + #[test] + fn service_list_rejects_sandbox_with_all_workspaces() { + let result = Cli::try_parse_from([ + "openshell", + "service", + "list", + "my-sandbox", + "--all-workspaces", + ]); + + assert!(result.is_err()); + } + #[test] fn service_get_accepts_optional_service_name() { let cli = Cli::try_parse_from(["openshell", "service", "get", "my-sandbox", "api"]) diff --git a/crates/openshell-cli/src/run.rs b/crates/openshell-cli/src/run.rs index abdb4bfa61..b21cfb9f93 100644 --- a/crates/openshell-cli/src/run.rs +++ b/crates/openshell-cli/src/run.rs @@ -69,6 +69,8 @@ use std::process::Command; use std::time::{Duration, Instant}; use tonic::{Code, Status}; +const PROVISIONAL_CONTAINER_EXIT_RECONCILIATION_TIMEOUT: Duration = Duration::from_secs(5); + // Re-export SSH functions for backward compatibility pub use crate::ssh::{Editor, print_ssh_config}; pub use crate::ssh::{ @@ -251,6 +253,19 @@ fn has_main_process_result(sandbox: &Sandbox) -> bool { }) } +fn is_provisional_container_exit(sandbox: &Sandbox) -> bool { + let phase = SandboxPhase::try_from(sandbox.phase()).unwrap_or(SandboxPhase::Unknown); + phase == SandboxPhase::Error + && sandbox.status.as_ref().is_some_and(|status| { + status.exit_code.is_none() + && status.conditions.iter().any(|condition| { + condition.r#type == "Ready" + && condition.status.eq_ignore_ascii_case("false") + && condition.reason == "ContainerExited" + }) + }) +} + fn build_sandbox_resource_limits( cpu: Option<&str>, memory: Option<&str>, @@ -628,7 +643,7 @@ pub async fn sandbox_create( name: name.unwrap_or_default().to_string(), labels, annotations, - workspace: workspace.to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector(workspace)), await_main_process_attachment, workload_template_name: template.unwrap_or_default().to_string(), }; @@ -671,10 +686,10 @@ pub async fn sandbox_create( let setting = parse_cli_setting_value(settings::PROPOSAL_APPROVAL_MODE_KEY, approval_mode)?; match client .update_config(UpdateConfigRequest { - name: sandbox_name.clone(), + sandbox_name: sandbox_name.clone(), + workspace_scope: Some(openshell_core::proto::workspace_selector(workspace)), setting_key: settings::PROPOSAL_APPROVAL_MODE_KEY.to_string(), setting_value: Some(setting), - workspace: workspace.to_string(), ..Default::default() }) .await @@ -730,14 +745,12 @@ pub async fn sandbox_create( // a newly created sandbox. Instead we handle termination client-side: // we wait until we have observed at least one non-Ready phase followed // by Ready (a genuine Provisioning → Ready transition). - let sandbox_id = if sandbox.object_id().is_empty() { - "unknown".to_string() - } else { - sandbox.object_id().to_string() - }; + let sandbox_name = sandbox.object_name().to_string(); + let sandbox_workspace = sandbox.object_workspace().to_string(); let mut stream = client .watch_sandbox(WatchSandboxRequest { - id: sandbox_id.clone(), + sandbox_name: sandbox_name.clone(), + workspace_scope: Some(openshell_core::proto::workspace_selector(sandbox_workspace)), follow_status: true, follow_logs: true, follow_events: true, @@ -765,6 +778,12 @@ pub async fn sandbox_create( .unwrap_or(300), ); let mut provisioning_idle_deadline = Instant::now() + provision_timeout; + // The compute driver can publish ContainerExited while the supervisor's + // authoritative canonical-process result is waiting for the same gateway + // state lock. Keep watching briefly so the provisional error cannot race + // ephemeral cleanup, but retain a deadline for containers that exit before + // the supervisor can report a result. + let mut provisional_container_exit_deadline: Option = None; // Track whether we saw the gateway become ready (from log messages). let mut saw_gateway_ready = false; @@ -773,8 +792,15 @@ pub async fn sandbox_create( // longer than the default timeout pulling and preparing large images, // but only recognized progress events extend the idle deadline. Logs // and generic status churn must not keep a stuck sandbox alive forever. - let remaining = provisioning_idle_deadline.saturating_duration_since(Instant::now()); + let now = Instant::now(); + let mut remaining = provisioning_idle_deadline.saturating_duration_since(now); + if let Some(deadline) = provisional_container_exit_deadline { + remaining = remaining.min(deadline.saturating_duration_since(now)); + } if remaining.is_zero() { + if provisional_container_exit_deadline.is_some() { + break; + } let timeout_message = provisioning_timeout_message( provision_timeout.as_secs(), resource_requirements.as_ref(), @@ -794,6 +820,7 @@ pub async fn sandbox_create( let item = match maybe_item { Ok(Some(item)) => item, Ok(None) => break, // stream ended + Err(_elapsed) if provisional_container_exit_deadline.is_some() => break, Err(_elapsed) => { // Timeout fired — the stream was idle for too long. let timeout_message = provisioning_timeout_message( @@ -850,6 +877,12 @@ pub async fn sandbox_create( format!("{}: {}", condition.reason, condition.message); } } + if is_provisional_container_exit(&s) { + provisional_container_exit_deadline.get_or_insert_with(|| { + Instant::now() + PROVISIONAL_CONTAINER_EXIT_RECONCILIATION_TIMEOUT + }); + continue; + } break; } @@ -1272,7 +1305,7 @@ async fn stage_rootfs_tar( // archive over its configured limit, before allocating anything. let slot = client .begin_rootfs_tar_staging(BeginRootfsTarStagingRequest { - workspace: workspace.to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector(workspace)), file_name, size_bytes: source_meta.len(), }) @@ -1459,8 +1492,8 @@ pub async fn sandbox_get( let response = client .get_sandbox(GetSandboxRequest { - name: name.to_string(), - workspace: workspace.to_string(), + sandbox_name: name.to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector(workspace)), }) .await .into_diagnostic()?; @@ -1469,14 +1502,11 @@ pub async fn sandbox_get( .sandbox .ok_or_else(|| miette::miette!("sandbox missing from response"))?; - let sandbox_id = if sandbox.object_id().is_empty() { - return Err(miette::miette!("sandbox missing metadata")); - } else { - sandbox.object_id().to_string() - }; - let config = client - .get_sandbox_config(GetSandboxConfigRequest { sandbox_id }) + .get_sandbox_config(GetSandboxConfigRequest { + sandbox_name: name.to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector(workspace)), + }) .await .into_diagnostic()? .into_inner(); @@ -1621,8 +1651,8 @@ pub async fn sandbox_exec_grpc( // Resolve sandbox name to id. let sandbox = client .get_sandbox(GetSandboxRequest { - name: name.to_string(), - workspace: workspace.to_string(), + sandbox_name: name.to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector(workspace)), }) .await .into_diagnostic()? @@ -1690,7 +1720,8 @@ pub async fn sandbox_exec_grpc( // Make the streaming gRPC call. let mut stream = client .exec_sandbox(ExecSandboxRequest { - sandbox_id: sandbox.object_id().to_string(), + sandbox_name: name.to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector(workspace)), command: command.to_vec(), workdir: workdir.unwrap_or_default().to_string(), environment: environment.clone(), @@ -1756,7 +1787,7 @@ pub async fn service_forward_tcp( let (bind_addr, bind_port) = parse_tcp_forward_spec(local, target_port)?; let mut client = grpc_client(server, tls).await?; - let sandbox = fetch_ready_sandbox_for_forward(&mut client, name, workspace).await?; + fetch_ready_sandbox_for_forward(&mut client, name, workspace).await?; let listener = tokio::net::TcpListener::bind((bind_addr.as_str(), bind_port)) .await @@ -1775,7 +1806,8 @@ pub async fn service_forward_tcp( name, ); - let sandbox_id = sandbox.object_id().to_string(); + let sandbox_name = name.to_string(); + let sandbox_workspace = workspace.to_string(); let (fatal_tx, mut fatal_rx) = tokio::sync::mpsc::channel::(1); let mut health_check = tokio::time::interval(Duration::from_secs(2)); health_check.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); @@ -1795,12 +1827,17 @@ pub async fn service_forward_tcp( .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 sandbox_name = sandbox_name.clone(); + let sandbox_workspace = sandbox_workspace.clone(); let target_host = target_host.to_string(); let service_id = format!("service-forward:{name}:{target_host}:{target_port}"); let fatal_tx = fatal_tx.clone(); tokio::spawn(async move { - let token = match create_forward_session_token(&mut client, &sandbox_id).await { + let token = match create_forward_session_token( + &mut client, + &sandbox_name, + &sandbox_workspace, + ).await { Ok(token) => token, Err(err) => { tracing::warn!(peer = %peer, error = %err, "service forward session creation failed"); @@ -1813,7 +1850,8 @@ pub async fn service_forward_tcp( if let Err(err) = forward_one_tcp_connection( &mut client, socket, - sandbox_id, + sandbox_name, + sandbox_workspace, target_host, target_port, service_id, @@ -1837,11 +1875,13 @@ pub async fn service_forward_tcp( async fn create_forward_session_token( client: &mut crate::tls::GrpcClient, - sandbox_id: &str, + sandbox_name: &str, + workspace: &str, ) -> std::result::Result { let response = client .create_ssh_session(CreateSshSessionRequest { - sandbox_id: sandbox_id.to_string(), + sandbox_name: sandbox_name.to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector(workspace)), }) .await .map_err(ForwardTcpConnectionError::from_status)?; @@ -1855,8 +1895,8 @@ async fn fetch_ready_sandbox_for_forward( ) -> Result { let response = match client .get_sandbox(GetSandboxRequest { - name: name.to_string(), - workspace: workspace.to_string(), + sandbox_name: name.to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector(workspace)), }) .await { @@ -1938,10 +1978,12 @@ fn parse_tcp_forward_spec(local: Option<&str>, default_port: u16) -> Result<(Str Ok(("127.0.0.1".to_string(), port)) } +#[allow(clippy::too_many_arguments)] // one connection's sandbox, target, and authorization context async fn forward_one_tcp_connection( client: &mut crate::tls::GrpcClient, socket: tokio::net::TcpStream, - sandbox_id: String, + sandbox_name: String, + workspace: String, target_host: String, target_port: u16, service_id: String, @@ -1954,7 +1996,8 @@ async fn forward_one_tcp_connection( tx.send(TcpForwardFrame { payload: Some(openshell_core::proto::tcp_forward_frame::Payload::Init( TcpForwardInit { - sandbox_id, + sandbox_name, + workspace_scope: Some(openshell_core::proto::workspace_selector(workspace)), service_id, target: Some(tcp_forward_init::Target::Tcp(TcpRelayTarget { host: target_host, @@ -2074,7 +2117,10 @@ async fn sandbox_exec_interactive_grpc( input_tx .send(ExecSandboxInput { payload: Some(exec_sandbox_input::Payload::Start(ExecSandboxRequest { - sandbox_id: sandbox.object_id().to_string(), + sandbox_name: sandbox.object_name().to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + sandbox.object_workspace(), + )), command: command.to_vec(), workdir: workdir.unwrap_or_default().to_string(), environment: environment.clone(), @@ -2214,12 +2260,11 @@ pub async fn sandbox_list( limit, offset, label_selector: label_selector.unwrap_or("").to_string(), - workspace: if all_workspaces { - String::new() + workspace_scope: Some(if all_workspaces { + openshell_core::proto::all_workspaces_selector() } else { - workspace.to_string() - }, - all_workspaces, + openshell_core::proto::workspace_selector(workspace) + }), }) .await .into_diagnostic()?; @@ -2455,7 +2500,7 @@ pub async fn sandbox_template_create( labels, resource_version: 0, annotations, - workspace: String::new(), + workspace: workspace.to_string(), deletion_timestamp_ms: 0, }), spec: Some(SandboxWorkloadTemplateSpec { @@ -2468,7 +2513,7 @@ pub async fn sandbox_template_create( desired_service_level, }), }), - workspace: workspace.to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector(workspace)), }) .await .into_diagnostic()?; @@ -2533,7 +2578,7 @@ pub async fn sandbox_template_get( let response = client .get_sandbox_template(GetSandboxTemplateRequest { name: name.to_string(), - workspace: workspace.to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector(workspace)), }) .await .into_diagnostic()?; @@ -2567,12 +2612,11 @@ pub async fn sandbox_template_list( .list_sandbox_templates(ListSandboxTemplatesRequest { limit, offset, - workspace: if all_workspaces { - String::new() + workspace_scope: Some(if all_workspaces { + openshell_core::proto::all_workspaces_selector() } else { - workspace.to_string() - }, - all_workspaces, + openshell_core::proto::workspace_selector(workspace) + }), label_selector: label_selector.unwrap_or_default().to_string(), }) .await @@ -2616,7 +2660,7 @@ pub async fn sandbox_template_delete( let response = client .delete_sandbox_template(DeleteSandboxTemplateRequest { name: name.clone(), - workspace: workspace.to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector(workspace)), }) .await .into_diagnostic()?; @@ -3001,8 +3045,7 @@ pub async fn sandbox_delete( limit: 1000, offset: 0, label_selector: String::new(), - workspace: workspace.to_string(), - all_workspaces: false, + workspace_scope: Some(openshell_core::proto::workspace_selector(workspace)), }) .await .into_diagnostic()?; @@ -3033,8 +3076,8 @@ pub async fn sandbox_delete( let response = match client .delete_sandbox(DeleteSandboxRequest { - name: name.clone(), - workspace: workspace.to_string(), + sandbox_name: name.clone(), + workspace_scope: Some(openshell_core::proto::workspace_selector(workspace)), }) .await { @@ -3085,8 +3128,8 @@ pub async fn sandbox_stop( let mut client = grpc_client(server, tls).await?; let sandbox = client .stop_sandbox(StopSandboxRequest { - name: name.to_string(), - workspace: workspace.to_string(), + sandbox_name: name.to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector(workspace)), }) .await .into_diagnostic()? @@ -3108,8 +3151,8 @@ pub async fn sandbox_start( let mut client = grpc_client(server, tls).await?; let sandbox = client .start_sandbox(StartSandboxRequest { - name: name.to_string(), - workspace: workspace.to_string(), + sandbox_name: name.to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector(workspace)), }) .await .into_diagnostic()? @@ -3142,10 +3185,12 @@ async fn wait_for_lifecycle_phase( .and_then(|value| value.parse().ok()) .unwrap_or(300), ); - let sandbox_id = sandbox.object_id().to_string(); + let sandbox_name = sandbox.object_name().to_string(); + let workspace = sandbox.object_workspace().to_string(); let mut stream = client .watch_sandbox(WatchSandboxRequest { - id: sandbox_id, + sandbox_name, + workspace_scope: Some(openshell_core::proto::workspace_selector(workspace)), follow_status: true, follow_logs: false, follow_events: false, @@ -3206,11 +3251,11 @@ pub async fn service_expose( let mut client = grpc_client(server, tls).await?; let response = client .expose_service(ExposeServiceRequest { - sandbox: sandbox.to_string(), + sandbox_name: (sandbox).to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector(workspace)), service: service.to_string(), target_port: u32::from(target_port), domain: true, - workspace: workspace.to_string(), }) .await .map_err(service_expose_status_error)? @@ -3257,15 +3302,14 @@ pub async fn service_list( let mut client = grpc_client(server, tls).await?; let response = client .list_services(ListServicesRequest { - sandbox: sandbox.unwrap_or_default().to_string(), + sandbox_name: sandbox.unwrap_or_default().to_string(), limit, offset, - workspace: if all_workspaces { - String::new() + workspace_scope: Some(if all_workspaces { + openshell_core::proto::all_workspaces_selector() } else { - workspace.to_string() - }, - all_workspaces, + openshell_core::proto::workspace_selector(workspace) + }), }) .await .map_err(|status| service_status_error("list services", "sandbox:read", status))? @@ -3303,9 +3347,9 @@ pub async fn service_get( let mut client = grpc_client(server, tls).await?; let response = client .get_service(GetServiceRequest { - sandbox: sandbox.to_string(), + sandbox_name: (sandbox).to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector(workspace)), service: service.to_string(), - workspace: workspace.to_string(), }) .await .map_err(|status| service_status_error("get service", "sandbox:read", status))? @@ -3325,9 +3369,9 @@ pub async fn service_delete( let mut client = grpc_client(server, tls).await?; let response = client .delete_service(DeleteServiceRequest { - sandbox: sandbox.to_string(), + sandbox_name: (sandbox).to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector(workspace)), service: service.to_string(), - workspace: workspace.to_string(), }) .await .map_err(|status| service_status_error("delete service", "sandbox:write", status))? @@ -4104,7 +4148,7 @@ pub async fn sandbox_policy_set_global( yes: bool, wait: bool, _timeout_secs: u64, - workspace: &str, + _workspace: &str, tls: &TlsOptions, ) -> Result<()> { if wait { @@ -4121,10 +4165,8 @@ pub async fn sandbox_policy_set_global( let mut client = grpc_client(server, tls).await?; let response = client .update_config(UpdateConfigRequest { - name: String::new(), policy: Some(policy), global: true, - workspace: workspace.to_string(), ..Default::default() }) .await @@ -4152,20 +4194,10 @@ pub async fn sandbox_settings_get( tls: &TlsOptions, ) -> Result<()> { let mut client = grpc_client(server, tls).await?; - 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 response = client .get_sandbox_config(GetSandboxConfigRequest { - sandbox_id: sandbox.object_id().to_string(), + sandbox_name: name.to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector(workspace)), }) .await .into_diagnostic()? @@ -4313,7 +4345,7 @@ pub async fn gateway_setting_set( key: &str, value: &str, yes: bool, - workspace: &str, + _workspace: &str, tls: &TlsOptions, ) -> Result<()> { let setting_value = parse_cli_setting_value(key, value)?; @@ -4322,11 +4354,9 @@ pub async fn gateway_setting_set( let mut client = grpc_client(server, tls).await?; let response = client .update_config(UpdateConfigRequest { - name: String::new(), setting_key: key.to_string(), setting_value: Some(setting_value), global: true, - workspace: workspace.to_string(), ..Default::default() }) .await @@ -4356,10 +4386,10 @@ pub async fn sandbox_setting_set( let mut client = grpc_client(server, tls).await?; let response = client .update_config(UpdateConfigRequest { - name: name.to_string(), + sandbox_name: name.to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector(workspace)), setting_key: key.to_string(), setting_value: Some(setting_value), - workspace: workspace.to_string(), ..Default::default() }) .await @@ -4381,7 +4411,7 @@ pub async fn gateway_setting_delete( server: &str, key: &str, yes: bool, - workspace: &str, + _workspace: &str, tls: &TlsOptions, ) -> Result<()> { confirm_global_setting_delete(key, yes)?; @@ -4389,11 +4419,9 @@ pub async fn gateway_setting_delete( let mut client = grpc_client(server, tls).await?; let response = client .update_config(UpdateConfigRequest { - name: String::new(), setting_key: key.to_string(), delete_setting: true, global: true, - workspace: workspace.to_string(), ..Default::default() }) .await @@ -4423,10 +4451,10 @@ pub async fn sandbox_setting_delete( let mut client = grpc_client(server, tls).await?; let response = client .update_config(UpdateConfigRequest { - name: name.to_string(), + sandbox_name: name.to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector(workspace)), setting_key: key.to_string(), delete_setting: true, - workspace: workspace.to_string(), ..Default::default() }) .await @@ -4469,10 +4497,10 @@ pub async fn sandbox_policy_set( // Get current version so we can detect no-ops. let current_version = client .get_sandbox_policy_status(GetSandboxPolicyStatusRequest { - name: name.to_string(), + sandbox_name: name.to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector(workspace)), version: 0, global: false, - workspace: workspace.to_string(), }) .await .ok() @@ -4481,9 +4509,9 @@ pub async fn sandbox_policy_set( let response = client .update_config(UpdateConfigRequest { - name: name.to_string(), + sandbox_name: name.to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector(workspace)), policy: Some(policy), - workspace: workspace.to_string(), ..Default::default() }) .await @@ -4528,10 +4556,10 @@ pub async fn sandbox_policy_set( let status_resp = client .get_sandbox_policy_status(GetSandboxPolicyStatusRequest { - name: name.to_string(), + sandbox_name: name.to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector(workspace)), version: resp.version, global: false, - workspace: workspace.to_string(), }) .await .into_diagnostic()?; @@ -4605,25 +4633,11 @@ pub async fn sandbox_policy_update( )?; let mut client = grpc_client(server, tls).await?; - let sandbox = client - .get_sandbox(GetSandboxRequest { - name: name.to_string(), - workspace: workspace.to_string(), - }) - .await - .into_diagnostic()? - .into_inner() - .sandbox - .ok_or_else(|| miette!("sandbox not found"))?; - - let sandbox_id = if sandbox.object_id().is_empty() { - return Err(miette!("sandbox missing metadata")); - } else { - sandbox.object_id().to_string() - }; - let current = client - .get_sandbox_config(GetSandboxConfigRequest { sandbox_id }) + .get_sandbox_config(GetSandboxConfigRequest { + sandbox_name: name.to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector(workspace)), + }) .await .into_diagnostic()? .into_inner(); @@ -4655,9 +4669,9 @@ pub async fn sandbox_policy_update( let current_hash = current.policy_hash.clone(); let response = client .update_config(UpdateConfigRequest { - name: name.to_string(), + sandbox_name: name.to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector(workspace)), merge_operations: plan.merge_operations, - workspace: workspace.to_string(), ..Default::default() }) .await @@ -4702,10 +4716,10 @@ pub async fn sandbox_policy_update( let status_resp = client .get_sandbox_policy_status(GetSandboxPolicyStatusRequest { - name: name.to_string(), + sandbox_name: name.to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector(workspace)), version: response.version, global: false, - workspace: workspace.to_string(), }) .await .into_diagnostic()?; @@ -4810,10 +4824,10 @@ where let status_resp = client .get_sandbox_policy_status(GetSandboxPolicyStatusRequest { - name: name.to_string(), + sandbox_name: name.to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector(workspace)), version, global: false, - workspace: workspace.to_string(), }) .await .into_diagnostic()?; @@ -4892,24 +4906,10 @@ where let (stdout, _stderr) = writers; let mut client = grpc_client(server, tls).await?; - let sandbox = client - .get_sandbox(GetSandboxRequest { - name: name.to_string(), - workspace: workspace.to_string(), - }) - .await - .into_diagnostic()? - .into_inner() - .sandbox - .ok_or_else(|| miette!("sandbox missing from response"))?; - let sandbox_id = sandbox.object_id(); - if sandbox_id.is_empty() { - return Err(miette!("sandbox missing metadata")); - } - let config = client .get_sandbox_config(GetSandboxConfigRequest { - sandbox_id: sandbox_id.to_string(), + sandbox_name: name.to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector(workspace)), }) .await .into_diagnostic()? @@ -4997,17 +4997,17 @@ pub async fn sandbox_policy_get_global( version: u32, view: PolicyGetView, output: &str, - workspace: &str, + _workspace: &str, tls: &TlsOptions, ) -> Result<()> { let mut client = grpc_client(server, tls).await?; let status_resp = client .get_sandbox_policy_status(GetSandboxPolicyStatusRequest { - name: String::new(), version, global: true, - workspace: workspace.to_string(), + sandbox_name: String::new(), + workspace_scope: None, }) .await .into_diagnostic()?; @@ -5144,11 +5144,11 @@ pub async fn sandbox_policy_list( let resp = client .list_sandbox_policies(ListSandboxPoliciesRequest { - name: name.to_string(), + sandbox_name: name.to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector(workspace)), limit, offset: 0, global: false, - workspace: workspace.to_string(), }) .await .into_diagnostic()?; @@ -5172,18 +5172,18 @@ pub async fn sandbox_policy_list_global( server: &str, limit: u32, output: &str, - workspace: &str, + _workspace: &str, tls: &TlsOptions, ) -> Result<()> { let mut client = grpc_client(server, tls).await?; let resp = client .list_sandbox_policies(ListSandboxPoliciesRequest { - name: String::new(), limit, offset: 0, global: true, - workspace: workspace.to_string(), + sandbox_name: String::new(), + workspace_scope: None, }) .await .into_diagnostic()?; @@ -5271,18 +5271,6 @@ pub async fn sandbox_logs( ) -> Result<()> { let mut client = grpc_client(server, tls).await?; - // Resolve sandbox name to id. - 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"))?; - // Normalize "all" to empty list (server treats empty as "no filter"). let source_filter: Vec = sources .iter() @@ -5308,7 +5296,8 @@ pub async fn sandbox_logs( // Streaming mode: use WatchSandbox. let mut stream = client .watch_sandbox(WatchSandboxRequest { - id: sandbox.object_id().to_string(), + sandbox_name: name.to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector(workspace)), follow_status: false, follow_logs: true, follow_events: false, @@ -5335,12 +5324,12 @@ pub async fn sandbox_logs( // One-shot mode: use GetSandboxLogs. let resp = client .get_sandbox_logs(GetSandboxLogsRequest { - sandbox_id: sandbox.object_id().to_string(), + sandbox_name: name.to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector(workspace)), lines, since_ms, sources: source_filter, min_level: level.to_uppercase(), - workspace: workspace.to_string(), }) .await .into_diagnostic()?; @@ -5414,9 +5403,9 @@ pub async fn sandbox_draft_get( let response = client .get_draft_policy(GetDraftPolicyRequest { - name: name.to_string(), + sandbox_name: name.to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector(workspace)), status_filter: status_filter.unwrap_or("").to_string(), - workspace: workspace.to_string(), }) .await .into_diagnostic()?; @@ -5522,9 +5511,9 @@ pub async fn sandbox_draft_approve( let mut client = grpc_client(server, tls).await?; let review_token = client .get_draft_policy(GetDraftPolicyRequest { - name: name.to_string(), + sandbox_name: name.to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector(workspace)), status_filter: String::new(), - workspace: workspace.to_string(), }) .await .into_diagnostic()? @@ -5537,9 +5526,9 @@ pub async fn sandbox_draft_approve( let response = client .approve_draft_chunk(ApproveDraftChunkRequest { - name: name.to_string(), + sandbox_name: name.to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector(workspace)), chunk_id: chunk_id.to_string(), - workspace: workspace.to_string(), review_token, }) .await @@ -5569,10 +5558,10 @@ pub async fn sandbox_draft_reject( client .reject_draft_chunk(RejectDraftChunkRequest { - name: name.to_string(), + sandbox_name: name.to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector(workspace)), chunk_id: chunk_id.to_string(), reason: reason.to_string(), - workspace: workspace.to_string(), }) .await .into_diagnostic()?; @@ -5593,9 +5582,9 @@ pub async fn sandbox_draft_approve_all( let mut client = grpc_client(server, tls).await?; let approvals = client .get_draft_policy(GetDraftPolicyRequest { - name: name.to_string(), + sandbox_name: name.to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector(workspace)), status_filter: "pending".to_string(), - workspace: workspace.to_string(), }) .await .into_diagnostic()? @@ -5610,9 +5599,9 @@ pub async fn sandbox_draft_approve_all( let response = client .approve_all_draft_chunks(ApproveAllDraftChunksRequest { - name: name.to_string(), + sandbox_name: name.to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector(workspace)), include_security_flagged, - workspace: workspace.to_string(), approvals, }) .await @@ -5641,8 +5630,8 @@ pub async fn sandbox_draft_clear( let response = client .clear_draft_chunks(ClearDraftChunksRequest { - name: name.to_string(), - workspace: workspace.to_string(), + sandbox_name: name.to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector(workspace)), }) .await .into_diagnostic()?; @@ -5668,8 +5657,8 @@ pub async fn sandbox_draft_history( let response = client .get_draft_history(GetDraftHistoryRequest { - name: name.to_string(), - workspace: workspace.to_string(), + sandbox_name: name.to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector(workspace)), }) .await .into_diagnostic()?; diff --git a/crates/openshell-cli/src/ssh.rs b/crates/openshell-cli/src/ssh.rs index 126cea652d..0e407b32d2 100644 --- a/crates/openshell-cli/src/ssh.rs +++ b/crates/openshell-cli/src/ssh.rs @@ -8,6 +8,7 @@ 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::driver_mounts; use openshell_core::forward::{ ForwardSpec, build_proxy_command, format_gateway_url, resolve_ssh_gateway, shell_escape, validate_ssh_session_response, write_forward_pid, @@ -16,7 +17,6 @@ use openshell_core::proto::{ CreateSshSessionRequest, GetSandboxRequest, SshRelayTarget, TcpForwardFrame, TcpForwardInit, tcp_forward_init, }; -use openshell_core::{ObjectId, driver_mounts}; use std::fs; use std::future::Future; use std::io::{IsTerminal, Write}; @@ -74,6 +74,7 @@ impl Editor { struct SshSessionConfig { proxy_command: String, sandbox_id: String, + sandbox_name: String, gateway_url: String, token: String, main_terminal: bool, @@ -88,11 +89,11 @@ async fn ssh_session_config( ) -> Result { let mut client = grpc_client(server, tls).await?; - // Resolve sandbox name to id. + // Resolve the sandbox and retain its ID for local lifecycle tracking. let sandbox = client .get_sandbox(GetSandboxRequest { - name: name.to_string(), - workspace: workspace.to_string(), + sandbox_name: name.to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector(workspace)), }) .await .into_diagnostic()? @@ -105,7 +106,8 @@ async fn ssh_session_config( let response = loop { match client .create_ssh_session(CreateSshSessionRequest { - sandbox_id: sandbox.object_id().to_string(), + sandbox_name: name.to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector(workspace)), }) .await { @@ -150,7 +152,7 @@ async fn ssh_session_config( let proxy_command = build_proxy_command( &exe_command, &gateway_url, - &session.sandbox_id, + name, &session.token, gateway_name, ); @@ -158,6 +160,7 @@ async fn ssh_session_config( Ok(SshSessionConfig { proxy_command, sandbox_id: session.sandbox_id.clone(), + sandbox_name: name.to_string(), gateway_url, token: session.token, main_terminal: sandbox.spec.as_ref().is_none_or(|spec| spec.tty), @@ -1425,7 +1428,7 @@ async fn sandbox_sync_down_directory( /// Run the SSH proxy, connecting stdin/stdout to the gateway. pub async fn sandbox_ssh_proxy( gateway_url: &str, - sandbox_id: &str, + sandbox_name: &str, token: &str, tls: &TlsOptions, ) -> Result<()> { @@ -1436,8 +1439,9 @@ pub async fn sandbox_ssh_proxy( tx.send(TcpForwardFrame { payload: Some(openshell_core::proto::tcp_forward_frame::Payload::Init( TcpForwardInit { - sandbox_id: sandbox_id.to_string(), - service_id: format!("ssh-proxy:{sandbox_id}"), + sandbox_name: sandbox_name.to_string(), + workspace_scope: None, + service_id: format!("ssh-proxy:{sandbox_name}"), target: Some(tcp_forward_init::Target::Ssh(SshRelayTarget {})), authorization_token: token.to_string(), }, @@ -1530,7 +1534,7 @@ pub async fn sandbox_ssh_proxy_by_name( let session = ssh_session_config(server, name, tls, workspace, None).await?; sandbox_ssh_proxy( &session.gateway_url, - &session.sandbox_id, + &session.sandbox_name, &session.token, tls, ) diff --git a/crates/openshell-cli/tests/provider_commands_integration.rs b/crates/openshell-cli/tests/provider_commands_integration.rs index 2d1b3f25d3..a46ac4ab5c 100644 --- a/crates/openshell-cli/tests/provider_commands_integration.rs +++ b/crates/openshell-cli/tests/provider_commands_integration.rs @@ -174,7 +174,8 @@ impl OpenShell for TestOpenShell { &self, request: tonic::Request, ) -> Result, Status> { - let name = request.into_inner().name; + let request = request.into_inner(); + let name = request.sandbox_name; // Return a minimal sandbox with metadata for CAS operations Ok(Response::new(SandboxResponse { sandbox: Some(Sandbox { @@ -208,7 +209,8 @@ impl OpenShell for TestOpenShell { &self, request: tonic::Request, ) -> Result, Status> { - let sandbox_name = request.into_inner().sandbox_name; + let request = request.into_inner(); + let sandbox_name = request.sandbox_name.clone(); self.state .sandbox_provider_requests .lock() @@ -237,12 +239,13 @@ impl OpenShell for TestOpenShell { request: tonic::Request, ) -> Result, Status> { let request = request.into_inner(); + let sandbox_name = request.sandbox_name.clone(); self.state .sandbox_provider_requests .lock() .await .push(SandboxProviderRequestLog::Attach { - sandbox_name: request.sandbox_name.clone(), + sandbox_name: sandbox_name.clone(), provider_name: request.provider_name.clone(), }); if !self @@ -255,9 +258,7 @@ impl OpenShell for TestOpenShell { return Err(Status::failed_precondition("provider not found")); } let mut sandbox_providers = self.state.sandbox_providers.lock().await; - let providers = sandbox_providers - .entry(request.sandbox_name.clone()) - .or_default(); + let providers = sandbox_providers.entry(sandbox_name.clone()).or_default(); let attached = if providers.contains(&request.provider_name) { false } else { @@ -266,7 +267,7 @@ impl OpenShell for TestOpenShell { }; let sandbox = Sandbox { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { - name: request.sandbox_name, + name: sandbox_name, ..Default::default() }), spec: Some(openshell_core::proto::SandboxSpec { @@ -286,24 +287,23 @@ impl OpenShell for TestOpenShell { request: tonic::Request, ) -> Result, Status> { let request = request.into_inner(); + let sandbox_name = request.sandbox_name.clone(); self.state .sandbox_provider_requests .lock() .await .push(SandboxProviderRequestLog::Detach { - sandbox_name: request.sandbox_name.clone(), + sandbox_name: sandbox_name.clone(), provider_name: request.provider_name.clone(), }); let mut sandbox_providers = self.state.sandbox_providers.lock().await; - let providers = sandbox_providers - .entry(request.sandbox_name.clone()) - .or_default(); + let providers = sandbox_providers.entry(sandbox_name.clone()).or_default(); let before_len = providers.len(); providers.retain(|name| name != &request.provider_name); let detached = providers.len() != before_len; let sandbox = Sandbox { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { - name: request.sandbox_name, + name: sandbox_name, ..Default::default() }), spec: Some(openshell_core::proto::SandboxSpec { @@ -2920,7 +2920,7 @@ async fn provider_create_supports_nvidia_type_with_nvidia_api_key() { let response = client .get_provider(GetProviderRequest { name: "my-nvidia".to_string(), - workspace: String::new(), + workspace_scope: Some(openshell_core::proto::workspace_selector("default")), }) .await .expect("get provider should succeed") diff --git a/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs b/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs index 33d15f7c5b..3721311df3 100644 --- a/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs +++ b/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs @@ -38,11 +38,24 @@ use std::time::{Duration, Instant}; use tempfile::TempDir; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::TcpListener; -use tokio::sync::{Mutex, mpsc}; +use tokio::sync::{Mutex, Notify, mpsc}; use tokio_stream::wrappers::TcpListenerStream; use tonic::transport::{Certificate as TlsCertificate, Identity, Server, ServerTlsConfig}; use tonic::{Response, Status}; +fn selected_workspace( + scope: &Option, +) -> Option<&str> { + match scope.as_ref()?.selection.as_ref()? { + openshell_core::proto::datamodel::v1::workspace_selector::Selection::Workspace( + workspace, + ) => Some(workspace), + openshell_core::proto::datamodel::v1::workspace_selector::Selection::AllWorkspaces(_) => { + None + } + } +} + #[derive(Clone, Default)] struct SandboxState { deleted_names: Arc>>>, @@ -53,6 +66,9 @@ struct SandboxState { vm_slow_progress_before_ready: Arc, vm_log_churn_before_ready: Arc, terminal_before_relay: Arc, + terminal_after_provisional_container_exit: Arc, + provisional_container_exit_without_result: Arc, + provisional_container_exit_sent: Arc, ssh_session_failures_remaining: Arc, ssh_session_requests: Arc, global_settings: Arc>>, @@ -166,7 +182,8 @@ impl OpenShell for TestOpenShell { &self, request: tonic::Request, ) -> Result, Status> { - let name = request.into_inner().name; + let request = request.into_inner(); + let name = request.sandbox_name; let mut sandbox = Sandbox { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: format!("id-{name}"), @@ -214,7 +231,9 @@ impl OpenShell for TestOpenShell { .unwrap_or_default(), resource_version: 1, annotations: HashMap::new(), - workspace: request.workspace.clone(), + workspace: selected_workspace(&request.workspace_scope) + .unwrap_or("default") + .to_string(), deletion_timestamp_ms: 0, }); self.state @@ -246,7 +265,9 @@ impl OpenShell for TestOpenShell { labels: HashMap::new(), resource_version: 1, annotations: HashMap::new(), - workspace: request.workspace, + workspace: selected_workspace(&request.workspace_scope) + .unwrap_or("default") + .to_string(), deletion_timestamp_ms: 0, }), spec: None, @@ -312,7 +333,7 @@ impl OpenShell for TestOpenShell { .deleted_names .lock() .await - .push(vec![request.name.clone()]); + .push(vec![request.sandbox_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)); @@ -366,7 +387,8 @@ impl OpenShell for TestOpenShell { { return Err(Status::failed_precondition("sandbox is not ready")); } - let sandbox_id = request.into_inner().sandbox_id; + let request = request.into_inner(); + let sandbox_id = format!("id-{}", request.sandbox_name); Ok(Response::new(CreateSshSessionResponse { sandbox_id, token: "test-token".to_string(), @@ -554,7 +576,8 @@ impl OpenShell for TestOpenShell { &self, request: tonic::Request, ) -> Result, Status> { - let sandbox_id = request.into_inner().id; + let request = request.into_inner(); + let sandbox_id = format!("id-{}", request.sandbox_name); 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 @@ -567,6 +590,16 @@ impl OpenShell for TestOpenShell { .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); + let terminal_after_provisional_container_exit = self + .state + .terminal_after_provisional_container_exit + .load(Ordering::SeqCst); + let provisional_container_exit_without_result = self + .state + .provisional_container_exit_without_result + .load(Ordering::SeqCst); + let provisional_container_exit_sent = + Arc::clone(&self.state.provisional_container_exit_sent); tokio::spawn(async move { let mut provisioning = Sandbox { @@ -610,11 +643,44 @@ impl OpenShell for TestOpenShell { }); completed.set_phase(SandboxPhase::Completed as i32); + let mut provisional_container_exit = error.clone(); + if let Some(ready) = provisional_container_exit + .status + .as_mut() + .and_then(|status| status.conditions.first_mut()) + { + ready.reason = "ContainerExited".to_string(); + ready.message = "Sandbox container exited".to_string(); + } + let _ = tx .send(Ok(SandboxStreamEvent { payload: Some(sandbox_stream_event::Payload::Sandbox(provisioning)), })) .await; + if terminal_after_provisional_container_exit + || provisional_container_exit_without_result + { + let _ = tx + .send(Ok(SandboxStreamEvent { + payload: Some(sandbox_stream_event::Payload::Sandbox( + provisional_container_exit, + )), + })) + .await; + provisional_container_exit_sent.notify_waiters(); + if provisional_container_exit_without_result { + std::future::pending::<()>().await; + return; + } + tokio::task::yield_now().await; + let _ = tx + .send(Ok(SandboxStreamEvent { + payload: Some(sandbox_stream_event::Payload::Sandbox(completed)), + })) + .await; + return; + } if vm_error_after_started { let _ = tx .send(Ok(SandboxStreamEvent { @@ -1145,7 +1211,7 @@ for arg in "$@"; do if [ "$previous" = "-o" ]; then case "$arg" in ProxyCommand=*) - sandbox_id="$(printf '%s\n' "$arg" | sed -n 's/.*--sandbox-id \([^ ]*\).*/\1/p')" + sandbox_id="$(printf '%s\n' "$arg" | sed -n 's/.*--sandbox-name \([^ ]*\).*/\1/p')" ;; esac previous="" @@ -1189,8 +1255,8 @@ fi helper='@HELPER_PATH@' echo "$$" > '@PID_PATH@' -printf '%s\n' "ssh -N -o ProxyCommand=/tmp/openshell ssh-proxy --gateway https://127.0.0.1:9443 --sandbox-id $sandbox_id --token test-token --gateway-name test-gateway -o ExitOnForwardFailure=yes -L $forward sandbox" > '@COMMAND_PATH@' -exec env OPENSHELL_FAKE_FORWARD_MODE=listen "$helper" -N -o "ProxyCommand=/tmp/openshell ssh-proxy --gateway https://127.0.0.1:9443 --sandbox-id $sandbox_id --token test-token --gateway-name test-gateway" -o ExitOnForwardFailure=yes -L "$forward" sandbox +printf '%s\n' "ssh -N -o ProxyCommand=/tmp/openshell ssh-proxy --gateway https://127.0.0.1:9443 --sandbox-name $sandbox_id --token test-token --gateway-name test-gateway -o ExitOnForwardFailure=yes -L $forward sandbox" > '@COMMAND_PATH@' +exec env OPENSHELL_FAKE_FORWARD_MODE=listen "$helper" -N -o "ProxyCommand=/tmp/openshell ssh-proxy --gateway https://127.0.0.1:9443 --sandbox-name $sandbox_id --token test-token --gateway-name test-gateway" -o ExitOnForwardFailure=yes -L "$forward" sandbox "# .replace("@PID_PATH@", &pid_path.display().to_string()) .replace("@COMMAND_PATH@", &command_path.display().to_string()) @@ -1234,7 +1300,7 @@ for arg in "$@"; do if [ "$previous" = "-o" ]; then case "$arg" in ProxyCommand=*) - sandbox_id="$(printf '%s\n' "$arg" | sed -n 's/.*--sandbox-id \([^ ]*\).*/\1/p')" + sandbox_id="$(printf '%s\n' "$arg" | sed -n 's/.*--sandbox-name \([^ ]*\).*/\1/p')" ;; esac previous="" @@ -1263,7 +1329,7 @@ fi helper='@HELPER_PATH@' echo "$$" > '@PID_PATH@' -exec env OPENSHELL_FAKE_FORWARD_MODE=sleep "$helper" -N -o "ProxyCommand=/tmp/openshell ssh-proxy --gateway https://127.0.0.1:9443 --sandbox-id $sandbox_id --token test-token --gateway-name test-gateway" -o ExitOnForwardFailure=yes -L "$forward" sandbox >'@LOG_PATH@' 2>&1 +exec env OPENSHELL_FAKE_FORWARD_MODE=sleep "$helper" -N -o "ProxyCommand=/tmp/openshell ssh-proxy --gateway https://127.0.0.1:9443 --sandbox-name $sandbox_id --token test-token --gateway-name test-gateway" -o ExitOnForwardFailure=yes -L "$forward" sandbox >'@LOG_PATH@' 2>&1 "# .replace("@LOG_PATH@", &log_path.display().to_string()) .replace("@PID_PATH@", &pid_path.display().to_string()) @@ -1728,7 +1794,7 @@ async fn sandbox_create_with_template_sends_workload_template_name() { } #[tokio::test] -async fn sandbox_template_create_sends_workload_template_resource() { +async fn sandbox_template_create_sends_non_default_workspace_in_scope_and_metadata() { let server = run_server().await; let fake_ssh_dir = tempfile::tempdir().unwrap(); let xdg_dir = tempfile::tempdir().unwrap(); @@ -1749,7 +1815,7 @@ async fn sandbox_template_create_sends_workload_template_resource() { HashMap::from([("owner".to_string(), "platform".to_string())]), HashMap::from([("FEATURE_FLAG".to_string(), "on".to_string())]), "table", - "default", + "team-a", &tls, ) .await @@ -1759,10 +1825,11 @@ async fn sandbox_template_create_sends_workload_template_resource() { let request = requests .first() .expect("template create request should be recorded"); - assert_eq!(request.workspace, "default"); + assert_eq!(selected_workspace(&request.workspace_scope), Some("team-a")); let template = request.template.as_ref().expect("template should be sent"); let metadata = template.metadata.as_ref().expect("metadata should be sent"); assert_eq!(metadata.name, "gpu-kata"); + assert_eq!(metadata.workspace, "team-a"); assert_eq!(metadata.labels.get("team"), Some(&"runtime".to_string())); assert_eq!( metadata.annotations.get("owner"), @@ -1831,15 +1898,20 @@ async fn sandbox_template_list_and_delete_send_workspace_requests() { 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); + assert_eq!( + selected_workspace(&list_request.workspace_scope), + Some("default") + ); let delete_requests = template_delete_requests(&server).await; let delete_request = delete_requests .first() .expect("template delete request should be recorded"); assert_eq!(delete_request.name, "gpu-kata"); - assert_eq!(delete_request.workspace, "default"); + assert_eq!( + selected_workspace(&delete_request.workspace_scope), + Some("default") + ); } #[tokio::test] @@ -2188,6 +2260,109 @@ async fn sandbox_create_retries_terminal_attachment_until_relay_registers() { ); } +#[tokio::test] +async fn sandbox_create_waits_for_main_result_after_provisional_container_exit() { + let server = run_server().await; + server + .openshell + .state + .terminal_after_provisional_container_exit + .store(true, Ordering::SeqCst); + let fake_ssh_dir = tempfile::tempdir().unwrap(); + let xdg_dir = tempfile::tempdir().unwrap(); + let _env = test_env(&fake_ssh_dir, &xdg_dir); + let tls = test_tls(&server); + install_fake_ssh(&fake_ssh_dir); + + let exit_code = run::sandbox_create( + &server.endpoint, + "openshell", + run::SandboxCreateConfig { + name: Some("fast-ephemeral-command"), + keep: false, + command: &["echo".into(), "OK".into()], + ..test_config() + }, + "default", + &tls, + ) + .await + .expect("a provisional container exit must yield to the canonical main-process result"); + + assert_eq!(exit_code, 0); + assert_eq!( + deleted_names(&server).await, + vec![vec!["fast-ephemeral-command".to_string()]] + ); +} + +#[tokio::test] +async fn sandbox_create_bounds_provisional_container_exit_reconciliation() { + let server = run_server().await; + server + .openshell + .state + .provisional_container_exit_without_result + .store(true, Ordering::SeqCst); + let fake_ssh_dir = tempfile::tempdir().unwrap(); + let xdg_dir = tempfile::tempdir().unwrap(); + let _env = test_env(&fake_ssh_dir, &xdg_dir); + let tls = test_tls(&server); + install_fake_ssh(&fake_ssh_dir); + + let provisional_container_exit_sent = server + .openshell + .state + .provisional_container_exit_sent + .notified(); + tokio::pin!(provisional_container_exit_sent); + let command = ["echo".into(), "OK".into()]; + let create = run::sandbox_create( + &server.endpoint, + "openshell", + run::SandboxCreateConfig { + name: Some("missing-main-result"), + command: &command, + ..test_config() + }, + "default", + &tls, + ); + tokio::pin!(create); + + tokio::select! { + () = &mut provisional_container_exit_sent => {} + result = &mut create => panic!("sandbox create returned before the provisional exit was observed: {result:?}"), + } + let reconciliation_started = Instant::now(); + let result = tokio::time::timeout(Duration::from_secs(10), &mut create) + .await + .expect("provisional container exit reconciliation must remain bounded"); + let reconciliation_elapsed = reconciliation_started.elapsed(); + + let err = result + .expect_err("a missing canonical main-process result must retain the container exit error"); + + let rendered = err.to_string(); + assert!( + rendered.contains("sandbox entered error phase while provisioning"), + "unexpected error: {rendered}" + ); + assert!( + rendered.contains("ContainerExited: Sandbox container exited"), + "unexpected error: {rendered}" + ); + assert!( + !rendered.contains("timed out"), + "unexpected error: {rendered}" + ); + assert!( + reconciliation_elapsed >= Duration::from_secs(5), + "provisional container exit returned before reconciliation: {reconciliation_elapsed:?}" + ); + assert!(deleted_names(&server).await.is_empty()); +} + #[tokio::test] async fn sandbox_create_deletes_command_sessions_with_no_keep() { let server = run_server().await; diff --git a/crates/openshell-cli/tests/sandbox_name_fallback_integration.rs b/crates/openshell-cli/tests/sandbox_name_fallback_integration.rs index fdb0ebbd0d..fe7b6cad0b 100644 --- a/crates/openshell-cli/tests/sandbox_name_fallback_integration.rs +++ b/crates/openshell-cli/tests/sandbox_name_fallback_integration.rs @@ -117,7 +117,8 @@ impl OpenShell for TestOpenShell { &self, request: tonic::Request, ) -> Result, Status> { - let name = request.into_inner().name; + let request = request.into_inner(); + let name = request.sandbox_name.clone(); *self.state.last_get_name.lock().await = Some(name.clone()); Ok(Response::new(SandboxResponse { sandbox: Some(Sandbox { @@ -178,10 +179,7 @@ impl OpenShell for TestOpenShell { request: tonic::Request, ) -> Result, Status> { let req = request.into_inner(); - assert_eq!( - req.sandbox_id, "test-id", - "GetSandboxConfig should pass the id from GetSandbox" - ); + assert!(!req.sandbox_name.is_empty()); Ok(Response::new(GetSandboxConfigResponse { policy: Some(SandboxPolicy { version: 9, @@ -450,7 +448,7 @@ impl OpenShell for TestOpenShell { request: tonic::Request, ) -> Result, Status> { let req = request.into_inner(); - assert_eq!(req.name, "my-sandbox"); + assert_eq!(req.sandbox_name, "my-sandbox"); assert_eq!(req.version, 3); assert!(!req.global); diff --git a/crates/openshell-core/src/forward.rs b/crates/openshell-core/src/forward.rs index a17edbdee2..f9762c60df 100644 --- a/crates/openshell-core/src/forward.rs +++ b/crates/openshell-core/src/forward.rs @@ -49,7 +49,7 @@ pub fn write_forward_pid( /// Find the PID of a backgrounded SSH forward by searching for the matching /// SSH process. Falls back to `pgrep` since SSH `-f` forks a new process /// whose PID we cannot capture directly. -pub fn find_ssh_forward_pid(sandbox_id: &str, port: u16) -> Option { +pub fn find_ssh_forward_pid(sandbox_name: &str, port: u16) -> Option { // Use pgrep only as a broad process source. The command line still needs a // second exact check before the PID can be tracked or signaled, otherwise a // requested port such as 80 can substring-match an existing 8080 forward. @@ -62,7 +62,7 @@ pub fn find_ssh_forward_pid(sandbox_id: &str, port: u16) -> Option { .lines() .rev() .filter_map(|l| l.trim().parse::().ok()) - .find(|pid| pid_matches_openshell_ssh_forward(*pid, port, Some(sandbox_id))) + .find(|pid| pid_matches_openshell_ssh_forward(*pid, port, Some(sandbox_name))) } /// Record read from a forward PID file. @@ -103,12 +103,12 @@ pub fn pid_is_alive(pid: u32) -> bool { } /// Validate that a PID belongs to the expected `OpenShell` SSH forward. -pub fn pid_matches_openshell_ssh_forward(pid: u32, port: u16, sandbox_id: Option<&str>) -> bool { +pub fn pid_matches_openshell_ssh_forward(pid: u32, port: u16, sandbox_name: Option<&str>) -> bool { let Some(argv) = process_forward_match_tokens(pid) else { return false; }; let tokens: Vec<&str> = argv.iter().map(String::as_str).collect(); - args_match_ssh_forward(&tokens, port, sandbox_id) + args_match_ssh_forward(&tokens, port, sandbox_name) } /// Read a process command line as matcher tokens. @@ -210,14 +210,14 @@ struct ProxyCommandMatch { } /// Match an `OpenShell` SSH forward by proxy ownership and outer SSH args. -fn args_match_ssh_forward(args: &[&str], port: u16, sandbox_id: Option<&str>) -> bool { +fn args_match_ssh_forward(args: &[&str], port: u16, sandbox_name: Option<&str>) -> bool { if args.first().and_then(|arg| arg.rsplit('/').next()) != Some("ssh") { return false; } - let Some(proxy) = find_proxy_command_match(args, sandbox_id) else { + let Some(proxy) = find_proxy_command_match(args, sandbox_name) else { return false; }; - if sandbox_id.is_some() && !proxy.sandbox_id_requirement_met { + if sandbox_name.is_some() && !proxy.sandbox_id_requirement_met { return false; } outer_ssh_forward_matches( @@ -229,12 +229,15 @@ fn args_match_ssh_forward(args: &[&str], port: u16, sandbox_id: Option<&str>) -> /// Test-only wrapper for flat command lines. #[cfg(test)] -fn command_matches_ssh_forward(command: &str, port: u16, sandbox_id: Option<&str>) -> bool { +fn command_matches_ssh_forward(command: &str, port: u16, sandbox_name: Option<&str>) -> bool { let args = command.split_whitespace().collect::>(); - args_match_ssh_forward(&args, port, sandbox_id) + args_match_ssh_forward(&args, port, sandbox_name) } -fn find_proxy_command_match(args: &[&str], sandbox_id: Option<&str>) -> Option { +fn find_proxy_command_match( + args: &[&str], + sandbox_name: Option<&str>, +) -> Option { for (index, arg) in args.iter().enumerate().skip(1) { let Some(prefix_has_no_command) = parse_ssh_prefix_before_proxy(args, index) else { continue; @@ -244,25 +247,25 @@ fn find_proxy_command_match(args: &[&str], sandbox_id: Option<&str>) -> Option

Result { return Ok(false); }; let pid = record.pid; - let Some(sandbox_id) = expected_sandbox_id_from_record(&record) else { + let Some(_) = expected_sandbox_id_from_record(&record) else { // Legacy PID records do not prove process ownership. let _ = std::fs::remove_file(&pid_path); return Ok(false); }; if pid_is_alive(pid) { - if !pid_matches_openshell_ssh_forward(pid, port, Some(sandbox_id)) { + if !pid_matches_openshell_ssh_forward(pid, port, Some(name)) { let _ = std::fs::remove_file(&pid_path); return Ok(false); } @@ -496,11 +499,10 @@ pub fn list_forwards() -> Result> { && let Some(record) = read_forward_pid(&stem[..dash_pos], port) { // Revalidate ownership so PID reuse does not look like a live forward. - let validated_alive = - expected_sandbox_id_from_record(&record).is_some_and(|sandbox_id| { - pid_is_alive(record.pid) - && pid_matches_openshell_ssh_forward(record.pid, port, Some(sandbox_id)) - }); + let validated_alive = expected_sandbox_id_from_record(&record).is_some_and(|_| { + pid_is_alive(record.pid) + && pid_matches_openshell_ssh_forward(record.pid, port, Some(&stem[..dash_pos])) + }); forwards.push(ForwardInfo { sandbox_name: stem[..dash_pos].to_string(), port, @@ -798,20 +800,20 @@ pub fn shell_escape(value: &str) -> String { /// Build the SSH `ProxyCommand` string used to tunnel to a sandbox. /// /// Every interpolated argument is shell-escaped so that server-supplied values -/// (gateway URL, sandbox id, token, gateway name) cannot inject shell +/// (gateway URL, sandbox name, token, gateway name) cannot inject shell /// metacharacters into the command that OpenSSH executes via `/bin/sh -c`. pub fn build_proxy_command( exe: &str, gateway_url: &str, - sandbox_id: &str, + sandbox_name: &str, token: &str, gateway_name: &str, ) -> String { format!( - "{} ssh-proxy --gateway {} --sandbox-id {} --token {} --gateway-name {}", + "{} ssh-proxy --gateway {} --sandbox-name {} --token {} --gateway-name {}", shell_escape(exe), shell_escape(gateway_url), - shell_escape(sandbox_id), + shell_escape(sandbox_name), shell_escape(token), shell_escape(gateway_name), ) @@ -1218,7 +1220,7 @@ mod tests { // An empty value must become `''` rather than disappearing — otherwise // downstream argv splitting would misalign. let cmd = build_proxy_command("exe", "gw", "", "tok", "name"); - assert!(cmd.contains("--sandbox-id ''")); + assert!(cmd.contains("--sandbox-name ''")); } #[test] @@ -1232,7 +1234,7 @@ mod tests { ); assert_eq!( cmd, - "/usr/local/bin/openshell ssh-proxy --gateway gw --sandbox-id sb-123 --token tok.456 --gateway-name name_1" + "/usr/local/bin/openshell ssh-proxy --gateway gw --sandbox-name sb-123 --token tok.456 --gateway-name name_1" ); } @@ -1451,8 +1453,8 @@ mod tests { #[test] fn ssh_forward_command_matches_exact_l_argument() { - let command = "ssh -o ProxyCommand=openshell ssh-proxy --sandbox-id sbx-1 -N -L 80:127.0.0.1:80 sandbox"; - let compact = "ssh -o ProxyCommand=openshell ssh-proxy --sandbox-id sbx-1 -N -L80:127.0.0.1:80 sandbox"; + let command = "ssh -o ProxyCommand=openshell ssh-proxy --sandbox-name sbx-1 -N -L 80:127.0.0.1:80 sandbox"; + let compact = "ssh -o ProxyCommand=openshell ssh-proxy --sandbox-name sbx-1 -N -L80:127.0.0.1:80 sandbox"; assert!(command_matches_ssh_forward(command, 80, Some("sbx-1"))); assert!(command_matches_ssh_forward(compact, 80, Some("sbx-1"))); @@ -1460,8 +1462,8 @@ mod tests { #[test] fn ssh_forward_command_matches_bind_prefixed_l_argument() { - let command = "ssh -o ProxyCommand=openshell ssh-proxy --sandbox-id sbx-1 -N -L 127.0.0.1:80:127.0.0.1:80 sandbox"; - let compact = "ssh -o ProxyCommand=openshell ssh-proxy --sandbox-id sbx-1 -N -L[::1]:80:127.0.0.1:80 sandbox"; + let command = "ssh -o ProxyCommand=openshell ssh-proxy --sandbox-name sbx-1 -N -L 127.0.0.1:80:127.0.0.1:80 sandbox"; + let compact = "ssh -o ProxyCommand=openshell ssh-proxy --sandbox-name sbx-1 -N -L[::1]:80:127.0.0.1:80 sandbox"; assert!(command_matches_ssh_forward(command, 80, Some("sbx-1"))); assert!(command_matches_ssh_forward(compact, 80, Some("sbx-1"))); @@ -1469,15 +1471,15 @@ mod tests { #[test] fn ssh_forward_command_rejects_substring_port_collision() { - let command = "ssh -o ProxyCommand=openshell ssh-proxy --sandbox-id sbx-1 -N -L 127.0.0.1:8080:127.0.0.1:8080 sandbox"; + let command = "ssh -o ProxyCommand=openshell ssh-proxy --sandbox-name sbx-1 -N -L 127.0.0.1:8080:127.0.0.1:8080 sandbox"; assert!(!command_matches_ssh_forward(command, 80, Some("sbx-1"))); } #[test] fn ssh_forward_command_requires_matching_sandbox_id() { - let command = "ssh -o ProxyCommand=openshell ssh-proxy --sandbox-id sbx-2 -N -L 80:127.0.0.1:80 sandbox"; - let equals = "ssh -o ProxyCommand=openshell ssh-proxy --sandbox-id=sbx-1 -N -L 80:127.0.0.1:80 sandbox"; + let command = "ssh -o ProxyCommand=openshell ssh-proxy --sandbox-name sbx-2 -N -L 80:127.0.0.1:80 sandbox"; + let equals = "ssh -o ProxyCommand=openshell ssh-proxy --sandbox-name=sbx-1 -N -L 80:127.0.0.1:80 sandbox"; assert!(!command_matches_ssh_forward(command, 80, Some("sbx-1"))); assert!(command_matches_ssh_forward(equals, 80, Some("sbx-1"))); @@ -1486,8 +1488,8 @@ mod tests { #[test] fn ssh_forward_command_rejects_sandbox_id_prefix_collision() { - let split = "ssh -o ProxyCommand=openshell ssh-proxy --sandbox-id sbx-10 -N -L 80:127.0.0.1:80 sandbox"; - let equals = "ssh -o ProxyCommand=openshell ssh-proxy --sandbox-id=sbx-10 -N -L 80:127.0.0.1:80 sandbox"; + let split = "ssh -o ProxyCommand=openshell ssh-proxy --sandbox-name sbx-10 -N -L 80:127.0.0.1:80 sandbox"; + let equals = "ssh -o ProxyCommand=openshell ssh-proxy --sandbox-name=sbx-10 -N -L 80:127.0.0.1:80 sandbox"; assert!(!command_matches_ssh_forward(split, 80, Some("sbx-1"))); assert!(!command_matches_ssh_forward(equals, 80, Some("sbx-1"))); @@ -1495,9 +1497,9 @@ mod tests { #[test] fn ssh_forward_command_rejects_host_port_ambiguity() { - let wrong_remote_port = "ssh -o ProxyCommand=openshell ssh-proxy --sandbox-id sbx-1 -N -L 80:127.0.0.1:8080 sandbox"; - let wrong_local_port = "ssh -o ProxyCommand=openshell ssh-proxy --sandbox-id sbx-1 -N -L 127.0.0.1:8080:127.0.0.1:80 sandbox"; - let wrong_remote_host = "ssh -o ProxyCommand=openshell ssh-proxy --sandbox-id sbx-1 -N -L 80:localhost:80 sandbox"; + let wrong_remote_port = "ssh -o ProxyCommand=openshell ssh-proxy --sandbox-name sbx-1 -N -L 80:127.0.0.1:8080 sandbox"; + let wrong_local_port = "ssh -o ProxyCommand=openshell ssh-proxy --sandbox-name sbx-1 -N -L 127.0.0.1:8080:127.0.0.1:80 sandbox"; + let wrong_remote_host = "ssh -o ProxyCommand=openshell ssh-proxy --sandbox-name sbx-1 -N -L 80:localhost:80 sandbox"; assert!(!command_matches_ssh_forward( wrong_remote_port, @@ -1518,14 +1520,14 @@ mod tests { #[test] fn ssh_forward_command_matches_path_basenames_and_bind_variants() { - let command = "/usr/bin/ssh -o ProxyCommand=/usr/local/bin/ssh-proxy --sandbox-id=sbx-1 -N -L localhost:80:127.0.0.1:80 sandbox"; + let command = "/usr/bin/ssh -o ProxyCommand=/usr/local/bin/ssh-proxy --sandbox-name=sbx-1 -N -L localhost:80:127.0.0.1:80 sandbox"; assert!(command_matches_ssh_forward(command, 80, Some("sbx-1"))); } #[test] fn ssh_forward_command_matches_generated_forward_shape() { - let command = "/usr/bin/ssh -N -o ProxyCommand=/path/openshell ssh-proxy --gateway https://127.0.0.1:9443 --sandbox-id sbx-1 --token tok_123 --gateway-name local -o ExitOnForwardFailure=yes -L 127.0.0.1:80:127.0.0.1:80 -f sandbox"; + let command = "/usr/bin/ssh -N -o ProxyCommand=/path/openshell ssh-proxy --gateway https://127.0.0.1:9443 --sandbox-name sbx-1 --token tok_123 --gateway-name local -o ExitOnForwardFailure=yes -L 127.0.0.1:80:127.0.0.1:80 -f sandbox"; assert!(command_matches_ssh_forward(command, 80, Some("sbx-1"))); } @@ -1548,7 +1550,7 @@ mod tests { fn expand_proxy_command_arg_splits_value_and_keeps_prefix() { let exe = "/Application Support/openshell"; let arg = format!( - "ProxyCommand={} ssh-proxy --sandbox-id sbx-1", + "ProxyCommand={} ssh-proxy --sandbox-name sbx-1", shell_escape(exe) ); assert_eq!( @@ -1556,7 +1558,7 @@ mod tests { vec![ format!("ProxyCommand={exe}"), "ssh-proxy".to_string(), - "--sandbox-id".to_string(), + "--sandbox-name".to_string(), "sbx-1".to_string(), ] ); @@ -1572,7 +1574,7 @@ mod tests { // ProxyCommand element and matches correctly. let exe = "/Application Support/openshell"; let proxy_arg = format!( - "ProxyCommand={} ssh-proxy --gateway https://127.0.0.1:9443 --sandbox-id sbx-1 --token tok_123 --gateway-name local", + "ProxyCommand={} ssh-proxy --gateway https://127.0.0.1:9443 --sandbox-name sbx-1 --token tok_123 --gateway-name local", shell_escape(exe) ); // Mirror process_forward_match_tokens: the ProxyCommand element is expanded. @@ -1600,8 +1602,8 @@ mod tests { #[test] fn ssh_forward_command_rejects_proxy_name_collisions() { - let wrong_ssh = "notssh ssh-proxy --sandbox-id sbx-1 -N -L 80:127.0.0.1:80 sandbox"; - let wrong_proxy = "ssh -o ProxyCommand=/usr/local/bin/not-ssh-proxy --sandbox-id=sbx-1 -N -L 80:127.0.0.1:80 sandbox"; + let wrong_ssh = "notssh ssh-proxy --sandbox-name sbx-1 -N -L 80:127.0.0.1:80 sandbox"; + let wrong_proxy = "ssh -o ProxyCommand=/usr/local/bin/not-ssh-proxy --sandbox-name=sbx-1 -N -L 80:127.0.0.1:80 sandbox"; assert!(!command_matches_ssh_forward(wrong_ssh, 80, Some("sbx-1"))); assert!(!command_matches_ssh_forward(wrong_proxy, 80, Some("sbx-1"))); @@ -1609,24 +1611,25 @@ mod tests { #[test] fn ssh_forward_command_rejects_non_ssh_process_with_matching_tokens() { - let command = "python3 /tmp/ssh ssh-proxy --sandbox-id sbx-1 -N -L 80:127.0.0.1:80 sandbox"; + let command = + "python3 /tmp/ssh ssh-proxy --sandbox-name sbx-1 -N -L 80:127.0.0.1:80 sandbox"; assert!(!command_matches_ssh_forward(command, 80, Some("sbx-1"))); } #[test] fn ssh_forward_command_rejects_bare_ssh_proxy_destination() { - let command = "ssh ssh-proxy --sandbox-id sbx-1 -N -L80:127.0.0.1:80 sandbox"; + let command = "ssh ssh-proxy --sandbox-name sbx-1 -N -L80:127.0.0.1:80 sandbox"; assert!(!command_matches_ssh_forward(command, 80, Some("sbx-1"))); } #[test] fn ssh_forward_command_rejects_remote_command_l_argument() { - let remote_arg = "ssh -o ProxyCommand=openshell ssh-proxy --sandbox-id sbx-1 -N sandbox -L 80:127.0.0.1:80"; - let missing_no_command = "ssh ssh-proxy --sandbox-id sbx-1 -L 80:127.0.0.1:80 sandbox"; - let remote_command_lookalike = "ssh -o ProxyCommand=openshell ssh-proxy --sandbox-id sbx-1 real-host echo -N -L 80:127.0.0.1:80 sandbox"; - let sandbox_id_in_remote_command = "ssh -o ProxyCommand=openshell ssh-proxy --sandbox-id sbx-2 real-host --sandbox-id sbx-1 -N -L 80:127.0.0.1:80 sandbox"; + let remote_arg = "ssh -o ProxyCommand=openshell ssh-proxy --sandbox-name sbx-1 -N sandbox -L 80:127.0.0.1:80"; + let missing_no_command = "ssh ssh-proxy --sandbox-name sbx-1 -L 80:127.0.0.1:80 sandbox"; + let remote_command_lookalike = "ssh -o ProxyCommand=openshell ssh-proxy --sandbox-name sbx-1 real-host echo -N -L 80:127.0.0.1:80 sandbox"; + let sandbox_id_in_remote_command = "ssh -o ProxyCommand=openshell ssh-proxy --sandbox-name sbx-2 real-host --sandbox-name sbx-1 -N -L 80:127.0.0.1:80 sandbox"; assert!(!command_matches_ssh_forward(remote_arg, 80, Some("sbx-1"))); assert!(!command_matches_ssh_forward( diff --git a/crates/openshell-core/src/grpc_client.rs b/crates/openshell-core/src/grpc_client.rs index 315ff0b72f..c2aa3a1ba1 100644 --- a/crates/openshell-core/src/grpc_client.rs +++ b/crates/openshell-core/src/grpc_client.rs @@ -685,14 +685,17 @@ async fn connect(endpoint: &str) -> Result> { /// Returns `Ok(Some(policy))` when the server has a policy configured, /// or `Ok(None)` when the sandbox was created without a policy (the sandbox /// should discover one from disk or use the restrictive default). -pub async fn fetch_policy(endpoint: &str, sandbox_id: &str) -> Result> { - debug!(endpoint = %endpoint, sandbox_id = %sandbox_id, "Connecting to OpenShell server"); +pub async fn fetch_policy( + endpoint: &str, + sandbox_name: &str, +) -> Result> { + debug!(endpoint = %endpoint, sandbox_name = %sandbox_name, "Connecting to OpenShell server"); let mut client = connect(endpoint).await?; debug!("Connected, fetching sandbox policy"); - fetch_policy_with_client(&mut client, sandbox_id).await + fetch_policy_with_client(&mut client, sandbox_name).await } /// Fetch the authoritative policy and revision metadata in one response. @@ -703,20 +706,22 @@ pub async fn fetch_policy(endpoint: &str, sandbox_id: &str) -> Result Result { - debug!(endpoint = %endpoint, sandbox_id = %sandbox_id, "Connecting to fetch OpenShell settings snapshot"); + debug!(endpoint = %endpoint, sandbox_name = %sandbox_name, "Connecting to fetch OpenShell settings snapshot"); let mut client = connect(endpoint).await?; - fetch_settings_snapshot_with_client(&mut client, sandbox_id).await + fetch_settings_snapshot_with_client(&mut client, sandbox_name, None).await } async fn fetch_settings_snapshot_with_client( client: &mut OpenShellClient, - sandbox_id: &str, + sandbox_name: &str, + workspace: Option<&str>, ) -> Result { let response = client .get_sandbox_config(GetSandboxConfigRequest { - sandbox_id: sandbox_id.to_string(), + sandbox_name: sandbox_name.to_string(), + workspace_scope: workspace.map(crate::proto::workspace_selector), }) .await .into_diagnostic()?; @@ -727,9 +732,9 @@ async fn fetch_settings_snapshot_with_client( /// Fetch sandbox policy using an existing client connection. async fn fetch_policy_with_client( client: &mut OpenShellClient, - sandbox_id: &str, + sandbox_name: &str, ) -> Result> { - let snapshot = fetch_settings_snapshot_with_client(client, sandbox_id).await?; + let snapshot = fetch_settings_snapshot_with_client(client, sandbox_name, None).await?; // version 0 with no policy means the sandbox was created without one. if snapshot.version == 0 && snapshot.policy.is_none() { @@ -750,9 +755,9 @@ async fn sync_policy_with_client( ) -> Result<()> { client .update_config(UpdateConfigRequest { - name: sandbox.to_string(), + sandbox_name: sandbox.to_string(), + workspace_scope: Some(crate::proto::workspace_selector(workspace)), policy: Some(policy.clone()), - workspace: workspace.to_string(), ..Default::default() }) .await @@ -768,14 +773,12 @@ async fn sync_policy_with_client( /// channel instead of establishing three separate connections. pub async fn discover_and_sync_policy( endpoint: &str, - sandbox_id: &str, sandbox: &str, discovered_policy: &ProtoSandboxPolicy, workspace: &str, ) -> Result { debug!( endpoint = %endpoint, - sandbox_id = %sandbox_id, sandbox = %sandbox, "Syncing discovered policy and re-fetching canonical version" ); @@ -786,8 +789,9 @@ pub async fn discover_and_sync_policy( sync_policy_with_client(&mut client, sandbox, discovered_policy, workspace).await?; // Re-fetch from the gateway to get the canonical version/hash. - fetch_policy_with_client(&mut client, sandbox_id) + fetch_settings_snapshot_with_client(&mut client, sandbox, Some(workspace)) .await? + .policy .ok_or_else(|| { miette::miette!("Server still returned no policy after sync — this is a bug") }) @@ -811,14 +815,13 @@ pub async fn sync_policy( /// Sync an enriched policy and return the authoritative revision snapshot. pub async fn sync_policy_and_fetch_snapshot( endpoint: &str, - sandbox_id: &str, sandbox: &str, policy: &ProtoSandboxPolicy, workspace: &str, ) -> Result { let mut client = connect(endpoint).await?; sync_policy_with_client(&mut client, sandbox, policy, workspace).await?; - fetch_settings_snapshot_with_client(&mut client, sandbox_id).await + fetch_settings_snapshot_with_client(&mut client, sandbox, Some(workspace)).await } /// Fetch provider environment variables for a sandbox from `OpenShell` server via gRPC. @@ -1047,12 +1050,15 @@ impl CachedOpenShellClient { } /// Poll for current effective sandbox settings and policy metadata. - pub async fn poll_settings(&self, sandbox_id: &str) -> Result { + pub async fn poll_settings(&self, sandbox_name: &str) -> Result { + let workspace = self.workspace(); let response = self .client .clone() .get_sandbox_config(GetSandboxConfigRequest { - sandbox_id: sandbox_id.to_string(), + sandbox_name: sandbox_name.to_string(), + workspace_scope: (!workspace.is_empty()) + .then(|| crate::proto::workspace_selector(workspace)), }) .await .into_diagnostic()?; @@ -1178,9 +1184,9 @@ impl CachedOpenShellClient { .client .clone() .get_draft_policy(GetDraftPolicyRequest { - name: sandbox_name.to_string(), status_filter: status_filter.to_string(), - workspace: self.workspace(), + sandbox_name: sandbox_name.to_string(), + workspace_scope: Some(crate::proto::workspace_selector(self.workspace())), }) .await .into_diagnostic()?; diff --git a/crates/openshell-core/src/proto/mod.rs b/crates/openshell-core/src/proto/mod.rs index cd4b24afe9..d0b976ff24 100644 --- a/crates/openshell-core/src/proto/mod.rs +++ b/crates/openshell-core/src/proto/mod.rs @@ -73,3 +73,19 @@ pub use middleware::v1::*; pub use openshell::*; pub use sandbox::v1::*; pub use test::ObjectForTest; + +/// Build a selector for one explicitly named workspace. +pub fn workspace_selector(workspace: impl Into) -> WorkspaceSelector { + WorkspaceSelector { + selection: Some(workspace_selector::Selection::Workspace(workspace.into())), + } +} + +/// Build a selector for every workspace supported by a cross-workspace request. +pub fn all_workspaces_selector() -> WorkspaceSelector { + WorkspaceSelector { + selection: Some(workspace_selector::Selection::AllWorkspaces( + AllWorkspaces {}, + )), + } +} diff --git a/crates/openshell-gateway-interceptors/src/proto_json.rs b/crates/openshell-gateway-interceptors/src/proto_json.rs index 3abee67535..94c563e835 100644 --- a/crates/openshell-gateway-interceptors/src/proto_json.rs +++ b/crates/openshell-gateway-interceptors/src/proto_json.rs @@ -291,7 +291,7 @@ mod tests { use openshell_core::proto::{ CreateProviderRequest, CreateSandboxRequest, GpuResourceRequirements, Provider, - SandboxSpec, UpdateConfigRequest, + SandboxSpec, UpdateConfigRequest, workspace_selector, }; use prost::Message as _; use prost_types::{ @@ -315,7 +315,7 @@ mod tests { name: "demo".to_string(), labels: HashMap::from([("team".to_string(), "agent".to_string())]), annotations: HashMap::new(), - workspace: String::new(), + workspace_scope: Some(workspace_selector("default")), await_main_process_attachment: false, workload_template_name: String::new(), }; @@ -345,7 +345,7 @@ mod tests { config: HashMap::from([("region".to_string(), "us-west".to_string())]), ..Provider::default() }), - workspace: String::new(), + workspace_scope: Some(workspace_selector("default")), }; let encoded = request.encode_to_vec(); @@ -404,6 +404,7 @@ mod tests { environment: HashMap::from([("FEATURE_FLAG".to_string(), "on".to_string())]), ..SandboxSpec::default() }), + workspace_scope: Some(workspace_selector("default")), ..CreateSandboxRequest::default() }; @@ -453,7 +454,8 @@ mod tests { let codec = ProtoJsonCodec::from_descriptor_set(openshell_core::FILE_DESCRIPTOR_SET).unwrap(); let request = UpdateConfigRequest { - name: "demo".to_string(), + sandbox_name: "demo".to_string(), + workspace_scope: Some(workspace_selector("default")), annotations: HashMap::from([( "openshell.nvidia.com/policy-signature".to_string(), "signed".to_string(), diff --git a/crates/openshell-gateway-interceptors/src/runtime.rs b/crates/openshell-gateway-interceptors/src/runtime.rs index cd0f59ea3c..09c4ce180b 100644 --- a/crates/openshell-gateway-interceptors/src/runtime.rs +++ b/crates/openshell-gateway-interceptors/src/runtime.rs @@ -764,7 +764,7 @@ mod tests { config: HashMap::from([("region".to_string(), "old".to_string())]), ..Provider::default() }), - workspace: String::new(), + workspace_scope: Some(openshell_core::proto::workspace_selector("default")), }; let json = codec .decode_message_to_json("openshell.v1.CreateProviderRequest", &request) @@ -1009,7 +1009,8 @@ mod tests { codec: codec.clone(), }; let request = UpdateConfigRequest { - name: "demo".to_string(), + sandbox_name: "demo".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector("default")), expected_resource_version: u64::MAX - 1, annotations: HashMap::from([ ("policy-hash".to_string(), "sha256:v2:abc".to_string()), @@ -1074,7 +1075,7 @@ mod tests { name: "demo".to_string(), labels: HashMap::new(), annotations: HashMap::new(), - workspace: String::new(), + workspace_scope: Some(openshell_core::proto::workspace_selector("default")), await_main_process_attachment: false, workload_template_name: String::new(), }; @@ -1185,7 +1186,10 @@ mod tests { let operation = ValidatedOperation::new( &codec, "openshell.v1.UpdateConfigRequest", - json!({"name": "demo", "expectedResourceVersion": "7"}), + json!({ + "sandboxName": "demo", "workspaceScope": {"workspace": "default"}, + "expectedResourceVersion": "7" + }), ) .unwrap(); let prior = operation.clone(); @@ -1223,7 +1227,10 @@ mod tests { let operation = ValidatedOperation::new( &codec, "openshell.v1.UpdateConfigRequest", - json!({"name": "demo", "expectedResourceVersion": "7"}), + json!({ + "sandboxName": "demo", "workspaceScope": {"workspace": "default"}, + "expectedResourceVersion": "7" + }), ) .unwrap(); let plan = test_modify_plan(FailurePolicy::FailClosed); @@ -1261,12 +1268,15 @@ mod tests { let operation = ValidatedOperation::new( &codec, "openshell.v1.UpdateConfigRequest", - json!({"name": "demo", "expectedResourceVersion": "7"}), + json!({ + "sandboxName": "demo", "workspaceScope": {"workspace": "default"}, + "expectedResourceVersion": "7" + }), ) .unwrap(); let prior = operation.clone(); let result = allowed_result(vec![ - patch("replace", "/name", json!("partially-mutated")), + patch("replace", "/sandboxName", json!("partially-mutated")), patch("replace", "/expectedResourceVersion", json!("not-a-number")), ]); @@ -1280,7 +1290,7 @@ mod tests { .unwrap(); assert_eq!(outcome, prior); - assert_eq!(outcome.json["name"], "demo"); + assert_eq!(outcome.json["sandboxName"], "demo"); } #[tokio::test] @@ -1291,17 +1301,20 @@ mod tests { let operation = ValidatedOperation::new( &codec, "openshell.v1.UpdateConfigRequest", - json!({"name": "demo", "expectedResourceVersion": "7"}), + json!({ + "sandboxName": "demo", "workspaceScope": {"workspace": "default"}, + "expectedResourceVersion": "7" + }), ) .unwrap(); let plan = test_modify_plan(FailurePolicy::FailOpen); let invalid_first = allowed_result(vec![ - patch("replace", "/name", json!("rejected-candidate")), + patch("replace", "/sandboxName", json!("rejected-candidate")), patch("replace", "/expectedResourceVersion", json!("not-a-number")), ]); let second = allowed_result(vec![ - patch("test", "/name", json!("demo")), - patch("replace", "/name", json!("accepted-candidate")), + patch("test", "/sandboxName", json!("demo")), + patch("replace", "/sandboxName", json!("accepted-candidate")), ]); let operation = apply_evaluation_result( @@ -1321,9 +1334,9 @@ mod tests { ) .unwrap(); - assert_eq!(operation.json["name"], "accepted-candidate"); + assert_eq!(operation.json["sandboxName"], "accepted-candidate"); let decoded = UpdateConfigRequest::decode(operation.encoded.as_slice()).unwrap(); - assert_eq!(decoded.name, "accepted-candidate"); + assert_eq!(decoded.sandbox_name, "accepted-candidate"); assert_eq!(decoded.expected_resource_version, 7); } @@ -1370,10 +1383,13 @@ mod tests { let operation = ValidatedOperation::new( &codec, "openshell.v1.UpdateConfigRequest", - json!({"name": "demo", "expectedResourceVersion": "7"}), + json!({ + "sandboxName": "demo", "workspaceScope": {"workspace": "default"}, + "expectedResourceVersion": "7" + }), ) .unwrap(); - let result = allowed_result(vec![patch("replace", "/name", json!("accepted"))]); + let result = allowed_result(vec![patch("replace", "/sandboxName", json!("accepted"))]); let recorder = TestRecorder::default(); let operation = metrics::with_local_recorder(&recorder, || { @@ -1388,7 +1404,7 @@ mod tests { .unwrap(); let decoded = UpdateConfigRequest::decode(operation.encoded.as_slice()).unwrap(); - assert_eq!(decoded.name, "accepted"); + assert_eq!(decoded.sandbox_name, "accepted"); assert_eq!(TestRecorder::count(&recorder.evaluations), 1); assert_eq!(TestRecorder::count(&recorder.patches), 1); assert_eq!(TestRecorder::count(&recorder.fail_open), 0); diff --git a/crates/openshell-sandbox/src/lib.rs b/crates/openshell-sandbox/src/lib.rs index d86c94b97b..8d5b6a5a9a 100644 --- a/crates/openshell-sandbox/src/lib.rs +++ b/crates/openshell-sandbox/src/lib.rs @@ -209,7 +209,7 @@ pub async fn run_sandbox( } else { load_policy( sandbox_id.clone(), - sandbox, + sandbox.clone(), openshell_endpoint.clone(), policy_rules, policy_data, @@ -733,8 +733,9 @@ pub async fn run_sandbox( // Spawn background policy poll task (gRPC mode only). if !process_uses_sidecar_control - && let (Some(id), Some(endpoint), Some(engine)) = ( + && let (Some(id), Some(sandbox_name), Some(endpoint), Some(engine)) = ( sandbox_id.as_deref(), + sandbox.as_deref(), openshell_endpoint.as_deref(), opa_engine.as_ref(), ) @@ -754,6 +755,7 @@ pub async fn run_sandbox( let poll_ctx = PolicyPollLoopContext { endpoint: poll_endpoint, sandbox_id: poll_id, + sandbox_name: sandbox_name.to_string(), opa_engine: poll_engine, loaded_policy_origin, entrypoint_pid: poll_pid, @@ -2368,14 +2370,16 @@ async fn load_policy( } // gRPC mode: fetch typed proto policy, construct OPA engine from baked rules + proto data - if let (Some(id), Some(endpoint)) = (&sandbox_id, &openshell_endpoint) { + if let (Some(id), Some(sandbox_name), Some(endpoint)) = + (&sandbox_id, &sandbox, &openshell_endpoint) + { info!( sandbox_id = %id, endpoint = %endpoint, "Fetching sandbox policy via gRPC" ); let mut snapshot = grpc_retry("Policy fetch", || { - openshell_core::grpc_client::fetch_settings_snapshot(endpoint, id) + openshell_core::grpc_client::fetch_settings_snapshot(endpoint, sandbox_name) }) .await?; @@ -2411,7 +2415,6 @@ async fn load_policy( snapshot = grpc_retry("Policy discovery sync", || { openshell_core::grpc_client::sync_policy_and_fetch_snapshot( endpoint, - id, sandbox, &discovered, &ws, @@ -2438,7 +2441,6 @@ async fn load_policy( if let Some(sandbox_name) = sandbox.as_deref() { match openshell_core::grpc_client::sync_policy_and_fetch_snapshot( endpoint, - id, sandbox_name, &sync_policy, &snapshot.workspace, @@ -3332,6 +3334,7 @@ async fn report_initial_policy_failure( struct PolicyPollLoopContext { endpoint: String, sandbox_id: String, + sandbox_name: String, opa_engine: Arc, /// Source of the policy currently loaded into OPA. This distinguishes an /// explicit local-file override from an unbound gateway revision so the @@ -3777,7 +3780,7 @@ async fn run_policy_poll_loop_with_client( // Initialize revision from the first poll and acknowledge the initial // policy revision the supervisor actually loaded. A mismatched result is // reconciled below instead of being recorded as already applied. - match client.poll_settings(&ctx.sandbox_id).await { + match client.poll_settings(&ctx.sandbox_name).await { Ok(result) => { let _ = ctx.workspace_tx.send(client.workspace()); match initial_poll_disposition(&ctx.loaded_policy_origin, &result) { @@ -3844,7 +3847,7 @@ async fn run_policy_poll_loop_with_client( result } else { tokio::time::sleep(next_poll_delay(&ctx.extension_credentials, interval)).await; - match client.poll_settings(&ctx.sandbox_id).await { + match client.poll_settings(&ctx.sandbox_name).await { Ok(result) => { let _ = ctx.workspace_tx.send(client.workspace()); result @@ -5076,6 +5079,7 @@ network_policies: PolicyPollLoopContext { endpoint: String::new(), sandbox_id: "sandbox-test".to_string(), + sandbox_name: "sandbox-test".to_string(), opa_engine, loaded_policy_origin, entrypoint_pid: Arc::new(AtomicU32::new(0)), diff --git a/crates/openshell-sandbox/src/main.rs b/crates/openshell-sandbox/src/main.rs index 440676ad14..35614294d9 100644 --- a/crates/openshell-sandbox/src/main.rs +++ b/crates/openshell-sandbox/src/main.rs @@ -29,8 +29,8 @@ const COPY_SELF_SUBCOMMAND: &str = "copy-self"; /// Reads the same token sources as the supervisor (env, file, K8s SA /// bootstrap) and issues a single gRPC call against the gateway. Useful /// for end-to-end verification: e.g. `docker exec` into a sandbox, then -/// run `openshell-sandbox debug-rpc get-sandbox-config --sandbox-id ` -/// to confirm the cross-sandbox IDOR guard fires. +/// run `openshell-sandbox debug-rpc get-sandbox-config --sandbox-name ` +/// to confirm the cross-sandbox authorization guard fires. const DEBUG_RPC_SUBCOMMAND: &str = "debug-rpc"; const VALIDATE_WORKSPACE_SUBCOMMAND: &str = "validate-workspace"; diff --git a/crates/openshell-sdk/README.md b/crates/openshell-sdk/README.md index c773de627d..dc57d5ec11 100644 --- a/crates/openshell-sdk/README.md +++ b/crates/openshell-sdk/README.md @@ -46,6 +46,7 @@ mTLS (client certificates) is not supported. `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`, +`list_sandboxes_all_workspaces`, `list_sandbox_templates_all_workspaces`, `wait_ready`, `wait_deleted`, and `exec`. Curated types (`SandboxSpec`, `SandboxRef`, `Health`, `ListOptions`, `SandboxTemplateListOptions`, `ExecOptions`, `SandboxPhase`) use SDK-shaped enums rather than raw proto @@ -54,6 +55,10 @@ integers where practical. Reusable template resources are exposed as portable workload shape and driver config. Failures map to a typed `SdkError` with a discriminable kind. +Curated calls without a workspace argument explicitly select the `default` +workspace. Cross-workspace listing uses the separate `*_all_workspaces` +methods and requires Platform Admin access. + ```rust use openshell_sdk::{ ClientConfig, OpenShellClient, SandboxTemplateCreateSpec, diff --git a/crates/openshell-sdk/src/client.rs b/crates/openshell-sdk/src/client.rs index cd48c41558..fc2b0176af 100644 --- a/crates/openshell-sdk/src/client.rs +++ b/crates/openshell-sdk/src/client.rs @@ -169,7 +169,7 @@ impl OpenShellClient { .unary(|mut grpc| { let request = proto::CreateSandboxTemplateRequest { template: Some(template.clone()), - workspace: String::new(), + workspace_scope: Some(proto::workspace_selector("default")), }; async move { grpc.create_sandbox_template(request).await } }) @@ -183,7 +183,7 @@ impl OpenShellClient { .unary(|mut grpc| { let request = proto::GetSandboxTemplateRequest { name: name.to_string(), - workspace: String::new(), + workspace_scope: Some(proto::workspace_selector("default")), }; async move { grpc.get_sandbox_template(request).await } }) @@ -191,7 +191,7 @@ impl OpenShellClient { sandbox_template_from_response(response.template) } - /// List reusable sandbox templates in the default workspace or across all workspaces. + /// List reusable sandbox templates in the default workspace. pub async fn list_sandbox_templates( &self, opts: SandboxTemplateListOptions, @@ -201,9 +201,27 @@ impl OpenShellClient { let request = proto::ListSandboxTemplatesRequest { limit: opts.limit, offset: opts.offset, - workspace: String::new(), - all_workspaces: opts.all_workspaces, label_selector: opts.label_selector.clone(), + workspace_scope: Some(proto::workspace_selector("default")), + }; + async move { grpc.list_sandbox_templates(request).await } + }) + .await?; + Ok(response.templates) + } + + /// List reusable sandbox templates across all workspaces. + pub async fn list_sandbox_templates_all_workspaces( + &self, + opts: SandboxTemplateListOptions, + ) -> Result> { + let response = self + .unary(|mut grpc| { + let request = proto::ListSandboxTemplatesRequest { + limit: opts.limit, + offset: opts.offset, + label_selector: opts.label_selector.clone(), + workspace_scope: Some(proto::all_workspaces_selector()), }; async move { grpc.list_sandbox_templates(request).await } }) @@ -217,7 +235,7 @@ impl OpenShellClient { .unary(|mut grpc| { let request = proto::DeleteSandboxTemplateRequest { name: name.to_string(), - workspace: String::new(), + workspace_scope: Some(proto::workspace_selector("default")), }; async move { grpc.delete_sandbox_template(request).await } }) @@ -230,8 +248,8 @@ impl OpenShellClient { let response = self .unary(|mut grpc| { let request = proto::GetSandboxRequest { - name: name.to_string(), - workspace: String::new(), + sandbox_name: name.to_string(), + workspace_scope: Some(proto::workspace_selector("default")), }; async move { grpc.get_sandbox(request).await } }) @@ -247,8 +265,7 @@ impl OpenShellClient { limit: opts.limit, offset: opts.offset, label_selector: opts.label_selector.clone().unwrap_or_default(), - workspace: String::new(), - all_workspaces: false, + workspace_scope: Some(proto::workspace_selector("default")), }; async move { grpc.list_sandboxes(request).await } }) @@ -270,8 +287,8 @@ impl OpenShellClient { let response = self .unary(|mut grpc| { let request = proto::DeleteSandboxRequest { - name: name.to_string(), - workspace: String::new(), + sandbox_name: name.to_string(), + workspace_scope: Some(proto::workspace_selector("default")), }; async move { grpc.delete_sandbox(request).await } }) @@ -284,8 +301,8 @@ impl OpenShellClient { let response = self .unary(|mut grpc| { let request = proto::StopSandboxRequest { - name: name.to_string(), - workspace: String::new(), + sandbox_name: name.to_string(), + workspace_scope: Some(proto::workspace_selector("default")), }; async move { grpc.stop_sandbox(request).await } }) @@ -298,8 +315,8 @@ impl OpenShellClient { let response = self .unary(|mut grpc| { let request = proto::StartSandboxRequest { - name: name.to_string(), - workspace: String::new(), + sandbox_name: name.to_string(), + workspace_scope: Some(proto::workspace_selector("default")), }; async move { grpc.start_sandbox(request).await } }) @@ -373,8 +390,7 @@ impl OpenShellClient { limit: opts.limit, offset: opts.offset, label_selector: opts.label_selector.clone().unwrap_or_default(), - workspace: String::new(), - all_workspaces: true, + workspace_scope: Some(proto::all_workspaces_selector()), }; async move { grpc.list_sandboxes(request).await } }) @@ -460,9 +476,9 @@ impl OpenShellClient { /// For streaming output, drop down to [`OpenShellClient::raw_grpc`] and /// call `exec_sandbox` directly. pub async fn exec(&self, name: &str, cmd: &[String], opts: ExecOptions) -> Result { - let sandbox = self.get_sandbox(name).await?; let request = proto::ExecSandboxRequest { - sandbox_id: sandbox.id, + sandbox_name: name.to_string(), + workspace_scope: Some(proto::workspace_selector("default")), command: cmd.to_vec(), workdir: opts.workdir.unwrap_or_default(), environment: opts.environment, @@ -618,7 +634,7 @@ impl WorkspaceScopedClient { /// Create a new sandbox in this workspace. pub async fn create_sandbox(&self, spec: SandboxSpec) -> Result { let mut request = create_sandbox_request(spec); - request.workspace = self.workspace.clone(); + request.workspace_scope = Some(proto::workspace_selector(&self.workspace)); let response = self .client .unary(|mut grpc| { @@ -635,7 +651,7 @@ impl WorkspaceScopedClient { spec: SandboxTemplateCreateSpec, ) -> Result { let mut request = create_sandbox_from_template_request(spec); - request.workspace = self.workspace.clone(); + request.workspace_scope = Some(proto::workspace_selector(&self.workspace)); let response = self .client .unary(|mut grpc| { @@ -656,7 +672,7 @@ impl WorkspaceScopedClient { .unary(|mut grpc| { let request = proto::CreateSandboxTemplateRequest { template: Some(template.clone()), - workspace: self.workspace.clone(), + workspace_scope: Some(proto::workspace_selector(&self.workspace)), }; async move { grpc.create_sandbox_template(request).await } }) @@ -671,7 +687,7 @@ impl WorkspaceScopedClient { .unary(|mut grpc| { let request = proto::GetSandboxTemplateRequest { name: name.to_string(), - workspace: self.workspace.clone(), + workspace_scope: Some(proto::workspace_selector(&self.workspace)), }; async move { grpc.get_sandbox_template(request).await } }) @@ -679,7 +695,7 @@ impl WorkspaceScopedClient { sandbox_template_from_response(response.template) } - /// List reusable sandbox templates in this workspace, or across all workspaces. + /// List reusable sandbox templates in this workspace. pub async fn list_sandbox_templates( &self, opts: SandboxTemplateListOptions, @@ -690,13 +706,8 @@ impl WorkspaceScopedClient { 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(), + workspace_scope: Some(proto::workspace_selector(&self.workspace)), }; async move { grpc.list_sandbox_templates(request).await } }) @@ -711,7 +722,7 @@ impl WorkspaceScopedClient { .unary(|mut grpc| { let request = proto::DeleteSandboxTemplateRequest { name: name.to_string(), - workspace: self.workspace.clone(), + workspace_scope: Some(proto::workspace_selector(&self.workspace)), }; async move { grpc.delete_sandbox_template(request).await } }) @@ -725,8 +736,8 @@ impl WorkspaceScopedClient { .client .unary(|mut grpc| { let request = proto::GetSandboxRequest { - name: name.to_string(), - workspace: self.workspace.clone(), + sandbox_name: name.to_string(), + workspace_scope: Some(proto::workspace_selector(&self.workspace)), }; async move { grpc.get_sandbox(request).await } }) @@ -743,8 +754,7 @@ impl WorkspaceScopedClient { limit: opts.limit, offset: opts.offset, label_selector: opts.label_selector.clone().unwrap_or_default(), - workspace: self.workspace.clone(), - all_workspaces: false, + workspace_scope: Some(proto::workspace_selector(&self.workspace)), }; async move { grpc.list_sandboxes(request).await } }) @@ -762,8 +772,8 @@ impl WorkspaceScopedClient { .client .unary(|mut grpc| { let request = proto::DeleteSandboxRequest { - name: name.to_string(), - workspace: self.workspace.clone(), + sandbox_name: name.to_string(), + workspace_scope: Some(proto::workspace_selector(&self.workspace)), }; async move { grpc.delete_sandbox(request).await } }) @@ -777,8 +787,8 @@ impl WorkspaceScopedClient { .client .unary(|mut grpc| { let request = proto::StopSandboxRequest { - name: name.to_string(), - workspace: self.workspace.clone(), + sandbox_name: name.to_string(), + workspace_scope: Some(proto::workspace_selector(&self.workspace)), }; async move { grpc.stop_sandbox(request).await } }) @@ -792,8 +802,8 @@ impl WorkspaceScopedClient { .client .unary(|mut grpc| { let request = proto::StartSandboxRequest { - name: name.to_string(), - workspace: self.workspace.clone(), + sandbox_name: name.to_string(), + workspace_scope: Some(proto::workspace_selector(&self.workspace)), }; async move { grpc.start_sandbox(request).await } }) @@ -856,9 +866,9 @@ impl WorkspaceScopedClient { /// Run a command inside a sandbox and buffer stdout/stderr. pub async fn exec(&self, name: &str, cmd: &[String], opts: ExecOptions) -> Result { - let sandbox = self.get_sandbox(name).await?; let request = proto::ExecSandboxRequest { - sandbox_id: sandbox.id, + sandbox_name: name.to_string(), + workspace_scope: Some(proto::workspace_selector(&self.workspace)), command: cmd.to_vec(), workdir: opts.workdir.unwrap_or_default(), environment: opts.environment, @@ -987,7 +997,7 @@ fn create_sandbox_request(spec: SandboxSpec) -> proto::CreateSandboxRequest { name: name.unwrap_or_default(), labels, annotations: HashMap::new(), - workspace: String::new(), + workspace_scope: Some(proto::workspace_selector("default")), await_main_process_attachment: false, workload_template_name: String::new(), } @@ -1016,7 +1026,7 @@ fn create_sandbox_from_template_request( name: name.unwrap_or_default(), labels, annotations: HashMap::new(), - workspace: String::new(), + workspace_scope: Some(proto::workspace_selector("default")), workload_template_name: template_name, await_main_process_attachment: false, } diff --git a/crates/openshell-sdk/src/types.rs b/crates/openshell-sdk/src/types.rs index db2944474b..12b5a72cb9 100644 --- a/crates/openshell-sdk/src/types.rs +++ b/crates/openshell-sdk/src/types.rs @@ -167,8 +167,6 @@ pub struct SandboxTemplateListOptions { 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. diff --git a/crates/openshell-sdk/tests/client_mock.rs b/crates/openshell-sdk/tests/client_mock.rs index 89cc68bf0f..abec226c6c 100644 --- a/crates/openshell-sdk/tests/client_mock.rs +++ b/crates/openshell-sdk/tests/client_mock.rs @@ -23,6 +23,22 @@ use tokio::sync::Mutex; use tokio_stream::wrappers::TcpListenerStream; use tonic::{Response, Status}; +fn selected_workspace(scope: &Option) -> Option<&str> { + match scope.as_ref()?.selection.as_ref()? { + proto::datamodel::v1::workspace_selector::Selection::Workspace(workspace) => { + Some(workspace) + } + proto::datamodel::v1::workspace_selector::Selection::AllWorkspaces(_) => None, + } +} + +fn selects_all_workspaces(scope: &Option) -> bool { + matches!( + scope.as_ref().and_then(|scope| scope.selection.as_ref()), + Some(proto::datamodel::v1::workspace_selector::Selection::AllWorkspaces(_)) + ) +} + /// Captured fixture state — what the mock observed and the canned replies it /// returned. One per test so assertions are scoped. #[derive(Default)] @@ -243,11 +259,7 @@ impl OpenShell for TestOpenShell { request: tonic::Request, ) -> Result, Status> { let request = request.into_inner(); - let workspace = if request.workspace.is_empty() { - "default" - } else { - &request.workspace - }; + let workspace = selected_workspace(&request.workspace_scope).unwrap_or("default"); let template = workload_template_proto(&request.name, workspace); *self.state.last_template_get.lock().await = Some(request); Ok(Response::new(proto::SandboxTemplateResponse { @@ -285,9 +297,9 @@ impl OpenShell for TestOpenShell { ) -> Result, Status> { let request = request.into_inner(); let sandbox = sandbox_with_phase_ws( - &request.name, + request.sandbox_name.as_str(), proto::SandboxPhase::Stopped, - &request.workspace, + selected_workspace(&request.workspace_scope).unwrap_or("default"), ); *self.state.last_stop.lock().await = Some(request); Ok(Response::new(proto::SandboxResponse { @@ -301,9 +313,9 @@ impl OpenShell for TestOpenShell { ) -> Result, Status> { let request = request.into_inner(); let sandbox = sandbox_with_phase_ws( - &request.name, + request.sandbox_name.as_str(), proto::SandboxPhase::Starting, - &request.workspace, + selected_workspace(&request.workspace_scope).unwrap_or("default"), ); *self.state.last_start.lock().await = Some(request); Ok(Response::new(proto::SandboxResponse { @@ -316,9 +328,10 @@ impl OpenShell for TestOpenShell { request: tonic::Request, ) -> Result, Status> { let req = request.into_inner(); - let name = req.name; + let name = req.sandbox_name.clone(); *self.state.last_get_name.lock().await = Some(name.clone()); - *self.state.last_get_workspace.lock().await = Some(req.workspace.clone()); + *self.state.last_get_workspace.lock().await = + selected_workspace(&req.workspace_scope).map(str::to_string); let count = self.state.get_calls.fetch_add(1, Ordering::SeqCst); if self.state.get_returns_not_found { @@ -386,8 +399,9 @@ impl OpenShell for TestOpenShell { request: tonic::Request, ) -> Result, Status> { let req = request.into_inner(); - *self.state.last_delete_name.lock().await = Some(req.name); - *self.state.last_delete_workspace.lock().await = Some(req.workspace); + *self.state.last_delete_name.lock().await = Some(req.sandbox_name.clone()); + *self.state.last_delete_workspace.lock().await = + selected_workspace(&req.workspace_scope).map(str::to_string); Ok(Response::new(proto::DeleteSandboxResponse { deleted: true, })) @@ -965,7 +979,10 @@ async fn sandbox_template_crud_uses_default_workspace() { 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!( + selected_workspace(&observed_create.workspace_scope), + Some("default") + ); assert_eq!( observed_create .template @@ -980,14 +997,16 @@ async fn sandbox_template_crud_uses_default_workspace() { 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()); + assert_eq!( + selected_workspace(&observed_get.workspace_scope), + Some("default") + ); let listed = client - .list_sandbox_templates(SandboxTemplateListOptions { + .list_sandbox_templates_all_workspaces(SandboxTemplateListOptions { limit: 10, offset: 2, label_selector: String::new(), - all_workspaces: true, }) .await .unwrap(); @@ -995,14 +1014,16 @@ async fn sandbox_template_crud_uses_default_workspace() { 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); + assert!(selects_all_workspaces(&observed_list.workspace_scope)); 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()); + assert_eq!( + selected_workspace(&observed_delete.workspace_scope), + Some("default") + ); } #[tokio::test] @@ -1083,8 +1104,8 @@ async fn stop_and_start_map_requests_and_phases() { 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()); + assert_eq!(Some(stop.sandbox_name.as_str()), Some("sleepy")); + assert_eq!(selected_workspace(&stop.workspace_scope), Some("default")); let started = client .workspace("team-a") @@ -1093,8 +1114,8 @@ async fn stop_and_start_map_requests_and_phases() { .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"); + assert_eq!(Some(start.sandbox_name.as_str()), Some("sleepy")); + assert_eq!(selected_workspace(&start.workspace_scope), Some("team-a")); } #[tokio::test] @@ -1226,7 +1247,7 @@ async fn exec_buffers_stdout_stderr_and_exit() { assert_eq!(result.stderr, b"warn\n"); let observed = state.last_exec_request.lock().await.clone().unwrap(); - assert_eq!(observed.sandbox_id, "id-my-box"); + assert_eq!(observed.sandbox_name, "my-box"); assert_eq!( observed.command, vec!["echo".to_string(), "hello".to_string()] @@ -1337,7 +1358,10 @@ async fn workspace_scoped_create_passes_workspace() { assert_eq!(result.name, "my-box"); let observed = state.last_create.lock().await.clone().unwrap(); - assert_eq!(observed.workspace, "staging"); + assert_eq!( + selected_workspace(&observed.workspace_scope), + Some("staging") + ); } #[tokio::test] @@ -1362,7 +1386,10 @@ async fn workspace_scoped_create_from_template_passes_workspace() { assert_eq!(sandbox.name, "from-template"); let observed = state.last_create.lock().await.clone().unwrap(); - assert_eq!(observed.workspace, "staging"); + assert_eq!( + selected_workspace(&observed.workspace_scope), + Some("staging") + ); assert_eq!(observed.workload_template_name, "python"); assert_eq!(observed.spec.unwrap().policy.unwrap().version, 2); } @@ -1395,8 +1422,7 @@ async fn workspace_scoped_list_passes_workspace() { assert_eq!(items.len(), 2); let observed = state.last_list_request.lock().await.clone().unwrap(); - assert_eq!(observed.workspace, "dev"); - assert!(!observed.all_workspaces); + assert_eq!(selected_workspace(&observed.workspace_scope), Some("dev")); } #[tokio::test] @@ -1410,12 +1436,18 @@ async fn workspace_scoped_sandbox_template_crud_passes_workspace() { .await .unwrap(); let observed_create = state.last_template_create.lock().await.clone().unwrap(); - assert_eq!(observed_create.workspace, "staging"); + assert_eq!( + selected_workspace(&observed_create.workspace_scope), + Some("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"); + assert_eq!( + selected_workspace(&observed_get.workspace_scope), + Some("staging") + ); let listed = ws .list_sandbox_templates(SandboxTemplateListOptions::default()) @@ -1423,24 +1455,26 @@ async fn workspace_scoped_sandbox_template_crud_passes_workspace() { .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); + assert_eq!( + selected_workspace(&observed_list.workspace_scope), + Some("staging") + ); - ws.list_sandbox_templates(SandboxTemplateListOptions { - all_workspaces: true, - ..Default::default() - }) - .await - .unwrap(); + client + .list_sandbox_templates_all_workspaces(SandboxTemplateListOptions::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); + assert!(selects_all_workspaces(&observed_all.workspace_scope)); 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"); + assert_eq!( + selected_workspace(&observed_delete.workspace_scope), + Some("staging") + ); } #[tokio::test] @@ -1470,8 +1504,7 @@ async fn list_sandboxes_all_workspaces_sets_flag() { assert_eq!(items.len(), 2); let observed = state.last_list_request.lock().await.clone().unwrap(); - assert!(observed.all_workspaces); - assert!(observed.workspace.is_empty()); + assert!(selects_all_workspaces(&observed.workspace_scope)); } // ---- Workspace CRUD tests ---- @@ -1536,7 +1569,7 @@ async fn delete_workspace_returns_ack() { } #[tokio::test] -async fn sandbox_ref_includes_workspace_field() { +async fn sandbox_result_includes_workspace_field() { let state = Arc::new(MockState { phase_sequence: vec![proto::SandboxPhase::Ready], ..Default::default() diff --git a/crates/openshell-server/src/auth/workspace_authz.rs b/crates/openshell-server/src/auth/workspace_authz.rs index e23d2287a3..2958d656c7 100644 --- a/crates/openshell-server/src/auth/workspace_authz.rs +++ b/crates/openshell-server/src/auth/workspace_authz.rs @@ -9,7 +9,10 @@ //! and workspace-level role. use super::principal::Principal; -use openshell_core::proto::WorkspaceRole as ProtoWorkspaceRole; +use openshell_core::proto::{ + WorkspaceRole as ProtoWorkspaceRole, WorkspaceSelector, + workspace_selector::Selection as WorkspaceSelection, +}; use tonic::Status; use crate::persistence::Store; @@ -39,7 +42,7 @@ impl MinWorkspaceRole { /// Result of a successful workspace authorization check. #[derive(Debug)] pub struct AuthorizedWorkspace { - /// Resolved workspace name (empty string normalized to `"default"`). + /// Explicit workspace name selected by the caller. pub workspace: String, /// How the caller was authorized. pub grant: AuthGrant, @@ -56,6 +59,75 @@ pub enum AuthGrant { Sandbox, } +/// Authorized scope for a request that supports one workspace or all workspaces. +#[derive(Debug)] +pub enum AuthorizedWorkspaceScope { + /// One explicitly named workspace. + Workspace(AuthorizedWorkspace), + /// All workspaces, authorized for a platform administrator. + AllWorkspaces, +} + +/// Authorize the required named selector on a single-workspace request. +#[allow(clippy::result_large_err)] +pub async fn authorize_workspace_selector( + store: &Store, + admin_role: &str, + principal: &Principal, + selector: Option<&WorkspaceSelector>, + min_role: MinWorkspaceRole, +) -> Result { + let workspace = selected_workspace_name(selector)?; + authorize_workspace(store, admin_role, principal, workspace, min_role).await +} + +/// Authorize a selector on a request that explicitly supports all workspaces. +#[allow(clippy::result_large_err)] +pub async fn authorize_list_workspace_selector( + store: &Store, + admin_role: &str, + principal: &Principal, + selector: Option<&WorkspaceSelector>, + min_role: MinWorkspaceRole, +) -> Result { + match selected_workspace(selector)? { + WorkspaceSelection::Workspace(workspace) => { + authorize_workspace(store, admin_role, principal, workspace, min_role) + .await + .map(AuthorizedWorkspaceScope::Workspace) + } + WorkspaceSelection::AllWorkspaces(_) => { + require_platform_admin(admin_role, principal)?; + Ok(AuthorizedWorkspaceScope::AllWorkspaces) + } + } +} + +/// Return the explicitly selected workspace name, rejecting missing, empty, +/// or all-workspaces selections. +#[allow(clippy::result_large_err)] +pub fn selected_workspace_name(selector: Option<&WorkspaceSelector>) -> Result<&str, Status> { + match selected_workspace(selector)? { + WorkspaceSelection::Workspace(workspace) => Ok(workspace), + WorkspaceSelection::AllWorkspaces(_) => Err(Status::invalid_argument( + "all_workspaces is not supported by this request", + )), + } +} + +#[allow(clippy::result_large_err)] +fn selected_workspace(selector: Option<&WorkspaceSelector>) -> Result<&WorkspaceSelection, Status> { + let selection = selector + .and_then(|selector| selector.selection.as_ref()) + .ok_or_else(|| Status::invalid_argument("workspace_scope is required"))?; + + if let WorkspaceSelection::Workspace(workspace) = selection { + crate::grpc::workspace::validate_workspace_name(workspace)?; + } + + Ok(selection) +} + /// Authorize a workspace-scoped operation for a user principal. /// /// Checks workspace membership and role. Platform admins (callers whose @@ -72,7 +144,7 @@ pub async fn authorize_workspace( workspace: &str, min_role: MinWorkspaceRole, ) -> Result { - let workspace = normalize_workspace(workspace); + let workspace = workspace.to_string(); match principal { Principal::User(user) => { @@ -186,14 +258,6 @@ fn role_satisfies(member_role: ProtoWorkspaceRole, min_role: MinWorkspaceRole) - } } -fn normalize_workspace(workspace: &str) -> String { - if workspace.is_empty() { - "default".to_string() - } else { - workspace.to_string() - } -} - #[cfg(test)] mod tests { use super::*; @@ -401,20 +465,75 @@ mod tests { } #[tokio::test] - async fn empty_workspace_normalizes_to_default() { + async fn empty_named_selector_is_rejected() { let store = test_store().await; add_member(&store, "default", "user-d", ProtoWorkspaceRole::User).await; let principal = user_principal("user-d", &["openshell-user"]); - let result = authorize_workspace( + let result = authorize_workspace_selector( &store, "openshell-admin", &principal, - "", + Some(&openshell_core::proto::workspace_selector("")), MinWorkspaceRole::User, ) .await; - assert!(result.is_ok()); - assert_eq!(result.unwrap().workspace, "default"); + let err = result.unwrap_err(); + assert_eq!(err.code(), tonic::Code::InvalidArgument); + assert_eq!(err.message(), "workspace name is required"); + } + + #[test] + fn missing_and_unset_selectors_are_rejected() { + let missing = selected_workspace_name(None).unwrap_err(); + assert_eq!(missing.code(), tonic::Code::InvalidArgument); + assert_eq!(missing.message(), "workspace_scope is required"); + + let unset = selected_workspace_name(Some(&WorkspaceSelector::default())).unwrap_err(); + assert_eq!(unset.code(), tonic::Code::InvalidArgument); + assert_eq!(unset.message(), "workspace_scope is required"); + } + + #[test] + fn all_workspaces_is_rejected_for_single_workspace_requests() { + let err = selected_workspace_name(Some(&openshell_core::proto::all_workspaces_selector())) + .unwrap_err(); + assert_eq!(err.code(), tonic::Code::InvalidArgument); + assert_eq!( + err.message(), + "all_workspaces is not supported by this request" + ); + } + + #[tokio::test] + async fn all_workspaces_requires_platform_admin() { + let store = test_store().await; + let selector = openshell_core::proto::all_workspaces_selector(); + let principal = user_principal("workspace-user", &["openshell-user"]); + let err = authorize_list_workspace_selector( + &store, + "openshell-admin", + &principal, + Some(&selector), + MinWorkspaceRole::User, + ) + .await + .unwrap_err(); + assert_eq!(err.code(), tonic::Code::PermissionDenied); + + let admin = user_principal("platform-admin", &["openshell-admin"]); + let authorized = authorize_list_workspace_selector( + &store, + "openshell-admin", + &admin, + Some(&selector), + MinWorkspaceRole::User, + ) + .await + .unwrap(); + assert!(matches!( + authorized, + AuthorizedWorkspaceScope::AllWorkspaces + )); } #[tokio::test] diff --git a/crates/openshell-server/src/grpc/auth_rpc.rs b/crates/openshell-server/src/grpc/auth_rpc.rs index 62547cc290..f6a6db0e80 100644 --- a/crates/openshell-server/src/grpc/auth_rpc.rs +++ b/crates/openshell-server/src/grpc/auth_rpc.rs @@ -91,7 +91,7 @@ pub async fn handle_issue_sandbox_token( Status::unavailable("sandbox JWT minting is not configured on this gateway") })?; - ensure_sandbox_exists(state, &sandbox.sandbox_id).await?; + let _ = ensure_sandbox_exists(state, &sandbox.sandbox_id).await?; let minted = issuer.mint(&sandbox.sandbox_id)?; info!( @@ -142,7 +142,7 @@ pub async fn handle_refresh_sandbox_token( Status::unavailable("sandbox JWT minting is not configured on this gateway") })?; - ensure_sandbox_exists(state, &sandbox.sandbox_id).await?; + let sandbox_record = ensure_sandbox_exists(state, &sandbox.sandbox_id).await?; let minted = issuer.mint(&sandbox.sandbox_id)?; let extension_credentials = if requested_extension_services.is_empty() { @@ -164,7 +164,11 @@ pub async fn handle_refresh_sandbox_token( )); } else { let mut config_request = Request::new(GetSandboxConfigRequest { - sandbox_id: sandbox.sandbox_id.clone(), + sandbox_name: sandbox_record + .metadata + .as_ref() + .map_or_else(String::new, |metadata| metadata.name.clone()), + workspace_scope: None, }); config_request .extensions_mut() @@ -263,7 +267,10 @@ fn mint_extension_credentials( .collect() } -async fn ensure_sandbox_exists(state: &Arc, sandbox_id: &str) -> Result<(), Status> { +async fn ensure_sandbox_exists( + state: &Arc, + sandbox_id: &str, +) -> Result { if sandbox_id.is_empty() { return Err(Status::invalid_argument("sandbox_id is required")); } @@ -273,9 +280,7 @@ async fn ensure_sandbox_exists(state: &Arc, sandbox_id: &str) -> Re .get_message::(sandbox_id) .await .map_err(|e| Status::internal(format!("fetch sandbox failed: {e}")))? - .ok_or_else(|| Status::not_found("sandbox not found"))?; - - Ok(()) + .ok_or_else(|| Status::not_found("sandbox not found")) } #[cfg(test)] diff --git a/crates/openshell-server/src/grpc/policy.rs b/crates/openshell-server/src/grpc/policy.rs index 054a3c34c6..6dd50dee6a 100644 --- a/crates/openshell-server/src/grpc/policy.rs +++ b/crates/openshell-server/src/grpc/policy.rs @@ -12,9 +12,7 @@ use crate::ServerState; use crate::auth::principal::Principal; -use crate::auth::workspace_authz::{ - MinWorkspaceRole, authorize_sandbox_workspace, authorize_workspace, require_platform_admin, -}; +use crate::auth::workspace_authz::{MinWorkspaceRole, require_platform_admin}; use crate::persistence::{ DraftChunkRecord, ObjectId, ObjectName, ObjectType, ObjectWorkspace, PolicyRecord, Store, }; @@ -2212,7 +2210,7 @@ fn validate_sandbox_caller_update(req: &UpdateConfigRequest) -> Result<(), Statu "sandbox callers cannot delete settings", )); } - if req.name.trim().is_empty() { + if req.sandbox_name.is_empty() { return Err(Status::permission_denied( "sandbox callers may only perform sandbox policy sync", )); @@ -2361,12 +2359,16 @@ pub(super) async fn handle_get_sandbox_config( request: Request, ) -> Result, Status> { let principal = super::extract_principal(&request)?; - let sandbox_id = request.get_ref().sandbox_id.clone(); - crate::auth::guard::enforce_sandbox_scope(&request, &sandbox_id)?; - drop(request); - - let sandbox = - super::sandbox::fetch_and_authorize_sandbox(state, &principal, &sandbox_id).await?; + let req = request.into_inner(); + let sandbox = super::sandbox::resolve_and_authorize_sandbox_name( + state, + &principal, + &req.sandbox_name, + req.workspace_scope.as_ref(), + MinWorkspaceRole::User, + ) + .await?; + let sandbox_id = sandbox.object_id().to_string(); let workspace = sandbox.object_workspace().to_string(); let sandbox_provider_names = sandbox .spec @@ -3236,36 +3238,36 @@ async fn handle_update_config_inner( ) -> Result, Status> { let req = request.into_inner(); validate_annotations(&req.annotations, "annotations")?; - let workspace = if req.global { + let sandbox = if req.global { + if !req.sandbox_name.is_empty() || req.workspace_scope.is_some() { + return Err(Status::invalid_argument( + "sandbox_name and workspace_scope must be omitted when global is true", + )); + } require_platform_admin(&state.admin_role, principal)?; - String::new() + None } else { let min_role = if sandbox_caller { MinWorkspaceRole::User } else { MinWorkspaceRole::Admin }; - authorize_sandbox_workspace( - &state.store, - &state.admin_role, - principal, - &req.workspace, - min_role, + Some( + super::sandbox::resolve_and_authorize_sandbox_name( + state, + principal, + &req.sandbox_name, + req.workspace_scope.as_ref(), + min_role, + ) + .await?, ) - .await?; - super::workspace::resolve_workspace(state.store.as_ref(), &req.workspace) - .await? - .name }; + let workspace = sandbox.as_ref().map_or_else(String::new, |sandbox| { + sandbox.object_workspace().to_string() + }); if sandbox_caller { validate_sandbox_caller_update(&req)?; - resolve_sandbox_by_name_for_principal( - state.store.as_ref(), - &workspace, - principal, - &req.name, - ) - .await?; } let key = req.setting_key.trim(); let has_policy = req.policy.is_some(); @@ -3469,19 +3471,7 @@ async fn handle_update_config_inner( )); } - if req.name.is_empty() { - return Err(Status::invalid_argument( - "name is required for sandbox-scoped updates", - )); - } - - // Resolve sandbox by name. - let sandbox = state - .store - .get_message_by_name::(&workspace, &req.name) - .await - .map_err(|e| Status::internal(format!("fetch sandbox failed: {e}")))? - .ok_or_else(|| Status::not_found("sandbox not found"))?; + let sandbox = sandbox.expect("non-global config update resolves a sandbox"); let sandbox_id = sandbox.object_id().to_string(); let mut response_annotations = sandbox_metadata_annotations(&sandbox); @@ -3918,35 +3908,31 @@ pub(super) async fn handle_get_sandbox_policy_status( ) -> Result, Status> { let principal = super::extract_principal(&request)?; let req = request.into_inner(); - let workspace = if req.global { + let sandbox = if req.global { + if !req.sandbox_name.is_empty() || req.workspace_scope.is_some() { + return Err(Status::invalid_argument( + "sandbox_name and workspace_scope must be omitted when global is true", + )); + } require_platform_admin(&state.admin_role, &principal)?; - String::new() + None } else { - let authz = authorize_workspace( - &state.store, - &state.admin_role, - &principal, - &req.workspace, - MinWorkspaceRole::User, + Some( + super::sandbox::resolve_and_authorize_sandbox_name( + state, + &principal, + &req.sandbox_name, + req.workspace_scope.as_ref(), + MinWorkspaceRole::User, + ) + .await?, ) - .await?; - super::workspace::resolve_workspace(state.store.as_ref(), &authz.workspace) - .await? - .name }; let (policy_id, active_version) = if req.global { (GLOBAL_POLICY_SANDBOX_ID.to_string(), 0_u32) } else { - if req.name.is_empty() { - return Err(Status::invalid_argument("name is required")); - } - let sandbox = state - .store - .get_message_by_name::(&workspace, &req.name) - .await - .map_err(|e| Status::internal(format!("fetch sandbox failed: {e}")))? - .ok_or_else(|| Status::not_found("sandbox not found"))?; + let sandbox = sandbox.as_ref().expect("sandbox query resolved a sandbox"); ( sandbox.object_id().to_string(), sandbox.current_policy_version(), @@ -3986,35 +3972,31 @@ pub(super) async fn handle_list_sandbox_policies( ) -> Result, Status> { let principal = super::extract_principal(&request)?; let req = request.into_inner(); - let workspace = if req.global { + let sandbox = if req.global { + if !req.sandbox_name.is_empty() || req.workspace_scope.is_some() { + return Err(Status::invalid_argument( + "sandbox_name and workspace_scope must be omitted when global is true", + )); + } require_platform_admin(&state.admin_role, &principal)?; - String::new() + None } else { - let authz = authorize_workspace( - &state.store, - &state.admin_role, - &principal, - &req.workspace, - MinWorkspaceRole::User, + Some( + super::sandbox::resolve_and_authorize_sandbox_name( + state, + &principal, + &req.sandbox_name, + req.workspace_scope.as_ref(), + MinWorkspaceRole::User, + ) + .await?, ) - .await?; - super::workspace::resolve_workspace(state.store.as_ref(), &authz.workspace) - .await? - .name }; let policy_id = if req.global { GLOBAL_POLICY_SANDBOX_ID.to_string() } else { - if req.name.is_empty() { - return Err(Status::invalid_argument("name is required")); - } - let sandbox = state - .store - .get_message_by_name::(&workspace, &req.name) - .await - .map_err(|e| Status::internal(format!("fetch sandbox failed: {e}")))? - .ok_or_else(|| Status::not_found("sandbox not found"))?; + let sandbox = sandbox.as_ref().expect("sandbox query resolved a sandbox"); sandbox.object_id().to_string() }; @@ -4123,14 +4105,18 @@ pub(super) async fn handle_get_sandbox_logs( ) -> Result, Status> { let principal = super::extract_principal(&request)?; let req = request.into_inner(); - if req.sandbox_id.is_empty() { - return Err(Status::invalid_argument("sandbox_id is required")); - } - let _sandbox = - super::sandbox::fetch_and_authorize_sandbox(state, &principal, &req.sandbox_id).await?; + let sandbox = super::sandbox::resolve_and_authorize_sandbox_name( + state, + &principal, + &req.sandbox_name, + req.workspace_scope.as_ref(), + MinWorkspaceRole::User, + ) + .await?; + let sandbox_id = sandbox.object_id(); let lines = if req.lines == 0 { 2000 } else { req.lines }; - let tail = state.tracing_log_bus.tail(&req.sandbox_id, lines as usize); + let tail = state.tracing_log_bus.tail(sandbox_id, lines as usize); let buffer_total = tail.len() as u32; @@ -4658,28 +4644,14 @@ pub(super) async fn handle_get_draft_policy( .cloned() .ok_or_else(|| Status::unauthenticated("missing principal"))?; let req = request.into_inner(); - authorize_sandbox_workspace( - &state.store, - &state.admin_role, + let sandbox = super::sandbox::resolve_and_authorize_sandbox_name( + state, &principal, - &req.workspace, + &req.sandbox_name, + req.workspace_scope.as_ref(), MinWorkspaceRole::User, ) .await?; - let workspace = super::workspace::resolve_workspace(state.store.as_ref(), &req.workspace) - .await? - .name; - if req.name.is_empty() { - return Err(Status::invalid_argument("name is required")); - } - - let sandbox = resolve_sandbox_by_name_for_principal( - state.store.as_ref(), - &workspace, - &principal, - &req.name, - ) - .await?; let sandbox_id = sandbox.object_id().to_string(); let status_filter = if req.status_filter.is_empty() { @@ -4739,32 +4711,21 @@ async fn handle_approve_draft_chunk_inner( ) -> Result, Status> { let principal = super::extract_principal(&request)?; let req = request.into_inner(); - let authz = authorize_workspace( - &state.store, - &state.admin_role, + let sandbox = super::sandbox::resolve_and_authorize_sandbox_name( + state, &principal, - &req.workspace, + &req.sandbox_name, + req.workspace_scope.as_ref(), MinWorkspaceRole::Admin, ) .await?; - let workspace = super::workspace::resolve_workspace(state.store.as_ref(), &authz.workspace) - .await? - .name; - if req.name.is_empty() { - return Err(Status::invalid_argument("name is required")); - } + let workspace = sandbox.object_workspace().to_string(); if req.chunk_id.is_empty() { return Err(Status::invalid_argument("chunk_id is required")); } require_no_global_policy(state).await?; - let sandbox = state - .store - .get_message_by_name::(&workspace, &req.name) - .await - .map_err(|e| Status::internal(format!("fetch sandbox failed: {e}")))? - .ok_or_else(|| Status::not_found("sandbox not found"))?; let sandbox_id = sandbox.object_id().to_string(); let chunk = state @@ -4894,30 +4855,19 @@ async fn handle_reject_draft_chunk_inner( ) -> Result, Status> { let principal = super::extract_principal(&request)?; let req = request.into_inner(); - let authz = authorize_workspace( - &state.store, - &state.admin_role, + let sandbox = super::sandbox::resolve_and_authorize_sandbox_name( + state, &principal, - &req.workspace, + &req.sandbox_name, + req.workspace_scope.as_ref(), MinWorkspaceRole::Admin, ) .await?; - let workspace = super::workspace::resolve_workspace(state.store.as_ref(), &authz.workspace) - .await? - .name; - if req.name.is_empty() { - return Err(Status::invalid_argument("name is required")); - } + let workspace = sandbox.object_workspace().to_string(); if req.chunk_id.is_empty() { return Err(Status::invalid_argument("chunk_id is required")); } - let sandbox = state - .store - .get_message_by_name::(&workspace, &req.name) - .await - .map_err(|e| Status::internal(format!("fetch sandbox failed: {e}")))? - .ok_or_else(|| Status::not_found("sandbox not found"))?; let sandbox_id = sandbox.object_id().to_string(); let chunk = state @@ -5004,29 +4954,18 @@ async fn handle_approve_all_draft_chunks_inner( ) -> Result, Status> { let principal = super::extract_principal(&request)?; let req = request.into_inner(); - let authz = authorize_workspace( - &state.store, - &state.admin_role, + let sandbox = super::sandbox::resolve_and_authorize_sandbox_name( + state, &principal, - &req.workspace, + &req.sandbox_name, + req.workspace_scope.as_ref(), MinWorkspaceRole::Admin, ) .await?; - let workspace = super::workspace::resolve_workspace(state.store.as_ref(), &authz.workspace) - .await? - .name; - if req.name.is_empty() { - return Err(Status::invalid_argument("name is required")); - } + let workspace = sandbox.object_workspace().to_string(); require_no_global_policy(state).await?; - let sandbox = state - .store - .get_message_by_name::(&workspace, &req.name) - .await - .map_err(|e| Status::internal(format!("fetch sandbox failed: {e}")))? - .ok_or_else(|| Status::not_found("sandbox not found"))?; let sandbox_id = sandbox.object_id().to_string(); let pending_chunks = state @@ -5312,20 +5251,15 @@ pub(super) async fn handle_edit_draft_chunk( ) -> Result, Status> { let principal = super::extract_principal(&request)?; let req = request.into_inner(); - let authz = authorize_workspace( - &state.store, - &state.admin_role, + let sandbox = super::sandbox::resolve_and_authorize_sandbox_name( + state, &principal, - &req.workspace, + &req.sandbox_name, + req.workspace_scope.as_ref(), MinWorkspaceRole::Admin, ) .await?; - let workspace = super::workspace::resolve_workspace(state.store.as_ref(), &authz.workspace) - .await? - .name; - if req.name.is_empty() { - return Err(Status::invalid_argument("name is required")); - } + let workspace = sandbox.object_workspace().to_string(); if req.chunk_id.is_empty() { return Err(Status::invalid_argument("chunk_id is required")); } @@ -5333,12 +5267,6 @@ pub(super) async fn handle_edit_draft_chunk( .proposed_rule .ok_or_else(|| Status::invalid_argument("proposed_rule is required"))?; - let sandbox = state - .store - .get_message_by_name::(&workspace, &req.name) - .await - .map_err(|e| Status::internal(format!("fetch sandbox failed: {e}")))? - .ok_or_else(|| Status::not_found("sandbox not found"))?; let sandbox_id = sandbox.object_id().to_string(); let chunk = state @@ -5393,30 +5321,19 @@ async fn handle_undo_draft_chunk_inner( ) -> Result, Status> { let principal = super::extract_principal(&request)?; let req = request.into_inner(); - let authz = authorize_workspace( - &state.store, - &state.admin_role, + let sandbox = super::sandbox::resolve_and_authorize_sandbox_name( + state, &principal, - &req.workspace, + &req.sandbox_name, + req.workspace_scope.as_ref(), MinWorkspaceRole::Admin, ) .await?; - let workspace = super::workspace::resolve_workspace(state.store.as_ref(), &authz.workspace) - .await? - .name; - if req.name.is_empty() { - return Err(Status::invalid_argument("name is required")); - } + let workspace = sandbox.object_workspace().to_string(); if req.chunk_id.is_empty() { return Err(Status::invalid_argument("chunk_id is required")); } - let sandbox = state - .store - .get_message_by_name::(&workspace, &req.name) - .await - .map_err(|e| Status::internal(format!("fetch sandbox failed: {e}")))? - .ok_or_else(|| Status::not_found("sandbox not found"))?; let sandbox_id = sandbox.object_id().to_string(); let chunk = state @@ -5490,27 +5407,14 @@ pub(super) async fn handle_clear_draft_chunks( ) -> Result, Status> { let principal = super::extract_principal(&request)?; let req = request.into_inner(); - let authz = authorize_workspace( - &state.store, - &state.admin_role, + let sandbox = super::sandbox::resolve_and_authorize_sandbox_name( + state, &principal, - &req.workspace, + &req.sandbox_name, + req.workspace_scope.as_ref(), MinWorkspaceRole::Admin, ) .await?; - let workspace = super::workspace::resolve_workspace(state.store.as_ref(), &authz.workspace) - .await? - .name; - if req.name.is_empty() { - return Err(Status::invalid_argument("name is required")); - } - - let sandbox = state - .store - .get_message_by_name::(&workspace, &req.name) - .await - .map_err(|e| Status::internal(format!("fetch sandbox failed: {e}")))? - .ok_or_else(|| Status::not_found("sandbox not found"))?; let sandbox_id = sandbox.object_id().to_string(); let deleted = state @@ -5538,27 +5442,14 @@ pub(super) async fn handle_get_draft_history( ) -> Result, Status> { let principal = super::extract_principal(&request)?; let req = request.into_inner(); - let authz = authorize_workspace( - &state.store, - &state.admin_role, + let sandbox = super::sandbox::resolve_and_authorize_sandbox_name( + state, &principal, - &req.workspace, + &req.sandbox_name, + req.workspace_scope.as_ref(), MinWorkspaceRole::User, ) .await?; - let workspace = super::workspace::resolve_workspace(state.store.as_ref(), &authz.workspace) - .await? - .name; - if req.name.is_empty() { - return Err(Status::invalid_argument("name is required")); - } - - let sandbox = state - .store - .get_message_by_name::(&workspace, &req.name) - .await - .map_err(|e| Status::internal(format!("fetch sandbox failed: {e}")))? - .ok_or_else(|| Status::not_found("sandbox not found"))?; let sandbox_id = sandbox.object_id().to_string(); let all_chunks = state @@ -7254,7 +7145,8 @@ mod tests { &state, with_sandbox( Request::new(GetSandboxConfigRequest { - sandbox_id: sandbox_id.clone(), + sandbox_name: sandbox_id.clone(), + workspace_scope: None, }), &sandbox_id, ), @@ -7301,7 +7193,8 @@ mod tests { &state, with_sandbox( Request::new(GetSandboxConfigRequest { - sandbox_id: sandbox_id.to_string(), + sandbox_name: sandbox_id.to_string(), + workspace_scope: None, }), sandbox_id, ), @@ -7355,7 +7248,8 @@ mod tests { &state, with_sandbox( Request::new(GetSandboxConfigRequest { - sandbox_id: sandbox_id.clone(), + sandbox_name: sandbox_id.clone(), + workspace_scope: None, }), &sandbox_id, ), @@ -7501,7 +7395,8 @@ mod tests { &state, with_sandbox( Request::new(GetSandboxConfigRequest { - sandbox_id: sandbox_id.to_string(), + sandbox_name: sandbox_id.to_string(), + workspace_scope: None, }), sandbox_id, ), @@ -7533,7 +7428,8 @@ mod tests { let detail_error = handle_get_sandbox_policy_status( &state, with_user(Request::new(GetSandboxPolicyStatusRequest { - name: "stored-invalid-history".to_string(), + sandbox_name: "stored-invalid-history".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector("default")), version: 2, ..Default::default() })), @@ -7550,7 +7446,8 @@ mod tests { let listed = handle_list_sandbox_policies( &state, with_user(Request::new(ListSandboxPoliciesRequest { - name: "stored-invalid-history".to_string(), + sandbox_name: "stored-invalid-history".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector("default")), limit: 10, ..Default::default() })), @@ -7603,7 +7500,8 @@ mod tests { &state, with_sandbox( Request::new(GetSandboxConfigRequest { - sandbox_id: sandbox_id.to_string(), + sandbox_name: sandbox_id.to_string(), + workspace_scope: None, }), sandbox_id, ), @@ -7659,7 +7557,8 @@ mod tests { &state, with_sandbox( Request::new(GetSandboxConfigRequest { - sandbox_id: sandbox_id.to_string(), + sandbox_name: sandbox_id.to_string(), + workspace_scope: None, }), sandbox_id, ), @@ -7912,7 +7811,8 @@ mod tests { let response = handle_update_config( &state, with_user(Request::new(UpdateConfigRequest { - name: sandbox_name, + sandbox_name: sandbox_name.clone(), + workspace_scope: Some(openshell_core::proto::workspace_selector("default")), policy: Some(mcp_policy_with_versions(&["2025-11-25"])), ..Default::default() })), @@ -8018,7 +7918,8 @@ mod tests { let response = handle_update_config( &state, with_user(Request::new(UpdateConfigRequest { - name: sandbox_name.to_string(), + sandbox_name: sandbox_name.to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector("default")), policy: Some(candidate.clone()), ..Default::default() })), @@ -8076,7 +7977,8 @@ mod tests { let response = handle_update_config( &state, with_user(Request::new(UpdateConfigRequest { - name: sandbox_name.to_string(), + sandbox_name: sandbox_name.to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector("default")), policy: Some(candidate.clone()), ..Default::default() })), @@ -8404,7 +8306,8 @@ mod tests { #[test] fn sandbox_caller_update_validation_allows_sandbox_policy_sync() { let req = UpdateConfigRequest { - name: "sandbox-1".to_string(), + sandbox_name: "sandbox-1".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector("default")), policy: Some(ProtoSandboxPolicy::default()), ..Default::default() }; @@ -8425,7 +8328,8 @@ mod tests { #[test] fn sandbox_caller_update_validation_rejects_setting_mutation() { let req = UpdateConfigRequest { - name: "sandbox-1".to_string(), + sandbox_name: "sandbox-1".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector("default")), setting_key: "inference.model".to_string(), setting_value: Some(SettingValue { value: None }), ..Default::default() @@ -8507,8 +8411,8 @@ mod tests { let error = handle_get_sandbox_logs( &state, with_user(Request::new(GetSandboxLogsRequest { - sandbox_id: "sandbox-b-id".to_string(), - workspace: "default".to_string(), + sandbox_name: "sandbox-b".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector("default")), ..GetSandboxLogsRequest::default() })), ) @@ -8594,6 +8498,50 @@ mod tests { ); } + #[tokio::test] + async fn global_policy_requests_reject_workspace_selectors() { + let state = test_server_state().await; + + let update_error = handle_update_config( + &state, + authed_request(UpdateConfigRequest { + global: true, + sandbox_name: String::new(), + workspace_scope: Some(openshell_core::proto::workspace_selector("default")), + ..Default::default() + }), + ) + .await + .unwrap_err(); + assert_eq!(update_error.code(), Code::InvalidArgument); + + let get_error = handle_get_sandbox_policy_status( + &state, + authed_request(GetSandboxPolicyStatusRequest { + global: true, + sandbox_name: String::new(), + workspace_scope: Some(openshell_core::proto::workspace_selector("default")), + ..Default::default() + }), + ) + .await + .unwrap_err(); + assert_eq!(get_error.code(), Code::InvalidArgument); + + let list_error = handle_list_sandbox_policies( + &state, + authed_request(ListSandboxPoliciesRequest { + global: true, + sandbox_name: String::new(), + workspace_scope: Some(openshell_core::proto::workspace_selector("default")), + ..Default::default() + }), + ) + .await + .unwrap_err(); + assert_eq!(list_error.code(), Code::InvalidArgument); + } + #[tokio::test] async fn update_config_rejects_missing_principal() { let state = test_server_state().await; @@ -8774,7 +8722,8 @@ mod tests { } let req = with_sandbox( Request::new(GetSandboxConfigRequest { - sandbox_id: "sb-b".to_string(), + sandbox_name: "sb-b".to_string(), + workspace_scope: None, }), "sb-a", ); @@ -8784,6 +8733,24 @@ mod tests { assert_eq!(err.code(), Code::PermissionDenied); } + #[tokio::test] + async fn missing_sandbox_get_sandbox_config_matches_foreign_sandbox_denial() { + let state = test_server_state().await; + let req = with_sandbox( + Request::new(GetSandboxConfigRequest { + sandbox_name: "missing-sandbox".to_string(), + workspace_scope: None, + }), + "sb-a", + ); + + let err = handle_get_sandbox_config(&state, req) + .await + .expect_err("missing sandbox must not be distinguishable from a foreign sandbox"); + assert_eq!(err.code(), Code::PermissionDenied); + assert_eq!(err.message(), "sandbox not found or not owned by caller"); + } + #[tokio::test] async fn same_sandbox_get_sandbox_config_allowed() { use openshell_core::proto::{SandboxPhase, SandboxSpec}; @@ -8809,7 +8776,8 @@ mod tests { state.store.put_message(&sandbox).await.unwrap(); let req = with_sandbox( Request::new(GetSandboxConfigRequest { - sandbox_id: "sb-self".to_string(), + sandbox_name: "self".to_string(), + workspace_scope: None, }), "sb-self", ); @@ -8883,9 +8851,11 @@ mod tests { } let req = with_sandbox( Request::new(GetDraftPolicyRequest { - name: "sandbox-b".to_string(), + sandbox_name: "sandbox-b".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), status_filter: String::new(), - workspace: "default".to_string(), }), "sb-a", ); @@ -8896,11 +8866,12 @@ mod tests { } #[tokio::test] - async fn sandbox_update_config_missing_name_returns_permission_denied() { + async fn sandbox_update_config_missing_name_matches_foreign_sandbox_denial() { let state = test_server_state().await; let req = with_sandbox( Request::new(UpdateConfigRequest { - name: "missing-sandbox".to_string(), + sandbox_name: "missing-sandbox".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector("default")), policy: Some(ProtoSandboxPolicy::default()), ..Default::default() }), @@ -8931,13 +8902,15 @@ mod tests { } #[tokio::test] - async fn sandbox_get_draft_policy_missing_name_returns_permission_denied() { + async fn sandbox_get_draft_policy_missing_name_matches_foreign_sandbox_denial() { let state = test_server_state().await; let req = with_sandbox( Request::new(GetDraftPolicyRequest { - name: "missing-sandbox".to_string(), + sandbox_name: "missing-sandbox".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), status_filter: String::new(), - workspace: "default".to_string(), }), "sb-a", ); @@ -8973,7 +8946,8 @@ mod tests { sandbox.set_phase(SandboxPhase::Provisioning as i32); state.store.put_message(&sandbox).await.unwrap(); let req = with_user(Request::new(GetSandboxConfigRequest { - sandbox_id: "sb-x".to_string(), + sandbox_name: "x".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector("default")), })); handle_get_sandbox_config(&state, req) .await @@ -9190,10 +9164,19 @@ mod tests { } async fn get_sandbox_policy(state: &Arc, sandbox_id: &str) -> ProtoSandboxPolicy { + let sandbox = state + .store + .get_message::(sandbox_id) + .await + .expect("sandbox lookup") + .expect("sandbox exists"); handle_get_sandbox_config( state, with_user(Request::new(GetSandboxConfigRequest { - sandbox_id: sandbox_id.to_string(), + sandbox_name: sandbox.object_name().to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + sandbox.object_workspace(), + )), })), ) .await @@ -9307,7 +9290,8 @@ mod tests { let first = handle_get_sandbox_config( &state, with_user(Request::new(GetSandboxConfigRequest { - sandbox_id: "sb-snapshot-consistency".to_string(), + sandbox_name: "snapshot-consistency".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector("default")), })), ) .await @@ -9329,7 +9313,8 @@ mod tests { let second = handle_get_sandbox_config( &state, with_user(Request::new(GetSandboxConfigRequest { - sandbox_id: "sb-snapshot-consistency".to_string(), + sandbox_name: "snapshot-consistency".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector("default")), })), ) .await @@ -9932,7 +9917,8 @@ mod tests { let response = handle_get_sandbox_config( &state, with_user(Request::new(GetSandboxConfigRequest { - sandbox_id: "sb-mcp-default-composed".to_string(), + sandbox_name: "mcp-default-composed".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector("default")), })), ) .await @@ -9995,8 +9981,10 @@ mod tests { let error = handle_update_config( &state, with_user(Request::new(UpdateConfigRequest { - name: "ambiguous-update".to_string(), - workspace: "default".to_string(), + sandbox_name: "ambiguous-update".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), policy: Some(test_ambiguous_policy()), ..Default::default() })), @@ -10032,8 +10020,10 @@ mod tests { let error = handle_update_config( &state, with_user(Request::new(UpdateConfigRequest { - name: "unattached-binding".to_string(), - workspace: "default".to_string(), + sandbox_name: "unattached-binding".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), policy: Some(test_policy_with_credential_binding( "cloud", "api.cloud.example", @@ -10077,8 +10067,10 @@ mod tests { let error = handle_update_config( &state, with_user(Request::new(UpdateConfigRequest { - name: "double-binding".to_string(), - workspace: "default".to_string(), + sandbox_name: "double-binding".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), policy: Some(test_policy_with_credential_binding( "cloud", "api.cloud.example", @@ -10147,8 +10139,10 @@ mod tests { let l4_error = handle_update_config( &state, with_user(Request::new(UpdateConfigRequest { - name: "endpointless-gating".to_string(), - workspace: "default".to_string(), + sandbox_name: "endpointless-gating".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), policy: Some(l4.clone()), ..Default::default() })), @@ -10170,8 +10164,10 @@ mod tests { let tls_error = handle_update_config( &state, with_user(Request::new(UpdateConfigRequest { - name: "endpointless-gating".to_string(), - workspace: "default".to_string(), + sandbox_name: "endpointless-gating".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), policy: Some(tls_skip.clone()), ..Default::default() })), @@ -10184,8 +10180,10 @@ mod tests { let merge_l4_error = handle_update_config( &state, with_user(Request::new(UpdateConfigRequest { - name: "endpointless-gating".to_string(), - workspace: "default".to_string(), + sandbox_name: "endpointless-gating".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), merge_operations: vec![add_bound_rule(&l4)], ..Default::default() })), @@ -10198,8 +10196,10 @@ mod tests { let merge_tls_error = handle_update_config( &state, with_user(Request::new(UpdateConfigRequest { - name: "endpointless-gating".to_string(), - workspace: "default".to_string(), + sandbox_name: "endpointless-gating".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), merge_operations: vec![add_bound_rule(&tls_skip)], ..Default::default() })), @@ -10229,8 +10229,10 @@ mod tests { handle_update_config( &state, with_user(Request::new(UpdateConfigRequest { - name: "endpointless-gating".to_string(), - workspace: "default".to_string(), + sandbox_name: "endpointless-gating".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), merge_operations: vec![add_bound_rule(&opted_in)], ..Default::default() })), @@ -10262,8 +10264,10 @@ mod tests { let error = handle_update_config( &state, with_user(Request::new(UpdateConfigRequest { - name: "signing-no-source".to_string(), - workspace: "default".to_string(), + sandbox_name: "signing-no-source".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), policy: Some(test_sigv4_policy("s3.amazonaws.com", None)), ..Default::default() })), @@ -10308,8 +10312,10 @@ mod tests { let error = handle_update_config( &state, with_user(Request::new(UpdateConfigRequest { - name: "signing-unbound-aws".to_string(), - workspace: "default".to_string(), + sandbox_name: "signing-unbound-aws".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), policy: Some(test_sigv4_policy("s3.amazonaws.com", None)), ..Default::default() })), @@ -10345,8 +10351,10 @@ mod tests { handle_update_config( &state, with_user(Request::new(UpdateConfigRequest { - name: "signing-bound-aws".to_string(), - workspace: "default".to_string(), + sandbox_name: "signing-bound-aws".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), policy: Some(test_sigv4_policy("s3.amazonaws.com", Some("aws-prod"))), ..Default::default() })), @@ -10389,8 +10397,10 @@ mod tests { handle_update_config( &state, with_user(Request::new(UpdateConfigRequest { - name: "signing-profile-endpoint".to_string(), - workspace: "default".to_string(), + sandbox_name: "signing-profile-endpoint".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), policy: Some(policy), ..Default::default() })), @@ -10419,8 +10429,10 @@ mod tests { let error = handle_update_config( &state, with_user(Request::new(UpdateConfigRequest { - name: "signing-profile-mismatch".to_string(), - workspace: "default".to_string(), + sandbox_name: "signing-profile-mismatch".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), policy: Some(test_sigv4_policy("api.example.com", None)), ..Default::default() })), @@ -10528,9 +10540,11 @@ mod tests { &state, authed_request(openshell_core::proto::AttachSandboxProviderRequest { sandbox_name: "provider-ambiguity".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), provider_name: "candidate-provider".to_string(), expected_resource_version: 0, - workspace: "default".to_string(), }), ) .await @@ -10616,7 +10630,8 @@ mod tests { let error = handle_get_sandbox_config( &state, with_user(Request::new(GetSandboxConfigRequest { - sandbox_id: "sb-invalid-composed-policy".to_string(), + sandbox_name: "invalid-composed-policy".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector("default")), })), ) .await @@ -11108,7 +11123,8 @@ mod tests { let config = handle_get_sandbox_config( &state, with_user(Request::new(GetSandboxConfigRequest { - sandbox_id: "sb-policy-binding".to_string(), + sandbox_name: "policy-binding".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector("default")), })), ) .await @@ -11157,8 +11173,10 @@ mod tests { handle_update_config( &state, with_user(Request::new(UpdateConfigRequest { - name: "policy-binding".to_string(), - workspace: "default".to_string(), + sandbox_name: "policy-binding".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), policy: Some(next_policy.clone()), ..Default::default() })), @@ -11168,7 +11186,8 @@ mod tests { let next_config = handle_get_sandbox_config( &state, with_user(Request::new(GetSandboxConfigRequest { - sandbox_id: "sb-policy-binding".to_string(), + sandbox_name: "policy-binding".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector("default")), })), ) .await @@ -11212,8 +11231,10 @@ mod tests { handle_update_config( &state, with_user(Request::new(UpdateConfigRequest { - name: "policy-binding".to_string(), - workspace: "default".to_string(), + sandbox_name: "policy-binding".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), policy: Some(unbound_policy), ..Default::default() })), @@ -11223,7 +11244,8 @@ mod tests { let unbound_config = handle_get_sandbox_config( &state, with_user(Request::new(GetSandboxConfigRequest { - sandbox_id: "sb-policy-binding".to_string(), + sandbox_name: "policy-binding".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector("default")), })), ) .await @@ -12001,9 +12023,11 @@ mod tests { &state, with_user(Request::new(AttachSandboxProviderRequest { sandbox_name: "attach-lifecycle".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), provider_name: "work-github".to_string(), expected_resource_version: 0, - workspace: "default".to_string(), })), ) .await @@ -12039,9 +12063,11 @@ mod tests { &state, authed_request(DetachSandboxProviderRequest { sandbox_name: "attach-lifecycle".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), provider_name: "work-github".to_string(), expected_resource_version: 0, - workspace: "default".to_string(), }), ) .await @@ -12171,9 +12197,11 @@ mod tests { &state, with_user(Request::new(AttachSandboxProviderRequest { sandbox_name: "attach-lifecycle".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), provider_name: "work-custom".to_string(), expected_resource_version: 0, - workspace: "default".to_string(), })), ) .await @@ -12212,9 +12240,11 @@ mod tests { &state, authed_request(DetachSandboxProviderRequest { sandbox_name: "attach-lifecycle".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), provider_name: "work-custom".to_string(), expected_resource_version: 0, - workspace: "default".to_string(), }), ) .await @@ -12326,7 +12356,8 @@ mod tests { let response = handle_get_sandbox_config( &state, with_user(Request::new(GetSandboxConfigRequest { - sandbox_id: "sb-global-profile".to_string(), + sandbox_name: "global-profile-sandbox".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector("default")), })), ) .await @@ -12520,8 +12551,10 @@ mod tests { let approved = handle_approve_all_draft_chunks( &state, with_user(Request::new(ApproveAllDraftChunksRequest { - name: sandbox_name.to_string(), - workspace: "default".to_string(), + sandbox_name: sandbox_name.to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), approvals: chunks .iter() .map(|chunk| openshell_core::proto::DraftChunkApproval { @@ -12647,8 +12680,10 @@ mod tests { let approved = handle_approve_all_draft_chunks( &state, with_user(Request::new(ApproveAllDraftChunksRequest { - name: sandbox_name.to_string(), - workspace: "default".to_string(), + sandbox_name: sandbox_name.to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), approvals: chunks .iter() .map(|chunk| openshell_core::proto::DraftChunkApproval { @@ -12848,9 +12883,11 @@ mod tests { let skipped = handle_approve_all_draft_chunks( &state, with_user(Request::new(ApproveAllDraftChunksRequest { - name: sandbox_name.to_string(), + sandbox_name: sandbox_name.to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".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(), @@ -12876,9 +12913,11 @@ mod tests { let approved = handle_approve_all_draft_chunks( &state, with_user(Request::new(ApproveAllDraftChunksRequest { - name: sandbox_name.to_string(), + sandbox_name: sandbox_name.to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".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(), @@ -12953,10 +12992,12 @@ mod tests { handle_edit_draft_chunk( &state, with_user(Request::new(EditDraftChunkRequest { - name: sandbox_name.to_string(), + sandbox_name: sandbox_name.to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), chunk_id: chunk_id.clone(), proposed_rule: Some(private_rule), - workspace: "default".to_string(), })), ) .await @@ -12973,9 +13014,11 @@ mod tests { let draft = handle_get_draft_policy( &state, with_user(Request::new(GetDraftPolicyRequest { - name: sandbox_name.to_string(), + sandbox_name: sandbox_name.to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), status_filter: String::new(), - workspace: "default".to_string(), })), ) .await @@ -12990,9 +13033,11 @@ mod tests { let skipped = handle_approve_all_draft_chunks( &state, with_user(Request::new(ApproveAllDraftChunksRequest { - name: sandbox_name.to_string(), + sandbox_name: sandbox_name.to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), include_security_flagged: false, - workspace: "default".to_string(), ..Default::default() })), ) @@ -13056,9 +13101,11 @@ mod tests { let skipped = handle_approve_all_draft_chunks( &state, with_user(Request::new(ApproveAllDraftChunksRequest { - name: sandbox_name.to_string(), + sandbox_name: sandbox_name.to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), include_security_flagged: false, - workspace: "default".to_string(), ..Default::default() })), ) @@ -13126,9 +13173,11 @@ mod tests { let draft = handle_get_draft_policy( &state, with_user(Request::new(GetDraftPolicyRequest { - name: sandbox_name.to_string(), + sandbox_name: sandbox_name.to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), status_filter: String::new(), - workspace: "default".to_string(), })), ) .await @@ -13239,10 +13288,12 @@ mod tests { handle_edit_draft_chunk( &state, with_user(Request::new(EditDraftChunkRequest { - name: sandbox_name.to_string(), + sandbox_name: sandbox_name.to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), chunk_id: chunk_id.clone(), proposed_rule: Some(finding_rule), - workspace: "default".to_string(), })), ) .await @@ -13373,9 +13424,11 @@ mod tests { let draft_policy = handle_get_draft_policy( &state, with_user(Request::new(GetDraftPolicyRequest { - name: sandbox_name.clone(), + sandbox_name: sandbox_name.clone(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), status_filter: String::new(), - workspace: "default".to_string(), })), ) .await @@ -13393,9 +13446,11 @@ mod tests { let approve = handle_approve_draft_chunk( &state, authed_request(ApproveDraftChunkRequest { - name: sandbox_name.clone(), + sandbox_name: sandbox_name.clone(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), chunk_id: chunk_id.clone(), - workspace: "default".to_string(), review_token, }), ) @@ -13408,8 +13463,10 @@ mod tests { let history_after_approve = handle_get_draft_history( &state, authed_request(GetDraftHistoryRequest { - name: sandbox_name.clone(), - workspace: "default".to_string(), + sandbox_name: sandbox_name.clone(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), }), ) .await @@ -13423,11 +13480,13 @@ mod tests { let policies_after_approve = handle_list_sandbox_policies( &state, authed_request(ListSandboxPoliciesRequest { - name: sandbox_name.clone(), + sandbox_name: sandbox_name.clone(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), limit: 10, offset: 0, global: false, - workspace: "default".to_string(), }), ) .await @@ -13439,9 +13498,11 @@ mod tests { let undo = handle_undo_draft_chunk( &state, authed_request(UndoDraftChunkRequest { - name: sandbox_name.clone(), + sandbox_name: sandbox_name.clone(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), chunk_id: chunk_id.clone(), - workspace: "default".to_string(), }), ) .await @@ -13453,9 +13514,11 @@ mod tests { let draft_policy_after_undo = handle_get_draft_policy( &state, with_user(Request::new(GetDraftPolicyRequest { - name: sandbox_name.clone(), + sandbox_name: sandbox_name.clone(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), status_filter: String::new(), - workspace: "default".to_string(), })), ) .await @@ -13467,8 +13530,10 @@ mod tests { let history_after_undo = handle_get_draft_history( &state, authed_request(GetDraftHistoryRequest { - name: sandbox_name.clone(), - workspace: "default".to_string(), + sandbox_name: sandbox_name.clone(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), }), ) .await @@ -13480,11 +13545,13 @@ mod tests { let policies_after_undo = handle_list_sandbox_policies( &state, authed_request(ListSandboxPoliciesRequest { - name: sandbox_name.clone(), + sandbox_name: sandbox_name.clone(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), limit: 10, offset: 0, global: false, - workspace: "default".to_string(), }), ) .await @@ -13497,8 +13564,10 @@ mod tests { let cleared = handle_clear_draft_chunks( &state, authed_request(ClearDraftChunksRequest { - name: sandbox_name.clone(), - workspace: "default".to_string(), + sandbox_name: sandbox_name.clone(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), }), ) .await @@ -13509,9 +13578,11 @@ mod tests { let draft_policy_after_clear = handle_get_draft_policy( &state, with_user(Request::new(GetDraftPolicyRequest { - name: sandbox_name.clone(), + sandbox_name: sandbox_name.clone(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), status_filter: String::new(), - workspace: "default".to_string(), })), ) .await @@ -13522,8 +13593,10 @@ mod tests { let history_after_clear = handle_get_draft_history( &state, authed_request(GetDraftHistoryRequest { - name: sandbox_name, - workspace: "default".to_string(), + sandbox_name: sandbox_name.clone(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), }), ) .await @@ -13597,10 +13670,12 @@ mod tests { handle_reject_draft_chunk( &state, authed_request(RejectDraftChunkRequest { - name: sandbox_name.clone(), + sandbox_name: sandbox_name.clone(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), chunk_id: chunk_id.clone(), reason: guidance.to_string(), - workspace: "default".to_string(), }), ) .await @@ -13609,9 +13684,11 @@ mod tests { let draft = handle_get_draft_policy( &state, with_user(Request::new(GetDraftPolicyRequest { - name: sandbox_name, + sandbox_name: sandbox_name.clone(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), status_filter: String::new(), - workspace: "default".to_string(), })), ) .await @@ -13716,9 +13793,11 @@ mod tests { let draft = handle_get_draft_policy( &state, with_user(Request::new(GetDraftPolicyRequest { - name: sandbox_name, + sandbox_name: sandbox_name.clone(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), status_filter: String::new(), - workspace: "default".to_string(), })), ) .await @@ -13829,9 +13908,11 @@ mod tests { let draft = handle_get_draft_policy( &state, with_user(Request::new(GetDraftPolicyRequest { - name: sandbox_name.clone(), + sandbox_name: sandbox_name.clone(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), status_filter: String::new(), - workspace: "default".to_string(), })), ) .await @@ -13905,9 +13986,11 @@ mod tests { let draft_after = handle_get_draft_policy( &state, with_user(Request::new(GetDraftPolicyRequest { - name: sandbox_name, + sandbox_name: sandbox_name.clone(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), status_filter: String::new(), - workspace: "default".to_string(), })), ) .await @@ -14035,9 +14118,11 @@ mod tests { let draft = handle_get_draft_policy( &state, with_user(Request::new(GetDraftPolicyRequest { - name: sandbox_name, + sandbox_name: sandbox_name.clone(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), status_filter: String::new(), - workspace: "default".to_string(), })), ) .await @@ -14140,8 +14225,10 @@ mod tests { let draft = handle_get_draft_policy( &state, with_user(Request::new(GetDraftPolicyRequest { - name: sandbox_name, - workspace: "default".to_string(), + sandbox_name: sandbox_name.clone(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), ..Default::default() })), ) @@ -14263,8 +14350,10 @@ mod tests { let draft = handle_get_draft_policy( &state, with_user(Request::new(GetDraftPolicyRequest { - name: sandbox_name, - workspace: "default".to_string(), + sandbox_name: sandbox_name.clone(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), ..Default::default() })), ) @@ -14393,9 +14482,11 @@ mod tests { let error = handle_approve_draft_chunk( &state, with_user(Request::new(ApproveDraftChunkRequest { - name: sandbox_name, + sandbox_name: sandbox_name.clone(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), chunk_id: chunk_id.clone(), - workspace: "default".to_string(), review_token: before.review_token.clone(), })), ) @@ -14577,9 +14668,11 @@ mod tests { let draft = handle_get_draft_policy( &state, with_user(Request::new(GetDraftPolicyRequest { - name: sandbox_name, + sandbox_name: sandbox_name.clone(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), status_filter: String::new(), - workspace: "default".to_string(), })), ) .await @@ -14681,9 +14774,11 @@ mod tests { let draft = handle_get_draft_policy( &state, with_user(Request::new(GetDraftPolicyRequest { - name: sandbox_name, + sandbox_name: sandbox_name.clone(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), status_filter: String::new(), - workspace: "default".to_string(), })), ) .await @@ -14778,9 +14873,11 @@ mod tests { let draft = handle_get_draft_policy( &state, with_user(Request::new(GetDraftPolicyRequest { - name: sandbox_name, + sandbox_name: sandbox_name.clone(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), status_filter: String::new(), - workspace: "default".to_string(), })), ) .await @@ -14866,9 +14963,11 @@ mod tests { let draft = handle_get_draft_policy( &state, with_user(Request::new(GetDraftPolicyRequest { - name: sandbox_name, + sandbox_name: sandbox_name.clone(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), status_filter: String::new(), - workspace: "default".to_string(), })), ) .await @@ -14958,9 +15057,11 @@ mod tests { let draft = handle_get_draft_policy( &state, with_user(Request::new(GetDraftPolicyRequest { - name: sandbox_name, + sandbox_name: sandbox_name.clone(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), status_filter: String::new(), - workspace: "default".to_string(), })), ) .await @@ -15053,9 +15154,11 @@ mod tests { let draft = handle_get_draft_policy( &state, with_user(Request::new(GetDraftPolicyRequest { - name: sandbox_name, + sandbox_name: sandbox_name.clone(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), status_filter: String::new(), - workspace: "default".to_string(), })), ) .await @@ -15227,9 +15330,11 @@ mod tests { let draft = handle_get_draft_policy( &state, with_user(Request::new(GetDraftPolicyRequest { - name: sandbox_name.to_string(), + sandbox_name: sandbox_name.to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), status_filter: String::new(), - workspace: "default".to_string(), })), ) .await @@ -15300,9 +15405,11 @@ mod tests { let err = handle_approve_draft_chunk( &state, with_user(Request::new(ApproveDraftChunkRequest { - name: sandbox_name.to_string(), + sandbox_name: sandbox_name.to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), chunk_id: chunk.id.clone(), - workspace: "default".to_string(), ..Default::default() })), ) @@ -15411,9 +15518,11 @@ mod tests { let draft = handle_get_draft_policy( &state, with_user(Request::new(GetDraftPolicyRequest { - name: sandbox_name, + sandbox_name: sandbox_name.clone(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), status_filter: String::new(), - workspace: "default".to_string(), })), ) .await @@ -15511,9 +15620,11 @@ mod tests { let draft = handle_get_draft_policy( &state, with_user(Request::new(GetDraftPolicyRequest { - name: sandbox_name, + sandbox_name: sandbox_name.clone(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), status_filter: String::new(), - workspace: "default".to_string(), })), ) .await @@ -15600,9 +15711,11 @@ mod tests { let draft = handle_get_draft_policy( &state, with_user(Request::new(GetDraftPolicyRequest { - name: sandbox_name, + sandbox_name: sandbox_name.clone(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), status_filter: String::new(), - workspace: "default".to_string(), })), ) .await @@ -15761,9 +15874,11 @@ mod tests { let draft = handle_get_draft_policy( &state, with_user(Request::new(GetDraftPolicyRequest { - name: sandbox_name.clone(), + sandbox_name: sandbox_name.clone(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), status_filter: String::new(), - workspace: "default".to_string(), })), ) .await @@ -15791,9 +15906,11 @@ mod tests { handle_approve_draft_chunk( &state, authed_request(ApproveDraftChunkRequest { - name: sandbox_name, + sandbox_name: sandbox_name.clone(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), chunk_id, - workspace: "default".to_string(), review_token: chunk.review_token.clone(), }), ) @@ -15979,9 +16096,11 @@ mod tests { let draft = handle_get_draft_policy( &state, with_user(Request::new(GetDraftPolicyRequest { - name: sandbox_name, + sandbox_name: sandbox_name.clone(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), status_filter: String::new(), - workspace: "default".to_string(), })), ) .await @@ -16130,9 +16249,11 @@ mod tests { let draft = handle_get_draft_policy( &state, with_user(Request::new(GetDraftPolicyRequest { - name: sandbox_name.clone(), + sandbox_name: sandbox_name.clone(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), status_filter: String::new(), - workspace: "default".to_string(), })), ) .await @@ -16151,10 +16272,12 @@ mod tests { handle_reject_draft_chunk( &state, authed_request(RejectDraftChunkRequest { - name: sandbox_name, + sandbox_name: sandbox_name.clone(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), chunk_id: second.accepted_chunk_ids[0].clone(), reason: "redraft test".to_string(), - workspace: "default".to_string(), }), ) .await @@ -16236,9 +16359,11 @@ mod tests { let draft = handle_get_draft_policy( &state, with_user(Request::new(GetDraftPolicyRequest { - name: sandbox_name, + sandbox_name: sandbox_name.clone(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), status_filter: String::new(), - workspace: "default".to_string(), })), ) .await @@ -16352,9 +16477,11 @@ mod tests { let after_first = handle_get_draft_policy( &state, with_user(Request::new(GetDraftPolicyRequest { - name: sandbox_name.clone(), + sandbox_name: sandbox_name.clone(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), status_filter: String::new(), - workspace: "default".to_string(), })), ) .await @@ -16373,9 +16500,11 @@ mod tests { let after_second = handle_get_draft_policy( &state, with_user(Request::new(GetDraftPolicyRequest { - name: sandbox_name.clone(), + sandbox_name: sandbox_name.clone(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), status_filter: String::new(), - workspace: "default".to_string(), })), ) .await @@ -16615,10 +16744,12 @@ mod tests { handle_reject_draft_chunk( &state, authed_request(RejectDraftChunkRequest { - name: sandbox_name.clone(), + sandbox_name: sandbox_name.clone(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), chunk_id: chunk_id.clone(), reason: "scope too broad".to_string(), - workspace: "default".to_string(), }), ) .await @@ -16627,9 +16758,11 @@ mod tests { handle_approve_draft_chunk( &state, authed_request(ApproveDraftChunkRequest { - name: sandbox_name.clone(), + sandbox_name: sandbox_name.clone(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), chunk_id: chunk_id.clone(), - workspace: "default".to_string(), review_token, }), ) @@ -16639,9 +16772,11 @@ mod tests { handle_undo_draft_chunk( &state, authed_request(UndoDraftChunkRequest { - name: sandbox_name.clone(), + sandbox_name: sandbox_name.clone(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), chunk_id: chunk_id.clone(), - workspace: "default".to_string(), }), ) .await @@ -16650,9 +16785,11 @@ mod tests { let draft = handle_get_draft_policy( &state, with_user(Request::new(GetDraftPolicyRequest { - name: sandbox_name, + sandbox_name: sandbox_name.clone(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), status_filter: String::new(), - workspace: "default".to_string(), })), ) .await @@ -16762,9 +16899,11 @@ mod tests { let draft_policy = handle_get_draft_policy( &state, with_user(Request::new(GetDraftPolicyRequest { - name: sandbox_a.object_name().to_string(), + sandbox_name: sandbox_a.object_name().to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), status_filter: String::new(), - workspace: "default".to_string(), })), ) .await @@ -16777,9 +16916,11 @@ mod tests { let approve_err = handle_approve_draft_chunk( &state, authed_request(ApproveDraftChunkRequest { - name: other_name.clone(), + sandbox_name: other_name.clone(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), chunk_id: chunk_id.clone(), - workspace: "default".to_string(), review_token: String::new(), }), ) @@ -16790,10 +16931,12 @@ mod tests { let reject_err = handle_reject_draft_chunk( &state, authed_request(RejectDraftChunkRequest { - name: other_name.clone(), + sandbox_name: other_name.clone(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), chunk_id: chunk_id.clone(), reason: "wrong sandbox".to_string(), - workspace: "default".to_string(), }), ) .await @@ -16803,10 +16946,12 @@ mod tests { let edit_err = handle_edit_draft_chunk( &state, authed_request(EditDraftChunkRequest { - name: other_name.clone(), + sandbox_name: other_name.clone(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), chunk_id: chunk_id.clone(), proposed_rule: Some(proposed_rule.clone()), - workspace: "default".to_string(), }), ) .await @@ -16816,9 +16961,11 @@ mod tests { handle_approve_draft_chunk( &state, authed_request(ApproveDraftChunkRequest { - name: sandbox_a.object_name().to_string(), + sandbox_name: sandbox_a.object_name().to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), chunk_id: chunk_id.clone(), - workspace: "default".to_string(), review_token, }), ) @@ -16828,9 +16975,11 @@ mod tests { let undo_err = handle_undo_draft_chunk( &state, authed_request(UndoDraftChunkRequest { - name: other_name, + sandbox_name: other_name.clone(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), chunk_id, - workspace: "default".to_string(), }), ) .await @@ -18768,7 +18917,8 @@ mod tests { let alpha_req = with_sandbox( Request::new(GetSandboxConfigRequest { - sandbox_id: "sb-alpha".to_string(), + sandbox_name: "work".to_string(), + workspace_scope: None, }), "sb-alpha", ); @@ -18780,7 +18930,8 @@ mod tests { let beta_req = with_sandbox( Request::new(GetSandboxConfigRequest { - sandbox_id: "sb-beta".to_string(), + sandbox_name: "work".to_string(), + workspace_scope: None, }), "sb-beta", ); @@ -18929,7 +19080,10 @@ mod tests { let response = handle_update_config( &state, authed_request(UpdateConfigRequest { - name: "test-sandbox".to_string(), + sandbox_name: "test-sandbox".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), policy: Some(new_policy), setting_key: String::new(), setting_value: None, @@ -18938,7 +19092,6 @@ mod tests { merge_operations: vec![], expected_resource_version: current_version, annotations: HashMap::new(), - workspace: "default".to_string(), }), ) .await @@ -19025,7 +19178,10 @@ mod tests { let response = handle_update_config( &state, authed_request(UpdateConfigRequest { - name: "annotated-backfill".to_string(), + sandbox_name: "annotated-backfill".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), policy: Some(ProtoSandboxPolicy::default()), setting_key: String::new(), setting_value: None, @@ -19034,7 +19190,6 @@ mod tests { merge_operations: vec![], expected_resource_version: current_version, annotations: annotations.clone(), - workspace: "default".to_string(), }), ) .await @@ -19101,7 +19256,8 @@ mod tests { let response = handle_update_config( &state, authed_request(UpdateConfigRequest { - name: "same-hash".to_string(), + sandbox_name: "same-hash".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector("default")), policy: Some(policy), annotations: HashMap::from([( "openshell.nvidia.com/policy-signature".to_string(), @@ -19187,7 +19343,8 @@ mod tests { let first = handle_update_config( &state, with_user(Request::new(UpdateConfigRequest { - name: "idempotent-provenance".to_string(), + sandbox_name: "idempotent-provenance".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector("default")), policy: Some(policy.clone()), annotations: annotations.clone(), ..Default::default() @@ -19199,7 +19356,8 @@ mod tests { let second = handle_update_config( &state, with_user(Request::new(UpdateConfigRequest { - name: "idempotent-provenance".to_string(), + sandbox_name: "idempotent-provenance".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector("default")), policy: Some(policy), annotations: annotations.clone(), ..Default::default() @@ -19253,7 +19411,8 @@ mod tests { let response = handle_update_config( &state, with_user(Request::new(UpdateConfigRequest { - name: "preserve-full".to_string(), + sandbox_name: "preserve-full".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector("default")), policy: Some(updated), ..Default::default() })), @@ -19301,7 +19460,8 @@ mod tests { let response = handle_update_config( &state, with_user(Request::new(UpdateConfigRequest { - name: "preserve-merge".to_string(), + sandbox_name: ("preserve-merge").to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector("default")), merge_operations: vec![PolicyMergeOperation { operation: Some(policy_merge_operation::Operation::AddRule( openshell_core::proto::AddNetworkRule { @@ -19372,7 +19532,8 @@ mod tests { let response = handle_update_config( &state, with_user(Request::new(UpdateConfigRequest { - name: "merge-provenance".to_string(), + sandbox_name: ("merge-provenance").to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector("default")), merge_operations: vec![PolicyMergeOperation { operation: Some(policy_merge_operation::Operation::AddRule( openshell_core::proto::AddNetworkRule { @@ -19448,7 +19609,8 @@ mod tests { let response = handle_update_config( &state, authed_request(UpdateConfigRequest { - name: "preserve-backfill".to_string(), + sandbox_name: "preserve-backfill".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector("default")), policy: Some(ProtoSandboxPolicy::default()), expected_resource_version: current_version, ..Default::default() @@ -19522,10 +19684,12 @@ mod tests { let error = handle_update_config( &state, with_user(Request::new(UpdateConfigRequest { - name: sandbox_name.to_string(), + sandbox_name: sandbox_name.to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), policy: Some(unsafe_replacement), expected_resource_version: current_version, - workspace: "default".to_string(), ..Default::default() })), ) @@ -19598,10 +19762,12 @@ mod tests { let error = handle_update_config( &state, with_user(Request::new(UpdateConfigRequest { - name: sandbox_name.to_string(), + sandbox_name: sandbox_name.to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), policy: Some(mcp_policy_with_versions(versions)), expected_resource_version: current_version, - workspace: "default".to_string(), ..Default::default() })), ) @@ -19680,10 +19846,12 @@ mod tests { let response = handle_update_config( &state, with_user(Request::new(UpdateConfigRequest { - name: sandbox_name.clone(), + sandbox_name: sandbox_name.clone(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), policy: Some(policy), expected_resource_version: current_version, - workspace: "default".to_string(), ..Default::default() })), ) @@ -19765,14 +19933,16 @@ mod tests { handle_update_config( &state, with_user(Request::new(UpdateConfigRequest { - name: sandbox_name.to_string(), + sandbox_name: sandbox_name.to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), policy: Some(mcp_policy_with_versions(&[ "2025-11-25", "2025-06-18", "2025-03-26", ])), expected_resource_version: current_version, - workspace: "default".to_string(), ..Default::default() })), ) @@ -19844,7 +20014,8 @@ mod tests { let err = handle_update_config( &state, with_user(Request::new(UpdateConfigRequest { - name: "invalid-annotation".to_string(), + sandbox_name: "invalid-annotation".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector("default")), policy: Some(ProtoSandboxPolicy::default()), annotations: HashMap::from([("bad key".to_string(), "value".to_string())]), ..Default::default() @@ -19874,7 +20045,8 @@ mod tests { let err = handle_update_config( &state, with_user(Request::new(UpdateConfigRequest { - name: "user-reserved-key".to_string(), + sandbox_name: "user-reserved-key".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector("default")), policy: Some(test_policy_with_rule( "_provider_work_github", "api.github.com", @@ -19942,7 +20114,8 @@ mod tests { &state, with_sandbox( Request::new(UpdateConfigRequest { - name: "sync-strip".to_string(), + sandbox_name: "sync-strip".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector("default")), policy: Some(synced_policy), expected_resource_version: current_version, ..Default::default() @@ -20037,7 +20210,10 @@ mod tests { let err = handle_update_config( &state, authed_request(UpdateConfigRequest { - name: "test-sandbox".to_string(), + sandbox_name: "test-sandbox".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), policy: Some(new_policy), setting_key: String::new(), setting_value: None, @@ -20046,7 +20222,6 @@ mod tests { merge_operations: vec![], expected_resource_version: 99, // stale version annotations: HashMap::new(), - workspace: "default".to_string(), }), ) .await @@ -20136,7 +20311,10 @@ mod tests { handle_update_config( &state_clone, authed_request(UpdateConfigRequest { - name: "test-sandbox".to_string(), + sandbox_name: "test-sandbox".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), policy: Some(new_policy), setting_key: String::new(), setting_value: None, @@ -20145,7 +20323,6 @@ mod tests { merge_operations: vec![], expected_resource_version: initial_version, annotations: HashMap::new(), - workspace: "default".to_string(), }), ) .await @@ -20206,7 +20383,7 @@ mod tests { /// when targeting a workspace that does not exist. Returning `NOT_FOUND` /// would create a CWE-203 workspace-name oracle. #[tokio::test] - async fn non_member_gets_permission_denied_not_workspace_oracle() { + async fn non_member_gets_gets_not_found_without_sandbox_oracle() { let mut state = test_server_state().await; Arc::get_mut(&mut state).unwrap().admin_role = "openshell-admin".to_string(); @@ -20227,7 +20404,8 @@ mod tests { let err = handle_get_sandbox_policy_status( &state, non_member_request(GetSandboxPolicyStatusRequest { - workspace: "no-such-ws".into(), + sandbox_name: ("any").to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector("no-such-ws")), ..Default::default() }), ) @@ -20235,15 +20413,16 @@ mod tests { .unwrap_err(); assert_eq!( err.code(), - Code::PermissionDenied, - "handle_get_sandbox_policy_status should return PermissionDenied, got {:?}", + Code::NotFound, + "handle_get_sandbox_policy_status should return NotFound, got {:?}", err.code() ); let err = handle_list_sandbox_policies( &state, non_member_request(ListSandboxPoliciesRequest { - workspace: "no-such-ws".into(), + sandbox_name: ("any").to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector("no-such-ws")), ..Default::default() }), ) @@ -20251,15 +20430,16 @@ mod tests { .unwrap_err(); assert_eq!( err.code(), - Code::PermissionDenied, - "handle_list_sandbox_policies should return PermissionDenied, got {:?}", + Code::NotFound, + "handle_list_sandbox_policies should return NotFound, got {:?}", err.code() ); let err = handle_update_config( &state, non_member_request(UpdateConfigRequest { - workspace: "no-such-ws".into(), + sandbox_name: ("any").to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector("no-such-ws")), ..Default::default() }), ) @@ -20267,15 +20447,16 @@ mod tests { .unwrap_err(); assert_eq!( err.code(), - Code::PermissionDenied, - "handle_update_config should return PermissionDenied, got {:?}", + Code::NotFound, + "handle_update_config should return NotFound, got {:?}", err.code() ); let err = handle_get_draft_policy( &state, non_member_request(GetDraftPolicyRequest { - workspace: "no-such-ws".into(), + sandbox_name: ("any").to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector("no-such-ws")), ..Default::default() }), ) @@ -20283,15 +20464,16 @@ mod tests { .unwrap_err(); assert_eq!( err.code(), - Code::PermissionDenied, - "handle_get_draft_policy should return PermissionDenied, got {:?}", + Code::NotFound, + "handle_get_draft_policy should return NotFound, got {:?}", err.code() ); let err = handle_approve_draft_chunk( &state, non_member_request(ApproveDraftChunkRequest { - workspace: "no-such-ws".into(), + sandbox_name: ("any").to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector("no-such-ws")), ..Default::default() }), ) @@ -20299,15 +20481,16 @@ mod tests { .unwrap_err(); assert_eq!( err.code(), - Code::PermissionDenied, - "handle_approve_draft_chunk should return PermissionDenied, got {:?}", + Code::NotFound, + "handle_approve_draft_chunk should return NotFound, got {:?}", err.code() ); let err = handle_reject_draft_chunk( &state, non_member_request(RejectDraftChunkRequest { - workspace: "no-such-ws".into(), + sandbox_name: ("any").to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector("no-such-ws")), ..Default::default() }), ) @@ -20315,15 +20498,16 @@ mod tests { .unwrap_err(); assert_eq!( err.code(), - Code::PermissionDenied, - "handle_reject_draft_chunk should return PermissionDenied, got {:?}", + Code::NotFound, + "handle_reject_draft_chunk should return NotFound, got {:?}", err.code() ); let err = handle_approve_all_draft_chunks( &state, non_member_request(ApproveAllDraftChunksRequest { - workspace: "no-such-ws".into(), + sandbox_name: ("any").to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector("no-such-ws")), ..Default::default() }), ) @@ -20331,15 +20515,16 @@ mod tests { .unwrap_err(); assert_eq!( err.code(), - Code::PermissionDenied, - "handle_approve_all_draft_chunks should return PermissionDenied, got {:?}", + Code::NotFound, + "handle_approve_all_draft_chunks should return NotFound, got {:?}", err.code() ); let err = handle_edit_draft_chunk( &state, non_member_request(EditDraftChunkRequest { - workspace: "no-such-ws".into(), + sandbox_name: ("any").to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector("no-such-ws")), ..Default::default() }), ) @@ -20347,15 +20532,16 @@ mod tests { .unwrap_err(); assert_eq!( err.code(), - Code::PermissionDenied, - "handle_edit_draft_chunk should return PermissionDenied, got {:?}", + Code::NotFound, + "handle_edit_draft_chunk should return NotFound, got {:?}", err.code() ); let err = handle_undo_draft_chunk( &state, non_member_request(UndoDraftChunkRequest { - workspace: "no-such-ws".into(), + sandbox_name: ("any").to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector("no-such-ws")), ..Default::default() }), ) @@ -20363,49 +20549,48 @@ mod tests { .unwrap_err(); assert_eq!( err.code(), - Code::PermissionDenied, - "handle_undo_draft_chunk should return PermissionDenied, got {:?}", + Code::NotFound, + "handle_undo_draft_chunk should return NotFound, got {:?}", err.code() ); let err = handle_clear_draft_chunks( &state, non_member_request(ClearDraftChunksRequest { - workspace: "no-such-ws".into(), - ..Default::default() + sandbox_name: ("any").to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector("no-such-ws")), }), ) .await .unwrap_err(); assert_eq!( err.code(), - Code::PermissionDenied, - "handle_clear_draft_chunks should return PermissionDenied, got {:?}", + Code::NotFound, + "handle_clear_draft_chunks should return NotFound, got {:?}", err.code() ); let err = handle_get_draft_history( &state, non_member_request(GetDraftHistoryRequest { - workspace: "no-such-ws".into(), - ..Default::default() + sandbox_name: ("any").to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector("no-such-ws")), }), ) .await .unwrap_err(); assert_eq!( err.code(), - Code::PermissionDenied, - "handle_get_draft_history should return PermissionDenied, got {:?}", + Code::NotFound, + "handle_get_draft_history should return NotFound, got {:?}", err.code() ); } - /// ID-based policy handlers must return `NOT_FOUND` — never - /// `PERMISSION_DENIED` — when the caller lacks workspace access, so that - /// cross-workspace sandbox existence cannot be inferred (CWE-203). + /// Name-based policy handlers hide cross-workspace resources after + /// authorizing the explicit workspace selector. #[tokio::test] - async fn id_based_policy_handlers_hide_cross_workspace_sandboxes() { + async fn name_based_policy_handlers_hide_cross_workspace_sandboxes() { let mut state = test_server_state().await; Arc::get_mut(&mut state).unwrap().admin_role = "openshell-admin".to_string(); @@ -20436,7 +20621,8 @@ mod tests { let err = handle_get_sandbox_config( &state, non_member_request(GetSandboxConfigRequest { - sandbox_id: "sandbox-other".into(), + sandbox_name: "other".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector("other-workspace")), }), ) .await @@ -20451,7 +20637,8 @@ mod tests { let err = handle_get_sandbox_logs( &state, non_member_request(GetSandboxLogsRequest { - sandbox_id: "sandbox-other".into(), + sandbox_name: "other".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector("other-workspace")), ..Default::default() }), ) @@ -20460,7 +20647,7 @@ mod tests { assert_eq!( err.code(), Code::NotFound, - "handle_get_sandbox_logs must return NotFound, not PermissionDenied" + "handle_get_sandbox_logs must hide unauthorized sandbox existence" ); } diff --git a/crates/openshell-server/src/grpc/provider.rs b/crates/openshell-server/src/grpc/provider.rs index 06ed7aa4ff..629e3a95f4 100644 --- a/crates/openshell-server/src/grpc/provider.rs +++ b/crates/openshell-server/src/grpc/provider.rs @@ -2350,7 +2350,10 @@ 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 crate::auth::workspace_authz::{ + AuthorizedWorkspaceScope, MinWorkspaceRole, authorize_list_workspace_selector, + authorize_workspace, authorize_workspace_selector, require_platform_admin, +}; use openshell_core::oauth::{ self, TokenExchangeParams, effective_client_assertion_type, effective_token_type, }; @@ -2468,11 +2471,11 @@ pub(super) async fn handle_create_provider( ) -> Result, Status> { let principal = super::extract_principal(&request)?; let req = request.into_inner(); - let authz = authorize_workspace( + let authz = authorize_workspace_selector( &state.store, &state.admin_role, &principal, - &req.workspace, + req.workspace_scope.as_ref(), MinWorkspaceRole::Admin, ) .await?; @@ -2542,11 +2545,11 @@ pub(super) async fn handle_get_provider( ) -> Result, Status> { let principal = super::extract_principal(&request)?; let req = request.into_inner(); - let authz = authorize_workspace( + let authz = authorize_workspace_selector( &state.store, &state.admin_role, &principal, - &req.workspace, + req.workspace_scope.as_ref(), MinWorkspaceRole::User, ) .await?; @@ -2566,15 +2569,17 @@ pub(super) async fn handle_list_providers( ) -> 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", - )); - } let limit = clamp_limit(request.limit, 100, MAX_PAGE_SIZE); - let providers = if request.all_workspaces { - require_platform_admin(&state.admin_role, &principal)?; + let scope = authorize_list_workspace_selector( + &state.store, + &state.admin_role, + &principal, + request.workspace_scope.as_ref(), + MinWorkspaceRole::User, + ) + .await?; + let providers = if matches!(scope, AuthorizedWorkspaceScope::AllWorkspaces) { let all: Vec = state .store .list_all_messages(limit, request.offset) @@ -2582,14 +2587,9 @@ pub(super) async fn handle_list_providers( .map_err(|e| Status::internal(format!("list providers failed: {e}")))?; all.into_iter().map(redact_provider_credentials).collect() } else { - let authz = authorize_workspace( - &state.store, - &state.admin_role, - &principal, - &request.workspace, - MinWorkspaceRole::User, - ) - .await?; + let AuthorizedWorkspaceScope::Workspace(authz) = scope else { + unreachable!("all-workspaces scope handled above") + }; let workspace = super::workspace::resolve_workspace(state.store.as_ref(), &authz.workspace) .await? .name; @@ -3697,11 +3697,11 @@ pub(super) async fn handle_update_provider( ) -> Result, Status> { let principal = super::extract_principal(&request)?; let req = request.into_inner(); - let authz = authorize_workspace( + let authz = authorize_workspace_selector( &state.store, &state.admin_role, &principal, - &req.workspace, + req.workspace_scope.as_ref(), MinWorkspaceRole::Admin, ) .await?; @@ -4228,11 +4228,11 @@ pub(super) async fn handle_get_provider_refresh_status( ) -> Result, Status> { let principal = super::extract_principal(&request)?; let request = request.into_inner(); - let authz = authorize_workspace( + let authz = authorize_workspace_selector( &state.store, &state.admin_role, &principal, - &request.workspace, + request.workspace_scope.as_ref(), MinWorkspaceRole::User, ) .await?; @@ -4281,11 +4281,11 @@ pub(super) async fn handle_configure_provider_refresh( ) -> Result, Status> { let principal = super::extract_principal(&request)?; let request = request.into_inner(); - let authz = authorize_workspace( + let authz = authorize_workspace_selector( &state.store, &state.admin_role, &principal, - &request.workspace, + request.workspace_scope.as_ref(), MinWorkspaceRole::Admin, ) .await?; @@ -4678,11 +4678,11 @@ pub(super) async fn handle_rotate_provider_credential( ) -> Result, Status> { let principal = super::extract_principal(&request)?; let request = request.into_inner(); - let authz = authorize_workspace( + let authz = authorize_workspace_selector( &state.store, &state.admin_role, &principal, - &request.workspace, + request.workspace_scope.as_ref(), MinWorkspaceRole::Admin, ) .await?; @@ -4748,11 +4748,11 @@ pub(super) async fn handle_delete_provider_refresh( ) -> Result, Status> { let principal = super::extract_principal(&request)?; let request = request.into_inner(); - let authz = authorize_workspace( + let authz = authorize_workspace_selector( &state.store, &state.admin_role, &principal, - &request.workspace, + request.workspace_scope.as_ref(), MinWorkspaceRole::Admin, ) .await?; @@ -4826,11 +4826,11 @@ pub(super) async fn handle_delete_provider( ) -> Result, Status> { let principal = super::extract_principal(&request)?; let req = request.into_inner(); - let authz = authorize_workspace( + let authz = authorize_workspace_selector( &state.store, &state.admin_role, &principal, - &req.workspace, + req.workspace_scope.as_ref(), MinWorkspaceRole::Admin, ) .await?; @@ -6567,9 +6567,11 @@ mod tests { &state, authed_request(AttachSandboxProviderRequest { sandbox_name: "sandbox-custom".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), provider_name: "custom-provider".to_string(), expected_resource_version: 0, - workspace: "default".to_string(), }), ) .await @@ -6664,7 +6666,9 @@ mod tests { // credential storage by omitting this advisory list. secret_material_keys: Vec::new(), expires_at_ms: Some(expires_at_ms), - workspace: "default".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), }), ) .await @@ -6679,7 +6683,9 @@ mod tests { authed_request(GetProviderRefreshStatusRequest { provider: "msgraph".to_string(), credential_key: "MS_GRAPH_ACCESS_TOKEN".to_string(), - workspace: "default".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), }), ) .await @@ -6746,7 +6752,9 @@ mod tests { ]), secret_material_keys: vec!["client_secret".to_string()], expires_at_ms: Some(expires_at_ms), - workspace: "default".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), }), ) .await @@ -6771,7 +6779,9 @@ mod tests { authed_request(DeleteProviderRefreshRequest { provider: "msgraph".to_string(), credential_key: "MS_GRAPH_ACCESS_TOKEN".to_string(), - workspace: "default".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), }), ) .await @@ -6785,7 +6795,9 @@ mod tests { authed_request(GetProviderRefreshStatusRequest { provider: "msgraph".to_string(), credential_key: "MS_GRAPH_ACCESS_TOKEN".to_string(), - workspace: "default".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), }), ) .await @@ -6837,7 +6849,9 @@ mod tests { ]), secret_material_keys: vec!["client_secret".to_string()], expires_at_ms: None, - workspace: "default".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), }; handle_configure_provider_refresh(&state, authed_request(request("original-secret"))) .await @@ -6957,7 +6971,9 @@ mod tests { ]), secret_material_keys: vec!["client_secret".to_string()], expires_at_ms: None, - workspace: "default".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), }; let (first_store_hit, release_first_store) = first_state.credentials.gate_next_store(); @@ -7082,7 +7098,9 @@ mod tests { material: HashMap::new(), secret_material_keys: Vec::new(), expires_at_ms: Some(expires_at_ms), - workspace: "default".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), }), ) .await @@ -7157,7 +7175,9 @@ mod tests { authed_request(DeleteProviderRefreshRequest { provider: "provider-a".to_string(), credential_key: "REFRESH_TOKEN".to_string(), - workspace: "default".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), }), ) .await @@ -7226,7 +7246,9 @@ mod tests { ]), secret_material_keys: vec!["private_key".to_string()], expires_at_ms: None, - workspace: "default".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), }), ) .await @@ -7292,7 +7314,9 @@ mod tests { ]), secret_material_keys: vec!["client_secret".to_string()], expires_at_ms: Some(refresh_expires_at_ms), - workspace: "default".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), }), ) .await @@ -7332,7 +7356,9 @@ mod tests { authed_request(DeleteProviderRefreshRequest { provider: "msgraph".to_string(), credential_key: "MS_GRAPH_ACCESS_TOKEN".to_string(), - workspace: "default".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), }), ) .await @@ -7395,7 +7421,9 @@ mod tests { )]), secret_material_keys: Vec::new(), expires_at_ms: Some(refresh_expires_at_ms), - workspace: "default".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), }), ) .await @@ -7438,7 +7466,9 @@ mod tests { authed_request(DeleteProviderRefreshRequest { provider: "aws-delete".to_string(), credential_key: "AWS_ACCESS_KEY_ID".to_string(), - workspace: "default".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), }), ) .await @@ -7616,7 +7646,9 @@ mod tests { ]), secret_material_keys: vec!["client_secret".to_string()], expires_at_ms: None, - workspace: "default".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), }), ) .await @@ -7696,7 +7728,9 @@ mod tests { ]), secret_material_keys: vec!["client_secret".to_string()], expires_at_ms: None, - workspace: "default".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), }), ) .await @@ -7715,7 +7749,9 @@ mod tests { ]), secret_material_keys: vec!["client_secret".to_string()], expires_at_ms: None, - workspace: "default".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), }), ) .await @@ -7778,7 +7814,9 @@ mod tests { ]), secret_material_keys: vec!["client_secret".to_string()], expires_at_ms: None, - workspace: "default".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), }), ) .await @@ -7795,7 +7833,9 @@ mod tests { material: HashMap::from([("tenant_id".to_string(), "tenant".to_string())]), secret_material_keys: vec!["client_secret".to_string()], expires_at_ms: None, - workspace: "default".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), }), ) .await @@ -7849,7 +7889,9 @@ mod tests { material: HashMap::new(), secret_material_keys: Vec::new(), expires_at_ms: None, - workspace: "default".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), }), ) .await @@ -7972,7 +8014,9 @@ mod tests { &task_state, authed_request(CreateProviderRequest { provider: Some(provider), - workspace: "default".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), }), ) .await @@ -8406,7 +8450,9 @@ mod tests { "openai", "OPENAI_API_KEY", )), - workspace: "default".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), }), ) .await @@ -8423,7 +8469,9 @@ mod tests { &state, authed_request(CreateProviderRequest { provider: Some(provider_with_values("legacy-gitlab", "gitlab")), - workspace: "default".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), }), ) .await @@ -8452,7 +8500,9 @@ mod tests { profile_workspace: "default".to_string(), ..Default::default() }), - workspace: "default".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), }), ) .await @@ -8494,7 +8544,9 @@ mod tests { profile_workspace: "default".to_string(), ..Default::default() }), - workspace: "default".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), }), ) .await @@ -8550,7 +8602,9 @@ mod tests { "GITHUB_TOKEN", "test-token", )), - workspace: "default".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), }), ) .await @@ -8596,7 +8650,9 @@ mod tests { "OPENAI_API_KEY", "sk-test", )), - workspace: "default".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), }), ) .await @@ -8711,7 +8767,9 @@ mod tests { "subject_token", "test-token", )), - workspace: "default".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), }), ) .await @@ -8747,7 +8805,9 @@ mod tests { "OPENAI_API_KEY", )), credential_expires_at_ms: HashMap::new(), - workspace: "default".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), }), ) .await @@ -9709,7 +9769,9 @@ mod tests { ]), secret_material_keys: vec!["client_secret".to_string()], expires_at_ms: None, - workspace: "default".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), }; handle_configure_provider_refresh(&state, authed_request(configure())) .await @@ -9774,7 +9836,9 @@ mod tests { ..Default::default() }), credential_expires_at_ms: HashMap::new(), - workspace: "default".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), }), ) .await @@ -11588,7 +11652,9 @@ mod tests { "OPENAI_API_KEY", "sk-test", )), - workspace: "default".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), }), ) .await @@ -11612,7 +11678,9 @@ mod tests { authed_request(UpdateProviderRequest { provider: Some(update), credential_expires_at_ms: HashMap::new(), - workspace: "default".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), }), ) .await @@ -11634,7 +11702,9 @@ mod tests { "OPENAI_API_KEY", "sk-test", )), - workspace: "default".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), }), ) .await @@ -11657,7 +11727,9 @@ mod tests { authed_request(UpdateProviderRequest { provider: Some(update), credential_expires_at_ms: HashMap::new(), - workspace: "default".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), }), ) .await @@ -11679,7 +11751,9 @@ mod tests { &state, authed_request(CreateProviderRequest { provider: Some(provider.clone()), - workspace: "default".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), }), ) .await @@ -11708,7 +11782,9 @@ mod tests { authed_request(UpdateProviderRequest { provider: Some(updated_provider.clone()), credential_expires_at_ms: HashMap::new(), - workspace: "default".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), }), ) .await @@ -11745,7 +11821,9 @@ mod tests { &state, authed_request(CreateProviderRequest { provider: Some(provider.clone()), - workspace: "default".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), }), ) .await @@ -11774,7 +11852,9 @@ mod tests { authed_request(UpdateProviderRequest { provider: Some(stale_provider), credential_expires_at_ms: HashMap::new(), - workspace: "default".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), }), ) .await @@ -11823,7 +11903,9 @@ mod tests { "OPENAI_API_KEY", "sk-first", )), - workspace: "default".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), }), ) .await @@ -11847,7 +11929,9 @@ mod tests { authed_request(UpdateProviderRequest { provider: Some(stale_provider), credential_expires_at_ms: HashMap::new(), - workspace: "default".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), }), ) .await @@ -11887,7 +11971,9 @@ mod tests { &state, authed_request(CreateProviderRequest { provider: Some(provider.clone()), - workspace: "default".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), }), ) .await @@ -11919,7 +12005,9 @@ mod tests { authed_request(UpdateProviderRequest { provider: Some(updated), credential_expires_at_ms: HashMap::new(), - workspace: "default".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), }), ) .await @@ -12013,7 +12101,9 @@ mod tests { )]), secret_material_keys: Vec::new(), expires_at_ms: None, - workspace: "default".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), }), ) .await @@ -12075,7 +12165,9 @@ mod tests { ]), secret_material_keys: Vec::new(), expires_at_ms: None, - workspace: "default".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), }), ) .await @@ -12149,7 +12241,9 @@ mod tests { ]), secret_material_keys: Vec::new(), expires_at_ms: None, - workspace: "default".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), }), ) .await @@ -12205,7 +12299,9 @@ mod tests { ]), secret_material_keys: vec!["aws_session_token".to_string()], expires_at_ms: None, - workspace: "default".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), }), ) .await @@ -12255,7 +12351,9 @@ mod tests { )]), secret_material_keys: Vec::new(), expires_at_ms: None, - workspace: "default".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), }), ) .await @@ -12333,7 +12431,9 @@ mod tests { )]), secret_material_keys: Vec::new(), expires_at_ms: None, - workspace: "default".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), }), ) .await @@ -12357,7 +12457,9 @@ mod tests { ..Default::default() }), credential_expires_at_ms: HashMap::new(), - workspace: "default".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), }), ) .await @@ -12423,7 +12525,9 @@ mod tests { )]), secret_material_keys: Vec::new(), expires_at_ms: None, - workspace: "default".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), }), ) .await @@ -12475,7 +12579,9 @@ mod tests { )]), secret_material_keys: Vec::new(), expires_at_ms: None, - workspace: "default".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), }), ) .await @@ -12653,7 +12759,9 @@ mod tests { )]), secret_material_keys: Vec::new(), expires_at_ms: None, - workspace: "default".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), }), ) .await @@ -12728,7 +12836,9 @@ mod tests { )]), secret_material_keys: Vec::new(), expires_at_ms: None, - workspace: "default".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), }) }; @@ -12931,12 +13041,14 @@ mod tests { labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), - workspace: String::new(), + workspace: "default".to_string(), deletion_timestamp_ms: 0, }); p }), - workspace: "default".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), }), ) .await @@ -12961,12 +13073,14 @@ mod tests { labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), - workspace: String::new(), + workspace: "default".to_string(), deletion_timestamp_ms: 0, }); p }), - workspace: "beta".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "beta".to_string(), + )), }), ) .await @@ -12986,7 +13100,9 @@ mod tests { &state, authed_request(GetProviderRequest { name: "shared-name".to_string(), - workspace: "default".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), }), ) .await @@ -12998,7 +13114,9 @@ mod tests { &state, authed_request(GetProviderRequest { name: "shared-name".to_string(), - workspace: "beta".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "beta".to_string(), + )), }), ) .await @@ -13012,8 +13130,9 @@ mod tests { authed_request(ListProvidersRequest { limit: 100, offset: 0, - workspace: "default".to_string(), - all_workspaces: false, + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), }), ) .await @@ -13027,8 +13146,9 @@ mod tests { authed_request(ListProvidersRequest { limit: 100, offset: 0, - workspace: "beta".to_string(), - all_workspaces: false, + workspace_scope: Some(openshell_core::proto::workspace_selector( + "beta".to_string(), + )), }), ) .await @@ -13042,7 +13162,9 @@ mod tests { &state, authed_request(DeleteProviderRequest { name: "shared-name".to_string(), - workspace: "default".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), }), ) .await @@ -13055,8 +13177,9 @@ mod tests { authed_request(ListProvidersRequest { limit: 100, offset: 0, - workspace: "default".to_string(), - all_workspaces: false, + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), }), ) .await @@ -13068,7 +13191,9 @@ mod tests { &state, authed_request(GetProviderRequest { name: "shared-name".to_string(), - workspace: "beta".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "beta".to_string(), + )), }), ) .await @@ -13090,12 +13215,14 @@ mod tests { labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), - workspace: String::new(), + workspace: "default".to_string(), deletion_timestamp_ms: 0, }); p }), - workspace: "default".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), }), ) .await @@ -13106,28 +13233,13 @@ mod tests { authed_request(ListProvidersRequest { limit: 100, offset: 0, - workspace: String::new(), - all_workspaces: true, + workspace_scope: Some(openshell_core::proto::all_workspaces_selector()), }), ) .await .unwrap() .into_inner(); assert_eq!(listed.providers.len(), 2); - - // all_workspaces with non-empty workspace is rejected. - let err = handle_list_providers( - &state, - authed_request(ListProvidersRequest { - limit: 100, - offset: 0, - workspace: "default".to_string(), - all_workspaces: true, - }), - ) - .await - .unwrap_err(); - assert_eq!(err.code(), Code::InvalidArgument); } #[tokio::test] @@ -13939,7 +14051,7 @@ mod tests { let err = handle_create_provider( &state, non_member_request(CreateProviderRequest { - workspace: "no-such-ws".into(), + workspace_scope: Some(openshell_core::proto::workspace_selector("no-such-ws")), ..Default::default() }), ) @@ -13954,7 +14066,7 @@ mod tests { let err = handle_get_provider( &state, non_member_request(GetProviderRequest { - workspace: "no-such-ws".into(), + workspace_scope: Some(openshell_core::proto::workspace_selector("no-such-ws")), ..Default::default() }), ) @@ -13969,7 +14081,7 @@ mod tests { let err = handle_list_providers( &state, non_member_request(ListProvidersRequest { - workspace: "no-such-ws".into(), + workspace_scope: Some(openshell_core::proto::workspace_selector("no-such-ws")), ..Default::default() }), ) @@ -13984,7 +14096,7 @@ mod tests { let err = handle_update_provider( &state, non_member_request(UpdateProviderRequest { - workspace: "no-such-ws".into(), + workspace_scope: Some(openshell_core::proto::workspace_selector("no-such-ws")), ..Default::default() }), ) @@ -13999,7 +14111,7 @@ mod tests { let err = handle_get_provider_refresh_status( &state, non_member_request(GetProviderRefreshStatusRequest { - workspace: "no-such-ws".into(), + workspace_scope: Some(openshell_core::proto::workspace_selector("no-such-ws")), ..Default::default() }), ) @@ -14014,7 +14126,7 @@ mod tests { let err = handle_configure_provider_refresh( &state, non_member_request(ConfigureProviderRefreshRequest { - workspace: "no-such-ws".into(), + workspace_scope: Some(openshell_core::proto::workspace_selector("no-such-ws")), ..Default::default() }), ) @@ -14029,7 +14141,7 @@ mod tests { let err = handle_rotate_provider_credential( &state, non_member_request(RotateProviderCredentialRequest { - workspace: "no-such-ws".into(), + workspace_scope: Some(openshell_core::proto::workspace_selector("no-such-ws")), ..Default::default() }), ) @@ -14044,7 +14156,7 @@ mod tests { let err = handle_delete_provider_refresh( &state, non_member_request(DeleteProviderRefreshRequest { - workspace: "no-such-ws".into(), + workspace_scope: Some(openshell_core::proto::workspace_selector("no-such-ws")), ..Default::default() }), ) @@ -14059,7 +14171,7 @@ mod tests { let err = handle_delete_provider( &state, non_member_request(DeleteProviderRequest { - workspace: "no-such-ws".into(), + workspace_scope: Some(openshell_core::proto::workspace_selector("no-such-ws")), ..Default::default() }), ) diff --git a/crates/openshell-server/src/grpc/sandbox.rs b/crates/openshell-server/src/grpc/sandbox.rs index e0ff130ebc..03cfdf10ab 100644 --- a/crates/openshell-server/src/grpc/sandbox.rs +++ b/crates/openshell-server/src/grpc/sandbox.rs @@ -11,7 +11,8 @@ use crate::ServerState; use crate::auth::workspace_authz::{ - MinWorkspaceRole, authorize_sandbox_workspace, authorize_workspace, require_platform_admin, + AuthorizedWorkspaceScope, MinWorkspaceRole, authorize_list_workspace_selector, + authorize_sandbox_workspace, authorize_workspace_selector, }; use crate::persistence::{ObjectLabels, ObjectType, WriteCondition, generate_name}; use futures::future; @@ -120,33 +121,76 @@ impl Drop for WatchSandboxStream { } } -/// Fetch a sandbox by ID and authorize the caller in one step, returning -/// `NOT_FOUND` for both missing and unauthorized sandboxes so that callers -/// cannot distinguish the two cases (CWE-203). -pub(super) async fn fetch_and_authorize_sandbox( +/// Resolve a public sandbox name and authorize its persisted workspace. +/// Missing and unauthorized objects deliberately share one response so names +/// cannot be used as an existence oracle. +pub(super) async fn resolve_and_authorize_sandbox_name( state: &Arc, principal: &crate::auth::principal::Principal, - sandbox_id: &str, + sandbox_name: &str, + workspace_scope: Option<&openshell_core::proto::WorkspaceSelector>, + min_role: MinWorkspaceRole, ) -> Result { - let sandbox = state - .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 sandbox_name.is_empty() { + return Err(Status::invalid_argument("sandbox_name is required")); + } + let sandbox = match principal { + crate::auth::principal::Principal::Sandbox(sandbox_principal) => { + let sandbox = state + .store + .get_message::(&sandbox_principal.sandbox_id) + .await + .map_err(|e| Status::internal(format!("fetch sandbox failed: {e}")))?; + sandbox.filter(|sandbox| { + sandbox.metadata.as_ref().is_some_and(|metadata| { + metadata.name == sandbox_name + && workspace_scope.is_none_or(|scope| { + crate::auth::workspace_authz::selected_workspace_name(Some(scope)) + .is_ok_and(|workspace| workspace == sandbox.object_workspace()) + }) + }) + }) + } + crate::auth::principal::Principal::User(_) + | crate::auth::principal::Principal::Anonymous => { + let workspace = crate::auth::workspace_authz::selected_workspace_name(workspace_scope)?; + state + .store + .get_message_by_name::(workspace, sandbox_name) + .await + .map_err(|e| Status::internal(format!("fetch sandbox failed: {e}")))? + } + }; + let sandbox = match sandbox { + Some(sandbox) => sandbox, + None if matches!(principal, crate::auth::principal::Principal::Sandbox(_)) => { + return Err(Status::permission_denied( + "sandbox not found or not owned by caller", + )); + } + None => return Err(Status::not_found("sandbox not found")), + }; + authorize_sandbox_workspace( &state.store, &state.admin_role, principal, sandbox.object_workspace(), - MinWorkspaceRole::User, + min_role, ) .await - .map_err(|e| { - if e.code() == tonic::Code::PermissionDenied { + .map_err(|error| { + if error.code() == tonic::Code::PermissionDenied { Status::not_found("sandbox not found") } else { - e + error + } + })?; + crate::auth::guard::ensure_sandbox_scope(principal, sandbox.object_id()).map_err(|error| { + if error.code() == tonic::Code::PermissionDenied { + Status::permission_denied("sandbox not found or not owned by caller") + } else { + error } })?; Ok(sandbox) @@ -193,11 +237,11 @@ pub(super) async fn handle_begin_rootfs_tar_staging( let principal = super::extract_principal(&request)?; let request = request.into_inner(); - let authz = authorize_workspace( + let authz = authorize_workspace_selector( &state.store, &state.admin_role, &principal, - &request.workspace, + request.workspace_scope.as_ref(), MinWorkspaceRole::User, ) .await?; @@ -330,11 +374,11 @@ async fn handle_create_sandbox_inner( validate_create_sandbox_request_pre_io(&request, &workload_template_name)?; - let authz = authorize_workspace( + let authz = authorize_workspace_selector( &state.store, &state.admin_role, &principal, - &request.workspace, + request.workspace_scope.as_ref(), MinWorkspaceRole::User, ) .await?; @@ -662,28 +706,14 @@ pub(super) async fn handle_get_sandbox( ) -> 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, + let sandbox = resolve_and_authorize_sandbox_name( + state, &principal, - &req.workspace, + &req.sandbox_name, + req.workspace_scope.as_ref(), MinWorkspaceRole::User, ) .await?; - let workspace = super::workspace::resolve_workspace(state.store.as_ref(), &authz.workspace) - .await? - .name; - - let sandbox = state - .store - .get_message_by_name::(&workspace, &req.name) - .await - .map_err(|e| Status::internal(format!("fetch sandbox failed: {e}")))?; - - let sandbox = sandbox.ok_or_else(|| Status::not_found("sandbox not found"))?; Ok(Response::new(SandboxResponse { sandbox: Some(sandbox), })) @@ -695,15 +725,17 @@ pub(super) async fn handle_list_sandboxes( ) -> 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", - )); - } let limit = clamp_limit(request.limit, 100, MAX_PAGE_SIZE); - let sandboxes: Vec = if request.all_workspaces { - require_platform_admin(&state.admin_role, &principal)?; + let scope = authorize_list_workspace_selector( + &state.store, + &state.admin_role, + &principal, + request.workspace_scope.as_ref(), + MinWorkspaceRole::User, + ) + .await?; + let sandboxes: Vec = if matches!(scope, AuthorizedWorkspaceScope::AllWorkspaces) { if request.label_selector.is_empty() { state .store @@ -719,14 +751,9 @@ pub(super) async fn handle_list_sandboxes( .map_err(|e| Status::internal(format!("list sandboxes failed: {e}")))? } } else { - let authz = authorize_workspace( - &state.store, - &state.admin_role, - &principal, - &request.workspace, - MinWorkspaceRole::User, - ) - .await?; + let AuthorizedWorkspaceScope::Workspace(authz) = scope else { + unreachable!("all-workspaces scope handled above") + }; let workspace = super::workspace::resolve_workspace(state.store.as_ref(), &authz.workspace) .await? .name; @@ -766,11 +793,11 @@ pub(super) async fn handle_create_sandbox_template( .template .ok_or_else(|| Status::invalid_argument("template is required"))?; let metadata = template.metadata.clone().unwrap_or_default(); - let authz = authorize_workspace( + let authz = authorize_workspace_selector( &state.store, &state.admin_role, &principal, - &req.workspace, + req.workspace_scope.as_ref(), MinWorkspaceRole::Admin, ) .await?; @@ -856,11 +883,11 @@ pub(super) async fn handle_get_sandbox_template( if req.name.is_empty() { return Err(Status::invalid_argument("name is required")); } - let authz = authorize_workspace( + let authz = authorize_workspace_selector( &state.store, &state.admin_role, &principal, - &req.workspace, + req.workspace_scope.as_ref(), MinWorkspaceRole::User, ) .await?; @@ -884,14 +911,16 @@ pub(super) async fn handle_list_sandbox_templates( ) -> 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", - )); - } let limit = clamp_limit(request.limit, 100, MAX_PAGE_SIZE); - let templates = if request.all_workspaces { - require_platform_admin(&state.admin_role, &principal)?; + let scope = authorize_list_workspace_selector( + &state.store, + &state.admin_role, + &principal, + request.workspace_scope.as_ref(), + MinWorkspaceRole::User, + ) + .await?; + let templates = if matches!(scope, AuthorizedWorkspaceScope::AllWorkspaces) { if request.label_selector.is_empty() { state .store @@ -911,14 +940,9 @@ pub(super) async fn handle_list_sandbox_templates( .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 AuthorizedWorkspaceScope::Workspace(authz) = scope else { + unreachable!("all-workspaces scope handled above") + }; let workspace = super::workspace::resolve_workspace(state.store.as_ref(), &authz.workspace) .await? .name; @@ -956,11 +980,11 @@ pub(super) async fn handle_delete_sandbox_template( if req.name.is_empty() { return Err(Status::invalid_argument("name is required")); } - let authz = authorize_workspace( + let authz = authorize_workspace_selector( &state.store, &state.admin_role, &principal, - &req.workspace, + req.workspace_scope.as_ref(), MinWorkspaceRole::Admin, ) .await?; @@ -1042,18 +1066,15 @@ pub(super) async fn handle_list_sandbox_providers( ) -> Result, Status> { let principal = super::extract_principal(&request)?; let req = request.into_inner(); - let authz = authorize_workspace( - &state.store, - &state.admin_role, + let sandbox = resolve_and_authorize_sandbox_name( + state, &principal, - &req.workspace, + &req.sandbox_name, + req.workspace_scope.as_ref(), 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 workspace = sandbox.object_workspace().to_string(); let providers = providers_for_sandbox(state, &sandbox, &workspace).await?; Ok(Response::new(ListSandboxProvidersResponse { providers })) } @@ -1064,17 +1085,18 @@ pub(super) async fn handle_attach_sandbox_provider( ) -> Result, Status> { let principal = super::extract_principal(&request)?; let request = request.into_inner(); - let authz = authorize_workspace( - &state.store, - &state.admin_role, + let sandbox = resolve_and_authorize_sandbox_name( + state, &principal, - &request.workspace, + &request.sandbox_name, + request.workspace_scope.as_ref(), MinWorkspaceRole::User, ) .await?; - let workspace = super::workspace::resolve_workspace(state.store.as_ref(), &authz.workspace) - .await? - .ensure_active()?; + let workspace = + super::workspace::resolve_workspace(state.store.as_ref(), sandbox.object_workspace()) + .await? + .ensure_active()?; if request.provider_name.is_empty() { return Err(Status::invalid_argument("provider_name is required")); } @@ -1103,7 +1125,7 @@ pub(super) async fn handle_attach_sandbox_provider( })?; let _sandbox_sync_guard = state.compute.sandbox_sync_guard().await; - let sandbox = sandbox_by_name(state, &workspace, &request.sandbox_name).await?; + let sandbox_name = sandbox.object_name().to_string(); let sandbox_id = sandbox .metadata .as_ref() @@ -1138,7 +1160,7 @@ pub(super) async fn handle_attach_sandbox_provider( { candidate_spec.providers.push(request.provider_name.clone()); } - validate_sandbox_spec(&request.sandbox_name, &candidate_spec)?; + validate_sandbox_spec(&sandbox_name, &candidate_spec)?; let provider_profile_catalog = state .provider_profile_sources .snapshot_catalog(state.store.as_ref(), &workspace) @@ -1195,7 +1217,7 @@ pub(super) async fn handle_attach_sandbox_provider( let attached = attached.load(Ordering::Relaxed); info!( - sandbox_name = %request.sandbox_name, + sandbox_name = %sandbox_name, provider_name = %request.provider_name, attached, "AttachSandboxProvider request completed successfully" @@ -1213,17 +1235,15 @@ pub(super) async fn handle_detach_sandbox_provider( ) -> Result, Status> { let principal = super::extract_principal(&request)?; let request = request.into_inner(); - let authz = authorize_workspace( - &state.store, - &state.admin_role, + let sandbox = resolve_and_authorize_sandbox_name( + state, &principal, - &request.workspace, + &request.sandbox_name, + request.workspace_scope.as_ref(), MinWorkspaceRole::User, ) .await?; - let workspace = super::workspace::resolve_workspace(state.store.as_ref(), &authz.workspace) - .await? - .name; + let workspace = sandbox.object_workspace().to_string(); if request.provider_name.is_empty() { return Err(Status::invalid_argument("provider_name is required")); } @@ -1238,7 +1258,7 @@ pub(super) async fn handle_detach_sandbox_provider( } let _sandbox_sync_guard = state.compute.sandbox_sync_guard().await; - let sandbox = sandbox_by_name(state, &workspace, &request.sandbox_name).await?; + let sandbox_name = sandbox.object_name().to_string(); let sandbox_id = sandbox .metadata .as_ref() @@ -1294,7 +1314,7 @@ pub(super) async fn handle_detach_sandbox_provider( let detached = detached.load(Ordering::Relaxed); info!( - sandbox_name = %request.sandbox_name, + sandbox_name = %sandbox_name, provider_name = %request.provider_name, detached, "DetachSandboxProvider request completed successfully" @@ -1329,21 +1349,16 @@ async fn handle_delete_sandbox_inner( ) -> Result, Status> { let principal = super::extract_principal(&request)?; let req = request.into_inner(); - let name = req.name; - if name.is_empty() { - return Err(Status::invalid_argument("name is required")); - } - let authz = authorize_workspace( - &state.store, - &state.admin_role, + let sandbox = resolve_and_authorize_sandbox_name( + state, &principal, - &req.workspace, + &req.sandbox_name, + req.workspace_scope.as_ref(), MinWorkspaceRole::User, ) .await?; - let workspace = super::workspace::resolve_workspace(state.store.as_ref(), &authz.workspace) - .await? - .name; + let workspace = sandbox.object_workspace().to_string(); + let name = sandbox.object_name().to_string(); let result = state.compute.delete_sandbox(&workspace, &name).await?; if result.deleted { @@ -1378,22 +1393,18 @@ async fn handle_stop_sandbox_inner( ) -> 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, + let resolved = resolve_and_authorize_sandbox_name( + state, &principal, - &req.workspace, + &req.sandbox_name, + req.workspace_scope.as_ref(), 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"); + let workspace = resolved.object_workspace(); + let name = resolved.object_name(); + let sandbox = state.compute.stop_sandbox(workspace, name).await?; + info!(sandbox_name = %name, "StopSandbox request completed successfully"); Ok(Response::new(SandboxResponse { sandbox: Some(sandbox), })) @@ -1422,44 +1433,23 @@ async fn handle_start_sandbox_inner( ) -> 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, + let resolved = resolve_and_authorize_sandbox_name( + state, &principal, - &req.workspace, + &req.sandbox_name, + req.workspace_scope.as_ref(), 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"); + let workspace = resolved.object_workspace(); + let name = resolved.object_name(); + let sandbox = state.compute.start_sandbox(workspace, name).await?; + info!(sandbox_name = %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, @@ -1508,12 +1498,15 @@ pub(super) async fn handle_watch_sandbox( ) -> Result, Status> { let principal = super::extract_principal(&request)?; let req = request.into_inner(); - if req.id.is_empty() { - return Err(Status::invalid_argument("id is required")); - } - let sandbox_id = req.id.clone(); - - let _sandbox = fetch_and_authorize_sandbox(state, &principal, &sandbox_id).await?; + let sandbox = resolve_and_authorize_sandbox_name( + state, + &principal, + &req.sandbox_name, + req.workspace_scope.as_ref(), + MinWorkspaceRole::User, + ) + .await?; + let sandbox_id = sandbox.object_id().to_string(); let follow_status = req.follow_status; let follow_logs = req.follow_logs; @@ -1767,9 +1760,6 @@ pub(super) async fn handle_exec_sandbox( let principal = super::extract_principal(&request)?; let req = request.into_inner(); - if req.sandbox_id.is_empty() { - return Err(Status::invalid_argument("sandbox_id is required")); - } if req.command.is_empty() { return Err(Status::invalid_argument("command is required")); } @@ -1780,7 +1770,14 @@ pub(super) async fn handle_exec_sandbox( } validate_exec_request_fields(&req)?; - let sandbox = fetch_and_authorize_sandbox(state, &principal, &req.sandbox_id).await?; + let sandbox = resolve_and_authorize_sandbox_name( + state, + &principal, + &req.sandbox_name, + req.workspace_scope.as_ref(), + MinWorkspaceRole::User, + ) + .await?; if SandboxPhase::try_from(sandbox.phase()).ok() != Some(SandboxPhase::Ready) { return Err(Status::failed_precondition("sandbox is not ready")); @@ -1896,7 +1893,14 @@ pub(super) async fn handle_forward_tcp( let target = validate_tcp_forward_init(&init)?; - let sandbox = fetch_and_authorize_sandbox(state, &principal, &init.sandbox_id).await?; + let sandbox = resolve_and_authorize_sandbox_name( + state, + &principal, + &init.sandbox_name, + init.workspace_scope.as_ref(), + MinWorkspaceRole::User, + ) + .await?; // The main process may finish between minting the SSH token and opening // its transport. Keep the relay reachable until terminal delivery is @@ -2054,10 +2058,6 @@ fn decrement_ssh_connection_count(counts: &std::sync::Mutex } fn validate_tcp_forward_init(init: &TcpForwardInit) -> Result { - if init.sandbox_id.is_empty() { - return Err(Status::invalid_argument("sandbox_id is required")); - } - if let Some(target) = init.target.as_ref() { return match target { tcp_forward_init::Target::Ssh(_) => { @@ -2189,9 +2189,6 @@ fn validate_interactive_exec_start( )); }; - if req.sandbox_id.is_empty() { - return Err(Status::invalid_argument("sandbox_id is required")); - } if req.command.is_empty() { return Err(Status::invalid_argument("command is required")); } @@ -2221,7 +2218,14 @@ pub(super) async fn handle_exec_sandbox_interactive( let req = validate_interactive_exec_start(first_msg)?; - let sandbox = fetch_and_authorize_sandbox(state, &principal, &req.sandbox_id).await?; + let sandbox = resolve_and_authorize_sandbox_name( + state, + &principal, + &req.sandbox_name, + req.workspace_scope.as_ref(), + MinWorkspaceRole::User, + ) + .await?; if SandboxPhase::try_from(sandbox.phase()).ok() != Some(SandboxPhase::Ready) { return Err(Status::failed_precondition("sandbox is not ready")); @@ -2299,11 +2303,15 @@ pub(super) async fn handle_create_ssh_session( ) -> Result, Status> { let principal = super::extract_principal(&request)?; let req = request.into_inner(); - if req.sandbox_id.is_empty() { - return Err(Status::invalid_argument("sandbox_id is required")); - } - - let sandbox = fetch_and_authorize_sandbox(state, &principal, &req.sandbox_id).await?; + let sandbox = resolve_and_authorize_sandbox_name( + state, + &principal, + &req.sandbox_name, + req.workspace_scope.as_ref(), + MinWorkspaceRole::User, + ) + .await?; + let sandbox_id = sandbox.object_id().to_string(); if !sandbox_relay_reachable(state, &sandbox) { return Err(Status::failed_precondition("sandbox is not ready")); @@ -2327,7 +2335,7 @@ pub(super) async fn handle_create_ssh_session( workspace: sandbox.object_workspace().to_string(), deletion_timestamp_ms: 0, }), - sandbox_id: req.sandbox_id.clone(), + sandbox_id: sandbox_id.clone(), token: token.clone(), revoked: false, expires_at_ms, @@ -2368,7 +2376,7 @@ pub(super) async fn handle_create_ssh_session( }; Ok(Response::new(CreateSshSessionResponse { - sandbox_id: req.sandbox_id, + sandbox_id, token, gateway_host, gateway_port: gateway_port.into(), @@ -3164,6 +3172,7 @@ mod tests { ..SandboxSpec::default() }), workload_template_name: "gpu-kata".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector("default")), ..CreateSandboxRequest::default() }; let created = Sandbox { @@ -3201,6 +3210,7 @@ mod tests { ..SandboxSpec::default() }), workload_template_name: "missing-template".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector("default")), ..CreateSandboxRequest::default() }; @@ -3268,7 +3278,8 @@ mod tests { fn build_remote_exec_command_basic() { use openshell_core::proto::ExecSandboxRequest; let req = ExecSandboxRequest { - sandbox_id: "test".to_string(), + sandbox_name: "test".to_string(), + workspace_scope: None, command: vec!["ls".to_string(), "-la".to_string()], ..Default::default() }; @@ -3279,7 +3290,8 @@ mod tests { fn build_remote_exec_command_with_env_and_workdir() { use openshell_core::proto::ExecSandboxRequest; let req = ExecSandboxRequest { - sandbox_id: "test".to_string(), + sandbox_name: "test".to_string(), + workspace_scope: None, command: vec![ "python".to_string(), "-c".to_string(), @@ -3299,7 +3311,8 @@ mod tests { fn build_remote_exec_command_rejects_null_bytes_in_args() { use openshell_core::proto::ExecSandboxRequest; let req = ExecSandboxRequest { - sandbox_id: "test".to_string(), + sandbox_name: "test".to_string(), + workspace_scope: None, command: vec!["echo".to_string(), "hello\x00world".to_string()], ..Default::default() }; @@ -3310,7 +3323,8 @@ mod tests { fn build_remote_exec_command_rejects_newlines_in_workdir() { use openshell_core::proto::ExecSandboxRequest; let req = ExecSandboxRequest { - sandbox_id: "test".to_string(), + sandbox_name: "test".to_string(), + workspace_scope: None, command: vec!["ls".to_string()], workdir: "/tmp\nmalicious".to_string(), ..Default::default() @@ -3323,7 +3337,8 @@ mod tests { fn build_remote_exec_command_accepts_multiline_script() { use openshell_core::proto::ExecSandboxRequest; let req = ExecSandboxRequest { - sandbox_id: "test".to_string(), + sandbox_name: "test".to_string(), + workspace_scope: None, command: vec![ "python3".to_string(), "-c".to_string(), @@ -3340,7 +3355,8 @@ mod tests { fn build_remote_exec_command_multiline_with_single_quotes() { use openshell_core::proto::ExecSandboxRequest; let req = ExecSandboxRequest { - sandbox_id: "test".to_string(), + sandbox_name: "test".to_string(), + workspace_scope: None, command: vec![ "python3".to_string(), "-c".to_string(), @@ -3360,7 +3376,8 @@ mod tests { fn tcp_forward_init_allows_loopback_targets() { for host in ["127.0.0.1", "::1", "localhost"] { let init = TcpForwardInit { - sandbox_id: "sbx".to_string(), + sandbox_name: "sbx".to_string(), + workspace_scope: None, service_id: String::new(), target: Some(tcp_forward_init::Target::Tcp(TcpRelayTarget { host: host.to_string(), @@ -3375,7 +3392,8 @@ mod tests { #[test] fn tcp_forward_init_allows_ssh_target() { let init = TcpForwardInit { - sandbox_id: "sbx".to_string(), + sandbox_name: "sbx".to_string(), + workspace_scope: None, target: Some(tcp_forward_init::Target::Ssh(SshRelayTarget::default())), ..Default::default() }; @@ -3388,7 +3406,8 @@ mod tests { #[test] fn tcp_forward_init_rejects_non_loopback_targets() { let init = TcpForwardInit { - sandbox_id: "sbx".to_string(), + sandbox_name: "sbx".to_string(), + workspace_scope: None, service_id: String::new(), target: Some(tcp_forward_init::Target::Tcp(TcpRelayTarget { host: "example.com".to_string(), @@ -3407,7 +3426,8 @@ mod tests { #[test] fn tcp_forward_init_rejects_invalid_port() { let init = TcpForwardInit { - sandbox_id: "sbx".to_string(), + sandbox_name: "sbx".to_string(), + workspace_scope: None, service_id: String::new(), target: Some(tcp_forward_init::Target::Tcp(TcpRelayTarget { host: "127.0.0.1".to_string(), @@ -3426,7 +3446,8 @@ mod tests { #[test] fn tcp_forward_init_requires_target() { let init = TcpForwardInit { - sandbox_id: "sbx".to_string(), + sandbox_name: "sbx".to_string(), + workspace_scope: None, ..Default::default() }; assert_eq!( @@ -3589,7 +3610,8 @@ mod tests { handle_watch_sandbox( &state, authed_request(WatchSandboxRequest { - id: sandbox.object_id().to_string(), + sandbox_name: "watched".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector("default")), ..Default::default() }), ) @@ -3632,8 +3654,10 @@ mod tests { handle_delete_sandbox_inner( &delete_state, authed_request(DeleteSandboxRequest { - name: "reused-name".to_string(), - workspace: "default".to_string(), + sandbox_name: "reused-name".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), }), ) .await @@ -3690,9 +3714,9 @@ mod tests { &state, authed_request(AttachSandboxProviderRequest { sandbox_name: "work".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector("default")), provider_name: "work-github".to_string(), expected_resource_version: 0, - workspace: String::new(), }), ) .await @@ -3731,9 +3755,9 @@ mod tests { &state, authed_request(AttachSandboxProviderRequest { sandbox_name: "work".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector("default")), provider_name: "work-github".to_string(), expected_resource_version: 0, - workspace: String::new(), }), ) .await @@ -3769,9 +3793,9 @@ mod tests { &state, authed_request(AttachSandboxProviderRequest { sandbox_name: "work".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector("default")), provider_name: "work-github".to_string(), expected_resource_version: 0, - workspace: String::new(), }), ) .await @@ -3820,9 +3844,9 @@ mod tests { &state, authed_request(DetachSandboxProviderRequest { sandbox_name: "work".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector("default")), provider_name: "work-github".to_string(), expected_resource_version: 0, - workspace: String::new(), }), ) .await @@ -3845,9 +3869,9 @@ mod tests { &state, authed_request(DetachSandboxProviderRequest { sandbox_name: "work".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector("default")), provider_name: "work-github".to_string(), expected_resource_version: 0, - workspace: String::new(), }), ) .await @@ -3892,9 +3916,9 @@ mod tests { &state, authed_request(DetachSandboxProviderRequest { sandbox_name: "work".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector("default")), provider_name: "work-gcp".to_string(), expected_resource_version: 0, - workspace: String::new(), }), ) .await @@ -3932,7 +3956,7 @@ mod tests { &state, authed_request(ListSandboxProvidersRequest { sandbox_name: "work".to_string(), - workspace: String::new(), + workspace_scope: Some(openshell_core::proto::workspace_selector("default")), }), ) .await @@ -3960,9 +3984,9 @@ mod tests { &state, authed_request(AttachSandboxProviderRequest { sandbox_name: "work".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector("default")), provider_name: "missing".to_string(), expected_resource_version: 0, - workspace: String::new(), }), ) .await @@ -4012,7 +4036,7 @@ mod tests { } #[test] - fn interactive_exec_rejects_missing_sandbox_id() { + fn interactive_exec_rejects_missing_sandbox_name() { use openshell_core::proto::exec_sandbox_input; let msg = ExecSandboxInput { payload: Some(exec_sandbox_input::Payload::Start(ExecSandboxRequest { @@ -4022,7 +4046,7 @@ mod tests { }; let err = validate_interactive_exec_start(Some(msg)).unwrap_err(); assert_eq!(err.code(), tonic::Code::InvalidArgument); - assert!(err.message().contains("sandbox_id")); + assert!(err.message().contains("sandbox_name")); } #[test] @@ -4030,7 +4054,8 @@ mod tests { use openshell_core::proto::exec_sandbox_input; let msg = ExecSandboxInput { payload: Some(exec_sandbox_input::Payload::Start(ExecSandboxRequest { - sandbox_id: "test-id".to_string(), + sandbox_name: "test-id".to_string(), + workspace_scope: None, ..Default::default() })), }; @@ -4044,7 +4069,8 @@ mod tests { use openshell_core::proto::exec_sandbox_input; let msg = ExecSandboxInput { payload: Some(exec_sandbox_input::Payload::Start(ExecSandboxRequest { - sandbox_id: "test-id".to_string(), + sandbox_name: "test-id".to_string(), + workspace_scope: None, command: vec!["bash".to_string()], environment: std::iter::once(("bad key!".to_string(), "val".to_string())).collect(), ..Default::default() @@ -4060,7 +4086,8 @@ mod tests { use openshell_core::proto::exec_sandbox_input; let msg = ExecSandboxInput { payload: Some(exec_sandbox_input::Payload::Start(ExecSandboxRequest { - sandbox_id: "test-id".to_string(), + sandbox_name: "test-id".to_string(), + workspace_scope: None, command: vec!["bash".to_string()], tty: true, cols: 120, @@ -4069,7 +4096,7 @@ mod tests { })), }; let req = validate_interactive_exec_start(Some(msg)).unwrap(); - assert_eq!(req.sandbox_id, "test-id"); + assert_eq!(req.sandbox_name, "test-id"); assert_eq!(req.command, vec!["bash"]); assert!(req.tty); assert_eq!(req.cols, 120); @@ -4081,14 +4108,15 @@ mod tests { let state = test_server_state().await; let req = ExecSandboxRequest { - sandbox_id: "nonexistent".to_string(), + sandbox_name: "nonexistent".to_string(), + workspace_scope: None, command: vec!["bash".to_string()], tty: true, ..Default::default() }; let sandbox_result = state .store - .get_message::(&req.sandbox_id) + .get_message_by_name::("default", &req.sandbox_name) .await .unwrap(); assert!(sandbox_result.is_none()); @@ -4137,7 +4165,7 @@ mod tests { }), labels: HashMap::new(), annotations: HashMap::new(), - workspace: String::new(), + workspace_scope: Some(openshell_core::proto::workspace_selector("default")), await_main_process_attachment: false, workload_template_name: String::new(), }), @@ -4162,7 +4190,7 @@ mod tests { spec: Some(SandboxSpec::default()), labels: HashMap::new(), annotations: HashMap::new(), - workspace: String::new(), + workspace_scope: Some(openshell_core::proto::workspace_selector("default")), await_main_process_attachment: false, workload_template_name: String::new(), }), @@ -4199,7 +4227,7 @@ mod tests { }), labels: HashMap::new(), annotations: HashMap::new(), - workspace: String::new(), + workspace_scope: Some(openshell_core::proto::workspace_selector("default")), await_main_process_attachment: false, workload_template_name: String::new(), }), @@ -4306,7 +4334,7 @@ mod tests { }), labels: HashMap::new(), annotations: HashMap::new(), - workspace: String::new(), + workspace_scope: Some(openshell_core::proto::workspace_selector("default")), ..Default::default() }), ) @@ -4363,7 +4391,7 @@ mod tests { }), labels: HashMap::new(), annotations: HashMap::new(), - workspace: String::new(), + workspace_scope: Some(openshell_core::proto::workspace_selector("default")), ..Default::default() }), ) @@ -4462,7 +4490,7 @@ mod tests { }), labels: HashMap::new(), annotations: HashMap::new(), - workspace: String::new(), + workspace_scope: Some(openshell_core::proto::workspace_selector("default")), ..Default::default() }), ) @@ -4530,7 +4558,7 @@ mod tests { }), labels: HashMap::new(), annotations: HashMap::new(), - workspace: String::new(), + workspace_scope: Some(openshell_core::proto::workspace_selector("default")), ..Default::default() }), ) @@ -4563,7 +4591,7 @@ mod tests { spec: Some(SandboxSpec::default()), labels: HashMap::new(), annotations: HashMap::from([(annotation_key.clone(), annotation_value.clone())]), - workspace: String::new(), + workspace_scope: Some(openshell_core::proto::workspace_selector("default")), await_main_process_attachment: false, workload_template_name: String::new(), }), @@ -4584,8 +4612,8 @@ mod tests { let fetched = handle_get_sandbox( &state, authed_request(GetSandboxRequest { - name: "annotated".to_string(), - workspace: String::new(), + sandbox_name: "annotated".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector("default")), }), ) .await @@ -4624,7 +4652,7 @@ mod tests { }), labels: HashMap::new(), annotations: HashMap::new(), - workspace: String::new(), + workspace_scope: Some(openshell_core::proto::workspace_selector("default")), await_main_process_attachment: false, workload_template_name: String::new(), }), @@ -4648,8 +4676,8 @@ mod tests { let fetched_process = handle_get_sandbox( &state, authed_request(GetSandboxRequest { - name: "partial-id".to_string(), - workspace: String::new(), + sandbox_name: "partial-id".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector("default")), }), ) .await @@ -4689,7 +4717,7 @@ mod tests { }), labels: HashMap::new(), annotations: HashMap::new(), - workspace: String::new(), + workspace_scope: Some(openshell_core::proto::workspace_selector("default")), await_main_process_attachment: false, workload_template_name: String::new(), }), @@ -4721,7 +4749,7 @@ mod tests { spec: Some(SandboxSpec::default()), labels: HashMap::from([("team".to_string(), "x".repeat(512))]), annotations: HashMap::new(), - workspace: String::new(), + workspace_scope: Some(openshell_core::proto::workspace_selector("default")), await_main_process_attachment: false, workload_template_name: String::new(), }), @@ -4755,7 +4783,7 @@ mod tests { }), labels: HashMap::new(), annotations: HashMap::new(), - workspace: String::new(), + workspace_scope: Some(openshell_core::proto::workspace_selector("default")), await_main_process_attachment: false, workload_template_name: String::new(), }), @@ -4790,7 +4818,9 @@ mod tests { &state, authed_request(CreateSandboxTemplateRequest { template: Some(test_workload_template("gpu-kata")), - workspace: "default".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), }), ) .await @@ -4809,7 +4839,9 @@ mod tests { &state, authed_request(GetSandboxTemplateRequest { name: "gpu-kata".to_string(), - workspace: "default".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), }), ) .await @@ -4825,8 +4857,9 @@ mod tests { authed_request(ListSandboxTemplatesRequest { limit: 100, offset: 0, - workspace: "default".to_string(), - all_workspaces: false, + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), label_selector: String::new(), }), ) @@ -4841,7 +4874,9 @@ mod tests { &state, authed_request(DeleteSandboxTemplateRequest { name: "gpu-kata".to_string(), - workspace: "default".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), }), ) .await @@ -4853,7 +4888,9 @@ mod tests { &state, authed_request(GetSandboxTemplateRequest { name: "gpu-kata".to_string(), - workspace: "default".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), }), ) .await @@ -4875,7 +4912,9 @@ mod tests { &state, authed_request(CreateSandboxTemplateRequest { template: Some(gpu), - workspace: "default".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), }), ) .await @@ -4891,7 +4930,9 @@ mod tests { &state, authed_request(CreateSandboxTemplateRequest { template: Some(cpu), - workspace: "default".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), }), ) .await @@ -4902,8 +4943,9 @@ mod tests { authed_request(ListSandboxTemplatesRequest { limit: 100, offset: 0, - workspace: "default".to_string(), - all_workspaces: false, + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), label_selector: "team=runtime".to_string(), }), ) @@ -4924,7 +4966,9 @@ mod tests { &state, authed_request(CreateSandboxTemplateRequest { template: Some(test_workload_template(" gpu-kata ")), - workspace: "default".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), }), ) .await @@ -4938,8 +4982,9 @@ mod tests { authed_request(ListSandboxTemplatesRequest { limit: 100, offset: 0, - workspace: "default".to_string(), - all_workspaces: false, + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), label_selector: String::new(), }), ) @@ -4972,7 +5017,7 @@ mod tests { &state, authed_request(CreateSandboxTemplateRequest { template: Some(template), - workspace: String::new(), + workspace_scope: Some(openshell_core::proto::workspace_selector("default")), }), ) .await @@ -4986,8 +5031,9 @@ mod tests { authed_request(ListSandboxTemplatesRequest { limit: 100, offset: 0, - workspace: "beta".to_string(), - all_workspaces: false, + workspace_scope: Some(openshell_core::proto::workspace_selector( + "beta".to_string(), + )), label_selector: String::new(), }), ) @@ -5008,7 +5054,9 @@ mod tests { &state, authed_request(CreateSandboxTemplateRequest { template: Some(template), - workspace: "default".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), }), ) .await @@ -5145,7 +5193,9 @@ mod tests { &state, authed_request(CreateSandboxTemplateRequest { template: Some(test_workload_template("overflow")), - workspace: "default".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), }), ) .await @@ -5174,7 +5224,9 @@ mod tests { &state, authed_request(CreateSandboxTemplateRequest { template: Some(test_workload_template(&format!("overflow-{index}"))), - workspace: "default".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), }), ) .await @@ -5272,7 +5324,9 @@ mod tests { &state, authed_request(CreateSandboxTemplateRequest { template: Some(test_workload_template("gpu-kata")), - workspace: "default".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), }), ) .await @@ -5303,7 +5357,9 @@ mod tests { }), labels: HashMap::new(), annotations: HashMap::new(), - workspace: "default".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), workload_template_name: "gpu-kata".to_string(), await_main_process_attachment: false, }), @@ -5372,7 +5428,9 @@ mod tests { &state, authed_request(CreateSandboxTemplateRequest { template: Some(template), - workspace: "default".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), }), ) .await @@ -5385,7 +5443,9 @@ mod tests { spec: Some(SandboxSpec::default()), labels: HashMap::new(), annotations: HashMap::new(), - workspace: "default".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), workload_template_name: "default-image".to_string(), await_main_process_attachment: false, }), @@ -5419,7 +5479,9 @@ mod tests { &state, authed_request(CreateSandboxTemplateRequest { template: Some(template), - workspace: "default".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), }), ) .await @@ -5432,7 +5494,9 @@ mod tests { spec: Some(SandboxSpec::default()), labels: HashMap::new(), annotations: HashMap::new(), - workspace: "default".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), workload_template_name: "default-gpu".to_string(), await_main_process_attachment: false, }), @@ -5469,7 +5533,9 @@ mod tests { spec: Some(SandboxSpec::default()), labels: HashMap::new(), annotations: HashMap::new(), - workspace: "default".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), workload_template_name: "corrupt-template".to_string(), await_main_process_attachment: false, }), @@ -5488,7 +5554,9 @@ mod tests { &state, authed_request(CreateSandboxTemplateRequest { template: Some(test_workload_template("gpu-kata")), - workspace: "default".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), }), ) .await @@ -5504,7 +5572,9 @@ mod tests { }), labels: HashMap::new(), annotations: HashMap::new(), - workspace: "default".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), workload_template_name: "gpu-kata".to_string(), await_main_process_attachment: false, }), @@ -5527,7 +5597,9 @@ mod tests { spec: Some(SandboxSpec::default()), labels: HashMap::new(), annotations: HashMap::new(), - workspace: "default".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), workload_template_name: "Invalid_Template_Name".to_string(), await_main_process_attachment: false, }), @@ -5553,7 +5625,9 @@ mod tests { }), labels: HashMap::new(), annotations: HashMap::new(), - workspace: "default".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), workload_template_name: "missing-template".to_string(), await_main_process_attachment: false, }), @@ -5579,7 +5653,9 @@ mod tests { }), labels: HashMap::new(), annotations: HashMap::new(), - workspace: "missing-workspace".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "missing-workspace".to_string(), + )), workload_template_name: String::new(), await_main_process_attachment: false, }), @@ -5614,9 +5690,9 @@ mod tests { &state, authed_request(AttachSandboxProviderRequest { sandbox_name: "work".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector("default")), provider_name: "provider-b".to_string(), expected_resource_version: 0, - workspace: String::new(), }), ) .await @@ -5661,9 +5737,9 @@ mod tests { &state, authed_request(AttachSandboxProviderRequest { sandbox_name: "work".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector("default")), provider_name: "provider-31".to_string(), expected_resource_version: 0, - workspace: String::new(), }), ) .await @@ -5716,9 +5792,9 @@ mod tests { &state, authed_request(AttachSandboxProviderRequest { sandbox_name: "work".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector("default")), provider_name: "provider-32".to_string(), expected_resource_version: 0, - workspace: String::new(), }), ) .await @@ -5763,9 +5839,9 @@ mod tests { &state, authed_request(AttachSandboxProviderRequest { sandbox_name: "work".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector("default")), provider_name: long_name, expected_resource_version: 0, - workspace: String::new(), }), ) .await @@ -5790,9 +5866,9 @@ mod tests { &state, authed_request(DetachSandboxProviderRequest { sandbox_name: "work".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector("default")), provider_name: long_name, expected_resource_version: 0, - workspace: String::new(), }), ) .await @@ -5818,7 +5894,8 @@ mod tests { handle_create_ssh_session( &state1, authed_request(CreateSshSessionRequest { - sandbox_id: "sandbox-work".to_string(), + sandbox_name: "work".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector("default")), }), ) .await @@ -5829,7 +5906,8 @@ mod tests { handle_create_ssh_session( &state2, authed_request(CreateSshSessionRequest { - sandbox_id: "sandbox-work".to_string(), + sandbox_name: "work".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector("default")), }), ) .await @@ -5882,7 +5960,8 @@ mod tests { let response = handle_create_ssh_session( &state, authed_request(CreateSshSessionRequest { - sandbox_id: "sandbox-work".to_string(), + sandbox_name: "work".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector("default")), }), ) .await; @@ -5910,7 +5989,8 @@ mod tests { let response = handle_create_ssh_session( &state, authed_request(CreateSshSessionRequest { - sandbox_id: "sandbox-work".to_string(), + sandbox_name: "work".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector("default")), }), ) .await @@ -5989,9 +6069,9 @@ mod tests { &state, authed_request(AttachSandboxProviderRequest { sandbox_name: "work".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector("default")), provider_name: "github".to_string(), expected_resource_version: current_version, - workspace: String::new(), }), ) .await @@ -6041,9 +6121,9 @@ mod tests { &state, authed_request(AttachSandboxProviderRequest { sandbox_name: "work".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector("default")), provider_name: "github".to_string(), expected_resource_version: 99, - workspace: String::new(), }), ) .await @@ -6104,9 +6184,9 @@ mod tests { &state, authed_request(DetachSandboxProviderRequest { sandbox_name: "work".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector("default")), provider_name: "github".to_string(), expected_resource_version: current_version, - workspace: String::new(), }), ) .await @@ -6156,9 +6236,9 @@ mod tests { &state, authed_request(DetachSandboxProviderRequest { sandbox_name: "work".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector("default")), provider_name: "github".to_string(), expected_resource_version: 99, - workspace: String::new(), }), ) .await @@ -6237,9 +6317,9 @@ mod tests { &state_clone, authed_request(AttachSandboxProviderRequest { sandbox_name: "work".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector("default")), provider_name: format!("provider-{i}"), expected_resource_version: initial_version, - workspace: String::new(), }), ) .await @@ -6322,8 +6402,10 @@ mod tests { let got = handle_get_sandbox( &state, authed_request(GetSandboxRequest { - name: "shared-name".to_string(), - workspace: "default".to_string(), + sandbox_name: "shared-name".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), }), ) .await @@ -6335,8 +6417,10 @@ mod tests { let got = handle_get_sandbox( &state, authed_request(GetSandboxRequest { - name: "shared-name".to_string(), - workspace: "beta".to_string(), + sandbox_name: "shared-name".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "beta".to_string(), + )), }), ) .await @@ -6351,8 +6435,9 @@ mod tests { limit: 100, offset: 0, label_selector: String::new(), - workspace: "default".to_string(), - all_workspaces: false, + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), }), ) .await @@ -6368,8 +6453,9 @@ mod tests { limit: 100, offset: 0, label_selector: String::new(), - workspace: "beta".to_string(), - all_workspaces: false, + workspace_scope: Some(openshell_core::proto::workspace_selector( + "beta".to_string(), + )), }), ) .await @@ -6392,8 +6478,9 @@ mod tests { limit: 100, offset: 0, label_selector: String::new(), - workspace: "default".to_string(), - all_workspaces: false, + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), }), ) .await @@ -6405,8 +6492,10 @@ mod tests { let got = handle_get_sandbox( &state, authed_request(GetSandboxRequest { - name: "shared-name".to_string(), - workspace: "beta".to_string(), + sandbox_name: "shared-name".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "beta".to_string(), + )), }), ) .await @@ -6434,35 +6523,18 @@ mod tests { limit: 100, offset: 0, label_selector: String::new(), - workspace: String::new(), - all_workspaces: true, + workspace_scope: Some(openshell_core::proto::all_workspaces_selector()), }), ) .await .unwrap() .into_inner(); assert_eq!(listed.sandboxes.len(), 2); - - // all_workspaces with non-empty workspace is rejected. - let err = handle_list_sandboxes( - &state, - authed_request(ListSandboxesRequest { - limit: 100, - offset: 0, - label_selector: String::new(), - workspace: "default".to_string(), - all_workspaces: true, - }), - ) - .await - .unwrap_err(); - assert_eq!(err.code(), tonic::Code::InvalidArgument); } - /// Non-members must receive `PERMISSION_DENIED` — never `NOT_FOUND` — when - /// calling workspace-scoped sandbox RPCs with a workspace they do not belong - /// to. If `authorize_workspace` ran *after* a store lookup the error code - /// would leak whether the workspace name exists (CWE-203 oracle). + /// Workspace collection operations reject non-members, while operations on + /// a sandbox reference hide both missing and unauthorized sandboxes as + /// `NOT_FOUND` to avoid an object-existence oracle. #[tokio::test] async fn non_member_gets_permission_denied_not_workspace_oracle() { use crate::auth::identity::{Identity, IdentityProvider}; @@ -6492,7 +6564,7 @@ mod tests { let err = handle_create_sandbox( &state, non_member_request(CreateSandboxRequest { - workspace: "no-such-ws".into(), + workspace_scope: Some(openshell_core::proto::workspace_selector("no-such-ws")), spec: Some(SandboxSpec::default()), ..Default::default() }), @@ -6510,23 +6582,23 @@ mod tests { let err = handle_get_sandbox( &state, non_member_request(GetSandboxRequest { - workspace: "no-such-ws".into(), - name: "any".into(), + sandbox_name: ("any").to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector("no-such-ws")), }), ) .await .unwrap_err(); assert_eq!( err.code(), - Code::PermissionDenied, - "handle_get_sandbox should reject non-members with PermissionDenied" + Code::NotFound, + "handle_get_sandbox should hide unauthorized sandbox existence" ); // --- handle_list_sandboxes --- let err = handle_list_sandboxes( &state, non_member_request(ListSandboxesRequest { - workspace: "no-such-ws".into(), + workspace_scope: Some(openshell_core::proto::workspace_selector("no-such-ws")), ..Default::default() }), ) @@ -6542,23 +6614,24 @@ mod tests { let err = handle_list_sandbox_providers( &state, non_member_request(ListSandboxProvidersRequest { - workspace: "no-such-ws".into(), - ..Default::default() + sandbox_name: ("any").to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector("no-such-ws")), }), ) .await .unwrap_err(); assert_eq!( err.code(), - Code::PermissionDenied, - "handle_list_sandbox_providers should reject non-members with PermissionDenied" + Code::NotFound, + "handle_list_sandbox_providers should hide unauthorized sandbox existence" ); // --- handle_attach_sandbox_provider --- let err = handle_attach_sandbox_provider( &state, non_member_request(AttachSandboxProviderRequest { - workspace: "no-such-ws".into(), + sandbox_name: ("any").to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector("no-such-ws")), ..Default::default() }), ) @@ -6566,15 +6639,16 @@ mod tests { .unwrap_err(); assert_eq!( err.code(), - Code::PermissionDenied, - "handle_attach_sandbox_provider should reject non-members with PermissionDenied" + Code::NotFound, + "handle_attach_sandbox_provider should hide unauthorized sandbox existence" ); // --- handle_detach_sandbox_provider --- let err = handle_detach_sandbox_provider( &state, non_member_request(DetachSandboxProviderRequest { - workspace: "no-such-ws".into(), + sandbox_name: ("any").to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector("no-such-ws")), ..Default::default() }), ) @@ -6582,8 +6656,8 @@ mod tests { .unwrap_err(); assert_eq!( err.code(), - Code::PermissionDenied, - "handle_detach_sandbox_provider should reject non-members with PermissionDenied" + Code::NotFound, + "handle_detach_sandbox_provider should hide unauthorized sandbox existence" ); // --- handle_delete_sandbox --- @@ -6591,49 +6665,49 @@ mod tests { let err = handle_delete_sandbox( &state, non_member_request(DeleteSandboxRequest { - workspace: "no-such-ws".into(), - name: "any".into(), + sandbox_name: ("any").to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector("no-such-ws")), }), ) .await .unwrap_err(); assert_eq!( err.code(), - Code::PermissionDenied, - "handle_delete_sandbox should reject non-members with PermissionDenied" + Code::NotFound, + "handle_delete_sandbox should hide unauthorized sandbox existence" ); for result in [ handle_stop_sandbox( &state, non_member_request(StopSandboxRequest { - workspace: "no-such-ws".into(), - name: "any".into(), + sandbox_name: ("any").to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector("no-such-ws")), }), ) .await, handle_start_sandbox( &state, non_member_request(StartSandboxRequest { - workspace: "no-such-ws".into(), - name: "any".into(), + sandbox_name: ("any").to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector("no-such-ws")), }), ) .await, ] { assert_eq!( result.unwrap_err().code(), - Code::PermissionDenied, - "lifecycle handlers should reject non-members" + Code::NotFound, + "lifecycle handlers should hide unauthorized sandbox existence" ); } } - /// ID-based data-plane handlers must return `NOT_FOUND` — never + /// Name-based data-plane handlers must return `NOT_FOUND` — never /// `PERMISSION_DENIED` — when the caller lacks workspace access, so that /// cross-workspace sandbox existence cannot be inferred (CWE-203). #[tokio::test] - async fn id_based_handlers_hide_cross_workspace_sandboxes() { + async fn name_based_handlers_hide_cross_workspace_sandboxes() { use crate::auth::identity::{Identity, IdentityProvider}; use crate::auth::principal::{Principal, UserPrincipal}; use tonic::Code; @@ -6663,7 +6737,8 @@ mod tests { let err = handle_watch_sandbox( &state, non_member_request(WatchSandboxRequest { - id: "sandbox-cross-ws".into(), + sandbox_name: "cross-ws".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector("other-workspace")), ..Default::default() }), ) @@ -6679,7 +6754,8 @@ mod tests { let err = handle_create_ssh_session( &state, non_member_request(CreateSshSessionRequest { - sandbox_id: "sandbox-cross-ws".into(), + sandbox_name: "cross-ws".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector("other-workspace")), }), ) .await @@ -6703,7 +6779,8 @@ mod tests { let response = handle_create_ssh_session( &state, authed_request(CreateSshSessionRequest { - sandbox_id: "sandbox-ws-test".to_string(), + sandbox_name: "ws-test".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector("default")), }), ) .await diff --git a/crates/openshell-server/src/grpc/service.rs b/crates/openshell-server/src/grpc/service.rs index 790e26d618..8b075dd0e1 100644 --- a/crates/openshell-server/src/grpc/service.rs +++ b/crates/openshell-server/src/grpc/service.rs @@ -7,20 +7,21 @@ use std::sync::Arc; use openshell_core::proto::datamodel::v1::ObjectMeta; use openshell_core::proto::{ DeleteServiceRequest, DeleteServiceResponse, ExposeServiceRequest, GetServiceRequest, - ListServicesRequest, ListServicesResponse, Sandbox, ServiceEndpoint, ServiceEndpointResponse, + ListServicesRequest, ListServicesResponse, ServiceEndpoint, ServiceEndpointResponse, }; -use openshell_core::{ObjectId, ObjectWorkspace}; +use openshell_core::{ObjectId, ObjectName, ObjectWorkspace}; use prost::Message as _; use tonic::{Request, Response, Status}; use uuid::Uuid; use crate::ServerState; -use crate::auth::workspace_authz::{MinWorkspaceRole, authorize_workspace, require_platform_admin}; +use crate::auth::workspace_authz::{ + AuthorizedWorkspaceScope, MinWorkspaceRole, authorize_list_workspace_selector, +}; use crate::persistence::{ObjectType, WriteCondition}; use crate::service_routing; const MAX_SERVICE_NAME_LEN: usize = super::MAX_ROUTABLE_NAME_LEN; -const MAX_SANDBOX_NAME_LEN: usize = super::MAX_ROUTABLE_NAME_LEN; pub(super) async fn handle_expose_service( state: &Arc, @@ -28,32 +29,26 @@ pub(super) async fn handle_expose_service( ) -> Result, Status> { let principal = super::extract_principal(&request)?; let req = request.into_inner(); - let authz = authorize_workspace( - &state.store, - &state.admin_role, + let sandbox = super::sandbox::resolve_and_authorize_sandbox_name( + state, &principal, - &req.workspace, + &req.sandbox_name, + req.workspace_scope.as_ref(), MinWorkspaceRole::User, ) .await?; - let workspace = super::workspace::resolve_workspace(state.store.as_ref(), &authz.workspace) - .await? - .ensure_active()?; - validate_endpoint_name("sandbox", &req.sandbox, MAX_SANDBOX_NAME_LEN)?; + let workspace = + super::workspace::resolve_workspace(state.store.as_ref(), sandbox.object_workspace()) + .await? + .ensure_active()?; + let sandbox_name = sandbox.object_name(); validate_optional_endpoint_name("service", &req.service, MAX_SERVICE_NAME_LEN)?; if req.target_port == 0 || req.target_port > u32::from(u16::MAX) { return Err(Status::invalid_argument("target_port must be in 1..=65535")); } - let sandbox = state - .store - .get_message_by_name::(&workspace, &req.sandbox) - .await - .map_err(|e| Status::internal(format!("fetch sandbox failed: {e}")))? - .ok_or_else(|| Status::not_found("sandbox not found"))?; - let now = crate::persistence::current_time_ms(); - let key = service_routing::endpoint_key(&req.sandbox, &req.service); + let key = service_routing::endpoint_key(sandbox_name, &req.service); // Fetch existing endpoint to determine create vs. update path let existing = state @@ -89,7 +84,7 @@ pub(super) async fn handle_expose_service( let labels_json = serde_json::to_string(&HashMap::from([( "sandbox".to_string(), - req.sandbox.clone(), + sandbox_name.to_string(), )])) .map_err(|e| Status::internal(format!("serialize labels failed: {e}")))?; @@ -98,14 +93,14 @@ pub(super) async fn handle_expose_service( id: id.clone(), name: key.clone(), created_at_ms, - labels: HashMap::from([("sandbox".to_string(), req.sandbox.clone())]), + labels: HashMap::from([("sandbox".to_string(), sandbox_name.to_string())]), resource_version: 0, annotations: HashMap::new(), workspace: workspace.clone(), deletion_timestamp_ms: 0, }), sandbox_id: sandbox.object_id().to_string(), - sandbox_name: req.sandbox.clone(), + sandbox_name: sandbox_name.to_string(), service_name: req.service.clone(), target_port: req.target_port, domain: true, @@ -131,7 +126,7 @@ pub(super) async fn handle_expose_service( meta.resource_version = result.resource_version; } - let url = service_routing::endpoint_url(&state.config, &workspace, &req.sandbox, &req.service) + let url = service_routing::endpoint_url(&state.config, &workspace, sandbox_name, &req.service) .unwrap_or_default(); service_routing::emit_service_endpoint_config_event(&endpoint, &url, created); @@ -147,21 +142,19 @@ pub(super) async fn handle_get_service( ) -> Result, Status> { let principal = super::extract_principal(&request)?; let req = request.into_inner(); - let authz = authorize_workspace( - &state.store, - &state.admin_role, + let sandbox = super::sandbox::resolve_and_authorize_sandbox_name( + state, &principal, - &req.workspace, + &req.sandbox_name, + req.workspace_scope.as_ref(), MinWorkspaceRole::User, ) .await?; - let workspace = super::workspace::resolve_workspace(state.store.as_ref(), &authz.workspace) - .await? - .name; - validate_endpoint_name("sandbox", &req.sandbox, MAX_SANDBOX_NAME_LEN)?; + let workspace = sandbox.object_workspace(); + let sandbox_name = sandbox.object_name(); validate_optional_endpoint_name("service", &req.service, MAX_SERVICE_NAME_LEN)?; - let endpoint = get_service_endpoint(state, &workspace, &req.sandbox, &req.service) + let endpoint = get_service_endpoint(state, workspace, sandbox_name, &req.service) .await? .ok_or_else(|| Status::not_found("service endpoint not found"))?; @@ -174,54 +167,57 @@ pub(super) async fn handle_list_services( ) -> Result, Status> { let principal = super::extract_principal(&request)?; let req = request.into_inner(); - if req.all_workspaces && !req.workspace.is_empty() { - return Err(Status::invalid_argument( - "all_workspaces and workspace are mutually exclusive", - )); - } - if !req.sandbox.is_empty() { - validate_endpoint_name("sandbox", &req.sandbox, MAX_SANDBOX_NAME_LEN)?; - } - let limit = super::clamp_limit(req.limit, 100, super::MAX_PAGE_SIZE); - let endpoints: Vec = if req.all_workspaces { - require_platform_admin(&state.admin_role, &principal)?; - if !req.sandbox.is_empty() { - return Err(Status::invalid_argument( - "sandbox filter is not supported with all_workspaces", - )); - } - state.store.list_all_messages(limit, req.offset).await - } else { - let authz = authorize_workspace( - &state.store, - &state.admin_role, + if !req.sandbox_name.is_empty() { + let sandbox = super::sandbox::resolve_and_authorize_sandbox_name( + state, &principal, - &req.workspace, + &req.sandbox_name, + req.workspace_scope.as_ref(), MinWorkspaceRole::User, ) .await?; - let workspace = super::workspace::resolve_workspace(state.store.as_ref(), &authz.workspace) - .await? - .name; - if req.sandbox.is_empty() { - state - .store - .list_messages(&workspace, limit, req.offset) - .await + let endpoints = state + .store + .list_messages_with_selector::( + sandbox.object_workspace(), + &format!("sandbox={}", sandbox.object_name()), + limit, + req.offset, + ) + .await + .map_err(|e| Status::internal(format!("list endpoints failed: {e}")))?; + let services = endpoints + .into_iter() + .map(|endpoint| service_endpoint_response(state, endpoint)) + .collect(); + return Ok(Response::new(ListServicesResponse { services })); + } + let scope = authorize_list_workspace_selector( + &state.store, + &state.admin_role, + &principal, + req.workspace_scope.as_ref(), + MinWorkspaceRole::User, + ) + .await?; + let endpoints: Vec = + if matches!(scope, AuthorizedWorkspaceScope::AllWorkspaces) { + state.store.list_all_messages(limit, req.offset).await } else { + let AuthorizedWorkspaceScope::Workspace(authz) = scope else { + unreachable!("all-workspaces scope handled above") + }; + let workspace = + super::workspace::resolve_workspace(state.store.as_ref(), &authz.workspace) + .await? + .name; state .store - .list_messages_with_selector( - &workspace, - &format!("sandbox={}", req.sandbox), - limit, - req.offset, - ) + .list_messages(&workspace, limit, req.offset) .await } - } - .map_err(|e| Status::internal(format!("list endpoints failed: {e}")))?; + .map_err(|e| Status::internal(format!("list endpoints failed: {e}")))?; let services = endpoints .into_iter() @@ -237,29 +233,27 @@ pub(super) async fn handle_delete_service( ) -> Result, Status> { let principal = super::extract_principal(&request)?; let req = request.into_inner(); - let authz = authorize_workspace( - &state.store, - &state.admin_role, + let sandbox = super::sandbox::resolve_and_authorize_sandbox_name( + state, &principal, - &req.workspace, + &req.sandbox_name, + req.workspace_scope.as_ref(), MinWorkspaceRole::User, ) .await?; - let workspace = super::workspace::resolve_workspace(state.store.as_ref(), &authz.workspace) - .await? - .name; - validate_endpoint_name("sandbox", &req.sandbox, MAX_SANDBOX_NAME_LEN)?; + let workspace = sandbox.object_workspace(); + let sandbox_name = sandbox.object_name(); validate_optional_endpoint_name("service", &req.service, MAX_SERVICE_NAME_LEN)?; - let endpoint = get_service_endpoint(state, &workspace, &req.sandbox, &req.service).await?; + let endpoint = get_service_endpoint(state, workspace, sandbox_name, &req.service).await?; let Some(endpoint) = endpoint else { return Ok(Response::new(DeleteServiceResponse { deleted: false })); }; - let key = service_routing::endpoint_key(&req.sandbox, &req.service); + let key = service_routing::endpoint_key(sandbox_name, &req.service); let deleted = state .store - .delete_by_name(ServiceEndpoint::object_type(), &workspace, &key) + .delete_by_name(ServiceEndpoint::object_type(), workspace, &key) .await .map_err(|e| Status::internal(format!("delete endpoint failed: {e}")))?; @@ -303,6 +297,7 @@ fn service_endpoint_response( } #[allow(clippy::result_large_err)] +#[cfg(test)] fn validate_endpoint_name(field: &str, value: &str, max_len: usize) -> Result<(), Status> { if value.is_empty() { return Err(Status::invalid_argument(format!("{field} is required"))); @@ -355,7 +350,7 @@ fn is_dns_label(value: &str) -> bool { mod tests { use super::*; use crate::grpc::test_support::{authed_request, test_server_state}; - use openshell_core::proto::SandboxPhase; + use openshell_core::proto::{Sandbox, SandboxPhase}; async fn seed_sandbox(state: &Arc, name: &str) { let mut sandbox = Sandbox { @@ -409,11 +404,13 @@ mod tests { let exposed = handle_expose_service( &state, authed_request(ExposeServiceRequest { - sandbox: "my-sandbox".to_string(), + sandbox_name: "my-sandbox".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), service: "web".to_string(), target_port: 8080, domain: true, - workspace: "default".to_string(), }), ) .await @@ -424,11 +421,12 @@ mod tests { let listed = handle_list_services( &state, authed_request(ListServicesRequest { - sandbox: "my-sandbox".to_string(), + sandbox_name: "my-sandbox".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), limit: 0, offset: 0, - workspace: "default".to_string(), - all_workspaces: false, }), ) .await @@ -443,9 +441,11 @@ mod tests { let fetched = handle_get_service( &state, authed_request(GetServiceRequest { - sandbox: "my-sandbox".to_string(), + sandbox_name: "my-sandbox".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), service: "web".to_string(), - workspace: "default".to_string(), }), ) .await @@ -456,9 +456,11 @@ mod tests { let deleted = handle_delete_service( &state, authed_request(DeleteServiceRequest { - sandbox: "my-sandbox".to_string(), + sandbox_name: "my-sandbox".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), service: "web".to_string(), - workspace: "default".to_string(), }), ) .await @@ -469,9 +471,11 @@ mod tests { let err = handle_get_service( &state, authed_request(GetServiceRequest { - sandbox: "my-sandbox".to_string(), + sandbox_name: "my-sandbox".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), service: "web".to_string(), - workspace: "default".to_string(), }), ) .await @@ -481,11 +485,12 @@ mod tests { let listed = handle_list_services( &state, authed_request(ListServicesRequest { - sandbox: "my-sandbox".to_string(), + sandbox_name: "my-sandbox".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), limit: 0, offset: 0, - workspace: "default".to_string(), - all_workspaces: false, }), ) .await @@ -505,11 +510,13 @@ mod tests { handle_expose_service( &state1, authed_request(ExposeServiceRequest { - sandbox: "my-sandbox".to_string(), + sandbox_name: "my-sandbox".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), service: "web".to_string(), target_port: 8080, domain: true, - workspace: "default".to_string(), }), ) .await @@ -520,11 +527,13 @@ mod tests { handle_expose_service( &state2, authed_request(ExposeServiceRequest { - sandbox: "my-sandbox".to_string(), + sandbox_name: "my-sandbox".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), service: "web".to_string(), target_port: 9090, domain: true, - workspace: "default".to_string(), }), ) .await @@ -546,11 +555,12 @@ mod tests { let listed = handle_list_services( &state, authed_request(ListServicesRequest { - sandbox: "my-sandbox".to_string(), + sandbox_name: "my-sandbox".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), limit: 0, offset: 0, - workspace: "default".to_string(), - all_workspaces: false, }), ) .await @@ -568,11 +578,13 @@ mod tests { handle_expose_service( &state, authed_request(ExposeServiceRequest { - sandbox: "my-sandbox".to_string(), + sandbox_name: "my-sandbox".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), service: "web".to_string(), target_port: 7070, domain: true, - workspace: "default".to_string(), }), ) .await @@ -584,11 +596,13 @@ mod tests { handle_expose_service( &state1, authed_request(ExposeServiceRequest { - sandbox: "my-sandbox".to_string(), + sandbox_name: "my-sandbox".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), service: "web".to_string(), target_port: 8080, domain: true, - workspace: "default".to_string(), }), ) .await @@ -599,11 +613,13 @@ mod tests { handle_expose_service( &state2, authed_request(ExposeServiceRequest { - sandbox: "my-sandbox".to_string(), + sandbox_name: "my-sandbox".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), service: "web".to_string(), target_port: 9090, domain: true, - workspace: "default".to_string(), }), ) .await @@ -624,9 +640,11 @@ mod tests { let fetched = handle_get_service( &state, authed_request(GetServiceRequest { - sandbox: "my-sandbox".to_string(), + sandbox_name: "my-sandbox".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), service: "web".to_string(), - workspace: "default".to_string(), }), ) .await @@ -683,11 +701,13 @@ mod tests { handle_expose_service( &state, authed_request(ExposeServiceRequest { - sandbox: "my-sandbox".to_string(), + sandbox_name: "my-sandbox".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), service: "web".to_string(), target_port: 8080, domain: true, - workspace: "default".to_string(), }), ) .await @@ -696,11 +716,13 @@ mod tests { handle_expose_service( &state, authed_request(ExposeServiceRequest { - sandbox: "my-sandbox".to_string(), + sandbox_name: "my-sandbox".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "beta".to_string(), + )), service: "web".to_string(), target_port: 9090, domain: true, - workspace: "beta".to_string(), }), ) .await @@ -710,9 +732,11 @@ mod tests { let got = handle_get_service( &state, authed_request(GetServiceRequest { - sandbox: "my-sandbox".to_string(), + sandbox_name: "my-sandbox".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), service: "web".to_string(), - workspace: "default".to_string(), }), ) .await @@ -724,9 +748,11 @@ mod tests { let got = handle_get_service( &state, authed_request(GetServiceRequest { - sandbox: "my-sandbox".to_string(), + sandbox_name: "my-sandbox".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "beta".to_string(), + )), service: "web".to_string(), - workspace: "beta".to_string(), }), ) .await @@ -738,11 +764,12 @@ mod tests { let listed = handle_list_services( &state, authed_request(ListServicesRequest { - sandbox: "my-sandbox".to_string(), + sandbox_name: "my-sandbox".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), limit: 100, offset: 0, - workspace: "default".to_string(), - all_workspaces: false, }), ) .await @@ -757,11 +784,12 @@ mod tests { let listed = handle_list_services( &state, authed_request(ListServicesRequest { - sandbox: "my-sandbox".to_string(), + sandbox_name: "my-sandbox".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "beta".to_string(), + )), limit: 100, offset: 0, - workspace: "beta".to_string(), - all_workspaces: false, }), ) .await @@ -777,9 +805,11 @@ mod tests { let deleted = handle_delete_service( &state, authed_request(DeleteServiceRequest { - sandbox: "my-sandbox".to_string(), + sandbox_name: "my-sandbox".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), service: "web".to_string(), - workspace: "default".to_string(), }), ) .await @@ -790,11 +820,12 @@ mod tests { let listed = handle_list_services( &state, authed_request(ListServicesRequest { - sandbox: "my-sandbox".to_string(), + sandbox_name: "my-sandbox".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), limit: 100, offset: 0, - workspace: "default".to_string(), - all_workspaces: false, }), ) .await @@ -805,9 +836,11 @@ mod tests { let got = handle_get_service( &state, authed_request(GetServiceRequest { - sandbox: "my-sandbox".to_string(), + sandbox_name: "my-sandbox".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "beta".to_string(), + )), service: "web".to_string(), - workspace: "beta".to_string(), }), ) .await @@ -820,11 +853,13 @@ mod tests { handle_expose_service( &state, authed_request(ExposeServiceRequest { - sandbox: "my-sandbox".to_string(), + sandbox_name: "my-sandbox".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), service: "api".to_string(), target_port: 3000, domain: true, - workspace: "default".to_string(), }), ) .await @@ -833,39 +868,23 @@ mod tests { let listed = handle_list_services( &state, authed_request(ListServicesRequest { - sandbox: String::new(), + sandbox_name: String::new(), + workspace_scope: Some(openshell_core::proto::all_workspaces_selector()), limit: 100, offset: 0, - workspace: String::new(), - all_workspaces: true, }), ) .await .unwrap() .into_inner(); assert_eq!(listed.services.len(), 2); - - // all_workspaces with non-empty workspace is rejected. - let err = handle_list_services( - &state, - authed_request(ListServicesRequest { - sandbox: String::new(), - limit: 100, - offset: 0, - workspace: "default".to_string(), - all_workspaces: true, - }), - ) - .await - .unwrap_err(); - assert_eq!(err.code(), tonic::Code::InvalidArgument); } /// Non-member callers must receive `PERMISSION_DENIED` — not `NOT_FOUND` — /// when targeting a workspace that does not exist. Returning `NOT_FOUND` /// would create a CWE-203 workspace-name oracle. #[tokio::test] - async fn non_member_gets_permission_denied_not_workspace_oracle() { + async fn non_member_gets_gets_not_found_without_sandbox_oracle() { use crate::auth::identity::{Identity, IdentityProvider}; use crate::auth::principal::{Principal, UserPrincipal}; @@ -889,7 +908,8 @@ mod tests { let err = handle_expose_service( &state, non_member_request(ExposeServiceRequest { - workspace: "no-such-ws".into(), + sandbox_name: ("any").to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector("no-such-ws")), ..Default::default() }), ) @@ -897,15 +917,16 @@ mod tests { .unwrap_err(); assert_eq!( err.code(), - tonic::Code::PermissionDenied, - "handle_expose_service should return PermissionDenied, got {:?}", + tonic::Code::NotFound, + "handle_expose_service should return NotFound, got {:?}", err.code() ); let err = handle_get_service( &state, non_member_request(GetServiceRequest { - workspace: "no-such-ws".into(), + sandbox_name: ("any").to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector("no-such-ws")), ..Default::default() }), ) @@ -913,15 +934,16 @@ mod tests { .unwrap_err(); assert_eq!( err.code(), - tonic::Code::PermissionDenied, - "handle_get_service should return PermissionDenied, got {:?}", + tonic::Code::NotFound, + "handle_get_service should return NotFound, got {:?}", err.code() ); let err = handle_list_services( &state, non_member_request(ListServicesRequest { - workspace: "no-such-ws".into(), + sandbox_name: ("any").to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector("no-such-ws")), ..Default::default() }), ) @@ -929,15 +951,16 @@ mod tests { .unwrap_err(); assert_eq!( err.code(), - tonic::Code::PermissionDenied, - "handle_list_services should return PermissionDenied, got {:?}", + tonic::Code::NotFound, + "handle_list_services should return NotFound, got {:?}", err.code() ); let err = handle_delete_service( &state, non_member_request(DeleteServiceRequest { - workspace: "no-such-ws".into(), + sandbox_name: ("any").to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector("no-such-ws")), ..Default::default() }), ) @@ -945,8 +968,8 @@ mod tests { .unwrap_err(); assert_eq!( err.code(), - tonic::Code::PermissionDenied, - "handle_delete_service should return PermissionDenied, got {:?}", + tonic::Code::NotFound, + "handle_delete_service should return NotFound, got {:?}", err.code() ); } diff --git a/crates/openshell-server/src/grpc/validation.rs b/crates/openshell-server/src/grpc/validation.rs index dac34524c1..17b14ecf55 100644 --- a/crates/openshell-server/src/grpc/validation.rs +++ b/crates/openshell-server/src/grpc/validation.rs @@ -43,6 +43,9 @@ pub(super) const MAX_MAIN_PROCESS_ARGV_SIZE: usize = 256 * 1024; /// Command arguments only reject NUL (newlines are valid for inline scripts). /// Environment values and workdir reject both NUL and newlines. pub(super) fn validate_exec_request_fields(req: &ExecSandboxRequest) -> Result<(), Status> { + if req.sandbox_name.is_empty() { + return Err(Status::invalid_argument("sandbox_name is required")); + } if req.command.len() > MAX_EXEC_COMMAND_ARGS { return Err(Status::invalid_argument(format!( "command array exceeds {MAX_EXEC_COMMAND_ARGS} argument limit" @@ -1351,7 +1354,8 @@ mod tests { #[test] fn validate_exec_request_rejects_reserved_env_key() { let req = ExecSandboxRequest { - sandbox_id: "id".to_string(), + sandbox_name: "id".to_string(), + workspace_scope: None, command: vec!["echo".to_string()], environment: std::iter::once(("OPENSHELL_SANDBOX_ID".to_string(), "evil".to_string())) .collect(), @@ -1368,7 +1372,8 @@ mod tests { #[test] fn validate_exec_request_allows_pyfunc_helper_key() { let req = ExecSandboxRequest { - sandbox_id: "id".to_string(), + sandbox_name: "id".to_string(), + workspace_scope: None, command: vec!["python".to_string()], environment: std::iter::once(("OPENSHELL_PYFUNC_B64".to_string(), "data".to_string())) .collect(), @@ -2246,7 +2251,8 @@ mod tests { #[test] fn validate_exec_allows_newlines_in_command_args() { let req = ExecSandboxRequest { - sandbox_id: "test".to_string(), + sandbox_name: "test".to_string(), + workspace_scope: None, command: vec![ "python3".to_string(), "-c".to_string(), @@ -2260,7 +2266,8 @@ mod tests { #[test] fn validate_exec_still_rejects_null_bytes_in_command_args() { let req = ExecSandboxRequest { - sandbox_id: "test".to_string(), + sandbox_name: "test".to_string(), + workspace_scope: None, command: vec!["echo".to_string(), "hello\x00world".to_string()], ..Default::default() }; @@ -2271,7 +2278,8 @@ mod tests { #[test] fn validate_exec_still_rejects_newlines_in_workdir() { let req = ExecSandboxRequest { - sandbox_id: "test".to_string(), + sandbox_name: "test".to_string(), + workspace_scope: None, command: vec!["ls".to_string()], workdir: "/tmp\nmalicious".to_string(), ..Default::default() @@ -2283,7 +2291,8 @@ mod tests { #[test] fn validate_exec_still_rejects_newlines_in_env_values() { let req = ExecSandboxRequest { - sandbox_id: "test".to_string(), + sandbox_name: "test".to_string(), + workspace_scope: None, command: vec!["ls".to_string()], environment: std::iter::once(("VAR".to_string(), "val\nmalicious".to_string())) .collect(), diff --git a/crates/openshell-server/src/grpc/workspace.rs b/crates/openshell-server/src/grpc/workspace.rs index 30ca756a1b..eab6c3ed4a 100644 --- a/crates/openshell-server/src/grpc/workspace.rs +++ b/crates/openshell-server/src/grpc/workspace.rs @@ -70,7 +70,7 @@ fn membership_filter_subject<'a>( } } -fn validate_workspace_name(name: &str) -> Result<(), Status> { +pub fn validate_workspace_name(name: &str) -> Result<(), Status> { if name.is_empty() { return Err(Status::invalid_argument("workspace name is required")); } diff --git a/crates/openshell-server/src/multiplex.rs b/crates/openshell-server/src/multiplex.rs index 21315f9830..f03aa4d07f 100644 --- a/crates/openshell-server/src/multiplex.rs +++ b/crates/openshell-server/src/multiplex.rs @@ -48,7 +48,7 @@ use crate::{ auth::identity::Identity, auth::oidc::{self, OidcAuthenticator}, auth::principal::{Principal, UserPrincipal}, - auth::workspace_authz::{MinWorkspaceRole, authorize_workspace}, + auth::workspace_authz::{MinWorkspaceRole, authorize_workspace_selector}, gateway_listener::GatewayListenerScope, http_router, service_http_router, }; @@ -552,11 +552,11 @@ async fn hydrate_update_provider_identity( let principal = principal.ok_or_else(|| tonic::Status::unauthenticated("authentication required"))?; - let authorized = authorize_workspace( + let authorized = authorize_workspace_selector( state.store.as_ref(), &state.admin_role, principal, - &request.workspace, + request.workspace_scope.as_ref(), MinWorkspaceRole::Admin, ) .await?; @@ -1740,7 +1740,9 @@ mod tests { )]), ..Default::default() }), - workspace: "default".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), ..Default::default() }; let authed = crate::grpc::test_support::authed_request(()); diff --git a/crates/openshell-supervisor-network/src/l7/jsonrpc.rs b/crates/openshell-supervisor-network/src/l7/jsonrpc.rs index c6a65f76c4..aea53edc08 100644 --- a/crates/openshell-supervisor-network/src/l7/jsonrpc.rs +++ b/crates/openshell-supervisor-network/src/l7/jsonrpc.rs @@ -86,6 +86,54 @@ pub(crate) async fn parse_jsonrpc_http_request Result { + if jsonrpc_receive_stream_request(request) { + return Ok(JsonRpcRequestInfo::receive_stream()); + } + + let header_end = request + .raw_header + .windows(4) + .position(|window| window == b"\r\n\r\n") + .ok_or_else(|| miette::miette!("HTTP request headers are missing the CRLF terminator"))? + + 4; + let body = &request.raw_header[header_end..]; + match request.body_length { + crate::l7::provider::BodyLength::None if body.is_empty() => {} + crate::l7::provider::BodyLength::ContentLength(length) => { + let length = usize::try_from(length) + .map_err(|_| miette::miette!("HTTP request body length exceeds platform limit"))?; + if body.len() != length { + return Err(miette::miette!( + "buffered HTTP request body length does not match Content-Length" + )); + } + } + crate::l7::provider::BodyLength::None => { + return Err(miette::miette!( + "buffered HTTP request has bytes without body framing" + )); + } + crate::l7::provider::BodyLength::Chunked => { + return Err(miette::miette!( + "buffered JSON-RPC request retained chunked framing" + )); + } + } + + Ok(parse_jsonrpc_body_with_options(body, inspection_options)) +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct JsonRpcRequestInfo { /// Calls found in the request body. Responses and receive-stream GETs have @@ -169,6 +217,12 @@ pub struct JsonRpcCallInfo { /// MCP `tools/call` tool name when known. Generic JSON-RPC leaves this /// unset because params are not inspected. pub tool: Option, + /// Whether this call is a JSON-RPC notification without an `id`. + /// + /// MCP initialization is a request, so transport code uses this bit to + /// avoid treating an extension notification named `initialize` as the + /// header-exempt initialization exchange. + pub is_notification: bool, } impl JsonRpcRequestInfo { @@ -365,6 +419,7 @@ fn parse_jsonrpc_call( method: method.to_string(), params: HashMap::new(), tool: None, + is_notification: value.get("id").is_none(), }) } @@ -421,6 +476,7 @@ fn parse_mcp_call( method: mcp_request.method_name().to_string(), params, tool, + is_notification: false, }); } @@ -441,6 +497,7 @@ fn parse_mcp_call( method: notification.method, params: HashMap::new(), tool: None, + is_notification: true, }) } diff --git a/crates/openshell-supervisor-network/src/l7/mcp.rs b/crates/openshell-supervisor-network/src/l7/mcp.rs new file mode 100644 index 0000000000..d5fbaed8bc --- /dev/null +++ b/crates/openshell-supervisor-network/src/l7/mcp.rs @@ -0,0 +1,318 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! MCP Streamable HTTP request-version selection. + +use openshell_core::mcp::McpProtocolVersion; + +use crate::l7::jsonrpc::JsonRpcRequestInfo; +use crate::l7::provider::L7Request; + +const MCP_PROTOCOL_VERSION_HEADER: &str = "mcp-protocol-version"; + +/// Protocol revision selected for one MCP HTTP request. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum McpRequestProtocolVersion { + /// A valid standalone initialize request selects its revision in the JSON-RPC body. + Initialization, + /// A subsequent request selected an exact revision from its header or the legacy fallback. + Selected(McpProtocolVersion), +} + +/// Failure to select a policy-allowed protocol revision for an MCP HTTP request. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum McpProtocolVersionError { + /// The HTTP header block could not yield one unambiguous end-to-end value. + InvalidHeader, + /// The header value is not an MCP revision supported by this `OpenShell` build. + UnsupportedHeaderValue, + /// The selected supported revision is absent from the endpoint allowlist. + NotAllowed(McpProtocolVersion), +} + +impl McpProtocolVersionError { + /// Return the HTTP status for this transport or policy rejection. + #[must_use] + pub(super) const fn http_status(self) -> &'static str { + match self { + Self::InvalidHeader | Self::UnsupportedHeaderValue => "400 Bad Request", + Self::NotAllowed(_) => "403 Forbidden", + } + } + + /// Return a stable machine-readable response code. + #[must_use] + pub(super) const fn response_code(self) -> &'static str { + match self { + Self::InvalidHeader => "invalid_mcp_protocol_version_header", + Self::UnsupportedHeaderValue => "unsupported_mcp_protocol_version", + Self::NotAllowed(_) => "mcp_protocol_version_not_allowed", + } + } +} + +impl std::fmt::Display for McpProtocolVersionError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::InvalidHeader => formatter.write_str( + "MCP-Protocol-Version must contain one non-empty end-to-end header value", + ), + Self::UnsupportedHeaderValue => { + formatter.write_str("MCP-Protocol-Version names an unsupported protocol version") + } + Self::NotAllowed(version) => write!( + formatter, + "MCP protocol version {version} is not allowed by endpoint policy" + ), + } + } +} + +impl std::error::Error for McpProtocolVersionError {} + +/// Select and authorize the protocol revision for one MCP HTTP request. +/// +/// MCP initialization negotiates its revision in the JSON-RPC body and is the +/// only request exempt from the header. Every other request is self-contained: +/// an absent header selects the specification-defined `2025-03-26` fallback, +/// and the resulting supported revision must appear in the endpoint allowlist. +pub(super) fn select_request_protocol_version( + request: &L7Request, + info: &JsonRpcRequestInfo, + allowed_versions: &[McpProtocolVersion], +) -> Result { + if is_standalone_initialize(info) { + return Ok(McpRequestProtocolVersion::Initialization); + } + + let version = match request_protocol_version_header(&request.raw_header)? { + Some(value) => value + .parse::() + .map_err(|_| McpProtocolVersionError::UnsupportedHeaderValue)?, + None => McpProtocolVersion::V2025_03_26, + }; + if !allowed_versions.contains(&version) { + return Err(McpProtocolVersionError::NotAllowed(version)); + } + + Ok(McpRequestProtocolVersion::Selected(version)) +} + +fn is_standalone_initialize(info: &JsonRpcRequestInfo) -> bool { + !info.is_batch + && !info.has_response + && info.error.is_none() + && matches!( + info.calls.as_slice(), + [call] if call.method == "initialize" && !call.is_notification + ) +} + +fn request_protocol_version_header( + raw_header: &[u8], +) -> Result, McpProtocolVersionError> { + let header_end = raw_header + .windows(4) + .position(|window| window == b"\r\n\r\n") + .ok_or(McpProtocolVersionError::InvalidHeader)? + + 4; + let headers = std::str::from_utf8(&raw_header[..header_end]) + .map_err(|_| McpProtocolVersionError::InvalidHeader)?; + // Forwarding removes Connection-nominated fields. A revision field must + // survive that cleanup. Use the forwarding parser's canonical field names + // so authorization and removal agree, including after middleware rebuilds. + let nominated = crate::l7::rest::connection_nominated_header_names(&raw_header[..header_end]) + .map_err(|_| McpProtocolVersionError::InvalidHeader)?; + if nominated.contains(MCP_PROTOCOL_VERSION_HEADER) { + return Err(McpProtocolVersionError::InvalidHeader); + } + let mut values = headers.split("\r\n").skip(1).filter_map(|line| { + let (name, value) = line.split_once(':')?; + // HTTP field-value optional whitespace is only SP or HTAB. Using + // Unicode whitespace trimming here would accept bytes that are part + // of the protocol-version value rather than HTTP framing. + name.eq_ignore_ascii_case(MCP_PROTOCOL_VERSION_HEADER) + .then_some(value.trim_matches([' ', '\t'])) + }); + let Some(value) = values.next() else { + return Ok(None); + }; + if value.is_empty() || values.next().is_some() { + return Err(McpProtocolVersionError::InvalidHeader); + } + Ok(Some(value)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::l7::jsonrpc::{JsonRpcInspectionMode, parse_jsonrpc_body}; + use crate::l7::provider::BodyLength; + + fn request(method: &str, headers: &str) -> L7Request { + L7Request { + action: method.to_string(), + target: "/mcp".to_string(), + query_params: std::collections::HashMap::new(), + raw_header: format!("{method} /mcp HTTP/1.1\r\nHost: example.test\r\n{headers}\r\n") + .into_bytes(), + body_length: BodyLength::None, + } + } + + fn request_info(body: &[u8]) -> JsonRpcRequestInfo { + parse_jsonrpc_body(body, JsonRpcInspectionMode::Mcp) + } + + #[test] + fn standalone_initialize_uses_body_negotiation_only() { + let info = request_info( + br#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"test","version":"1"}}}"#, + ); + + assert_eq!( + select_request_protocol_version(&request("POST", ""), &info, &[]), + Ok(McpRequestProtocolVersion::Initialization) + ); + } + + #[test] + fn initialize_notification_is_not_treated_as_initialization() { + let info = request_info(br#"{"jsonrpc":"2.0","method":"initialize","params":{}}"#); + + assert_eq!( + select_request_protocol_version( + &request("POST", ""), + &info, + &[McpProtocolVersion::V2025_03_26] + ), + Ok(McpRequestProtocolVersion::Selected( + McpProtocolVersion::V2025_03_26 + )) + ); + } + + #[test] + fn subsequent_requests_select_exact_header_for_every_http_method() { + let info = request_info(br#"{"jsonrpc":"2.0","id":1,"method":"tools/list"}"#); + + for method in ["POST", "GET", "DELETE"] { + assert_eq!( + select_request_protocol_version( + &request(method, "MCP-Protocol-Version: 2025-11-25\r\n"), + &info, + &[McpProtocolVersion::V2025_11_25] + ), + Ok(McpRequestProtocolVersion::Selected( + McpProtocolVersion::V2025_11_25 + )), + "method {method} must use the same per-request selection" + ); + } + } + + #[test] + fn missing_header_uses_only_the_legacy_specification_fallback() { + let info = request_info(br#"{"jsonrpc":"2.0","id":1,"method":"tools/list"}"#); + + assert_eq!( + select_request_protocol_version( + &request("POST", ""), + &info, + &[McpProtocolVersion::V2025_03_26] + ), + Ok(McpRequestProtocolVersion::Selected( + McpProtocolVersion::V2025_03_26 + )) + ); + assert_eq!( + select_request_protocol_version( + &request("POST", ""), + &info, + &[McpProtocolVersion::V2025_11_25] + ), + Err(McpProtocolVersionError::NotAllowed( + McpProtocolVersion::V2025_03_26 + )) + ); + } + + #[test] + fn repeated_or_empty_headers_are_bad_requests() { + let info = request_info(br#"{"jsonrpc":"2.0","id":1,"method":"tools/list"}"#); + + for headers in [ + "MCP-Protocol-Version:\r\n", + "MCP-Protocol-Version: 2025-11-25\r\nMCP-Protocol-Version: 2025-11-25\r\n", + "MCP-Protocol-Version: 2025-03-26\r\nmcp-protocol-version: 2025-11-25\r\n", + ] { + assert_eq!( + select_request_protocol_version( + &request("POST", headers), + &info, + &[McpProtocolVersion::V2025_11_25] + ), + Err(McpProtocolVersionError::InvalidHeader) + ); + } + } + + #[test] + fn protocol_version_must_remain_an_end_to_end_field() { + for connection_options in [ + "mcp-protocol-version", + "keep-alive, MCP-Protocol-Version", + "\tMcp-Protocol-Version\t, close", + ] { + let headers = request("POST", &format!("Connection: {connection_options}\r\n")); + assert_eq!( + request_protocol_version_header(&headers.raw_header), + Err(McpProtocolVersionError::InvalidHeader), + "revision metadata cannot be declared hop-by-hop" + ); + } + let headers = request("POST", "Connection: keep-alive, x-request-id\r\n"); + assert_eq!( + request_protocol_version_header(&headers.raw_header), + Ok(None) + ); + } + + #[test] + fn unsupported_or_non_exact_header_values_are_bad_requests() { + let info = request_info(br#"{"jsonrpc":"2.0","id":1,"method":"tools/list"}"#); + + for value in [ + "2026-07-28", + "2025-11-25, 2025-03-26", + "2025-11-25x", + "\u{00a0}2025-11-25", + ] { + assert_eq!( + select_request_protocol_version( + &request("POST", &format!("MCP-Protocol-Version: {value}\r\n")), + &info, + &[McpProtocolVersion::V2025_11_25] + ), + Err(McpProtocolVersionError::UnsupportedHeaderValue) + ); + } + } + + #[test] + fn supported_but_disallowed_header_is_forbidden() { + let info = request_info(br#"{"jsonrpc":"2.0","id":1,"method":"tools/list"}"#); + + let error = select_request_protocol_version( + &request("POST", "MCP-Protocol-Version: 2025-06-18\r\n"), + &info, + &[McpProtocolVersion::V2025_11_25], + ) + .expect_err("supported revision is outside endpoint policy"); + assert_eq!( + error, + McpProtocolVersionError::NotAllowed(McpProtocolVersion::V2025_06_18) + ); + assert_eq!(error.http_status(), "403 Forbidden"); + } +} diff --git a/crates/openshell-supervisor-network/src/l7/mod.rs b/crates/openshell-supervisor-network/src/l7/mod.rs index 990619f963..77e4497d48 100644 --- a/crates/openshell-supervisor-network/src/l7/mod.rs +++ b/crates/openshell-supervisor-network/src/l7/mod.rs @@ -11,6 +11,7 @@ pub mod graphql; pub(crate) mod http; pub mod jsonrpc; +pub(crate) mod mcp; pub(crate) mod middleware; pub mod path; pub mod provider; diff --git a/crates/openshell-supervisor-network/src/l7/relay.rs b/crates/openshell-supervisor-network/src/l7/relay.rs index aec52cf9cd..d022622a38 100644 --- a/crates/openshell-supervisor-network/src/l7/relay.rs +++ b/crates/openshell-supervisor-network/src/l7/relay.rs @@ -164,6 +164,93 @@ where Ok(()) } +/// Enforce MCP request-version policy and emit a transport or policy rejection. +/// Non-MCP adapters share this entry point without changing their behavior. +pub(crate) async fn enforce_mcp_protocol_version( + config: &L7EndpointConfig, + request: &crate::l7::provider::L7Request, + info: &crate::l7::jsonrpc::JsonRpcRequestInfo, + client: &mut W, + ctx: &L7EvalContext, + redacted_target: &str, +) -> Result +where + W: AsyncWrite + Unpin, +{ + if config.protocol != L7Protocol::Mcp { + return Ok(true); + } + + match crate::l7::mcp::select_request_protocol_version(request, info, &config.mcp_versions) { + Ok(crate::l7::mcp::McpRequestProtocolVersion::Initialization) => Ok(true), + Ok(crate::l7::mcp::McpRequestProtocolVersion::Selected(version)) => { + debug!(mcp_protocol_version = %version, "Selected MCP request protocol version"); + Ok(true) + } + Err(error) => { + let reason = error.to_string(); + let summary = l7_protocol_log_summary(None, Some(info)); + ocsf_emit!(build_l7_request_event( + ctx, + &request.action, + redacted_target, + "deny", + "l7-mcp", + &reason, + summary.as_deref(), + )); + let deny_group = match error { + crate::l7::mcp::McpProtocolVersionError::NotAllowed(_) => "l7_policy", + crate::l7::mcp::McpProtocolVersionError::InvalidHeader + | crate::l7::mcp::McpProtocolVersionError::UnsupportedHeaderValue => { + "l7_parse_rejection" + } + }; + emit_activity(ctx, true, deny_group); + + let body = serde_json::json!({ + "error": error.response_code(), + "detail": reason, + "policy": ctx.policy_name, + "layer": "l7", + "protocol": "mcp", + "method": request.action, + "path": redacted_target, + }); + crate::l7::rest::send_json_response( + &ctx.policy_name, + body, + client, + error.http_status(), + ) + .await?; + Ok(false) + } + } +} + +/// Reinspect the buffered outgoing MCP request after request transformations. +/// The forwarding adapter must call this before any upstream request write. +pub(crate) async fn enforce_final_mcp_protocol_version( + config: &L7EndpointConfig, + request: &crate::l7::provider::L7Request, + client: &mut W, + ctx: &L7EvalContext, + redacted_target: &str, +) -> Result +where + W: AsyncWrite + Unpin, +{ + if config.protocol != L7Protocol::Mcp { + return Ok(true); + } + let info = crate::l7::jsonrpc::inspect_buffered_jsonrpc_http_request( + request, + crate::l7::jsonrpc::JsonRpcInspectionOptions::for_config(config), + )?; + enforce_mcp_protocol_version(config, request, &info, client, ctx, redacted_target).await +} + fn build_request_authority_mismatch_event(ctx: &L7EvalContext) -> openshell_ocsf::OcsfEvent { HttpActivityBuilder::new(openshell_ocsf::ctx::ctx()) .activity(ActivityId::Fail) @@ -688,6 +775,12 @@ where graphql: graphql_info.clone(), jsonrpc: jsonrpc_info.clone(), }; + if let Some(info) = jsonrpc_info.as_ref() + && !enforce_mcp_protocol_version(config, &req, info, client, ctx, &redacted_target) + .await? + { + return Ok(()); + } let websocket_request = crate::l7::rest::request_is_websocket_upgrade(&req.raw_header); if config.protocol == L7Protocol::Websocket && !websocket_request { crate::l7::rest::RestProvider::default() @@ -790,6 +883,11 @@ where return Ok(()); } }; + if !enforce_final_mcp_protocol_version(config, &req, 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() { @@ -1759,9 +1857,6 @@ where return Ok(()); } - // Future MCP version-profile request checks should hook here before OPA - // evaluation. See McpOptions in proto/sandbox.proto for the policy - // roadmap and source documentation. let parsed = match crate::l7::jsonrpc::parse_jsonrpc_http_request( client, config.json_rpc_max_body_bytes, @@ -1817,6 +1912,11 @@ where graphql: None, jsonrpc: Some(jsonrpc_info.clone()), }; + if !enforce_mcp_protocol_version(config, &req, &jsonrpc_info, client, ctx, &redacted_target) + .await? + { + return Ok(()); + } let hard_deny_reason = l7_request_hard_deny_reason(config.protocol, &request_info); let force_deny = hard_deny_reason.is_some(); @@ -1928,6 +2028,11 @@ where return Ok(()); } }; + if !enforce_final_mcp_protocol_version(config, &req, 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 @@ -8574,6 +8679,213 @@ network_policies: .unwrap(); } + #[tokio::test] + async fn mcp_relay_forwards_standalone_initialize_without_version_header() { + let (config, tunnel_engine, ctx) = mcp_test_relay_context(); + 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#"{"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:8000\r\nContent-Type: application/json\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 upstream_bytes = vec![0; 2048]; + let count = tokio::time::timeout( + std::time::Duration::from_secs(1), + upstream.read(&mut upstream_bytes), + ) + .await + .expect("standalone initialize should reach upstream") + .unwrap(); + let upstream_request = String::from_utf8_lossy(&upstream_bytes[..count]); + assert!(upstream_request.contains(r#""method":"initialize""#)); + + upstream + .write_all( + b"HTTP/1.1 200 OK\r\nContent-Length: 36\r\nConnection: close\r\n\r\n{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{}}", + ) + .await + .unwrap(); + let mut response = [0; 512]; + let count = + tokio::time::timeout(std::time::Duration::from_secs(1), app.read(&mut response)) + .await + .expect("initialize response should reach client") + .unwrap(); + assert!(String::from_utf8_lossy(&response[..count]).contains("200 OK")); + + drop(app); + tokio::time::timeout(std::time::Duration::from_secs(1), relay) + .await + .expect("relay should complete") + .unwrap() + .unwrap(); + } + + async fn run_rejected_mcp_version_request( + route_selected: bool, + version_headers: &str, + ) -> (String, Vec) { + let (config, tunnel_engine, ctx) = mcp_test_relay_context(); + 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 { + if route_selected { + relay_with_route_selection( + &[config], + tunnel_engine, + &mut relay_client, + &mut relay_upstream, + &ctx, + ) + .await + } else { + relay_with_inspection( + &config, + 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 /mcp HTTP/1.1\r\nHost: mcp.example.test:8000\r\nContent-Type: application/json\r\n{version_headers}Content-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 = Vec::new(); + tokio::time::timeout( + std::time::Duration::from_secs(1), + app.read_to_end(&mut response), + ) + .await + .expect("MCP version rejection should close the client response") + .unwrap(); + tokio::time::timeout(std::time::Duration::from_secs(1), relay) + .await + .expect("relay should complete after version rejection") + .unwrap() + .unwrap(); + + let mut forwarded = Vec::new(); + tokio::time::timeout( + std::time::Duration::from_secs(1), + upstream.read_to_end(&mut forwarded), + ) + .await + .expect("version rejection should close upstream without forwarding") + .unwrap(); + ( + String::from_utf8(response).expect("UTF-8 response"), + forwarded, + ) + } + + #[tokio::test] + async fn mcp_relay_rejects_invalid_disallowed_and_missing_versions_without_forwarding() { + for (headers, status, code) in [ + ( + "MCP-Protocol-Version: 2026-07-28\r\n", + "400 Bad Request", + "unsupported_mcp_protocol_version", + ), + ( + "MCP-Protocol-Version: 2025-11-25\r\nMCP-Protocol-Version: 2025-11-25\r\n", + "400 Bad Request", + "invalid_mcp_protocol_version_header", + ), + ( + "MCP-Protocol-Version: 2025-06-18\r\n", + "403 Forbidden", + "mcp_protocol_version_not_allowed", + ), + ("", "403 Forbidden", "mcp_protocol_version_not_allowed"), + ] { + let (response, forwarded) = run_rejected_mcp_version_request(false, headers).await; + assert!( + response.starts_with(&format!("HTTP/1.1 {status}")), + "{response}" + ); + assert!(response.contains(code), "{response}"); + assert!(forwarded.is_empty(), "rejected request reached upstream"); + } + } + + #[tokio::test] + async fn route_selected_mcp_relay_enforces_request_version_before_forwarding() { + let (response, forwarded) = + run_rejected_mcp_version_request(true, "MCP-Protocol-Version: 2026-07-28\r\n").await; + + assert!( + response.starts_with("HTTP/1.1 400 Bad Request"), + "{response}" + ); + assert!( + response.contains("unsupported_mcp_protocol_version"), + "{response}" + ); + assert!(forwarded.is_empty(), "rejected request reached upstream"); + } + + #[tokio::test] + async fn final_mcp_version_check_reclassifies_a_rewritten_initialize_body() { + let (config, _, ctx) = mcp_test_relay_context(); + let final_body = br#"{"jsonrpc":"2.0","id":2,"method":"tools/list"}"#; + let mut raw_header = format!( + "POST /mcp HTTP/1.1\r\nHost: mcp.example.test:8000\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + final_body.len() + ) + .into_bytes(); + raw_header.extend_from_slice(final_body); + let request = crate::l7::provider::L7Request { + action: "POST".to_string(), + target: "/mcp".to_string(), + query_params: std::collections::HashMap::new(), + raw_header, + body_length: crate::l7::provider::BodyLength::ContentLength(final_body.len() as u64), + }; + let (mut client, mut relay_client) = tokio::io::duplex(2048); + + let allowed = + enforce_final_mcp_protocol_version(&config, &request, &mut relay_client, &ctx, "/mcp") + .await + .expect("final request inspection"); + assert!( + !allowed, + "rewritten non-initialize request must require a version" + ); + drop(relay_client); + + let mut response = Vec::new(); + client.read_to_end(&mut response).await.unwrap(); + let response = String::from_utf8(response).expect("UTF-8 response"); + assert!(response.starts_with("HTTP/1.1 403 Forbidden"), "{response}"); + assert!( + response.contains("mcp_protocol_version_not_allowed"), + "{response}" + ); + } + #[tokio::test] async fn mcp_relay_forwards_jsonrpc_response_frame() { let (config, tunnel_engine, ctx) = mcp_test_relay_context(); @@ -8592,7 +8904,7 @@ network_policies: let body = br#"{"jsonrpc":"2.0","id":7,"result":{"action":"accept","content":{}}}"#; let request = format!( - "POST /mcp HTTP/1.1\r\nHost: mcp.example.test:8000\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + "POST /mcp HTTP/1.1\r\nHost: mcp.example.test:8000\r\nContent-Type: application/json\r\nMCP-Protocol-Version: 2025-11-25\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", body.len() ); app.write_all(request.as_bytes()).await.unwrap(); diff --git a/crates/openshell-supervisor-network/src/l7/rest.rs b/crates/openshell-supervisor-network/src/l7/rest.rs index 93315a671a..42bf3a33df 100644 --- a/crates/openshell-supervisor-network/src/l7/rest.rs +++ b/crates/openshell-supervisor-network/src/l7/rest.rs @@ -2639,7 +2639,8 @@ async fn send_forbidden_json( send_json_response(policy_name, body, client, "403 Forbidden").await } -async fn send_json_response( +/// Send a platform-owned JSON response with an explicit HTTP status. +pub(crate) async fn send_json_response( policy_name: &str, body: serde_json::Value, client: &mut C, diff --git a/crates/openshell-supervisor-network/src/proxy.rs b/crates/openshell-supervisor-network/src/proxy.rs index 2fefb788eb..add7be9b33 100644 --- a/crates/openshell-supervisor-network/src/proxy.rs +++ b/crates/openshell-supervisor-network/src/proxy.rs @@ -4651,9 +4651,8 @@ async fn handle_forward_proxy( raw_header: forward_request_bytes, body_length, }; - if crate::l7::jsonrpc::jsonrpc_receive_stream_request(&jsonrpc_request) { - forward_request_bytes = jsonrpc_request.raw_header; - Some(crate::l7::jsonrpc::JsonRpcRequestInfo::receive_stream()) + let info = if crate::l7::jsonrpc::jsonrpc_receive_stream_request(&jsonrpc_request) { + crate::l7::jsonrpc::JsonRpcRequestInfo::receive_stream() } else { let body = match crate::l7::http::read_body_for_inspection( client, @@ -4686,12 +4685,28 @@ async fn handle_forward_proxy( return Ok(()); } }; - forward_request_bytes = jsonrpc_request.raw_header; - Some(crate::l7::jsonrpc::parse_jsonrpc_body_with_options( + crate::l7::jsonrpc::parse_jsonrpc_body_with_options( &body, crate::l7::jsonrpc::JsonRpcInspectionOptions::for_config(&l7_config.config), - )) + ) + }; + // Forward HTTP shares the MCP transport gate with CONNECT before + // method authorization. Borrow the buffered request so checking + // the version does not copy the inspected body. + if !crate::l7::relay::enforce_mcp_protocol_version( + &l7_config.config, + &jsonrpc_request, + &info, + client, + &l7_ctx, + &telemetry_path, + ) + .await? + { + return Ok(()); } + forward_request_bytes = jsonrpc_request.raw_header; + Some(info) } else { None }; @@ -5159,6 +5174,38 @@ async fn handle_forward_proxy( } }; + // Middleware and credential rewriting can change request headers. Check + // the final origin-form bytes after hop-by-hop sanitization, before an + // upstream connection exists, so forwarding preserves the MCP decision. + let rewritten = match forward_l7_reeval.as_ref() { + Some((config, _)) if config.protocol == crate::l7::L7Protocol::Mcp => { + let request = crate::l7::rest::request_from_buffered_http( + method, + middleware_path, + &upstream_target, + rewritten, + )?; + if !crate::l7::relay::enforce_final_mcp_protocol_version( + config, + &request, + client, + &l7_ctx, + &telemetry_path, + ) + .await? + { + if let Some(session) = middleware_session.take() { + session + .end(openshell_core::proto::MiddlewareSessionEndReason::Cancellation) + .await; + } + return Ok(()); + } + request.raw_header + } + _ => rewritten, + }; + if let Err(e) = forward_generation_guard.ensure_current() { warn!( host = %host_lc, @@ -5813,6 +5860,158 @@ network_policies: {} } } + #[tokio::test] + async fn plaintext_mcp_forwarding_preserves_initialization_and_selected_revision() { + 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; + }; + + for (body, version_header) in [ + ( + r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"test","version":"1"}}}"#, + "", + ), + ( + r#"{"jsonrpc":"2.0","id":2,"method":"tools/list"}"#, + "MCP-Protocol-Version: 2025-11-25\r\n", + ), + ] { + let upstream_listener = TcpListener::bind((upstream_ip, 0)) + .await + .expect("bind MCP 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: + inspect: + middleware: openshell/regex + on_error: fail_closed + endpoints: + include: ["{upstream_ip}"] +network_policies: + mcp-upstream: + name: mcp-upstream + endpoints: + - host: "{upstream_ip}" + port: {upstream_port} + path: /mcp + protocol: mcp + enforcement: enforce + rules: + - allow: + method: initialize + - allow: + method: tools/list + binaries: + - {{ path: "{executable}" }} +"#, + executable = executable.display(), + ); + let engine = Arc::new( + OpaEngine::from_strings(include_str!("../data/sandbox-policy.rego"), &data) + .expect("load MCP 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 mut request = Vec::new(); + let mut chunk = [0; 2048]; + loop { + let count = socket.read(&mut chunk).await.unwrap(); + assert_ne!(count, 0, "MCP request closed before its body completed"); + request.extend_from_slice(&chunk[..count]); + if let Some(header_end) = request + .windows(4) + .position(|window| window == b"\r\n\r\n") + .map(|end| end + 4) + && request.len() >= header_end + body.len() + { + assert_eq!(&request[header_end..], body.as_bytes()); + break; + } + } + socket + .write_all( + b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok", + ) + .await + .unwrap(); + String::from_utf8(request).expect("UTF-8 MCP request") + }); + 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}/mcp"); + let request = format!( + "POST {target} HTTP/1.1\r\nHost: {upstream_ip}:{upstream_port}\r\nContent-Type: application/json\r\n{version_header}Content-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len(), + ); + let client = tokio::spawn(async move { + let mut socket = TcpStream::connect(proxy_address).await.unwrap(); + let mut response = Vec::new(); + socket.read_to_end(&mut response).await.unwrap(); + response + }); + let (mut proxy_connection, _) = proxy_listener.accept().await.unwrap(); + + tokio::time::timeout( + std::time::Duration::from_secs(30), + handle_forward_proxy( + "POST", + &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("MCP forwarding should complete") + .expect("handle valid MCP request"); + drop(proxy_connection); + + let response = client.await.expect("join MCP client"); + assert!(response.starts_with(b"HTTP/1.1 200 OK")); + let forwarded = upstream.await.expect("join MCP upstream"); + assert!(forwarded.starts_with("POST /mcp HTTP/1.1\r\n")); + if version_header.is_empty() { + assert!( + !forwarded + .to_ascii_lowercase() + .contains("mcp-protocol-version:") + ); + } else { + assert!(forwarded.contains(version_header)); + } + } + } + #[tokio::test] async fn plaintext_websocket_preflight_denial_does_not_connect_upstream() { if !cfg!(target_os = "linux") { diff --git a/crates/openshell-supervisor-process/src/debug_rpc.rs b/crates/openshell-supervisor-process/src/debug_rpc.rs index 6f885a69db..f8389296b1 100644 --- a/crates/openshell-supervisor-process/src/debug_rpc.rs +++ b/crates/openshell-supervisor-process/src/debug_rpc.rs @@ -7,10 +7,10 @@ //! flow (issue #1354). A `docker exec` (or `kubectl exec`) into a //! running sandbox can issue raw sandbox-class gRPC calls without //! standing up a custom binary inside the sandbox image — useful for -//! confirming the cross-sandbox IDOR guard and renewal semantics. +//! confirming the cross-sandbox authorization guard and renewal semantics. //! //! Subcommands: -//! - `get-sandbox-config --sandbox-id ` — call `GetSandboxConfig` +//! - `get-sandbox-config --sandbox-name ` — call `GetSandboxConfig` //! - `refresh` — call `RefreshSandboxToken` //! - `show-token` — print a token fingerprint and expiry, never the bearer //! - `show-principal` — pretty-print the decoded JWT claims @@ -53,7 +53,7 @@ const USAGE: &str = "\ usage: openshell-sandbox debug-rpc [options] commands: - get-sandbox-config --sandbox-id call GetSandboxConfig + get-sandbox-config --sandbox-name call GetSandboxConfig refresh renew the gateway JWT show-token print JWT fingerprint and expiry show-principal print decoded JWT claims @@ -71,12 +71,13 @@ async fn open_client() -> Result> { } async fn run_get_sandbox_config(args: &[String]) -> Result { - let sandbox_id = parse_flag(args, "--sandbox-id") - .ok_or_else(|| miette::miette!("get-sandbox-config: --sandbox-id is required"))?; + let sandbox_name = parse_flag(args, "--sandbox-name") + .ok_or_else(|| miette::miette!("get-sandbox-config: --sandbox-name is required"))?; let mut client = open_client().await?; let resp = client .get_sandbox_config(GetSandboxConfigRequest { - sandbox_id: sandbox_id.to_string(), + sandbox_name: sandbox_name.to_string(), + workspace_scope: None, }) .await; match resp { @@ -252,22 +253,22 @@ mod tests { #[test] fn parse_flag_handles_space_separated() { - let args: Vec = ["--sandbox-id", "abc-123"] + let args: Vec = ["--sandbox-name", "abc-123"] .iter() .map(ToString::to_string) .collect(); - assert_eq!(parse_flag(&args, "--sandbox-id"), Some("abc-123")); + assert_eq!(parse_flag(&args, "--sandbox-name"), Some("abc-123")); } #[test] fn parse_flag_handles_equals_separated() { - let args: Vec = ["--sandbox-id=abc-123".to_string()].to_vec(); - assert_eq!(parse_flag(&args, "--sandbox-id"), Some("abc-123")); + let args: Vec = ["--sandbox-name=abc-123".to_string()].to_vec(); + assert_eq!(parse_flag(&args, "--sandbox-name"), Some("abc-123")); } #[test] fn parse_flag_returns_none_when_missing() { let args: Vec = ["--other".to_string(), "x".to_string()].to_vec(); - assert!(parse_flag(&args, "--sandbox-id").is_none()); + assert!(parse_flag(&args, "--sandbox-name").is_none()); } } diff --git a/crates/openshell-tui/src/app.rs b/crates/openshell-tui/src/app.rs index cf7464fcd8..428f887111 100644 --- a/crates/openshell-tui/src/app.rs +++ b/crates/openshell-tui/src/app.rs @@ -3213,13 +3213,6 @@ impl App { // Helpers // ------------------------------------------------------------------ - /// Get the ID of the currently selected sandbox. - pub fn selected_sandbox_id(&self) -> Option<&str> { - self.sandbox_ids - .get(self.sandbox_selected) - .map(String::as_str) - } - /// Get the name of the currently selected sandbox. pub fn selected_sandbox_name(&self) -> Option<&str> { self.sandbox_names diff --git a/crates/openshell-tui/src/lib.rs b/crates/openshell-tui/src/lib.rs index 174f9910d0..1975809910 100644 --- a/crates/openshell-tui/src/lib.rs +++ b/crates/openshell-tui/src/lib.rs @@ -40,6 +40,21 @@ const PROVIDER_PROFILE_PAGE_SIZE: u32 = 100; type ProviderProfileCache = HashMap<(String, String), openshell_core::proto::ProviderProfile>; +fn named_workspace_scope(workspace: impl Into) -> openshell_core::proto::WorkspaceSelector { + openshell_core::proto::workspace_selector(workspace) +} + +fn list_workspace_scope( + workspace: impl Into, + all_workspaces: bool, +) -> openshell_core::proto::WorkspaceSelector { + if all_workspaces { + openshell_core::proto::all_workspaces_selector() + } else { + openshell_core::proto::workspace_selector(workspace) + } +} + // Re-export for use by the CLI crate. pub use theme::ThemeMode; @@ -632,23 +647,22 @@ fn spawn_log_stream(app: &mut App, tx: mpsc::UnboundedSender) { // Cancel any previous stream. app.cancel_log_stream(); - let sandbox_id = match app.selected_sandbox_id() { - Some(id) => id.to_string(), + let sandbox_name = match app.selected_sandbox_name() { + Some(name) => name.to_string(), None => return, }; - - let mut client = app.client.clone(); let workspace = app.selected_sandbox_workspace(); + let mut client = app.client.clone(); let handle = tokio::spawn(async move { // Phase 1: Fetch initial history via unary RPC. let req = openshell_core::proto::GetSandboxLogsRequest { - sandbox_id: sandbox_id.clone(), + sandbox_name: sandbox_name.clone(), + workspace_scope: Some(openshell_core::proto::workspace_selector(&workspace)), lines: 500, since_ms: 0, sources: vec![], min_level: String::new(), - workspace, }; match tokio::time::timeout(Duration::from_secs(5), client.get_sandbox_logs(req)).await { @@ -685,7 +699,8 @@ fn spawn_log_stream(app: &mut App, tx: mpsc::UnboundedSender) { // Phase 2: Stream live logs via WatchSandbox. let req = openshell_core::proto::WatchSandboxRequest { - id: sandbox_id, + sandbox_name, + workspace_scope: Some(openshell_core::proto::workspace_selector(workspace)), follow_status: false, follow_logs: true, follow_events: false, @@ -750,8 +765,10 @@ async fn handle_sandbox_delete(app: &mut App) { } let req = openshell_core::proto::DeleteSandboxRequest { - name: sandbox_name, - workspace: app.selected_sandbox_workspace(), + sandbox_name, + workspace_scope: Some(openshell_core::proto::workspace_selector( + app.selected_sandbox_workspace(), + )), }; match app.client.delete_sandbox(req).await { Ok(_) => { @@ -783,37 +800,43 @@ async fn fetch_sandbox_detail(app: &mut App) { }; let req = openshell_core::proto::GetSandboxRequest { - name: sandbox_name.clone(), - workspace: app.selected_sandbox_workspace(), + sandbox_name: sandbox_name.clone(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + app.selected_sandbox_workspace(), + )), }; // Step 1: Fetch sandbox metadata (providers, sandbox ID). - let sandbox_id = + let found = match tokio::time::timeout(Duration::from_secs(5), app.client.get_sandbox(req)).await { Ok(Ok(resp)) => { if let Some(sandbox) = resp.into_inner().sandbox { if let Some(spec) = &sandbox.spec { app.sandbox_providers_list.clone_from(&spec.providers); } - let id = sandbox.object_id().to_string(); - if id.is_empty() { None } else { Some(id) } + true } else { - None + false } } Ok(Err(e)) => { app.status_text = format!("failed to fetch sandbox detail: {}", e.message()); - None + false } Err(_) => { app.status_text = "sandbox detail request timed out".to_string(); - None + false } }; // Step 2: Fetch the current live policy (includes updates since creation). - if let Some(id) = sandbox_id { - let policy_req = openshell_core::proto::GetSandboxConfigRequest { sandbox_id: id }; + if found { + let policy_req = openshell_core::proto::GetSandboxConfigRequest { + sandbox_name, + workspace_scope: Some(openshell_core::proto::workspace_selector( + app.selected_sandbox_workspace(), + )), + }; match tokio::time::timeout( Duration::from_secs(5), @@ -866,36 +889,11 @@ async fn handle_shell_connect( None => return Ok(()), }; - // Step 1: Get sandbox ID. - let sandbox_id = { - let req = openshell_core::proto::GetSandboxRequest { - name: sandbox_name.clone(), - workspace: app.selected_sandbox_workspace(), - }; - match tokio::time::timeout(Duration::from_secs(5), app.client.get_sandbox(req)).await { - Ok(Ok(resp)) => { - if let Some(s) = resp.into_inner().sandbox { - s.object_id().to_string() - } else { - app.status_text = "sandbox not found".to_string(); - return Ok(()); - } - } - Ok(Err(e)) => { - app.status_text = format!("failed to get sandbox: {}", e.message()); - return Ok(()); - } - Err(_) => { - app.status_text = "get sandbox timed out".to_string(); - return Ok(()); - } - } - }; - - // Step 2: Create SSH session. + let workspace = app.selected_sandbox_workspace(); let session = { let req = openshell_core::proto::CreateSshSessionRequest { - sandbox_id: sandbox_id.clone(), + sandbox_name: sandbox_name.clone(), + workspace_scope: Some(openshell_core::proto::workspace_selector(workspace)), }; match tokio::time::timeout(Duration::from_secs(5), app.client.create_ssh_session(req)).await { @@ -933,7 +931,7 @@ async fn handle_shell_connect( let proxy_command = build_proxy_command( &exe.to_string_lossy(), &gateway_url, - &session.sandbox_id, + &sandbox_name, &session.token, &app.gateway_name, ); @@ -1023,35 +1021,10 @@ async fn handle_exec_command( command: &str, workspace: &str, ) -> Result<()> { - // Step 1: Resolve sandbox → SSH session (same as handle_shell_connect). - let sandbox_id = { - let req = openshell_core::proto::GetSandboxRequest { - name: sandbox_name.to_string(), - workspace: workspace.to_string(), - }; - match tokio::time::timeout(Duration::from_secs(5), app.client.get_sandbox(req)).await { - Ok(Ok(resp)) => { - if let Some(s) = resp.into_inner().sandbox { - s.object_id().to_string() - } else { - app.status_text = format!("exec: sandbox {sandbox_name} not found"); - return Ok(()); - } - } - Ok(Err(e)) => { - app.status_text = format!("exec: failed to get sandbox: {}", e.message()); - return Ok(()); - } - Err(_) => { - app.status_text = "exec: get sandbox timed out".to_string(); - return Ok(()); - } - } - }; - let session = { let req = openshell_core::proto::CreateSshSessionRequest { - sandbox_id: sandbox_id.clone(), + sandbox_name: sandbox_name.to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector(workspace)), }; match tokio::time::timeout(Duration::from_secs(5), app.client.create_ssh_session(req)).await { @@ -1088,7 +1061,7 @@ async fn handle_exec_command( let proxy_command = build_proxy_command( &exe.to_string_lossy(), &gateway_url, - &session.sandbox_id, + sandbox_name, &session.token, &app.gateway_name, ); @@ -1403,7 +1376,7 @@ fn spawn_create_sandbox(app: &mut App, tx: mpsc::UnboundedSender) { }), labels: HashMap::new(), annotations: HashMap::new(), - workspace: workspace.clone(), + workspace_scope: Some(named_workspace_scope(&workspace)), await_main_process_attachment: false, workload_template_name: String::new(), }; @@ -1434,7 +1407,7 @@ fn spawn_create_sandbox(app: &mut App, tx: mpsc::UnboundedSender) { // If ports or command are set, wait for Ready before finishing. if need_ready { let mut attempts = 0; - let sandbox_id = loop { + let _sandbox_id = loop { attempts += 1; if attempts > 150 { let _ = tx.send(Event::CreateResult(Err( @@ -1445,8 +1418,8 @@ fn spawn_create_sandbox(app: &mut App, tx: mpsc::UnboundedSender) { tokio::time::sleep(Duration::from_secs(2)).await; let req = openshell_core::proto::GetSandboxRequest { - name: sandbox_name.clone(), - workspace: workspace.clone(), + sandbox_name: sandbox_name.clone(), + workspace_scope: Some(openshell_core::proto::workspace_selector(&workspace)), }; // Retry on transient errors. if let Ok(resp) = client.get_sandbox(req).await @@ -1471,7 +1444,7 @@ fn spawn_create_sandbox(app: &mut App, tx: mpsc::UnboundedSender) { &endpoint, &gateway_name, &sandbox_name, - &sandbox_id, + &workspace, &ports, ) .await; @@ -1496,7 +1469,7 @@ async fn start_port_forwards( endpoint: &str, gateway_name: &str, sandbox_name: &str, - sandbox_id: &str, + workspace: &str, specs: &[openshell_core::forward::ForwardSpec], ) -> Vec { let mut warnings = Vec::new(); @@ -1504,7 +1477,8 @@ async fn start_port_forwards( // Create SSH session. let session = { let req = openshell_core::proto::CreateSshSessionRequest { - sandbox_id: sandbox_id.to_string(), + sandbox_name: sandbox_name.to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector(workspace)), }; match tokio::time::timeout(Duration::from_secs(10), client.create_ssh_session(req)).await { Ok(Ok(resp)) => resp.into_inner(), @@ -1543,7 +1517,7 @@ async fn start_port_forwards( let proxy_command = build_proxy_command( &exe.to_string_lossy(), &gateway_url, - &session.sandbox_id, + sandbox_name, &session.token, gateway_name, ); @@ -1610,7 +1584,7 @@ async fn start_port_forwards( match result { Ok(Ok(true)) => { - if let Some(pid) = openshell_core::forward::find_ssh_forward_pid(&sid, port_val) { + if let Some(pid) = openshell_core::forward::find_ssh_forward_pid(&name, port_val) { let _ = openshell_core::forward::write_forward_pid( &name, port_val, pid, &sid, &bind_addr, ); @@ -1688,7 +1662,7 @@ fn spawn_create_provider(app: &App, tx: mpsc::UnboundedSender) { profile_workspace: workspace.clone(), credential_handles: HashMap::default(), }), - workspace: workspace.clone(), + workspace_scope: Some(named_workspace_scope(&workspace)), }; match client.create_provider(req).await { @@ -1729,7 +1703,10 @@ fn spawn_get_provider(app: &App, tx: mpsc::UnboundedSender) { let workspace = app.selected_provider_workspace(); tokio::spawn(async move { - let req = openshell_core::proto::GetProviderRequest { name, workspace }; + let req = openshell_core::proto::GetProviderRequest { + name, + workspace_scope: Some(named_workspace_scope(workspace)), + }; match tokio::time::timeout(Duration::from_secs(5), client.get_provider(req)).await { Ok(Ok(resp)) => { if let Some(provider) = resp.into_inner().provider { @@ -1803,7 +1780,7 @@ fn spawn_update_provider(app: &App, tx: mpsc::UnboundedSender) { credential_handles: HashMap::default(), }), credential_expires_at_ms: HashMap::default(), - workspace, + workspace_scope: Some(named_workspace_scope(workspace)), }; match tokio::time::timeout(Duration::from_secs(5), client.update_provider(req)).await { @@ -1832,7 +1809,10 @@ fn spawn_delete_provider(app: &App, tx: mpsc::UnboundedSender) { let workspace = app.selected_provider_workspace(); tokio::spawn(async move { - let req = openshell_core::proto::DeleteProviderRequest { name, workspace }; + let req = openshell_core::proto::DeleteProviderRequest { + name, + workspace_scope: Some(named_workspace_scope(workspace)), + }; match tokio::time::timeout(Duration::from_secs(5), client.delete_provider(req)).await { Ok(Ok(resp)) => { let _ = tx.send(Event::ProviderDeleteResult(Ok(resp.into_inner().deleted))); @@ -1873,9 +1853,9 @@ fn spawn_draft_approve(app: &App, tx: mpsc::UnboundedSender) { tokio::spawn(async move { let req = openshell_core::proto::ApproveDraftChunkRequest { - name, + sandbox_name: name.clone(), + workspace_scope: Some(openshell_core::proto::workspace_selector(workspace)), chunk_id, - workspace, review_token, }; match tokio::time::timeout(Duration::from_secs(5), client.approve_draft_chunk(req)).await { @@ -1918,10 +1898,10 @@ fn spawn_draft_reject(app: &App, tx: mpsc::UnboundedSender) { tokio::spawn(async move { let req = openshell_core::proto::RejectDraftChunkRequest { - name, + sandbox_name: name.clone(), + workspace_scope: Some(openshell_core::proto::workspace_selector(workspace)), chunk_id, reason: String::new(), - workspace, }; match tokio::time::timeout(Duration::from_secs(5), client.reject_draft_chunk(req)).await { Ok(Ok(_)) => { @@ -1968,9 +1948,9 @@ fn spawn_draft_approve_all( }) .collect(); let req = openshell_core::proto::ApproveAllDraftChunksRequest { - name, + sandbox_name: name.clone(), + workspace_scope: Some(openshell_core::proto::workspace_selector(workspace)), include_security_flagged: false, - workspace, approvals, }; match tokio::time::timeout( @@ -2094,12 +2074,10 @@ async fn refresh_providers(app: &mut App) { let req = openshell_core::proto::ListProvidersRequest { limit: 100, offset: 0, - workspace: if app.all_workspaces { - String::new() - } else { - app.current_workspace.clone() - }, - all_workspaces: app.all_workspaces, + workspace_scope: Some(list_workspace_scope( + &app.current_workspace, + app.all_workspaces, + )), }; let response = match tokio::time::timeout(Duration::from_secs(5), app.client.list_providers(req)).await { @@ -2243,11 +2221,11 @@ async fn refresh_global_settings(app: &mut App) { // Check for an active global policy only while the caller can read it. let policy_req = openshell_core::proto::ListSandboxPoliciesRequest { - name: String::new(), limit: 1, offset: 0, global: true, - workspace: String::new(), + sandbox_name: String::new(), + workspace_scope: None, }; match tokio::time::timeout( Duration::from_secs(5), @@ -2323,11 +2301,9 @@ fn spawn_set_global_setting(app: &App, tx: mpsc::UnboundedSender) { }; let req = UpdateConfigRequest { - name: String::new(), setting_key: key, setting_value: Some(SettingValue { value: Some(value) }), global: true, - workspace: String::new(), ..Default::default() }; @@ -2357,11 +2333,9 @@ fn spawn_delete_global_setting(app: &App, tx: mpsc::UnboundedSender) { use openshell_core::proto::UpdateConfigRequest; let req = UpdateConfigRequest { - name: String::new(), setting_key: key, delete_setting: true, global: true, - workspace: String::new(), ..Default::default() }; @@ -2426,10 +2400,10 @@ fn spawn_set_sandbox_setting(app: &App, tx: mpsc::UnboundedSender) { }; let req = UpdateConfigRequest { - name, + sandbox_name: name.clone(), + workspace_scope: Some(openshell_core::proto::workspace_selector(workspace)), setting_key: key, setting_value: Some(SettingValue { value: Some(value) }), - workspace, ..Default::default() }; @@ -2464,10 +2438,10 @@ fn spawn_delete_sandbox_setting(app: &App, tx: mpsc::UnboundedSender) { use openshell_core::proto::UpdateConfigRequest; let req = UpdateConfigRequest { - name, + sandbox_name: name.clone(), + workspace_scope: Some(openshell_core::proto::workspace_selector(workspace)), setting_key: key, delete_setting: true, - workspace, ..Default::default() }; @@ -2511,12 +2485,10 @@ async fn refresh_sandboxes(app: &mut App) { limit: 100, offset: 0, label_selector: String::new(), - workspace: if app.all_workspaces { - String::new() - } else { - app.current_workspace.clone() - }, - all_workspaces: app.all_workspaces, + workspace_scope: Some(list_workspace_scope( + &app.current_workspace, + app.all_workspaces, + )), }; let result = tokio::time::timeout(Duration::from_secs(5), app.client.list_sandboxes(req)).await; match result { @@ -2620,12 +2592,16 @@ async fn refresh_sandboxes(app: &mut App) { /// Unlike `fetch_sandbox_detail()`, this skips the `GetSandbox` metadata call /// and preserves the current scroll position so the user isn't disrupted. async fn refresh_sandbox_policy(app: &mut App) { - let sandbox_id = match app.selected_sandbox_id() { - Some(id) => id.to_string(), + let sandbox_name = match app.selected_sandbox_name() { + Some(name) => name.to_string(), None => return, }; + let workspace = app.selected_sandbox_workspace(); - let policy_req = openshell_core::proto::GetSandboxConfigRequest { sandbox_id }; + let policy_req = openshell_core::proto::GetSandboxConfigRequest { + sandbox_name, + workspace_scope: Some(openshell_core::proto::workspace_selector(workspace)), + }; match tokio::time::timeout( Duration::from_secs(5), @@ -2663,9 +2639,11 @@ async fn refresh_draft_chunks(app: &mut App) { }; let req = openshell_core::proto::GetDraftPolicyRequest { - name: sandbox_name, + sandbox_name, + workspace_scope: Some(openshell_core::proto::workspace_selector( + app.selected_sandbox_workspace(), + )), status_filter: String::new(), - workspace: app.selected_sandbox_workspace(), }; if let Ok(Ok(resp)) = @@ -2694,9 +2672,9 @@ async fn refresh_sandbox_draft_counts(app: &mut App) { .cloned() .unwrap_or_else(|| app.current_workspace.clone()); let req = openshell_core::proto::GetDraftPolicyRequest { - name: name.clone(), + sandbox_name: name.clone(), + workspace_scope: Some(openshell_core::proto::workspace_selector(ws)), status_filter: "pending".to_string(), - workspace: ws, }; if let Ok(Ok(resp)) = tokio::time::timeout(Duration::from_secs(2), app.client.get_draft_policy(req)).await diff --git a/docs/reference/policy-schema.mdx b/docs/reference/policy-schema.mdx index 73a6b5cfc4..8f7baa3130 100644 --- a/docs/reference/policy-schema.mdx +++ b/docs/reference/policy-schema.mdx @@ -185,7 +185,7 @@ Each endpoint defines a reachable destination and optional inspection rules. | `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`. | | `mcp` | object | No | MCP endpoint options for `protocol: mcp`. Omit this key to use all MCP endpoint defaults, including the exact `2025-11-25` revision; `mcp: null` is invalid. The object is rejected on other protocols. Every MCP endpoint must still set a concrete `host` and `port` or `ports`; an entry containing only `protocol: mcp` is invalid and is not treated as a wildcard endpoint. | -| `mcp.versions` | list of string | No | Nonempty allowlist of exact supported MCP core revisions: `2025-03-26`, `2025-06-18`, and `2025-11-25`. Omission resolves to the exact allowlist `["2025-11-25"]`; it never means latest or all known revisions. The key must be absent to use this default; `versions: null` and `versions: []` are invalid. Use an explicit nonempty list only for intentional compatibility or downgrade control. The exact floor is `2025-03-26`; this is a closed set, not a date range. Values must be unique and contain no extra whitespace. Moving aliases such as `draft` and `latest`, unknown dates, and named SEP overlays are not accepted. OpenShell stores the materialized list in semantic order. The sessionless `2026-07-28` revision is not accepted until its distinct per-request runtime contract is supported. This field declares compatibility but does not yet select request parsing or forwarding behavior. For an unsupported revision, omit `protocol` and `mcp` only when deliberate uninspected L4 passthrough is an acceptable weaker boundary. | +| `mcp.versions` | list of string | No | Nonempty allowlist of exact supported MCP core revisions: `2025-03-26`, `2025-06-18`, and `2025-11-25`. Omission resolves to the exact allowlist `["2025-11-25"]`; it never means latest or all known revisions. The key must be absent to use this default; `versions: null` and `versions: []` are invalid. Values must be unique, contain no extra whitespace, and name a revision in the closed supported set. For every request except a valid standalone `initialize`, OpenShell selects the revision from one `MCP-Protocol-Version` header or, when the header is absent, the MCP specification's `2025-03-26` compatibility fallback. The selected revision must appear in this allowlist. Duplicate, empty, or unsupported header values receive `400 Bad Request`; a supported revision outside the allowlist receives `403 Forbidden`. The sessionless `2026-07-28` revision is not yet supported. For an unsupported revision, omit `protocol` and `mcp` only when deliberate uninspected L4 passthrough is an acceptable weaker boundary. | | `mcp.max_body_bytes` | integer | No | Maximum MCP JSON-RPC-over-HTTP request body bytes buffered for inspection. Defaults to `65536`. | | `mcp.strict_tool_names` | bool | No | Defaults to `true`. Requires `tools/call` `params.name` values to match `^[A-Za-z0-9_.-]{1,128}$` before policy evaluation. Set to `false` only for compatibility with MCP servers that intentionally use non-recommended tool names. Wildcard `tool` matchers require this to remain enabled. | | `mcp.allow_all_known_mcp_methods` | bool | No | Defaults to `false`. When `true`, enables the endpoint MCP method profile: omitted `rules` allow all MCP-family methods and all tools before `deny_rules`, and omitted rule `method` uses that profile. When unset or `false`, explicit MCP method rules are required; rules with `tool` or `params.name` must set `method: tools/call`. | @@ -346,7 +346,7 @@ Do not combine `method`, `path`, or `query` with `operation_type`, `operation_na ##### MCP Allow And Deny Rules (`protocol: mcp`) -MCP rules match sandbox-to-server MCP Streamable HTTP request bodies by MCP method and optional tool selectors. OpenShell parses the underlying JSON-RPC 2.0 envelope, validates known MCP request and notification params, and preserves unknown extension methods as policy-addressable literal method strings. An endpoint that omits `mcp.versions`, including one that omits the entire `mcp` object, immediately resolves to the exact `2025-11-25` allowlist. Canonical serialization and stored policy data contain that explicit materialized list, so adding another supported revision cannot widen the normalized policy. The schema validates and canonicalizes an explicit nonempty allowlist as an advanced compatibility or downgrade control, but it does not yet use the allowlist to select request parsing or forwarding behavior. Later runtime negotiation must select one server-confirmed revision from the allowlist and apply only that profile, never a union or fallback. `mcp.allow_all_known_mcp_methods` defaults to `false`, so endpoints require explicit MCP method rules. Set it to `true` to enable the endpoint method profile; in that mode, rules can omit `method`, and tool selectors are normalized to `tools/call` internally. By default, `tools/call` `params.name` must match the MCP-recommended tool-name pattern `^[A-Za-z0-9_.-]{1,128}$`; configure `mcp.strict_tool_names: false` on the endpoint only to allow a server that intentionally uses names outside that pattern. Wildcard `tool` matchers require `mcp.strict_tool_names` to remain enabled. JSON-RPC responses and server-to-client MCP messages on response bodies or SSE streams are relayed but are not currently parsed for policy enforcement. +MCP rules match sandbox-to-server MCP Streamable HTTP request bodies by MCP method and optional tool selectors. OpenShell parses the underlying JSON-RPC 2.0 envelope, validates known MCP request and notification params, and preserves unknown extension methods as policy-addressable literal method strings. An endpoint that omits `mcp.versions`, including one that omits the entire `mcp` object, immediately resolves to the exact `2025-11-25` allowlist. Canonical serialization and stored policy data contain that explicit materialized list, so adding another supported revision cannot widen the normalized policy. For every request except a valid standalone `initialize`, OpenShell checks exactly one `MCP-Protocol-Version` value against the allowlist before policy evaluation and repeats the check after middleware changes the request. An absent header selects the `2025-03-26` compatibility fallback; it does not select the policy default. The check stores no connection or session state. The current parser remains version-independent and does not yet apply the batch rules recorded in each revision's wire profile. `mcp.allow_all_known_mcp_methods` defaults to `false`, so endpoints require explicit MCP method rules. Set it to `true` to enable the endpoint method profile; in that mode, rules can omit `method`, and tool selectors are normalized to `tools/call` internally. By default, `tools/call` `params.name` must match the MCP-recommended tool-name pattern `^[A-Za-z0-9_.-]{1,128}$`; configure `mcp.strict_tool_names: false` on the endpoint only to allow a server that intentionally uses names outside that pattern. Wildcard `tool` matchers require `mcp.strict_tool_names` to remain enabled. JSON-RPC responses and server-to-client MCP messages on response bodies or SSE streams are relayed but are not currently parsed for policy enforcement. Use `rules` for MCP allow rules and `deny_rules` for MCP deny rules. Deny rules take precedence over allow rules. If an MCP endpoint sets `mcp.allow_all_known_mcp_methods: true` and omits `rules`, OpenShell allows all MCP-family methods and all tools, then applies any `deny_rules`. Otherwise, the endpoint must define explicit rules. A broad allow or deny rule whose method matcher includes `tools/call` cannot be combined with tool-specific allow rules because it would bypass or erase the tool filter; add `tool` or `params.name` to scope `tools/call`, or remove the tool-specific rules. In a batch request, one denied call denies the full batch. diff --git a/docs/sandboxes/manage-workspaces.mdx b/docs/sandboxes/manage-workspaces.mdx index 2212eb93e7..cd4812499a 100644 --- a/docs/sandboxes/manage-workspaces.mdx +++ b/docs/sandboxes/manage-workspaces.mdx @@ -149,8 +149,9 @@ export OPENSHELL_WORKSPACE=team-ml openshell sandbox list ``` -An empty workspace value resolves to `default`. It never means all -workspaces. +When `--workspace` is omitted, the CLI intentionally selects the `default` +workspace. An empty workspace name is invalid and never means either `default` +or all workspaces. Platform Admins can opt into cross-workspace list operations: @@ -160,6 +161,49 @@ openshell provider list --all-workspaces openshell service list --all-workspaces ``` +The public API represents this choice with a `WorkspaceSelector` oneof. Set +`workspace` to a non-empty name, including the literal `default`, or set the +`all_workspaces` marker on list requests that support it. Omitting the selector +or sending an unset selector is invalid for workspace-scoped operations. The +all-workspaces variant is accepted only by sandbox, sandbox template, provider, +and service list requests, and it requires Platform Admin access. For services, +choose either a sandbox name or `--all-workspaces`; the options are mutually +exclusive. + +Clients migrating from the previous request fields should make the scope +explicit: + +| Previous request | Typed selector | +| --- | --- | +| `workspace: "team-ml"` | `workspace_scope.workspace: "team-ml"` | +| Empty or omitted `workspace` for the default | `workspace_scope.workspace: "default"` | +| `all_workspaces: true` | `workspace_scope.all_workspaces: {}` | +| `global: true` | Omit `workspace_scope` | + +Every sandbox-scoped RPC identifies the sandbox by its human-readable name and +keeps workspace selection in a separate field: + +```json +{ + "sandboxName": "agent", + "workspaceScope": { + "workspace": "team-ml" + } +} +``` + +Public requests do not accept canonical sandbox IDs as references. Fields that +previously accepted `sandbox_id` are removed and their protobuf field numbers +are reserved. Resolve stored IDs to sandbox names before calling the public +API. List RPCs that are not filtered to one sandbox continue to use only the +request-level `workspace_scope` selector. + +The Rust and Python SDKs expose separate all-workspaces list methods. The Go +SDK uses `ListAll`, and the TypeScript SDK uses a discriminated option type, so +a caller cannot select a named workspace and all workspaces in one typed call. +The TUI starts in the `default` workspace and sends that named selector +explicitly. Its all-workspaces view sends the marker instead. + Provider profiles and policy also have explicit `--global` operations. Those operations target platform scope and require Platform Admin access. A Workspace Admin should use `--workspace` for workspace-scoped profiles and diff --git a/docs/sandboxes/policies.mdx b/docs/sandboxes/policies.mdx index d0fc05b061..af25238a6b 100644 --- a/docs/sandboxes/policies.mdx +++ b/docs/sandboxes/policies.mdx @@ -738,7 +738,7 @@ REST rules can also constrain query parameter values: ### MCP and JSON-RPC matching -MCP endpoints use `protocol: mcp`. The proxy parses sandbox-to-server MCP Streamable HTTP request bodies, validates known MCP request and notification params, can evaluate the MCP method against rule `method`, and can match tool calls with the `tool` alias. Unknown extension methods stay addressable as literal method strings. You may omit the entire `mcp` stanza when using its defaults, or omit only `mcp.versions` when setting another MCP option. OpenShell immediately resolves either form to the exact `["2025-11-25"]` allowlist and stores the materialized list in canonical policy data. Adding another supported revision therefore never widens a normalized policy. Defaulting requires the `mcp` or `mcp.versions` key to be absent; explicit `mcp: null`, `versions: null`, and `versions: []` values are invalid. Use an explicit nonempty allowlist only for intentional compatibility or downgrade control. Supported revisions are `2025-03-26`, `2025-06-18`, and `2025-11-25`; the exact support floor is `2025-03-26`, and this is a closed set rather than a date range. Explicit values must be unique and contain no extra whitespace; OpenShell stores them in semantic order. Moving aliases such as `draft` or `latest` are rejected because they could change policy meaning without a policy edit; omission never means all known versions. The sessionless `2026-07-28` revision is not accepted until OpenShell supports its distinct per-request runtime contract. A version identifies a core MCP revision only; there is no policy syntax for separately named SEP overlays. The allowlist establishes the policy contract but does not yet select request parsing or forwarding behavior. Later runtime negotiation must select one server-confirmed revision from the allowlist and apply that exact profile, never a union or fallback. `mcp.allow_all_known_mcp_methods` defaults to `false`, so endpoints require explicit MCP method rules. Set it to `true` to enable the endpoint method profile; in that mode, rules can omit `method`, and tool selectors are normalized to `tools/call` internally. By default, MCP `tools/call` tool names must match `^[A-Za-z0-9_.-]{1,128}$`; set `mcp.strict_tool_names: false` on that endpoint only when a server intentionally uses names outside the MCP-recommended pattern. Wildcard `tool` matchers require `mcp.strict_tool_names` to remain enabled. Generic JSON-RPC endpoints use `protocol: json-rpc` and evaluate `method`. +MCP endpoints use `protocol: mcp`. The proxy parses sandbox-to-server MCP Streamable HTTP request bodies, validates known MCP request and notification params, can evaluate the MCP method against rule `method`, and can match tool calls with the `tool` alias. Unknown extension methods stay addressable as literal method strings. You may omit the entire `mcp` stanza when using its defaults, or omit only `mcp.versions` when setting another MCP option. OpenShell immediately resolves either form to the exact `["2025-11-25"]` allowlist and stores the materialized list in canonical policy data. Adding another supported revision therefore never widens a normalized policy. Defaulting requires the `mcp` or `mcp.versions` key to be absent; explicit `mcp: null`, `versions: null`, and `versions: []` values are invalid. Use an explicit nonempty allowlist only for intentional compatibility or downgrade control. Supported revisions are `2025-03-26`, `2025-06-18`, and `2025-11-25`; the exact support floor is `2025-03-26`, and this is a closed set rather than a date range. Explicit values must be unique and contain no extra whitespace; OpenShell stores them in semantic order. Moving aliases such as `draft` or `latest` are rejected because they could change policy meaning without a policy edit; omission never means all known versions. The sessionless `2026-07-28` revision is not accepted until OpenShell supports its distinct per-request contract. A version identifies a core MCP revision only; there is no policy syntax for separately named SEP overlays. For every MCP HTTP request except a valid standalone `initialize`, OpenShell selects the revision from one `MCP-Protocol-Version` header or, when the header is absent, the MCP specification's `2025-03-26` compatibility fallback. The selected revision must appear in the allowlist. Duplicate, empty, and unsupported header values receive `400 Bad Request`; a supported revision outside the allowlist receives `403 Forbidden`. OpenShell checks the request again after middleware changes it and before forwarding. Generic JSON-RPC endpoints do not use this header and continue to evaluate only `method`. `mcp.allow_all_known_mcp_methods` defaults to `false`, so endpoints require explicit MCP method rules. Set it to `true` to enable the endpoint method profile; in that mode, rules can omit `method`, and tool selectors are normalized to `tools/call` internally. By default, MCP `tools/call` tool names must match `^[A-Za-z0-9_.-]{1,128}$`; set `mcp.strict_tool_names: false` on that endpoint only when a server intentionally uses names outside the MCP-recommended pattern. Wildcard `tool` matchers require `mcp.strict_tool_names` to remain enabled. The current registry declares these profile facts for later runtime enforcement: @@ -746,7 +746,7 @@ The current registry declares these profile facts for later runtime enforcement: - `2025-06-18` prohibits top-level JSON-RPC arrays. - `2025-11-25` prohibits top-level JSON-RPC arrays. -These declarations do not change the current version-independent request parser. OpenShell does not yet treat the client's `initialize.params.protocolVersion` as the selected profile, inspect the server's negotiated revision, bind `MCP-Session-Id`, or validate `MCP-Protocol-Version` on later requests. Later runtime selection must observe the successful server response, verify that the selected revision appears in the effective allowlist, and bind that one exact profile without a union or fallback so the proxy does not interfere with version negotiation. +OpenShell uses the per-request header only to enforce the allowlist; the current parser does not yet apply these version-specific batch rules. OpenShell does not treat the client's `initialize.params.protocolVersion` as the selected revision, inspect the server's initialization response, or bind `MCP-Session-Id`. The version check is stateless and applies independently to each later request, including GET and DELETE. MCP endpoints must declare a concrete destination with `host` and `port` or `ports`. A policy entry that only sets `protocol: mcp` is invalid and is not treated as a wildcard MCP authorization. Use `path: /mcp` when the server's MCP endpoint is path-scoped; omitting `path` matches every HTTP path on that host and port. diff --git a/e2e/mcp-conformance/README.md b/e2e/mcp-conformance/README.md index 0be6b34c22..8d244ab612 100644 --- a/e2e/mcp-conformance/README.md +++ b/e2e/mcp-conformance/README.md @@ -35,7 +35,7 @@ bridge at `host.openshell.internal` (the alias `e2e/with-docker-gateway.sh` attaches to the CI job container on the e2e network), at `host.docker.internal` on local Docker Desktop, or via `--add-host ...:host-gateway` on local Linux. -The generated policy uses `protocol: mcp`, inserts the conformance runner's spec revision into the endpoint allowlist, and sets `mcp.allow_all_known_mcp_methods: true` so omitted rule methods use the endpoint MCP method profile. This schema declaration does not yet make OpenShell select or enforce that wire profile; the conformance runner still owns the revision used by its client and server. The policy keeps OpenShell deny-by-default at the network boundary while allowing the upstream scenarios to exercise MCP behavior. The policy body lives in `policy-template.yaml`; the wrapper renders its MCP revision, host, port, and path placeholders from the upstream server URL. +The generated policy uses `protocol: mcp`, inserts the conformance runner's spec revision into the endpoint allowlist, and sets `mcp.allow_all_known_mcp_methods: true` so omitted rule methods use the endpoint MCP method profile. OpenShell enforces that allowlist on each non-initialize request using `MCP-Protocol-Version`, with `2025-03-26` as the missing-header fallback. The conformance runner selects the revision used by its client and server; OpenShell's request-version check does not yet provide complete revision-specific message parsing or response validation. The policy keeps OpenShell deny-by-default at the network boundary while allowing the upstream scenarios to exercise MCP behavior. The policy body lives in `policy-template.yaml`; the wrapper renders its MCP revision, host, port, and path placeholders from the upstream server URL. For local runs, the wrapper builds `openshell/supervisor:dev` automatically when no supervisor image override is set. Set `OPENSHELL_DOCKER_SUPERVISOR_IMAGE` diff --git a/e2e/rust/tests/credential_drivers.rs b/e2e/rust/tests/credential_drivers.rs index 34a457b542..fd8ebb0607 100644 --- a/e2e/rust/tests/credential_drivers.rs +++ b/e2e/rust/tests/credential_drivers.rs @@ -41,6 +41,12 @@ fn credential_driver() -> String { .unwrap_or_else(|_| "kubernetes-secrets".to_string()) } +#[derive(Debug)] +struct ProviderIdentity { + id: String, + workspace: String, +} + fn vault_namespace() -> String { std::env::var("OPENSHELL_E2E_VAULT_NAMESPACE").unwrap_or_else(|_| "vault".to_string()) } @@ -53,23 +59,28 @@ fn vault_token() -> String { std::env::var("OPENSHELL_E2E_VAULT_TOKEN").unwrap_or_else(|_| "root".to_string()) } -fn managed_kubernetes_secret_name(provider_name: &str) -> String { +fn managed_credential_hash(identity: &ProviderIdentity, provider_name: &str) -> String { + // This test stores an ordinary provider credential, whose storage object ID is + // the provider ID, so both managed drivers use these four identity fields. let mut hasher = Sha256::new(); + hasher.update(identity.workspace.as_bytes()); + hasher.update([0]); + hasher.update(identity.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(); - let hex = format!("{digest:x}"); + format!("{digest:x}") +} + +fn managed_kubernetes_secret_name(identity: &ProviderIdentity, provider_name: &str) -> String { + let hex = managed_credential_hash(identity, provider_name); format!("openshell-cred-{}", &hex[..40]) } -fn managed_vault_path(provider_name: &str) -> String { - let mut hasher = Sha256::new(); - hasher.update(provider_name.as_bytes()); - hasher.update([0]); - hasher.update(CREDENTIAL_KEY.as_bytes()); - let digest = hasher.finalize(); - let hex = format!("{digest:x}"); +fn managed_vault_path(identity: &ProviderIdentity, provider_name: &str) -> String { + let hex = managed_credential_hash(identity, provider_name); format!("openshell/provider-credentials/{}", &hex[..40]) } @@ -217,6 +228,31 @@ async fn create_provider(name: &str, secret_value: &str) -> Result Result { + let (output, code) = run_cli(&["provider", "list", "--output", "json"]).await; + let clean = strip_ansi(&output); + if code != 0 { + return Err(format!("provider list failed (exit {code}):\n{clean}")); + } + let providers: Vec = serde_json::from_str(&clean) + .map_err(|err| format!("failed to parse provider list JSON: {err}\n{clean}"))?; + let provider = providers + .iter() + .find(|provider| provider["name"].as_str() == Some(provider_name)) + .ok_or_else(|| format!("provider '{provider_name}' was not returned by provider list"))?; + let id = provider["id"] + .as_str() + .filter(|id| !id.is_empty()) + .ok_or_else(|| format!("provider '{provider_name}' did not include an ID"))?; + let workspace = provider["workspace"] + .as_str() + .ok_or_else(|| format!("provider '{provider_name}' did not include a workspace"))?; + Ok(ProviderIdentity { + id: id.to_string(), + workspace: workspace.to_string(), + }) +} + async fn assert_provider_get_does_not_expose_secret( provider_name: &str, secret_value: &str, @@ -296,11 +332,12 @@ async fn configure_vault_storage() -> Result<(), String> { } async fn assert_kubernetes_secret_stored( + identity: &ProviderIdentity, provider_name: &str, secret_value: &str, ) -> Result<(), String> { let namespace = namespace(); - let secret_name = managed_kubernetes_secret_name(provider_name); + let secret_name = managed_kubernetes_secret_name(identity, provider_name); let encoded = kubectl(&[ "-n", &namespace, @@ -322,9 +359,12 @@ async fn assert_kubernetes_secret_stored( Ok(()) } -async fn assert_kubernetes_secret_deleted(provider_name: &str) -> Result<(), String> { +async fn assert_kubernetes_secret_deleted( + identity: &ProviderIdentity, + provider_name: &str, +) -> Result<(), String> { let namespace = namespace(); - let secret_name = managed_kubernetes_secret_name(provider_name); + let secret_name = managed_kubernetes_secret_name(identity, provider_name); match kubectl(&["-n", &namespace, "get", "secret", &secret_name]).await { Ok(output) => Err(format!( "Kubernetes Secret '{secret_name}' still exists after provider deletion:\n{output}" @@ -333,8 +373,12 @@ async fn assert_kubernetes_secret_deleted(provider_name: &str) -> Result<(), Str } } -async fn assert_vault_secret_stored(provider_name: &str, secret_value: &str) -> Result<(), String> { - let logical_path = managed_vault_path(provider_name); +async fn assert_vault_secret_stored( + identity: &ProviderIdentity, + provider_name: &str, + secret_value: &str, +) -> Result<(), String> { + let logical_path = managed_vault_path(identity, provider_name); let output = bao(&[ "kv", "get", @@ -348,8 +392,11 @@ async fn assert_vault_secret_stored(provider_name: &str, secret_value: &str) -> Ok(()) } -async fn assert_vault_secret_deleted(provider_name: &str) -> Result<(), String> { - let logical_path = managed_vault_path(provider_name); +async fn assert_vault_secret_deleted( + identity: &ProviderIdentity, + provider_name: &str, +) -> Result<(), String> { + let logical_path = managed_vault_path(identity, provider_name); match bao(&[ "kv", "get", @@ -367,20 +414,27 @@ async fn assert_vault_secret_deleted(provider_name: &str) -> Result<(), String> async fn assert_backend_stored( driver: &str, + identity: &ProviderIdentity, provider_name: &str, secret_value: &str, ) -> Result<(), String> { match driver { - "kubernetes-secrets" => assert_kubernetes_secret_stored(provider_name, secret_value).await, - "vault" => assert_vault_secret_stored(provider_name, secret_value).await, + "kubernetes-secrets" => { + assert_kubernetes_secret_stored(identity, provider_name, secret_value).await + } + "vault" => assert_vault_secret_stored(identity, provider_name, secret_value).await, other => Err(format!("unsupported credential driver '{other}'")), } } -async fn assert_backend_deleted(driver: &str, provider_name: &str) -> Result<(), String> { +async fn assert_backend_deleted( + driver: &str, + identity: &ProviderIdentity, + provider_name: &str, +) -> Result<(), String> { match driver { - "kubernetes-secrets" => assert_kubernetes_secret_deleted(provider_name).await, - "vault" => assert_vault_secret_deleted(provider_name).await, + "kubernetes-secrets" => assert_kubernetes_secret_deleted(identity, provider_name).await, + "vault" => assert_vault_secret_deleted(identity, provider_name).await, other => Err(format!("unsupported credential driver '{other}'")), } } @@ -409,23 +463,24 @@ async fn provider_credentials_are_stored_in_configured_backend() { .expect("configure Vault storage fixture"); } - let result: Result<(), String> = async { + let result: Result = async { create_provider(&provider_name, &secret_value).await?; assert_provider_get_does_not_expose_secret(&provider_name, &secret_value).await?; - assert_backend_stored(&driver, &provider_name, &secret_value).await?; + let identity = provider_identity(&provider_name).await?; + assert_backend_stored(&driver, &identity, &provider_name, &secret_value).await?; assert_provider_placeholder_available_in_sandbox( &provider_name, &sandbox_name, &secret_value, ) .await?; - Ok(()) + Ok(identity) } .await; delete_provider(&provider_name).await; - assert_backend_deleted(&driver, &provider_name) + let identity = result.expect("credential storage e2e failed"); + assert_backend_deleted(&driver, &identity, &provider_name) .await .expect("credential backend object should be deleted with provider"); - result.expect("credential storage e2e failed"); } diff --git a/examples/governance-interceptor/src/main.rs b/examples/governance-interceptor/src/main.rs index 207727622a..648b0000bc 100644 --- a/examples/governance-interceptor/src/main.rs +++ b/examples/governance-interceptor/src/main.rs @@ -1233,8 +1233,7 @@ async fn propagate_policy_to_running_sandboxes( limit, offset, label_selector: String::new(), - workspace: String::new(), - all_workspaces: true, + workspace_scope: Some(openshell_core::proto::all_workspaces_selector()), }) .await .map_err(|status| format!("list sandboxes failed: {status}"))? @@ -1253,7 +1252,8 @@ async fn propagate_policy_to_running_sandboxes( .map_or(0, |metadata| metadata.resource_version); let result = client .update_config(UpdateConfigRequest { - name: name.clone(), + sandbox_name: name.clone(), + workspace_scope: Some(openshell_core::proto::workspace_selector("default")), policy: Some(policy_state.policy_proto.clone()), annotations: policy_update_annotations(policy_state, &correlation_id), expected_resource_version: resource_version, diff --git a/examples/governance-interceptor/src/smoke_client.rs b/examples/governance-interceptor/src/smoke_client.rs index e1dfde4038..bc5c7bdaeb 100644 --- a/examples/governance-interceptor/src/smoke_client.rs +++ b/examples/governance-interceptor/src/smoke_client.rs @@ -57,7 +57,8 @@ async fn main() -> Result<(), Box> { let before = client .get_sandbox_config(GetSandboxConfigRequest { - sandbox_id: sandbox_id.clone(), + sandbox_name: sandbox_name.clone(), + workspace_scope: None, }) .await? .into_inner(); @@ -83,7 +84,8 @@ async fn main() -> Result<(), Box> { let policy_result = client .update_config(UpdateConfigRequest { - name: sandbox_name.clone(), + sandbox_name: sandbox_name.clone(), + workspace_scope: Some(openshell_core::proto::workspace_selector("default")), policy: Some(widened_policy), ..Default::default() }) @@ -107,7 +109,7 @@ async fn main() -> Result<(), Box> { client .submit_policy_analysis(SubmitPolicyAnalysisRequest { - name: sandbox_name, + name: sandbox_name.clone(), network_activity_summaries: vec![NetworkActivitySummary { network_activity_count: 1, ..Default::default() @@ -119,7 +121,10 @@ async fn main() -> Result<(), Box> { .map_err(|status| format!("telemetry-only policy analysis was denied: {status}"))?; let after = client - .get_sandbox_config(GetSandboxConfigRequest { sandbox_id }) + .get_sandbox_config(GetSandboxConfigRequest { + sandbox_name, + workspace_scope: None, + }) .await? .into_inner(); if after.version != before.version || after.policy_hash != before.policy_hash { diff --git a/fern/README.md b/fern/README.md new file mode 100644 index 0000000000..b8e0ea3964 --- /dev/null +++ b/fern/README.md @@ -0,0 +1,67 @@ +# Fern documentation site + +OpenShell uses [Fern](https://buildwithfern.com/) to validate, preview, and publish the documentation at [docs.nvidia.com/openshell](https://docs.nvidia.com/openshell/). This directory contains the site configuration and presentation files. The documentation content lives in `docs/`. + +## Repository layout + +| Path | Purpose | +|---|---| +| `docs/` | MDX pages, navigation in `docs/index.yml`, and page-specific components. | +| `fern/docs.yml` | Site, theme, version, redirect, and navigation configuration. | +| `fern/fern.config.json` | Fern organization and pinned CLI version. | +| `fern/components/` | Shared site components. | +| `fern/assets/` | Logos and other shared assets. | +| `fern/main.css` | Site-wide styles. | + +In a normal source checkout, `fern/docs.yml` points the `latest` version at `docs/index.yml`. Release automation builds the multi-version configuration on the generated `docs-website` branch. + +## Local development + +Start a local Fern server from the repository root: + +```shell +mise run docs:serve +``` + +Validate the configuration, navigation, and links without starting a server: + +```shell +mise run docs +``` + +The tasks read the Fern CLI version from `fern/fern.config.json`, so local checks and GitHub Actions use the same version. See [docs/CONTRIBUTING.mdx](../docs/CONTRIBUTING.mdx) for the authoring and style guide. + +## Pull request previews + +`.github/workflows/branch-docs.yml` validates pull requests that change documentation or Fern configuration. When the workflow can access `FERN_TOKEN`, it publishes a Fern preview from the pull request checkout and adds the preview URL to the pull request. This path does not use or update the `docs-website` branch. + +## Versioned production site + +The generated `docs-website` branch contains the complete production input for Fern. Each version has an exact copy of its source commit's `docs/` tree under `fern/pages-/` and a navigation file under `fern/versions/`. `fern/.docs-snapshots.yml` records the original source ref, resolved source commit, and release version for each managed snapshot. + +The automated site uses these version types: + +| Version | Source | Update policy | Fern status | +|---|---|---|---| +| `latest` | The newest stable release. | Mutable. A maintenance release older than the current stable release cannot move it backward unless a maintainer explicitly allows a rollback. | No status before v0.1.0. | +| `dev` | The most recent successful Release Dev run from `main`. | Mutable. Automation rejects an older version or the same version from a different commit unless a maintainer explicitly allows a rollback. | Beta. | + +Release Dev waits for the development artifacts and Helm chart, then calls `.github/workflows/sync-docs.yml` once. The reusable workflow updates `dev`, validates the generated site, commits and pushes the branch when needed, and publishes the production site once. + +Release Tag follows the same sequence for a non-prerelease tag after the release artifacts, SDK package, Helm chart, and wheel publication complete. It updates `latest` when the release is not older than the current version, then publishes the production site once. + +The sync and publish workflows share the `docs-website` concurrency group. This serializes writes and publication. Queued runs remain pending instead of replacing one another. + +The `dev` snapshot also owns the shared Fern configuration, components, assets, and CSS on `docs-website`. The `latest` snapshot copies its documentation and navigation but does not replace those shared files. This keeps the site configuration aligned with `main` while preserving the released content. + +## Manual maintenance and publishing + +Maintainers can run `.github/workflows/sync-docs.yml` manually to add, refresh, or remove a historical version snapshot. The workflow preserves snapshots that were not selected. Production publishing is disabled by default for a manual sync. + +`.github/workflows/publish-docs-website.yml` validates and publishes the existing `docs-website` branch without syncing content. Its default mode creates a preview. Selecting production mode publishes the live site, so use it only for an intentional production republish. + +Run the automated sync tests after changing the version model or either publishing workflow: + +```shell +mise run test:docs-website +``` diff --git a/fern/fern.config.json b/fern/fern.config.json index 9ec6917e2e..635e3615c9 100644 --- a/fern/fern.config.json +++ b/fern/fern.config.json @@ -1,4 +1,4 @@ { "organization": "nvidia", - "version": "5.40.0" + "version": "5.112.0" } diff --git a/proto/datamodel.proto b/proto/datamodel.proto index b990f05768..d39ee75500 100644 --- a/proto/datamodel.proto +++ b/proto/datamodel.proto @@ -7,6 +7,26 @@ package openshell.datamodel.v1; import "options.proto"; +// Selects the workspace scope for a public API request. +// +// Requests that operate on one workspace require a non-empty `workspace`. +// Cross-workspace list requests additionally accept `all_workspaces`. The +// containing request documents which selections it supports; an omitted +// selector is invalid for workspace-scoped operations. +message WorkspaceSelector { + oneof selection { + // One explicitly named workspace. Use `default` to select the gateway's + // default workspace; an empty name is invalid. + string workspace = 1; + // All workspaces the caller is authorized to access. Only supported by + // requests that explicitly document cross-workspace behavior. + AllWorkspaces all_workspaces = 2; + } +} + +// Marker for the all-workspaces selector variant. +message AllWorkspaces {} + // Kubernetes-style metadata shared by all top-level OpenShell domain objects. // // This structure provides consistent metadata (identity, labels, annotations, diff --git a/proto/openshell.proto b/proto/openshell.proto index c2051f94b8..d9199cfc5b 100644 --- a/proto/openshell.proto +++ b/proto/openshell.proto @@ -1117,6 +1117,8 @@ message PlatformEvent { // Create sandbox request. message CreateSandboxRequest { + reserved 5; + reserved "workspace"; SandboxSpec spec = 1; // Optional user-supplied sandbox name. When empty the server generates one. string name = 2; @@ -1124,43 +1126,49 @@ message CreateSandboxRequest { map labels = 3; // Optional annotations for the sandbox (non-selector metadata). map annotations = 4; - // Workspace for the sandbox. Empty defaults to "default". - string workspace = 5; // One-shot launch hint indicating that the creating client will attach to // the canonical main process. The supervisor keeps the terminal transport // alive until that attachment connects and closes naturally. bool await_main_process_attachment = 6; // Workspace-scoped SandboxWorkloadTemplate name to resolve at creation time. string workload_template_name = 7; + // Explicit workspace for the sandbox. The all-workspaces selection is invalid. + openshell.datamodel.v1.WorkspaceSelector workspace_scope = 8; } message CreateSandboxTemplateRequest { + reserved 2; + reserved "workspace"; SandboxWorkloadTemplate template = 1; - // Workspace for the template. Empty defaults to "default". - string workspace = 2; + // Explicit workspace for the template. The all-workspaces selection is invalid. + openshell.datamodel.v1.WorkspaceSelector workspace_scope = 3; } message GetSandboxTemplateRequest { + reserved 2; + reserved "workspace"; string name = 1; - // Workspace scope. Empty defaults to "default". - string workspace = 2; + // Explicit workspace scope. The all-workspaces selection is invalid. + openshell.datamodel.v1.WorkspaceSelector workspace_scope = 3; } message ListSandboxTemplatesRequest { + reserved 3, 4; + reserved "workspace", "all_workspaces"; uint32 limit = 1; uint32 offset = 2; - // Workspace scope. Empty defaults to "default". - string workspace = 3; - // List across all workspaces. Mutually exclusive with workspace. - bool all_workspaces = 4; // Optional label selector in key=value comma-separated form. string label_selector = 5; + // Explicit named or all-workspaces scope. + openshell.datamodel.v1.WorkspaceSelector workspace_scope = 6; } message DeleteSandboxTemplateRequest { + reserved 2; + reserved "workspace"; string name = 1; - // Workspace scope. Empty defaults to "default". - string workspace = 2; + // Explicit workspace scope. The all-workspaces selection is invalid. + openshell.datamodel.v1.WorkspaceSelector workspace_scope = 3; } message SandboxTemplateResponse { @@ -1177,15 +1185,17 @@ message DeleteSandboxTemplateResponse { // Request a gateway-owned staging slot for a local rootfs tar archive. message BeginRootfsTarStagingRequest { - // Workspace that will own the sandbox created from this archive. Empty - // defaults to "default", matching CreateSandboxRequest.workspace. - string workspace = 1; + reserved 1; + reserved "workspace"; // Base file name of the local archive. The gateway uses it only to name the // staged file; path separators and traversal components are rejected. string file_name = 2; // Size of the local archive in bytes, checked against the driver limit // before the gateway allocates a slot. uint64 size_bytes = 3; + // Explicit workspace that will own the sandbox created from this archive. + // The all-workspaces selection is invalid. + openshell.datamodel.v1.WorkspaceSelector workspace_scope = 4; } // Gateway-issued staging slot. @@ -1204,35 +1214,36 @@ message BeginRootfsTarStagingResponse { // Get sandbox request. message GetSandboxRequest { - // Sandbox name (canonical lookup key). - string name = 1; - // Workspace scope. Empty defaults to "default". - string workspace = 2; + reserved 2; + reserved "name", "workspace"; + string sandbox_name = 1; + openshell.datamodel.v1.WorkspaceSelector workspace_scope = 3; } // List sandboxes request. message ListSandboxesRequest { + reserved 4, 5; + reserved "workspace", "all_workspaces"; uint32 limit = 1; uint32 offset = 2; // Optional label selector for filtering (format: "key1=value1,key2=value2"). string label_selector = 3; - // Workspace scope. Empty defaults to "default". - string workspace = 4; - // List across all workspaces. Mutually exclusive with workspace. - bool all_workspaces = 5; + // Explicit named or all-workspaces scope. + openshell.datamodel.v1.WorkspaceSelector workspace_scope = 6; } // List providers attached to a sandbox request. message ListSandboxProvidersRequest { - // Sandbox name (canonical lookup key). + reserved 2; + reserved "workspace"; string sandbox_name = 1; - // Workspace scope. Empty defaults to "default". - string workspace = 2; + openshell.datamodel.v1.WorkspaceSelector workspace_scope = 3; } // Attach provider to sandbox request. message AttachSandboxProviderRequest { - // Sandbox name (canonical lookup key). + reserved 4; + reserved "workspace"; string sandbox_name = 1; // Provider name to attach. string provider_name = 2; @@ -1241,13 +1252,13 @@ message AttachSandboxProviderRequest { // If non-zero, the server validates that the sandbox's current resource_version // matches this value before applying the mutation, returning ABORTED on mismatch. uint64 expected_resource_version = 3; - // Workspace scope. Empty defaults to "default". - string workspace = 4; + openshell.datamodel.v1.WorkspaceSelector workspace_scope = 5; } // Detach provider from sandbox request. message DetachSandboxProviderRequest { - // Sandbox name (canonical lookup key). + reserved 4; + reserved "workspace"; string sandbox_name = 1; // Provider name to detach. string provider_name = 2; @@ -1256,32 +1267,31 @@ message DetachSandboxProviderRequest { // If non-zero, the server validates that the sandbox's current resource_version // matches this value before applying the mutation, returning ABORTED on mismatch. uint64 expected_resource_version = 3; - // Workspace scope. Empty defaults to "default". - string workspace = 4; + openshell.datamodel.v1.WorkspaceSelector workspace_scope = 5; } // Delete sandbox request. message DeleteSandboxRequest { - // Sandbox name (canonical lookup key). - string name = 1; - // Workspace scope. Empty defaults to "default". - string workspace = 2; + reserved 2; + reserved "name", "workspace"; + string sandbox_name = 1; + openshell.datamodel.v1.WorkspaceSelector workspace_scope = 3; } // Stop sandbox request. message StopSandboxRequest { - // Sandbox name (canonical lookup key). - string name = 1; - // Workspace scope. Empty defaults to "default". - string workspace = 2; + reserved 2; + reserved "name", "workspace"; + string sandbox_name = 1; + openshell.datamodel.v1.WorkspaceSelector workspace_scope = 3; } // Start sandbox request. message StartSandboxRequest { - // Sandbox name (canonical lookup key). - string name = 1; - // Workspace scope. Empty defaults to "default". - string workspace = 2; + reserved 2; + reserved "name", "workspace"; + string sandbox_name = 1; + openshell.datamodel.v1.WorkspaceSelector workspace_scope = 3; } // Sandbox response. @@ -1320,8 +1330,10 @@ message DeleteSandboxResponse { // Create SSH session request. message CreateSshSessionRequest { - // Sandbox id. - string sandbox_id = 1; + reserved 1; + reserved "sandbox_id"; + string sandbox_name = 2; + openshell.datamodel.v1.WorkspaceSelector workspace_scope = 3; } // Create SSH session response. @@ -1360,40 +1372,40 @@ message CreateSshSessionResponse { // Request to expose an HTTP service running inside a sandbox. message ExposeServiceRequest { - // Sandbox name. - string sandbox = 1; + reserved 5; + reserved "sandbox", "workspace"; // Service name within the sandbox. string service = 2; // Loopback TCP port inside the sandbox. uint32 target_port = 3; // Whether to print/use the browser-facing service URL. bool domain = 4; - // Workspace scope. Empty defaults to "default". - string workspace = 5; + string sandbox_name = 1; + openshell.datamodel.v1.WorkspaceSelector workspace_scope = 6; } // Request to fetch an exposed sandbox service endpoint. message GetServiceRequest { - // Sandbox name. - string sandbox = 1; + reserved 3; + reserved "sandbox", "workspace"; // Service name within the sandbox. Empty selects the unnamed endpoint. string service = 2; - // Workspace scope. Empty defaults to "default". - string workspace = 3; + string sandbox_name = 1; + openshell.datamodel.v1.WorkspaceSelector workspace_scope = 4; } // Request to list exposed sandbox service endpoints. message ListServicesRequest { - // Optional sandbox name. Empty lists endpoints for all sandboxes. - string sandbox = 1; + reserved 4, 5; + reserved "sandbox", "workspace", "all_workspaces"; // Page size. Zero uses the server default. uint32 limit = 2; // Page offset. uint32 offset = 3; - // Workspace scope. Empty defaults to "default". - string workspace = 4; - // List across all workspaces. Mutually exclusive with workspace. - bool all_workspaces = 5; + // Explicit named or all-workspaces scope. + openshell.datamodel.v1.WorkspaceSelector workspace_scope = 6; + // Optional sandbox name. Empty lists endpoints for all sandboxes. + string sandbox_name = 1; } // Response containing exposed sandbox service endpoints. @@ -1403,12 +1415,12 @@ message ListServicesResponse { // Request to delete an exposed sandbox service endpoint. message DeleteServiceRequest { - // Sandbox name. - string sandbox = 1; + reserved 3; + reserved "sandbox", "workspace"; // Service name within the sandbox. Empty selects the unnamed endpoint. string service = 2; - // Workspace scope. Empty defaults to "default". - string workspace = 3; + string sandbox_name = 1; + openshell.datamodel.v1.WorkspaceSelector workspace_scope = 4; } // Response for deleting an exposed sandbox service endpoint. @@ -1453,8 +1465,8 @@ message RevokeSshSessionResponse { // Execute command request. message ExecSandboxRequest { - // Sandbox id. - string sandbox_id = 1; + reserved 1; + reserved "sandbox_id"; // Command and arguments. repeated string command = 2; @@ -1486,6 +1498,8 @@ message ExecSandboxRequest { // sourced by them) are applied. When true, the command runs without those // files (`bash -c`), for automation that needs predictable startup behavior. bool no_login_shell = 10; + string sandbox_name = 11; + openshell.datamodel.v1.WorkspaceSelector workspace_scope = 12; } // One stdout chunk from a sandbox exec. @@ -1514,8 +1528,10 @@ message ExecSandboxEvent { // Initial frame for one TCP forward stream. message TcpForwardInit { - // Sandbox id. - string sandbox_id = 1; + reserved 1; + reserved "sandbox_id"; + string sandbox_name = 2; + openshell.datamodel.v1.WorkspaceSelector workspace_scope = 3; // Optional service identifier for audit/correlation. string service_id = 4; // Target the gateway should request from the supervisor. @@ -1576,8 +1592,8 @@ message SshSession { // Watch sandbox request. message WatchSandboxRequest { - // Sandbox id. - string id = 1; + reserved 1; + reserved "id"; // Stream sandbox status snapshots. bool follow_status = 2; @@ -1607,6 +1623,8 @@ message WatchSandboxRequest { // Minimum log level to include (e.g. "INFO", "WARN", "ERROR"). Empty means all levels. string log_min_level = 10; + string sandbox_name = 11; + openshell.datamodel.v1.WorkspaceSelector workspace_scope = 12; } // One event in a sandbox watch stream. @@ -1645,43 +1663,51 @@ message SandboxStreamWarning { // Create provider request. message CreateProviderRequest { + reserved 2; + reserved "workspace"; openshell.datamodel.v1.Provider provider = 1; - // Workspace for the provider. Empty defaults to "default". - string workspace = 2; + // Explicit workspace for the provider. The all-workspaces selection is invalid. + openshell.datamodel.v1.WorkspaceSelector workspace_scope = 3; } // Get provider request. message GetProviderRequest { + reserved 2; + reserved "workspace"; string name = 1; - // Workspace scope. Empty defaults to "default". - string workspace = 2; + // Explicit workspace scope. The all-workspaces selection is invalid. + openshell.datamodel.v1.WorkspaceSelector workspace_scope = 3; } // List providers request. message ListProvidersRequest { + reserved 3, 4; + reserved "workspace", "all_workspaces"; uint32 limit = 1; uint32 offset = 2; - // Workspace scope. Empty defaults to "default". - string workspace = 3; - // List across all workspaces. Mutually exclusive with workspace. - bool all_workspaces = 4; + // Explicit named or all-workspaces scope. + openshell.datamodel.v1.WorkspaceSelector workspace_scope = 5; } // Update provider request. message UpdateProviderRequest { + reserved 3; + reserved "workspace"; openshell.datamodel.v1.Provider provider = 1; // Optional per-credential expiry timestamps to merge into the provider. // A zero value removes the expiry for that credential. map credential_expires_at_ms = 2; - // Workspace scope. Empty defaults to "default". - string workspace = 3; + // Explicit workspace scope. The all-workspaces selection is invalid. + openshell.datamodel.v1.WorkspaceSelector workspace_scope = 4; } // Delete provider request. message DeleteProviderRequest { + reserved 2; + reserved "workspace"; string name = 1; - // Workspace scope. Empty defaults to "default". - string workspace = 2; + // Explicit workspace scope. The all-workspaces selection is invalid. + openshell.datamodel.v1.WorkspaceSelector workspace_scope = 3; } // Provider response. @@ -1942,10 +1968,12 @@ message StoredRefreshMaterialDeletion { } message GetProviderRefreshStatusRequest { + reserved 3; + reserved "workspace"; string provider = 1; string credential_key = 2; - // Workspace scope. Empty defaults to "default". - string workspace = 3; + // Explicit workspace scope. The all-workspaces selection is invalid. + openshell.datamodel.v1.WorkspaceSelector workspace_scope = 4; } message GetProviderRefreshStatusResponse { @@ -1953,6 +1981,8 @@ message GetProviderRefreshStatusResponse { } message ConfigureProviderRefreshRequest { + reserved 7; + reserved "workspace"; string provider = 1; string credential_key = 2; ProviderCredentialRefreshStrategy strategy = 3; @@ -1962,8 +1992,8 @@ message ConfigureProviderRefreshRequest { // the authoritative provider profile and refresh strategy. repeated string secret_material_keys = 5; optional int64 expires_at_ms = 6; - // Workspace scope. Empty defaults to "default". - string workspace = 7; + // Explicit workspace scope. The all-workspaces selection is invalid. + openshell.datamodel.v1.WorkspaceSelector workspace_scope = 8; } message ConfigureProviderRefreshResponse { @@ -1971,10 +2001,12 @@ message ConfigureProviderRefreshResponse { } message RotateProviderCredentialRequest { + reserved 3; + reserved "workspace"; string provider = 1; string credential_key = 2; - // Workspace scope. Empty defaults to "default". - string workspace = 3; + // Explicit workspace scope. The all-workspaces selection is invalid. + openshell.datamodel.v1.WorkspaceSelector workspace_scope = 4; } message RotateProviderCredentialResponse { @@ -1982,10 +2014,12 @@ message RotateProviderCredentialResponse { } message DeleteProviderRefreshRequest { + reserved 3; + reserved "workspace"; string provider = 1; string credential_key = 2; - // Workspace scope. Empty defaults to "default". - string workspace = 3; + // Explicit workspace scope. The all-workspaces selection is invalid. + openshell.datamodel.v1.WorkspaceSelector workspace_scope = 4; } message DeleteProviderRefreshResponse { @@ -2195,9 +2229,8 @@ message ExchangeProviderSubjectTokenResponse { // Update sandbox policy request. message UpdateConfigRequest { - // Sandbox name (canonical lookup key). Required for sandbox-scoped updates. - // Not required when `global=true`. - string name = 1; + reserved 10; + reserved "name", "workspace"; // The new policy to apply. // // Sandbox scope (`global=false`): @@ -2231,8 +2264,9 @@ message UpdateConfigRequest { // sandbox metadata as a convenience projection. For setting-only updates, it // only merges them into sandbox metadata. map annotations = 9; - // Workspace scope. Empty defaults to "default". Ignored for global-scoped updates. - string workspace = 10; + // Required for sandbox-scoped updates and empty for global updates. + string sandbox_name = 1; + openshell.datamodel.v1.WorkspaceSelector workspace_scope = 11; } message PolicyMergeOperation { @@ -2294,14 +2328,14 @@ message UpdateConfigResponse { // Get sandbox policy status request. message GetSandboxPolicyStatusRequest { - // Sandbox name (canonical lookup key). Ignored when global is true. - string name = 1; + reserved 4; + reserved "name", "workspace"; // The specific policy version to query. 0 means latest. uint32 version = 2; // Query global policy revisions instead of a sandbox-scoped one. bool global = 3; - // Workspace scope. Empty defaults to "default". Ignored when global is true. - string workspace = 4; + string sandbox_name = 1; + openshell.datamodel.v1.WorkspaceSelector workspace_scope = 5; } // Get sandbox policy status response. @@ -2314,14 +2348,14 @@ message GetSandboxPolicyStatusResponse { // List sandbox policies request. message ListSandboxPoliciesRequest { - // Sandbox name (canonical lookup key). Ignored when global is true. - string name = 1; + reserved 5; + reserved "name", "workspace"; uint32 limit = 2; uint32 offset = 3; // List global policy revisions instead of sandbox-scoped ones. bool global = 4; - // Workspace scope. Empty defaults to "default". Ignored when global is true. - string workspace = 5; + string sandbox_name = 1; + openshell.datamodel.v1.WorkspaceSelector workspace_scope = 6; } // List sandbox policies response. @@ -2392,8 +2426,8 @@ enum PolicyStatus { // Get sandbox logs request (one-shot fetch). message GetSandboxLogsRequest { - // Sandbox id. - string sandbox_id = 1; + reserved 1, 6; + reserved "sandbox_id", "workspace"; // Maximum number of log lines to return. 0 means use default (2000). uint32 lines = 2; // Only include logs with timestamp >= this value (ms since epoch). 0 means no filter. @@ -2402,8 +2436,8 @@ message GetSandboxLogsRequest { repeated string sources = 4; // Minimum log level to include (e.g. "INFO", "WARN", "ERROR"). Empty means all levels. string min_level = 5; - // Workspace scope. Empty defaults to "default". - string workspace = 6; + string sandbox_name = 8; + openshell.datamodel.v1.WorkspaceSelector workspace_scope = 7; } // Batch of log lines pushed from sandbox to server. @@ -2733,11 +2767,13 @@ message SubmitPolicyAnalysisRequest { // to watch. Other values are treated as agent-style (no dedup) so a new // mode does not silently collapse proposals. string analysis_mode = 3; - // Sandbox name. + // Sandbox name. The authenticated sandbox principal remains authoritative + // for this internal callback. string name = 4; // Anonymous network activity counters. repeated NetworkActivitySummary network_activity_summaries = 5; - // Workspace scope. Empty defaults to "default". + // Internal callback workspace. The gateway validates it against the + // authenticated sandbox principal. string workspace = 6; } @@ -2756,12 +2792,12 @@ message SubmitPolicyAnalysisResponse { // Get draft policy for a sandbox. message GetDraftPolicyRequest { - // Sandbox name. - string name = 1; + reserved 3; + reserved "name", "workspace"; // Optional status filter: "pending", "approved", "rejected", or "" for all. string status_filter = 2; - // Workspace scope. Empty defaults to "default". - string workspace = 3; + string sandbox_name = 1; + openshell.datamodel.v1.WorkspaceSelector workspace_scope = 4; } message GetDraftPolicyResponse { @@ -2777,15 +2813,15 @@ message GetDraftPolicyResponse { // Approve a single draft chunk. message ApproveDraftChunkRequest { - // Sandbox name. - string name = 1; + reserved 3; + reserved "name", "workspace"; // Chunk ID to approve. string chunk_id = 2; - // Workspace scope. Empty defaults to "default". - string workspace = 3; // Token returned with the reviewed PolicyChunk. Approval fails with // FAILED_PRECONDITION if live decision inputs no longer match it. string review_token = 4; + string sandbox_name = 1; + openshell.datamodel.v1.WorkspaceSelector workspace_scope = 5; } message ApproveDraftChunkResponse { @@ -2797,14 +2833,14 @@ message ApproveDraftChunkResponse { // Reject a single draft chunk. message RejectDraftChunkRequest { - // Sandbox name. - string name = 1; + reserved 4; + reserved "name", "workspace"; // Chunk ID to reject. string chunk_id = 2; // Optional reason for rejection (fed to LLM context in future analysis). string reason = 3; - // Workspace scope. Empty defaults to "default". - string workspace = 4; + string sandbox_name = 1; + openshell.datamodel.v1.WorkspaceSelector workspace_scope = 5; } message RejectDraftChunkResponse {} @@ -2816,15 +2852,15 @@ message DraftChunkApproval { } message ApproveAllDraftChunksRequest { - // Sandbox name. - string name = 1; + reserved 3; + reserved "name", "workspace"; // Include chunks with security_notes (default false: skips them). bool include_security_flagged = 2; - // Workspace scope. Empty defaults to "default". - string workspace = 3; // Exact reviewed chunks and tokens. The server validates them against one // live snapshot, stages compatible operations in order, and writes once. repeated DraftChunkApproval approvals = 4; + string sandbox_name = 1; + openshell.datamodel.v1.WorkspaceSelector workspace_scope = 5; } message ApproveAllDraftChunksResponse { @@ -2841,26 +2877,26 @@ message ApproveAllDraftChunksResponse { // Edit a pending chunk in-place. message EditDraftChunkRequest { - // Sandbox name. - string name = 1; + reserved 4; + reserved "name", "workspace"; // Chunk ID to edit. string chunk_id = 2; // The modified rule (replaces existing proposed_rule). openshell.sandbox.v1.NetworkPolicyRule proposed_rule = 3; - // Workspace scope. Empty defaults to "default". - string workspace = 4; + string sandbox_name = 1; + openshell.datamodel.v1.WorkspaceSelector workspace_scope = 5; } message EditDraftChunkResponse {} // Reverse an approval (remove merged rule from active policy). message UndoDraftChunkRequest { - // Sandbox name. - string name = 1; + reserved 3; + reserved "name", "workspace"; // Chunk ID to undo. string chunk_id = 2; - // Workspace scope. Empty defaults to "default". - string workspace = 3; + string sandbox_name = 1; + openshell.datamodel.v1.WorkspaceSelector workspace_scope = 4; } message UndoDraftChunkResponse { @@ -2872,10 +2908,10 @@ message UndoDraftChunkResponse { // Clear all pending draft chunks for a sandbox. message ClearDraftChunksRequest { - // Sandbox name. - string name = 1; - // Workspace scope. Empty defaults to "default". - string workspace = 2; + reserved 2; + reserved "name", "workspace"; + string sandbox_name = 1; + openshell.datamodel.v1.WorkspaceSelector workspace_scope = 3; } message ClearDraftChunksResponse { @@ -2885,10 +2921,10 @@ message ClearDraftChunksResponse { // Get decision history for a sandbox's draft policy. message GetDraftHistoryRequest { - // Sandbox name. - string name = 1; - // Workspace scope. Empty defaults to "default". - string workspace = 2; + reserved 2; + reserved "name", "workspace"; + string sandbox_name = 1; + openshell.datamodel.v1.WorkspaceSelector workspace_scope = 3; } message DraftHistoryEntry { diff --git a/proto/sandbox.proto b/proto/sandbox.proto index c2b61d0b3a..df41c165c1 100644 --- a/proto/sandbox.proto +++ b/proto/sandbox.proto @@ -5,6 +5,7 @@ syntax = "proto3"; package openshell.sandbox.v1; +import "datamodel.proto"; import "google/protobuf/struct.proto"; // Sandbox-supervisor configuration and policy messages. @@ -315,10 +316,12 @@ message NetworkBinary { bool harness = 2 [deprecated = true]; } -// Request to get sandbox settings by sandbox ID. +// Request to get sandbox settings by sandbox name. message GetSandboxConfigRequest { - // The sandbox ID. - string sandbox_id = 1; + reserved 1; + reserved "sandbox_id"; + string sandbox_name = 2; + openshell.datamodel.v1.WorkspaceSelector workspace_scope = 3; } // Request to get gateway-global settings. diff --git a/python/openshell/sandbox.py b/python/openshell/sandbox.py index c34a2c05fe..eaaad5b13e 100644 --- a/python/openshell/sandbox.py +++ b/python/openshell/sandbox.py @@ -37,6 +37,16 @@ _OAUTH_MAX_RESPONSE_BYTES = 1 << 20 +def _workspace_scope(workspace: str) -> datamodel_pb2.WorkspaceSelector: + if not workspace: + raise ValueError("workspace must be non-empty") + return datamodel_pb2.WorkspaceSelector(workspace=workspace) + + +def _all_workspaces_scope() -> datamodel_pb2.WorkspaceSelector: + return datamodel_pb2.WorkspaceSelector(all_workspaces=datamodel_pb2.AllWorkspaces()) + + class _ClientCallDetails(_ClientCallDetailsBase, grpc.ClientCallDetails): pass @@ -460,8 +470,9 @@ def exec( no_login_shell: bool = False, ) -> ExecResult: return self._client.exec( - self.sandbox.id, + self.sandbox.name, command, + workspace=self._workspace, stream_output=stream_output, workdir=workdir, env=env, @@ -482,8 +493,9 @@ def exec_python( timeout_seconds: int | None = None, ) -> ExecResult: return self._client.exec_python( - self.sandbox.id, + self.sandbox.name, function, + workspace=self._workspace, args=args, kwargs=kwargs, stream_output=stream_output, @@ -722,7 +734,7 @@ def create( spec=request_spec, name=name or "", labels=dict(labels) if labels else {}, - workspace=workspace, + workspace_scope=_workspace_scope(workspace), ), timeout=self._timeout, ) @@ -748,7 +760,7 @@ def create_from_template( spec=request_spec, name=name or "", labels=dict(labels) if labels else {}, - workspace=workspace, + workspace_scope=_workspace_scope(workspace), workload_template_name=template_name, ), timeout=self._timeout, @@ -795,7 +807,10 @@ def sandbox_templates(self) -> SandboxTemplateClient: def get(self, sandbox_name: str, *, workspace: str) -> SandboxRef: response = self._stub.GetSandbox( - openshell_pb2.GetSandboxRequest(name=sandbox_name, workspace=workspace), + openshell_pb2.GetSandboxRequest( + sandbox_name=sandbox_name, + workspace_scope=_workspace_scope(workspace), + ), timeout=self._timeout, ) return _sandbox_ref(response.sandbox) @@ -812,7 +827,7 @@ def list( label_selector: str | None = None, ) -> builtins.list[SandboxRef]: request = openshell_pb2.ListSandboxesRequest( - workspace=workspace, + workspace_scope=_workspace_scope(workspace), limit=limit, offset=offset, label_selector=label_selector or "", @@ -828,7 +843,7 @@ def list_for_all_workspaces( label_selector: str | None = None, ) -> builtins.list[SandboxRef]: request = openshell_pb2.ListSandboxesRequest( - all_workspaces=True, + workspace_scope=_all_workspaces_scope(), limit=limit, offset=offset, label_selector=label_selector or "", @@ -872,21 +887,30 @@ def list_ids_for_all_workspaces( def delete(self, sandbox_name: str, *, workspace: str) -> bool: response = self._stub.DeleteSandbox( - openshell_pb2.DeleteSandboxRequest(name=sandbox_name, workspace=workspace), + openshell_pb2.DeleteSandboxRequest( + sandbox_name=sandbox_name, + workspace_scope=_workspace_scope(workspace), + ), timeout=self._timeout, ) return bool(response.deleted) def stop(self, sandbox_name: str, *, workspace: str) -> SandboxRef: response = self._stub.StopSandbox( - openshell_pb2.StopSandboxRequest(name=sandbox_name, workspace=workspace), + openshell_pb2.StopSandboxRequest( + sandbox_name=sandbox_name, + workspace_scope=_workspace_scope(workspace), + ), timeout=self._timeout, ) return _sandbox_ref(response.sandbox) def start(self, sandbox_name: str, *, workspace: str) -> SandboxRef: response = self._stub.StartSandbox( - openshell_pb2.StartSandboxRequest(name=sandbox_name, workspace=workspace), + openshell_pb2.StartSandboxRequest( + sandbox_name=sandbox_name, + workspace_scope=_workspace_scope(workspace), + ), timeout=self._timeout, ) return _sandbox_ref(response.sandbox) @@ -965,9 +989,10 @@ def _wait_for_phase( def exec_stream( self, - sandbox_id: str, + sandbox_name: str, command: Sequence[str], *, + workspace: str, workdir: str | None = None, env: Mapping[str, str] | None = None, stdin: bytes | None = None, @@ -978,7 +1003,8 @@ def exec_stream( raise SandboxError("command must not be empty") request = openshell_pb2.ExecSandboxRequest( - sandbox_id=sandbox_id, + sandbox_name=sandbox_name, + workspace_scope=_workspace_scope(workspace), command=list(command), workdir=workdir or "", environment=dict(env or {}), @@ -1021,9 +1047,10 @@ def exec_stream( def exec( self, - sandbox_id: str, + sandbox_name: str, command: Sequence[str], *, + workspace: str, stream_output: bool = False, workdir: str | None = None, env: Mapping[str, str] | None = None, @@ -1033,8 +1060,9 @@ def exec( ) -> ExecResult: result: ExecResult | None = None for item in self.exec_stream( - sandbox_id, + sandbox_name, command, + workspace=workspace, workdir=workdir, env=env, stdin=stdin, @@ -1056,9 +1084,10 @@ def exec( def exec_python( self, - sandbox_id: str, + sandbox_name: str, function: Callable[..., object], *, + workspace: str, args: Sequence[object] = (), kwargs: Mapping[str, object] | None = None, stream_output: bool = False, @@ -1073,8 +1102,9 @@ def exec_python( kwargs=kwargs, ) return self.exec( - sandbox_id, + sandbox_name, [_SANDBOX_PYTHON_BIN, "-c", _PYTHON_CLOUDPICKLE_BOOTSTRAP], + workspace=workspace, stream_output=stream_output, workdir=workdir, env=exec_env, @@ -1138,7 +1168,7 @@ def create( response = self._stub.CreateSandboxTemplate( openshell_pb2.CreateSandboxTemplateRequest( - workspace=workspace, + workspace_scope=_workspace_scope(workspace), template=template, ), timeout=self._timeout, @@ -1152,7 +1182,9 @@ def get( workspace: str, ) -> openshell_pb2.SandboxWorkloadTemplate: response = self._stub.GetSandboxTemplate( - openshell_pb2.GetSandboxTemplateRequest(name=name, workspace=workspace), + openshell_pb2.GetSandboxTemplateRequest( + name=name, workspace_scope=_workspace_scope(workspace) + ), timeout=self._timeout, ) return response.template @@ -1167,7 +1199,7 @@ def list( ) -> builtins.list[openshell_pb2.SandboxWorkloadTemplate]: response = self._stub.ListSandboxTemplates( openshell_pb2.ListSandboxTemplatesRequest( - workspace=workspace, + workspace_scope=_workspace_scope(workspace), limit=limit, offset=offset, label_selector=label_selector, @@ -1185,7 +1217,7 @@ def list_for_all_workspaces( ) -> builtins.list[openshell_pb2.SandboxWorkloadTemplate]: response = self._stub.ListSandboxTemplates( openshell_pb2.ListSandboxTemplatesRequest( - all_workspaces=True, + workspace_scope=_all_workspaces_scope(), limit=limit, offset=offset, label_selector=label_selector, @@ -1196,7 +1228,9 @@ def list_for_all_workspaces( def delete(self, name: str, *, workspace: str) -> bool: response = self._stub.DeleteSandboxTemplate( - openshell_pb2.DeleteSandboxTemplateRequest(name=name, workspace=workspace), + openshell_pb2.DeleteSandboxTemplateRequest( + name=name, workspace_scope=_workspace_scope(workspace) + ), timeout=self._timeout, ) return bool(response.deleted) diff --git a/python/openshell/sandbox_test.py b/python/openshell/sandbox_test.py index fd9e2bcb5b..3d904b11a1 100644 --- a/python/openshell/sandbox_test.py +++ b/python/openshell/sandbox_test.py @@ -42,6 +42,21 @@ ) +def _request_workspace(request: Any) -> str | None: + scope = request.workspace_scope + if scope.WhichOneof("selection") == "workspace": + return cast("str", scope.workspace) + return None + + +def _request_selects_all_workspaces(request: Any) -> bool: + return request.workspace_scope.WhichOneof("selection") == "all_workspaces" + + +def _request_sandbox_name(request: Any) -> str: + return cast("str", request.sandbox_name) + + def _client_credentials_fixture() -> dict[str, Any]: return json.loads( ( @@ -420,7 +435,12 @@ def test_exec_sends_stdin_payload() -> None: stub = _FakeStub() client = _client_with_fake_stub(stub) - result = client.exec("sandbox-1", ["python", "-c", "print('ok')"], stdin=b"payload") + result = client.exec( + "sandbox-1", + ["python", "-c", "print('ok')"], + workspace="default", + stdin=b"payload", + ) assert result.exit_code == 0 assert stub.request is not None @@ -434,7 +454,7 @@ def test_exec_python_serializes_callable_payload() -> None: def add(a: int, b: int) -> int: return a + b - result = client.exec_python("sandbox-1", add, args=(2, 3)) + result = client.exec_python("sandbox-1", add, workspace="default", args=(2, 3)) assert result.exit_code == 0 assert stub.request is not None @@ -1981,7 +2001,9 @@ def GetSandbox( _ = timeout return SimpleNamespace( sandbox=_make_sandbox_proto( - "sandbox-1", request.name, workspace=request.workspace or "default" + "sandbox-1", + _request_sandbox_name(request), + workspace=_request_workspace(request) or "default", ) ) @@ -2004,9 +2026,9 @@ def StopSandbox( return SimpleNamespace( sandbox=_make_sandbox_proto( "sandbox-1", - request.name, + _request_sandbox_name(request), phase=openshell_pb2.SANDBOX_PHASE_STOPPED, - workspace=request.workspace, + workspace=_request_workspace(request) or "default", ) ) @@ -2020,9 +2042,9 @@ def StartSandbox( return SimpleNamespace( sandbox=_make_sandbox_proto( "sandbox-1", - request.name, + _request_sandbox_name(request), phase=openshell_pb2.SANDBOX_PHASE_STARTING, - workspace=request.workspace, + workspace=_request_workspace(request) or "default", ) ) @@ -2038,7 +2060,7 @@ def CreateSandbox( "sandbox-1", request.name or "generated", dict(request.labels), - workspace=request.workspace or "default", + workspace=_request_workspace(request) or "default", ) ) @@ -2071,7 +2093,7 @@ def GetSandboxTemplate( return SimpleNamespace( template=_make_workload_template_proto( request.name, - workspace=request.workspace or "default", + workspace=_request_workspace(request) or "default", ) ) @@ -2217,7 +2239,7 @@ def test_sandbox_template_create_builds_template_from_public_fields() -> None: assert created.metadata.name == "gpu-kata" assert stub.create_template_request is not None - assert stub.create_template_request.workspace == "default" + assert _request_workspace(stub.create_template_request) == "default" template = stub.create_template_request.template assert template.metadata.name == "gpu-kata" assert dict(template.metadata.labels) == {"team": "runtime"} @@ -2361,7 +2383,7 @@ def test_sandbox_template_client_crud_forwards_requests() -> None: assert created.metadata.name == "gpu-kata" assert stub.create_template_request is not None - assert stub.create_template_request.workspace == "default" + assert _request_workspace(stub.create_template_request) == "default" assert ( stub.create_template_request.template.spec.workload.image == "ghcr.io/test/gpu-kata:latest" @@ -2377,34 +2399,34 @@ def test_sandbox_template_client_crud_forwards_requests() -> None: assert got.metadata.name == "gpu-kata" assert stub.get_template_request is not None assert stub.get_template_request.name == "gpu-kata" - assert stub.get_template_request.workspace == "default" + assert _request_workspace(stub.get_template_request) == "default" listed = client.list( workspace="default", limit=50, offset=10, label_selector="team=runtime" ) assert len(listed) == 1 assert stub.list_template_request is not None - assert stub.list_template_request.workspace == "default" + assert _request_workspace(stub.list_template_request) == "default" assert stub.list_template_request.limit == 50 assert stub.list_template_request.offset == 10 assert stub.list_template_request.label_selector == "team=runtime" - assert not stub.list_template_request.all_workspaces + assert not _request_selects_all_workspaces(stub.list_template_request) assert client.delete("gpu-kata", workspace="default") is True assert stub.delete_template_request is not None assert stub.delete_template_request.name == "gpu-kata" - assert stub.delete_template_request.workspace == "default" + assert _request_workspace(stub.delete_template_request) == "default" -def test_sandbox_template_list_for_all_workspaces_clears_workspace() -> None: +def test_sandbox_template_list_for_all_workspaces_selects_all() -> None: stub = _FakeSandboxStub() client = _template_client_with_fake_stub(stub) client.list_for_all_workspaces(limit=100, offset=5, label_selector="team=runtime") assert stub.list_template_request is not None - assert stub.list_template_request.all_workspaces - assert stub.list_template_request.workspace == "" + assert _request_selects_all_workspaces(stub.list_template_request) + assert _request_workspace(stub.list_template_request) is None assert stub.list_template_request.limit == 100 assert stub.list_template_request.offset == 5 assert stub.list_template_request.label_selector == "team=runtime" @@ -2416,14 +2438,14 @@ def test_stop_and_start_forward_workspace_and_return_phase() -> None: stopped = client.stop("job-1", workspace="team-a") assert stub.stop_request is not None - assert stub.stop_request.name == "job-1" - assert stub.stop_request.workspace == "team-a" + assert _request_sandbox_name(stub.stop_request) == "job-1" + assert _request_workspace(stub.stop_request) == "team-a" assert stopped.phase == openshell_pb2.SANDBOX_PHASE_STOPPED starting = client.start("job-1", workspace="team-a") assert stub.start_request is not None - assert stub.start_request.name == "job-1" - assert stub.start_request.workspace == "team-a" + assert _request_sandbox_name(stub.start_request) == "job-1" + assert _request_workspace(stub.start_request) == "team-a" assert starting.phase == openshell_pb2.SANDBOX_PHASE_STARTING @@ -2447,9 +2469,9 @@ def GetSandbox( return SimpleNamespace( sandbox=_make_sandbox_proto( "sandbox-1", - request.name, + _request_sandbox_name(request), phase=phase, - workspace=request.workspace, + workspace=_request_workspace(request) or "default", ) ) @@ -2471,7 +2493,7 @@ def test_create_without_args_sends_empty_metadata() -> None: assert stub.create_request is not None assert stub.create_request.name == "" assert dict(stub.create_request.labels) == {} - assert stub.create_request.workspace == "default" + assert _request_workspace(stub.create_request) == "default" def test_create_copies_caller_labels() -> None: @@ -2508,7 +2530,7 @@ def test_list_forwards_label_selector() -> None: assert stub.list_request is not None assert stub.list_request.label_selector == "aiq=deep-research" - assert stub.list_request.workspace == "default" + assert _request_workspace(stub.list_request) == "default" def test_list_without_selector_sends_empty_string() -> None: @@ -2725,7 +2747,7 @@ def test_create_passes_workspace_to_proto() -> None: ref = client.create(workspace="staging", name="job-1") assert stub.create_request is not None - assert stub.create_request.workspace == "staging" + assert _request_workspace(stub.create_request) == "staging" assert ref.workspace == "staging" @@ -2736,7 +2758,7 @@ def test_get_passes_workspace_to_proto() -> None: ref = client.get("job-1", workspace="production") assert stub.get_request is not None - assert stub.get_request.workspace == "production" + assert _request_workspace(stub.get_request) == "production" assert ref.workspace == "production" @@ -2748,7 +2770,7 @@ def test_delete_passes_workspace_to_proto() -> None: assert result is True assert stub.delete_request is not None - assert stub.delete_request.workspace == "staging" + assert _request_workspace(stub.delete_request) == "staging" def test_list_for_all_workspaces_sets_flag() -> None: @@ -2758,8 +2780,8 @@ def test_list_for_all_workspaces_sets_flag() -> None: client.list_for_all_workspaces() assert stub.list_request is not None - assert stub.list_request.all_workspaces is True - assert stub.list_request.workspace == "" + assert _request_selects_all_workspaces(stub.list_request) + assert _request_workspace(stub.list_request) is None def test_list_with_workspace_passes_workspace() -> None: @@ -2769,8 +2791,8 @@ def test_list_with_workspace_passes_workspace() -> None: client.list(workspace="staging") assert stub.list_request is not None - assert stub.list_request.workspace == "staging" - assert stub.list_request.all_workspaces is False + assert _request_workspace(stub.list_request) == "staging" + assert not _request_selects_all_workspaces(stub.list_request) def test_sandbox_ref_includes_workspace_from_proto() -> None: @@ -2809,4 +2831,4 @@ def test_sandbox_session_delete_passes_workspace() -> None: session.delete() assert stub.delete_request is not None - assert stub.delete_request.workspace == "staging" + assert _request_workspace(stub.delete_request) == "staging" diff --git a/rfc/0011-multi-player-design/README.md b/rfc/0011-multi-player-design/README.md index 32a2f584e1..87fb5f558a 100644 --- a/rfc/0011-multi-player-design/README.md +++ b/rfc/0011-multi-player-design/README.md @@ -312,8 +312,8 @@ Within a workspace, access varies by resource type: boundary — within a workspace, all members share the same trust domain and the same provider credentials, so there is no security benefit to restricting sandbox access by owner. Platform Admins can list across workspaces using - `all_workspaces = true` on list RPCs (see Cross-Workspace List Operations - below). + the `all_workspaces` `WorkspaceSelector` variant on list RPCs (see + Cross-Workspace List Operations below). - **Providers.** Users can list and reference providers by name within their workspace but cannot create, update, or delete them, and cannot see raw @@ -507,7 +507,8 @@ resolves it, and the runtime consumes it directly. that depends on a request field. Handlers may strengthen, but never weaken, the declared baseline for these cases: -- `all_workspaces: true` requires Platform Admin on cross-workspace list RPCs. +- The `all_workspaces` selector requires Platform Admin on cross-workspace list + RPCs. - `global: true` requires Platform Admin for global configuration and policy reads or writes. - An empty provider-profile workspace selects platform scope and requires @@ -861,16 +862,16 @@ analogous to `kubectl get pods --all-namespaces`. This is an explicit opt-in on list RPCs, not the default behavior. **RPC mechanism.** Workspace-scoped list RPCs (`ListSandboxes`, -`ListProviders`, `ListServices`) gain an `all_workspaces` boolean field. When -`all_workspaces = true`, the handler bypasses workspace scoping and returns -results from all workspaces. The caller must have the Platform Admin global role; -workspace-scoped roles cannot set `all_workspaces`. Results include the -`workspace` field in each resource's `ObjectMeta` so the caller can distinguish -provenance. +`ListSandboxTemplates`, `ListProviders`, `ListServices`) use a +`WorkspaceSelector` oneof with either a non-empty named workspace or an +`all_workspaces` marker. The all-workspaces variant bypasses workspace scoping +and returns results from all workspaces. The caller must have the Platform +Admin global role; workspace-scoped roles cannot select all workspaces. Results +include the `workspace` field in each resource's `ObjectMeta` so the caller can +distinguish provenance. -This is distinct from passing an empty workspace string. Empty workspace is -resolved to `"default"` by the gateway's `resolve_workspace()` logic for -backwards compatibility — it does not mean "all workspaces." +An omitted selector, an unset selector, and an empty named workspace are +invalid. The default workspace is selected explicitly with the name `default`. **Store query.** The `all_workspaces` handler path uses the same `list_by_type(object_type, limit, offset)` store method as the internal @@ -1081,8 +1082,8 @@ etc.). Unlike the CLI, the SDK does not default to `"default"` — programmatic callers must always specify the target workspace. This is a deliberate design choice: agents and automation scripts should be explicit about which workspace they operate on, and a silent default could mask workspace-routing bugs. -Passing `workspace=None` to `list()` uses `all_workspaces=True` for -cross-workspace queries. +Cross-workspace queries use the separate `list_for_all_workspaces()` method so +named and all-workspaces scopes cannot conflict. ## Implementation plan @@ -1149,8 +1150,9 @@ foundations. The work can be phased to deliver value incrementally: workspace through `StoredProviderCredentialRefreshState` so the provider refresh worker can unambiguously resolve workspace-scoped providers — with multiple workspaces, `provider_name` alone is insufficient because different - workspaces can have same-named providers. Add `all_workspaces` field to - workspace-scoped list RPCs for Platform Admin cross-workspace visibility. + workspaces can have same-named providers. Add the typed `WorkspaceSelector` + to workspace-scoped request RPCs and its `all_workspaces` variant to list + RPCs for Platform Admin cross-workspace visibility. Add `ObjectWorkspace::requires_workspace()` trait method and validation in store write helpers (`put_message`, `put_scoped_message`) that returns an error when a workspace-scoped resource is persisted with an empty workspace. @@ -1270,10 +1272,10 @@ depend only on Phase 1. - **Cross-workspace store query authorization.** The `list_by_type` store method has no access-control gate — it is a persistence-layer primitive. Authorization for cross-workspace queries is enforced at the gRPC handler level (Platform - Admin check for `all_workspaces` on list RPCs) and by code-level access - control for internal operations (only the reconciler, start, and refresh - worker call it). This relies on internal code discipline rather than an - enforced store-level boundary. A future extension could add a store-level + Admin check for the `all_workspaces` selector on list RPCs) and by code-level + access control for internal operations (only the reconciler, start, and + refresh worker call it). This relies on internal code discipline rather than + an enforced store-level boundary. A future extension could add a store-level caller identity parameter if defense-in-depth is desired. - **Remote compute driver channel security.** The `RemoteComputeDriver` gRPC diff --git a/sdk/go/docs/src/api/providers.md b/sdk/go/docs/src/api/providers.md index 9eec23f675..87c82379a1 100644 --- a/sdk/go/docs/src/api/providers.md +++ b/sdk/go/docs/src/api/providers.md @@ -59,6 +59,9 @@ providers, err = client.Providers().List(ctx, "default", v1.ListOptions{ Limit: 10, Offset: 0, }) + +// Platform Admin only: list across all workspaces +allProviders, err := client.Providers().ListAll(ctx) ``` ## Update diff --git a/sdk/go/docs/src/api/sandbox-templates.md b/sdk/go/docs/src/api/sandbox-templates.md index d1dcc86e47..03e8ae43e8 100644 --- a/sdk/go/docs/src/api/sandbox-templates.md +++ b/sdk/go/docs/src/api/sandbox-templates.md @@ -97,8 +97,8 @@ templates, err := client.SandboxTemplates().List(ctx, "default", v1.ListOptions{ Offset: 0, }) -allTemplates, err := client.SandboxTemplates().List(ctx, "", v1.ListOptions{ - AllWorkspaces: true, +allTemplates, err := client.SandboxTemplates().ListAll(ctx, v1.ListOptions{ + Limit: 50, }) ``` diff --git a/sdk/go/docs/src/api/sandboxes.md b/sdk/go/docs/src/api/sandboxes.md index 698982fe13..af026a17cb 100644 --- a/sdk/go/docs/src/api/sandboxes.md +++ b/sdk/go/docs/src/api/sandboxes.md @@ -69,6 +69,9 @@ sandboxes, err := client.Sandboxes().List(ctx, "default", v1.ListOptions{ Offset: 0, LabelSelector: "team=platform", }) + +// Platform Admin only: list across all workspaces +allSandboxes, err := client.Sandboxes().ListAll(ctx) ``` ## Delete diff --git a/sdk/go/docs/src/api/services.md b/sdk/go/docs/src/api/services.md index 6d3195e1ad..0c44567d2f 100644 --- a/sdk/go/docs/src/api/services.md +++ b/sdk/go/docs/src/api/services.md @@ -30,6 +30,9 @@ if err != nil { for _, svc := range services { fmt.Printf(" %s -> port %d (%s)\n", svc.ServiceName, svc.TargetPort, svc.URL) } + +// Platform Admin only: list services across all workspaces +allServices, err := client.Services().ListAll(ctx) ``` ## Delete diff --git a/sdk/go/openshell/v1/config_client.go b/sdk/go/openshell/v1/config_client.go index e7086b9b6c..bd3d5e29e6 100644 --- a/sdk/go/openshell/v1/config_client.go +++ b/sdk/go/openshell/v1/config_client.go @@ -25,13 +25,12 @@ func (c *configClient) GetSandbox(ctx context.Context, workspace, sandboxName st if sandboxName == "" { return nil, &StatusError{Code: ErrorInvalidArgument, Message: "sandbox name must not be empty"} } - sb, err := c.sandboxes.Get(ctx, workspace, sandboxName) - if err != nil { + if _, err := c.sandboxes.Get(ctx, workspace, sandboxName); err != nil { return nil, err } - resp, err := c.client.GetSandboxConfig(ctx, &sbv1.GetSandboxConfigRequest{ - SandboxId: sb.ID, + SandboxName: sandboxName, + WorkspaceScope: namedWorkspaceScope(workspace), }) if err != nil { return nil, converter.FromGRPCError(err) @@ -58,7 +57,9 @@ func (c *configClient) Update(ctx context.Context, workspace string, update *Con if convErr != nil { return nil, &StatusError{Code: ErrorInvalidArgument, Message: convErr.Error()} } - req.Workspace = workspace + if !req.GetGlobal() { + req.WorkspaceScope = namedWorkspaceScope(workspace) + } resp, err := c.client.UpdateConfig(ctx, req) if err != nil { return nil, converter.FromGRPCError(err) diff --git a/sdk/go/openshell/v1/config_client_test.go b/sdk/go/openshell/v1/config_client_test.go index 9fa8675ff2..a5697e2a01 100644 --- a/sdk/go/openshell/v1/config_client_test.go +++ b/sdk/go/openshell/v1/config_client_test.go @@ -143,7 +143,7 @@ func TestConfigGetSandbox(t *testing.T) { // Verify request was forwarded with resolved ID (stubSandboxResolver returns "sb-"). mock.mu.Lock() - assert.Equal(t, "sb-my-sandbox", mock.lastSandboxReq.GetSandboxId()) + assert.Equal(t, "my-sandbox", mock.lastSandboxReq.GetSandboxName()) mock.mu.Unlock() // Scalar fields. @@ -222,7 +222,7 @@ func TestConfigGetSandbox_Error(t *testing.T) { // --- Name-to-ID resolution tests --- -func TestConfigGetSandbox_ResolvesNameToID(t *testing.T) { +func TestConfigGetSandbox_UsesName(t *testing.T) { mock := newMockConfigServer() mock.sandboxResp = &sbv1.GetSandboxConfigResponse{Version: 1} @@ -235,7 +235,7 @@ func TestConfigGetSandbox_ResolvesNameToID(t *testing.T) { // stubSandboxResolver returns ID "sb-" — verify the proto has the resolved ID, not the name. mock.mu.Lock() - assert.Equal(t, "sb-my-sandbox", mock.lastSandboxReq.GetSandboxId(), "GetSandbox should send resolved sandbox ID, not the name") + assert.Equal(t, "my-sandbox", mock.lastSandboxReq.GetSandboxName()) mock.mu.Unlock() } @@ -361,7 +361,7 @@ func TestConfigUpdate_SandboxScope(t *testing.T) { mock.mu.Unlock() require.NotNil(t, req) - assert.Equal(t, "my-sandbox", req.GetName()) + assert.Equal(t, "my-sandbox", req.GetSandboxName()) assert.Equal(t, "max_tokens", req.GetSettingKey()) assert.False(t, req.GetGlobal()) assert.Equal(t, uint64(4), req.GetExpectedResourceVersion()) @@ -397,7 +397,7 @@ func TestConfigUpdate_GlobalScope(t *testing.T) { mock.mu.Unlock() assert.True(t, req.GetGlobal()) - assert.Empty(t, req.GetName()) + assert.Empty(t, req.GetSandboxName()) } func TestConfigUpdate_DeleteSetting(t *testing.T) { diff --git a/sdk/go/openshell/v1/doc.go b/sdk/go/openshell/v1/doc.go index 900d86c643..2732347f00 100644 --- a/sdk/go/openshell/v1/doc.go +++ b/sdk/go/openshell/v1/doc.go @@ -314,7 +314,7 @@ // // Read a policy back from revision history: // -// revisions, err := client.Policy().List(ctx, "default") +// revisions, err := client.Policy().List(ctx, "default", "my-sandbox") // if err != nil { // log.Fatal(err) // } @@ -328,7 +328,7 @@ // // List gateway-global policy revisions (no sandbox name or workspace needed): // -// revisions, err := client.Policy().List(ctx, "", v1.WithListGlobal(true)) +// revisions, err := client.Policy().List(ctx, "", "", v1.WithListGlobal(true)) // if err != nil { // log.Fatal(err) // } diff --git a/sdk/go/openshell/v1/exec_client.go b/sdk/go/openshell/v1/exec_client.go index c74fe7a5d3..8bc9c803b4 100644 --- a/sdk/go/openshell/v1/exec_client.go +++ b/sdk/go/openshell/v1/exec_client.go @@ -26,16 +26,15 @@ func (e *execClient) Run(ctx context.Context, workspace, sandboxName string, com if sandboxName == "" { return nil, &StatusError{Code: ErrorInvalidArgument, Message: "sandbox name must not be empty"} } - sb, err := e.sandboxes.Get(ctx, workspace, sandboxName) - if err != nil { + if _, err := e.sandboxes.Get(ctx, workspace, sandboxName); err != nil { return nil, err } - var opt *ExecOptions if len(opts) > 0 { opt = &opts[0] } - req := converter.ExecRequestToProto(sb.ID, command, opt) + req := converter.ExecRequestToProto(sandboxName, command, opt) + req.WorkspaceScope = namedWorkspaceScope(workspace) stream, err := e.client.ExecSandbox(ctx, req) if err != nil { @@ -61,16 +60,15 @@ func (e *execClient) Stream(ctx context.Context, workspace, sandboxName string, if sandboxName == "" { return nil, &StatusError{Code: ErrorInvalidArgument, Message: "sandbox name must not be empty"} } - sb, err := e.sandboxes.Get(ctx, workspace, sandboxName) - if err != nil { + if _, err := e.sandboxes.Get(ctx, workspace, sandboxName); err != nil { return nil, err } - var opt *ExecOptions if len(opts) > 0 { opt = &opts[0] } - req := converter.ExecRequestToProto(sb.ID, command, opt) + req := converter.ExecRequestToProto(sandboxName, command, opt) + req.WorkspaceScope = namedWorkspaceScope(workspace) streamCtx, cancel := context.WithCancel(ctx) stream, err := e.client.ExecSandbox(streamCtx, req) @@ -86,11 +84,9 @@ func (e *execClient) Interactive(ctx context.Context, workspace, sandboxName str if sandboxName == "" { return nil, &StatusError{Code: ErrorInvalidArgument, Message: "sandbox name must not be empty"} } - sb, err := e.sandboxes.Get(ctx, workspace, sandboxName) - if err != nil { + if _, err := e.sandboxes.Get(ctx, workspace, sandboxName); err != nil { return nil, err } - var opt *ExecOptions if len(opts) > 0 { opt = &opts[0] @@ -103,7 +99,8 @@ func (e *execClient) Interactive(ctx context.Context, workspace, sandboxName str return nil, converter.FromGRPCError(err) } - startReq := converter.ExecInteractiveRequestToProto(sb.ID, command, cols, rows, opt) + startReq := converter.ExecInteractiveRequestToProto(sandboxName, command, cols, rows, opt) + startReq.WorkspaceScope = namedWorkspaceScope(workspace) if sendErr := stream.Send(&pb.ExecSandboxInput{ Payload: &pb.ExecSandboxInput_Start{Start: startReq}, }); sendErr != nil { diff --git a/sdk/go/openshell/v1/exec_client_test.go b/sdk/go/openshell/v1/exec_client_test.go index 1c649ac0b7..58ec5a0db4 100644 --- a/sdk/go/openshell/v1/exec_client_test.go +++ b/sdk/go/openshell/v1/exec_client_test.go @@ -40,6 +40,9 @@ func (r *stubSandboxResolver) Create(context.Context, string, string, *SandboxSp func (r *stubSandboxResolver) List(context.Context, string, ...ListOptions) ([]*Sandbox, error) { panic("not implemented") } +func (r *stubSandboxResolver) ListAll(context.Context, ...ListOptions) ([]*Sandbox, error) { + panic("not implemented") +} func (r *stubSandboxResolver) Delete(context.Context, string, string) error { panic("not implemented") } @@ -233,7 +236,7 @@ func TestExecRun_WithOptions(t *testing.T) { mock.mu.Lock() defer mock.mu.Unlock() - assert.Equal(t, "sb-test-sandbox", mock.lastExecRequest.GetSandboxId()) + assert.Equal(t, "test-sandbox", mock.lastExecRequest.GetSandboxName()) assert.Equal(t, []string{"ls"}, mock.lastExecRequest.GetCommand()) assert.Equal(t, "/tmp", mock.lastExecRequest.GetWorkdir()) assert.Equal(t, map[string]string{"FOO": "bar"}, mock.lastExecRequest.GetEnvironment()) @@ -377,7 +380,7 @@ func TestExecInteractive(t *testing.T) { startReq := startInput.GetStart() require.NotNil(t, startReq) - assert.Equal(t, "sb-test-sandbox", startReq.GetSandboxId()) + assert.Equal(t, "test-sandbox", startReq.GetSandboxName()) assert.Equal(t, []string{"/bin/bash"}, startReq.GetCommand()) assert.True(t, startReq.GetTty()) assert.Equal(t, uint32(80), startReq.GetCols()) @@ -529,7 +532,7 @@ func TestExecInteractive_ConcurrentReadAndExitCode(t *testing.T) { // --- Name-to-ID resolution tests --- -func TestExecRun_ResolvesNameToID(t *testing.T) { +func TestExecRun_UsesName(t *testing.T) { mock := newMockExecServer() mock.execEvents = []*pb.ExecSandboxEvent{ {Payload: &pb.ExecSandboxEvent_Exit{Exit: &pb.ExecSandboxExit{ExitCode: 0}}}, @@ -543,7 +546,7 @@ func TestExecRun_ResolvesNameToID(t *testing.T) { mock.mu.Lock() defer mock.mu.Unlock() // Verify the proto request contains the resolved ID, not the name - assert.Equal(t, "sb-my-sandbox", mock.lastExecRequest.GetSandboxId()) + assert.Equal(t, "my-sandbox", mock.lastExecRequest.GetSandboxName()) } func TestExecRun_ResolutionError(t *testing.T) { @@ -557,7 +560,7 @@ func TestExecRun_ResolutionError(t *testing.T) { assert.True(t, IsNotFound(err)) } -func TestExecStream_ResolvesNameToID(t *testing.T) { +func TestExecStream_UsesName(t *testing.T) { mock := newMockExecServer() mock.execEvents = []*pb.ExecSandboxEvent{ {Payload: &pb.ExecSandboxEvent_Exit{Exit: &pb.ExecSandboxExit{ExitCode: 0}}}, @@ -573,7 +576,7 @@ func TestExecStream_ResolvesNameToID(t *testing.T) { mock.mu.Lock() defer mock.mu.Unlock() - assert.Equal(t, "sb-my-sandbox", mock.lastExecRequest.GetSandboxId()) + assert.Equal(t, "my-sandbox", mock.lastExecRequest.GetSandboxName()) } func TestExecStream_ResolutionError(t *testing.T) { @@ -587,7 +590,7 @@ func TestExecStream_ResolutionError(t *testing.T) { assert.True(t, IsNotFound(err)) } -func TestExecInteractive_ResolvesNameToID(t *testing.T) { +func TestExecInteractive_UsesName(t *testing.T) { mock := newMockExecServer() mock.interactiveEvents = []*pb.ExecSandboxEvent{ {Payload: &pb.ExecSandboxEvent_Exit{Exit: &pb.ExecSandboxExit{ExitCode: 0}}}, @@ -606,7 +609,7 @@ func TestExecInteractive_ResolvesNameToID(t *testing.T) { require.NotEmpty(t, mock.receivedInputs) startReq := mock.receivedInputs[0].GetStart() require.NotNil(t, startReq) - assert.Equal(t, "sb-my-sandbox", startReq.GetSandboxId()) + assert.Equal(t, "my-sandbox", startReq.GetSandboxName()) } func TestExecInteractive_ResolutionError(t *testing.T) { diff --git a/sdk/go/openshell/v1/fake/policy.go b/sdk/go/openshell/v1/fake/policy.go index 7360af1b03..851b810bb2 100644 --- a/sdk/go/openshell/v1/fake/policy.go +++ b/sdk/go/openshell/v1/fake/policy.go @@ -7,7 +7,6 @@ import ( "context" "maps" "slices" - "strings" "sync" v1 "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1" @@ -172,9 +171,8 @@ func (c *fakePolicyClient) GetStatus(_ context.Context, workspace, sandboxName s } // List returns policy revisions. When the global option is set, it returns -// global revisions; otherwise it returns all sandbox-scoped revisions for the -// given workspace. -func (c *fakePolicyClient) List(_ context.Context, workspace string, opts ...v1.ListPolicyOption) ([]types.SandboxPolicyRevision, error) { +// global revisions; otherwise it returns revisions for the specified sandbox. +func (c *fakePolicyClient) List(_ context.Context, workspace, sandboxName string, opts ...v1.ListPolicyOption) ([]types.SandboxPolicyRevision, error) { if c.closedFunc() { return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} } @@ -187,13 +185,7 @@ func (c *fakePolicyClient) List(_ context.Context, workspace string, opts ...v1. if cfg.Global() { revisions = slices.Clone(c.globalRevisions) } else { - // Collect all revisions for sandboxes in this workspace. - prefix := workspace + "/" - for key, revs := range c.sandboxRevisions { - if strings.HasPrefix(key, prefix) { - revisions = append(revisions, revs...) - } - } + revisions = slices.Clone(c.sandboxRevisions[workspace+"/"+sandboxName]) } if len(revisions) == 0 { diff --git a/sdk/go/openshell/v1/fake/policy_test.go b/sdk/go/openshell/v1/fake/policy_test.go index f48398a137..79e8b06e26 100644 --- a/sdk/go/openshell/v1/fake/policy_test.go +++ b/sdk/go/openshell/v1/fake/policy_test.go @@ -66,7 +66,7 @@ func TestFakePolicy_GetStatus_EmptyReturnsNotFound(t *testing.T) { func TestFakePolicy_List_EmptyReturnsNil(t *testing.T) { c := newFakePolicyClient(func() bool { return false }) - revisions, err := c.List(context.Background(), "default") + revisions, err := c.List(context.Background(), "default", "sb-1") require.NoError(t, err) assert.Nil(t, revisions) } @@ -96,7 +96,7 @@ func TestFakePolicy_List_Global(t *testing.T) { c.AddRevision("default", "sb-1", types.SandboxPolicyRevision{Version: 1, PolicyHash: "sha256:sb-v1", Status: types.PolicyLoadStatusLoaded}) // List global revisions. - revisions, err := c.List(context.Background(), "", types.WithListGlobal(true)) + revisions, err := c.List(context.Background(), "", "", types.WithListGlobal(true)) require.NoError(t, err) require.Len(t, revisions, 2) assert.Equal(t, uint32(1), revisions[0].Version) @@ -113,7 +113,7 @@ func TestFakePolicy_List_Sandbox(t *testing.T) { c.AddRevision("default", "sb-1", types.SandboxPolicyRevision{Version: 2, PolicyHash: "sha256:sb-v2", Status: types.PolicyLoadStatusPending}) // List sandbox-scoped revisions (no global flag). - revisions, err := c.List(context.Background(), "default") + revisions, err := c.List(context.Background(), "default", "sb-1") require.NoError(t, err) require.Len(t, revisions, 2) assert.Equal(t, "sha256:sb-v1", revisions[0].PolicyHash) @@ -127,7 +127,7 @@ func TestFakePolicy_List_NoIsolationCrossContamination(t *testing.T) { c.AddRevision("default", "sb-1", types.SandboxPolicyRevision{Version: 1, PolicyHash: "sha256:sb-v1"}) // Global list returns empty (no global revisions seeded). - revisions, err := c.List(context.Background(), "", types.WithListGlobal(true)) + revisions, err := c.List(context.Background(), "", "", types.WithListGlobal(true)) require.NoError(t, err) assert.Nil(t, revisions) } @@ -140,14 +140,14 @@ func TestFakePolicy_List_GlobalWithPagination(t *testing.T) { c.AddGlobalRevision(types.SandboxPolicyRevision{Version: 3}) // Limit to 2. - revisions, err := c.List(context.Background(), "", types.WithListGlobal(true), types.WithLimit(2)) + revisions, err := c.List(context.Background(), "", "", types.WithListGlobal(true), types.WithLimit(2)) require.NoError(t, err) require.Len(t, revisions, 2) assert.Equal(t, uint32(1), revisions[0].Version) assert.Equal(t, uint32(2), revisions[1].Version) // Offset by 1, limit 2. - revisions, err = c.List(context.Background(), "", types.WithListGlobal(true), types.WithLimit(2), types.WithOffset(1)) + revisions, err = c.List(context.Background(), "", "", types.WithListGlobal(true), types.WithLimit(2), types.WithOffset(1)) require.NoError(t, err) require.Len(t, revisions, 2) assert.Equal(t, uint32(2), revisions[0].Version) @@ -236,14 +236,14 @@ func TestFakePolicy_DeepCopyWithPolicy(t *testing.T) { ctx := context.Background() // Get global revision and mutate it. - revisions, err := fc.Policy().List(ctx, "", types.WithListGlobal(true)) + revisions, err := fc.Policy().List(ctx, "", "", types.WithListGlobal(true)) require.NoError(t, err) require.Len(t, revisions, 1) require.NotNil(t, revisions[0].Policy) revisions[0].Policy.NetworkPolicies["rule-1"] = types.NetworkPolicyRule{Name: "mutated"} // Verify internal state is not corrupted. - revisions2, err := fc.Policy().List(ctx, "", types.WithListGlobal(true)) + revisions2, err := fc.Policy().List(ctx, "", "", types.WithListGlobal(true)) require.NoError(t, err) assert.Equal(t, "rule-1", revisions2[0].Policy.NetworkPolicies["rule-1"].Name) @@ -256,7 +256,7 @@ func TestFakePolicy_DeepCopyWithPolicy(t *testing.T) { func TestFakePolicy_List_ClosedReturnsUnavailable(t *testing.T) { c := newFakePolicyClient(func() bool { return true }) - _, err := c.List(context.Background(), "", types.WithListGlobal(true)) + _, err := c.List(context.Background(), "", "", types.WithListGlobal(true)) require.Error(t, err) assert.True(t, types.IsUnavailable(err)) } diff --git a/sdk/go/openshell/v1/fake/provider.go b/sdk/go/openshell/v1/fake/provider.go index 1f5ce4d831..7ba0345880 100644 --- a/sdk/go/openshell/v1/fake/provider.go +++ b/sdk/go/openshell/v1/fake/provider.go @@ -113,16 +113,20 @@ func (c *fakeProviderClient) Get(_ context.Context, workspace, name string) (*ty // List returns all providers. ListOptions are accepted for interface // compatibility but filtering is not implemented. -func (c *fakeProviderClient) List(_ context.Context, workspace string, opts ...v1.ListOptions) ([]*types.Provider, error) { +func (c *fakeProviderClient) List(_ context.Context, workspace string, _ ...v1.ListOptions) ([]*types.Provider, error) { if c.closedFunc() { return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} } - if len(opts) > 0 && opts[0].AllWorkspaces { - return c.store.ListAll(), nil - } return c.store.List(workspace), nil } +func (c *fakeProviderClient) ListAll(_ context.Context, _ ...v1.ListOptions) ([]*types.Provider, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + return c.store.ListAll(), nil +} + // Update replaces an existing provider's data. ResourceVersion is // incremented automatically. func (c *fakeProviderClient) Update(_ context.Context, workspace string, provider *types.Provider) (*types.Provider, error) { diff --git a/sdk/go/openshell/v1/fake/sandbox.go b/sdk/go/openshell/v1/fake/sandbox.go index 5de3062d96..8191594a5c 100644 --- a/sdk/go/openshell/v1/fake/sandbox.go +++ b/sdk/go/openshell/v1/fake/sandbox.go @@ -447,16 +447,20 @@ func (c *fakeSandboxClient) Get(_ context.Context, workspace, name string) (*typ // List returns all sandboxes. ListOptions are accepted for interface // compatibility but filtering is not implemented. -func (c *fakeSandboxClient) List(_ context.Context, workspace string, opts ...v1.ListOptions) ([]*types.Sandbox, error) { +func (c *fakeSandboxClient) List(_ context.Context, workspace string, _ ...v1.ListOptions) ([]*types.Sandbox, error) { if c.closedFunc() { return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} } - if len(opts) > 0 && opts[0].AllWorkspaces { - return c.store.ListAll(), nil - } return c.store.List(workspace), nil } +func (c *fakeSandboxClient) ListAll(_ context.Context, _ ...v1.ListOptions) ([]*types.Sandbox, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + return c.store.ListAll(), nil +} + // Stop transitions a sandbox to the Stopped phase. func (c *fakeSandboxClient) Stop(_ context.Context, workspace, name string) (*types.Sandbox, error) { if c.closedFunc() { diff --git a/sdk/go/openshell/v1/fake/sandbox_template.go b/sdk/go/openshell/v1/fake/sandbox_template.go index 8dbf68cc12..b105de7c13 100644 --- a/sdk/go/openshell/v1/fake/sandbox_template.go +++ b/sdk/go/openshell/v1/fake/sandbox_template.go @@ -106,6 +106,14 @@ func (c *fakeSandboxTemplateClient) Get(_ context.Context, workspace, name strin } func (c *fakeSandboxTemplateClient) List(_ context.Context, workspace string, opts ...v1.ListOptions) ([]*types.SandboxWorkloadTemplate, error) { + return c.list(workspace, false, opts...) +} + +func (c *fakeSandboxTemplateClient) ListAll(_ context.Context, opts ...v1.ListOptions) ([]*types.SandboxWorkloadTemplate, error) { + return c.list("", true, opts...) +} + +func (c *fakeSandboxTemplateClient) list(workspace string, allWorkspaces bool, opts ...v1.ListOptions) ([]*types.SandboxWorkloadTemplate, error) { if c.closedFunc() { return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} } @@ -120,7 +128,7 @@ func (c *fakeSandboxTemplateClient) List(_ context.Context, workspace string, op } } var templates []*types.SandboxWorkloadTemplate - if options.AllWorkspaces { + if allWorkspaces { templates = c.store.ListAll() } else { templates = c.store.List(workspace) diff --git a/sdk/go/openshell/v1/fake/sandbox_template_test.go b/sdk/go/openshell/v1/fake/sandbox_template_test.go index 06a5187227..6caa0da684 100644 --- a/sdk/go/openshell/v1/fake/sandbox_template_test.go +++ b/sdk/go/openshell/v1/fake/sandbox_template_test.go @@ -108,7 +108,7 @@ func TestSandboxTemplate_ListAllWorkspaces(t *testing.T) { _, _ = tc.Create(ctx, "default", testSandboxWorkloadTemplate("default-template")) _, _ = tc.Create(ctx, "team-a", testSandboxWorkloadTemplate("team-template")) - listed, err := tc.List(ctx, "default", types.ListOptions{AllWorkspaces: true}) + listed, err := tc.ListAll(ctx) require.NoError(t, err) assert.Len(t, listed, 2) } diff --git a/sdk/go/openshell/v1/fake/service.go b/sdk/go/openshell/v1/fake/service.go index 8b0c4e1482..4b3029012f 100644 --- a/sdk/go/openshell/v1/fake/service.go +++ b/sdk/go/openshell/v1/fake/service.go @@ -45,6 +45,14 @@ func (c *fakeServiceClient) List(_ context.Context, _, _ string, _ ...v1.ListOpt return nil, &types.StatusError{Code: types.ErrorUnimplemented, Message: "List is not supported by the fake client"} } +// ListAll returns Unimplemented. +func (c *fakeServiceClient) ListAll(_ context.Context, _ ...v1.ListOptions) ([]*types.ServiceEndpoint, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + return nil, &types.StatusError{Code: types.ErrorUnimplemented, Message: "ListAll is not supported by the fake client"} +} + // Delete returns Unimplemented. func (c *fakeServiceClient) Delete(_ context.Context, _, _, _ string) error { if c.closedFunc() { diff --git a/sdk/go/openshell/v1/file_client.go b/sdk/go/openshell/v1/file_client.go index 7e9edad176..7a9e35f4a8 100644 --- a/sdk/go/openshell/v1/file_client.go +++ b/sdk/go/openshell/v1/file_client.go @@ -44,6 +44,9 @@ func (f *fileClient) Upload(ctx context.Context, workspace, sandboxName string, if remotePath == "" { return &StatusError{Code: ErrorInvalidArgument, Message: "remote path must not be empty"} } + if _, err := f.sandboxes.Get(ctx, workspace, sandboxName); err != nil { + return err + } info, err := os.Stat(localPath) if err != nil { @@ -53,13 +56,9 @@ func (f *fileClient) Upload(ctx context.Context, workspace, sandboxName string, return fmt.Errorf("local path is a directory, not a file: %s", localPath) } - sb, err := f.sandboxes.Get(ctx, workspace, sandboxName) - if err != nil { - return err - } - session, err := f.client.CreateSshSession(ctx, &pb.CreateSshSessionRequest{ - SandboxId: sb.ID, + SandboxName: sandboxName, + WorkspaceScope: namedWorkspaceScope(workspace), }) if err != nil { return converter.FromGRPCError(err) @@ -86,14 +85,13 @@ func (f *fileClient) Download(ctx context.Context, workspace, sandboxName string if remotePath == "" { return &StatusError{Code: ErrorInvalidArgument, Message: "remote path must not be empty"} } - - sb, err := f.sandboxes.Get(ctx, workspace, sandboxName) - if err != nil { + if _, err := f.sandboxes.Get(ctx, workspace, sandboxName); err != nil { return err } session, err := f.client.CreateSshSession(ctx, &pb.CreateSshSessionRequest{ - SandboxId: sb.ID, + SandboxName: sandboxName, + WorkspaceScope: namedWorkspaceScope(workspace), }) if err != nil { return converter.FromGRPCError(err) diff --git a/sdk/go/openshell/v1/file_client_test.go b/sdk/go/openshell/v1/file_client_test.go index ac08c8dec0..38f5d4d71f 100644 --- a/sdk/go/openshell/v1/file_client_test.go +++ b/sdk/go/openshell/v1/file_client_test.go @@ -109,7 +109,7 @@ func TestFileUpload(t *testing.T) { err := client.Upload(context.Background(), "default", "test-sandbox", localPath, "/remote/upload.txt") require.NoError(t, err) - assert.Equal(t, "sb-test-sandbox", mock.lastCreateReq.GetSandboxId()) + assert.Equal(t, "test-sandbox", mock.lastCreateReq.GetSandboxName()) assert.Equal(t, 1, mock.createCallCount) } @@ -164,7 +164,7 @@ func TestFileDownload(t *testing.T) { err := client.Download(context.Background(), "default", "test-sandbox", "/remote/file.txt", localPath) require.NoError(t, err) - assert.Equal(t, "sb-test-sandbox", mock.lastCreateReq.GetSandboxId()) + assert.Equal(t, "test-sandbox", mock.lastCreateReq.GetSandboxName()) assert.Equal(t, 1, mock.createCallCount) } @@ -242,7 +242,7 @@ func TestFileDownload_EmptyRemotePath(t *testing.T) { // --- Name-to-ID resolution tests --- -func TestFileUpload_ResolvesNameToID(t *testing.T) { +func TestFileUpload_UsesName(t *testing.T) { mock := newMockFileServer() mock.createResp = &pb.CreateSshSessionResponse{ SandboxId: "sb-my-sandbox", @@ -261,7 +261,7 @@ func TestFileUpload_ResolvesNameToID(t *testing.T) { _ = client.Upload(context.Background(), "default", "my-sandbox", localPath, "/remote/file.txt") // Verify the proto request contains the resolved ID, not the name - assert.Equal(t, "sb-my-sandbox", mock.lastCreateReq.GetSandboxId()) + assert.Equal(t, "my-sandbox", mock.lastCreateReq.GetSandboxName()) } func TestFileUpload_ResolutionError(t *testing.T) { @@ -299,7 +299,7 @@ func TestFileUpload_ResolutionError(t *testing.T) { assert.Equal(t, 0, mock.createCallCount) } -func TestFileDownload_ResolvesNameToID(t *testing.T) { +func TestFileDownload_UsesName(t *testing.T) { mock := newMockFileServer() client, cleanup := setupFileTest(t, mock) defer cleanup() @@ -307,7 +307,7 @@ func TestFileDownload_ResolvesNameToID(t *testing.T) { localPath := filepath.Join(t.TempDir(), "downloaded.txt") _ = client.Download(context.Background(), "default", "my-sandbox", "/remote/file.txt", localPath) - assert.Equal(t, "sb-my-sandbox", mock.lastCreateReq.GetSandboxId()) + assert.Equal(t, "my-sandbox", mock.lastCreateReq.GetSandboxName()) } func TestFileDownload_ResolutionError(t *testing.T) { diff --git a/sdk/go/openshell/v1/internal/converter/exec.go b/sdk/go/openshell/v1/internal/converter/exec.go index 3c73f0c23a..de21b15743 100644 --- a/sdk/go/openshell/v1/internal/converter/exec.go +++ b/sdk/go/openshell/v1/internal/converter/exec.go @@ -37,10 +37,10 @@ func ExecChunkFromEvent(event *pb.ExecSandboxEvent) (*types.ExecChunk, int, erro } // ExecRequestToProto builds a proto ExecSandboxRequest for Run/Stream modes. -func ExecRequestToProto(sandboxID string, command []string, opts *types.ExecOptions) *pb.ExecSandboxRequest { +func ExecRequestToProto(sandboxName string, command []string, opts *types.ExecOptions) *pb.ExecSandboxRequest { req := &pb.ExecSandboxRequest{ - SandboxId: sandboxID, - Command: CopyStringSlice(command), + SandboxName: sandboxName, + Command: CopyStringSlice(command), } if opts != nil { req.Workdir = opts.WorkDir @@ -51,8 +51,8 @@ func ExecRequestToProto(sandboxID string, command []string, opts *types.ExecOpti } // ExecInteractiveRequestToProto builds a proto ExecSandboxRequest for Interactive mode. -func ExecInteractiveRequestToProto(sandboxID string, command []string, cols, rows uint32, opts *types.ExecOptions) *pb.ExecSandboxRequest { - req := ExecRequestToProto(sandboxID, command, opts) +func ExecInteractiveRequestToProto(sandboxName string, command []string, cols, rows uint32, opts *types.ExecOptions) *pb.ExecSandboxRequest { + req := ExecRequestToProto(sandboxName, command, opts) req.Tty = true req.Cols = cols req.Rows = rows diff --git a/sdk/go/openshell/v1/internal/converter/exec_test.go b/sdk/go/openshell/v1/internal/converter/exec_test.go index 078a72d4d1..1581ab4705 100644 --- a/sdk/go/openshell/v1/internal/converter/exec_test.go +++ b/sdk/go/openshell/v1/internal/converter/exec_test.go @@ -117,7 +117,7 @@ func TestExecRequestToProto(t *testing.T) { }) require.NotNil(t, req) - assert.Equal(t, "sb-1", req.SandboxId) + assert.Equal(t, "sb-1", req.GetSandboxName()) assert.Equal(t, []string{"ls", "-la"}, req.Command) assert.Equal(t, "/home/user", req.Workdir) assert.Equal(t, map[string]string{"FOO": "bar"}, req.Environment) @@ -128,7 +128,7 @@ func TestExecRequestToProto_NilOptions(t *testing.T) { req := ExecRequestToProto("sb-2", []string{"echo", "hi"}, nil) require.NotNil(t, req) - assert.Equal(t, "sb-2", req.SandboxId) + assert.Equal(t, "sb-2", req.GetSandboxName()) assert.Equal(t, []string{"echo", "hi"}, req.Command) assert.Empty(t, req.Workdir) assert.Nil(t, req.Environment) @@ -141,7 +141,7 @@ func TestExecRequestToProto_Interactive(t *testing.T) { }) require.NotNil(t, req) - assert.Equal(t, "sb-3", req.SandboxId) + assert.Equal(t, "sb-3", req.GetSandboxName()) assert.Equal(t, []string{"/bin/bash"}, req.Command) assert.Equal(t, "/root", req.Workdir) assert.Equal(t, map[string]string{"TERM": "xterm"}, req.Environment) diff --git a/sdk/go/openshell/v1/internal/converter/setting.go b/sdk/go/openshell/v1/internal/converter/setting.go index 495545938d..c3536e1027 100644 --- a/sdk/go/openshell/v1/internal/converter/setting.go +++ b/sdk/go/openshell/v1/internal/converter/setting.go @@ -180,7 +180,6 @@ func ConfigUpdateToProto(cu *v1.ConfigUpdate) (*pb.UpdateConfigRequest, error) { return nil, nil } req := &pb.UpdateConfigRequest{ - Name: cu.Name, SettingKey: cu.SettingKey, SettingValue: SettingValueToProto(cu.SettingValue), DeleteSetting: cu.DeleteSetting, @@ -188,6 +187,9 @@ func ConfigUpdateToProto(cu *v1.ConfigUpdate) (*pb.UpdateConfigRequest, error) { ExpectedResourceVersion: cu.ExpectedResourceVersion, Annotations: CopyStringMap(cu.Annotations), } + if !cu.Global { + req.SandboxName = cu.Name + } // Convert typed SDK SandboxPolicy to proto SandboxPolicy. policy, err := SandboxPolicyToProtoChecked(cu.Policy) diff --git a/sdk/go/openshell/v1/internal/converter/setting_test.go b/sdk/go/openshell/v1/internal/converter/setting_test.go index f546902429..43b47a867c 100644 --- a/sdk/go/openshell/v1/internal/converter/setting_test.go +++ b/sdk/go/openshell/v1/internal/converter/setting_test.go @@ -429,7 +429,7 @@ func TestConfigUpdateToProto(t *testing.T) { require.NoError(t, err) require.NotNil(t, req) - assert.Equal(t, "my-sandbox", req.Name) + assert.Equal(t, "my-sandbox", req.GetSandboxName()) assert.Equal(t, "timeout", req.SettingKey) require.NotNil(t, req.SettingValue) assert.Equal(t, int64(60), req.SettingValue.GetIntValue()) @@ -495,7 +495,7 @@ func TestConfigUpdateToProto_GlobalScope(t *testing.T) { require.NotNil(t, req) assert.True(t, req.Global) - assert.Empty(t, req.Name) + assert.Empty(t, req.GetSandboxName()) } func TestConfigUpdateToProto_NilSettingValue(t *testing.T) { @@ -779,7 +779,7 @@ func TestConfigUpdateToProto_WithMergeOperations(t *testing.T) { require.NoError(t, err) require.NotNil(t, req) - assert.Equal(t, "my-sandbox", req.GetName()) + assert.Equal(t, "my-sandbox", req.GetSandboxName()) require.Len(t, req.GetMergeOperations(), 3) // First: RemoveRule diff --git a/sdk/go/openshell/v1/policy.go b/sdk/go/openshell/v1/policy.go index cbe2e8621a..1cb4433d8c 100644 --- a/sdk/go/openshell/v1/policy.go +++ b/sdk/go/openshell/v1/policy.go @@ -111,7 +111,7 @@ type PolicyInterface interface { ClearDraftChunks(ctx context.Context, workspace, sandboxName string) (*ClearResult, error) GetDraftHistory(ctx context.Context, workspace, sandboxName string) ([]DraftHistoryEntry, error) GetStatus(ctx context.Context, workspace, sandboxName string, opts ...GetStatusOption) (*PolicyStatusResult, error) - List(ctx context.Context, workspace string, opts ...ListPolicyOption) ([]SandboxPolicyRevision, error) + List(ctx context.Context, workspace, sandboxName string, opts ...ListPolicyOption) ([]SandboxPolicyRevision, error) EditDraftChunk(ctx context.Context, workspace, sandboxName, chunkID string, proposedRule *NetworkPolicyRule) error UndoDraftChunk(ctx context.Context, workspace, sandboxName, chunkID string) (*UndoResult, error) } diff --git a/sdk/go/openshell/v1/policy_client.go b/sdk/go/openshell/v1/policy_client.go index f002caf9d9..17f5d1faf2 100644 --- a/sdk/go/openshell/v1/policy_client.go +++ b/sdk/go/openshell/v1/policy_client.go @@ -23,9 +23,9 @@ func newPolicyClient(conn grpc.ClientConnInterface) *policyClient { func (p *policyClient) GetDraft(ctx context.Context, workspace, sandboxName string, opts ...GetDraftOption) (*DraftPolicy, error) { cfg := types.ApplyGetDraftOptions(opts) resp, err := p.client.GetDraftPolicy(ctx, &pb.GetDraftPolicyRequest{ - Name: sandboxName, - StatusFilter: cfg.StatusFilter(), - Workspace: workspace, + SandboxName: sandboxName, + WorkspaceScope: namedWorkspaceScope(workspace), + StatusFilter: cfg.StatusFilter(), }) if err != nil { return nil, converter.FromGRPCError(err) @@ -35,10 +35,10 @@ func (p *policyClient) GetDraft(ctx context.Context, workspace, sandboxName stri func (p *policyClient) ApproveDraftChunk(ctx context.Context, workspace, sandboxName, chunkID, reviewToken string) (*ApproveResult, error) { resp, err := p.client.ApproveDraftChunk(ctx, &pb.ApproveDraftChunkRequest{ - Name: sandboxName, - ChunkId: chunkID, - Workspace: workspace, - ReviewToken: reviewToken, + SandboxName: sandboxName, + WorkspaceScope: namedWorkspaceScope(workspace), + ChunkId: chunkID, + ReviewToken: reviewToken, }) if err != nil { return nil, converter.FromGRPCError(err) @@ -48,10 +48,10 @@ func (p *policyClient) ApproveDraftChunk(ctx context.Context, workspace, sandbox func (p *policyClient) RejectDraftChunk(ctx context.Context, workspace, sandboxName, chunkID, reason string) error { _, err := p.client.RejectDraftChunk(ctx, &pb.RejectDraftChunkRequest{ - Name: sandboxName, - ChunkId: chunkID, - Reason: reason, - Workspace: workspace, + SandboxName: sandboxName, + WorkspaceScope: namedWorkspaceScope(workspace), + ChunkId: chunkID, + Reason: reason, }) if err != nil { return converter.FromGRPCError(err) @@ -69,10 +69,10 @@ func (p *policyClient) ApproveAllDraftChunks(ctx context.Context, workspace, san }) } resp, err := p.client.ApproveAllDraftChunks(ctx, &pb.ApproveAllDraftChunksRequest{ - Name: sandboxName, IncludeSecurityFlagged: cfg.IncludeSecurityFlagged(), - Workspace: workspace, Approvals: approvals, + SandboxName: sandboxName, + WorkspaceScope: namedWorkspaceScope(workspace), }) if err != nil { return nil, converter.FromGRPCError(err) @@ -82,8 +82,8 @@ func (p *policyClient) ApproveAllDraftChunks(ctx context.Context, workspace, san func (p *policyClient) ClearDraftChunks(ctx context.Context, workspace, sandboxName string) (*ClearResult, error) { resp, err := p.client.ClearDraftChunks(ctx, &pb.ClearDraftChunksRequest{ - Name: sandboxName, - Workspace: workspace, + SandboxName: sandboxName, + WorkspaceScope: namedWorkspaceScope(workspace), }) if err != nil { return nil, converter.FromGRPCError(err) @@ -93,8 +93,8 @@ func (p *policyClient) ClearDraftChunks(ctx context.Context, workspace, sandboxN func (p *policyClient) GetDraftHistory(ctx context.Context, workspace, sandboxName string) ([]DraftHistoryEntry, error) { resp, err := p.client.GetDraftHistory(ctx, &pb.GetDraftHistoryRequest{ - Name: sandboxName, - Workspace: workspace, + SandboxName: sandboxName, + WorkspaceScope: namedWorkspaceScope(workspace), }) if err != nil { return nil, converter.FromGRPCError(err) @@ -114,26 +114,33 @@ func (p *policyClient) GetDraftHistory(ctx context.Context, workspace, sandboxNa func (p *policyClient) GetStatus(ctx context.Context, workspace, sandboxName string, opts ...GetStatusOption) (*PolicyStatusResult, error) { cfg := types.ApplyGetStatusOptions(opts) - resp, err := p.client.GetSandboxPolicyStatus(ctx, &pb.GetSandboxPolicyStatusRequest{ - Name: sandboxName, - Version: cfg.Version(), - Workspace: workspace, - Global: cfg.Global(), - }) + req := &pb.GetSandboxPolicyStatusRequest{ + Version: cfg.Version(), + Global: cfg.Global(), + } + if !cfg.Global() { + req.SandboxName = sandboxName + req.WorkspaceScope = namedWorkspaceScope(workspace) + } + resp, err := p.client.GetSandboxPolicyStatus(ctx, req) if err != nil { return nil, converter.FromGRPCError(err) } return converter.PolicyStatusResultFromProto(resp), nil } -func (p *policyClient) List(ctx context.Context, workspace string, opts ...ListPolicyOption) ([]SandboxPolicyRevision, error) { +func (p *policyClient) List(ctx context.Context, workspace, sandboxName string, opts ...ListPolicyOption) ([]SandboxPolicyRevision, error) { cfg := types.ApplyListPolicyOptions(opts) - resp, err := p.client.ListSandboxPolicies(ctx, &pb.ListSandboxPoliciesRequest{ - Workspace: workspace, - Limit: cfg.Limit(), - Offset: cfg.Offset(), - Global: cfg.Global(), - }) + req := &pb.ListSandboxPoliciesRequest{ + Limit: cfg.Limit(), + Offset: cfg.Offset(), + Global: cfg.Global(), + } + if !cfg.Global() { + req.SandboxName = sandboxName + req.WorkspaceScope = namedWorkspaceScope(workspace) + } + resp, err := p.client.ListSandboxPolicies(ctx, req) if err != nil { return nil, converter.FromGRPCError(err) } @@ -152,10 +159,10 @@ func (p *policyClient) List(ctx context.Context, workspace string, opts ...ListP func (p *policyClient) EditDraftChunk(ctx context.Context, workspace, sandboxName, chunkID string, proposedRule *NetworkPolicyRule) error { _, err := p.client.EditDraftChunk(ctx, &pb.EditDraftChunkRequest{ - Name: sandboxName, - ChunkId: chunkID, - ProposedRule: converter.NetworkPolicyRuleToProto(proposedRule), - Workspace: workspace, + SandboxName: sandboxName, + WorkspaceScope: namedWorkspaceScope(workspace), + ChunkId: chunkID, + ProposedRule: converter.NetworkPolicyRuleToProto(proposedRule), }) if err != nil { return converter.FromGRPCError(err) @@ -165,9 +172,9 @@ func (p *policyClient) EditDraftChunk(ctx context.Context, workspace, sandboxNam func (p *policyClient) UndoDraftChunk(ctx context.Context, workspace, sandboxName, chunkID string) (*UndoResult, error) { resp, err := p.client.UndoDraftChunk(ctx, &pb.UndoDraftChunkRequest{ - Name: sandboxName, - ChunkId: chunkID, - Workspace: workspace, + SandboxName: sandboxName, + WorkspaceScope: namedWorkspaceScope(workspace), + ChunkId: chunkID, }) if err != nil { return nil, converter.FromGRPCError(err) diff --git a/sdk/go/openshell/v1/policy_client_test.go b/sdk/go/openshell/v1/policy_client_test.go index d572ca8e33..c6aae8ab42 100644 --- a/sdk/go/openshell/v1/policy_client_test.go +++ b/sdk/go/openshell/v1/policy_client_test.go @@ -235,7 +235,7 @@ func TestPolicyGetDraft(t *testing.T) { // Verify request was forwarded. mock.mu.Lock() - assert.Equal(t, "my-sandbox", mock.lastGetDraftReq.GetName()) + assert.Equal(t, "my-sandbox", mock.lastGetDraftReq.GetSandboxName()) assert.Empty(t, mock.lastGetDraftReq.GetStatusFilter()) mock.mu.Unlock() @@ -321,7 +321,7 @@ func TestPolicyApproveDraftChunk(t *testing.T) { // Verify request was forwarded. mock.mu.Lock() - assert.Equal(t, "my-sandbox", mock.lastApproveReq.GetName()) + assert.Equal(t, "my-sandbox", mock.lastApproveReq.GetSandboxName()) assert.Equal(t, "chunk-1", mock.lastApproveReq.GetChunkId()) assert.Equal(t, "token-1", mock.lastApproveReq.GetReviewToken()) mock.mu.Unlock() @@ -358,7 +358,7 @@ func TestPolicyRejectDraftChunk(t *testing.T) { // Verify request was forwarded. mock.mu.Lock() - assert.Equal(t, "my-sandbox", mock.lastRejectReq.GetName()) + assert.Equal(t, "my-sandbox", mock.lastRejectReq.GetSandboxName()) assert.Equal(t, "chunk-2", mock.lastRejectReq.GetChunkId()) assert.Equal(t, "too broad", mock.lastRejectReq.GetReason()) mock.mu.Unlock() @@ -400,7 +400,7 @@ func TestPolicyApproveAllDraftChunks(t *testing.T) { // Verify default: security-flagged NOT included. mock.mu.Lock() - assert.Equal(t, "my-sandbox", mock.lastApproveAllReq.GetName()) + assert.Equal(t, "my-sandbox", mock.lastApproveAllReq.GetSandboxName()) assert.False(t, mock.lastApproveAllReq.GetIncludeSecurityFlagged()) mock.mu.Unlock() @@ -466,7 +466,7 @@ func TestPolicyClearDraftChunks(t *testing.T) { // Verify request was forwarded. mock.mu.Lock() - assert.Equal(t, "my-sandbox", mock.lastClearReq.GetName()) + assert.Equal(t, "my-sandbox", mock.lastClearReq.GetSandboxName()) mock.mu.Unlock() assert.Equal(t, uint32(4), result.ChunksCleared) @@ -517,7 +517,7 @@ func TestPolicyGetDraftHistory(t *testing.T) { // Verify request was forwarded. mock.mu.Lock() - assert.Equal(t, "my-sandbox", mock.lastHistoryReq.GetName()) + assert.Equal(t, "my-sandbox", mock.lastHistoryReq.GetSandboxName()) mock.mu.Unlock() assert.Equal(t, "approved", entries[0].EventType) @@ -583,7 +583,7 @@ func TestPolicyGetStatus(t *testing.T) { // Verify request was forwarded (no version = latest). mock.mu.Lock() - assert.Equal(t, "my-sandbox", mock.lastStatusReq.GetName()) + assert.Equal(t, "my-sandbox", mock.lastStatusReq.GetSandboxName()) assert.Equal(t, uint32(0), mock.lastStatusReq.GetVersion()) mock.mu.Unlock() @@ -649,8 +649,7 @@ func TestPolicyGetStatus_WithGlobal(t *testing.T) { // Verify global flag was forwarded in the proto request. mock.mu.Lock() assert.True(t, mock.lastStatusReq.GetGlobal()) - assert.Empty(t, mock.lastStatusReq.GetName()) - assert.Empty(t, mock.lastStatusReq.GetWorkspace()) + assert.Empty(t, mock.lastStatusReq.GetSandboxName()) mock.mu.Unlock() } @@ -675,8 +674,7 @@ func TestPolicyGetStatus_WithGlobalIgnoresNonEmptyName(t *testing.T) { mock.mu.Lock() assert.True(t, mock.lastStatusReq.GetGlobal()) - assert.Equal(t, "some-sandbox", mock.lastStatusReq.GetName()) - assert.Equal(t, "some-workspace", mock.lastStatusReq.GetWorkspace()) + assert.Empty(t, mock.lastStatusReq.GetSandboxName()) mock.mu.Unlock() } @@ -731,8 +729,8 @@ func TestPolicyGetStatus_WithoutGlobal_PreservesExistingBehavior(t *testing.T) { // Verify global flag is false by default. mock.mu.Lock() assert.False(t, mock.lastStatusReq.GetGlobal()) - assert.Equal(t, "default", mock.lastStatusReq.GetWorkspace()) - assert.Equal(t, "my-sandbox", mock.lastStatusReq.GetName()) + assert.Equal(t, "default", mock.lastStatusReq.GetWorkspaceScope().GetWorkspace()) + assert.Equal(t, "my-sandbox", mock.lastStatusReq.GetSandboxName()) mock.mu.Unlock() } @@ -773,14 +771,15 @@ func TestPolicyList(t *testing.T) { client, cleanup := setupPolicyTest(t, mock) defer cleanup() - revisions, err := client.List(context.Background(), "default") + revisions, err := client.List(context.Background(), "default", "my-sandbox") require.NoError(t, err) require.Len(t, revisions, 2) // Verify request was forwarded (no pagination options). mock.mu.Lock() - assert.Equal(t, "default", mock.lastListReq.GetWorkspace()) + assert.Equal(t, "my-sandbox", mock.lastListReq.GetSandboxName()) + assert.Equal(t, "default", mock.lastListReq.GetWorkspaceScope().GetWorkspace()) assert.Equal(t, uint32(0), mock.lastListReq.GetLimit()) assert.Equal(t, uint32(0), mock.lastListReq.GetOffset()) mock.mu.Unlock() @@ -805,7 +804,7 @@ func TestPolicyList_WithPagination(t *testing.T) { client, cleanup := setupPolicyTest(t, mock) defer cleanup() - revisions, err := client.List(context.Background(), "default", + revisions, err := client.List(context.Background(), "default", "my-sandbox", types.WithLimit(10), types.WithOffset(20), ) @@ -827,7 +826,7 @@ func TestPolicyList_Empty(t *testing.T) { client, cleanup := setupPolicyTest(t, mock) defer cleanup() - revisions, err := client.List(context.Background(), "default") + revisions, err := client.List(context.Background(), "default", "my-sandbox") require.NoError(t, err) assert.Nil(t, revisions) @@ -845,7 +844,7 @@ func TestPolicyList_WithGlobal(t *testing.T) { defer cleanup() // List with global flag and empty workspace. - revisions, err := client.List(context.Background(), "", types.WithListGlobal(true)) + revisions, err := client.List(context.Background(), "", "", types.WithListGlobal(true)) require.NoError(t, err) require.Len(t, revisions, 1) @@ -855,7 +854,7 @@ func TestPolicyList_WithGlobal(t *testing.T) { // Verify global flag was forwarded in the proto request. mock.mu.Lock() assert.True(t, mock.lastListReq.GetGlobal()) - assert.Empty(t, mock.lastListReq.GetWorkspace()) + assert.Empty(t, mock.lastListReq.GetSandboxName()) mock.mu.Unlock() } @@ -870,14 +869,14 @@ func TestPolicyList_WithGlobalIgnoresWorkspace(t *testing.T) { client, cleanup := setupPolicyTest(t, mock) defer cleanup() - revisions, err := client.List(context.Background(), "some-workspace", types.WithListGlobal(true)) + revisions, err := client.List(context.Background(), "some-workspace", "", types.WithListGlobal(true)) require.NoError(t, err) require.Len(t, revisions, 1) mock.mu.Lock() assert.True(t, mock.lastListReq.GetGlobal()) - assert.Equal(t, "some-workspace", mock.lastListReq.GetWorkspace()) + assert.Empty(t, mock.lastListReq.GetSandboxName()) mock.mu.Unlock() } @@ -893,7 +892,7 @@ func TestPolicyList_WithGlobalAndPagination(t *testing.T) { defer cleanup() // Global flag composes with pagination options. - revisions, err := client.List(context.Background(), "", + revisions, err := client.List(context.Background(), "", "", types.WithListGlobal(true), types.WithLimit(10), types.WithOffset(20), @@ -920,7 +919,7 @@ func TestPolicyList_WithoutGlobal_PreservesExistingBehavior(t *testing.T) { client, cleanup := setupPolicyTest(t, mock) defer cleanup() - revisions, err := client.List(context.Background(), "default") + revisions, err := client.List(context.Background(), "default", "my-sandbox") require.NoError(t, err) require.Len(t, revisions, 1) @@ -928,7 +927,8 @@ func TestPolicyList_WithoutGlobal_PreservesExistingBehavior(t *testing.T) { // Verify global flag is false by default. mock.mu.Lock() assert.False(t, mock.lastListReq.GetGlobal()) - assert.Equal(t, "default", mock.lastListReq.GetWorkspace()) + assert.Equal(t, "my-sandbox", mock.lastListReq.GetSandboxName()) + assert.Equal(t, "default", mock.lastListReq.GetWorkspaceScope().GetWorkspace()) mock.mu.Unlock() } @@ -939,7 +939,7 @@ func TestPolicyList_Error(t *testing.T) { client, cleanup := setupPolicyTest(t, mock) defer cleanup() - revisions, err := client.List(context.Background(), "default") + revisions, err := client.List(context.Background(), "default", "my-sandbox") assert.Nil(t, revisions) require.Error(t, err) @@ -966,7 +966,7 @@ func TestPolicyEditDraftChunk(t *testing.T) { // Verify request was forwarded. mock.mu.Lock() - assert.Equal(t, "my-sandbox", mock.lastEditReq.GetName()) + assert.Equal(t, "my-sandbox", mock.lastEditReq.GetSandboxName()) assert.Equal(t, "chunk-1", mock.lastEditReq.GetChunkId()) require.NotNil(t, mock.lastEditReq.GetProposedRule()) assert.Equal(t, "allow-https", mock.lastEditReq.GetProposedRule().GetName()) @@ -1005,7 +1005,7 @@ func TestPolicyUndoDraftChunk(t *testing.T) { // Verify request was forwarded. mock.mu.Lock() - assert.Equal(t, "my-sandbox", mock.lastUndoReq.GetName()) + assert.Equal(t, "my-sandbox", mock.lastUndoReq.GetSandboxName()) assert.Equal(t, "chunk-3", mock.lastUndoReq.GetChunkId()) mock.mu.Unlock() diff --git a/sdk/go/openshell/v1/provider.go b/sdk/go/openshell/v1/provider.go index f1ab67c482..42788f8855 100644 --- a/sdk/go/openshell/v1/provider.go +++ b/sdk/go/openshell/v1/provider.go @@ -21,6 +21,7 @@ type ProviderInterface interface { Create(ctx context.Context, workspace string, provider *Provider) (*Provider, error) Get(ctx context.Context, workspace, name string) (*Provider, error) List(ctx context.Context, workspace string, opts ...ListOptions) ([]*Provider, error) + ListAll(ctx context.Context, opts ...ListOptions) ([]*Provider, error) Update(ctx context.Context, workspace string, provider *Provider) (*Provider, error) Delete(ctx context.Context, workspace, name string) error Ensure(ctx context.Context, workspace string, provider *Provider) (*Provider, error) diff --git a/sdk/go/openshell/v1/provider_client.go b/sdk/go/openshell/v1/provider_client.go index 19784b6346..237325a1e3 100644 --- a/sdk/go/openshell/v1/provider_client.go +++ b/sdk/go/openshell/v1/provider_client.go @@ -35,8 +35,8 @@ func (p *providerClient) Refresh() RefreshInterface { func (p *providerClient) Create(ctx context.Context, workspace string, provider *Provider) (*Provider, error) { resp, err := p.client.CreateProvider(ctx, &pb.CreateProviderRequest{ - Provider: converter.ProviderToProto(provider), - Workspace: workspace, + Provider: converter.ProviderToProto(provider), + WorkspaceScope: namedWorkspaceScope(workspace), }) if err != nil { return nil, converter.FromGRPCError(err) @@ -46,8 +46,8 @@ func (p *providerClient) Create(ctx context.Context, workspace string, provider func (p *providerClient) Get(ctx context.Context, workspace, name string) (*Provider, error) { resp, err := p.client.GetProvider(ctx, &pb.GetProviderRequest{ - Name: name, - Workspace: workspace, + Name: name, + WorkspaceScope: namedWorkspaceScope(workspace), }) if err != nil { return nil, converter.FromGRPCError(err) @@ -57,8 +57,16 @@ func (p *providerClient) Get(ctx context.Context, workspace, name string) (*Prov func (p *providerClient) List(ctx context.Context, workspace string, opts ...ListOptions) ([]*Provider, error) { req := &pb.ListProvidersRequest{ - Workspace: workspace, + WorkspaceScope: namedWorkspaceScope(workspace), } + return p.list(ctx, req, opts...) +} + +func (p *providerClient) ListAll(ctx context.Context, opts ...ListOptions) ([]*Provider, error) { + return p.list(ctx, &pb.ListProvidersRequest{WorkspaceScope: allWorkspacesScope()}, opts...) +} + +func (p *providerClient) list(ctx context.Context, req *pb.ListProvidersRequest, opts ...ListOptions) ([]*Provider, error) { if len(opts) > 0 { if opts[0].Limit < 0 { return nil, &StatusError{Code: ErrorInvalidArgument, Message: "limit must not be negative"} @@ -68,7 +76,6 @@ func (p *providerClient) List(ctx context.Context, workspace string, opts ...Lis } req.Limit = uint32(opts[0].Limit) req.Offset = uint32(opts[0].Offset) - req.AllWorkspaces = opts[0].AllWorkspaces } resp, err := p.client.ListProviders(ctx, req) @@ -86,8 +93,8 @@ func (p *providerClient) List(ctx context.Context, workspace string, opts ...Lis func (p *providerClient) Update(ctx context.Context, workspace string, provider *Provider) (*Provider, error) { proto := converter.ProviderToProto(provider) req := &pb.UpdateProviderRequest{ - Provider: proto, - Workspace: workspace, + Provider: proto, + WorkspaceScope: namedWorkspaceScope(workspace), } if proto != nil { req.CredentialExpiresAtMs = proto.CredentialExpiresAtMs @@ -102,8 +109,8 @@ func (p *providerClient) Update(ctx context.Context, workspace string, provider func (p *providerClient) Delete(ctx context.Context, workspace, name string) error { _, err := p.client.DeleteProvider(ctx, &pb.DeleteProviderRequest{ - Name: name, - Workspace: workspace, + Name: name, + WorkspaceScope: namedWorkspaceScope(workspace), }) if err != nil { return converter.FromGRPCError(err) diff --git a/sdk/go/openshell/v1/refresh_client.go b/sdk/go/openshell/v1/refresh_client.go index ec98316a93..dc182da844 100644 --- a/sdk/go/openshell/v1/refresh_client.go +++ b/sdk/go/openshell/v1/refresh_client.go @@ -21,9 +21,9 @@ func newRefreshClient(conn grpc.ClientConnInterface) *refreshClient { func (r *refreshClient) GetStatus(ctx context.Context, workspace, provider, credentialKey string) ([]*RefreshStatus, error) { resp, err := r.client.GetProviderRefreshStatus(ctx, &pb.GetProviderRefreshStatusRequest{ - Provider: provider, - CredentialKey: credentialKey, - Workspace: workspace, + Provider: provider, + CredentialKey: credentialKey, + WorkspaceScope: namedWorkspaceScope(workspace), }) if err != nil { return nil, converter.FromGRPCError(err) @@ -38,7 +38,7 @@ func (r *refreshClient) GetStatus(ctx context.Context, workspace, provider, cred func (r *refreshClient) Configure(ctx context.Context, workspace string, config *RefreshConfig) (*RefreshStatus, error) { req := converter.RefreshConfigToProto(config) - req.Workspace = workspace + req.WorkspaceScope = namedWorkspaceScope(workspace) resp, err := r.client.ConfigureProviderRefresh(ctx, req) if err != nil { return nil, converter.FromGRPCError(err) @@ -48,9 +48,9 @@ func (r *refreshClient) Configure(ctx context.Context, workspace string, config func (r *refreshClient) Rotate(ctx context.Context, workspace, provider, credentialKey string) (*RefreshStatus, error) { resp, err := r.client.RotateProviderCredential(ctx, &pb.RotateProviderCredentialRequest{ - Provider: provider, - CredentialKey: credentialKey, - Workspace: workspace, + Provider: provider, + CredentialKey: credentialKey, + WorkspaceScope: namedWorkspaceScope(workspace), }) if err != nil { return nil, converter.FromGRPCError(err) @@ -60,9 +60,9 @@ func (r *refreshClient) Rotate(ctx context.Context, workspace, provider, credent func (r *refreshClient) Delete(ctx context.Context, workspace, provider, credentialKey string) (bool, error) { resp, err := r.client.DeleteProviderRefresh(ctx, &pb.DeleteProviderRefreshRequest{ - Provider: provider, - CredentialKey: credentialKey, - Workspace: workspace, + Provider: provider, + CredentialKey: credentialKey, + WorkspaceScope: namedWorkspaceScope(workspace), }) if err != nil { return false, converter.FromGRPCError(err) diff --git a/sdk/go/openshell/v1/sandbox.go b/sdk/go/openshell/v1/sandbox.go index 79174ac7f3..59ab37ee91 100644 --- a/sdk/go/openshell/v1/sandbox.go +++ b/sdk/go/openshell/v1/sandbox.go @@ -56,6 +56,7 @@ type SandboxInterface interface { Create(ctx context.Context, workspace, name string, spec *SandboxSpec, labels map[string]string, opts ...CreateOptions) (*Sandbox, error) Get(ctx context.Context, workspace, name string) (*Sandbox, error) List(ctx context.Context, workspace string, opts ...ListOptions) ([]*Sandbox, error) + ListAll(ctx context.Context, opts ...ListOptions) ([]*Sandbox, error) Stop(ctx context.Context, workspace, name string) (*Sandbox, error) Start(ctx context.Context, workspace, name string) (*Sandbox, error) Delete(ctx context.Context, workspace, name string) error diff --git a/sdk/go/openshell/v1/sandbox_client.go b/sdk/go/openshell/v1/sandbox_client.go index 75d8d4caa3..4f0fa170dc 100644 --- a/sdk/go/openshell/v1/sandbox_client.go +++ b/sdk/go/openshell/v1/sandbox_client.go @@ -34,10 +34,10 @@ func (s *sandboxClient) Create(ctx context.Context, workspace, name string, spec return nil, &StatusError{Code: ErrorInvalidArgument, Message: err.Error()} } req := &pb.CreateSandboxRequest{ - Name: name, - Spec: protoSpec, - Labels: labels, - Workspace: workspace, + Name: name, + Spec: protoSpec, + Labels: labels, + WorkspaceScope: namedWorkspaceScope(workspace), } if len(opts) > 0 { req.Annotations = converter.CopyStringMap(opts[0].Annotations) @@ -64,7 +64,7 @@ func (s *sandboxClient) CreateFromTemplate(ctx context.Context, workspace, name, Name: name, Spec: protoSpec, Labels: labels, - Workspace: workspace, + WorkspaceScope: namedWorkspaceScope(workspace), WorkloadTemplateName: templateName, } if len(opts) > 0 { @@ -89,8 +89,8 @@ func validateTemplateCreateSpec(spec *SandboxSpec) error { func (s *sandboxClient) Get(ctx context.Context, workspace, name string) (*Sandbox, error) { resp, err := s.client.GetSandbox(ctx, &pb.GetSandboxRequest{ - Name: name, - Workspace: workspace, + SandboxName: name, + WorkspaceScope: namedWorkspaceScope(workspace), }) if err != nil { return nil, converter.FromGRPCError(err) @@ -100,8 +100,16 @@ func (s *sandboxClient) Get(ctx context.Context, workspace, name string) (*Sandb func (s *sandboxClient) List(ctx context.Context, workspace string, opts ...ListOptions) ([]*Sandbox, error) { req := &pb.ListSandboxesRequest{ - Workspace: workspace, + WorkspaceScope: namedWorkspaceScope(workspace), } + return s.list(ctx, req, opts...) +} + +func (s *sandboxClient) ListAll(ctx context.Context, opts ...ListOptions) ([]*Sandbox, error) { + return s.list(ctx, &pb.ListSandboxesRequest{WorkspaceScope: allWorkspacesScope()}, opts...) +} + +func (s *sandboxClient) list(ctx context.Context, req *pb.ListSandboxesRequest, opts ...ListOptions) ([]*Sandbox, error) { if len(opts) > 0 { if opts[0].Limit < 0 { return nil, &StatusError{Code: ErrorInvalidArgument, Message: "limit must not be negative"} @@ -112,7 +120,6 @@ func (s *sandboxClient) List(ctx context.Context, workspace string, opts ...List req.Limit = uint32(opts[0].Limit) req.Offset = uint32(opts[0].Offset) req.LabelSelector = opts[0].LabelSelector - req.AllWorkspaces = opts[0].AllWorkspaces } resp, err := s.client.ListSandboxes(ctx, req) @@ -129,8 +136,8 @@ func (s *sandboxClient) List(ctx context.Context, workspace string, opts ...List func (s *sandboxClient) Delete(ctx context.Context, workspace, name string) error { _, err := s.client.DeleteSandbox(ctx, &pb.DeleteSandboxRequest{ - Name: name, - Workspace: workspace, + SandboxName: name, + WorkspaceScope: namedWorkspaceScope(workspace), }) if err != nil { return converter.FromGRPCError(err) @@ -140,8 +147,8 @@ func (s *sandboxClient) Delete(ctx context.Context, workspace, name string) erro func (s *sandboxClient) Stop(ctx context.Context, workspace, name string) (*Sandbox, error) { resp, err := s.client.StopSandbox(ctx, &pb.StopSandboxRequest{ - Name: name, - Workspace: workspace, + SandboxName: name, + WorkspaceScope: namedWorkspaceScope(workspace), }) if err != nil { return nil, converter.FromGRPCError(err) @@ -151,8 +158,8 @@ func (s *sandboxClient) Stop(ctx context.Context, workspace, name string) (*Sand func (s *sandboxClient) Start(ctx context.Context, workspace, name string) (*Sandbox, error) { resp, err := s.client.StartSandbox(ctx, &pb.StartSandboxRequest{ - Name: name, - Workspace: workspace, + SandboxName: name, + WorkspaceScope: namedWorkspaceScope(workspace), }) if err != nil { return nil, converter.FromGRPCError(err) @@ -162,10 +169,10 @@ func (s *sandboxClient) Start(ctx context.Context, workspace, name string) (*San func (s *sandboxClient) AttachProvider(ctx context.Context, workspace, sandboxName, providerName string, expectedResourceVersion uint64) (*AttachProviderResult, error) { resp, err := s.client.AttachSandboxProvider(ctx, &pb.AttachSandboxProviderRequest{ - SandboxName: sandboxName, ProviderName: providerName, ExpectedResourceVersion: expectedResourceVersion, - Workspace: workspace, + SandboxName: sandboxName, + WorkspaceScope: namedWorkspaceScope(workspace), }) if err != nil { return nil, converter.FromGRPCError(err) @@ -178,10 +185,10 @@ func (s *sandboxClient) AttachProvider(ctx context.Context, workspace, sandboxNa func (s *sandboxClient) DetachProvider(ctx context.Context, workspace, sandboxName, providerName string, expectedResourceVersion uint64) (*DetachProviderResult, error) { resp, err := s.client.DetachSandboxProvider(ctx, &pb.DetachSandboxProviderRequest{ - SandboxName: sandboxName, ProviderName: providerName, ExpectedResourceVersion: expectedResourceVersion, - Workspace: workspace, + SandboxName: sandboxName, + WorkspaceScope: namedWorkspaceScope(workspace), }) if err != nil { return nil, converter.FromGRPCError(err) @@ -194,8 +201,8 @@ func (s *sandboxClient) DetachProvider(ctx context.Context, workspace, sandboxNa func (s *sandboxClient) ListProviders(ctx context.Context, workspace, sandboxName string) ([]*Provider, error) { resp, err := s.client.ListSandboxProviders(ctx, &pb.ListSandboxProvidersRequest{ - SandboxName: sandboxName, - Workspace: workspace, + SandboxName: sandboxName, + WorkspaceScope: namedWorkspaceScope(workspace), }) if err != nil { return nil, converter.FromGRPCError(err) @@ -280,15 +287,14 @@ func (s *sandboxClient) Watch(ctx context.Context, workspace, name string, opts if len(opts) > 0 { watchOpts = opts[0] } - - sb, err := s.Get(ctx, workspace, name) - if err != nil { + if _, err := s.Get(ctx, workspace, name); err != nil { return nil, err } streamCtx, streamCancel := context.WithCancel(ctx) stream, err := s.client.WatchSandbox(streamCtx, &pb.WatchSandboxRequest{ - Id: sb.ID, + SandboxName: name, + WorkspaceScope: namedWorkspaceScope(workspace), FollowStatus: true, StopOnTerminal: watchOpts.StopOnTerminal, }) @@ -354,18 +360,16 @@ func (s *sandboxClient) Watch(ctx context.Context, workspace, name string, opts } func (s *sandboxClient) GetLogs(ctx context.Context, workspace, sandboxName string, opts ...LogOption) (*LogResult, error) { - sb, err := s.Get(ctx, workspace, sandboxName) - if err != nil { + if _, err := s.Get(ctx, workspace, sandboxName); err != nil { return nil, err } - cfg := types.ApplyLogOptions(opts) req := &pb.GetSandboxLogsRequest{ - SandboxId: sb.ID, - Lines: cfg.Lines(), - Sources: cfg.Sources(), - MinLevel: cfg.MinLevel(), - Workspace: workspace, + SandboxName: sandboxName, + WorkspaceScope: namedWorkspaceScope(workspace), + Lines: cfg.Lines(), + Sources: cfg.Sources(), + MinLevel: cfg.MinLevel(), } if !cfg.Since().IsZero() { req.SinceMs = converter.MillisFromTime(cfg.Since()) diff --git a/sdk/go/openshell/v1/sandbox_client_test.go b/sdk/go/openshell/v1/sandbox_client_test.go index 7926b28a16..a7a38cb37e 100644 --- a/sdk/go/openshell/v1/sandbox_client_test.go +++ b/sdk/go/openshell/v1/sandbox_client_test.go @@ -82,9 +82,10 @@ func (s *mockSandboxServer) GetSandbox(_ context.Context, req *pb.GetSandboxRequ if s.getErr != nil { return nil, s.getErr } - sb, ok := s.sandboxes[req.GetName()] + name := req.GetSandboxName() + sb, ok := s.sandboxes[name] if !ok { - return nil, status.Errorf(codes.NotFound, "sandbox %q not found", req.GetName()) + return nil, status.Errorf(codes.NotFound, "sandbox %q not found", name) } cloned := proto.Clone(sb).(*pb.Sandbox) return &pb.SandboxResponse{Sandbox: cloned}, nil @@ -117,20 +118,22 @@ func (s *mockSandboxServer) DeleteSandbox(_ context.Context, req *pb.DeleteSandb if s.deleteErr != nil { return nil, s.deleteErr } - _, ok := s.sandboxes[req.GetName()] + name := req.GetSandboxName() + _, ok := s.sandboxes[name] if !ok { - return nil, status.Errorf(codes.NotFound, "sandbox %q not found", req.GetName()) + return nil, status.Errorf(codes.NotFound, "sandbox %q not found", name) } - delete(s.sandboxes, req.GetName()) + delete(s.sandboxes, name) return &pb.DeleteSandboxResponse{Deleted: true}, nil } func (s *mockSandboxServer) StopSandbox(_ context.Context, req *pb.StopSandboxRequest) (*pb.SandboxResponse, error) { s.mu.Lock() defer s.mu.Unlock() - sb, ok := s.sandboxes[req.GetName()] + name := req.GetSandboxName() + sb, ok := s.sandboxes[name] if !ok { - return nil, status.Errorf(codes.NotFound, "sandbox %q not found", req.GetName()) + return nil, status.Errorf(codes.NotFound, "sandbox %q not found", name) } sb.Status.Phase = pb.SandboxPhase_SANDBOX_PHASE_STOPPED return &pb.SandboxResponse{Sandbox: proto.Clone(sb).(*pb.Sandbox)}, nil @@ -139,9 +142,10 @@ func (s *mockSandboxServer) StopSandbox(_ context.Context, req *pb.StopSandboxRe func (s *mockSandboxServer) StartSandbox(_ context.Context, req *pb.StartSandboxRequest) (*pb.SandboxResponse, error) { s.mu.Lock() defer s.mu.Unlock() - sb, ok := s.sandboxes[req.GetName()] + name := req.GetSandboxName() + sb, ok := s.sandboxes[name] if !ok { - return nil, status.Errorf(codes.NotFound, "sandbox %q not found", req.GetName()) + return nil, status.Errorf(codes.NotFound, "sandbox %q not found", name) } sb.Status.Phase = pb.SandboxPhase_SANDBOX_PHASE_STARTING return &pb.SandboxResponse{Sandbox: proto.Clone(sb).(*pb.Sandbox)}, nil @@ -153,9 +157,10 @@ func (s *mockSandboxServer) AttachSandboxProvider(_ context.Context, req *pb.Att if s.attachErr != nil { return nil, s.attachErr } - sb, ok := s.sandboxes[req.GetSandboxName()] + name := req.GetSandboxName() + sb, ok := s.sandboxes[name] if !ok { - return nil, status.Errorf(codes.NotFound, "sandbox %q not found", req.GetSandboxName()) + return nil, status.Errorf(codes.NotFound, "sandbox %q not found", name) } sb.Spec.Providers = append(sb.Spec.Providers, req.GetProviderName()) return &pb.AttachSandboxProviderResponse{Sandbox: sb, Attached: true}, nil @@ -167,9 +172,10 @@ func (s *mockSandboxServer) DetachSandboxProvider(_ context.Context, req *pb.Det if s.detachErr != nil { return nil, s.detachErr } - sb, ok := s.sandboxes[req.GetSandboxName()] + name := req.GetSandboxName() + sb, ok := s.sandboxes[name] if !ok { - return nil, status.Errorf(codes.NotFound, "sandbox %q not found", req.GetSandboxName()) + return nil, status.Errorf(codes.NotFound, "sandbox %q not found", name) } return &pb.DetachSandboxProviderResponse{Sandbox: sb, Detached: true}, nil } @@ -924,7 +930,7 @@ func TestSandboxWatch_MidStreamErrorDeliveredAsStatusError(t *testing.T) { // --- T016: Watch name-to-ID resolution verification tests --- -func TestSandboxWatch_ResolvesNameToID(t *testing.T) { +func TestSandboxWatch_UsesName(t *testing.T) { mock := newMockSandboxServer() mock.sandboxes["my-sandbox"] = &pb.Sandbox{ Metadata: &dm.ObjectMeta{Id: "resolved-id-123", Name: "my-sandbox"}, @@ -945,12 +951,12 @@ func TestSandboxWatch_ResolvesNameToID(t *testing.T) { require.NoError(t, err) defer w.Stop() - // Verify the WatchSandboxRequest.Id contains the resolved ID, not the name + // Verify the WatchSandboxRequest reference contains the resolved ID, not the name. mock.mu.Lock() req := mock.watchRequest mock.mu.Unlock() require.NotNil(t, req) - assert.Equal(t, "resolved-id-123", req.GetId(), "Watch should send resolved sandbox ID, not the name") + assert.Equal(t, "my-sandbox", req.GetSandboxName()) } func TestSandboxWatch_ResolutionError(t *testing.T) { @@ -1161,7 +1167,7 @@ func TestSandboxGetLogs(t *testing.T) { // Verify name→id resolution: the proto request should contain the sandbox ID mock.mu.Lock() - assert.Equal(t, "sb-id-123", mock.getLogsRequest.GetSandboxId()) + assert.Equal(t, "log-sb", mock.getLogsRequest.GetSandboxName()) mock.mu.Unlock() } @@ -1195,7 +1201,7 @@ func TestSandboxGetLogs_WithOptions(t *testing.T) { mock.mu.Lock() req := mock.getLogsRequest mock.mu.Unlock() - assert.Equal(t, "sb-id-opts", req.GetSandboxId()) + assert.Equal(t, "opts-sb", req.GetSandboxName()) assert.Equal(t, uint32(50), req.GetLines()) assert.Equal(t, since.UnixMilli(), req.GetSinceMs()) assert.Equal(t, []string{"gateway", "sandbox"}, req.GetSources()) diff --git a/sdk/go/openshell/v1/sandbox_template.go b/sdk/go/openshell/v1/sandbox_template.go index e2f2d71874..395b254bac 100644 --- a/sdk/go/openshell/v1/sandbox_template.go +++ b/sdk/go/openshell/v1/sandbox_template.go @@ -41,5 +41,6 @@ type SandboxTemplateInterface interface { Create(ctx context.Context, workspace string, template *SandboxWorkloadTemplate) (*SandboxWorkloadTemplate, error) Get(ctx context.Context, workspace, name string) (*SandboxWorkloadTemplate, error) List(ctx context.Context, workspace string, opts ...ListOptions) ([]*SandboxWorkloadTemplate, error) + ListAll(ctx context.Context, opts ...ListOptions) ([]*SandboxWorkloadTemplate, error) Delete(ctx context.Context, workspace, name string) (bool, error) } diff --git a/sdk/go/openshell/v1/sandbox_template_client.go b/sdk/go/openshell/v1/sandbox_template_client.go index de6e19237c..fd9ddb53e3 100644 --- a/sdk/go/openshell/v1/sandbox_template_client.go +++ b/sdk/go/openshell/v1/sandbox_template_client.go @@ -30,8 +30,8 @@ func (s *sandboxTemplateClient) Create(ctx context.Context, workspace string, te return nil, &StatusError{Code: ErrorInvalidArgument, Message: err.Error()} } resp, err := s.client.CreateSandboxTemplate(ctx, &pb.CreateSandboxTemplateRequest{ - Template: protoTemplate, - Workspace: workspace, + Template: protoTemplate, + WorkspaceScope: namedWorkspaceScope(workspace), }) if err != nil { return nil, converter.FromGRPCError(err) @@ -41,8 +41,8 @@ func (s *sandboxTemplateClient) Create(ctx context.Context, workspace string, te func (s *sandboxTemplateClient) Get(ctx context.Context, workspace, name string) (*SandboxWorkloadTemplate, error) { resp, err := s.client.GetSandboxTemplate(ctx, &pb.GetSandboxTemplateRequest{ - Name: name, - Workspace: workspace, + Name: name, + WorkspaceScope: namedWorkspaceScope(workspace), }) if err != nil { return nil, converter.FromGRPCError(err) @@ -52,8 +52,16 @@ func (s *sandboxTemplateClient) Get(ctx context.Context, workspace, name string) func (s *sandboxTemplateClient) List(ctx context.Context, workspace string, opts ...ListOptions) ([]*SandboxWorkloadTemplate, error) { req := &pb.ListSandboxTemplatesRequest{ - Workspace: workspace, + WorkspaceScope: namedWorkspaceScope(workspace), } + return s.list(ctx, req, opts...) +} + +func (s *sandboxTemplateClient) ListAll(ctx context.Context, opts ...ListOptions) ([]*SandboxWorkloadTemplate, error) { + return s.list(ctx, &pb.ListSandboxTemplatesRequest{WorkspaceScope: allWorkspacesScope()}, opts...) +} + +func (s *sandboxTemplateClient) list(ctx context.Context, req *pb.ListSandboxTemplatesRequest, opts ...ListOptions) ([]*SandboxWorkloadTemplate, error) { if len(opts) > 0 { if opts[0].Limit < 0 { return nil, &StatusError{Code: ErrorInvalidArgument, Message: "limit must not be negative"} @@ -64,10 +72,6 @@ func (s *sandboxTemplateClient) List(ctx context.Context, workspace string, opts req.Limit = uint32(opts[0].Limit) req.Offset = uint32(opts[0].Offset) req.LabelSelector = opts[0].LabelSelector - req.AllWorkspaces = opts[0].AllWorkspaces - if req.AllWorkspaces { - req.Workspace = "" - } } resp, err := s.client.ListSandboxTemplates(ctx, req) @@ -84,8 +88,8 @@ func (s *sandboxTemplateClient) List(ctx context.Context, workspace string, opts func (s *sandboxTemplateClient) Delete(ctx context.Context, workspace, name string) (bool, error) { resp, err := s.client.DeleteSandboxTemplate(ctx, &pb.DeleteSandboxTemplateRequest{ - Name: name, - Workspace: workspace, + Name: name, + WorkspaceScope: namedWorkspaceScope(workspace), }) if err != nil { return false, converter.FromGRPCError(err) diff --git a/sdk/go/openshell/v1/sandbox_template_client_test.go b/sdk/go/openshell/v1/sandbox_template_client_test.go index 4f9e3058c0..48045dbec8 100644 --- a/sdk/go/openshell/v1/sandbox_template_client_test.go +++ b/sdk/go/openshell/v1/sandbox_template_client_test.go @@ -57,7 +57,7 @@ func (s *mockSandboxTemplateServer) CreateSandboxTemplate(_ context.Context, req if template.Metadata == nil { template.Metadata = &dm.ObjectMeta{} } - template.Metadata.Workspace = req.GetWorkspace() + template.Metadata.Workspace = req.GetWorkspaceScope().GetWorkspace() template.Metadata.ResourceVersion = 1 s.templates[template.Metadata.GetName()] = template return &pb.SandboxTemplateResponse{Template: proto.Clone(template).(*pb.SandboxWorkloadTemplate)}, nil @@ -164,7 +164,7 @@ func TestSandboxTemplateCreate(t *testing.T) { mock.mu.Lock() defer mock.mu.Unlock() require.NotNil(t, mock.createRequest) - assert.Equal(t, "default", mock.createRequest.Workspace) + assert.Equal(t, "default", mock.createRequest.GetWorkspaceScope().GetWorkspace()) assert.Equal(t, "gpu-kata", mock.createRequest.Template.Metadata.Name) assert.Equal(t, "2", mock.createRequest.Template.Spec.Workload.Resources.Cpu) assert.Equal(t, "8Gi", mock.createRequest.Template.Spec.Workload.Resources.Memory) @@ -220,11 +220,10 @@ func TestSandboxTemplateGetListDelete(t *testing.T) { assert.Equal(t, "gpu-kata", got.Name) assert.Equal(t, "img:v1", got.Spec.Workload.Image) - list, err := client.List(context.Background(), "default", ListOptions{ + list, err := client.ListAll(context.Background(), ListOptions{ Limit: 10, Offset: 2, LabelSelector: "team=runtime", - AllWorkspaces: true, }) require.NoError(t, err) require.Len(t, list, 1) @@ -237,16 +236,15 @@ func TestSandboxTemplateGetListDelete(t *testing.T) { mock.mu.Lock() defer mock.mu.Unlock() require.NotNil(t, mock.getRequest) - assert.Equal(t, "default", mock.getRequest.Workspace) + assert.Equal(t, "default", mock.getRequest.GetWorkspaceScope().GetWorkspace()) assert.Equal(t, "gpu-kata", mock.getRequest.Name) require.NotNil(t, mock.listRequest) - assert.Empty(t, mock.listRequest.Workspace) + assert.NotNil(t, mock.listRequest.GetWorkspaceScope().GetAllWorkspaces()) assert.Equal(t, uint32(10), mock.listRequest.Limit) assert.Equal(t, uint32(2), mock.listRequest.Offset) assert.Equal(t, "team=runtime", mock.listRequest.LabelSelector) - assert.True(t, mock.listRequest.AllWorkspaces) require.NotNil(t, mock.deleteRequest) - assert.Equal(t, "default", mock.deleteRequest.Workspace) + assert.Equal(t, "default", mock.deleteRequest.GetWorkspaceScope().GetWorkspace()) assert.Equal(t, "gpu-kata", mock.deleteRequest.Name) } diff --git a/sdk/go/openshell/v1/service.go b/sdk/go/openshell/v1/service.go index 4ee819c522..acdcd1eeb6 100644 --- a/sdk/go/openshell/v1/service.go +++ b/sdk/go/openshell/v1/service.go @@ -17,5 +17,6 @@ type ServiceInterface interface { Expose(ctx context.Context, workspace, sandboxName, serviceName string, targetPort uint32, domain bool) (*ServiceEndpoint, error) Get(ctx context.Context, workspace, sandboxName, serviceName string) (*ServiceEndpoint, error) List(ctx context.Context, workspace, sandboxName string, opts ...ListOptions) ([]*ServiceEndpoint, error) + ListAll(ctx context.Context, opts ...ListOptions) ([]*ServiceEndpoint, error) Delete(ctx context.Context, workspace, sandboxName, serviceName string) error } diff --git a/sdk/go/openshell/v1/service_client.go b/sdk/go/openshell/v1/service_client.go index a16dd0dc05..8e205ffb20 100644 --- a/sdk/go/openshell/v1/service_client.go +++ b/sdk/go/openshell/v1/service_client.go @@ -21,11 +21,11 @@ func newServiceClient(conn grpc.ClientConnInterface) *serviceClient { func (s *serviceClient) Expose(ctx context.Context, workspace, sandboxName, serviceName string, targetPort uint32, domain bool) (*ServiceEndpoint, error) { resp, err := s.client.ExposeService(ctx, &pb.ExposeServiceRequest{ - Sandbox: sandboxName, - Service: serviceName, - TargetPort: targetPort, - Domain: domain, - Workspace: workspace, + SandboxName: sandboxName, + WorkspaceScope: namedWorkspaceScope(workspace), + Service: serviceName, + TargetPort: targetPort, + Domain: domain, }) if err != nil { return nil, converter.FromGRPCError(err) @@ -35,9 +35,9 @@ func (s *serviceClient) Expose(ctx context.Context, workspace, sandboxName, serv func (s *serviceClient) Get(ctx context.Context, workspace, sandboxName, serviceName string) (*ServiceEndpoint, error) { resp, err := s.client.GetService(ctx, &pb.GetServiceRequest{ - Sandbox: sandboxName, - Service: serviceName, - Workspace: workspace, + SandboxName: sandboxName, + WorkspaceScope: namedWorkspaceScope(workspace), + Service: serviceName, }) if err != nil { return nil, converter.FromGRPCError(err) @@ -46,10 +46,20 @@ func (s *serviceClient) Get(ctx context.Context, workspace, sandboxName, service } func (s *serviceClient) List(ctx context.Context, workspace, sandboxName string, opts ...ListOptions) ([]*ServiceEndpoint, error) { - req := &pb.ListServicesRequest{ - Sandbox: sandboxName, - Workspace: workspace, + req := &pb.ListServicesRequest{WorkspaceScope: namedWorkspaceScope(workspace)} + if sandboxName != "" { + req.SandboxName = sandboxName + req.WorkspaceScope = namedWorkspaceScope(workspace) + req.WorkspaceScope = nil } + return s.list(ctx, req, opts...) +} + +func (s *serviceClient) ListAll(ctx context.Context, opts ...ListOptions) ([]*ServiceEndpoint, error) { + return s.list(ctx, &pb.ListServicesRequest{WorkspaceScope: allWorkspacesScope()}, opts...) +} + +func (s *serviceClient) list(ctx context.Context, req *pb.ListServicesRequest, opts ...ListOptions) ([]*ServiceEndpoint, error) { if len(opts) > 0 { if opts[0].Limit < 0 { return nil, &StatusError{Code: ErrorInvalidArgument, Message: "limit must not be negative"} @@ -59,7 +69,6 @@ func (s *serviceClient) List(ctx context.Context, workspace, sandboxName string, } req.Limit = uint32(opts[0].Limit) req.Offset = uint32(opts[0].Offset) - req.AllWorkspaces = opts[0].AllWorkspaces } resp, err := s.client.ListServices(ctx, req) @@ -76,9 +85,9 @@ func (s *serviceClient) List(ctx context.Context, workspace, sandboxName string, func (s *serviceClient) Delete(ctx context.Context, workspace, sandboxName, serviceName string) error { _, err := s.client.DeleteService(ctx, &pb.DeleteServiceRequest{ - Sandbox: sandboxName, - Service: serviceName, - Workspace: workspace, + SandboxName: sandboxName, + WorkspaceScope: namedWorkspaceScope(workspace), + Service: serviceName, }) if err != nil { return converter.FromGRPCError(err) diff --git a/sdk/go/openshell/v1/service_client_test.go b/sdk/go/openshell/v1/service_client_test.go index 334acd215a..b448f84b4c 100644 --- a/sdk/go/openshell/v1/service_client_test.go +++ b/sdk/go/openshell/v1/service_client_test.go @@ -30,6 +30,7 @@ type mockServiceServer struct { getErr error listErr error deleteErr error + lastList *pb.ListServicesRequest } func newMockServiceServer() *mockServiceServer { @@ -54,7 +55,7 @@ func (s *mockServiceServer) ExposeService(_ context.Context, req *pb.ExposeServi Metadata: &dm.ObjectMeta{ Id: "ep-" + req.GetService(), }, - SandboxName: req.GetSandbox(), + SandboxName: req.GetSandboxName(), ServiceName: req.GetService(), TargetPort: req.GetTargetPort(), Domain: req.GetDomain(), @@ -64,7 +65,7 @@ func (s *mockServiceServer) ExposeService(_ context.Context, req *pb.ExposeServi resp.Url = "https://" + req.GetService() + ".example.com" } - s.endpoints[serviceKey(req.GetSandbox(), req.GetService())] = resp + s.endpoints[serviceKey(req.GetSandboxName(), req.GetService())] = resp return resp, nil } @@ -75,9 +76,10 @@ func (s *mockServiceServer) GetService(_ context.Context, req *pb.GetServiceRequ return nil, s.getErr } - ep, ok := s.endpoints[serviceKey(req.GetSandbox(), req.GetService())] + sandboxName := req.GetSandboxName() + ep, ok := s.endpoints[serviceKey(sandboxName, req.GetService())] if !ok { - return nil, status.Errorf(codes.NotFound, "service %q not found in sandbox %q", req.GetService(), req.GetSandbox()) + return nil, status.Errorf(codes.NotFound, "service %q not found in sandbox %q", req.GetService(), sandboxName) } return ep, nil } @@ -85,14 +87,16 @@ func (s *mockServiceServer) GetService(_ context.Context, req *pb.GetServiceRequ func (s *mockServiceServer) ListServices(_ context.Context, req *pb.ListServicesRequest) (*pb.ListServicesResponse, error) { s.mu.Lock() defer s.mu.Unlock() + s.lastList = req if s.listErr != nil { return nil, s.listErr } var services []*pb.ServiceEndpointResponse for key, ep := range s.endpoints { - prefix := req.GetSandbox() + "/" - if req.GetSandbox() == "" || (len(key) >= len(prefix) && key[:len(prefix)] == prefix) { + sandboxName := req.GetSandboxName() + prefix := sandboxName + "/" + if sandboxName == "" || (len(key) >= len(prefix) && key[:len(prefix)] == prefix) { services = append(services, ep) } } @@ -106,10 +110,11 @@ func (s *mockServiceServer) DeleteService(_ context.Context, req *pb.DeleteServi return nil, s.deleteErr } - key := serviceKey(req.GetSandbox(), req.GetService()) + sandboxName := req.GetSandboxName() + key := serviceKey(sandboxName, req.GetService()) _, ok := s.endpoints[key] if !ok { - return nil, status.Errorf(codes.NotFound, "service %q not found in sandbox %q", req.GetService(), req.GetSandbox()) + return nil, status.Errorf(codes.NotFound, "service %q not found in sandbox %q", req.GetService(), sandboxName) } delete(s.endpoints, key) return &pb.DeleteServiceResponse{Deleted: true}, nil @@ -266,6 +271,20 @@ func TestServiceList_WithOptions(t *testing.T) { assert.Len(t, endpoints, 1) } +func TestServiceListAll_SelectsAllWorkspaces(t *testing.T) { + mock := newMockServiceServer() + client, cleanup := setupServiceTest(t, mock) + defer cleanup() + + endpoints, err := client.ListAll(context.Background(), ListOptions{Limit: 10}) + + require.NoError(t, err) + assert.Empty(t, endpoints) + require.NotNil(t, mock.lastList) + assert.Empty(t, mock.lastList.GetSandboxName()) + assert.NotNil(t, mock.lastList.GetWorkspaceScope().GetAllWorkspaces()) +} + func TestServiceList_Error(t *testing.T) { mock := newMockServiceServer() mock.listErr = status.Errorf(codes.Unavailable, "unavailable") diff --git a/sdk/go/openshell/v1/ssh.go b/sdk/go/openshell/v1/ssh.go index 19a4b65a3e..0e61a5f804 100644 --- a/sdk/go/openshell/v1/ssh.go +++ b/sdk/go/openshell/v1/ssh.go @@ -31,7 +31,7 @@ func WithTunnelServiceID(id string) TunnelOption { // SSHInterface defines operations for managing SSH sessions. type SSHInterface interface { - CreateSession(ctx context.Context, workspace, sandboxID string) (*SSHSession, error) + CreateSession(ctx context.Context, workspace, sandboxName string) (*SSHSession, error) RevokeSession(ctx context.Context, workspace, token string) (bool, error) Tunnel(ctx context.Context, workspace, sandboxName string, port uint32, opts ...TunnelOption) (io.ReadWriteCloser, error) } diff --git a/sdk/go/openshell/v1/ssh_client.go b/sdk/go/openshell/v1/ssh_client.go index 6e7ee1a462..5d4b4cc673 100644 --- a/sdk/go/openshell/v1/ssh_client.go +++ b/sdk/go/openshell/v1/ssh_client.go @@ -30,9 +30,10 @@ func newSSHClient(conn grpc.ClientConnInterface, sandboxes SandboxInterface) *ss } } -func (s *sshClient) CreateSession(ctx context.Context, _, sandboxID string) (*SSHSession, error) { +func (s *sshClient) CreateSession(ctx context.Context, workspace, sandboxName string) (*SSHSession, error) { resp, err := s.client.CreateSshSession(ctx, &pb.CreateSshSessionRequest{ - SandboxId: sandboxID, + SandboxName: sandboxName, + WorkspaceScope: namedWorkspaceScope(workspace), }) if err != nil { return nil, converter.FromGRPCError(err) @@ -63,16 +64,14 @@ func (s *sshClient) Tunnel(ctx context.Context, workspace, sandboxName string, p Message: fmt.Sprintf("port must be in range 1-65535, got %d", port), } } + if _, err := s.sandboxes.Get(ctx, workspace, sandboxName); err != nil { + return nil, err + } var cfg tunnelConfig options.Apply(&cfg, opts) - sandbox, err := s.sandboxes.Get(ctx, workspace, sandboxName) - if err != nil { - return nil, err - } - - session, err := s.CreateSession(ctx, workspace, sandbox.ID) + session, err := s.CreateSession(ctx, workspace, sandboxName) if err != nil { return nil, err } @@ -94,7 +93,8 @@ func (s *sshClient) Tunnel(ctx context.Context, workspace, sandboxName string, p initFrame := &pb.TcpForwardFrame{ Payload: &pb.TcpForwardFrame_Init{ Init: &pb.TcpForwardInit{ - SandboxId: sandbox.ID, + SandboxName: sandboxName, + WorkspaceScope: namedWorkspaceScope(workspace), ServiceId: cfg.serviceID, AuthorizationToken: session.Token, Target: &pb.TcpForwardInit_Ssh{ diff --git a/sdk/go/openshell/v1/ssh_client_test.go b/sdk/go/openshell/v1/ssh_client_test.go index 5b600b3370..1d8edb5c18 100644 --- a/sdk/go/openshell/v1/ssh_client_test.go +++ b/sdk/go/openshell/v1/ssh_client_test.go @@ -51,13 +51,14 @@ func (s *mockSSHServer) CreateSshSession(_ context.Context, req *pb.CreateSshSes return nil, s.createErr } - token := "tok-" + req.GetSandboxId() + sandboxName := req.GetSandboxName() + token := "tok-" + sandboxName if s.nextToken != "" { token = s.nextToken } resp := &pb.CreateSshSessionResponse{ - SandboxId: req.GetSandboxId(), + SandboxId: "id-" + sandboxName, Token: token, GatewayHost: "gw.example.com", GatewayPort: 2222, @@ -65,7 +66,7 @@ func (s *mockSSHServer) CreateSshSession(_ context.Context, req *pb.CreateSshSes HostKeyFingerprint: "SHA256:abc123", ExpiresAtMs: 1700000000000, } - s.sessions[req.GetSandboxId()] = resp + s.sessions[sandboxName] = resp s.tokens[token] = true return resp, nil } @@ -152,6 +153,9 @@ func (m *mockSandboxResolver) Get(_ context.Context, _, name string) (*Sandbox, func (m *mockSandboxResolver) List(_ context.Context, _ string, _ ...ListOptions) ([]*Sandbox, error) { return nil, nil } +func (m *mockSandboxResolver) ListAll(_ context.Context, _ ...ListOptions) ([]*Sandbox, error) { + return nil, nil +} func (m *mockSandboxResolver) Delete(_ context.Context, _, _ string) error { return nil } func (m *mockSandboxResolver) AttachProvider(_ context.Context, _, _, _ string, _ uint64) (*AttachProviderResult, error) { return nil, nil @@ -220,7 +224,7 @@ func TestSSHCreateSession(t *testing.T) { require.NoError(t, err) require.NotNil(t, session) - assert.Equal(t, "my-sandbox", session.SandboxID) + assert.Equal(t, "id-my-sandbox", session.SandboxID) assert.Equal(t, "tok-my-sandbox", session.Token) assert.Equal(t, "gw.example.com", session.GatewayHost) assert.Equal(t, uint32(2222), session.GatewayPort) @@ -327,7 +331,7 @@ func TestSSHTunnel_Success(t *testing.T) { mock.mu.Unlock() require.NotNil(t, init) - assert.Equal(t, "sb-123", init.GetSandboxId()) + assert.Equal(t, "my-sandbox", init.GetSandboxName()) assert.NotEmpty(t, init.GetAuthorizationToken()) assert.NotNil(t, init.GetSsh(), "target should be SshRelayTarget") } diff --git a/sdk/go/openshell/v1/tcp_client.go b/sdk/go/openshell/v1/tcp_client.go index e0795a9682..12e137634e 100644 --- a/sdk/go/openshell/v1/tcp_client.go +++ b/sdk/go/openshell/v1/tcp_client.go @@ -37,9 +37,7 @@ func (t *tcpClient) Forward(ctx context.Context, workspace, sandboxName string, Message: fmt.Sprintf("port must be in range 1-65535, got %d", port), } } - - sb, err := t.sandboxes.Get(ctx, workspace, sandboxName) - if err != nil { + if _, err := t.sandboxes.Get(ctx, workspace, sandboxName); err != nil { return nil, err } @@ -56,8 +54,9 @@ func (t *tcpClient) Forward(ctx context.Context, workspace, sandboxName string, initFrame := &pb.TcpForwardFrame{ Payload: &pb.TcpForwardFrame_Init{ Init: &pb.TcpForwardInit{ - SandboxId: sb.ID, - ServiceId: cfg.serviceID, + SandboxName: sandboxName, + WorkspaceScope: namedWorkspaceScope(workspace), + ServiceId: cfg.serviceID, Target: &pb.TcpForwardInit_Tcp{ Tcp: &pb.TcpRelayTarget{ Host: "127.0.0.1", diff --git a/sdk/go/openshell/v1/tcp_client_test.go b/sdk/go/openshell/v1/tcp_client_test.go index e445ab2fab..4785301271 100644 --- a/sdk/go/openshell/v1/tcp_client_test.go +++ b/sdk/go/openshell/v1/tcp_client_test.go @@ -130,7 +130,7 @@ func TestTCPForward_InitFrame(t *testing.T) { mock.mu.Unlock() require.NotNil(t, init) - assert.Equal(t, "sb-my-sandbox", init.GetSandboxId()) + assert.Equal(t, "my-sandbox", init.GetSandboxName()) assert.Empty(t, init.GetServiceId(), "service_id should be empty per FR-007a") assert.Empty(t, init.GetAuthorizationToken()) @@ -336,7 +336,7 @@ func TestTCPForward_WithServiceID(t *testing.T) { require.NotNil(t, init) assert.Equal(t, "audit-svc", init.GetServiceId()) - assert.Equal(t, "sb-my-sandbox", init.GetSandboxId()) + assert.Equal(t, "my-sandbox", init.GetSandboxName()) } func TestTCPForward_WithoutOptions_BackwardCompat(t *testing.T) { @@ -393,7 +393,7 @@ func TestTCPForward_ServerError(t *testing.T) { // --- Name-to-ID resolution tests --- -func TestTCPForward_ResolvesNameToID(t *testing.T) { +func TestTCPForward_UsesName(t *testing.T) { mock := newMockTCPServer() client, cleanup := setupTCPTest(t, mock) defer cleanup() @@ -416,7 +416,7 @@ func TestTCPForward_ResolvesNameToID(t *testing.T) { require.NotNil(t, init) // stubSandboxResolver returns ID "sb-" — verify the proto has the resolved ID, not the name - assert.Equal(t, "sb-my-sandbox", init.GetSandboxId(), "Forward should send resolved sandbox ID, not the name") + assert.Equal(t, "my-sandbox", init.GetSandboxName()) } func TestTCPForward_ResolutionError(t *testing.T) { @@ -999,6 +999,9 @@ func (r *flippableResolver) Create(context.Context, string, string, *SandboxSpec func (r *flippableResolver) List(context.Context, string, ...ListOptions) ([]*Sandbox, error) { panic("not implemented") } +func (r *flippableResolver) ListAll(context.Context, ...ListOptions) ([]*Sandbox, error) { + panic("not implemented") +} func (r *flippableResolver) Delete(context.Context, string, string) error { panic("not implemented") } diff --git a/sdk/go/openshell/v1/types/options.go b/sdk/go/openshell/v1/types/options.go index b0f6145999..0d9136a407 100644 --- a/sdk/go/openshell/v1/types/options.go +++ b/sdk/go/openshell/v1/types/options.go @@ -15,7 +15,6 @@ type ListOptions struct { Limit int Offset int LabelSelector string - AllWorkspaces bool } // WatchOptions configures watch behavior. diff --git a/sdk/go/openshell/v1/workspace_scope.go b/sdk/go/openshell/v1/workspace_scope.go new file mode 100644 index 0000000000..0ae2e8d52e --- /dev/null +++ b/sdk/go/openshell/v1/workspace_scope.go @@ -0,0 +1,18 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package v1 + +import dm "github.com/NVIDIA/OpenShell/sdk/go/proto/datamodelv1" + +func namedWorkspaceScope(workspace string) *dm.WorkspaceSelector { + return &dm.WorkspaceSelector{ + Selection: &dm.WorkspaceSelector_Workspace{Workspace: workspace}, + } +} + +func allWorkspacesScope() *dm.WorkspaceSelector { + return &dm.WorkspaceSelector{ + Selection: &dm.WorkspaceSelector_AllWorkspaces{AllWorkspaces: &dm.AllWorkspaces{}}, + } +} diff --git a/sdk/go/proto/datamodelv1/datamodel.pb.go b/sdk/go/proto/datamodelv1/datamodel.pb.go index a672bf3d8b..1b9eec16b4 100644 --- a/sdk/go/proto/datamodelv1/datamodel.pb.go +++ b/sdk/go/proto/datamodelv1/datamodel.pb.go @@ -75,6 +75,135 @@ func (WorkspacePhase) EnumDescriptor() ([]byte, []int) { return file_datamodel_proto_rawDescGZIP(), []int{0} } +// Selects the workspace scope for a public API request. +// +// Requests that operate on one workspace require a non-empty `workspace`. +// Cross-workspace list requests additionally accept `all_workspaces`. The +// containing request documents which selections it supports; an omitted +// selector is invalid for workspace-scoped operations. +type WorkspaceSelector struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Types that are valid to be assigned to Selection: + // + // *WorkspaceSelector_Workspace + // *WorkspaceSelector_AllWorkspaces + Selection isWorkspaceSelector_Selection `protobuf_oneof:"selection"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *WorkspaceSelector) Reset() { + *x = WorkspaceSelector{} + mi := &file_datamodel_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *WorkspaceSelector) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*WorkspaceSelector) ProtoMessage() {} + +func (x *WorkspaceSelector) ProtoReflect() protoreflect.Message { + mi := &file_datamodel_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use WorkspaceSelector.ProtoReflect.Descriptor instead. +func (*WorkspaceSelector) Descriptor() ([]byte, []int) { + return file_datamodel_proto_rawDescGZIP(), []int{0} +} + +func (x *WorkspaceSelector) GetSelection() isWorkspaceSelector_Selection { + if x != nil { + return x.Selection + } + return nil +} + +func (x *WorkspaceSelector) GetWorkspace() string { + if x != nil { + if x, ok := x.Selection.(*WorkspaceSelector_Workspace); ok { + return x.Workspace + } + } + return "" +} + +func (x *WorkspaceSelector) GetAllWorkspaces() *AllWorkspaces { + if x != nil { + if x, ok := x.Selection.(*WorkspaceSelector_AllWorkspaces); ok { + return x.AllWorkspaces + } + } + return nil +} + +type isWorkspaceSelector_Selection interface { + isWorkspaceSelector_Selection() +} + +type WorkspaceSelector_Workspace struct { + // One explicitly named workspace. Use `default` to select the gateway's + // default workspace; an empty name is invalid. + Workspace string `protobuf:"bytes,1,opt,name=workspace,proto3,oneof"` +} + +type WorkspaceSelector_AllWorkspaces struct { + // All workspaces the caller is authorized to access. Only supported by + // requests that explicitly document cross-workspace behavior. + AllWorkspaces *AllWorkspaces `protobuf:"bytes,2,opt,name=all_workspaces,json=allWorkspaces,proto3,oneof"` +} + +func (*WorkspaceSelector_Workspace) isWorkspaceSelector_Selection() {} + +func (*WorkspaceSelector_AllWorkspaces) isWorkspaceSelector_Selection() {} + +// Marker for the all-workspaces selector variant. +type AllWorkspaces struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AllWorkspaces) Reset() { + *x = AllWorkspaces{} + mi := &file_datamodel_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AllWorkspaces) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AllWorkspaces) ProtoMessage() {} + +func (x *AllWorkspaces) ProtoReflect() protoreflect.Message { + mi := &file_datamodel_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AllWorkspaces.ProtoReflect.Descriptor instead. +func (*AllWorkspaces) Descriptor() ([]byte, []int) { + return file_datamodel_proto_rawDescGZIP(), []int{1} +} + // Kubernetes-style metadata shared by all top-level OpenShell domain objects. // // This structure provides consistent metadata (identity, labels, annotations, @@ -110,7 +239,7 @@ type ObjectMeta struct { func (x *ObjectMeta) Reset() { *x = ObjectMeta{} - mi := &file_datamodel_proto_msgTypes[0] + mi := &file_datamodel_proto_msgTypes[2] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -122,7 +251,7 @@ func (x *ObjectMeta) String() string { func (*ObjectMeta) ProtoMessage() {} func (x *ObjectMeta) ProtoReflect() protoreflect.Message { - mi := &file_datamodel_proto_msgTypes[0] + mi := &file_datamodel_proto_msgTypes[2] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -135,7 +264,7 @@ func (x *ObjectMeta) ProtoReflect() protoreflect.Message { // Deprecated: Use ObjectMeta.ProtoReflect.Descriptor instead. func (*ObjectMeta) Descriptor() ([]byte, []int) { - return file_datamodel_proto_rawDescGZIP(), []int{0} + return file_datamodel_proto_rawDescGZIP(), []int{2} } func (x *ObjectMeta) GetId() string { @@ -204,7 +333,7 @@ type WorkspaceStatus struct { func (x *WorkspaceStatus) Reset() { *x = WorkspaceStatus{} - mi := &file_datamodel_proto_msgTypes[1] + mi := &file_datamodel_proto_msgTypes[3] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -216,7 +345,7 @@ func (x *WorkspaceStatus) String() string { func (*WorkspaceStatus) ProtoMessage() {} func (x *WorkspaceStatus) ProtoReflect() protoreflect.Message { - mi := &file_datamodel_proto_msgTypes[1] + mi := &file_datamodel_proto_msgTypes[3] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -229,7 +358,7 @@ func (x *WorkspaceStatus) ProtoReflect() protoreflect.Message { // Deprecated: Use WorkspaceStatus.ProtoReflect.Descriptor instead. func (*WorkspaceStatus) Descriptor() ([]byte, []int) { - return file_datamodel_proto_rawDescGZIP(), []int{1} + return file_datamodel_proto_rawDescGZIP(), []int{3} } func (x *WorkspaceStatus) GetPhase() WorkspacePhase { @@ -255,7 +384,7 @@ type Workspace struct { func (x *Workspace) Reset() { *x = Workspace{} - mi := &file_datamodel_proto_msgTypes[2] + mi := &file_datamodel_proto_msgTypes[4] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -267,7 +396,7 @@ func (x *Workspace) String() string { func (*Workspace) ProtoMessage() {} func (x *Workspace) ProtoReflect() protoreflect.Message { - mi := &file_datamodel_proto_msgTypes[2] + mi := &file_datamodel_proto_msgTypes[4] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -280,7 +409,7 @@ func (x *Workspace) ProtoReflect() protoreflect.Message { // Deprecated: Use Workspace.ProtoReflect.Descriptor instead. func (*Workspace) Descriptor() ([]byte, []int) { - return file_datamodel_proto_rawDescGZIP(), []int{2} + return file_datamodel_proto_rawDescGZIP(), []int{4} } func (x *Workspace) GetMetadata() *ObjectMeta { @@ -313,7 +442,7 @@ type CredentialHandle struct { func (x *CredentialHandle) Reset() { *x = CredentialHandle{} - mi := &file_datamodel_proto_msgTypes[3] + mi := &file_datamodel_proto_msgTypes[5] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -325,7 +454,7 @@ func (x *CredentialHandle) String() string { func (*CredentialHandle) ProtoMessage() {} func (x *CredentialHandle) ProtoReflect() protoreflect.Message { - mi := &file_datamodel_proto_msgTypes[3] + mi := &file_datamodel_proto_msgTypes[5] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -338,7 +467,7 @@ func (x *CredentialHandle) ProtoReflect() protoreflect.Message { // Deprecated: Use CredentialHandle.ProtoReflect.Descriptor instead. func (*CredentialHandle) Descriptor() ([]byte, []int) { - return file_datamodel_proto_rawDescGZIP(), []int{3} + return file_datamodel_proto_rawDescGZIP(), []int{5} } func (x *CredentialHandle) GetDriver() string { @@ -389,7 +518,7 @@ type Provider struct { func (x *Provider) Reset() { *x = Provider{} - mi := &file_datamodel_proto_msgTypes[4] + mi := &file_datamodel_proto_msgTypes[6] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -401,7 +530,7 @@ func (x *Provider) String() string { func (*Provider) ProtoMessage() {} func (x *Provider) ProtoReflect() protoreflect.Message { - mi := &file_datamodel_proto_msgTypes[4] + mi := &file_datamodel_proto_msgTypes[6] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -414,7 +543,7 @@ func (x *Provider) ProtoReflect() protoreflect.Message { // Deprecated: Use Provider.ProtoReflect.Descriptor instead. func (*Provider) Descriptor() ([]byte, []int) { - return file_datamodel_proto_rawDescGZIP(), []int{4} + return file_datamodel_proto_rawDescGZIP(), []int{6} } func (x *Provider) GetMetadata() *ObjectMeta { @@ -470,7 +599,12 @@ var File_datamodel_proto protoreflect.FileDescriptor const file_datamodel_proto_rawDesc = "" + "\n" + - "\x0fdatamodel.proto\x12\x16openshell.datamodel.v1\x1a\roptions.proto\"\xeb\x03\n" + + "\x0fdatamodel.proto\x12\x16openshell.datamodel.v1\x1a\roptions.proto\"\x90\x01\n" + + "\x11WorkspaceSelector\x12\x1e\n" + + "\tworkspace\x18\x01 \x01(\tH\x00R\tworkspace\x12N\n" + + "\x0eall_workspaces\x18\x02 \x01(\v2%.openshell.datamodel.v1.AllWorkspacesH\x00R\rallWorkspacesB\v\n" + + "\tselection\"\x0f\n" + + "\rAllWorkspaces\"\xeb\x03\n" + "\n" + "ObjectMeta\x12\x0e\n" + "\x02id\x18\x01 \x01(\tR\x02id\x12\x12\n" + @@ -537,40 +671,43 @@ func file_datamodel_proto_rawDescGZIP() []byte { } var file_datamodel_proto_enumTypes = make([]protoimpl.EnumInfo, 1) -var file_datamodel_proto_msgTypes = make([]protoimpl.MessageInfo, 12) +var file_datamodel_proto_msgTypes = make([]protoimpl.MessageInfo, 14) var file_datamodel_proto_goTypes = []any{ - (WorkspacePhase)(0), // 0: openshell.datamodel.v1.WorkspacePhase - (*ObjectMeta)(nil), // 1: openshell.datamodel.v1.ObjectMeta - (*WorkspaceStatus)(nil), // 2: openshell.datamodel.v1.WorkspaceStatus - (*Workspace)(nil), // 3: openshell.datamodel.v1.Workspace - (*CredentialHandle)(nil), // 4: openshell.datamodel.v1.CredentialHandle - (*Provider)(nil), // 5: openshell.datamodel.v1.Provider - nil, // 6: openshell.datamodel.v1.ObjectMeta.LabelsEntry - nil, // 7: openshell.datamodel.v1.ObjectMeta.AnnotationsEntry - nil, // 8: openshell.datamodel.v1.CredentialHandle.MetadataEntry - nil, // 9: openshell.datamodel.v1.Provider.CredentialsEntry - nil, // 10: openshell.datamodel.v1.Provider.ConfigEntry - nil, // 11: openshell.datamodel.v1.Provider.CredentialExpiresAtMsEntry - nil, // 12: openshell.datamodel.v1.Provider.CredentialHandlesEntry + (WorkspacePhase)(0), // 0: openshell.datamodel.v1.WorkspacePhase + (*WorkspaceSelector)(nil), // 1: openshell.datamodel.v1.WorkspaceSelector + (*AllWorkspaces)(nil), // 2: openshell.datamodel.v1.AllWorkspaces + (*ObjectMeta)(nil), // 3: openshell.datamodel.v1.ObjectMeta + (*WorkspaceStatus)(nil), // 4: openshell.datamodel.v1.WorkspaceStatus + (*Workspace)(nil), // 5: openshell.datamodel.v1.Workspace + (*CredentialHandle)(nil), // 6: openshell.datamodel.v1.CredentialHandle + (*Provider)(nil), // 7: openshell.datamodel.v1.Provider + nil, // 8: openshell.datamodel.v1.ObjectMeta.LabelsEntry + nil, // 9: openshell.datamodel.v1.ObjectMeta.AnnotationsEntry + nil, // 10: openshell.datamodel.v1.CredentialHandle.MetadataEntry + nil, // 11: openshell.datamodel.v1.Provider.CredentialsEntry + nil, // 12: openshell.datamodel.v1.Provider.ConfigEntry + nil, // 13: openshell.datamodel.v1.Provider.CredentialExpiresAtMsEntry + nil, // 14: openshell.datamodel.v1.Provider.CredentialHandlesEntry } var file_datamodel_proto_depIdxs = []int32{ - 6, // 0: openshell.datamodel.v1.ObjectMeta.labels:type_name -> openshell.datamodel.v1.ObjectMeta.LabelsEntry - 7, // 1: openshell.datamodel.v1.ObjectMeta.annotations:type_name -> openshell.datamodel.v1.ObjectMeta.AnnotationsEntry - 0, // 2: openshell.datamodel.v1.WorkspaceStatus.phase:type_name -> openshell.datamodel.v1.WorkspacePhase - 1, // 3: openshell.datamodel.v1.Workspace.metadata:type_name -> openshell.datamodel.v1.ObjectMeta - 2, // 4: openshell.datamodel.v1.Workspace.status:type_name -> openshell.datamodel.v1.WorkspaceStatus - 8, // 5: openshell.datamodel.v1.CredentialHandle.metadata:type_name -> openshell.datamodel.v1.CredentialHandle.MetadataEntry - 1, // 6: openshell.datamodel.v1.Provider.metadata:type_name -> openshell.datamodel.v1.ObjectMeta - 9, // 7: openshell.datamodel.v1.Provider.credentials:type_name -> openshell.datamodel.v1.Provider.CredentialsEntry - 10, // 8: openshell.datamodel.v1.Provider.config:type_name -> openshell.datamodel.v1.Provider.ConfigEntry - 11, // 9: openshell.datamodel.v1.Provider.credential_expires_at_ms:type_name -> openshell.datamodel.v1.Provider.CredentialExpiresAtMsEntry - 12, // 10: openshell.datamodel.v1.Provider.credential_handles:type_name -> openshell.datamodel.v1.Provider.CredentialHandlesEntry - 4, // 11: openshell.datamodel.v1.Provider.CredentialHandlesEntry.value:type_name -> openshell.datamodel.v1.CredentialHandle - 12, // [12:12] is the sub-list for method output_type - 12, // [12:12] is the sub-list for method input_type - 12, // [12:12] is the sub-list for extension type_name - 12, // [12:12] is the sub-list for extension extendee - 0, // [0:12] is the sub-list for field type_name + 2, // 0: openshell.datamodel.v1.WorkspaceSelector.all_workspaces:type_name -> openshell.datamodel.v1.AllWorkspaces + 8, // 1: openshell.datamodel.v1.ObjectMeta.labels:type_name -> openshell.datamodel.v1.ObjectMeta.LabelsEntry + 9, // 2: openshell.datamodel.v1.ObjectMeta.annotations:type_name -> openshell.datamodel.v1.ObjectMeta.AnnotationsEntry + 0, // 3: openshell.datamodel.v1.WorkspaceStatus.phase:type_name -> openshell.datamodel.v1.WorkspacePhase + 3, // 4: openshell.datamodel.v1.Workspace.metadata:type_name -> openshell.datamodel.v1.ObjectMeta + 4, // 5: openshell.datamodel.v1.Workspace.status:type_name -> openshell.datamodel.v1.WorkspaceStatus + 10, // 6: openshell.datamodel.v1.CredentialHandle.metadata:type_name -> openshell.datamodel.v1.CredentialHandle.MetadataEntry + 3, // 7: openshell.datamodel.v1.Provider.metadata:type_name -> openshell.datamodel.v1.ObjectMeta + 11, // 8: openshell.datamodel.v1.Provider.credentials:type_name -> openshell.datamodel.v1.Provider.CredentialsEntry + 12, // 9: openshell.datamodel.v1.Provider.config:type_name -> openshell.datamodel.v1.Provider.ConfigEntry + 13, // 10: openshell.datamodel.v1.Provider.credential_expires_at_ms:type_name -> openshell.datamodel.v1.Provider.CredentialExpiresAtMsEntry + 14, // 11: openshell.datamodel.v1.Provider.credential_handles:type_name -> openshell.datamodel.v1.Provider.CredentialHandlesEntry + 6, // 12: openshell.datamodel.v1.Provider.CredentialHandlesEntry.value:type_name -> openshell.datamodel.v1.CredentialHandle + 13, // [13:13] is the sub-list for method output_type + 13, // [13:13] is the sub-list for method input_type + 13, // [13:13] is the sub-list for extension type_name + 13, // [13:13] is the sub-list for extension extendee + 0, // [0:13] is the sub-list for field type_name } func init() { file_datamodel_proto_init() } @@ -578,13 +715,17 @@ func file_datamodel_proto_init() { if File_datamodel_proto != nil { return } + file_datamodel_proto_msgTypes[0].OneofWrappers = []any{ + (*WorkspaceSelector_Workspace)(nil), + (*WorkspaceSelector_AllWorkspaces)(nil), + } type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_datamodel_proto_rawDesc), len(file_datamodel_proto_rawDesc)), NumEnums: 1, - NumMessages: 12, + NumMessages: 14, NumExtensions: 0, NumServices: 0, }, diff --git a/sdk/go/proto/openshellv1/openshell.pb.go b/sdk/go/proto/openshellv1/openshell.pb.go index 3c977cbb76..d604de4cec 100644 --- a/sdk/go/proto/openshellv1/openshell.pb.go +++ b/sdk/go/proto/openshellv1/openshell.pb.go @@ -2477,16 +2477,16 @@ type CreateSandboxRequest struct { Labels map[string]string `protobuf:"bytes,3,rep,name=labels,proto3" json:"labels,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` // Optional annotations for the sandbox (non-selector metadata). Annotations map[string]string `protobuf:"bytes,4,rep,name=annotations,proto3" json:"annotations,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` - // Workspace for the sandbox. Empty defaults to "default". - Workspace string `protobuf:"bytes,5,opt,name=workspace,proto3" json:"workspace,omitempty"` // One-shot launch hint indicating that the creating client will attach to // the canonical main process. The supervisor keeps the terminal transport // alive until that attachment connects and closes naturally. AwaitMainProcessAttachment bool `protobuf:"varint,6,opt,name=await_main_process_attachment,json=awaitMainProcessAttachment,proto3" json:"await_main_process_attachment,omitempty"` // Workspace-scoped SandboxWorkloadTemplate name to resolve at creation time. WorkloadTemplateName string `protobuf:"bytes,7,opt,name=workload_template_name,json=workloadTemplateName,proto3" json:"workload_template_name,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // Explicit workspace for the sandbox. The all-workspaces selection is invalid. + WorkspaceScope *datamodelv1.WorkspaceSelector `protobuf:"bytes,8,opt,name=workspace_scope,json=workspaceScope,proto3" json:"workspace_scope,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *CreateSandboxRequest) Reset() { @@ -2547,13 +2547,6 @@ func (x *CreateSandboxRequest) GetAnnotations() map[string]string { return nil } -func (x *CreateSandboxRequest) GetWorkspace() string { - if x != nil { - return x.Workspace - } - return "" -} - func (x *CreateSandboxRequest) GetAwaitMainProcessAttachment() bool { if x != nil { return x.AwaitMainProcessAttachment @@ -2568,13 +2561,20 @@ func (x *CreateSandboxRequest) GetWorkloadTemplateName() string { return "" } +func (x *CreateSandboxRequest) GetWorkspaceScope() *datamodelv1.WorkspaceSelector { + if x != nil { + return x.WorkspaceScope + } + return nil +} + type CreateSandboxTemplateRequest struct { state protoimpl.MessageState `protogen:"open.v1"` Template *SandboxWorkloadTemplate `protobuf:"bytes,1,opt,name=template,proto3" json:"template,omitempty"` - // Workspace for the template. Empty defaults to "default". - Workspace string `protobuf:"bytes,2,opt,name=workspace,proto3" json:"workspace,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // Explicit workspace for the template. The all-workspaces selection is invalid. + WorkspaceScope *datamodelv1.WorkspaceSelector `protobuf:"bytes,3,opt,name=workspace_scope,json=workspaceScope,proto3" json:"workspace_scope,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *CreateSandboxTemplateRequest) Reset() { @@ -2614,20 +2614,20 @@ func (x *CreateSandboxTemplateRequest) GetTemplate() *SandboxWorkloadTemplate { return nil } -func (x *CreateSandboxTemplateRequest) GetWorkspace() string { +func (x *CreateSandboxTemplateRequest) GetWorkspaceScope() *datamodelv1.WorkspaceSelector { if x != nil { - return x.Workspace + return x.WorkspaceScope } - return "" + return nil } type GetSandboxTemplateRequest struct { state protoimpl.MessageState `protogen:"open.v1"` Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` - // Workspace scope. Empty defaults to "default". - Workspace string `protobuf:"bytes,2,opt,name=workspace,proto3" json:"workspace,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // Explicit workspace scope. The all-workspaces selection is invalid. + WorkspaceScope *datamodelv1.WorkspaceSelector `protobuf:"bytes,3,opt,name=workspace_scope,json=workspaceScope,proto3" json:"workspace_scope,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *GetSandboxTemplateRequest) Reset() { @@ -2667,25 +2667,23 @@ func (x *GetSandboxTemplateRequest) GetName() string { return "" } -func (x *GetSandboxTemplateRequest) GetWorkspace() string { +func (x *GetSandboxTemplateRequest) GetWorkspaceScope() *datamodelv1.WorkspaceSelector { if x != nil { - return x.Workspace + return x.WorkspaceScope } - return "" + return nil } type ListSandboxTemplatesRequest struct { state protoimpl.MessageState `protogen:"open.v1"` Limit uint32 `protobuf:"varint,1,opt,name=limit,proto3" json:"limit,omitempty"` Offset uint32 `protobuf:"varint,2,opt,name=offset,proto3" json:"offset,omitempty"` - // Workspace scope. Empty defaults to "default". - Workspace string `protobuf:"bytes,3,opt,name=workspace,proto3" json:"workspace,omitempty"` - // List across all workspaces. Mutually exclusive with workspace. - AllWorkspaces bool `protobuf:"varint,4,opt,name=all_workspaces,json=allWorkspaces,proto3" json:"all_workspaces,omitempty"` // Optional label selector in key=value comma-separated form. LabelSelector string `protobuf:"bytes,5,opt,name=label_selector,json=labelSelector,proto3" json:"label_selector,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // Explicit named or all-workspaces scope. + WorkspaceScope *datamodelv1.WorkspaceSelector `protobuf:"bytes,6,opt,name=workspace_scope,json=workspaceScope,proto3" json:"workspace_scope,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *ListSandboxTemplatesRequest) Reset() { @@ -2732,34 +2730,27 @@ func (x *ListSandboxTemplatesRequest) GetOffset() uint32 { return 0 } -func (x *ListSandboxTemplatesRequest) GetWorkspace() string { +func (x *ListSandboxTemplatesRequest) GetLabelSelector() string { if x != nil { - return x.Workspace + return x.LabelSelector } return "" } -func (x *ListSandboxTemplatesRequest) GetAllWorkspaces() bool { - if x != nil { - return x.AllWorkspaces - } - return false -} - -func (x *ListSandboxTemplatesRequest) GetLabelSelector() string { +func (x *ListSandboxTemplatesRequest) GetWorkspaceScope() *datamodelv1.WorkspaceSelector { if x != nil { - return x.LabelSelector + return x.WorkspaceScope } - return "" + return nil } type DeleteSandboxTemplateRequest struct { state protoimpl.MessageState `protogen:"open.v1"` Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` - // Workspace scope. Empty defaults to "default". - Workspace string `protobuf:"bytes,2,opt,name=workspace,proto3" json:"workspace,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // Explicit workspace scope. The all-workspaces selection is invalid. + WorkspaceScope *datamodelv1.WorkspaceSelector `protobuf:"bytes,3,opt,name=workspace_scope,json=workspaceScope,proto3" json:"workspace_scope,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *DeleteSandboxTemplateRequest) Reset() { @@ -2799,11 +2790,11 @@ func (x *DeleteSandboxTemplateRequest) GetName() string { return "" } -func (x *DeleteSandboxTemplateRequest) GetWorkspace() string { +func (x *DeleteSandboxTemplateRequest) GetWorkspaceScope() *datamodelv1.WorkspaceSelector { if x != nil { - return x.Workspace + return x.WorkspaceScope } - return "" + return nil } type SandboxTemplateResponse struct { @@ -2941,17 +2932,17 @@ func (x *DeleteSandboxTemplateResponse) GetDeleted() bool { // Request a gateway-owned staging slot for a local rootfs tar archive. type BeginRootfsTarStagingRequest struct { state protoimpl.MessageState `protogen:"open.v1"` - // Workspace that will own the sandbox created from this archive. Empty - // defaults to "default", matching CreateSandboxRequest.workspace. - Workspace string `protobuf:"bytes,1,opt,name=workspace,proto3" json:"workspace,omitempty"` // Base file name of the local archive. The gateway uses it only to name the // staged file; path separators and traversal components are rejected. FileName string `protobuf:"bytes,2,opt,name=file_name,json=fileName,proto3" json:"file_name,omitempty"` // Size of the local archive in bytes, checked against the driver limit // before the gateway allocates a slot. - SizeBytes uint64 `protobuf:"varint,3,opt,name=size_bytes,json=sizeBytes,proto3" json:"size_bytes,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + SizeBytes uint64 `protobuf:"varint,3,opt,name=size_bytes,json=sizeBytes,proto3" json:"size_bytes,omitempty"` + // Explicit workspace that will own the sandbox created from this archive. + // The all-workspaces selection is invalid. + WorkspaceScope *datamodelv1.WorkspaceSelector `protobuf:"bytes,4,opt,name=workspace_scope,json=workspaceScope,proto3" json:"workspace_scope,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *BeginRootfsTarStagingRequest) Reset() { @@ -2984,13 +2975,6 @@ func (*BeginRootfsTarStagingRequest) Descriptor() ([]byte, []int) { return file_openshell_proto_rawDescGZIP(), []int{39} } -func (x *BeginRootfsTarStagingRequest) GetWorkspace() string { - if x != nil { - return x.Workspace - } - return "" -} - func (x *BeginRootfsTarStagingRequest) GetFileName() string { if x != nil { return x.FileName @@ -3005,6 +2989,13 @@ func (x *BeginRootfsTarStagingRequest) GetSizeBytes() uint64 { return 0 } +func (x *BeginRootfsTarStagingRequest) GetWorkspaceScope() *datamodelv1.WorkspaceSelector { + if x != nil { + return x.WorkspaceScope + } + return nil +} + // Gateway-issued staging slot. type BeginRootfsTarStagingResponse struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -3082,13 +3073,11 @@ func (x *BeginRootfsTarStagingResponse) GetExpiresAtMs() int64 { // Get sandbox request. type GetSandboxRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - // Sandbox name (canonical lookup key). - Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` - // Workspace scope. Empty defaults to "default". - Workspace string `protobuf:"bytes,2,opt,name=workspace,proto3" json:"workspace,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + state protoimpl.MessageState `protogen:"open.v1"` + SandboxName string `protobuf:"bytes,1,opt,name=sandbox_name,json=sandboxName,proto3" json:"sandbox_name,omitempty"` + WorkspaceScope *datamodelv1.WorkspaceSelector `protobuf:"bytes,3,opt,name=workspace_scope,json=workspaceScope,proto3" json:"workspace_scope,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *GetSandboxRequest) Reset() { @@ -3121,18 +3110,18 @@ func (*GetSandboxRequest) Descriptor() ([]byte, []int) { return file_openshell_proto_rawDescGZIP(), []int{41} } -func (x *GetSandboxRequest) GetName() string { +func (x *GetSandboxRequest) GetSandboxName() string { if x != nil { - return x.Name + return x.SandboxName } return "" } -func (x *GetSandboxRequest) GetWorkspace() string { +func (x *GetSandboxRequest) GetWorkspaceScope() *datamodelv1.WorkspaceSelector { if x != nil { - return x.Workspace + return x.WorkspaceScope } - return "" + return nil } // List sandboxes request. @@ -3142,12 +3131,10 @@ type ListSandboxesRequest struct { Offset uint32 `protobuf:"varint,2,opt,name=offset,proto3" json:"offset,omitempty"` // Optional label selector for filtering (format: "key1=value1,key2=value2"). LabelSelector string `protobuf:"bytes,3,opt,name=label_selector,json=labelSelector,proto3" json:"label_selector,omitempty"` - // Workspace scope. Empty defaults to "default". - Workspace string `protobuf:"bytes,4,opt,name=workspace,proto3" json:"workspace,omitempty"` - // List across all workspaces. Mutually exclusive with workspace. - AllWorkspaces bool `protobuf:"varint,5,opt,name=all_workspaces,json=allWorkspaces,proto3" json:"all_workspaces,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // Explicit named or all-workspaces scope. + WorkspaceScope *datamodelv1.WorkspaceSelector `protobuf:"bytes,6,opt,name=workspace_scope,json=workspaceScope,proto3" json:"workspace_scope,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *ListSandboxesRequest) Reset() { @@ -3201,29 +3188,20 @@ func (x *ListSandboxesRequest) GetLabelSelector() string { return "" } -func (x *ListSandboxesRequest) GetWorkspace() string { +func (x *ListSandboxesRequest) GetWorkspaceScope() *datamodelv1.WorkspaceSelector { if x != nil { - return x.Workspace + return x.WorkspaceScope } - return "" -} - -func (x *ListSandboxesRequest) GetAllWorkspaces() bool { - if x != nil { - return x.AllWorkspaces - } - return false + return nil } // List providers attached to a sandbox request. type ListSandboxProvidersRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - // Sandbox name (canonical lookup key). - SandboxName string `protobuf:"bytes,1,opt,name=sandbox_name,json=sandboxName,proto3" json:"sandbox_name,omitempty"` - // Workspace scope. Empty defaults to "default". - Workspace string `protobuf:"bytes,2,opt,name=workspace,proto3" json:"workspace,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + state protoimpl.MessageState `protogen:"open.v1"` + SandboxName string `protobuf:"bytes,1,opt,name=sandbox_name,json=sandboxName,proto3" json:"sandbox_name,omitempty"` + WorkspaceScope *datamodelv1.WorkspaceSelector `protobuf:"bytes,3,opt,name=workspace_scope,json=workspaceScope,proto3" json:"workspace_scope,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *ListSandboxProvidersRequest) Reset() { @@ -3263,29 +3241,27 @@ func (x *ListSandboxProvidersRequest) GetSandboxName() string { return "" } -func (x *ListSandboxProvidersRequest) GetWorkspace() string { +func (x *ListSandboxProvidersRequest) GetWorkspaceScope() *datamodelv1.WorkspaceSelector { if x != nil { - return x.Workspace + return x.WorkspaceScope } - return "" + return nil } // Attach provider to sandbox request. type AttachSandboxProviderRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - // Sandbox name (canonical lookup key). - SandboxName string `protobuf:"bytes,1,opt,name=sandbox_name,json=sandboxName,proto3" json:"sandbox_name,omitempty"` + state protoimpl.MessageState `protogen:"open.v1"` + SandboxName string `protobuf:"bytes,1,opt,name=sandbox_name,json=sandboxName,proto3" json:"sandbox_name,omitempty"` // Provider name to attach. ProviderName string `protobuf:"bytes,2,opt,name=provider_name,json=providerName,proto3" json:"provider_name,omitempty"` // Expected resource version for optimistic concurrency control. // If 0, the server uses the current version (backward compatibility). // If non-zero, the server validates that the sandbox's current resource_version // matches this value before applying the mutation, returning ABORTED on mismatch. - ExpectedResourceVersion uint64 `protobuf:"varint,3,opt,name=expected_resource_version,json=expectedResourceVersion,proto3" json:"expected_resource_version,omitempty"` - // Workspace scope. Empty defaults to "default". - Workspace string `protobuf:"bytes,4,opt,name=workspace,proto3" json:"workspace,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + ExpectedResourceVersion uint64 `protobuf:"varint,3,opt,name=expected_resource_version,json=expectedResourceVersion,proto3" json:"expected_resource_version,omitempty"` + WorkspaceScope *datamodelv1.WorkspaceSelector `protobuf:"bytes,5,opt,name=workspace_scope,json=workspaceScope,proto3" json:"workspace_scope,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *AttachSandboxProviderRequest) Reset() { @@ -3339,29 +3315,27 @@ func (x *AttachSandboxProviderRequest) GetExpectedResourceVersion() uint64 { return 0 } -func (x *AttachSandboxProviderRequest) GetWorkspace() string { +func (x *AttachSandboxProviderRequest) GetWorkspaceScope() *datamodelv1.WorkspaceSelector { if x != nil { - return x.Workspace + return x.WorkspaceScope } - return "" + return nil } // Detach provider from sandbox request. type DetachSandboxProviderRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - // Sandbox name (canonical lookup key). - SandboxName string `protobuf:"bytes,1,opt,name=sandbox_name,json=sandboxName,proto3" json:"sandbox_name,omitempty"` + state protoimpl.MessageState `protogen:"open.v1"` + SandboxName string `protobuf:"bytes,1,opt,name=sandbox_name,json=sandboxName,proto3" json:"sandbox_name,omitempty"` // Provider name to detach. ProviderName string `protobuf:"bytes,2,opt,name=provider_name,json=providerName,proto3" json:"provider_name,omitempty"` // Expected resource version for optimistic concurrency control. // If 0, the server uses the current version (backward compatibility). // If non-zero, the server validates that the sandbox's current resource_version // matches this value before applying the mutation, returning ABORTED on mismatch. - ExpectedResourceVersion uint64 `protobuf:"varint,3,opt,name=expected_resource_version,json=expectedResourceVersion,proto3" json:"expected_resource_version,omitempty"` - // Workspace scope. Empty defaults to "default". - Workspace string `protobuf:"bytes,4,opt,name=workspace,proto3" json:"workspace,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + ExpectedResourceVersion uint64 `protobuf:"varint,3,opt,name=expected_resource_version,json=expectedResourceVersion,proto3" json:"expected_resource_version,omitempty"` + WorkspaceScope *datamodelv1.WorkspaceSelector `protobuf:"bytes,5,opt,name=workspace_scope,json=workspaceScope,proto3" json:"workspace_scope,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *DetachSandboxProviderRequest) Reset() { @@ -3415,22 +3389,20 @@ func (x *DetachSandboxProviderRequest) GetExpectedResourceVersion() uint64 { return 0 } -func (x *DetachSandboxProviderRequest) GetWorkspace() string { +func (x *DetachSandboxProviderRequest) GetWorkspaceScope() *datamodelv1.WorkspaceSelector { if x != nil { - return x.Workspace + return x.WorkspaceScope } - return "" + return nil } // Delete sandbox request. type DeleteSandboxRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - // Sandbox name (canonical lookup key). - Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` - // Workspace scope. Empty defaults to "default". - Workspace string `protobuf:"bytes,2,opt,name=workspace,proto3" json:"workspace,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + state protoimpl.MessageState `protogen:"open.v1"` + SandboxName string `protobuf:"bytes,1,opt,name=sandbox_name,json=sandboxName,proto3" json:"sandbox_name,omitempty"` + WorkspaceScope *datamodelv1.WorkspaceSelector `protobuf:"bytes,3,opt,name=workspace_scope,json=workspaceScope,proto3" json:"workspace_scope,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *DeleteSandboxRequest) Reset() { @@ -3463,29 +3435,27 @@ func (*DeleteSandboxRequest) Descriptor() ([]byte, []int) { return file_openshell_proto_rawDescGZIP(), []int{46} } -func (x *DeleteSandboxRequest) GetName() string { +func (x *DeleteSandboxRequest) GetSandboxName() string { if x != nil { - return x.Name + return x.SandboxName } return "" } -func (x *DeleteSandboxRequest) GetWorkspace() string { +func (x *DeleteSandboxRequest) GetWorkspaceScope() *datamodelv1.WorkspaceSelector { if x != nil { - return x.Workspace + return x.WorkspaceScope } - return "" + return nil } // Stop sandbox request. type StopSandboxRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - // Sandbox name (canonical lookup key). - Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` - // Workspace scope. Empty defaults to "default". - Workspace string `protobuf:"bytes,2,opt,name=workspace,proto3" json:"workspace,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + state protoimpl.MessageState `protogen:"open.v1"` + SandboxName string `protobuf:"bytes,1,opt,name=sandbox_name,json=sandboxName,proto3" json:"sandbox_name,omitempty"` + WorkspaceScope *datamodelv1.WorkspaceSelector `protobuf:"bytes,3,opt,name=workspace_scope,json=workspaceScope,proto3" json:"workspace_scope,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *StopSandboxRequest) Reset() { @@ -3518,29 +3488,27 @@ func (*StopSandboxRequest) Descriptor() ([]byte, []int) { return file_openshell_proto_rawDescGZIP(), []int{47} } -func (x *StopSandboxRequest) GetName() string { +func (x *StopSandboxRequest) GetSandboxName() string { if x != nil { - return x.Name + return x.SandboxName } return "" } -func (x *StopSandboxRequest) GetWorkspace() string { +func (x *StopSandboxRequest) GetWorkspaceScope() *datamodelv1.WorkspaceSelector { if x != nil { - return x.Workspace + return x.WorkspaceScope } - return "" + return nil } // Start sandbox request. type StartSandboxRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - // Sandbox name (canonical lookup key). - Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` - // Workspace scope. Empty defaults to "default". - Workspace string `protobuf:"bytes,2,opt,name=workspace,proto3" json:"workspace,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + state protoimpl.MessageState `protogen:"open.v1"` + SandboxName string `protobuf:"bytes,1,opt,name=sandbox_name,json=sandboxName,proto3" json:"sandbox_name,omitempty"` + WorkspaceScope *datamodelv1.WorkspaceSelector `protobuf:"bytes,3,opt,name=workspace_scope,json=workspaceScope,proto3" json:"workspace_scope,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *StartSandboxRequest) Reset() { @@ -3573,18 +3541,18 @@ func (*StartSandboxRequest) Descriptor() ([]byte, []int) { return file_openshell_proto_rawDescGZIP(), []int{48} } -func (x *StartSandboxRequest) GetName() string { +func (x *StartSandboxRequest) GetSandboxName() string { if x != nil { - return x.Name + return x.SandboxName } return "" } -func (x *StartSandboxRequest) GetWorkspace() string { +func (x *StartSandboxRequest) GetWorkspaceScope() *datamodelv1.WorkspaceSelector { if x != nil { - return x.Workspace + return x.WorkspaceScope } - return "" + return nil } // Sandbox response. @@ -3877,11 +3845,11 @@ func (x *DeleteSandboxResponse) GetDeleted() bool { // Create SSH session request. type CreateSshSessionRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - // Sandbox id. - SandboxId string `protobuf:"bytes,1,opt,name=sandbox_id,json=sandboxId,proto3" json:"sandbox_id,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + state protoimpl.MessageState `protogen:"open.v1"` + SandboxName string `protobuf:"bytes,2,opt,name=sandbox_name,json=sandboxName,proto3" json:"sandbox_name,omitempty"` + WorkspaceScope *datamodelv1.WorkspaceSelector `protobuf:"bytes,3,opt,name=workspace_scope,json=workspaceScope,proto3" json:"workspace_scope,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *CreateSshSessionRequest) Reset() { @@ -3914,13 +3882,20 @@ func (*CreateSshSessionRequest) Descriptor() ([]byte, []int) { return file_openshell_proto_rawDescGZIP(), []int{55} } -func (x *CreateSshSessionRequest) GetSandboxId() string { +func (x *CreateSshSessionRequest) GetSandboxName() string { if x != nil { - return x.SandboxId + return x.SandboxName } return "" } +func (x *CreateSshSessionRequest) GetWorkspaceScope() *datamodelv1.WorkspaceSelector { + if x != nil { + return x.WorkspaceScope + } + return nil +} + // Create SSH session response. // // Fields are interpolated into an SSH `ProxyCommand` string that OpenSSH @@ -4034,18 +4009,16 @@ func (x *CreateSshSessionResponse) GetExpiresAtMs() int64 { // Request to expose an HTTP service running inside a sandbox. type ExposeServiceRequest struct { state protoimpl.MessageState `protogen:"open.v1"` - // Sandbox name. - Sandbox string `protobuf:"bytes,1,opt,name=sandbox,proto3" json:"sandbox,omitempty"` // Service name within the sandbox. Service string `protobuf:"bytes,2,opt,name=service,proto3" json:"service,omitempty"` // Loopback TCP port inside the sandbox. TargetPort uint32 `protobuf:"varint,3,opt,name=target_port,json=targetPort,proto3" json:"target_port,omitempty"` // Whether to print/use the browser-facing service URL. - Domain bool `protobuf:"varint,4,opt,name=domain,proto3" json:"domain,omitempty"` - // Workspace scope. Empty defaults to "default". - Workspace string `protobuf:"bytes,5,opt,name=workspace,proto3" json:"workspace,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + Domain bool `protobuf:"varint,4,opt,name=domain,proto3" json:"domain,omitempty"` + SandboxName string `protobuf:"bytes,1,opt,name=sandbox_name,json=sandboxName,proto3" json:"sandbox_name,omitempty"` + WorkspaceScope *datamodelv1.WorkspaceSelector `protobuf:"bytes,6,opt,name=workspace_scope,json=workspaceScope,proto3" json:"workspace_scope,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *ExposeServiceRequest) Reset() { @@ -4078,13 +4051,6 @@ func (*ExposeServiceRequest) Descriptor() ([]byte, []int) { return file_openshell_proto_rawDescGZIP(), []int{57} } -func (x *ExposeServiceRequest) GetSandbox() string { - if x != nil { - return x.Sandbox - } - return "" -} - func (x *ExposeServiceRequest) GetService() string { if x != nil { return x.Service @@ -4106,24 +4072,29 @@ func (x *ExposeServiceRequest) GetDomain() bool { return false } -func (x *ExposeServiceRequest) GetWorkspace() string { +func (x *ExposeServiceRequest) GetSandboxName() string { if x != nil { - return x.Workspace + return x.SandboxName } return "" } +func (x *ExposeServiceRequest) GetWorkspaceScope() *datamodelv1.WorkspaceSelector { + if x != nil { + return x.WorkspaceScope + } + return nil +} + // Request to fetch an exposed sandbox service endpoint. type GetServiceRequest struct { state protoimpl.MessageState `protogen:"open.v1"` - // Sandbox name. - Sandbox string `protobuf:"bytes,1,opt,name=sandbox,proto3" json:"sandbox,omitempty"` // Service name within the sandbox. Empty selects the unnamed endpoint. - Service string `protobuf:"bytes,2,opt,name=service,proto3" json:"service,omitempty"` - // Workspace scope. Empty defaults to "default". - Workspace string `protobuf:"bytes,3,opt,name=workspace,proto3" json:"workspace,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + Service string `protobuf:"bytes,2,opt,name=service,proto3" json:"service,omitempty"` + SandboxName string `protobuf:"bytes,1,opt,name=sandbox_name,json=sandboxName,proto3" json:"sandbox_name,omitempty"` + WorkspaceScope *datamodelv1.WorkspaceSelector `protobuf:"bytes,4,opt,name=workspace_scope,json=workspaceScope,proto3" json:"workspace_scope,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *GetServiceRequest) Reset() { @@ -4156,40 +4127,38 @@ func (*GetServiceRequest) Descriptor() ([]byte, []int) { return file_openshell_proto_rawDescGZIP(), []int{58} } -func (x *GetServiceRequest) GetSandbox() string { +func (x *GetServiceRequest) GetService() string { if x != nil { - return x.Sandbox + return x.Service } return "" } -func (x *GetServiceRequest) GetService() string { +func (x *GetServiceRequest) GetSandboxName() string { if x != nil { - return x.Service + return x.SandboxName } return "" } -func (x *GetServiceRequest) GetWorkspace() string { +func (x *GetServiceRequest) GetWorkspaceScope() *datamodelv1.WorkspaceSelector { if x != nil { - return x.Workspace + return x.WorkspaceScope } - return "" + return nil } // Request to list exposed sandbox service endpoints. type ListServicesRequest struct { state protoimpl.MessageState `protogen:"open.v1"` - // Optional sandbox name. Empty lists endpoints for all sandboxes. - Sandbox string `protobuf:"bytes,1,opt,name=sandbox,proto3" json:"sandbox,omitempty"` // Page size. Zero uses the server default. Limit uint32 `protobuf:"varint,2,opt,name=limit,proto3" json:"limit,omitempty"` // Page offset. Offset uint32 `protobuf:"varint,3,opt,name=offset,proto3" json:"offset,omitempty"` - // Workspace scope. Empty defaults to "default". - Workspace string `protobuf:"bytes,4,opt,name=workspace,proto3" json:"workspace,omitempty"` - // List across all workspaces. Mutually exclusive with workspace. - AllWorkspaces bool `protobuf:"varint,5,opt,name=all_workspaces,json=allWorkspaces,proto3" json:"all_workspaces,omitempty"` + // Explicit named or all-workspaces scope. + WorkspaceScope *datamodelv1.WorkspaceSelector `protobuf:"bytes,6,opt,name=workspace_scope,json=workspaceScope,proto3" json:"workspace_scope,omitempty"` + // Optional sandbox name. Empty lists endpoints for all sandboxes. + SandboxName string `protobuf:"bytes,1,opt,name=sandbox_name,json=sandboxName,proto3" json:"sandbox_name,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -4224,13 +4193,6 @@ func (*ListServicesRequest) Descriptor() ([]byte, []int) { return file_openshell_proto_rawDescGZIP(), []int{59} } -func (x *ListServicesRequest) GetSandbox() string { - if x != nil { - return x.Sandbox - } - return "" -} - func (x *ListServicesRequest) GetLimit() uint32 { if x != nil { return x.Limit @@ -4245,18 +4207,18 @@ func (x *ListServicesRequest) GetOffset() uint32 { return 0 } -func (x *ListServicesRequest) GetWorkspace() string { +func (x *ListServicesRequest) GetWorkspaceScope() *datamodelv1.WorkspaceSelector { if x != nil { - return x.Workspace + return x.WorkspaceScope } - return "" + return nil } -func (x *ListServicesRequest) GetAllWorkspaces() bool { +func (x *ListServicesRequest) GetSandboxName() string { if x != nil { - return x.AllWorkspaces + return x.SandboxName } - return false + return "" } // Response containing exposed sandbox service endpoints. @@ -4307,14 +4269,12 @@ func (x *ListServicesResponse) GetServices() []*ServiceEndpointResponse { // Request to delete an exposed sandbox service endpoint. type DeleteServiceRequest struct { state protoimpl.MessageState `protogen:"open.v1"` - // Sandbox name. - Sandbox string `protobuf:"bytes,1,opt,name=sandbox,proto3" json:"sandbox,omitempty"` // Service name within the sandbox. Empty selects the unnamed endpoint. - Service string `protobuf:"bytes,2,opt,name=service,proto3" json:"service,omitempty"` - // Workspace scope. Empty defaults to "default". - Workspace string `protobuf:"bytes,3,opt,name=workspace,proto3" json:"workspace,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + Service string `protobuf:"bytes,2,opt,name=service,proto3" json:"service,omitempty"` + SandboxName string `protobuf:"bytes,1,opt,name=sandbox_name,json=sandboxName,proto3" json:"sandbox_name,omitempty"` + WorkspaceScope *datamodelv1.WorkspaceSelector `protobuf:"bytes,4,opt,name=workspace_scope,json=workspaceScope,proto3" json:"workspace_scope,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *DeleteServiceRequest) Reset() { @@ -4347,25 +4307,25 @@ func (*DeleteServiceRequest) Descriptor() ([]byte, []int) { return file_openshell_proto_rawDescGZIP(), []int{61} } -func (x *DeleteServiceRequest) GetSandbox() string { +func (x *DeleteServiceRequest) GetService() string { if x != nil { - return x.Sandbox + return x.Service } return "" } -func (x *DeleteServiceRequest) GetService() string { +func (x *DeleteServiceRequest) GetSandboxName() string { if x != nil { - return x.Service + return x.SandboxName } return "" } -func (x *DeleteServiceRequest) GetWorkspace() string { +func (x *DeleteServiceRequest) GetWorkspaceScope() *datamodelv1.WorkspaceSelector { if x != nil { - return x.Workspace + return x.WorkspaceScope } - return "" + return nil } // Response for deleting an exposed sandbox service endpoint. @@ -4653,8 +4613,6 @@ func (x *RevokeSshSessionResponse) GetRevoked() bool { // Execute command request. type ExecSandboxRequest struct { state protoimpl.MessageState `protogen:"open.v1"` - // Sandbox id. - SandboxId string `protobuf:"bytes,1,opt,name=sandbox_id,json=sandboxId,proto3" json:"sandbox_id,omitempty"` // Command and arguments. Command []string `protobuf:"bytes,2,rep,name=command,proto3" json:"command,omitempty"` // Optional working directory. @@ -4676,9 +4634,11 @@ type ExecSandboxRequest struct { // (`bash -lc`) so user startup files (.bash_profile/.profile, and .bashrc if // sourced by them) are applied. When true, the command runs without those // files (`bash -c`), for automation that needs predictable startup behavior. - NoLoginShell bool `protobuf:"varint,10,opt,name=no_login_shell,json=noLoginShell,proto3" json:"no_login_shell,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + NoLoginShell bool `protobuf:"varint,10,opt,name=no_login_shell,json=noLoginShell,proto3" json:"no_login_shell,omitempty"` + SandboxName string `protobuf:"bytes,11,opt,name=sandbox_name,json=sandboxName,proto3" json:"sandbox_name,omitempty"` + WorkspaceScope *datamodelv1.WorkspaceSelector `protobuf:"bytes,12,opt,name=workspace_scope,json=workspaceScope,proto3" json:"workspace_scope,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *ExecSandboxRequest) Reset() { @@ -4711,13 +4671,6 @@ func (*ExecSandboxRequest) Descriptor() ([]byte, []int) { return file_openshell_proto_rawDescGZIP(), []int{67} } -func (x *ExecSandboxRequest) GetSandboxId() string { - if x != nil { - return x.SandboxId - } - return "" -} - func (x *ExecSandboxRequest) GetCommand() []string { if x != nil { return x.Command @@ -4781,6 +4734,20 @@ func (x *ExecSandboxRequest) GetNoLoginShell() bool { return false } +func (x *ExecSandboxRequest) GetSandboxName() string { + if x != nil { + return x.SandboxName + } + return "" +} + +func (x *ExecSandboxRequest) GetWorkspaceScope() *datamodelv1.WorkspaceSelector { + if x != nil { + return x.WorkspaceScope + } + return nil +} + // One stdout chunk from a sandbox exec. type ExecSandboxStdout struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -5017,9 +4984,9 @@ func (*ExecSandboxEvent_Exit) isExecSandboxEvent_Payload() {} // Initial frame for one TCP forward stream. type TcpForwardInit struct { - state protoimpl.MessageState `protogen:"open.v1"` - // Sandbox id. - SandboxId string `protobuf:"bytes,1,opt,name=sandbox_id,json=sandboxId,proto3" json:"sandbox_id,omitempty"` + state protoimpl.MessageState `protogen:"open.v1"` + SandboxName string `protobuf:"bytes,2,opt,name=sandbox_name,json=sandboxName,proto3" json:"sandbox_name,omitempty"` + WorkspaceScope *datamodelv1.WorkspaceSelector `protobuf:"bytes,3,opt,name=workspace_scope,json=workspaceScope,proto3" json:"workspace_scope,omitempty"` // Optional service identifier for audit/correlation. ServiceId string `protobuf:"bytes,4,opt,name=service_id,json=serviceId,proto3" json:"service_id,omitempty"` // Target the gateway should request from the supervisor. @@ -5066,13 +5033,20 @@ func (*TcpForwardInit) Descriptor() ([]byte, []int) { return file_openshell_proto_rawDescGZIP(), []int{72} } -func (x *TcpForwardInit) GetSandboxId() string { +func (x *TcpForwardInit) GetSandboxName() string { if x != nil { - return x.SandboxId + return x.SandboxName } return "" } +func (x *TcpForwardInit) GetWorkspaceScope() *datamodelv1.WorkspaceSelector { + if x != nil { + return x.WorkspaceScope + } + return nil +} + func (x *TcpForwardInit) GetServiceId() string { if x != nil { return x.ServiceId @@ -5452,8 +5426,6 @@ func (x *SshSession) GetRevoked() bool { // Watch sandbox request. type WatchSandboxRequest struct { state protoimpl.MessageState `protogen:"open.v1"` - // Sandbox id. - Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` // Stream sandbox status snapshots. FollowStatus bool `protobuf:"varint,2,opt,name=follow_status,json=followStatus,proto3" json:"follow_status,omitempty"` // Stream openshell-server process logs correlated to this sandbox. @@ -5473,9 +5445,11 @@ type WatchSandboxRequest struct { // Filter by log source (e.g. "gateway", "sandbox"). Empty means all sources. LogSources []string `protobuf:"bytes,9,rep,name=log_sources,json=logSources,proto3" json:"log_sources,omitempty"` // Minimum log level to include (e.g. "INFO", "WARN", "ERROR"). Empty means all levels. - LogMinLevel string `protobuf:"bytes,10,opt,name=log_min_level,json=logMinLevel,proto3" json:"log_min_level,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + LogMinLevel string `protobuf:"bytes,10,opt,name=log_min_level,json=logMinLevel,proto3" json:"log_min_level,omitempty"` + SandboxName string `protobuf:"bytes,11,opt,name=sandbox_name,json=sandboxName,proto3" json:"sandbox_name,omitempty"` + WorkspaceScope *datamodelv1.WorkspaceSelector `protobuf:"bytes,12,opt,name=workspace_scope,json=workspaceScope,proto3" json:"workspace_scope,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *WatchSandboxRequest) Reset() { @@ -5508,13 +5482,6 @@ func (*WatchSandboxRequest) Descriptor() ([]byte, []int) { return file_openshell_proto_rawDescGZIP(), []int{77} } -func (x *WatchSandboxRequest) GetId() string { - if x != nil { - return x.Id - } - return "" -} - func (x *WatchSandboxRequest) GetFollowStatus() bool { if x != nil { return x.FollowStatus @@ -5578,6 +5545,20 @@ func (x *WatchSandboxRequest) GetLogMinLevel() string { return "" } +func (x *WatchSandboxRequest) GetSandboxName() string { + if x != nil { + return x.SandboxName + } + return "" +} + +func (x *WatchSandboxRequest) GetWorkspaceScope() *datamodelv1.WorkspaceSelector { + if x != nil { + return x.WorkspaceScope + } + return nil +} + // One event in a sandbox watch stream. type SandboxStreamEvent struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -5858,10 +5839,10 @@ func (x *SandboxStreamWarning) GetMessage() string { type CreateProviderRequest struct { state protoimpl.MessageState `protogen:"open.v1"` Provider *datamodelv1.Provider `protobuf:"bytes,1,opt,name=provider,proto3" json:"provider,omitempty"` - // Workspace for the provider. Empty defaults to "default". - Workspace string `protobuf:"bytes,2,opt,name=workspace,proto3" json:"workspace,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // Explicit workspace for the provider. The all-workspaces selection is invalid. + WorkspaceScope *datamodelv1.WorkspaceSelector `protobuf:"bytes,3,opt,name=workspace_scope,json=workspaceScope,proto3" json:"workspace_scope,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *CreateProviderRequest) Reset() { @@ -5901,21 +5882,21 @@ func (x *CreateProviderRequest) GetProvider() *datamodelv1.Provider { return nil } -func (x *CreateProviderRequest) GetWorkspace() string { +func (x *CreateProviderRequest) GetWorkspaceScope() *datamodelv1.WorkspaceSelector { if x != nil { - return x.Workspace + return x.WorkspaceScope } - return "" + return nil } // Get provider request. type GetProviderRequest struct { state protoimpl.MessageState `protogen:"open.v1"` Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` - // Workspace scope. Empty defaults to "default". - Workspace string `protobuf:"bytes,2,opt,name=workspace,proto3" json:"workspace,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // Explicit workspace scope. The all-workspaces selection is invalid. + WorkspaceScope *datamodelv1.WorkspaceSelector `protobuf:"bytes,3,opt,name=workspace_scope,json=workspaceScope,proto3" json:"workspace_scope,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *GetProviderRequest) Reset() { @@ -5955,11 +5936,11 @@ func (x *GetProviderRequest) GetName() string { return "" } -func (x *GetProviderRequest) GetWorkspace() string { +func (x *GetProviderRequest) GetWorkspaceScope() *datamodelv1.WorkspaceSelector { if x != nil { - return x.Workspace + return x.WorkspaceScope } - return "" + return nil } // List providers request. @@ -5967,12 +5948,10 @@ type ListProvidersRequest struct { state protoimpl.MessageState `protogen:"open.v1"` Limit uint32 `protobuf:"varint,1,opt,name=limit,proto3" json:"limit,omitempty"` Offset uint32 `protobuf:"varint,2,opt,name=offset,proto3" json:"offset,omitempty"` - // Workspace scope. Empty defaults to "default". - Workspace string `protobuf:"bytes,3,opt,name=workspace,proto3" json:"workspace,omitempty"` - // List across all workspaces. Mutually exclusive with workspace. - AllWorkspaces bool `protobuf:"varint,4,opt,name=all_workspaces,json=allWorkspaces,proto3" json:"all_workspaces,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // Explicit named or all-workspaces scope. + WorkspaceScope *datamodelv1.WorkspaceSelector `protobuf:"bytes,5,opt,name=workspace_scope,json=workspaceScope,proto3" json:"workspace_scope,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *ListProvidersRequest) Reset() { @@ -6019,18 +5998,11 @@ func (x *ListProvidersRequest) GetOffset() uint32 { return 0 } -func (x *ListProvidersRequest) GetWorkspace() string { +func (x *ListProvidersRequest) GetWorkspaceScope() *datamodelv1.WorkspaceSelector { if x != nil { - return x.Workspace + return x.WorkspaceScope } - return "" -} - -func (x *ListProvidersRequest) GetAllWorkspaces() bool { - if x != nil { - return x.AllWorkspaces - } - return false + return nil } // Update provider request. @@ -6040,10 +6012,10 @@ type UpdateProviderRequest struct { // Optional per-credential expiry timestamps to merge into the provider. // A zero value removes the expiry for that credential. CredentialExpiresAtMs map[string]int64 `protobuf:"bytes,2,rep,name=credential_expires_at_ms,json=credentialExpiresAtMs,proto3" json:"credential_expires_at_ms,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"varint,2,opt,name=value"` - // Workspace scope. Empty defaults to "default". - Workspace string `protobuf:"bytes,3,opt,name=workspace,proto3" json:"workspace,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // Explicit workspace scope. The all-workspaces selection is invalid. + WorkspaceScope *datamodelv1.WorkspaceSelector `protobuf:"bytes,4,opt,name=workspace_scope,json=workspaceScope,proto3" json:"workspace_scope,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *UpdateProviderRequest) Reset() { @@ -6090,21 +6062,21 @@ func (x *UpdateProviderRequest) GetCredentialExpiresAtMs() map[string]int64 { return nil } -func (x *UpdateProviderRequest) GetWorkspace() string { +func (x *UpdateProviderRequest) GetWorkspaceScope() *datamodelv1.WorkspaceSelector { if x != nil { - return x.Workspace + return x.WorkspaceScope } - return "" + return nil } // Delete provider request. type DeleteProviderRequest struct { state protoimpl.MessageState `protogen:"open.v1"` Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` - // Workspace scope. Empty defaults to "default". - Workspace string `protobuf:"bytes,2,opt,name=workspace,proto3" json:"workspace,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // Explicit workspace scope. The all-workspaces selection is invalid. + WorkspaceScope *datamodelv1.WorkspaceSelector `protobuf:"bytes,3,opt,name=workspace_scope,json=workspaceScope,proto3" json:"workspace_scope,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *DeleteProviderRequest) Reset() { @@ -6144,11 +6116,11 @@ func (x *DeleteProviderRequest) GetName() string { return "" } -func (x *DeleteProviderRequest) GetWorkspace() string { +func (x *DeleteProviderRequest) GetWorkspaceScope() *datamodelv1.WorkspaceSelector { if x != nil { - return x.Workspace + return x.WorkspaceScope } - return "" + return nil } // Provider response. @@ -7604,11 +7576,11 @@ func (x *StoredRefreshMaterialDeletion) GetHandle() *datamodelv1.CredentialHandl type GetProviderRefreshStatusRequest struct { state protoimpl.MessageState `protogen:"open.v1"` Provider string `protobuf:"bytes,1,opt,name=provider,proto3" json:"provider,omitempty"` - CredentialKey string `protobuf:"bytes,2,opt,name=credential_key,json=credentialKey,proto3" json:"credential_key,omitempty"` - // Workspace scope. Empty defaults to "default". - Workspace string `protobuf:"bytes,3,opt,name=workspace,proto3" json:"workspace,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + CredentialKey string `protobuf:"bytes,2,opt,name=credential_key,json=credentialKey,proto3" json:"credential_key,omitempty"` + // Explicit workspace scope. The all-workspaces selection is invalid. + WorkspaceScope *datamodelv1.WorkspaceSelector `protobuf:"bytes,4,opt,name=workspace_scope,json=workspaceScope,proto3" json:"workspace_scope,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *GetProviderRefreshStatusRequest) Reset() { @@ -7655,11 +7627,11 @@ func (x *GetProviderRefreshStatusRequest) GetCredentialKey() string { return "" } -func (x *GetProviderRefreshStatusRequest) GetWorkspace() string { +func (x *GetProviderRefreshStatusRequest) GetWorkspaceScope() *datamodelv1.WorkspaceSelector { if x != nil { - return x.Workspace + return x.WorkspaceScope } - return "" + return nil } type GetProviderRefreshStatusResponse struct { @@ -7717,10 +7689,10 @@ type ConfigureProviderRefreshRequest struct { // the authoritative provider profile and refresh strategy. SecretMaterialKeys []string `protobuf:"bytes,5,rep,name=secret_material_keys,json=secretMaterialKeys,proto3" json:"secret_material_keys,omitempty"` ExpiresAtMs *int64 `protobuf:"varint,6,opt,name=expires_at_ms,json=expiresAtMs,proto3,oneof" json:"expires_at_ms,omitempty"` - // Workspace scope. Empty defaults to "default". - Workspace string `protobuf:"bytes,7,opt,name=workspace,proto3" json:"workspace,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // Explicit workspace scope. The all-workspaces selection is invalid. + WorkspaceScope *datamodelv1.WorkspaceSelector `protobuf:"bytes,8,opt,name=workspace_scope,json=workspaceScope,proto3" json:"workspace_scope,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *ConfigureProviderRefreshRequest) Reset() { @@ -7795,11 +7767,11 @@ func (x *ConfigureProviderRefreshRequest) GetExpiresAtMs() int64 { return 0 } -func (x *ConfigureProviderRefreshRequest) GetWorkspace() string { +func (x *ConfigureProviderRefreshRequest) GetWorkspaceScope() *datamodelv1.WorkspaceSelector { if x != nil { - return x.Workspace + return x.WorkspaceScope } - return "" + return nil } type ConfigureProviderRefreshResponse struct { @@ -7850,10 +7822,10 @@ type RotateProviderCredentialRequest struct { state protoimpl.MessageState `protogen:"open.v1"` Provider string `protobuf:"bytes,1,opt,name=provider,proto3" json:"provider,omitempty"` CredentialKey string `protobuf:"bytes,2,opt,name=credential_key,json=credentialKey,proto3" json:"credential_key,omitempty"` - // Workspace scope. Empty defaults to "default". - Workspace string `protobuf:"bytes,3,opt,name=workspace,proto3" json:"workspace,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // Explicit workspace scope. The all-workspaces selection is invalid. + WorkspaceScope *datamodelv1.WorkspaceSelector `protobuf:"bytes,4,opt,name=workspace_scope,json=workspaceScope,proto3" json:"workspace_scope,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *RotateProviderCredentialRequest) Reset() { @@ -7900,11 +7872,11 @@ func (x *RotateProviderCredentialRequest) GetCredentialKey() string { return "" } -func (x *RotateProviderCredentialRequest) GetWorkspace() string { +func (x *RotateProviderCredentialRequest) GetWorkspaceScope() *datamodelv1.WorkspaceSelector { if x != nil { - return x.Workspace + return x.WorkspaceScope } - return "" + return nil } type RotateProviderCredentialResponse struct { @@ -7955,10 +7927,10 @@ type DeleteProviderRefreshRequest struct { state protoimpl.MessageState `protogen:"open.v1"` Provider string `protobuf:"bytes,1,opt,name=provider,proto3" json:"provider,omitempty"` CredentialKey string `protobuf:"bytes,2,opt,name=credential_key,json=credentialKey,proto3" json:"credential_key,omitempty"` - // Workspace scope. Empty defaults to "default". - Workspace string `protobuf:"bytes,3,opt,name=workspace,proto3" json:"workspace,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // Explicit workspace scope. The all-workspaces selection is invalid. + WorkspaceScope *datamodelv1.WorkspaceSelector `protobuf:"bytes,4,opt,name=workspace_scope,json=workspaceScope,proto3" json:"workspace_scope,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *DeleteProviderRefreshRequest) Reset() { @@ -8005,11 +7977,11 @@ func (x *DeleteProviderRefreshRequest) GetCredentialKey() string { return "" } -func (x *DeleteProviderRefreshRequest) GetWorkspace() string { +func (x *DeleteProviderRefreshRequest) GetWorkspaceScope() *datamodelv1.WorkspaceSelector { if x != nil { - return x.Workspace + return x.WorkspaceScope } - return "" + return nil } type DeleteProviderRefreshResponse struct { @@ -9274,9 +9246,6 @@ func (x *ExchangeProviderSubjectTokenResponse) GetTokenType() string { // Update sandbox policy request. type UpdateConfigRequest struct { state protoimpl.MessageState `protogen:"open.v1"` - // Sandbox name (canonical lookup key). Required for sandbox-scoped updates. - // Not required when `global=true`. - Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` // The new policy to apply. // // Sandbox scope (`global=false`): @@ -9310,10 +9279,11 @@ type UpdateConfigRequest struct { // sandbox metadata as a convenience projection. For setting-only updates, it // only merges them into sandbox metadata. Annotations map[string]string `protobuf:"bytes,9,rep,name=annotations,proto3" json:"annotations,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` - // Workspace scope. Empty defaults to "default". Ignored for global-scoped updates. - Workspace string `protobuf:"bytes,10,opt,name=workspace,proto3" json:"workspace,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // Required for sandbox-scoped updates and empty for global updates. + SandboxName string `protobuf:"bytes,1,opt,name=sandbox_name,json=sandboxName,proto3" json:"sandbox_name,omitempty"` + WorkspaceScope *datamodelv1.WorkspaceSelector `protobuf:"bytes,11,opt,name=workspace_scope,json=workspaceScope,proto3" json:"workspace_scope,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *UpdateConfigRequest) Reset() { @@ -9346,13 +9316,6 @@ func (*UpdateConfigRequest) Descriptor() ([]byte, []int) { return file_openshell_proto_rawDescGZIP(), []int{130} } -func (x *UpdateConfigRequest) GetName() string { - if x != nil { - return x.Name - } - return "" -} - func (x *UpdateConfigRequest) GetPolicy() *sandboxv1.SandboxPolicy { if x != nil { return x.Policy @@ -9409,13 +9372,20 @@ func (x *UpdateConfigRequest) GetAnnotations() map[string]string { return nil } -func (x *UpdateConfigRequest) GetWorkspace() string { +func (x *UpdateConfigRequest) GetSandboxName() string { if x != nil { - return x.Workspace + return x.SandboxName } return "" } +func (x *UpdateConfigRequest) GetWorkspaceScope() *datamodelv1.WorkspaceSelector { + if x != nil { + return x.WorkspaceScope + } + return nil +} + type PolicyMergeOperation struct { state protoimpl.MessageState `protogen:"open.v1"` // Types that are valid to be assigned to Operation: @@ -9975,16 +9945,14 @@ func (x *UpdateConfigResponse) GetAnnotations() map[string]string { // Get sandbox policy status request. type GetSandboxPolicyStatusRequest struct { state protoimpl.MessageState `protogen:"open.v1"` - // Sandbox name (canonical lookup key). Ignored when global is true. - Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` // The specific policy version to query. 0 means latest. Version uint32 `protobuf:"varint,2,opt,name=version,proto3" json:"version,omitempty"` // Query global policy revisions instead of a sandbox-scoped one. - Global bool `protobuf:"varint,3,opt,name=global,proto3" json:"global,omitempty"` - // Workspace scope. Empty defaults to "default". Ignored when global is true. - Workspace string `protobuf:"bytes,4,opt,name=workspace,proto3" json:"workspace,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + Global bool `protobuf:"varint,3,opt,name=global,proto3" json:"global,omitempty"` + SandboxName string `protobuf:"bytes,1,opt,name=sandbox_name,json=sandboxName,proto3" json:"sandbox_name,omitempty"` + WorkspaceScope *datamodelv1.WorkspaceSelector `protobuf:"bytes,5,opt,name=workspace_scope,json=workspaceScope,proto3" json:"workspace_scope,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *GetSandboxPolicyStatusRequest) Reset() { @@ -10017,13 +9985,6 @@ func (*GetSandboxPolicyStatusRequest) Descriptor() ([]byte, []int) { return file_openshell_proto_rawDescGZIP(), []int{139} } -func (x *GetSandboxPolicyStatusRequest) GetName() string { - if x != nil { - return x.Name - } - return "" -} - func (x *GetSandboxPolicyStatusRequest) GetVersion() uint32 { if x != nil { return x.Version @@ -10038,13 +9999,20 @@ func (x *GetSandboxPolicyStatusRequest) GetGlobal() bool { return false } -func (x *GetSandboxPolicyStatusRequest) GetWorkspace() string { +func (x *GetSandboxPolicyStatusRequest) GetSandboxName() string { if x != nil { - return x.Workspace + return x.SandboxName } return "" } +func (x *GetSandboxPolicyStatusRequest) GetWorkspaceScope() *datamodelv1.WorkspaceSelector { + if x != nil { + return x.WorkspaceScope + } + return nil +} + // Get sandbox policy status response. type GetSandboxPolicyStatusResponse struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -10102,17 +10070,15 @@ func (x *GetSandboxPolicyStatusResponse) GetActiveVersion() uint32 { // List sandbox policies request. type ListSandboxPoliciesRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - // Sandbox name (canonical lookup key). Ignored when global is true. - Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` - Limit uint32 `protobuf:"varint,2,opt,name=limit,proto3" json:"limit,omitempty"` - Offset uint32 `protobuf:"varint,3,opt,name=offset,proto3" json:"offset,omitempty"` + state protoimpl.MessageState `protogen:"open.v1"` + Limit uint32 `protobuf:"varint,2,opt,name=limit,proto3" json:"limit,omitempty"` + Offset uint32 `protobuf:"varint,3,opt,name=offset,proto3" json:"offset,omitempty"` // List global policy revisions instead of sandbox-scoped ones. - Global bool `protobuf:"varint,4,opt,name=global,proto3" json:"global,omitempty"` - // Workspace scope. Empty defaults to "default". Ignored when global is true. - Workspace string `protobuf:"bytes,5,opt,name=workspace,proto3" json:"workspace,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + Global bool `protobuf:"varint,4,opt,name=global,proto3" json:"global,omitempty"` + SandboxName string `protobuf:"bytes,1,opt,name=sandbox_name,json=sandboxName,proto3" json:"sandbox_name,omitempty"` + WorkspaceScope *datamodelv1.WorkspaceSelector `protobuf:"bytes,6,opt,name=workspace_scope,json=workspaceScope,proto3" json:"workspace_scope,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *ListSandboxPoliciesRequest) Reset() { @@ -10145,13 +10111,6 @@ func (*ListSandboxPoliciesRequest) Descriptor() ([]byte, []int) { return file_openshell_proto_rawDescGZIP(), []int{141} } -func (x *ListSandboxPoliciesRequest) GetName() string { - if x != nil { - return x.Name - } - return "" -} - func (x *ListSandboxPoliciesRequest) GetLimit() uint32 { if x != nil { return x.Limit @@ -10173,13 +10132,20 @@ func (x *ListSandboxPoliciesRequest) GetGlobal() bool { return false } -func (x *ListSandboxPoliciesRequest) GetWorkspace() string { +func (x *ListSandboxPoliciesRequest) GetSandboxName() string { if x != nil { - return x.Workspace + return x.SandboxName } return "" } +func (x *ListSandboxPoliciesRequest) GetWorkspaceScope() *datamodelv1.WorkspaceSelector { + if x != nil { + return x.WorkspaceScope + } + return nil +} + // List sandbox policies response. type ListSandboxPoliciesResponse struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -10454,8 +10420,6 @@ func (x *SandboxPolicyRevision) GetProvenance() map[string]string { // Get sandbox logs request (one-shot fetch). type GetSandboxLogsRequest struct { state protoimpl.MessageState `protogen:"open.v1"` - // Sandbox id. - SandboxId string `protobuf:"bytes,1,opt,name=sandbox_id,json=sandboxId,proto3" json:"sandbox_id,omitempty"` // Maximum number of log lines to return. 0 means use default (2000). Lines uint32 `protobuf:"varint,2,opt,name=lines,proto3" json:"lines,omitempty"` // Only include logs with timestamp >= this value (ms since epoch). 0 means no filter. @@ -10463,11 +10427,11 @@ type GetSandboxLogsRequest struct { // Filter by log source (e.g. "gateway", "sandbox"). Empty means all sources. Sources []string `protobuf:"bytes,4,rep,name=sources,proto3" json:"sources,omitempty"` // Minimum log level to include (e.g. "INFO", "WARN", "ERROR"). Empty means all levels. - MinLevel string `protobuf:"bytes,5,opt,name=min_level,json=minLevel,proto3" json:"min_level,omitempty"` - // Workspace scope. Empty defaults to "default". - Workspace string `protobuf:"bytes,6,opt,name=workspace,proto3" json:"workspace,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + MinLevel string `protobuf:"bytes,5,opt,name=min_level,json=minLevel,proto3" json:"min_level,omitempty"` + SandboxName string `protobuf:"bytes,8,opt,name=sandbox_name,json=sandboxName,proto3" json:"sandbox_name,omitempty"` + WorkspaceScope *datamodelv1.WorkspaceSelector `protobuf:"bytes,7,opt,name=workspace_scope,json=workspaceScope,proto3" json:"workspace_scope,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *GetSandboxLogsRequest) Reset() { @@ -10500,13 +10464,6 @@ func (*GetSandboxLogsRequest) Descriptor() ([]byte, []int) { return file_openshell_proto_rawDescGZIP(), []int{146} } -func (x *GetSandboxLogsRequest) GetSandboxId() string { - if x != nil { - return x.SandboxId - } - return "" -} - func (x *GetSandboxLogsRequest) GetLines() uint32 { if x != nil { return x.Lines @@ -10535,13 +10492,20 @@ func (x *GetSandboxLogsRequest) GetMinLevel() string { return "" } -func (x *GetSandboxLogsRequest) GetWorkspace() string { +func (x *GetSandboxLogsRequest) GetSandboxName() string { if x != nil { - return x.Workspace + return x.SandboxName } return "" } +func (x *GetSandboxLogsRequest) GetWorkspaceScope() *datamodelv1.WorkspaceSelector { + if x != nil { + return x.WorkspaceScope + } + return nil +} + // Batch of log lines pushed from sandbox to server. type PushSandboxLogsRequest struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -12537,11 +12501,13 @@ type SubmitPolicyAnalysisRequest struct { // to watch. Other values are treated as agent-style (no dedup) so a new // mode does not silently collapse proposals. AnalysisMode string `protobuf:"bytes,3,opt,name=analysis_mode,json=analysisMode,proto3" json:"analysis_mode,omitempty"` - // Sandbox name. + // Sandbox name. The authenticated sandbox principal remains authoritative + // for this internal callback. Name string `protobuf:"bytes,4,opt,name=name,proto3" json:"name,omitempty"` // Anonymous network activity counters. NetworkActivitySummaries []*NetworkActivitySummary `protobuf:"bytes,5,rep,name=network_activity_summaries,json=networkActivitySummaries,proto3" json:"network_activity_summaries,omitempty"` - // Workspace scope. Empty defaults to "default". + // Internal callback workspace. The gateway validates it against the + // authenticated sandbox principal. Workspace string `protobuf:"bytes,6,opt,name=workspace,proto3" json:"workspace,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache @@ -12696,14 +12662,12 @@ func (x *SubmitPolicyAnalysisResponse) GetAcceptedChunkIds() []string { // Get draft policy for a sandbox. type GetDraftPolicyRequest struct { state protoimpl.MessageState `protogen:"open.v1"` - // Sandbox name. - Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` // Optional status filter: "pending", "approved", "rejected", or "" for all. - StatusFilter string `protobuf:"bytes,2,opt,name=status_filter,json=statusFilter,proto3" json:"status_filter,omitempty"` - // Workspace scope. Empty defaults to "default". - Workspace string `protobuf:"bytes,3,opt,name=workspace,proto3" json:"workspace,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + StatusFilter string `protobuf:"bytes,2,opt,name=status_filter,json=statusFilter,proto3" json:"status_filter,omitempty"` + SandboxName string `protobuf:"bytes,1,opt,name=sandbox_name,json=sandboxName,proto3" json:"sandbox_name,omitempty"` + WorkspaceScope *datamodelv1.WorkspaceSelector `protobuf:"bytes,4,opt,name=workspace_scope,json=workspaceScope,proto3" json:"workspace_scope,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *GetDraftPolicyRequest) Reset() { @@ -12736,25 +12700,25 @@ func (*GetDraftPolicyRequest) Descriptor() ([]byte, []int) { return file_openshell_proto_rawDescGZIP(), []int{176} } -func (x *GetDraftPolicyRequest) GetName() string { +func (x *GetDraftPolicyRequest) GetStatusFilter() string { if x != nil { - return x.Name + return x.StatusFilter } return "" } -func (x *GetDraftPolicyRequest) GetStatusFilter() string { +func (x *GetDraftPolicyRequest) GetSandboxName() string { if x != nil { - return x.StatusFilter + return x.SandboxName } return "" } -func (x *GetDraftPolicyRequest) GetWorkspace() string { +func (x *GetDraftPolicyRequest) GetWorkspaceScope() *datamodelv1.WorkspaceSelector { if x != nil { - return x.Workspace + return x.WorkspaceScope } - return "" + return nil } type GetDraftPolicyResponse struct { @@ -12832,17 +12796,15 @@ func (x *GetDraftPolicyResponse) GetLastAnalyzedAtMs() int64 { // Approve a single draft chunk. type ApproveDraftChunkRequest struct { state protoimpl.MessageState `protogen:"open.v1"` - // Sandbox name. - Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` // Chunk ID to approve. ChunkId string `protobuf:"bytes,2,opt,name=chunk_id,json=chunkId,proto3" json:"chunk_id,omitempty"` - // Workspace scope. Empty defaults to "default". - Workspace string `protobuf:"bytes,3,opt,name=workspace,proto3" json:"workspace,omitempty"` // Token returned with the reviewed PolicyChunk. Approval fails with // FAILED_PRECONDITION if live decision inputs no longer match it. - ReviewToken string `protobuf:"bytes,4,opt,name=review_token,json=reviewToken,proto3" json:"review_token,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + ReviewToken string `protobuf:"bytes,4,opt,name=review_token,json=reviewToken,proto3" json:"review_token,omitempty"` + SandboxName string `protobuf:"bytes,1,opt,name=sandbox_name,json=sandboxName,proto3" json:"sandbox_name,omitempty"` + WorkspaceScope *datamodelv1.WorkspaceSelector `protobuf:"bytes,5,opt,name=workspace_scope,json=workspaceScope,proto3" json:"workspace_scope,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *ApproveDraftChunkRequest) Reset() { @@ -12875,32 +12837,32 @@ func (*ApproveDraftChunkRequest) Descriptor() ([]byte, []int) { return file_openshell_proto_rawDescGZIP(), []int{178} } -func (x *ApproveDraftChunkRequest) GetName() string { +func (x *ApproveDraftChunkRequest) GetChunkId() string { if x != nil { - return x.Name + return x.ChunkId } return "" } -func (x *ApproveDraftChunkRequest) GetChunkId() string { +func (x *ApproveDraftChunkRequest) GetReviewToken() string { if x != nil { - return x.ChunkId + return x.ReviewToken } return "" } -func (x *ApproveDraftChunkRequest) GetWorkspace() string { +func (x *ApproveDraftChunkRequest) GetSandboxName() string { if x != nil { - return x.Workspace + return x.SandboxName } return "" } -func (x *ApproveDraftChunkRequest) GetReviewToken() string { +func (x *ApproveDraftChunkRequest) GetWorkspaceScope() *datamodelv1.WorkspaceSelector { if x != nil { - return x.ReviewToken + return x.WorkspaceScope } - return "" + return nil } type ApproveDraftChunkResponse struct { @@ -12960,16 +12922,14 @@ func (x *ApproveDraftChunkResponse) GetPolicyHash() string { // Reject a single draft chunk. type RejectDraftChunkRequest struct { state protoimpl.MessageState `protogen:"open.v1"` - // Sandbox name. - Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` // Chunk ID to reject. ChunkId string `protobuf:"bytes,2,opt,name=chunk_id,json=chunkId,proto3" json:"chunk_id,omitempty"` // Optional reason for rejection (fed to LLM context in future analysis). - Reason string `protobuf:"bytes,3,opt,name=reason,proto3" json:"reason,omitempty"` - // Workspace scope. Empty defaults to "default". - Workspace string `protobuf:"bytes,4,opt,name=workspace,proto3" json:"workspace,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + Reason string `protobuf:"bytes,3,opt,name=reason,proto3" json:"reason,omitempty"` + SandboxName string `protobuf:"bytes,1,opt,name=sandbox_name,json=sandboxName,proto3" json:"sandbox_name,omitempty"` + WorkspaceScope *datamodelv1.WorkspaceSelector `protobuf:"bytes,5,opt,name=workspace_scope,json=workspaceScope,proto3" json:"workspace_scope,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *RejectDraftChunkRequest) Reset() { @@ -13002,13 +12962,6 @@ func (*RejectDraftChunkRequest) Descriptor() ([]byte, []int) { return file_openshell_proto_rawDescGZIP(), []int{180} } -func (x *RejectDraftChunkRequest) GetName() string { - if x != nil { - return x.Name - } - return "" -} - func (x *RejectDraftChunkRequest) GetChunkId() string { if x != nil { return x.ChunkId @@ -13023,13 +12976,20 @@ func (x *RejectDraftChunkRequest) GetReason() string { return "" } -func (x *RejectDraftChunkRequest) GetWorkspace() string { +func (x *RejectDraftChunkRequest) GetSandboxName() string { if x != nil { - return x.Workspace + return x.SandboxName } return "" } +func (x *RejectDraftChunkRequest) GetWorkspaceScope() *datamodelv1.WorkspaceSelector { + if x != nil { + return x.WorkspaceScope + } + return nil +} + type RejectDraftChunkResponse struct { state protoimpl.MessageState `protogen:"open.v1"` unknownFields protoimpl.UnknownFields @@ -13121,17 +13081,15 @@ func (x *DraftChunkApproval) GetReviewToken() string { type ApproveAllDraftChunksRequest struct { state protoimpl.MessageState `protogen:"open.v1"` - // Sandbox name. - Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` // Include chunks with security_notes (default false: skips them). IncludeSecurityFlagged bool `protobuf:"varint,2,opt,name=include_security_flagged,json=includeSecurityFlagged,proto3" json:"include_security_flagged,omitempty"` - // Workspace scope. Empty defaults to "default". - Workspace string `protobuf:"bytes,3,opt,name=workspace,proto3" json:"workspace,omitempty"` // Exact reviewed chunks and tokens. The server validates them against one // live snapshot, stages compatible operations in order, and writes once. - Approvals []*DraftChunkApproval `protobuf:"bytes,4,rep,name=approvals,proto3" json:"approvals,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + Approvals []*DraftChunkApproval `protobuf:"bytes,4,rep,name=approvals,proto3" json:"approvals,omitempty"` + SandboxName string `protobuf:"bytes,1,opt,name=sandbox_name,json=sandboxName,proto3" json:"sandbox_name,omitempty"` + WorkspaceScope *datamodelv1.WorkspaceSelector `protobuf:"bytes,5,opt,name=workspace_scope,json=workspaceScope,proto3" json:"workspace_scope,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *ApproveAllDraftChunksRequest) Reset() { @@ -13164,30 +13122,30 @@ func (*ApproveAllDraftChunksRequest) Descriptor() ([]byte, []int) { return file_openshell_proto_rawDescGZIP(), []int{183} } -func (x *ApproveAllDraftChunksRequest) GetName() string { +func (x *ApproveAllDraftChunksRequest) GetIncludeSecurityFlagged() bool { if x != nil { - return x.Name + return x.IncludeSecurityFlagged } - return "" + return false } -func (x *ApproveAllDraftChunksRequest) GetIncludeSecurityFlagged() bool { +func (x *ApproveAllDraftChunksRequest) GetApprovals() []*DraftChunkApproval { if x != nil { - return x.IncludeSecurityFlagged + return x.Approvals } - return false + return nil } -func (x *ApproveAllDraftChunksRequest) GetWorkspace() string { +func (x *ApproveAllDraftChunksRequest) GetSandboxName() string { if x != nil { - return x.Workspace + return x.SandboxName } return "" } -func (x *ApproveAllDraftChunksRequest) GetApprovals() []*DraftChunkApproval { +func (x *ApproveAllDraftChunksRequest) GetWorkspaceScope() *datamodelv1.WorkspaceSelector { if x != nil { - return x.Approvals + return x.WorkspaceScope } return nil } @@ -13268,16 +13226,14 @@ func (x *ApproveAllDraftChunksResponse) GetChunksSkipped() uint32 { // Edit a pending chunk in-place. type EditDraftChunkRequest struct { state protoimpl.MessageState `protogen:"open.v1"` - // Sandbox name. - Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` // Chunk ID to edit. ChunkId string `protobuf:"bytes,2,opt,name=chunk_id,json=chunkId,proto3" json:"chunk_id,omitempty"` // The modified rule (replaces existing proposed_rule). - ProposedRule *sandboxv1.NetworkPolicyRule `protobuf:"bytes,3,opt,name=proposed_rule,json=proposedRule,proto3" json:"proposed_rule,omitempty"` - // Workspace scope. Empty defaults to "default". - Workspace string `protobuf:"bytes,4,opt,name=workspace,proto3" json:"workspace,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + ProposedRule *sandboxv1.NetworkPolicyRule `protobuf:"bytes,3,opt,name=proposed_rule,json=proposedRule,proto3" json:"proposed_rule,omitempty"` + SandboxName string `protobuf:"bytes,1,opt,name=sandbox_name,json=sandboxName,proto3" json:"sandbox_name,omitempty"` + WorkspaceScope *datamodelv1.WorkspaceSelector `protobuf:"bytes,5,opt,name=workspace_scope,json=workspaceScope,proto3" json:"workspace_scope,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *EditDraftChunkRequest) Reset() { @@ -13310,13 +13266,6 @@ func (*EditDraftChunkRequest) Descriptor() ([]byte, []int) { return file_openshell_proto_rawDescGZIP(), []int{185} } -func (x *EditDraftChunkRequest) GetName() string { - if x != nil { - return x.Name - } - return "" -} - func (x *EditDraftChunkRequest) GetChunkId() string { if x != nil { return x.ChunkId @@ -13331,13 +13280,20 @@ func (x *EditDraftChunkRequest) GetProposedRule() *sandboxv1.NetworkPolicyRule { return nil } -func (x *EditDraftChunkRequest) GetWorkspace() string { +func (x *EditDraftChunkRequest) GetSandboxName() string { if x != nil { - return x.Workspace + return x.SandboxName } return "" } +func (x *EditDraftChunkRequest) GetWorkspaceScope() *datamodelv1.WorkspaceSelector { + if x != nil { + return x.WorkspaceScope + } + return nil +} + type EditDraftChunkResponse struct { state protoimpl.MessageState `protogen:"open.v1"` unknownFields protoimpl.UnknownFields @@ -13377,14 +13333,12 @@ func (*EditDraftChunkResponse) Descriptor() ([]byte, []int) { // Reverse an approval (remove merged rule from active policy). type UndoDraftChunkRequest struct { state protoimpl.MessageState `protogen:"open.v1"` - // Sandbox name. - Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` // Chunk ID to undo. - ChunkId string `protobuf:"bytes,2,opt,name=chunk_id,json=chunkId,proto3" json:"chunk_id,omitempty"` - // Workspace scope. Empty defaults to "default". - Workspace string `protobuf:"bytes,3,opt,name=workspace,proto3" json:"workspace,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + ChunkId string `protobuf:"bytes,2,opt,name=chunk_id,json=chunkId,proto3" json:"chunk_id,omitempty"` + SandboxName string `protobuf:"bytes,1,opt,name=sandbox_name,json=sandboxName,proto3" json:"sandbox_name,omitempty"` + WorkspaceScope *datamodelv1.WorkspaceSelector `protobuf:"bytes,4,opt,name=workspace_scope,json=workspaceScope,proto3" json:"workspace_scope,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *UndoDraftChunkRequest) Reset() { @@ -13417,25 +13371,25 @@ func (*UndoDraftChunkRequest) Descriptor() ([]byte, []int) { return file_openshell_proto_rawDescGZIP(), []int{187} } -func (x *UndoDraftChunkRequest) GetName() string { +func (x *UndoDraftChunkRequest) GetChunkId() string { if x != nil { - return x.Name + return x.ChunkId } return "" } -func (x *UndoDraftChunkRequest) GetChunkId() string { +func (x *UndoDraftChunkRequest) GetSandboxName() string { if x != nil { - return x.ChunkId + return x.SandboxName } return "" } -func (x *UndoDraftChunkRequest) GetWorkspace() string { +func (x *UndoDraftChunkRequest) GetWorkspaceScope() *datamodelv1.WorkspaceSelector { if x != nil { - return x.Workspace + return x.WorkspaceScope } - return "" + return nil } type UndoDraftChunkResponse struct { @@ -13494,13 +13448,11 @@ func (x *UndoDraftChunkResponse) GetPolicyHash() string { // Clear all pending draft chunks for a sandbox. type ClearDraftChunksRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - // Sandbox name. - Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` - // Workspace scope. Empty defaults to "default". - Workspace string `protobuf:"bytes,2,opt,name=workspace,proto3" json:"workspace,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + state protoimpl.MessageState `protogen:"open.v1"` + SandboxName string `protobuf:"bytes,1,opt,name=sandbox_name,json=sandboxName,proto3" json:"sandbox_name,omitempty"` + WorkspaceScope *datamodelv1.WorkspaceSelector `protobuf:"bytes,3,opt,name=workspace_scope,json=workspaceScope,proto3" json:"workspace_scope,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *ClearDraftChunksRequest) Reset() { @@ -13533,18 +13485,18 @@ func (*ClearDraftChunksRequest) Descriptor() ([]byte, []int) { return file_openshell_proto_rawDescGZIP(), []int{189} } -func (x *ClearDraftChunksRequest) GetName() string { +func (x *ClearDraftChunksRequest) GetSandboxName() string { if x != nil { - return x.Name + return x.SandboxName } return "" } -func (x *ClearDraftChunksRequest) GetWorkspace() string { +func (x *ClearDraftChunksRequest) GetWorkspaceScope() *datamodelv1.WorkspaceSelector { if x != nil { - return x.Workspace + return x.WorkspaceScope } - return "" + return nil } type ClearDraftChunksResponse struct { @@ -13594,13 +13546,11 @@ func (x *ClearDraftChunksResponse) GetChunksCleared() uint32 { // Get decision history for a sandbox's draft policy. type GetDraftHistoryRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - // Sandbox name. - Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` - // Workspace scope. Empty defaults to "default". - Workspace string `protobuf:"bytes,2,opt,name=workspace,proto3" json:"workspace,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + state protoimpl.MessageState `protogen:"open.v1"` + SandboxName string `protobuf:"bytes,1,opt,name=sandbox_name,json=sandboxName,proto3" json:"sandbox_name,omitempty"` + WorkspaceScope *datamodelv1.WorkspaceSelector `protobuf:"bytes,3,opt,name=workspace_scope,json=workspaceScope,proto3" json:"workspace_scope,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *GetDraftHistoryRequest) Reset() { @@ -13633,18 +13583,18 @@ func (*GetDraftHistoryRequest) Descriptor() ([]byte, []int) { return file_openshell_proto_rawDescGZIP(), []int{191} } -func (x *GetDraftHistoryRequest) GetName() string { +func (x *GetDraftHistoryRequest) GetSandboxName() string { if x != nil { - return x.Name + return x.SandboxName } return "" } -func (x *GetDraftHistoryRequest) GetWorkspace() string { +func (x *GetDraftHistoryRequest) GetWorkspaceScope() *datamodelv1.WorkspaceSelector { if x != nil { - return x.Workspace + return x.WorkspaceScope } - return "" + return nil } type DraftHistoryEntry struct { @@ -15385,84 +15335,82 @@ const file_openshell_proto_rawDesc = "" + "\bmetadata\x18\x06 \x03(\v2).openshell.v1.PlatformEvent.MetadataEntryR\bmetadata\x1a;\n" + "\rMetadataEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + - "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"\x8a\x04\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"\xd1\x04\n" + "\x14CreateSandboxRequest\x12-\n" + "\x04spec\x18\x01 \x01(\v2\x19.openshell.v1.SandboxSpecR\x04spec\x12\x12\n" + "\x04name\x18\x02 \x01(\tR\x04name\x12F\n" + "\x06labels\x18\x03 \x03(\v2..openshell.v1.CreateSandboxRequest.LabelsEntryR\x06labels\x12U\n" + - "\vannotations\x18\x04 \x03(\v23.openshell.v1.CreateSandboxRequest.AnnotationsEntryR\vannotations\x12\x1c\n" + - "\tworkspace\x18\x05 \x01(\tR\tworkspace\x12A\n" + + "\vannotations\x18\x04 \x03(\v23.openshell.v1.CreateSandboxRequest.AnnotationsEntryR\vannotations\x12A\n" + "\x1dawait_main_process_attachment\x18\x06 \x01(\bR\x1aawaitMainProcessAttachment\x124\n" + - "\x16workload_template_name\x18\a \x01(\tR\x14workloadTemplateName\x1a9\n" + + "\x16workload_template_name\x18\a \x01(\tR\x14workloadTemplateName\x12R\n" + + "\x0fworkspace_scope\x18\b \x01(\v2).openshell.datamodel.v1.WorkspaceSelectorR\x0eworkspaceScope\x1a9\n" + "\vLabelsEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\x1a>\n" + "\x10AnnotationsEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + - "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"\x7f\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01J\x04\b\x05\x10\x06R\tworkspace\"\xc6\x01\n" + "\x1cCreateSandboxTemplateRequest\x12A\n" + - "\btemplate\x18\x01 \x01(\v2%.openshell.v1.SandboxWorkloadTemplateR\btemplate\x12\x1c\n" + - "\tworkspace\x18\x02 \x01(\tR\tworkspace\"M\n" + + "\btemplate\x18\x01 \x01(\v2%.openshell.v1.SandboxWorkloadTemplateR\btemplate\x12R\n" + + "\x0fworkspace_scope\x18\x03 \x01(\v2).openshell.datamodel.v1.WorkspaceSelectorR\x0eworkspaceScopeJ\x04\b\x02\x10\x03R\tworkspace\"\x94\x01\n" + "\x19GetSandboxTemplateRequest\x12\x12\n" + - "\x04name\x18\x01 \x01(\tR\x04name\x12\x1c\n" + - "\tworkspace\x18\x02 \x01(\tR\tworkspace\"\xb7\x01\n" + + "\x04name\x18\x01 \x01(\tR\x04name\x12R\n" + + "\x0fworkspace_scope\x18\x03 \x01(\v2).openshell.datamodel.v1.WorkspaceSelectorR\x0eworkspaceScopeJ\x04\b\x02\x10\x03R\tworkspace\"\xed\x01\n" + "\x1bListSandboxTemplatesRequest\x12\x14\n" + "\x05limit\x18\x01 \x01(\rR\x05limit\x12\x16\n" + - "\x06offset\x18\x02 \x01(\rR\x06offset\x12\x1c\n" + - "\tworkspace\x18\x03 \x01(\tR\tworkspace\x12%\n" + - "\x0eall_workspaces\x18\x04 \x01(\bR\rallWorkspaces\x12%\n" + - "\x0elabel_selector\x18\x05 \x01(\tR\rlabelSelector\"P\n" + + "\x06offset\x18\x02 \x01(\rR\x06offset\x12%\n" + + "\x0elabel_selector\x18\x05 \x01(\tR\rlabelSelector\x12R\n" + + "\x0fworkspace_scope\x18\x06 \x01(\v2).openshell.datamodel.v1.WorkspaceSelectorR\x0eworkspaceScopeJ\x04\b\x03\x10\x04J\x04\b\x04\x10\x05R\tworkspaceR\x0eall_workspaces\"\x97\x01\n" + "\x1cDeleteSandboxTemplateRequest\x12\x12\n" + - "\x04name\x18\x01 \x01(\tR\x04name\x12\x1c\n" + - "\tworkspace\x18\x02 \x01(\tR\tworkspace\"\\\n" + + "\x04name\x18\x01 \x01(\tR\x04name\x12R\n" + + "\x0fworkspace_scope\x18\x03 \x01(\v2).openshell.datamodel.v1.WorkspaceSelectorR\x0eworkspaceScopeJ\x04\b\x02\x10\x03R\tworkspace\"\\\n" + "\x17SandboxTemplateResponse\x12A\n" + "\btemplate\x18\x01 \x01(\v2%.openshell.v1.SandboxWorkloadTemplateR\btemplate\"c\n" + "\x1cListSandboxTemplatesResponse\x12C\n" + "\ttemplates\x18\x01 \x03(\v2%.openshell.v1.SandboxWorkloadTemplateR\ttemplates\"9\n" + "\x1dDeleteSandboxTemplateResponse\x12\x18\n" + - "\adeleted\x18\x01 \x01(\bR\adeleted\"x\n" + - "\x1cBeginRootfsTarStagingRequest\x12\x1c\n" + - "\tworkspace\x18\x01 \x01(\tR\tworkspace\x12\x1b\n" + + "\adeleted\x18\x01 \x01(\bR\adeleted\"\xbf\x01\n" + + "\x1cBeginRootfsTarStagingRequest\x12\x1b\n" + "\tfile_name\x18\x02 \x01(\tR\bfileName\x12\x1d\n" + "\n" + - "size_bytes\x18\x03 \x01(\x04R\tsizeBytes\"\xa6\x01\n" + + "size_bytes\x18\x03 \x01(\x04R\tsizeBytes\x12R\n" + + "\x0fworkspace_scope\x18\x04 \x01(\v2).openshell.datamodel.v1.WorkspaceSelectorR\x0eworkspaceScopeJ\x04\b\x01\x10\x02R\tworkspace\"\xa6\x01\n" + "\x1dBeginRootfsTarStagingResponse\x12#\n" + "\rstaging_token\x18\x01 \x01(\tR\fstagingToken\x12\x1f\n" + "\vupload_path\x18\x02 \x01(\tR\n" + "uploadPath\x12\x1b\n" + "\tmax_bytes\x18\x03 \x01(\x04R\bmaxBytes\x12\"\n" + - "\rexpires_at_ms\x18\x04 \x01(\x03R\vexpiresAtMs\"E\n" + - "\x11GetSandboxRequest\x12\x12\n" + - "\x04name\x18\x01 \x01(\tR\x04name\x12\x1c\n" + - "\tworkspace\x18\x02 \x01(\tR\tworkspace\"\xb0\x01\n" + + "\rexpires_at_ms\x18\x04 \x01(\x03R\vexpiresAtMs\"\xa1\x01\n" + + "\x11GetSandboxRequest\x12!\n" + + "\fsandbox_name\x18\x01 \x01(\tR\vsandboxName\x12R\n" + + "\x0fworkspace_scope\x18\x03 \x01(\v2).openshell.datamodel.v1.WorkspaceSelectorR\x0eworkspaceScopeJ\x04\b\x02\x10\x03R\x04nameR\tworkspace\"\xe6\x01\n" + "\x14ListSandboxesRequest\x12\x14\n" + "\x05limit\x18\x01 \x01(\rR\x05limit\x12\x16\n" + "\x06offset\x18\x02 \x01(\rR\x06offset\x12%\n" + - "\x0elabel_selector\x18\x03 \x01(\tR\rlabelSelector\x12\x1c\n" + - "\tworkspace\x18\x04 \x01(\tR\tworkspace\x12%\n" + - "\x0eall_workspaces\x18\x05 \x01(\bR\rallWorkspaces\"^\n" + + "\x0elabel_selector\x18\x03 \x01(\tR\rlabelSelector\x12R\n" + + "\x0fworkspace_scope\x18\x06 \x01(\v2).openshell.datamodel.v1.WorkspaceSelectorR\x0eworkspaceScopeJ\x04\b\x04\x10\x05J\x04\b\x05\x10\x06R\tworkspaceR\x0eall_workspaces\"\xa5\x01\n" + "\x1bListSandboxProvidersRequest\x12!\n" + - "\fsandbox_name\x18\x01 \x01(\tR\vsandboxName\x12\x1c\n" + - "\tworkspace\x18\x02 \x01(\tR\tworkspace\"\xc0\x01\n" + + "\fsandbox_name\x18\x01 \x01(\tR\vsandboxName\x12R\n" + + "\x0fworkspace_scope\x18\x03 \x01(\v2).openshell.datamodel.v1.WorkspaceSelectorR\x0eworkspaceScopeJ\x04\b\x02\x10\x03R\tworkspace\"\x87\x02\n" + "\x1cAttachSandboxProviderRequest\x12!\n" + "\fsandbox_name\x18\x01 \x01(\tR\vsandboxName\x12#\n" + "\rprovider_name\x18\x02 \x01(\tR\fproviderName\x12:\n" + - "\x19expected_resource_version\x18\x03 \x01(\x04R\x17expectedResourceVersion\x12\x1c\n" + - "\tworkspace\x18\x04 \x01(\tR\tworkspace\"\xc0\x01\n" + + "\x19expected_resource_version\x18\x03 \x01(\x04R\x17expectedResourceVersion\x12R\n" + + "\x0fworkspace_scope\x18\x05 \x01(\v2).openshell.datamodel.v1.WorkspaceSelectorR\x0eworkspaceScopeJ\x04\b\x04\x10\x05R\tworkspace\"\x87\x02\n" + "\x1cDetachSandboxProviderRequest\x12!\n" + "\fsandbox_name\x18\x01 \x01(\tR\vsandboxName\x12#\n" + "\rprovider_name\x18\x02 \x01(\tR\fproviderName\x12:\n" + - "\x19expected_resource_version\x18\x03 \x01(\x04R\x17expectedResourceVersion\x12\x1c\n" + - "\tworkspace\x18\x04 \x01(\tR\tworkspace\"H\n" + - "\x14DeleteSandboxRequest\x12\x12\n" + - "\x04name\x18\x01 \x01(\tR\x04name\x12\x1c\n" + - "\tworkspace\x18\x02 \x01(\tR\tworkspace\"F\n" + - "\x12StopSandboxRequest\x12\x12\n" + - "\x04name\x18\x01 \x01(\tR\x04name\x12\x1c\n" + - "\tworkspace\x18\x02 \x01(\tR\tworkspace\"G\n" + - "\x13StartSandboxRequest\x12\x12\n" + - "\x04name\x18\x01 \x01(\tR\x04name\x12\x1c\n" + - "\tworkspace\x18\x02 \x01(\tR\tworkspace\"B\n" + + "\x19expected_resource_version\x18\x03 \x01(\x04R\x17expectedResourceVersion\x12R\n" + + "\x0fworkspace_scope\x18\x05 \x01(\v2).openshell.datamodel.v1.WorkspaceSelectorR\x0eworkspaceScopeJ\x04\b\x04\x10\x05R\tworkspace\"\xa4\x01\n" + + "\x14DeleteSandboxRequest\x12!\n" + + "\fsandbox_name\x18\x01 \x01(\tR\vsandboxName\x12R\n" + + "\x0fworkspace_scope\x18\x03 \x01(\v2).openshell.datamodel.v1.WorkspaceSelectorR\x0eworkspaceScopeJ\x04\b\x02\x10\x03R\x04nameR\tworkspace\"\xa2\x01\n" + + "\x12StopSandboxRequest\x12!\n" + + "\fsandbox_name\x18\x01 \x01(\tR\vsandboxName\x12R\n" + + "\x0fworkspace_scope\x18\x03 \x01(\v2).openshell.datamodel.v1.WorkspaceSelectorR\x0eworkspaceScopeJ\x04\b\x02\x10\x03R\x04nameR\tworkspace\"\xa3\x01\n" + + "\x13StartSandboxRequest\x12!\n" + + "\fsandbox_name\x18\x01 \x01(\tR\vsandboxName\x12R\n" + + "\x0fworkspace_scope\x18\x03 \x01(\v2).openshell.datamodel.v1.WorkspaceSelectorR\x0eworkspaceScopeJ\x04\b\x02\x10\x03R\x04nameR\tworkspace\"B\n" + "\x0fSandboxResponse\x12/\n" + "\asandbox\x18\x01 \x01(\v2\x15.openshell.v1.SandboxR\asandbox\"L\n" + "\x15ListSandboxesResponse\x123\n" + @@ -15476,10 +15424,11 @@ const file_openshell_proto_rawDesc = "" + "\asandbox\x18\x01 \x01(\v2\x15.openshell.v1.SandboxR\asandbox\x12\x1a\n" + "\bdetached\x18\x02 \x01(\bR\bdetached\"1\n" + "\x15DeleteSandboxResponse\x12\x18\n" + - "\adeleted\x18\x01 \x01(\bR\adeleted\"8\n" + - "\x17CreateSshSessionRequest\x12\x1d\n" + - "\n" + - "sandbox_id\x18\x01 \x01(\tR\tsandboxId\"\x98\x02\n" + + "\adeleted\x18\x01 \x01(\bR\adeleted\"\xa2\x01\n" + + "\x17CreateSshSessionRequest\x12!\n" + + "\fsandbox_name\x18\x02 \x01(\tR\vsandboxName\x12R\n" + + "\x0fworkspace_scope\x18\x03 \x01(\v2).openshell.datamodel.v1.WorkspaceSelectorR\x0eworkspaceScopeJ\x04\b\x01\x10\x02R\n" + + "sandbox_id\"\x98\x02\n" + "\x18CreateSshSessionResponse\x12\x1d\n" + "\n" + "sandbox_id\x18\x01 \x01(\tR\tsandboxId\x12\x1a\n" + @@ -15488,30 +15437,29 @@ const file_openshell_proto_rawDesc = "" + "\fgateway_port\x18\x04 \x01(\rR\vgatewayPort\x12%\n" + "\x0egateway_scheme\x18\x05 \x01(\tR\rgatewayScheme\x120\n" + "\x14host_key_fingerprint\x18\a \x01(\tR\x12hostKeyFingerprint\x12\"\n" + - "\rexpires_at_ms\x18\b \x01(\x03R\vexpiresAtMs\"\xa1\x01\n" + + "\rexpires_at_ms\x18\b \x01(\x03R\vexpiresAtMs\"\xfa\x01\n" + "\x14ExposeServiceRequest\x12\x18\n" + - "\asandbox\x18\x01 \x01(\tR\asandbox\x12\x18\n" + "\aservice\x18\x02 \x01(\tR\aservice\x12\x1f\n" + "\vtarget_port\x18\x03 \x01(\rR\n" + "targetPort\x12\x16\n" + - "\x06domain\x18\x04 \x01(\bR\x06domain\x12\x1c\n" + - "\tworkspace\x18\x05 \x01(\tR\tworkspace\"e\n" + + "\x06domain\x18\x04 \x01(\bR\x06domain\x12!\n" + + "\fsandbox_name\x18\x01 \x01(\tR\vsandboxName\x12R\n" + + "\x0fworkspace_scope\x18\x06 \x01(\v2).openshell.datamodel.v1.WorkspaceSelectorR\x0eworkspaceScopeJ\x04\b\x05\x10\x06R\asandboxR\tworkspace\"\xbe\x01\n" + "\x11GetServiceRequest\x12\x18\n" + - "\asandbox\x18\x01 \x01(\tR\asandbox\x12\x18\n" + - "\aservice\x18\x02 \x01(\tR\aservice\x12\x1c\n" + - "\tworkspace\x18\x03 \x01(\tR\tworkspace\"\xa2\x01\n" + - "\x13ListServicesRequest\x12\x18\n" + - "\asandbox\x18\x01 \x01(\tR\asandbox\x12\x14\n" + + "\aservice\x18\x02 \x01(\tR\aservice\x12!\n" + + "\fsandbox_name\x18\x01 \x01(\tR\vsandboxName\x12R\n" + + "\x0fworkspace_scope\x18\x04 \x01(\v2).openshell.datamodel.v1.WorkspaceSelectorR\x0eworkspaceScopeJ\x04\b\x03\x10\x04R\asandboxR\tworkspace\"\xea\x01\n" + + "\x13ListServicesRequest\x12\x14\n" + "\x05limit\x18\x02 \x01(\rR\x05limit\x12\x16\n" + - "\x06offset\x18\x03 \x01(\rR\x06offset\x12\x1c\n" + - "\tworkspace\x18\x04 \x01(\tR\tworkspace\x12%\n" + - "\x0eall_workspaces\x18\x05 \x01(\bR\rallWorkspaces\"Y\n" + + "\x06offset\x18\x03 \x01(\rR\x06offset\x12R\n" + + "\x0fworkspace_scope\x18\x06 \x01(\v2).openshell.datamodel.v1.WorkspaceSelectorR\x0eworkspaceScope\x12!\n" + + "\fsandbox_name\x18\x01 \x01(\tR\vsandboxNameJ\x04\b\x04\x10\x05J\x04\b\x05\x10\x06R\asandboxR\tworkspaceR\x0eall_workspaces\"Y\n" + "\x14ListServicesResponse\x12A\n" + - "\bservices\x18\x01 \x03(\v2%.openshell.v1.ServiceEndpointResponseR\bservices\"h\n" + + "\bservices\x18\x01 \x03(\v2%.openshell.v1.ServiceEndpointResponseR\bservices\"\xc1\x01\n" + "\x14DeleteServiceRequest\x12\x18\n" + - "\asandbox\x18\x01 \x01(\tR\asandbox\x12\x18\n" + - "\aservice\x18\x02 \x01(\tR\aservice\x12\x1c\n" + - "\tworkspace\x18\x03 \x01(\tR\tworkspace\"1\n" + + "\aservice\x18\x02 \x01(\tR\aservice\x12!\n" + + "\fsandbox_name\x18\x01 \x01(\tR\vsandboxName\x12R\n" + + "\x0fworkspace_scope\x18\x04 \x01(\v2).openshell.datamodel.v1.WorkspaceSelectorR\x0eworkspaceScopeJ\x04\b\x03\x10\x04R\asandboxR\tworkspace\"1\n" + "\x15DeleteServiceResponse\x12\x18\n" + "\adeleted\x18\x01 \x01(\bR\adeleted\"\xef\x01\n" + "\x0fServiceEndpoint\x12>\n" + @@ -15529,10 +15477,8 @@ const file_openshell_proto_rawDesc = "" + "\x17RevokeSshSessionRequest\x12\x1a\n" + "\x05token\x18\x01 \x01(\tB\x04\x88\xb5\x18\x01R\x05token\"4\n" + "\x18RevokeSshSessionResponse\x12\x18\n" + - "\arevoked\x18\x01 \x01(\bR\arevoked\"\x9b\x03\n" + - "\x12ExecSandboxRequest\x12\x1d\n" + - "\n" + - "sandbox_id\x18\x01 \x01(\tR\tsandboxId\x12\x18\n" + + "\arevoked\x18\x01 \x01(\bR\arevoked\"\x85\x04\n" + + "\x12ExecSandboxRequest\x12\x18\n" + "\acommand\x18\x02 \x03(\tR\acommand\x12\x18\n" + "\aworkdir\x18\x03 \x01(\tR\aworkdir\x12S\n" + "\venvironment\x18\x04 \x03(\v21.openshell.v1.ExecSandboxRequest.EnvironmentEntryR\venvironment\x12'\n" + @@ -15542,10 +15488,13 @@ const file_openshell_proto_rawDesc = "" + "\x04cols\x18\b \x01(\rR\x04cols\x12\x12\n" + "\x04rows\x18\t \x01(\rR\x04rows\x12$\n" + "\x0eno_login_shell\x18\n" + - " \x01(\bR\fnoLoginShell\x1a>\n" + + " \x01(\bR\fnoLoginShell\x12!\n" + + "\fsandbox_name\x18\v \x01(\tR\vsandboxName\x12R\n" + + "\x0fworkspace_scope\x18\f \x01(\v2).openshell.datamodel.v1.WorkspaceSelectorR\x0eworkspaceScope\x1a>\n" + "\x10EnvironmentEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + - "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"'\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01J\x04\b\x01\x10\x02R\n" + + "sandbox_id\"'\n" + "\x11ExecSandboxStdout\x12\x12\n" + "\x04data\x18\x01 \x01(\fR\x04data\"'\n" + "\x11ExecSandboxStderr\x12\x12\n" + @@ -15556,16 +15505,17 @@ const file_openshell_proto_rawDesc = "" + "\x06stdout\x18\x01 \x01(\v2\x1f.openshell.v1.ExecSandboxStdoutH\x00R\x06stdout\x129\n" + "\x06stderr\x18\x02 \x01(\v2\x1f.openshell.v1.ExecSandboxStderrH\x00R\x06stderr\x123\n" + "\x04exit\x18\x03 \x01(\v2\x1d.openshell.v1.ExecSandboxExitH\x00R\x04exitB\t\n" + - "\apayload\"\xf3\x01\n" + - "\x0eTcpForwardInit\x12\x1d\n" + - "\n" + - "sandbox_id\x18\x01 \x01(\tR\tsandboxId\x12\x1d\n" + + "\apayload\"\xdd\x02\n" + + "\x0eTcpForwardInit\x12!\n" + + "\fsandbox_name\x18\x02 \x01(\tR\vsandboxName\x12R\n" + + "\x0fworkspace_scope\x18\x03 \x01(\v2).openshell.datamodel.v1.WorkspaceSelectorR\x0eworkspaceScope\x12\x1d\n" + "\n" + "service_id\x18\x04 \x01(\tR\tserviceId\x120\n" + "\x03ssh\x18\x05 \x01(\v2\x1c.openshell.v1.SshRelayTargetH\x00R\x03ssh\x120\n" + "\x03tcp\x18\x06 \x01(\v2\x1c.openshell.v1.TcpRelayTargetH\x00R\x03tcp\x125\n" + "\x13authorization_token\x18\a \x01(\tB\x04\x88\xb5\x18\x01R\x12authorizationTokenB\b\n" + - "\x06target\"f\n" + + "\x06targetJ\x04\b\x01\x10\x02R\n" + + "sandbox_id\"f\n" + "\x0fTcpForwardFrame\x122\n" + "\x04init\x18\x01 \x01(\v2\x1c.openshell.v1.TcpForwardInitH\x00R\x04init\x12\x14\n" + "\x04data\x18\x02 \x01(\fH\x00R\x04dataB\t\n" + @@ -15585,9 +15535,8 @@ const file_openshell_proto_rawDesc = "" + "sandbox_id\x18\x02 \x01(\tR\tsandboxId\x12\x1a\n" + "\x05token\x18\x03 \x01(\tB\x04\x88\xb5\x18\x01R\x05token\x12\"\n" + "\rexpires_at_ms\x18\x04 \x01(\x03R\vexpiresAtMs\x12\x18\n" + - "\arevoked\x18\x05 \x01(\bR\arevoked\"\xe6\x02\n" + - "\x13WatchSandboxRequest\x12\x0e\n" + - "\x02id\x18\x01 \x01(\tR\x02id\x12#\n" + + "\arevoked\x18\x05 \x01(\bR\arevoked\"\xd7\x03\n" + + "\x13WatchSandboxRequest\x12#\n" + "\rfollow_status\x18\x02 \x01(\bR\ffollowStatus\x12\x1f\n" + "\vfollow_logs\x18\x03 \x01(\bR\n" + "followLogs\x12#\n" + @@ -15601,7 +15550,9 @@ const file_openshell_proto_rawDesc = "" + "\vlog_sources\x18\t \x03(\tR\n" + "logSources\x12\"\n" + "\rlog_min_level\x18\n" + - " \x01(\tR\vlogMinLevel\"\xcc\x02\n" + + " \x01(\tR\vlogMinLevel\x12!\n" + + "\fsandbox_name\x18\v \x01(\tR\vsandboxName\x12R\n" + + "\x0fworkspace_scope\x18\f \x01(\v2).openshell.datamodel.v1.WorkspaceSelectorR\x0eworkspaceScopeJ\x04\b\x01\x10\x02R\x02id\"\xcc\x02\n" + "\x12SandboxStreamEvent\x121\n" + "\asandbox\x18\x01 \x01(\v2\x15.openshell.v1.SandboxH\x00R\asandbox\x120\n" + "\x03log\x18\x02 \x01(\v2\x1c.openshell.v1.SandboxLogLineH\x00R\x03log\x123\n" + @@ -15622,28 +15573,27 @@ const file_openshell_proto_rawDesc = "" + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"0\n" + "\x14SandboxStreamWarning\x12\x18\n" + - "\amessage\x18\x01 \x01(\tR\amessage\"s\n" + + "\amessage\x18\x01 \x01(\tR\amessage\"\xba\x01\n" + "\x15CreateProviderRequest\x12<\n" + - "\bprovider\x18\x01 \x01(\v2 .openshell.datamodel.v1.ProviderR\bprovider\x12\x1c\n" + - "\tworkspace\x18\x02 \x01(\tR\tworkspace\"F\n" + + "\bprovider\x18\x01 \x01(\v2 .openshell.datamodel.v1.ProviderR\bprovider\x12R\n" + + "\x0fworkspace_scope\x18\x03 \x01(\v2).openshell.datamodel.v1.WorkspaceSelectorR\x0eworkspaceScopeJ\x04\b\x02\x10\x03R\tworkspace\"\x8d\x01\n" + "\x12GetProviderRequest\x12\x12\n" + - "\x04name\x18\x01 \x01(\tR\x04name\x12\x1c\n" + - "\tworkspace\x18\x02 \x01(\tR\tworkspace\"\x89\x01\n" + + "\x04name\x18\x01 \x01(\tR\x04name\x12R\n" + + "\x0fworkspace_scope\x18\x03 \x01(\v2).openshell.datamodel.v1.WorkspaceSelectorR\x0eworkspaceScopeJ\x04\b\x02\x10\x03R\tworkspace\"\xbf\x01\n" + "\x14ListProvidersRequest\x12\x14\n" + "\x05limit\x18\x01 \x01(\rR\x05limit\x12\x16\n" + - "\x06offset\x18\x02 \x01(\rR\x06offset\x12\x1c\n" + - "\tworkspace\x18\x03 \x01(\tR\tworkspace\x12%\n" + - "\x0eall_workspaces\x18\x04 \x01(\bR\rallWorkspaces\"\xb6\x02\n" + + "\x06offset\x18\x02 \x01(\rR\x06offset\x12R\n" + + "\x0fworkspace_scope\x18\x05 \x01(\v2).openshell.datamodel.v1.WorkspaceSelectorR\x0eworkspaceScopeJ\x04\b\x03\x10\x04J\x04\b\x04\x10\x05R\tworkspaceR\x0eall_workspaces\"\xfd\x02\n" + "\x15UpdateProviderRequest\x12<\n" + "\bprovider\x18\x01 \x01(\v2 .openshell.datamodel.v1.ProviderR\bprovider\x12w\n" + - "\x18credential_expires_at_ms\x18\x02 \x03(\v2>.openshell.v1.UpdateProviderRequest.CredentialExpiresAtMsEntryR\x15credentialExpiresAtMs\x12\x1c\n" + - "\tworkspace\x18\x03 \x01(\tR\tworkspace\x1aH\n" + + "\x18credential_expires_at_ms\x18\x02 \x03(\v2>.openshell.v1.UpdateProviderRequest.CredentialExpiresAtMsEntryR\x15credentialExpiresAtMs\x12R\n" + + "\x0fworkspace_scope\x18\x04 \x01(\v2).openshell.datamodel.v1.WorkspaceSelectorR\x0eworkspaceScope\x1aH\n" + "\x1aCredentialExpiresAtMsEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + - "\x05value\x18\x02 \x01(\x03R\x05value:\x028\x01\"I\n" + + "\x05value\x18\x02 \x01(\x03R\x05value:\x028\x01J\x04\b\x03\x10\x04R\tworkspace\"\x90\x01\n" + "\x15DeleteProviderRequest\x12\x12\n" + - "\x04name\x18\x01 \x01(\tR\x04name\x12\x1c\n" + - "\tworkspace\x18\x02 \x01(\tR\tworkspace\"P\n" + + "\x04name\x18\x01 \x01(\tR\x04name\x12R\n" + + "\x0fworkspace_scope\x18\x03 \x01(\v2).openshell.datamodel.v1.WorkspaceSelectorR\x0eworkspaceScopeJ\x04\b\x02\x10\x03R\tworkspace\"P\n" + "\x10ProviderResponse\x12<\n" + "\bprovider\x18\x01 \x01(\v2 .openshell.datamodel.v1.ProviderR\bprovider\"W\n" + "\x15ListProvidersResponse\x12>\n" + @@ -15782,37 +15732,37 @@ const file_openshell_proto_rawDesc = "" + "\x05value\x18\x02 \x01(\v2(.openshell.datamodel.v1.CredentialHandleR\x05value:\x028\x01\"\x84\x01\n" + "\x1dStoredRefreshMaterialDeletion\x12!\n" + "\fmaterial_key\x18\x01 \x01(\tR\vmaterialKey\x12@\n" + - "\x06handle\x18\x02 \x01(\v2(.openshell.datamodel.v1.CredentialHandleR\x06handle\"\x82\x01\n" + + "\x06handle\x18\x02 \x01(\v2(.openshell.datamodel.v1.CredentialHandleR\x06handle\"\xc9\x01\n" + "\x1fGetProviderRefreshStatusRequest\x12\x1a\n" + "\bprovider\x18\x01 \x01(\tR\bprovider\x12%\n" + - "\x0ecredential_key\x18\x02 \x01(\tR\rcredentialKey\x12\x1c\n" + - "\tworkspace\x18\x03 \x01(\tR\tworkspace\"s\n" + + "\x0ecredential_key\x18\x02 \x01(\tR\rcredentialKey\x12R\n" + + "\x0fworkspace_scope\x18\x04 \x01(\v2).openshell.datamodel.v1.WorkspaceSelectorR\x0eworkspaceScopeJ\x04\b\x03\x10\x04R\tworkspace\"s\n" + " GetProviderRefreshStatusResponse\x12O\n" + - "\vcredentials\x18\x01 \x03(\v2-.openshell.v1.ProviderCredentialRefreshStatusR\vcredentials\"\xd8\x03\n" + + "\vcredentials\x18\x01 \x03(\v2-.openshell.v1.ProviderCredentialRefreshStatusR\vcredentials\"\x9f\x04\n" + "\x1fConfigureProviderRefreshRequest\x12\x1a\n" + "\bprovider\x18\x01 \x01(\tR\bprovider\x12%\n" + "\x0ecredential_key\x18\x02 \x01(\tR\rcredentialKey\x12K\n" + "\bstrategy\x18\x03 \x01(\x0e2/.openshell.v1.ProviderCredentialRefreshStrategyR\bstrategy\x12]\n" + "\bmaterial\x18\x04 \x03(\v2;.openshell.v1.ConfigureProviderRefreshRequest.MaterialEntryB\x04\x88\xb5\x18\x01R\bmaterial\x120\n" + "\x14secret_material_keys\x18\x05 \x03(\tR\x12secretMaterialKeys\x12'\n" + - "\rexpires_at_ms\x18\x06 \x01(\x03H\x00R\vexpiresAtMs\x88\x01\x01\x12\x1c\n" + - "\tworkspace\x18\a \x01(\tR\tworkspace\x1a;\n" + + "\rexpires_at_ms\x18\x06 \x01(\x03H\x00R\vexpiresAtMs\x88\x01\x01\x12R\n" + + "\x0fworkspace_scope\x18\b \x01(\v2).openshell.datamodel.v1.WorkspaceSelectorR\x0eworkspaceScope\x1a;\n" + "\rMaterialEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01B\x10\n" + - "\x0e_expires_at_ms\"i\n" + + "\x0e_expires_at_msJ\x04\b\a\x10\bR\tworkspace\"i\n" + " ConfigureProviderRefreshResponse\x12E\n" + - "\x06status\x18\x01 \x01(\v2-.openshell.v1.ProviderCredentialRefreshStatusR\x06status\"\x82\x01\n" + + "\x06status\x18\x01 \x01(\v2-.openshell.v1.ProviderCredentialRefreshStatusR\x06status\"\xc9\x01\n" + "\x1fRotateProviderCredentialRequest\x12\x1a\n" + "\bprovider\x18\x01 \x01(\tR\bprovider\x12%\n" + - "\x0ecredential_key\x18\x02 \x01(\tR\rcredentialKey\x12\x1c\n" + - "\tworkspace\x18\x03 \x01(\tR\tworkspace\"i\n" + + "\x0ecredential_key\x18\x02 \x01(\tR\rcredentialKey\x12R\n" + + "\x0fworkspace_scope\x18\x04 \x01(\v2).openshell.datamodel.v1.WorkspaceSelectorR\x0eworkspaceScopeJ\x04\b\x03\x10\x04R\tworkspace\"i\n" + " RotateProviderCredentialResponse\x12E\n" + - "\x06status\x18\x01 \x01(\v2-.openshell.v1.ProviderCredentialRefreshStatusR\x06status\"\x7f\n" + + "\x06status\x18\x01 \x01(\v2-.openshell.v1.ProviderCredentialRefreshStatusR\x06status\"\xc6\x01\n" + "\x1cDeleteProviderRefreshRequest\x12\x1a\n" + "\bprovider\x18\x01 \x01(\tR\bprovider\x12%\n" + - "\x0ecredential_key\x18\x02 \x01(\tR\rcredentialKey\x12\x1c\n" + - "\tworkspace\x18\x03 \x01(\tR\tworkspace\"9\n" + + "\x0ecredential_key\x18\x02 \x01(\tR\rcredentialKey\x12R\n" + + "\x0fworkspace_scope\x18\x04 \x01(\v2).openshell.datamodel.v1.WorkspaceSelectorR\x0eworkspaceScopeJ\x04\b\x03\x10\x04R\tworkspace\"9\n" + "\x1dDeleteProviderRefreshResponse\x12\x18\n" + "\adeleted\x18\x01 \x01(\bR\adeleted\"\xd8\x05\n" + "\x0fProviderProfile\x12\x0e\n" + @@ -15911,9 +15861,8 @@ const file_openshell_proto_rawDesc = "" + "\n" + "expires_in\x18\x02 \x01(\x03R\texpiresIn\x12\x1d\n" + "\n" + - "token_type\x18\x03 \x01(\tR\ttokenType\"\xce\x04\n" + - "\x13UpdateConfigRequest\x12\x12\n" + - "\x04name\x18\x01 \x01(\tR\x04name\x12;\n" + + "token_type\x18\x03 \x01(\tR\ttokenType\"\xaa\x05\n" + + "\x13UpdateConfigRequest\x12;\n" + "\x06policy\x18\x02 \x01(\v2#.openshell.sandbox.v1.SandboxPolicyR\x06policy\x12\x1f\n" + "\vsetting_key\x18\x03 \x01(\tR\n" + "settingKey\x12G\n" + @@ -15922,12 +15871,13 @@ const file_openshell_proto_rawDesc = "" + "\x06global\x18\x06 \x01(\bR\x06global\x12M\n" + "\x10merge_operations\x18\a \x03(\v2\".openshell.v1.PolicyMergeOperationR\x0fmergeOperations\x12:\n" + "\x19expected_resource_version\x18\b \x01(\x04R\x17expectedResourceVersion\x12T\n" + - "\vannotations\x18\t \x03(\v22.openshell.v1.UpdateConfigRequest.AnnotationsEntryR\vannotations\x12\x1c\n" + - "\tworkspace\x18\n" + - " \x01(\tR\tworkspace\x1a>\n" + + "\vannotations\x18\t \x03(\v22.openshell.v1.UpdateConfigRequest.AnnotationsEntryR\vannotations\x12!\n" + + "\fsandbox_name\x18\x01 \x01(\tR\vsandboxName\x12R\n" + + "\x0fworkspace_scope\x18\v \x01(\v2).openshell.datamodel.v1.WorkspaceSelectorR\x0eworkspaceScope\x1a>\n" + "\x10AnnotationsEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + - "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"\xc7\x03\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01J\x04\b\n" + + "\x10\vR\x04nameR\tworkspace\"\xc7\x03\n" + "\x14PolicyMergeOperation\x129\n" + "\badd_rule\x18\x01 \x01(\v2\x1c.openshell.v1.AddNetworkRuleH\x00R\aaddRule\x12N\n" + "\x0fremove_endpoint\x18\x02 \x01(\v2#.openshell.v1.RemoveNetworkEndpointH\x00R\x0eremoveEndpoint\x12B\n" + @@ -15968,21 +15918,21 @@ const file_openshell_proto_rawDesc = "" + "\vannotations\x18\x05 \x03(\v23.openshell.v1.UpdateConfigResponse.AnnotationsEntryR\vannotations\x1a>\n" + "\x10AnnotationsEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + - "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"\x83\x01\n" + - "\x1dGetSandboxPolicyStatusRequest\x12\x12\n" + - "\x04name\x18\x01 \x01(\tR\x04name\x12\x18\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"\xdf\x01\n" + + "\x1dGetSandboxPolicyStatusRequest\x12\x18\n" + "\aversion\x18\x02 \x01(\rR\aversion\x12\x16\n" + - "\x06global\x18\x03 \x01(\bR\x06global\x12\x1c\n" + - "\tworkspace\x18\x04 \x01(\tR\tworkspace\"\x88\x01\n" + + "\x06global\x18\x03 \x01(\bR\x06global\x12!\n" + + "\fsandbox_name\x18\x01 \x01(\tR\vsandboxName\x12R\n" + + "\x0fworkspace_scope\x18\x05 \x01(\v2).openshell.datamodel.v1.WorkspaceSelectorR\x0eworkspaceScopeJ\x04\b\x04\x10\x05R\x04nameR\tworkspace\"\x88\x01\n" + "\x1eGetSandboxPolicyStatusResponse\x12?\n" + "\brevision\x18\x01 \x01(\v2#.openshell.v1.SandboxPolicyRevisionR\brevision\x12%\n" + - "\x0eactive_version\x18\x02 \x01(\rR\ractiveVersion\"\x94\x01\n" + - "\x1aListSandboxPoliciesRequest\x12\x12\n" + - "\x04name\x18\x01 \x01(\tR\x04name\x12\x14\n" + + "\x0eactive_version\x18\x02 \x01(\rR\ractiveVersion\"\xf0\x01\n" + + "\x1aListSandboxPoliciesRequest\x12\x14\n" + "\x05limit\x18\x02 \x01(\rR\x05limit\x12\x16\n" + "\x06offset\x18\x03 \x01(\rR\x06offset\x12\x16\n" + - "\x06global\x18\x04 \x01(\bR\x06global\x12\x1c\n" + - "\tworkspace\x18\x05 \x01(\tR\tworkspace\"`\n" + + "\x06global\x18\x04 \x01(\bR\x06global\x12!\n" + + "\fsandbox_name\x18\x01 \x01(\tR\vsandboxName\x12R\n" + + "\x0fworkspace_scope\x18\x06 \x01(\v2).openshell.datamodel.v1.WorkspaceSelectorR\x0eworkspaceScopeJ\x04\b\x05\x10\x06R\x04nameR\tworkspace\"`\n" + "\x1bListSandboxPoliciesResponse\x12A\n" + "\trevisions\x18\x01 \x03(\v2#.openshell.v1.SandboxPolicyRevisionR\trevisions\"\xa7\x01\n" + "\x19ReportPolicyStatusRequest\x12\x1d\n" + @@ -16009,15 +15959,15 @@ const file_openshell_proto_rawDesc = "" + "provenance\x1a=\n" + "\x0fProvenanceEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + - "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"\xbc\x01\n" + - "\x15GetSandboxLogsRequest\x12\x1d\n" + - "\n" + - "sandbox_id\x18\x01 \x01(\tR\tsandboxId\x12\x14\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"\x99\x02\n" + + "\x15GetSandboxLogsRequest\x12\x14\n" + "\x05lines\x18\x02 \x01(\rR\x05lines\x12\x19\n" + "\bsince_ms\x18\x03 \x01(\x03R\asinceMs\x12\x18\n" + "\asources\x18\x04 \x03(\tR\asources\x12\x1b\n" + - "\tmin_level\x18\x05 \x01(\tR\bminLevel\x12\x1c\n" + - "\tworkspace\x18\x06 \x01(\tR\tworkspace\"i\n" + + "\tmin_level\x18\x05 \x01(\tR\bminLevel\x12!\n" + + "\fsandbox_name\x18\b \x01(\tR\vsandboxName\x12R\n" + + "\x0fworkspace_scope\x18\a \x01(\v2).openshell.datamodel.v1.WorkspaceSelectorR\x0eworkspaceScopeJ\x04\b\x01\x10\x02J\x04\b\x06\x10\aR\n" + + "sandbox_idR\tworkspace\"i\n" + "\x16PushSandboxLogsRequest\x12\x1d\n" + "\n" + "sandbox_id\x18\x01 \x01(\tR\tsandboxId\x120\n" + @@ -16182,67 +16132,67 @@ const file_openshell_proto_rawDesc = "" + "\x0faccepted_chunks\x18\x01 \x01(\rR\x0eacceptedChunks\x12'\n" + "\x0frejected_chunks\x18\x02 \x01(\rR\x0erejectedChunks\x12+\n" + "\x11rejection_reasons\x18\x03 \x03(\tR\x10rejectionReasons\x12,\n" + - "\x12accepted_chunk_ids\x18\x04 \x03(\tR\x10acceptedChunkIds\"n\n" + - "\x15GetDraftPolicyRequest\x12\x12\n" + - "\x04name\x18\x01 \x01(\tR\x04name\x12#\n" + - "\rstatus_filter\x18\x02 \x01(\tR\fstatusFilter\x12\x1c\n" + - "\tworkspace\x18\x03 \x01(\tR\tworkspace\"\xc8\x01\n" + + "\x12accepted_chunk_ids\x18\x04 \x03(\tR\x10acceptedChunkIds\"\xca\x01\n" + + "\x15GetDraftPolicyRequest\x12#\n" + + "\rstatus_filter\x18\x02 \x01(\tR\fstatusFilter\x12!\n" + + "\fsandbox_name\x18\x01 \x01(\tR\vsandboxName\x12R\n" + + "\x0fworkspace_scope\x18\x04 \x01(\v2).openshell.datamodel.v1.WorkspaceSelectorR\x0eworkspaceScopeJ\x04\b\x03\x10\x04R\x04nameR\tworkspace\"\xc8\x01\n" + "\x16GetDraftPolicyResponse\x121\n" + "\x06chunks\x18\x01 \x03(\v2\x19.openshell.v1.PolicyChunkR\x06chunks\x12'\n" + "\x0frolling_summary\x18\x02 \x01(\tR\x0erollingSummary\x12#\n" + "\rdraft_version\x18\x03 \x01(\x04R\fdraftVersion\x12-\n" + - "\x13last_analyzed_at_ms\x18\x04 \x01(\x03R\x10lastAnalyzedAtMs\"\x8a\x01\n" + - "\x18ApproveDraftChunkRequest\x12\x12\n" + - "\x04name\x18\x01 \x01(\tR\x04name\x12\x19\n" + - "\bchunk_id\x18\x02 \x01(\tR\achunkId\x12\x1c\n" + - "\tworkspace\x18\x03 \x01(\tR\tworkspace\x12!\n" + - "\freview_token\x18\x04 \x01(\tR\vreviewToken\"c\n" + + "\x13last_analyzed_at_ms\x18\x04 \x01(\x03R\x10lastAnalyzedAtMs\"\xe6\x01\n" + + "\x18ApproveDraftChunkRequest\x12\x19\n" + + "\bchunk_id\x18\x02 \x01(\tR\achunkId\x12!\n" + + "\freview_token\x18\x04 \x01(\tR\vreviewToken\x12!\n" + + "\fsandbox_name\x18\x01 \x01(\tR\vsandboxName\x12R\n" + + "\x0fworkspace_scope\x18\x05 \x01(\v2).openshell.datamodel.v1.WorkspaceSelectorR\x0eworkspaceScopeJ\x04\b\x03\x10\x04R\x04nameR\tworkspace\"c\n" + "\x19ApproveDraftChunkResponse\x12%\n" + "\x0epolicy_version\x18\x01 \x01(\rR\rpolicyVersion\x12\x1f\n" + "\vpolicy_hash\x18\x02 \x01(\tR\n" + - "policyHash\"~\n" + - "\x17RejectDraftChunkRequest\x12\x12\n" + - "\x04name\x18\x01 \x01(\tR\x04name\x12\x19\n" + + "policyHash\"\xda\x01\n" + + "\x17RejectDraftChunkRequest\x12\x19\n" + "\bchunk_id\x18\x02 \x01(\tR\achunkId\x12\x16\n" + - "\x06reason\x18\x03 \x01(\tR\x06reason\x12\x1c\n" + - "\tworkspace\x18\x04 \x01(\tR\tworkspace\"\x1a\n" + + "\x06reason\x18\x03 \x01(\tR\x06reason\x12!\n" + + "\fsandbox_name\x18\x01 \x01(\tR\vsandboxName\x12R\n" + + "\x0fworkspace_scope\x18\x05 \x01(\v2).openshell.datamodel.v1.WorkspaceSelectorR\x0eworkspaceScopeJ\x04\b\x04\x10\x05R\x04nameR\tworkspace\"\x1a\n" + "\x18RejectDraftChunkResponse\"R\n" + "\x12DraftChunkApproval\x12\x19\n" + "\bchunk_id\x18\x01 \x01(\tR\achunkId\x12!\n" + - "\freview_token\x18\x02 \x01(\tR\vreviewToken\"\xca\x01\n" + - "\x1cApproveAllDraftChunksRequest\x12\x12\n" + - "\x04name\x18\x01 \x01(\tR\x04name\x128\n" + - "\x18include_security_flagged\x18\x02 \x01(\bR\x16includeSecurityFlagged\x12\x1c\n" + - "\tworkspace\x18\x03 \x01(\tR\tworkspace\x12>\n" + - "\tapprovals\x18\x04 \x03(\v2 .openshell.v1.DraftChunkApprovalR\tapprovals\"\xb7\x01\n" + + "\freview_token\x18\x02 \x01(\tR\vreviewToken\"\xa6\x02\n" + + "\x1cApproveAllDraftChunksRequest\x128\n" + + "\x18include_security_flagged\x18\x02 \x01(\bR\x16includeSecurityFlagged\x12>\n" + + "\tapprovals\x18\x04 \x03(\v2 .openshell.v1.DraftChunkApprovalR\tapprovals\x12!\n" + + "\fsandbox_name\x18\x01 \x01(\tR\vsandboxName\x12R\n" + + "\x0fworkspace_scope\x18\x05 \x01(\v2).openshell.datamodel.v1.WorkspaceSelectorR\x0eworkspaceScopeJ\x04\b\x03\x10\x04R\x04nameR\tworkspace\"\xb7\x01\n" + "\x1dApproveAllDraftChunksResponse\x12%\n" + "\x0epolicy_version\x18\x01 \x01(\rR\rpolicyVersion\x12\x1f\n" + "\vpolicy_hash\x18\x02 \x01(\tR\n" + "policyHash\x12'\n" + "\x0fchunks_approved\x18\x03 \x01(\rR\x0echunksApproved\x12%\n" + - "\x0echunks_skipped\x18\x04 \x01(\rR\rchunksSkipped\"\xb2\x01\n" + - "\x15EditDraftChunkRequest\x12\x12\n" + - "\x04name\x18\x01 \x01(\tR\x04name\x12\x19\n" + + "\x0echunks_skipped\x18\x04 \x01(\rR\rchunksSkipped\"\x8e\x02\n" + + "\x15EditDraftChunkRequest\x12\x19\n" + "\bchunk_id\x18\x02 \x01(\tR\achunkId\x12L\n" + - "\rproposed_rule\x18\x03 \x01(\v2'.openshell.sandbox.v1.NetworkPolicyRuleR\fproposedRule\x12\x1c\n" + - "\tworkspace\x18\x04 \x01(\tR\tworkspace\"\x18\n" + - "\x16EditDraftChunkResponse\"d\n" + - "\x15UndoDraftChunkRequest\x12\x12\n" + - "\x04name\x18\x01 \x01(\tR\x04name\x12\x19\n" + - "\bchunk_id\x18\x02 \x01(\tR\achunkId\x12\x1c\n" + - "\tworkspace\x18\x03 \x01(\tR\tworkspace\"`\n" + + "\rproposed_rule\x18\x03 \x01(\v2'.openshell.sandbox.v1.NetworkPolicyRuleR\fproposedRule\x12!\n" + + "\fsandbox_name\x18\x01 \x01(\tR\vsandboxName\x12R\n" + + "\x0fworkspace_scope\x18\x05 \x01(\v2).openshell.datamodel.v1.WorkspaceSelectorR\x0eworkspaceScopeJ\x04\b\x04\x10\x05R\x04nameR\tworkspace\"\x18\n" + + "\x16EditDraftChunkResponse\"\xc0\x01\n" + + "\x15UndoDraftChunkRequest\x12\x19\n" + + "\bchunk_id\x18\x02 \x01(\tR\achunkId\x12!\n" + + "\fsandbox_name\x18\x01 \x01(\tR\vsandboxName\x12R\n" + + "\x0fworkspace_scope\x18\x04 \x01(\v2).openshell.datamodel.v1.WorkspaceSelectorR\x0eworkspaceScopeJ\x04\b\x03\x10\x04R\x04nameR\tworkspace\"`\n" + "\x16UndoDraftChunkResponse\x12%\n" + "\x0epolicy_version\x18\x01 \x01(\rR\rpolicyVersion\x12\x1f\n" + "\vpolicy_hash\x18\x02 \x01(\tR\n" + - "policyHash\"K\n" + - "\x17ClearDraftChunksRequest\x12\x12\n" + - "\x04name\x18\x01 \x01(\tR\x04name\x12\x1c\n" + - "\tworkspace\x18\x02 \x01(\tR\tworkspace\"A\n" + + "policyHash\"\xa7\x01\n" + + "\x17ClearDraftChunksRequest\x12!\n" + + "\fsandbox_name\x18\x01 \x01(\tR\vsandboxName\x12R\n" + + "\x0fworkspace_scope\x18\x03 \x01(\v2).openshell.datamodel.v1.WorkspaceSelectorR\x0eworkspaceScopeJ\x04\b\x02\x10\x03R\x04nameR\tworkspace\"A\n" + "\x18ClearDraftChunksResponse\x12%\n" + - "\x0echunks_cleared\x18\x01 \x01(\rR\rchunksCleared\"J\n" + - "\x16GetDraftHistoryRequest\x12\x12\n" + - "\x04name\x18\x01 \x01(\tR\x04name\x12\x1c\n" + - "\tworkspace\x18\x02 \x01(\tR\tworkspace\"\x92\x01\n" + + "\x0echunks_cleared\x18\x01 \x01(\rR\rchunksCleared\"\xa6\x01\n" + + "\x16GetDraftHistoryRequest\x12!\n" + + "\fsandbox_name\x18\x01 \x01(\tR\vsandboxName\x12R\n" + + "\x0fworkspace_scope\x18\x03 \x01(\v2).openshell.datamodel.v1.WorkspaceSelectorR\x0eworkspaceScopeJ\x04\b\x02\x10\x03R\x04nameR\tworkspace\"\x92\x01\n" + "\x11DraftHistoryEntry\x12!\n" + "\ftimestamp_ms\x18\x01 \x01(\x03R\vtimestampMs\x12\x1d\n" + "\n" + @@ -16863,19 +16813,20 @@ var file_openshell_proto_goTypes = []any{ (*sandboxv1.SandboxPolicy)(nil), // 249: openshell.sandbox.v1.SandboxPolicy (*structpb.Struct)(nil), // 250: google.protobuf.Struct (*durationpb.Duration)(nil), // 251: google.protobuf.Duration - (*datamodelv1.Provider)(nil), // 252: openshell.datamodel.v1.Provider - (*datamodelv1.CredentialHandle)(nil), // 253: openshell.datamodel.v1.CredentialHandle - (*sandboxv1.NetworkEndpoint)(nil), // 254: openshell.sandbox.v1.NetworkEndpoint - (*sandboxv1.NetworkBinary)(nil), // 255: openshell.sandbox.v1.NetworkBinary - (*sandboxv1.SettingValue)(nil), // 256: openshell.sandbox.v1.SettingValue - (*sandboxv1.NetworkPolicyRule)(nil), // 257: openshell.sandbox.v1.NetworkPolicyRule - (*sandboxv1.L7DenyRule)(nil), // 258: openshell.sandbox.v1.L7DenyRule - (*sandboxv1.L7Rule)(nil), // 259: openshell.sandbox.v1.L7Rule - (*datamodelv1.Workspace)(nil), // 260: openshell.datamodel.v1.Workspace - (*sandboxv1.GetSandboxConfigRequest)(nil), // 261: openshell.sandbox.v1.GetSandboxConfigRequest - (*sandboxv1.GetGatewayConfigRequest)(nil), // 262: openshell.sandbox.v1.GetGatewayConfigRequest - (*sandboxv1.GetSandboxConfigResponse)(nil), // 263: openshell.sandbox.v1.GetSandboxConfigResponse - (*sandboxv1.GetGatewayConfigResponse)(nil), // 264: openshell.sandbox.v1.GetGatewayConfigResponse + (*datamodelv1.WorkspaceSelector)(nil), // 252: openshell.datamodel.v1.WorkspaceSelector + (*datamodelv1.Provider)(nil), // 253: openshell.datamodel.v1.Provider + (*datamodelv1.CredentialHandle)(nil), // 254: openshell.datamodel.v1.CredentialHandle + (*sandboxv1.NetworkEndpoint)(nil), // 255: openshell.sandbox.v1.NetworkEndpoint + (*sandboxv1.NetworkBinary)(nil), // 256: openshell.sandbox.v1.NetworkBinary + (*sandboxv1.SettingValue)(nil), // 257: openshell.sandbox.v1.SettingValue + (*sandboxv1.NetworkPolicyRule)(nil), // 258: openshell.sandbox.v1.NetworkPolicyRule + (*sandboxv1.L7DenyRule)(nil), // 259: openshell.sandbox.v1.L7DenyRule + (*sandboxv1.L7Rule)(nil), // 260: openshell.sandbox.v1.L7Rule + (*datamodelv1.Workspace)(nil), // 261: openshell.datamodel.v1.Workspace + (*sandboxv1.GetSandboxConfigRequest)(nil), // 262: openshell.sandbox.v1.GetSandboxConfigRequest + (*sandboxv1.GetGatewayConfigRequest)(nil), // 263: openshell.sandbox.v1.GetGatewayConfigRequest + (*sandboxv1.GetSandboxConfigResponse)(nil), // 264: openshell.sandbox.v1.GetSandboxConfigResponse + (*sandboxv1.GetGatewayConfigResponse)(nil), // 265: openshell.sandbox.v1.GetGatewayConfigResponse } var file_openshell_proto_depIdxs = []int32{ 221, // 0: openshell.v1.RefreshSandboxTokenResponse.extension_credentials:type_name -> openshell.v1.ExtensionServiceCredential @@ -16917,304 +16868,347 @@ var file_openshell_proto_depIdxs = []int32{ 25, // 36: openshell.v1.CreateSandboxRequest.spec:type_name -> openshell.v1.SandboxSpec 228, // 37: openshell.v1.CreateSandboxRequest.labels:type_name -> openshell.v1.CreateSandboxRequest.LabelsEntry 229, // 38: openshell.v1.CreateSandboxRequest.annotations:type_name -> openshell.v1.CreateSandboxRequest.AnnotationsEntry - 29, // 39: openshell.v1.CreateSandboxTemplateRequest.template:type_name -> openshell.v1.SandboxWorkloadTemplate - 29, // 40: openshell.v1.SandboxTemplateResponse.template:type_name -> openshell.v1.SandboxWorkloadTemplate - 29, // 41: openshell.v1.ListSandboxTemplatesResponse.templates:type_name -> openshell.v1.SandboxWorkloadTemplate - 24, // 42: openshell.v1.SandboxResponse.sandbox:type_name -> openshell.v1.Sandbox - 24, // 43: openshell.v1.ListSandboxesResponse.sandboxes:type_name -> openshell.v1.Sandbox - 252, // 44: openshell.v1.ListSandboxProvidersResponse.providers:type_name -> openshell.datamodel.v1.Provider - 24, // 45: openshell.v1.AttachSandboxProviderResponse.sandbox:type_name -> openshell.v1.Sandbox - 24, // 46: openshell.v1.DetachSandboxProviderResponse.sandbox:type_name -> openshell.v1.Sandbox - 72, // 47: openshell.v1.ListServicesResponse.services:type_name -> openshell.v1.ServiceEndpointResponse - 248, // 48: openshell.v1.ServiceEndpoint.metadata:type_name -> openshell.datamodel.v1.ObjectMeta - 71, // 49: openshell.v1.ServiceEndpointResponse.endpoint:type_name -> openshell.v1.ServiceEndpoint - 230, // 50: openshell.v1.ExecSandboxRequest.environment:type_name -> openshell.v1.ExecSandboxRequest.EnvironmentEntry - 76, // 51: openshell.v1.ExecSandboxEvent.stdout:type_name -> openshell.v1.ExecSandboxStdout - 77, // 52: openshell.v1.ExecSandboxEvent.stderr:type_name -> openshell.v1.ExecSandboxStderr - 78, // 53: openshell.v1.ExecSandboxEvent.exit:type_name -> openshell.v1.ExecSandboxExit - 170, // 54: openshell.v1.TcpForwardInit.ssh:type_name -> openshell.v1.SshRelayTarget - 171, // 55: openshell.v1.TcpForwardInit.tcp:type_name -> openshell.v1.TcpRelayTarget - 80, // 56: openshell.v1.TcpForwardFrame.init:type_name -> openshell.v1.TcpForwardInit - 75, // 57: openshell.v1.ExecSandboxInput.start:type_name -> openshell.v1.ExecSandboxRequest - 83, // 58: openshell.v1.ExecSandboxInput.resize:type_name -> openshell.v1.ExecSandboxWindowResize - 248, // 59: openshell.v1.SshSession.metadata:type_name -> openshell.datamodel.v1.ObjectMeta - 24, // 60: openshell.v1.SandboxStreamEvent.sandbox:type_name -> openshell.v1.Sandbox - 87, // 61: openshell.v1.SandboxStreamEvent.log:type_name -> openshell.v1.SandboxLogLine - 38, // 62: openshell.v1.SandboxStreamEvent.event:type_name -> openshell.v1.PlatformEvent - 88, // 63: openshell.v1.SandboxStreamEvent.warning:type_name -> openshell.v1.SandboxStreamWarning - 181, // 64: openshell.v1.SandboxStreamEvent.draft_policy_update:type_name -> openshell.v1.DraftPolicyUpdate - 231, // 65: openshell.v1.SandboxLogLine.fields:type_name -> openshell.v1.SandboxLogLine.FieldsEntry - 252, // 66: openshell.v1.CreateProviderRequest.provider:type_name -> openshell.datamodel.v1.Provider - 252, // 67: openshell.v1.UpdateProviderRequest.provider:type_name -> openshell.datamodel.v1.Provider - 232, // 68: openshell.v1.UpdateProviderRequest.credential_expires_at_ms:type_name -> openshell.v1.UpdateProviderRequest.CredentialExpiresAtMsEntry - 252, // 69: openshell.v1.ProviderResponse.provider:type_name -> openshell.datamodel.v1.Provider - 252, // 70: openshell.v1.ListProvidersResponse.providers:type_name -> openshell.datamodel.v1.Provider - 119, // 71: openshell.v1.ProviderProfileImportItem.profile:type_name -> openshell.v1.ProviderProfile - 100, // 72: openshell.v1.ProviderCredentialTokenGrant.audience_overrides:type_name -> openshell.v1.ProviderCredentialTokenGrantAudienceOverride - 1, // 73: openshell.v1.ProviderCredentialTokenGrant.grant_type:type_name -> openshell.v1.ProviderCredentialTokenGrantType - 101, // 74: openshell.v1.ProviderCredentialTokenGrant.subject_token:type_name -> openshell.v1.ProviderCredentialTokenGrantSubjectToken - 106, // 75: openshell.v1.ProviderProfileCredential.refresh:type_name -> openshell.v1.ProviderCredentialRefresh - 102, // 76: openshell.v1.ProviderProfileCredential.token_grant:type_name -> openshell.v1.ProviderCredentialTokenGrant - 2, // 77: openshell.v1.ProviderCredentialRefresh.strategy:type_name -> openshell.v1.ProviderCredentialRefreshStrategy - 104, // 78: openshell.v1.ProviderCredentialRefresh.material:type_name -> openshell.v1.ProviderCredentialRefreshMaterial - 105, // 79: openshell.v1.ProviderCredentialRefresh.additional_outputs:type_name -> openshell.v1.ProviderCredentialRefreshOutput - 2, // 80: openshell.v1.ProviderCredentialRefreshStatus.strategy:type_name -> openshell.v1.ProviderCredentialRefreshStrategy - 7, // 81: openshell.v1.ProviderCredentialRefreshStatus.recovery_action:type_name -> openshell.v1.ProviderCredentialRefreshRecoveryAction - 248, // 82: openshell.v1.StoredProviderCredentialRefreshState.metadata:type_name -> openshell.datamodel.v1.ObjectMeta - 2, // 83: openshell.v1.StoredProviderCredentialRefreshState.strategy:type_name -> openshell.v1.ProviderCredentialRefreshStrategy - 233, // 84: openshell.v1.StoredProviderCredentialRefreshState.material:type_name -> openshell.v1.StoredProviderCredentialRefreshState.MaterialEntry - 234, // 85: openshell.v1.StoredProviderCredentialRefreshState.additional_output_keys:type_name -> openshell.v1.StoredProviderCredentialRefreshState.AdditionalOutputKeysEntry - 235, // 86: openshell.v1.StoredProviderCredentialRefreshState.secret_material_handles:type_name -> openshell.v1.StoredProviderCredentialRefreshState.SecretMaterialHandlesEntry - 110, // 87: openshell.v1.StoredProviderCredentialRefreshState.pending_secret_deletions:type_name -> openshell.v1.StoredRefreshMaterialDeletion - 7, // 88: openshell.v1.StoredProviderCredentialRefreshState.recovery_action:type_name -> openshell.v1.ProviderCredentialRefreshRecoveryAction - 253, // 89: openshell.v1.StoredRefreshMaterialDeletion.handle:type_name -> openshell.datamodel.v1.CredentialHandle - 107, // 90: openshell.v1.GetProviderRefreshStatusResponse.credentials:type_name -> openshell.v1.ProviderCredentialRefreshStatus - 2, // 91: openshell.v1.ConfigureProviderRefreshRequest.strategy:type_name -> openshell.v1.ProviderCredentialRefreshStrategy - 236, // 92: openshell.v1.ConfigureProviderRefreshRequest.material:type_name -> openshell.v1.ConfigureProviderRefreshRequest.MaterialEntry - 107, // 93: openshell.v1.ConfigureProviderRefreshResponse.status:type_name -> openshell.v1.ProviderCredentialRefreshStatus - 107, // 94: openshell.v1.RotateProviderCredentialResponse.status:type_name -> openshell.v1.ProviderCredentialRefreshStatus - 3, // 95: openshell.v1.ProviderProfile.category:type_name -> openshell.v1.ProviderProfileCategory - 103, // 96: openshell.v1.ProviderProfile.credentials:type_name -> openshell.v1.ProviderProfileCredential - 254, // 97: openshell.v1.ProviderProfile.endpoints:type_name -> openshell.sandbox.v1.NetworkEndpoint - 255, // 98: openshell.v1.ProviderProfile.binaries:type_name -> openshell.sandbox.v1.NetworkBinary - 108, // 99: openshell.v1.ProviderProfile.discovery:type_name -> openshell.v1.ProviderProfileDiscovery - 237, // 100: openshell.v1.ProviderProfile.annotations:type_name -> openshell.v1.ProviderProfile.AnnotationsEntry - 248, // 101: openshell.v1.StoredProviderProfile.metadata:type_name -> openshell.datamodel.v1.ObjectMeta - 119, // 102: openshell.v1.StoredProviderProfile.profile:type_name -> openshell.v1.ProviderProfile - 119, // 103: openshell.v1.ProviderProfileResponse.profile:type_name -> openshell.v1.ProviderProfile - 119, // 104: openshell.v1.ListProviderProfilesResponse.profiles:type_name -> openshell.v1.ProviderProfile - 98, // 105: openshell.v1.ImportProviderProfilesRequest.profiles:type_name -> openshell.v1.ProviderProfileImportItem - 99, // 106: openshell.v1.ImportProviderProfilesResponse.diagnostics:type_name -> openshell.v1.ProviderProfileDiagnostic - 119, // 107: openshell.v1.ImportProviderProfilesResponse.profiles:type_name -> openshell.v1.ProviderProfile - 98, // 108: openshell.v1.UpdateProviderProfilesRequest.profile:type_name -> openshell.v1.ProviderProfileImportItem - 99, // 109: openshell.v1.UpdateProviderProfilesResponse.diagnostics:type_name -> openshell.v1.ProviderProfileDiagnostic - 119, // 110: openshell.v1.UpdateProviderProfilesResponse.profile:type_name -> openshell.v1.ProviderProfile - 98, // 111: openshell.v1.LintProviderProfilesRequest.profiles:type_name -> openshell.v1.ProviderProfileImportItem - 99, // 112: openshell.v1.LintProviderProfilesResponse.diagnostics:type_name -> openshell.v1.ProviderProfileDiagnostic - 133, // 113: openshell.v1.StaticCredentialBinding.endpoints:type_name -> openshell.v1.StaticCredentialEndpointBinding - 238, // 114: openshell.v1.GetSandboxProviderEnvironmentResponse.environment:type_name -> openshell.v1.GetSandboxProviderEnvironmentResponse.EnvironmentEntry - 239, // 115: openshell.v1.GetSandboxProviderEnvironmentResponse.credential_expires_at_ms:type_name -> openshell.v1.GetSandboxProviderEnvironmentResponse.CredentialExpiresAtMsEntry - 240, // 116: openshell.v1.GetSandboxProviderEnvironmentResponse.dynamic_credentials:type_name -> openshell.v1.GetSandboxProviderEnvironmentResponse.DynamicCredentialsEntry - 241, // 117: openshell.v1.GetSandboxProviderEnvironmentResponse.static_credential_bindings:type_name -> openshell.v1.GetSandboxProviderEnvironmentResponse.StaticCredentialBindingsEntry - 249, // 118: openshell.v1.UpdateConfigRequest.policy:type_name -> openshell.sandbox.v1.SandboxPolicy - 256, // 119: openshell.v1.UpdateConfigRequest.setting_value:type_name -> openshell.sandbox.v1.SettingValue - 139, // 120: openshell.v1.UpdateConfigRequest.merge_operations:type_name -> openshell.v1.PolicyMergeOperation - 242, // 121: openshell.v1.UpdateConfigRequest.annotations:type_name -> openshell.v1.UpdateConfigRequest.AnnotationsEntry - 140, // 122: openshell.v1.PolicyMergeOperation.add_rule:type_name -> openshell.v1.AddNetworkRule - 141, // 123: openshell.v1.PolicyMergeOperation.remove_endpoint:type_name -> openshell.v1.RemoveNetworkEndpoint - 142, // 124: openshell.v1.PolicyMergeOperation.remove_rule:type_name -> openshell.v1.RemoveNetworkRule - 143, // 125: openshell.v1.PolicyMergeOperation.add_deny_rules:type_name -> openshell.v1.AddDenyRules - 144, // 126: openshell.v1.PolicyMergeOperation.add_allow_rules:type_name -> openshell.v1.AddAllowRules - 145, // 127: openshell.v1.PolicyMergeOperation.remove_binary:type_name -> openshell.v1.RemoveNetworkBinary - 257, // 128: openshell.v1.AddNetworkRule.rule:type_name -> openshell.sandbox.v1.NetworkPolicyRule - 258, // 129: openshell.v1.AddDenyRules.deny_rules:type_name -> openshell.sandbox.v1.L7DenyRule - 259, // 130: openshell.v1.AddAllowRules.rules:type_name -> openshell.sandbox.v1.L7Rule - 243, // 131: openshell.v1.UpdateConfigResponse.annotations:type_name -> openshell.v1.UpdateConfigResponse.AnnotationsEntry - 153, // 132: openshell.v1.GetSandboxPolicyStatusResponse.revision:type_name -> openshell.v1.SandboxPolicyRevision - 153, // 133: openshell.v1.ListSandboxPoliciesResponse.revisions:type_name -> openshell.v1.SandboxPolicyRevision - 4, // 134: openshell.v1.ReportPolicyStatusRequest.status:type_name -> openshell.v1.PolicyStatus - 4, // 135: openshell.v1.SandboxPolicyRevision.status:type_name -> openshell.v1.PolicyStatus - 249, // 136: openshell.v1.SandboxPolicyRevision.policy:type_name -> openshell.sandbox.v1.SandboxPolicy - 244, // 137: openshell.v1.SandboxPolicyRevision.provenance:type_name -> openshell.v1.SandboxPolicyRevision.ProvenanceEntry - 87, // 138: openshell.v1.PushSandboxLogsRequest.logs:type_name -> openshell.v1.SandboxLogLine - 87, // 139: openshell.v1.GetSandboxLogsResponse.logs:type_name -> openshell.v1.SandboxLogLine - 160, // 140: openshell.v1.SupervisorMessage.hello:type_name -> openshell.v1.SupervisorHello - 163, // 141: openshell.v1.SupervisorMessage.heartbeat:type_name -> openshell.v1.SupervisorHeartbeat - 174, // 142: openshell.v1.SupervisorMessage.relay_open_result:type_name -> openshell.v1.RelayOpenResult - 175, // 143: openshell.v1.SupervisorMessage.relay_close:type_name -> openshell.v1.RelayClose - 161, // 144: openshell.v1.GatewayMessage.session_accepted:type_name -> openshell.v1.SessionAccepted - 162, // 145: openshell.v1.GatewayMessage.session_rejected:type_name -> openshell.v1.SessionRejected - 164, // 146: openshell.v1.GatewayMessage.heartbeat:type_name -> openshell.v1.GatewayHeartbeat - 169, // 147: openshell.v1.GatewayMessage.relay_open:type_name -> openshell.v1.RelayOpen - 175, // 148: openshell.v1.GatewayMessage.relay_close:type_name -> openshell.v1.RelayClose - 170, // 149: openshell.v1.RelayOpen.ssh:type_name -> openshell.v1.SshRelayTarget - 171, // 150: openshell.v1.RelayOpen.tcp:type_name -> openshell.v1.TcpRelayTarget - 172, // 151: openshell.v1.RelayFrame.init:type_name -> openshell.v1.RelayInit - 176, // 152: openshell.v1.DenialSummary.l7_request_samples:type_name -> openshell.v1.L7RequestSample - 178, // 153: openshell.v1.NetworkActivitySummary.denials_by_group:type_name -> openshell.v1.DenialGroupCount - 257, // 154: openshell.v1.PolicyChunk.proposed_rule:type_name -> openshell.sandbox.v1.NetworkPolicyRule - 249, // 155: openshell.v1.PolicyChunk.current_effective_policy:type_name -> openshell.sandbox.v1.SandboxPolicy - 249, // 156: openshell.v1.PolicyChunk.candidate_effective_policy:type_name -> openshell.sandbox.v1.SandboxPolicy - 177, // 157: openshell.v1.SubmitPolicyAnalysisRequest.summaries:type_name -> openshell.v1.DenialSummary - 180, // 158: openshell.v1.SubmitPolicyAnalysisRequest.proposed_chunks:type_name -> openshell.v1.PolicyChunk - 179, // 159: openshell.v1.SubmitPolicyAnalysisRequest.network_activity_summaries:type_name -> openshell.v1.NetworkActivitySummary - 180, // 160: openshell.v1.GetDraftPolicyResponse.chunks:type_name -> openshell.v1.PolicyChunk - 190, // 161: openshell.v1.ApproveAllDraftChunksRequest.approvals:type_name -> openshell.v1.DraftChunkApproval - 257, // 162: openshell.v1.EditDraftChunkRequest.proposed_rule:type_name -> openshell.sandbox.v1.NetworkPolicyRule - 200, // 163: openshell.v1.GetDraftHistoryResponse.entries:type_name -> openshell.v1.DraftHistoryEntry - 249, // 164: openshell.v1.PolicyRevisionPayload.policy:type_name -> openshell.sandbox.v1.SandboxPolicy - 245, // 165: openshell.v1.PolicyRevisionPayload.provenance:type_name -> openshell.v1.PolicyRevisionPayload.ProvenanceEntry - 257, // 166: openshell.v1.DraftChunkPayload.proposed_rule:type_name -> openshell.sandbox.v1.NetworkPolicyRule - 249, // 167: openshell.v1.DraftChunkPayload.current_effective_policy:type_name -> openshell.sandbox.v1.SandboxPolicy - 249, // 168: openshell.v1.DraftChunkPayload.candidate_effective_policy:type_name -> openshell.sandbox.v1.SandboxPolicy - 246, // 169: openshell.v1.StoredPolicyRevision.provenance:type_name -> openshell.v1.StoredPolicyRevision.ProvenanceEntry - 249, // 170: openshell.v1.StoredDraftChunk.current_effective_policy:type_name -> openshell.sandbox.v1.SandboxPolicy - 249, // 171: openshell.v1.StoredDraftChunk.candidate_effective_policy:type_name -> openshell.sandbox.v1.SandboxPolicy - 247, // 172: openshell.v1.CreateWorkspaceRequest.labels:type_name -> openshell.v1.CreateWorkspaceRequest.LabelsEntry - 260, // 173: openshell.v1.CreateWorkspaceResponse.workspace:type_name -> openshell.datamodel.v1.Workspace - 260, // 174: openshell.v1.GetWorkspaceResponse.workspace:type_name -> openshell.datamodel.v1.Workspace - 260, // 175: openshell.v1.ListWorkspacesResponse.workspaces:type_name -> openshell.datamodel.v1.Workspace - 248, // 176: openshell.v1.WorkspaceMember.metadata:type_name -> openshell.datamodel.v1.ObjectMeta - 6, // 177: openshell.v1.WorkspaceMember.role:type_name -> openshell.v1.WorkspaceRole - 6, // 178: openshell.v1.AddWorkspaceMemberRequest.role:type_name -> openshell.v1.WorkspaceRole - 214, // 179: openshell.v1.AddWorkspaceMemberResponse.member:type_name -> openshell.v1.WorkspaceMember - 214, // 180: openshell.v1.ListWorkspaceMembersResponse.members:type_name -> openshell.v1.WorkspaceMember - 253, // 181: openshell.v1.StoredProviderCredentialRefreshState.SecretMaterialHandlesEntry.value:type_name -> openshell.datamodel.v1.CredentialHandle - 103, // 182: openshell.v1.GetSandboxProviderEnvironmentResponse.DynamicCredentialsEntry.value:type_name -> openshell.v1.ProviderProfileCredential - 134, // 183: openshell.v1.GetSandboxProviderEnvironmentResponse.StaticCredentialBindingsEntry.value:type_name -> openshell.v1.StaticCredentialBinding - 12, // 184: openshell.v1.OpenShell.Health:input_type -> openshell.v1.HealthRequest - 14, // 185: openshell.v1.OpenShell.GetCurrentUser:input_type -> openshell.v1.GetCurrentUserRequest - 16, // 186: openshell.v1.OpenShell.GetGatewayInfo:input_type -> openshell.v1.GetGatewayInfoRequest - 39, // 187: openshell.v1.OpenShell.CreateSandbox:input_type -> openshell.v1.CreateSandboxRequest - 47, // 188: openshell.v1.OpenShell.BeginRootfsTarStaging:input_type -> openshell.v1.BeginRootfsTarStagingRequest - 49, // 189: openshell.v1.OpenShell.GetSandbox:input_type -> openshell.v1.GetSandboxRequest - 50, // 190: openshell.v1.OpenShell.ListSandboxes:input_type -> openshell.v1.ListSandboxesRequest - 40, // 191: openshell.v1.OpenShell.CreateSandboxTemplate:input_type -> openshell.v1.CreateSandboxTemplateRequest - 41, // 192: openshell.v1.OpenShell.GetSandboxTemplate:input_type -> openshell.v1.GetSandboxTemplateRequest - 42, // 193: openshell.v1.OpenShell.ListSandboxTemplates:input_type -> openshell.v1.ListSandboxTemplatesRequest - 43, // 194: openshell.v1.OpenShell.DeleteSandboxTemplate:input_type -> openshell.v1.DeleteSandboxTemplateRequest - 51, // 195: openshell.v1.OpenShell.ListSandboxProviders:input_type -> openshell.v1.ListSandboxProvidersRequest - 52, // 196: openshell.v1.OpenShell.AttachSandboxProvider:input_type -> openshell.v1.AttachSandboxProviderRequest - 53, // 197: openshell.v1.OpenShell.DetachSandboxProvider:input_type -> openshell.v1.DetachSandboxProviderRequest - 54, // 198: openshell.v1.OpenShell.DeleteSandbox:input_type -> openshell.v1.DeleteSandboxRequest - 55, // 199: openshell.v1.OpenShell.StopSandbox:input_type -> openshell.v1.StopSandboxRequest - 56, // 200: openshell.v1.OpenShell.StartSandbox:input_type -> openshell.v1.StartSandboxRequest - 63, // 201: openshell.v1.OpenShell.CreateSshSession:input_type -> openshell.v1.CreateSshSessionRequest - 65, // 202: openshell.v1.OpenShell.ExposeService:input_type -> openshell.v1.ExposeServiceRequest - 66, // 203: openshell.v1.OpenShell.GetService:input_type -> openshell.v1.GetServiceRequest - 67, // 204: openshell.v1.OpenShell.ListServices:input_type -> openshell.v1.ListServicesRequest - 69, // 205: openshell.v1.OpenShell.DeleteService:input_type -> openshell.v1.DeleteServiceRequest - 73, // 206: openshell.v1.OpenShell.RevokeSshSession:input_type -> openshell.v1.RevokeSshSessionRequest - 75, // 207: openshell.v1.OpenShell.ExecSandbox:input_type -> openshell.v1.ExecSandboxRequest - 81, // 208: openshell.v1.OpenShell.ForwardTcp:input_type -> openshell.v1.TcpForwardFrame - 82, // 209: openshell.v1.OpenShell.ExecSandboxInteractive:input_type -> openshell.v1.ExecSandboxInput - 89, // 210: openshell.v1.OpenShell.CreateProvider:input_type -> openshell.v1.CreateProviderRequest - 90, // 211: openshell.v1.OpenShell.GetProvider:input_type -> openshell.v1.GetProviderRequest - 91, // 212: openshell.v1.OpenShell.ListProviders:input_type -> openshell.v1.ListProvidersRequest - 96, // 213: openshell.v1.OpenShell.ListProviderProfiles:input_type -> openshell.v1.ListProviderProfilesRequest - 97, // 214: openshell.v1.OpenShell.GetProviderProfile:input_type -> openshell.v1.GetProviderProfileRequest - 123, // 215: openshell.v1.OpenShell.ImportProviderProfiles:input_type -> openshell.v1.ImportProviderProfilesRequest - 125, // 216: openshell.v1.OpenShell.UpdateProviderProfiles:input_type -> openshell.v1.UpdateProviderProfilesRequest - 127, // 217: openshell.v1.OpenShell.LintProviderProfiles:input_type -> openshell.v1.LintProviderProfilesRequest - 92, // 218: openshell.v1.OpenShell.UpdateProvider:input_type -> openshell.v1.UpdateProviderRequest - 111, // 219: openshell.v1.OpenShell.GetProviderRefreshStatus:input_type -> openshell.v1.GetProviderRefreshStatusRequest - 113, // 220: openshell.v1.OpenShell.ConfigureProviderRefresh:input_type -> openshell.v1.ConfigureProviderRefreshRequest - 115, // 221: openshell.v1.OpenShell.RotateProviderCredential:input_type -> openshell.v1.RotateProviderCredentialRequest - 117, // 222: openshell.v1.OpenShell.DeleteProviderRefresh:input_type -> openshell.v1.DeleteProviderRefreshRequest - 93, // 223: openshell.v1.OpenShell.DeleteProvider:input_type -> openshell.v1.DeleteProviderRequest - 130, // 224: openshell.v1.OpenShell.DeleteProviderProfile:input_type -> openshell.v1.DeleteProviderProfileRequest - 261, // 225: openshell.v1.OpenShell.GetSandboxConfig:input_type -> openshell.sandbox.v1.GetSandboxConfigRequest - 262, // 226: openshell.v1.OpenShell.GetGatewayConfig:input_type -> openshell.sandbox.v1.GetGatewayConfigRequest - 138, // 227: openshell.v1.OpenShell.UpdateConfig:input_type -> openshell.v1.UpdateConfigRequest - 147, // 228: openshell.v1.OpenShell.GetSandboxPolicyStatus:input_type -> openshell.v1.GetSandboxPolicyStatusRequest - 149, // 229: openshell.v1.OpenShell.ListSandboxPolicies:input_type -> openshell.v1.ListSandboxPoliciesRequest - 151, // 230: openshell.v1.OpenShell.ReportPolicyStatus:input_type -> openshell.v1.ReportPolicyStatusRequest - 132, // 231: openshell.v1.OpenShell.GetSandboxProviderEnvironment:input_type -> openshell.v1.GetSandboxProviderEnvironmentRequest - 136, // 232: openshell.v1.OpenShell.ExchangeProviderSubjectToken:input_type -> openshell.v1.ExchangeProviderSubjectTokenRequest - 154, // 233: openshell.v1.OpenShell.GetSandboxLogs:input_type -> openshell.v1.GetSandboxLogsRequest - 155, // 234: openshell.v1.OpenShell.PushSandboxLogs:input_type -> openshell.v1.PushSandboxLogsRequest - 158, // 235: openshell.v1.OpenShell.ConnectSupervisor:input_type -> openshell.v1.SupervisorMessage - 165, // 236: openshell.v1.OpenShell.ReportMainProcessExit:input_type -> openshell.v1.ReportMainProcessExitRequest - 167, // 237: openshell.v1.OpenShell.FinalizeMainProcessExit:input_type -> openshell.v1.FinalizeMainProcessExitRequest - 173, // 238: openshell.v1.OpenShell.RelayStream:input_type -> openshell.v1.RelayFrame - 85, // 239: openshell.v1.OpenShell.WatchSandbox:input_type -> openshell.v1.WatchSandboxRequest - 182, // 240: openshell.v1.OpenShell.SubmitPolicyAnalysis:input_type -> openshell.v1.SubmitPolicyAnalysisRequest - 184, // 241: openshell.v1.OpenShell.GetDraftPolicy:input_type -> openshell.v1.GetDraftPolicyRequest - 186, // 242: openshell.v1.OpenShell.ApproveDraftChunk:input_type -> openshell.v1.ApproveDraftChunkRequest - 188, // 243: openshell.v1.OpenShell.RejectDraftChunk:input_type -> openshell.v1.RejectDraftChunkRequest - 191, // 244: openshell.v1.OpenShell.ApproveAllDraftChunks:input_type -> openshell.v1.ApproveAllDraftChunksRequest - 193, // 245: openshell.v1.OpenShell.EditDraftChunk:input_type -> openshell.v1.EditDraftChunkRequest - 195, // 246: openshell.v1.OpenShell.UndoDraftChunk:input_type -> openshell.v1.UndoDraftChunkRequest - 197, // 247: openshell.v1.OpenShell.ClearDraftChunks:input_type -> openshell.v1.ClearDraftChunksRequest - 199, // 248: openshell.v1.OpenShell.GetDraftHistory:input_type -> openshell.v1.GetDraftHistoryRequest - 8, // 249: openshell.v1.OpenShell.IssueSandboxToken:input_type -> openshell.v1.IssueSandboxTokenRequest - 10, // 250: openshell.v1.OpenShell.RefreshSandboxToken:input_type -> openshell.v1.RefreshSandboxTokenRequest - 206, // 251: openshell.v1.OpenShell.CreateWorkspace:input_type -> openshell.v1.CreateWorkspaceRequest - 208, // 252: openshell.v1.OpenShell.GetWorkspace:input_type -> openshell.v1.GetWorkspaceRequest - 210, // 253: openshell.v1.OpenShell.ListWorkspaces:input_type -> openshell.v1.ListWorkspacesRequest - 212, // 254: openshell.v1.OpenShell.DeleteWorkspace:input_type -> openshell.v1.DeleteWorkspaceRequest - 215, // 255: openshell.v1.OpenShell.AddWorkspaceMember:input_type -> openshell.v1.AddWorkspaceMemberRequest - 217, // 256: openshell.v1.OpenShell.RemoveWorkspaceMember:input_type -> openshell.v1.RemoveWorkspaceMemberRequest - 219, // 257: openshell.v1.OpenShell.ListWorkspaceMembers:input_type -> openshell.v1.ListWorkspaceMembersRequest - 13, // 258: openshell.v1.OpenShell.Health:output_type -> openshell.v1.HealthResponse - 15, // 259: openshell.v1.OpenShell.GetCurrentUser:output_type -> openshell.v1.GetCurrentUserResponse - 17, // 260: openshell.v1.OpenShell.GetGatewayInfo:output_type -> openshell.v1.GetGatewayInfoResponse - 57, // 261: openshell.v1.OpenShell.CreateSandbox:output_type -> openshell.v1.SandboxResponse - 48, // 262: openshell.v1.OpenShell.BeginRootfsTarStaging:output_type -> openshell.v1.BeginRootfsTarStagingResponse - 57, // 263: openshell.v1.OpenShell.GetSandbox:output_type -> openshell.v1.SandboxResponse - 58, // 264: openshell.v1.OpenShell.ListSandboxes:output_type -> openshell.v1.ListSandboxesResponse - 44, // 265: openshell.v1.OpenShell.CreateSandboxTemplate:output_type -> openshell.v1.SandboxTemplateResponse - 44, // 266: openshell.v1.OpenShell.GetSandboxTemplate:output_type -> openshell.v1.SandboxTemplateResponse - 45, // 267: openshell.v1.OpenShell.ListSandboxTemplates:output_type -> openshell.v1.ListSandboxTemplatesResponse - 46, // 268: openshell.v1.OpenShell.DeleteSandboxTemplate:output_type -> openshell.v1.DeleteSandboxTemplateResponse - 59, // 269: openshell.v1.OpenShell.ListSandboxProviders:output_type -> openshell.v1.ListSandboxProvidersResponse - 60, // 270: openshell.v1.OpenShell.AttachSandboxProvider:output_type -> openshell.v1.AttachSandboxProviderResponse - 61, // 271: openshell.v1.OpenShell.DetachSandboxProvider:output_type -> openshell.v1.DetachSandboxProviderResponse - 62, // 272: openshell.v1.OpenShell.DeleteSandbox:output_type -> openshell.v1.DeleteSandboxResponse - 57, // 273: openshell.v1.OpenShell.StopSandbox:output_type -> openshell.v1.SandboxResponse - 57, // 274: openshell.v1.OpenShell.StartSandbox:output_type -> openshell.v1.SandboxResponse - 64, // 275: openshell.v1.OpenShell.CreateSshSession:output_type -> openshell.v1.CreateSshSessionResponse - 72, // 276: openshell.v1.OpenShell.ExposeService:output_type -> openshell.v1.ServiceEndpointResponse - 72, // 277: openshell.v1.OpenShell.GetService:output_type -> openshell.v1.ServiceEndpointResponse - 68, // 278: openshell.v1.OpenShell.ListServices:output_type -> openshell.v1.ListServicesResponse - 70, // 279: openshell.v1.OpenShell.DeleteService:output_type -> openshell.v1.DeleteServiceResponse - 74, // 280: openshell.v1.OpenShell.RevokeSshSession:output_type -> openshell.v1.RevokeSshSessionResponse - 79, // 281: openshell.v1.OpenShell.ExecSandbox:output_type -> openshell.v1.ExecSandboxEvent - 81, // 282: openshell.v1.OpenShell.ForwardTcp:output_type -> openshell.v1.TcpForwardFrame - 79, // 283: openshell.v1.OpenShell.ExecSandboxInteractive:output_type -> openshell.v1.ExecSandboxEvent - 94, // 284: openshell.v1.OpenShell.CreateProvider:output_type -> openshell.v1.ProviderResponse - 94, // 285: openshell.v1.OpenShell.GetProvider:output_type -> openshell.v1.ProviderResponse - 95, // 286: openshell.v1.OpenShell.ListProviders:output_type -> openshell.v1.ListProvidersResponse - 122, // 287: openshell.v1.OpenShell.ListProviderProfiles:output_type -> openshell.v1.ListProviderProfilesResponse - 121, // 288: openshell.v1.OpenShell.GetProviderProfile:output_type -> openshell.v1.ProviderProfileResponse - 124, // 289: openshell.v1.OpenShell.ImportProviderProfiles:output_type -> openshell.v1.ImportProviderProfilesResponse - 126, // 290: openshell.v1.OpenShell.UpdateProviderProfiles:output_type -> openshell.v1.UpdateProviderProfilesResponse - 128, // 291: openshell.v1.OpenShell.LintProviderProfiles:output_type -> openshell.v1.LintProviderProfilesResponse - 94, // 292: openshell.v1.OpenShell.UpdateProvider:output_type -> openshell.v1.ProviderResponse - 112, // 293: openshell.v1.OpenShell.GetProviderRefreshStatus:output_type -> openshell.v1.GetProviderRefreshStatusResponse - 114, // 294: openshell.v1.OpenShell.ConfigureProviderRefresh:output_type -> openshell.v1.ConfigureProviderRefreshResponse - 116, // 295: openshell.v1.OpenShell.RotateProviderCredential:output_type -> openshell.v1.RotateProviderCredentialResponse - 118, // 296: openshell.v1.OpenShell.DeleteProviderRefresh:output_type -> openshell.v1.DeleteProviderRefreshResponse - 129, // 297: openshell.v1.OpenShell.DeleteProvider:output_type -> openshell.v1.DeleteProviderResponse - 131, // 298: openshell.v1.OpenShell.DeleteProviderProfile:output_type -> openshell.v1.DeleteProviderProfileResponse - 263, // 299: openshell.v1.OpenShell.GetSandboxConfig:output_type -> openshell.sandbox.v1.GetSandboxConfigResponse - 264, // 300: openshell.v1.OpenShell.GetGatewayConfig:output_type -> openshell.sandbox.v1.GetGatewayConfigResponse - 146, // 301: openshell.v1.OpenShell.UpdateConfig:output_type -> openshell.v1.UpdateConfigResponse - 148, // 302: openshell.v1.OpenShell.GetSandboxPolicyStatus:output_type -> openshell.v1.GetSandboxPolicyStatusResponse - 150, // 303: openshell.v1.OpenShell.ListSandboxPolicies:output_type -> openshell.v1.ListSandboxPoliciesResponse - 152, // 304: openshell.v1.OpenShell.ReportPolicyStatus:output_type -> openshell.v1.ReportPolicyStatusResponse - 135, // 305: openshell.v1.OpenShell.GetSandboxProviderEnvironment:output_type -> openshell.v1.GetSandboxProviderEnvironmentResponse - 137, // 306: openshell.v1.OpenShell.ExchangeProviderSubjectToken:output_type -> openshell.v1.ExchangeProviderSubjectTokenResponse - 157, // 307: openshell.v1.OpenShell.GetSandboxLogs:output_type -> openshell.v1.GetSandboxLogsResponse - 156, // 308: openshell.v1.OpenShell.PushSandboxLogs:output_type -> openshell.v1.PushSandboxLogsResponse - 159, // 309: openshell.v1.OpenShell.ConnectSupervisor:output_type -> openshell.v1.GatewayMessage - 166, // 310: openshell.v1.OpenShell.ReportMainProcessExit:output_type -> openshell.v1.ReportMainProcessExitResponse - 168, // 311: openshell.v1.OpenShell.FinalizeMainProcessExit:output_type -> openshell.v1.FinalizeMainProcessExitResponse - 173, // 312: openshell.v1.OpenShell.RelayStream:output_type -> openshell.v1.RelayFrame - 86, // 313: openshell.v1.OpenShell.WatchSandbox:output_type -> openshell.v1.SandboxStreamEvent - 183, // 314: openshell.v1.OpenShell.SubmitPolicyAnalysis:output_type -> openshell.v1.SubmitPolicyAnalysisResponse - 185, // 315: openshell.v1.OpenShell.GetDraftPolicy:output_type -> openshell.v1.GetDraftPolicyResponse - 187, // 316: openshell.v1.OpenShell.ApproveDraftChunk:output_type -> openshell.v1.ApproveDraftChunkResponse - 189, // 317: openshell.v1.OpenShell.RejectDraftChunk:output_type -> openshell.v1.RejectDraftChunkResponse - 192, // 318: openshell.v1.OpenShell.ApproveAllDraftChunks:output_type -> openshell.v1.ApproveAllDraftChunksResponse - 194, // 319: openshell.v1.OpenShell.EditDraftChunk:output_type -> openshell.v1.EditDraftChunkResponse - 196, // 320: openshell.v1.OpenShell.UndoDraftChunk:output_type -> openshell.v1.UndoDraftChunkResponse - 198, // 321: openshell.v1.OpenShell.ClearDraftChunks:output_type -> openshell.v1.ClearDraftChunksResponse - 201, // 322: openshell.v1.OpenShell.GetDraftHistory:output_type -> openshell.v1.GetDraftHistoryResponse - 9, // 323: openshell.v1.OpenShell.IssueSandboxToken:output_type -> openshell.v1.IssueSandboxTokenResponse - 11, // 324: openshell.v1.OpenShell.RefreshSandboxToken:output_type -> openshell.v1.RefreshSandboxTokenResponse - 207, // 325: openshell.v1.OpenShell.CreateWorkspace:output_type -> openshell.v1.CreateWorkspaceResponse - 209, // 326: openshell.v1.OpenShell.GetWorkspace:output_type -> openshell.v1.GetWorkspaceResponse - 211, // 327: openshell.v1.OpenShell.ListWorkspaces:output_type -> openshell.v1.ListWorkspacesResponse - 213, // 328: openshell.v1.OpenShell.DeleteWorkspace:output_type -> openshell.v1.DeleteWorkspaceResponse - 216, // 329: openshell.v1.OpenShell.AddWorkspaceMember:output_type -> openshell.v1.AddWorkspaceMemberResponse - 218, // 330: openshell.v1.OpenShell.RemoveWorkspaceMember:output_type -> openshell.v1.RemoveWorkspaceMemberResponse - 220, // 331: openshell.v1.OpenShell.ListWorkspaceMembers:output_type -> openshell.v1.ListWorkspaceMembersResponse - 258, // [258:332] is the sub-list for method output_type - 184, // [184:258] is the sub-list for method input_type - 184, // [184:184] is the sub-list for extension type_name - 184, // [184:184] is the sub-list for extension extendee - 0, // [0:184] is the sub-list for field type_name + 252, // 39: openshell.v1.CreateSandboxRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 29, // 40: openshell.v1.CreateSandboxTemplateRequest.template:type_name -> openshell.v1.SandboxWorkloadTemplate + 252, // 41: openshell.v1.CreateSandboxTemplateRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 252, // 42: openshell.v1.GetSandboxTemplateRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 252, // 43: openshell.v1.ListSandboxTemplatesRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 252, // 44: openshell.v1.DeleteSandboxTemplateRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 29, // 45: openshell.v1.SandboxTemplateResponse.template:type_name -> openshell.v1.SandboxWorkloadTemplate + 29, // 46: openshell.v1.ListSandboxTemplatesResponse.templates:type_name -> openshell.v1.SandboxWorkloadTemplate + 252, // 47: openshell.v1.BeginRootfsTarStagingRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 252, // 48: openshell.v1.GetSandboxRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 252, // 49: openshell.v1.ListSandboxesRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 252, // 50: openshell.v1.ListSandboxProvidersRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 252, // 51: openshell.v1.AttachSandboxProviderRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 252, // 52: openshell.v1.DetachSandboxProviderRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 252, // 53: openshell.v1.DeleteSandboxRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 252, // 54: openshell.v1.StopSandboxRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 252, // 55: openshell.v1.StartSandboxRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 24, // 56: openshell.v1.SandboxResponse.sandbox:type_name -> openshell.v1.Sandbox + 24, // 57: openshell.v1.ListSandboxesResponse.sandboxes:type_name -> openshell.v1.Sandbox + 253, // 58: openshell.v1.ListSandboxProvidersResponse.providers:type_name -> openshell.datamodel.v1.Provider + 24, // 59: openshell.v1.AttachSandboxProviderResponse.sandbox:type_name -> openshell.v1.Sandbox + 24, // 60: openshell.v1.DetachSandboxProviderResponse.sandbox:type_name -> openshell.v1.Sandbox + 252, // 61: openshell.v1.CreateSshSessionRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 252, // 62: openshell.v1.ExposeServiceRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 252, // 63: openshell.v1.GetServiceRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 252, // 64: openshell.v1.ListServicesRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 72, // 65: openshell.v1.ListServicesResponse.services:type_name -> openshell.v1.ServiceEndpointResponse + 252, // 66: openshell.v1.DeleteServiceRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 248, // 67: openshell.v1.ServiceEndpoint.metadata:type_name -> openshell.datamodel.v1.ObjectMeta + 71, // 68: openshell.v1.ServiceEndpointResponse.endpoint:type_name -> openshell.v1.ServiceEndpoint + 230, // 69: openshell.v1.ExecSandboxRequest.environment:type_name -> openshell.v1.ExecSandboxRequest.EnvironmentEntry + 252, // 70: openshell.v1.ExecSandboxRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 76, // 71: openshell.v1.ExecSandboxEvent.stdout:type_name -> openshell.v1.ExecSandboxStdout + 77, // 72: openshell.v1.ExecSandboxEvent.stderr:type_name -> openshell.v1.ExecSandboxStderr + 78, // 73: openshell.v1.ExecSandboxEvent.exit:type_name -> openshell.v1.ExecSandboxExit + 252, // 74: openshell.v1.TcpForwardInit.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 170, // 75: openshell.v1.TcpForwardInit.ssh:type_name -> openshell.v1.SshRelayTarget + 171, // 76: openshell.v1.TcpForwardInit.tcp:type_name -> openshell.v1.TcpRelayTarget + 80, // 77: openshell.v1.TcpForwardFrame.init:type_name -> openshell.v1.TcpForwardInit + 75, // 78: openshell.v1.ExecSandboxInput.start:type_name -> openshell.v1.ExecSandboxRequest + 83, // 79: openshell.v1.ExecSandboxInput.resize:type_name -> openshell.v1.ExecSandboxWindowResize + 248, // 80: openshell.v1.SshSession.metadata:type_name -> openshell.datamodel.v1.ObjectMeta + 252, // 81: openshell.v1.WatchSandboxRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 24, // 82: openshell.v1.SandboxStreamEvent.sandbox:type_name -> openshell.v1.Sandbox + 87, // 83: openshell.v1.SandboxStreamEvent.log:type_name -> openshell.v1.SandboxLogLine + 38, // 84: openshell.v1.SandboxStreamEvent.event:type_name -> openshell.v1.PlatformEvent + 88, // 85: openshell.v1.SandboxStreamEvent.warning:type_name -> openshell.v1.SandboxStreamWarning + 181, // 86: openshell.v1.SandboxStreamEvent.draft_policy_update:type_name -> openshell.v1.DraftPolicyUpdate + 231, // 87: openshell.v1.SandboxLogLine.fields:type_name -> openshell.v1.SandboxLogLine.FieldsEntry + 253, // 88: openshell.v1.CreateProviderRequest.provider:type_name -> openshell.datamodel.v1.Provider + 252, // 89: openshell.v1.CreateProviderRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 252, // 90: openshell.v1.GetProviderRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 252, // 91: openshell.v1.ListProvidersRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 253, // 92: openshell.v1.UpdateProviderRequest.provider:type_name -> openshell.datamodel.v1.Provider + 232, // 93: openshell.v1.UpdateProviderRequest.credential_expires_at_ms:type_name -> openshell.v1.UpdateProviderRequest.CredentialExpiresAtMsEntry + 252, // 94: openshell.v1.UpdateProviderRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 252, // 95: openshell.v1.DeleteProviderRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 253, // 96: openshell.v1.ProviderResponse.provider:type_name -> openshell.datamodel.v1.Provider + 253, // 97: openshell.v1.ListProvidersResponse.providers:type_name -> openshell.datamodel.v1.Provider + 119, // 98: openshell.v1.ProviderProfileImportItem.profile:type_name -> openshell.v1.ProviderProfile + 100, // 99: openshell.v1.ProviderCredentialTokenGrant.audience_overrides:type_name -> openshell.v1.ProviderCredentialTokenGrantAudienceOverride + 1, // 100: openshell.v1.ProviderCredentialTokenGrant.grant_type:type_name -> openshell.v1.ProviderCredentialTokenGrantType + 101, // 101: openshell.v1.ProviderCredentialTokenGrant.subject_token:type_name -> openshell.v1.ProviderCredentialTokenGrantSubjectToken + 106, // 102: openshell.v1.ProviderProfileCredential.refresh:type_name -> openshell.v1.ProviderCredentialRefresh + 102, // 103: openshell.v1.ProviderProfileCredential.token_grant:type_name -> openshell.v1.ProviderCredentialTokenGrant + 2, // 104: openshell.v1.ProviderCredentialRefresh.strategy:type_name -> openshell.v1.ProviderCredentialRefreshStrategy + 104, // 105: openshell.v1.ProviderCredentialRefresh.material:type_name -> openshell.v1.ProviderCredentialRefreshMaterial + 105, // 106: openshell.v1.ProviderCredentialRefresh.additional_outputs:type_name -> openshell.v1.ProviderCredentialRefreshOutput + 2, // 107: openshell.v1.ProviderCredentialRefreshStatus.strategy:type_name -> openshell.v1.ProviderCredentialRefreshStrategy + 7, // 108: openshell.v1.ProviderCredentialRefreshStatus.recovery_action:type_name -> openshell.v1.ProviderCredentialRefreshRecoveryAction + 248, // 109: openshell.v1.StoredProviderCredentialRefreshState.metadata:type_name -> openshell.datamodel.v1.ObjectMeta + 2, // 110: openshell.v1.StoredProviderCredentialRefreshState.strategy:type_name -> openshell.v1.ProviderCredentialRefreshStrategy + 233, // 111: openshell.v1.StoredProviderCredentialRefreshState.material:type_name -> openshell.v1.StoredProviderCredentialRefreshState.MaterialEntry + 234, // 112: openshell.v1.StoredProviderCredentialRefreshState.additional_output_keys:type_name -> openshell.v1.StoredProviderCredentialRefreshState.AdditionalOutputKeysEntry + 235, // 113: openshell.v1.StoredProviderCredentialRefreshState.secret_material_handles:type_name -> openshell.v1.StoredProviderCredentialRefreshState.SecretMaterialHandlesEntry + 110, // 114: openshell.v1.StoredProviderCredentialRefreshState.pending_secret_deletions:type_name -> openshell.v1.StoredRefreshMaterialDeletion + 7, // 115: openshell.v1.StoredProviderCredentialRefreshState.recovery_action:type_name -> openshell.v1.ProviderCredentialRefreshRecoveryAction + 254, // 116: openshell.v1.StoredRefreshMaterialDeletion.handle:type_name -> openshell.datamodel.v1.CredentialHandle + 252, // 117: openshell.v1.GetProviderRefreshStatusRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 107, // 118: openshell.v1.GetProviderRefreshStatusResponse.credentials:type_name -> openshell.v1.ProviderCredentialRefreshStatus + 2, // 119: openshell.v1.ConfigureProviderRefreshRequest.strategy:type_name -> openshell.v1.ProviderCredentialRefreshStrategy + 236, // 120: openshell.v1.ConfigureProviderRefreshRequest.material:type_name -> openshell.v1.ConfigureProviderRefreshRequest.MaterialEntry + 252, // 121: openshell.v1.ConfigureProviderRefreshRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 107, // 122: openshell.v1.ConfigureProviderRefreshResponse.status:type_name -> openshell.v1.ProviderCredentialRefreshStatus + 252, // 123: openshell.v1.RotateProviderCredentialRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 107, // 124: openshell.v1.RotateProviderCredentialResponse.status:type_name -> openshell.v1.ProviderCredentialRefreshStatus + 252, // 125: openshell.v1.DeleteProviderRefreshRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 3, // 126: openshell.v1.ProviderProfile.category:type_name -> openshell.v1.ProviderProfileCategory + 103, // 127: openshell.v1.ProviderProfile.credentials:type_name -> openshell.v1.ProviderProfileCredential + 255, // 128: openshell.v1.ProviderProfile.endpoints:type_name -> openshell.sandbox.v1.NetworkEndpoint + 256, // 129: openshell.v1.ProviderProfile.binaries:type_name -> openshell.sandbox.v1.NetworkBinary + 108, // 130: openshell.v1.ProviderProfile.discovery:type_name -> openshell.v1.ProviderProfileDiscovery + 237, // 131: openshell.v1.ProviderProfile.annotations:type_name -> openshell.v1.ProviderProfile.AnnotationsEntry + 248, // 132: openshell.v1.StoredProviderProfile.metadata:type_name -> openshell.datamodel.v1.ObjectMeta + 119, // 133: openshell.v1.StoredProviderProfile.profile:type_name -> openshell.v1.ProviderProfile + 119, // 134: openshell.v1.ProviderProfileResponse.profile:type_name -> openshell.v1.ProviderProfile + 119, // 135: openshell.v1.ListProviderProfilesResponse.profiles:type_name -> openshell.v1.ProviderProfile + 98, // 136: openshell.v1.ImportProviderProfilesRequest.profiles:type_name -> openshell.v1.ProviderProfileImportItem + 99, // 137: openshell.v1.ImportProviderProfilesResponse.diagnostics:type_name -> openshell.v1.ProviderProfileDiagnostic + 119, // 138: openshell.v1.ImportProviderProfilesResponse.profiles:type_name -> openshell.v1.ProviderProfile + 98, // 139: openshell.v1.UpdateProviderProfilesRequest.profile:type_name -> openshell.v1.ProviderProfileImportItem + 99, // 140: openshell.v1.UpdateProviderProfilesResponse.diagnostics:type_name -> openshell.v1.ProviderProfileDiagnostic + 119, // 141: openshell.v1.UpdateProviderProfilesResponse.profile:type_name -> openshell.v1.ProviderProfile + 98, // 142: openshell.v1.LintProviderProfilesRequest.profiles:type_name -> openshell.v1.ProviderProfileImportItem + 99, // 143: openshell.v1.LintProviderProfilesResponse.diagnostics:type_name -> openshell.v1.ProviderProfileDiagnostic + 133, // 144: openshell.v1.StaticCredentialBinding.endpoints:type_name -> openshell.v1.StaticCredentialEndpointBinding + 238, // 145: openshell.v1.GetSandboxProviderEnvironmentResponse.environment:type_name -> openshell.v1.GetSandboxProviderEnvironmentResponse.EnvironmentEntry + 239, // 146: openshell.v1.GetSandboxProviderEnvironmentResponse.credential_expires_at_ms:type_name -> openshell.v1.GetSandboxProviderEnvironmentResponse.CredentialExpiresAtMsEntry + 240, // 147: openshell.v1.GetSandboxProviderEnvironmentResponse.dynamic_credentials:type_name -> openshell.v1.GetSandboxProviderEnvironmentResponse.DynamicCredentialsEntry + 241, // 148: openshell.v1.GetSandboxProviderEnvironmentResponse.static_credential_bindings:type_name -> openshell.v1.GetSandboxProviderEnvironmentResponse.StaticCredentialBindingsEntry + 249, // 149: openshell.v1.UpdateConfigRequest.policy:type_name -> openshell.sandbox.v1.SandboxPolicy + 257, // 150: openshell.v1.UpdateConfigRequest.setting_value:type_name -> openshell.sandbox.v1.SettingValue + 139, // 151: openshell.v1.UpdateConfigRequest.merge_operations:type_name -> openshell.v1.PolicyMergeOperation + 242, // 152: openshell.v1.UpdateConfigRequest.annotations:type_name -> openshell.v1.UpdateConfigRequest.AnnotationsEntry + 252, // 153: openshell.v1.UpdateConfigRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 140, // 154: openshell.v1.PolicyMergeOperation.add_rule:type_name -> openshell.v1.AddNetworkRule + 141, // 155: openshell.v1.PolicyMergeOperation.remove_endpoint:type_name -> openshell.v1.RemoveNetworkEndpoint + 142, // 156: openshell.v1.PolicyMergeOperation.remove_rule:type_name -> openshell.v1.RemoveNetworkRule + 143, // 157: openshell.v1.PolicyMergeOperation.add_deny_rules:type_name -> openshell.v1.AddDenyRules + 144, // 158: openshell.v1.PolicyMergeOperation.add_allow_rules:type_name -> openshell.v1.AddAllowRules + 145, // 159: openshell.v1.PolicyMergeOperation.remove_binary:type_name -> openshell.v1.RemoveNetworkBinary + 258, // 160: openshell.v1.AddNetworkRule.rule:type_name -> openshell.sandbox.v1.NetworkPolicyRule + 259, // 161: openshell.v1.AddDenyRules.deny_rules:type_name -> openshell.sandbox.v1.L7DenyRule + 260, // 162: openshell.v1.AddAllowRules.rules:type_name -> openshell.sandbox.v1.L7Rule + 243, // 163: openshell.v1.UpdateConfigResponse.annotations:type_name -> openshell.v1.UpdateConfigResponse.AnnotationsEntry + 252, // 164: openshell.v1.GetSandboxPolicyStatusRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 153, // 165: openshell.v1.GetSandboxPolicyStatusResponse.revision:type_name -> openshell.v1.SandboxPolicyRevision + 252, // 166: openshell.v1.ListSandboxPoliciesRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 153, // 167: openshell.v1.ListSandboxPoliciesResponse.revisions:type_name -> openshell.v1.SandboxPolicyRevision + 4, // 168: openshell.v1.ReportPolicyStatusRequest.status:type_name -> openshell.v1.PolicyStatus + 4, // 169: openshell.v1.SandboxPolicyRevision.status:type_name -> openshell.v1.PolicyStatus + 249, // 170: openshell.v1.SandboxPolicyRevision.policy:type_name -> openshell.sandbox.v1.SandboxPolicy + 244, // 171: openshell.v1.SandboxPolicyRevision.provenance:type_name -> openshell.v1.SandboxPolicyRevision.ProvenanceEntry + 252, // 172: openshell.v1.GetSandboxLogsRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 87, // 173: openshell.v1.PushSandboxLogsRequest.logs:type_name -> openshell.v1.SandboxLogLine + 87, // 174: openshell.v1.GetSandboxLogsResponse.logs:type_name -> openshell.v1.SandboxLogLine + 160, // 175: openshell.v1.SupervisorMessage.hello:type_name -> openshell.v1.SupervisorHello + 163, // 176: openshell.v1.SupervisorMessage.heartbeat:type_name -> openshell.v1.SupervisorHeartbeat + 174, // 177: openshell.v1.SupervisorMessage.relay_open_result:type_name -> openshell.v1.RelayOpenResult + 175, // 178: openshell.v1.SupervisorMessage.relay_close:type_name -> openshell.v1.RelayClose + 161, // 179: openshell.v1.GatewayMessage.session_accepted:type_name -> openshell.v1.SessionAccepted + 162, // 180: openshell.v1.GatewayMessage.session_rejected:type_name -> openshell.v1.SessionRejected + 164, // 181: openshell.v1.GatewayMessage.heartbeat:type_name -> openshell.v1.GatewayHeartbeat + 169, // 182: openshell.v1.GatewayMessage.relay_open:type_name -> openshell.v1.RelayOpen + 175, // 183: openshell.v1.GatewayMessage.relay_close:type_name -> openshell.v1.RelayClose + 170, // 184: openshell.v1.RelayOpen.ssh:type_name -> openshell.v1.SshRelayTarget + 171, // 185: openshell.v1.RelayOpen.tcp:type_name -> openshell.v1.TcpRelayTarget + 172, // 186: openshell.v1.RelayFrame.init:type_name -> openshell.v1.RelayInit + 176, // 187: openshell.v1.DenialSummary.l7_request_samples:type_name -> openshell.v1.L7RequestSample + 178, // 188: openshell.v1.NetworkActivitySummary.denials_by_group:type_name -> openshell.v1.DenialGroupCount + 258, // 189: openshell.v1.PolicyChunk.proposed_rule:type_name -> openshell.sandbox.v1.NetworkPolicyRule + 249, // 190: openshell.v1.PolicyChunk.current_effective_policy:type_name -> openshell.sandbox.v1.SandboxPolicy + 249, // 191: openshell.v1.PolicyChunk.candidate_effective_policy:type_name -> openshell.sandbox.v1.SandboxPolicy + 177, // 192: openshell.v1.SubmitPolicyAnalysisRequest.summaries:type_name -> openshell.v1.DenialSummary + 180, // 193: openshell.v1.SubmitPolicyAnalysisRequest.proposed_chunks:type_name -> openshell.v1.PolicyChunk + 179, // 194: openshell.v1.SubmitPolicyAnalysisRequest.network_activity_summaries:type_name -> openshell.v1.NetworkActivitySummary + 252, // 195: openshell.v1.GetDraftPolicyRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 180, // 196: openshell.v1.GetDraftPolicyResponse.chunks:type_name -> openshell.v1.PolicyChunk + 252, // 197: openshell.v1.ApproveDraftChunkRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 252, // 198: openshell.v1.RejectDraftChunkRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 190, // 199: openshell.v1.ApproveAllDraftChunksRequest.approvals:type_name -> openshell.v1.DraftChunkApproval + 252, // 200: openshell.v1.ApproveAllDraftChunksRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 258, // 201: openshell.v1.EditDraftChunkRequest.proposed_rule:type_name -> openshell.sandbox.v1.NetworkPolicyRule + 252, // 202: openshell.v1.EditDraftChunkRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 252, // 203: openshell.v1.UndoDraftChunkRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 252, // 204: openshell.v1.ClearDraftChunksRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 252, // 205: openshell.v1.GetDraftHistoryRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 200, // 206: openshell.v1.GetDraftHistoryResponse.entries:type_name -> openshell.v1.DraftHistoryEntry + 249, // 207: openshell.v1.PolicyRevisionPayload.policy:type_name -> openshell.sandbox.v1.SandboxPolicy + 245, // 208: openshell.v1.PolicyRevisionPayload.provenance:type_name -> openshell.v1.PolicyRevisionPayload.ProvenanceEntry + 258, // 209: openshell.v1.DraftChunkPayload.proposed_rule:type_name -> openshell.sandbox.v1.NetworkPolicyRule + 249, // 210: openshell.v1.DraftChunkPayload.current_effective_policy:type_name -> openshell.sandbox.v1.SandboxPolicy + 249, // 211: openshell.v1.DraftChunkPayload.candidate_effective_policy:type_name -> openshell.sandbox.v1.SandboxPolicy + 246, // 212: openshell.v1.StoredPolicyRevision.provenance:type_name -> openshell.v1.StoredPolicyRevision.ProvenanceEntry + 249, // 213: openshell.v1.StoredDraftChunk.current_effective_policy:type_name -> openshell.sandbox.v1.SandboxPolicy + 249, // 214: openshell.v1.StoredDraftChunk.candidate_effective_policy:type_name -> openshell.sandbox.v1.SandboxPolicy + 247, // 215: openshell.v1.CreateWorkspaceRequest.labels:type_name -> openshell.v1.CreateWorkspaceRequest.LabelsEntry + 261, // 216: openshell.v1.CreateWorkspaceResponse.workspace:type_name -> openshell.datamodel.v1.Workspace + 261, // 217: openshell.v1.GetWorkspaceResponse.workspace:type_name -> openshell.datamodel.v1.Workspace + 261, // 218: openshell.v1.ListWorkspacesResponse.workspaces:type_name -> openshell.datamodel.v1.Workspace + 248, // 219: openshell.v1.WorkspaceMember.metadata:type_name -> openshell.datamodel.v1.ObjectMeta + 6, // 220: openshell.v1.WorkspaceMember.role:type_name -> openshell.v1.WorkspaceRole + 6, // 221: openshell.v1.AddWorkspaceMemberRequest.role:type_name -> openshell.v1.WorkspaceRole + 214, // 222: openshell.v1.AddWorkspaceMemberResponse.member:type_name -> openshell.v1.WorkspaceMember + 214, // 223: openshell.v1.ListWorkspaceMembersResponse.members:type_name -> openshell.v1.WorkspaceMember + 254, // 224: openshell.v1.StoredProviderCredentialRefreshState.SecretMaterialHandlesEntry.value:type_name -> openshell.datamodel.v1.CredentialHandle + 103, // 225: openshell.v1.GetSandboxProviderEnvironmentResponse.DynamicCredentialsEntry.value:type_name -> openshell.v1.ProviderProfileCredential + 134, // 226: openshell.v1.GetSandboxProviderEnvironmentResponse.StaticCredentialBindingsEntry.value:type_name -> openshell.v1.StaticCredentialBinding + 12, // 227: openshell.v1.OpenShell.Health:input_type -> openshell.v1.HealthRequest + 14, // 228: openshell.v1.OpenShell.GetCurrentUser:input_type -> openshell.v1.GetCurrentUserRequest + 16, // 229: openshell.v1.OpenShell.GetGatewayInfo:input_type -> openshell.v1.GetGatewayInfoRequest + 39, // 230: openshell.v1.OpenShell.CreateSandbox:input_type -> openshell.v1.CreateSandboxRequest + 47, // 231: openshell.v1.OpenShell.BeginRootfsTarStaging:input_type -> openshell.v1.BeginRootfsTarStagingRequest + 49, // 232: openshell.v1.OpenShell.GetSandbox:input_type -> openshell.v1.GetSandboxRequest + 50, // 233: openshell.v1.OpenShell.ListSandboxes:input_type -> openshell.v1.ListSandboxesRequest + 40, // 234: openshell.v1.OpenShell.CreateSandboxTemplate:input_type -> openshell.v1.CreateSandboxTemplateRequest + 41, // 235: openshell.v1.OpenShell.GetSandboxTemplate:input_type -> openshell.v1.GetSandboxTemplateRequest + 42, // 236: openshell.v1.OpenShell.ListSandboxTemplates:input_type -> openshell.v1.ListSandboxTemplatesRequest + 43, // 237: openshell.v1.OpenShell.DeleteSandboxTemplate:input_type -> openshell.v1.DeleteSandboxTemplateRequest + 51, // 238: openshell.v1.OpenShell.ListSandboxProviders:input_type -> openshell.v1.ListSandboxProvidersRequest + 52, // 239: openshell.v1.OpenShell.AttachSandboxProvider:input_type -> openshell.v1.AttachSandboxProviderRequest + 53, // 240: openshell.v1.OpenShell.DetachSandboxProvider:input_type -> openshell.v1.DetachSandboxProviderRequest + 54, // 241: openshell.v1.OpenShell.DeleteSandbox:input_type -> openshell.v1.DeleteSandboxRequest + 55, // 242: openshell.v1.OpenShell.StopSandbox:input_type -> openshell.v1.StopSandboxRequest + 56, // 243: openshell.v1.OpenShell.StartSandbox:input_type -> openshell.v1.StartSandboxRequest + 63, // 244: openshell.v1.OpenShell.CreateSshSession:input_type -> openshell.v1.CreateSshSessionRequest + 65, // 245: openshell.v1.OpenShell.ExposeService:input_type -> openshell.v1.ExposeServiceRequest + 66, // 246: openshell.v1.OpenShell.GetService:input_type -> openshell.v1.GetServiceRequest + 67, // 247: openshell.v1.OpenShell.ListServices:input_type -> openshell.v1.ListServicesRequest + 69, // 248: openshell.v1.OpenShell.DeleteService:input_type -> openshell.v1.DeleteServiceRequest + 73, // 249: openshell.v1.OpenShell.RevokeSshSession:input_type -> openshell.v1.RevokeSshSessionRequest + 75, // 250: openshell.v1.OpenShell.ExecSandbox:input_type -> openshell.v1.ExecSandboxRequest + 81, // 251: openshell.v1.OpenShell.ForwardTcp:input_type -> openshell.v1.TcpForwardFrame + 82, // 252: openshell.v1.OpenShell.ExecSandboxInteractive:input_type -> openshell.v1.ExecSandboxInput + 89, // 253: openshell.v1.OpenShell.CreateProvider:input_type -> openshell.v1.CreateProviderRequest + 90, // 254: openshell.v1.OpenShell.GetProvider:input_type -> openshell.v1.GetProviderRequest + 91, // 255: openshell.v1.OpenShell.ListProviders:input_type -> openshell.v1.ListProvidersRequest + 96, // 256: openshell.v1.OpenShell.ListProviderProfiles:input_type -> openshell.v1.ListProviderProfilesRequest + 97, // 257: openshell.v1.OpenShell.GetProviderProfile:input_type -> openshell.v1.GetProviderProfileRequest + 123, // 258: openshell.v1.OpenShell.ImportProviderProfiles:input_type -> openshell.v1.ImportProviderProfilesRequest + 125, // 259: openshell.v1.OpenShell.UpdateProviderProfiles:input_type -> openshell.v1.UpdateProviderProfilesRequest + 127, // 260: openshell.v1.OpenShell.LintProviderProfiles:input_type -> openshell.v1.LintProviderProfilesRequest + 92, // 261: openshell.v1.OpenShell.UpdateProvider:input_type -> openshell.v1.UpdateProviderRequest + 111, // 262: openshell.v1.OpenShell.GetProviderRefreshStatus:input_type -> openshell.v1.GetProviderRefreshStatusRequest + 113, // 263: openshell.v1.OpenShell.ConfigureProviderRefresh:input_type -> openshell.v1.ConfigureProviderRefreshRequest + 115, // 264: openshell.v1.OpenShell.RotateProviderCredential:input_type -> openshell.v1.RotateProviderCredentialRequest + 117, // 265: openshell.v1.OpenShell.DeleteProviderRefresh:input_type -> openshell.v1.DeleteProviderRefreshRequest + 93, // 266: openshell.v1.OpenShell.DeleteProvider:input_type -> openshell.v1.DeleteProviderRequest + 130, // 267: openshell.v1.OpenShell.DeleteProviderProfile:input_type -> openshell.v1.DeleteProviderProfileRequest + 262, // 268: openshell.v1.OpenShell.GetSandboxConfig:input_type -> openshell.sandbox.v1.GetSandboxConfigRequest + 263, // 269: openshell.v1.OpenShell.GetGatewayConfig:input_type -> openshell.sandbox.v1.GetGatewayConfigRequest + 138, // 270: openshell.v1.OpenShell.UpdateConfig:input_type -> openshell.v1.UpdateConfigRequest + 147, // 271: openshell.v1.OpenShell.GetSandboxPolicyStatus:input_type -> openshell.v1.GetSandboxPolicyStatusRequest + 149, // 272: openshell.v1.OpenShell.ListSandboxPolicies:input_type -> openshell.v1.ListSandboxPoliciesRequest + 151, // 273: openshell.v1.OpenShell.ReportPolicyStatus:input_type -> openshell.v1.ReportPolicyStatusRequest + 132, // 274: openshell.v1.OpenShell.GetSandboxProviderEnvironment:input_type -> openshell.v1.GetSandboxProviderEnvironmentRequest + 136, // 275: openshell.v1.OpenShell.ExchangeProviderSubjectToken:input_type -> openshell.v1.ExchangeProviderSubjectTokenRequest + 154, // 276: openshell.v1.OpenShell.GetSandboxLogs:input_type -> openshell.v1.GetSandboxLogsRequest + 155, // 277: openshell.v1.OpenShell.PushSandboxLogs:input_type -> openshell.v1.PushSandboxLogsRequest + 158, // 278: openshell.v1.OpenShell.ConnectSupervisor:input_type -> openshell.v1.SupervisorMessage + 165, // 279: openshell.v1.OpenShell.ReportMainProcessExit:input_type -> openshell.v1.ReportMainProcessExitRequest + 167, // 280: openshell.v1.OpenShell.FinalizeMainProcessExit:input_type -> openshell.v1.FinalizeMainProcessExitRequest + 173, // 281: openshell.v1.OpenShell.RelayStream:input_type -> openshell.v1.RelayFrame + 85, // 282: openshell.v1.OpenShell.WatchSandbox:input_type -> openshell.v1.WatchSandboxRequest + 182, // 283: openshell.v1.OpenShell.SubmitPolicyAnalysis:input_type -> openshell.v1.SubmitPolicyAnalysisRequest + 184, // 284: openshell.v1.OpenShell.GetDraftPolicy:input_type -> openshell.v1.GetDraftPolicyRequest + 186, // 285: openshell.v1.OpenShell.ApproveDraftChunk:input_type -> openshell.v1.ApproveDraftChunkRequest + 188, // 286: openshell.v1.OpenShell.RejectDraftChunk:input_type -> openshell.v1.RejectDraftChunkRequest + 191, // 287: openshell.v1.OpenShell.ApproveAllDraftChunks:input_type -> openshell.v1.ApproveAllDraftChunksRequest + 193, // 288: openshell.v1.OpenShell.EditDraftChunk:input_type -> openshell.v1.EditDraftChunkRequest + 195, // 289: openshell.v1.OpenShell.UndoDraftChunk:input_type -> openshell.v1.UndoDraftChunkRequest + 197, // 290: openshell.v1.OpenShell.ClearDraftChunks:input_type -> openshell.v1.ClearDraftChunksRequest + 199, // 291: openshell.v1.OpenShell.GetDraftHistory:input_type -> openshell.v1.GetDraftHistoryRequest + 8, // 292: openshell.v1.OpenShell.IssueSandboxToken:input_type -> openshell.v1.IssueSandboxTokenRequest + 10, // 293: openshell.v1.OpenShell.RefreshSandboxToken:input_type -> openshell.v1.RefreshSandboxTokenRequest + 206, // 294: openshell.v1.OpenShell.CreateWorkspace:input_type -> openshell.v1.CreateWorkspaceRequest + 208, // 295: openshell.v1.OpenShell.GetWorkspace:input_type -> openshell.v1.GetWorkspaceRequest + 210, // 296: openshell.v1.OpenShell.ListWorkspaces:input_type -> openshell.v1.ListWorkspacesRequest + 212, // 297: openshell.v1.OpenShell.DeleteWorkspace:input_type -> openshell.v1.DeleteWorkspaceRequest + 215, // 298: openshell.v1.OpenShell.AddWorkspaceMember:input_type -> openshell.v1.AddWorkspaceMemberRequest + 217, // 299: openshell.v1.OpenShell.RemoveWorkspaceMember:input_type -> openshell.v1.RemoveWorkspaceMemberRequest + 219, // 300: openshell.v1.OpenShell.ListWorkspaceMembers:input_type -> openshell.v1.ListWorkspaceMembersRequest + 13, // 301: openshell.v1.OpenShell.Health:output_type -> openshell.v1.HealthResponse + 15, // 302: openshell.v1.OpenShell.GetCurrentUser:output_type -> openshell.v1.GetCurrentUserResponse + 17, // 303: openshell.v1.OpenShell.GetGatewayInfo:output_type -> openshell.v1.GetGatewayInfoResponse + 57, // 304: openshell.v1.OpenShell.CreateSandbox:output_type -> openshell.v1.SandboxResponse + 48, // 305: openshell.v1.OpenShell.BeginRootfsTarStaging:output_type -> openshell.v1.BeginRootfsTarStagingResponse + 57, // 306: openshell.v1.OpenShell.GetSandbox:output_type -> openshell.v1.SandboxResponse + 58, // 307: openshell.v1.OpenShell.ListSandboxes:output_type -> openshell.v1.ListSandboxesResponse + 44, // 308: openshell.v1.OpenShell.CreateSandboxTemplate:output_type -> openshell.v1.SandboxTemplateResponse + 44, // 309: openshell.v1.OpenShell.GetSandboxTemplate:output_type -> openshell.v1.SandboxTemplateResponse + 45, // 310: openshell.v1.OpenShell.ListSandboxTemplates:output_type -> openshell.v1.ListSandboxTemplatesResponse + 46, // 311: openshell.v1.OpenShell.DeleteSandboxTemplate:output_type -> openshell.v1.DeleteSandboxTemplateResponse + 59, // 312: openshell.v1.OpenShell.ListSandboxProviders:output_type -> openshell.v1.ListSandboxProvidersResponse + 60, // 313: openshell.v1.OpenShell.AttachSandboxProvider:output_type -> openshell.v1.AttachSandboxProviderResponse + 61, // 314: openshell.v1.OpenShell.DetachSandboxProvider:output_type -> openshell.v1.DetachSandboxProviderResponse + 62, // 315: openshell.v1.OpenShell.DeleteSandbox:output_type -> openshell.v1.DeleteSandboxResponse + 57, // 316: openshell.v1.OpenShell.StopSandbox:output_type -> openshell.v1.SandboxResponse + 57, // 317: openshell.v1.OpenShell.StartSandbox:output_type -> openshell.v1.SandboxResponse + 64, // 318: openshell.v1.OpenShell.CreateSshSession:output_type -> openshell.v1.CreateSshSessionResponse + 72, // 319: openshell.v1.OpenShell.ExposeService:output_type -> openshell.v1.ServiceEndpointResponse + 72, // 320: openshell.v1.OpenShell.GetService:output_type -> openshell.v1.ServiceEndpointResponse + 68, // 321: openshell.v1.OpenShell.ListServices:output_type -> openshell.v1.ListServicesResponse + 70, // 322: openshell.v1.OpenShell.DeleteService:output_type -> openshell.v1.DeleteServiceResponse + 74, // 323: openshell.v1.OpenShell.RevokeSshSession:output_type -> openshell.v1.RevokeSshSessionResponse + 79, // 324: openshell.v1.OpenShell.ExecSandbox:output_type -> openshell.v1.ExecSandboxEvent + 81, // 325: openshell.v1.OpenShell.ForwardTcp:output_type -> openshell.v1.TcpForwardFrame + 79, // 326: openshell.v1.OpenShell.ExecSandboxInteractive:output_type -> openshell.v1.ExecSandboxEvent + 94, // 327: openshell.v1.OpenShell.CreateProvider:output_type -> openshell.v1.ProviderResponse + 94, // 328: openshell.v1.OpenShell.GetProvider:output_type -> openshell.v1.ProviderResponse + 95, // 329: openshell.v1.OpenShell.ListProviders:output_type -> openshell.v1.ListProvidersResponse + 122, // 330: openshell.v1.OpenShell.ListProviderProfiles:output_type -> openshell.v1.ListProviderProfilesResponse + 121, // 331: openshell.v1.OpenShell.GetProviderProfile:output_type -> openshell.v1.ProviderProfileResponse + 124, // 332: openshell.v1.OpenShell.ImportProviderProfiles:output_type -> openshell.v1.ImportProviderProfilesResponse + 126, // 333: openshell.v1.OpenShell.UpdateProviderProfiles:output_type -> openshell.v1.UpdateProviderProfilesResponse + 128, // 334: openshell.v1.OpenShell.LintProviderProfiles:output_type -> openshell.v1.LintProviderProfilesResponse + 94, // 335: openshell.v1.OpenShell.UpdateProvider:output_type -> openshell.v1.ProviderResponse + 112, // 336: openshell.v1.OpenShell.GetProviderRefreshStatus:output_type -> openshell.v1.GetProviderRefreshStatusResponse + 114, // 337: openshell.v1.OpenShell.ConfigureProviderRefresh:output_type -> openshell.v1.ConfigureProviderRefreshResponse + 116, // 338: openshell.v1.OpenShell.RotateProviderCredential:output_type -> openshell.v1.RotateProviderCredentialResponse + 118, // 339: openshell.v1.OpenShell.DeleteProviderRefresh:output_type -> openshell.v1.DeleteProviderRefreshResponse + 129, // 340: openshell.v1.OpenShell.DeleteProvider:output_type -> openshell.v1.DeleteProviderResponse + 131, // 341: openshell.v1.OpenShell.DeleteProviderProfile:output_type -> openshell.v1.DeleteProviderProfileResponse + 264, // 342: openshell.v1.OpenShell.GetSandboxConfig:output_type -> openshell.sandbox.v1.GetSandboxConfigResponse + 265, // 343: openshell.v1.OpenShell.GetGatewayConfig:output_type -> openshell.sandbox.v1.GetGatewayConfigResponse + 146, // 344: openshell.v1.OpenShell.UpdateConfig:output_type -> openshell.v1.UpdateConfigResponse + 148, // 345: openshell.v1.OpenShell.GetSandboxPolicyStatus:output_type -> openshell.v1.GetSandboxPolicyStatusResponse + 150, // 346: openshell.v1.OpenShell.ListSandboxPolicies:output_type -> openshell.v1.ListSandboxPoliciesResponse + 152, // 347: openshell.v1.OpenShell.ReportPolicyStatus:output_type -> openshell.v1.ReportPolicyStatusResponse + 135, // 348: openshell.v1.OpenShell.GetSandboxProviderEnvironment:output_type -> openshell.v1.GetSandboxProviderEnvironmentResponse + 137, // 349: openshell.v1.OpenShell.ExchangeProviderSubjectToken:output_type -> openshell.v1.ExchangeProviderSubjectTokenResponse + 157, // 350: openshell.v1.OpenShell.GetSandboxLogs:output_type -> openshell.v1.GetSandboxLogsResponse + 156, // 351: openshell.v1.OpenShell.PushSandboxLogs:output_type -> openshell.v1.PushSandboxLogsResponse + 159, // 352: openshell.v1.OpenShell.ConnectSupervisor:output_type -> openshell.v1.GatewayMessage + 166, // 353: openshell.v1.OpenShell.ReportMainProcessExit:output_type -> openshell.v1.ReportMainProcessExitResponse + 168, // 354: openshell.v1.OpenShell.FinalizeMainProcessExit:output_type -> openshell.v1.FinalizeMainProcessExitResponse + 173, // 355: openshell.v1.OpenShell.RelayStream:output_type -> openshell.v1.RelayFrame + 86, // 356: openshell.v1.OpenShell.WatchSandbox:output_type -> openshell.v1.SandboxStreamEvent + 183, // 357: openshell.v1.OpenShell.SubmitPolicyAnalysis:output_type -> openshell.v1.SubmitPolicyAnalysisResponse + 185, // 358: openshell.v1.OpenShell.GetDraftPolicy:output_type -> openshell.v1.GetDraftPolicyResponse + 187, // 359: openshell.v1.OpenShell.ApproveDraftChunk:output_type -> openshell.v1.ApproveDraftChunkResponse + 189, // 360: openshell.v1.OpenShell.RejectDraftChunk:output_type -> openshell.v1.RejectDraftChunkResponse + 192, // 361: openshell.v1.OpenShell.ApproveAllDraftChunks:output_type -> openshell.v1.ApproveAllDraftChunksResponse + 194, // 362: openshell.v1.OpenShell.EditDraftChunk:output_type -> openshell.v1.EditDraftChunkResponse + 196, // 363: openshell.v1.OpenShell.UndoDraftChunk:output_type -> openshell.v1.UndoDraftChunkResponse + 198, // 364: openshell.v1.OpenShell.ClearDraftChunks:output_type -> openshell.v1.ClearDraftChunksResponse + 201, // 365: openshell.v1.OpenShell.GetDraftHistory:output_type -> openshell.v1.GetDraftHistoryResponse + 9, // 366: openshell.v1.OpenShell.IssueSandboxToken:output_type -> openshell.v1.IssueSandboxTokenResponse + 11, // 367: openshell.v1.OpenShell.RefreshSandboxToken:output_type -> openshell.v1.RefreshSandboxTokenResponse + 207, // 368: openshell.v1.OpenShell.CreateWorkspace:output_type -> openshell.v1.CreateWorkspaceResponse + 209, // 369: openshell.v1.OpenShell.GetWorkspace:output_type -> openshell.v1.GetWorkspaceResponse + 211, // 370: openshell.v1.OpenShell.ListWorkspaces:output_type -> openshell.v1.ListWorkspacesResponse + 213, // 371: openshell.v1.OpenShell.DeleteWorkspace:output_type -> openshell.v1.DeleteWorkspaceResponse + 216, // 372: openshell.v1.OpenShell.AddWorkspaceMember:output_type -> openshell.v1.AddWorkspaceMemberResponse + 218, // 373: openshell.v1.OpenShell.RemoveWorkspaceMember:output_type -> openshell.v1.RemoveWorkspaceMemberResponse + 220, // 374: openshell.v1.OpenShell.ListWorkspaceMembers:output_type -> openshell.v1.ListWorkspaceMembersResponse + 301, // [301:375] is the sub-list for method output_type + 227, // [227:301] is the sub-list for method input_type + 227, // [227:227] is the sub-list for extension type_name + 227, // [227:227] is the sub-list for extension extendee + 0, // [0:227] is the sub-list for field type_name } func init() { file_openshell_proto_init() } diff --git a/sdk/go/proto/sandboxv1/sandbox.pb.go b/sdk/go/proto/sandboxv1/sandbox.pb.go index 989589002b..f9b2620820 100644 --- a/sdk/go/proto/sandboxv1/sandbox.pb.go +++ b/sdk/go/proto/sandboxv1/sandbox.pb.go @@ -10,6 +10,7 @@ package sandboxv1 import ( + datamodelv1 "github.com/NVIDIA/OpenShell/sdk/go/proto/datamodelv1" protoreflect "google.golang.org/protobuf/reflect/protoreflect" protoimpl "google.golang.org/protobuf/runtime/protoimpl" structpb "google.golang.org/protobuf/types/known/structpb" @@ -1494,13 +1495,13 @@ func (x *NetworkBinary) GetHarness() bool { return false } -// Request to get sandbox settings by sandbox ID. +// Request to get sandbox settings by sandbox name. type GetSandboxConfigRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - // The sandbox ID. - SandboxId string `protobuf:"bytes,1,opt,name=sandbox_id,json=sandboxId,proto3" json:"sandbox_id,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + state protoimpl.MessageState `protogen:"open.v1"` + SandboxName string `protobuf:"bytes,2,opt,name=sandbox_name,json=sandboxName,proto3" json:"sandbox_name,omitempty"` + WorkspaceScope *datamodelv1.WorkspaceSelector `protobuf:"bytes,3,opt,name=workspace_scope,json=workspaceScope,proto3" json:"workspace_scope,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *GetSandboxConfigRequest) Reset() { @@ -1533,13 +1534,20 @@ func (*GetSandboxConfigRequest) Descriptor() ([]byte, []int) { return file_sandbox_proto_rawDescGZIP(), []int{16} } -func (x *GetSandboxConfigRequest) GetSandboxId() string { +func (x *GetSandboxConfigRequest) GetSandboxName() string { if x != nil { - return x.SandboxId + return x.SandboxName } return "" } +func (x *GetSandboxConfigRequest) GetWorkspaceScope() *datamodelv1.WorkspaceSelector { + if x != nil { + return x.WorkspaceScope + } + return nil +} + // Request to get gateway-global settings. type GetGatewayConfigRequest struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -2069,7 +2077,7 @@ var File_sandbox_proto protoreflect.FileDescriptor const file_sandbox_proto_rawDesc = "" + "\n" + - "\rsandbox.proto\x12\x14openshell.sandbox.v1\x1a\x1cgoogle/protobuf/struct.proto\"\xa8\x05\n" + + "\rsandbox.proto\x12\x14openshell.sandbox.v1\x1a\x0fdatamodel.proto\x1a\x1cgoogle/protobuf/struct.proto\"\xa8\x05\n" + "\rSandboxPolicy\x12\x18\n" + "\aversion\x18\x01 \x01(\rR\aversion\x12F\n" + "\n" + @@ -2199,10 +2207,11 @@ const file_sandbox_proto_rawDesc = "" + "\x03any\x18\x02 \x03(\tR\x03any\"A\n" + "\rNetworkBinary\x12\x12\n" + "\x04path\x18\x01 \x01(\tR\x04path\x12\x1c\n" + - "\aharness\x18\x02 \x01(\bB\x02\x18\x01R\aharness\"8\n" + - "\x17GetSandboxConfigRequest\x12\x1d\n" + - "\n" + - "sandbox_id\x18\x01 \x01(\tR\tsandboxId\"\x19\n" + + "\aharness\x18\x02 \x01(\bB\x02\x18\x01R\aharness\"\xa2\x01\n" + + "\x17GetSandboxConfigRequest\x12!\n" + + "\fsandbox_name\x18\x02 \x01(\tR\vsandboxName\x12R\n" + + "\x0fworkspace_scope\x18\x03 \x01(\v2).openshell.datamodel.v1.WorkspaceSelectorR\x0eworkspaceScopeJ\x04\b\x01\x10\x02R\n" + + "sandbox_id\"\x19\n" + "\x17GetGatewayConfigRequest\"\x82\x02\n" + "\x18GetGatewayConfigResponse\x12X\n" + "\bsettings\x18\x01 \x03(\v2<.openshell.sandbox.v1.GetGatewayConfigResponse.SettingsEntryR\bsettings\x12+\n" + @@ -2271,41 +2280,42 @@ func file_sandbox_proto_rawDescGZIP() []byte { var file_sandbox_proto_enumTypes = make([]protoimpl.EnumInfo, 2) var file_sandbox_proto_msgTypes = make([]protoimpl.MessageInfo, 32) var file_sandbox_proto_goTypes = []any{ - (SettingScope)(0), // 0: openshell.sandbox.v1.SettingScope - (PolicySource)(0), // 1: openshell.sandbox.v1.PolicySource - (*SandboxPolicy)(nil), // 2: openshell.sandbox.v1.SandboxPolicy - (*FilesystemPolicy)(nil), // 3: openshell.sandbox.v1.FilesystemPolicy - (*LandlockPolicy)(nil), // 4: openshell.sandbox.v1.LandlockPolicy - (*ProcessPolicy)(nil), // 5: openshell.sandbox.v1.ProcessPolicy - (*NetworkPolicyRule)(nil), // 6: openshell.sandbox.v1.NetworkPolicyRule - (*NetworkMiddlewareConfig)(nil), // 7: openshell.sandbox.v1.NetworkMiddlewareConfig - (*MiddlewareEndpointSelector)(nil), // 8: openshell.sandbox.v1.MiddlewareEndpointSelector - (*NetworkCredentialBinding)(nil), // 9: openshell.sandbox.v1.NetworkCredentialBinding - (*NetworkEndpoint)(nil), // 10: openshell.sandbox.v1.NetworkEndpoint - (*McpOptions)(nil), // 11: openshell.sandbox.v1.McpOptions - (*GraphqlOperation)(nil), // 12: openshell.sandbox.v1.GraphqlOperation - (*L7DenyRule)(nil), // 13: openshell.sandbox.v1.L7DenyRule - (*L7Rule)(nil), // 14: openshell.sandbox.v1.L7Rule - (*L7Allow)(nil), // 15: openshell.sandbox.v1.L7Allow - (*L7QueryMatcher)(nil), // 16: openshell.sandbox.v1.L7QueryMatcher - (*NetworkBinary)(nil), // 17: openshell.sandbox.v1.NetworkBinary - (*GetSandboxConfigRequest)(nil), // 18: openshell.sandbox.v1.GetSandboxConfigRequest - (*GetGatewayConfigRequest)(nil), // 19: openshell.sandbox.v1.GetGatewayConfigRequest - (*GetGatewayConfigResponse)(nil), // 20: openshell.sandbox.v1.GetGatewayConfigResponse - (*SettingValue)(nil), // 21: openshell.sandbox.v1.SettingValue - (*EffectiveSetting)(nil), // 22: openshell.sandbox.v1.EffectiveSetting - (*GetSandboxConfigResponse)(nil), // 23: openshell.sandbox.v1.GetSandboxConfigResponse - (*SupervisorMiddlewareService)(nil), // 24: openshell.sandbox.v1.SupervisorMiddlewareService - nil, // 25: openshell.sandbox.v1.SandboxPolicy.NetworkPoliciesEntry - nil, // 26: openshell.sandbox.v1.SandboxPolicy.NetworkMiddlewaresEntry - nil, // 27: openshell.sandbox.v1.NetworkEndpoint.GraphqlPersistedQueriesEntry - nil, // 28: openshell.sandbox.v1.L7DenyRule.QueryEntry - nil, // 29: openshell.sandbox.v1.L7DenyRule.ParamsEntry - nil, // 30: openshell.sandbox.v1.L7Allow.QueryEntry - nil, // 31: openshell.sandbox.v1.L7Allow.ParamsEntry - nil, // 32: openshell.sandbox.v1.GetGatewayConfigResponse.SettingsEntry - nil, // 33: openshell.sandbox.v1.GetSandboxConfigResponse.SettingsEntry - (*structpb.Struct)(nil), // 34: google.protobuf.Struct + (SettingScope)(0), // 0: openshell.sandbox.v1.SettingScope + (PolicySource)(0), // 1: openshell.sandbox.v1.PolicySource + (*SandboxPolicy)(nil), // 2: openshell.sandbox.v1.SandboxPolicy + (*FilesystemPolicy)(nil), // 3: openshell.sandbox.v1.FilesystemPolicy + (*LandlockPolicy)(nil), // 4: openshell.sandbox.v1.LandlockPolicy + (*ProcessPolicy)(nil), // 5: openshell.sandbox.v1.ProcessPolicy + (*NetworkPolicyRule)(nil), // 6: openshell.sandbox.v1.NetworkPolicyRule + (*NetworkMiddlewareConfig)(nil), // 7: openshell.sandbox.v1.NetworkMiddlewareConfig + (*MiddlewareEndpointSelector)(nil), // 8: openshell.sandbox.v1.MiddlewareEndpointSelector + (*NetworkCredentialBinding)(nil), // 9: openshell.sandbox.v1.NetworkCredentialBinding + (*NetworkEndpoint)(nil), // 10: openshell.sandbox.v1.NetworkEndpoint + (*McpOptions)(nil), // 11: openshell.sandbox.v1.McpOptions + (*GraphqlOperation)(nil), // 12: openshell.sandbox.v1.GraphqlOperation + (*L7DenyRule)(nil), // 13: openshell.sandbox.v1.L7DenyRule + (*L7Rule)(nil), // 14: openshell.sandbox.v1.L7Rule + (*L7Allow)(nil), // 15: openshell.sandbox.v1.L7Allow + (*L7QueryMatcher)(nil), // 16: openshell.sandbox.v1.L7QueryMatcher + (*NetworkBinary)(nil), // 17: openshell.sandbox.v1.NetworkBinary + (*GetSandboxConfigRequest)(nil), // 18: openshell.sandbox.v1.GetSandboxConfigRequest + (*GetGatewayConfigRequest)(nil), // 19: openshell.sandbox.v1.GetGatewayConfigRequest + (*GetGatewayConfigResponse)(nil), // 20: openshell.sandbox.v1.GetGatewayConfigResponse + (*SettingValue)(nil), // 21: openshell.sandbox.v1.SettingValue + (*EffectiveSetting)(nil), // 22: openshell.sandbox.v1.EffectiveSetting + (*GetSandboxConfigResponse)(nil), // 23: openshell.sandbox.v1.GetSandboxConfigResponse + (*SupervisorMiddlewareService)(nil), // 24: openshell.sandbox.v1.SupervisorMiddlewareService + nil, // 25: openshell.sandbox.v1.SandboxPolicy.NetworkPoliciesEntry + nil, // 26: openshell.sandbox.v1.SandboxPolicy.NetworkMiddlewaresEntry + nil, // 27: openshell.sandbox.v1.NetworkEndpoint.GraphqlPersistedQueriesEntry + nil, // 28: openshell.sandbox.v1.L7DenyRule.QueryEntry + nil, // 29: openshell.sandbox.v1.L7DenyRule.ParamsEntry + nil, // 30: openshell.sandbox.v1.L7Allow.QueryEntry + nil, // 31: openshell.sandbox.v1.L7Allow.ParamsEntry + nil, // 32: openshell.sandbox.v1.GetGatewayConfigResponse.SettingsEntry + nil, // 33: openshell.sandbox.v1.GetSandboxConfigResponse.SettingsEntry + (*structpb.Struct)(nil), // 34: google.protobuf.Struct + (*datamodelv1.WorkspaceSelector)(nil), // 35: openshell.datamodel.v1.WorkspaceSelector } var file_sandbox_proto_depIdxs = []int32{ 3, // 0: openshell.sandbox.v1.SandboxPolicy.filesystem:type_name -> openshell.sandbox.v1.FilesystemPolicy @@ -2327,27 +2337,28 @@ var file_sandbox_proto_depIdxs = []int32{ 15, // 16: openshell.sandbox.v1.L7Rule.allow:type_name -> openshell.sandbox.v1.L7Allow 30, // 17: openshell.sandbox.v1.L7Allow.query:type_name -> openshell.sandbox.v1.L7Allow.QueryEntry 31, // 18: openshell.sandbox.v1.L7Allow.params:type_name -> openshell.sandbox.v1.L7Allow.ParamsEntry - 32, // 19: openshell.sandbox.v1.GetGatewayConfigResponse.settings:type_name -> openshell.sandbox.v1.GetGatewayConfigResponse.SettingsEntry - 21, // 20: openshell.sandbox.v1.EffectiveSetting.value:type_name -> openshell.sandbox.v1.SettingValue - 0, // 21: openshell.sandbox.v1.EffectiveSetting.scope:type_name -> openshell.sandbox.v1.SettingScope - 2, // 22: openshell.sandbox.v1.GetSandboxConfigResponse.policy:type_name -> openshell.sandbox.v1.SandboxPolicy - 33, // 23: openshell.sandbox.v1.GetSandboxConfigResponse.settings:type_name -> openshell.sandbox.v1.GetSandboxConfigResponse.SettingsEntry - 1, // 24: openshell.sandbox.v1.GetSandboxConfigResponse.policy_source:type_name -> openshell.sandbox.v1.PolicySource - 24, // 25: openshell.sandbox.v1.GetSandboxConfigResponse.supervisor_middleware_services:type_name -> openshell.sandbox.v1.SupervisorMiddlewareService - 6, // 26: openshell.sandbox.v1.SandboxPolicy.NetworkPoliciesEntry.value:type_name -> openshell.sandbox.v1.NetworkPolicyRule - 7, // 27: openshell.sandbox.v1.SandboxPolicy.NetworkMiddlewaresEntry.value:type_name -> openshell.sandbox.v1.NetworkMiddlewareConfig - 12, // 28: openshell.sandbox.v1.NetworkEndpoint.GraphqlPersistedQueriesEntry.value:type_name -> openshell.sandbox.v1.GraphqlOperation - 16, // 29: openshell.sandbox.v1.L7DenyRule.QueryEntry.value:type_name -> openshell.sandbox.v1.L7QueryMatcher - 16, // 30: openshell.sandbox.v1.L7DenyRule.ParamsEntry.value:type_name -> openshell.sandbox.v1.L7QueryMatcher - 16, // 31: openshell.sandbox.v1.L7Allow.QueryEntry.value:type_name -> openshell.sandbox.v1.L7QueryMatcher - 16, // 32: openshell.sandbox.v1.L7Allow.ParamsEntry.value:type_name -> openshell.sandbox.v1.L7QueryMatcher - 21, // 33: openshell.sandbox.v1.GetGatewayConfigResponse.SettingsEntry.value:type_name -> openshell.sandbox.v1.SettingValue - 22, // 34: openshell.sandbox.v1.GetSandboxConfigResponse.SettingsEntry.value:type_name -> openshell.sandbox.v1.EffectiveSetting - 35, // [35:35] is the sub-list for method output_type - 35, // [35:35] is the sub-list for method input_type - 35, // [35:35] is the sub-list for extension type_name - 35, // [35:35] is the sub-list for extension extendee - 0, // [0:35] is the sub-list for field type_name + 35, // 19: openshell.sandbox.v1.GetSandboxConfigRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 32, // 20: openshell.sandbox.v1.GetGatewayConfigResponse.settings:type_name -> openshell.sandbox.v1.GetGatewayConfigResponse.SettingsEntry + 21, // 21: openshell.sandbox.v1.EffectiveSetting.value:type_name -> openshell.sandbox.v1.SettingValue + 0, // 22: openshell.sandbox.v1.EffectiveSetting.scope:type_name -> openshell.sandbox.v1.SettingScope + 2, // 23: openshell.sandbox.v1.GetSandboxConfigResponse.policy:type_name -> openshell.sandbox.v1.SandboxPolicy + 33, // 24: openshell.sandbox.v1.GetSandboxConfigResponse.settings:type_name -> openshell.sandbox.v1.GetSandboxConfigResponse.SettingsEntry + 1, // 25: openshell.sandbox.v1.GetSandboxConfigResponse.policy_source:type_name -> openshell.sandbox.v1.PolicySource + 24, // 26: openshell.sandbox.v1.GetSandboxConfigResponse.supervisor_middleware_services:type_name -> openshell.sandbox.v1.SupervisorMiddlewareService + 6, // 27: openshell.sandbox.v1.SandboxPolicy.NetworkPoliciesEntry.value:type_name -> openshell.sandbox.v1.NetworkPolicyRule + 7, // 28: openshell.sandbox.v1.SandboxPolicy.NetworkMiddlewaresEntry.value:type_name -> openshell.sandbox.v1.NetworkMiddlewareConfig + 12, // 29: openshell.sandbox.v1.NetworkEndpoint.GraphqlPersistedQueriesEntry.value:type_name -> openshell.sandbox.v1.GraphqlOperation + 16, // 30: openshell.sandbox.v1.L7DenyRule.QueryEntry.value:type_name -> openshell.sandbox.v1.L7QueryMatcher + 16, // 31: openshell.sandbox.v1.L7DenyRule.ParamsEntry.value:type_name -> openshell.sandbox.v1.L7QueryMatcher + 16, // 32: openshell.sandbox.v1.L7Allow.QueryEntry.value:type_name -> openshell.sandbox.v1.L7QueryMatcher + 16, // 33: openshell.sandbox.v1.L7Allow.ParamsEntry.value:type_name -> openshell.sandbox.v1.L7QueryMatcher + 21, // 34: openshell.sandbox.v1.GetGatewayConfigResponse.SettingsEntry.value:type_name -> openshell.sandbox.v1.SettingValue + 22, // 35: openshell.sandbox.v1.GetSandboxConfigResponse.SettingsEntry.value:type_name -> openshell.sandbox.v1.EffectiveSetting + 36, // [36:36] is the sub-list for method output_type + 36, // [36:36] is the sub-list for method input_type + 36, // [36:36] is the sub-list for extension type_name + 36, // [36:36] is the sub-list for extension extendee + 0, // [0:36] is the sub-list for field type_name } func init() { file_sandbox_proto_init() } diff --git a/sdk/typescript/README.md b/sdk/typescript/README.md index 322b1a092c..112bfdc5fe 100644 --- a/sdk/typescript/README.md +++ b/sdk/typescript/README.md @@ -190,9 +190,9 @@ await client.sandboxTemplates.list({ workspace: 'default', limit: 100 }) await client.sandboxTemplates.delete('python', { workspace: 'default' }) ``` -Use `allWorkspaces: true` on `list()` for a platform-admin view. The SDK clears -the workspace field in that request because the gateway treats `workspace` and -`allWorkspaces` as mutually exclusive. +Use `allWorkspaces: true` on `list()` for a platform-admin view. The +discriminated option type makes `workspace` and `allWorkspaces` mutually +exclusive. Omitting both options explicitly selects the `default` workspace. ## Surface and roadmap @@ -212,14 +212,24 @@ Curated methods are added deliberately, so some gateway RPCs are not yet wrapped `client.raw` is a generated client for every gateway RPC, including surface the curated sub-clients do not wrap yet (gateway config, provider CRUD, policy status, watch, logs, and the full observed `Sandbox`). `client.transport` is the shared connection, so extra clients reuse one socket. Generated request and response types live at `@nvidia/openshell-sdk/raw`. ```ts +import { create } from '@bufbuild/protobuf' import { OpenShellClient } from '@nvidia/openshell-sdk' +import { WorkspaceSelectorSchema } from '@nvidia/openshell-sdk/raw' import type { GetGatewayConfigResponse } from '@nvidia/openshell-sdk/raw' const client = await OpenShellClient.connect({ gateway, oidcToken }) // Reach RPCs the curated surface does not wrap yet: const cfg: GetGatewayConfigResponse = await client.raw.getGatewayConfig({}) -const status = await client.raw.getSandboxPolicyStatus({ name: 'my-sandbox', version: 0, global: false }) +const defaultWorkspace = create(WorkspaceSelectorSchema, { + selection: { case: 'workspace', value: 'default' }, +}) +const status = await client.raw.getSandboxPolicyStatus({ + name: 'my-sandbox', + version: 0, + global: false, + workspaceScope: defaultWorkspace, +}) ``` The raw layer returns the generated wire messages verbatim, preserving proto distinctions (an omitted optional versus an explicitly empty map) that the curated types may smooth over. As curated sub-clients land, prefer them; `raw` stays as the always-available floor. diff --git a/sdk/typescript/src/client.test.ts b/sdk/typescript/src/client.test.ts index 5b12a48e23..5b0f7c1ee7 100644 --- a/sdk/typescript/src/client.test.ts +++ b/sdk/typescript/src/client.test.ts @@ -56,9 +56,27 @@ function readySandbox( const enc = (s: string) => new TextEncoder().encode(s); +type ScopedRequest = { + sandboxName?: string; + workspaceScope?: { selection?: { case?: string; value?: unknown } }; +}; + +function selectedWorkspace(req: ScopedRequest): string | undefined { + const selection = req.workspaceScope?.selection; + return selection?.case === 'workspace' && typeof selection.value === 'string' ? selection.value : undefined; +} + +function requestSandboxName(req: ScopedRequest): string | undefined { + return req.sandboxName; +} + +function selectsAllWorkspaces(req: ScopedRequest): boolean { + return req.workspaceScope?.selection?.case === 'allWorkspaces'; +} + describe('exec / execStream', () => { it('resolves the id via get, frames tty:false, and buffers the result (backward compat)', async () => { - let execReq: { sandboxId?: string; tty?: boolean; command?: string[] } = {}; + let execReq: ScopedRequest & { tty?: boolean; command?: string[] } = {}; const sandbox = client({ getSandbox: () => readySandbox('sb', 'sb-id-1'), // eslint-disable-next-line require-yield @@ -72,7 +90,7 @@ describe('exec / execStream', () => { }); const result = await sandbox.exec('sb', ['/bin/sh', '-c', 'echo hi']); - expect(execReq.sandboxId).toBe('sb-id-1'); + expect(requestSandboxName(execReq)).toBe('sb'); expect(execReq.tty).toBe(false); expect(execReq.command).toEqual(['/bin/sh', '-c', 'echo hi']); expect(result.exitCode).toBe(3); @@ -276,7 +294,7 @@ describe('create', () => { let created: { workloadTemplateName?: string; name?: string; - workspace?: string; + workspaceScope?: ScopedRequest['workspaceScope']; labels?: Record; spec?: { policy?: { version?: number }; @@ -289,7 +307,7 @@ describe('create', () => { const sandbox = client({ createSandbox: (req) => { created = req; - return readySandbox('job-1', 'sb-id', 7n, undefined, req.workspace || 'default'); + return readySandbox('job-1', 'sb-id', 7n, undefined, selectedWorkspace(req) ?? 'default'); }, }); const ref = await sandbox.createFromTemplate({ @@ -305,7 +323,7 @@ describe('create', () => { expect(created.workloadTemplateName).toBe('gpu-kata'); expect(created.name).toBe('job-1'); - expect(created.workspace).toBe('staging'); + expect(selectedWorkspace(created)).toBe('staging'); expect(created.labels).toEqual({ team: 'runtime' }); expect(created.spec?.providers).toEqual(['github']); expect(created.spec?.command).toEqual(['/opt/worker', '--serve']); @@ -317,15 +335,15 @@ describe('create', () => { it('propagates workspace through sandbox lifecycle calls', async () => { const observed: { - create?: { workspace?: string }; - get?: { workspace?: string }; - list?: { workspace?: string; allWorkspaces?: boolean }; - delete?: { workspace?: string }; - attach?: { workspace?: string }; - detach?: { workspace?: string }; - listProviders?: { workspace?: string }; - updatePolicy?: { workspace?: string }; - updateSetting?: { workspace?: string }; + create?: ScopedRequest; + get?: ScopedRequest; + list?: ScopedRequest; + delete?: ScopedRequest; + attach?: ScopedRequest; + detach?: ScopedRequest; + listProviders?: ScopedRequest; + updatePolicy?: ScopedRequest; + updateSetting?: ScopedRequest; configGets: string[]; execGet?: string; interactiveGet?: string; @@ -335,16 +353,18 @@ describe('create', () => { const sandbox = client({ createSandbox: (req) => { observed.create = req; - return readySandbox(req.name || 'sb', 'sb-created', 7n, undefined, req.workspace || 'default'); + return readySandbox(req.name || 'sb', 'sb-created', 7n, undefined, selectedWorkspace(req) ?? 'default'); }, getSandbox: (req) => { - if (req.name === 'exec') observed.execGet = req.workspace; - else if (req.name === 'interactive') observed.interactiveGet = req.workspace; - else if (req.name === 'ssh') observed.sshGet = req.workspace; - else if (req.name === 'forward') observed.forwardGet = req.workspace; - else if (req.name === 'config') observed.configGets.push(req.workspace); + const workspace = selectedWorkspace(req); + const name = requestSandboxName(req); + if (name === 'exec') observed.execGet = workspace; + else if (name === 'interactive') observed.interactiveGet = workspace; + else if (name === 'ssh') observed.sshGet = workspace; + else if (name === 'forward') observed.forwardGet = workspace; + else if (name === 'config' && workspace) observed.configGets.push(workspace); else observed.get = req; - return readySandbox(req.name, `${req.name}-id`, 7n, undefined, req.workspace || 'default'); + return readySandbox(name ?? '', `${name}-id`, 7n, undefined, workspace ?? 'default'); }, listSandboxes: (req) => { observed.list = req; @@ -354,7 +374,7 @@ describe('create', () => { metadata: { id: 'listed-id', name: 'listed', - workspace: req.workspace || 'default', + workspace: selectedWorkspace(req) ?? 'default', labels: { team: 'aire' }, resourceVersion: 7n, }, @@ -370,14 +390,26 @@ describe('create', () => { attachSandboxProvider: (req) => { observed.attach = req; return { - sandbox: readySandbox(req.sandboxName, 'attach-id', 7n, undefined, req.workspace || 'default').sandbox, + sandbox: readySandbox( + requestSandboxName(req) ?? '', + 'attach-id', + 7n, + undefined, + selectedWorkspace(req) ?? 'default', + ).sandbox, attached: true, }; }, detachSandboxProvider: (req) => { observed.detach = req; return { - sandbox: readySandbox(req.sandboxName, 'detach-id', 7n, undefined, req.workspace || 'default').sandbox, + sandbox: readySandbox( + requestSandboxName(req) ?? '', + 'detach-id', + 7n, + undefined, + selectedWorkspace(req) ?? 'default', + ).sandbox, detached: true, }; }, @@ -409,7 +441,7 @@ describe('create', () => { yield { payload: { case: 'exit', value: { exitCode: 0 } } }; }, createSshSession: (req) => ({ - sandboxId: req.sandboxId, + sandboxId: `${requestSandboxName(req) ?? ''}-id`, token: 'tok', gatewayHost: 'gw', gatewayPort: 443, @@ -451,19 +483,20 @@ describe('create', () => { expect(deleted).toBe(true); expect(attached.sandbox.workspace).toBe('staging'); expect(detached.sandbox.workspace).toBe('staging'); - expect(observed.create?.workspace).toBe('staging'); - expect(observed.get?.workspace).toBe('staging'); - expect(observed.list).toMatchObject({ workspace: 'staging', allWorkspaces: false }); - expect(observed.delete?.workspace).toBe('staging'); + expect(selectedWorkspace(observed.create ?? {})).toBe('staging'); + expect(selectedWorkspace(observed.get ?? {})).toBe('staging'); + expect(selectedWorkspace(observed.list ?? {})).toBe('staging'); + expect(selectsAllWorkspaces(observed.list ?? {})).toBe(false); + expect(selectedWorkspace(observed.delete ?? {})).toBe('staging'); expect(observed.execGet).toBe('staging'); expect(observed.interactiveGet).toBe('staging'); expect(observed.sshGet).toBe('staging'); - expect(observed.attach?.workspace).toBe('staging'); - expect(observed.detach?.workspace).toBe('staging'); - expect(observed.listProviders?.workspace).toBe('staging'); + expect(selectedWorkspace(observed.attach ?? {})).toBe('staging'); + expect(selectedWorkspace(observed.detach ?? {})).toBe('staging'); + expect(selectedWorkspace(observed.listProviders ?? {})).toBe('staging'); expect(observed.configGets).toContain('staging'); - expect(observed.updatePolicy?.workspace).toBe('staging'); - expect(observed.updateSetting?.workspace).toBe('staging'); + expect(selectedWorkspace(observed.updatePolicy ?? {})).toBe('staging'); + expect(selectedWorkspace(observed.updateSetting ?? {})).toBe('staging'); }); it('createFromTemplate rejects an empty template name locally', async () => { @@ -521,8 +554,7 @@ describe('create', () => { describe('sandbox templates', () => { it('create sends the template resource and workspace', async () => { - let observed: { - workspace?: string; + let observed: ScopedRequest & { template?: { metadata?: { name?: string; labels?: Record }; spec?: { @@ -544,7 +576,7 @@ describe('sandbox templates', () => { id: 'template-python', name: req.template?.metadata?.name ?? '', labels: req.template?.metadata?.labels ?? {}, - workspace: req.workspace, + workspace: selectedWorkspace(req), resourceVersion: 1n, }, spec: req.template?.spec, @@ -568,7 +600,7 @@ describe('sandbox templates', () => { { workspace: 'default' }, ); - expect(observed.workspace).toBe('default'); + expect(selectedWorkspace(observed)).toBe('default'); expect(observed.template?.metadata?.name).toBe('python'); expect(observed.template?.metadata?.labels).toEqual({ team: 'runtime' }); expect(observed.template?.spec?.workload?.environment).toEqual({ FEATURE_FLAG: 'on' }); @@ -579,16 +611,16 @@ describe('sandbox templates', () => { it('get list and delete forward workspace and pagination', async () => { const observed: { - get?: { name?: string; workspace?: string }; - list?: { limit?: number; offset?: number; workspace?: string; allWorkspaces?: boolean }; - delete?: { name?: string; workspace?: string }; + get?: ScopedRequest & { name?: string }; + list?: ScopedRequest & { limit?: number; offset?: number; labelSelector?: string }; + delete?: ScopedRequest & { name?: string }; } = {}; const templates = templateClient({ getSandboxTemplate: (req) => { observed.get = req; return { template: { - metadata: { id: 'template-gpu-kata', name: req.name, workspace: req.workspace }, + metadata: { id: 'template-gpu-kata', name: req.name, workspace: selectedWorkspace(req) }, spec: { workload: { image: 'img:v1' } }, }, }; @@ -598,7 +630,7 @@ describe('sandbox templates', () => { return { templates: [ { - metadata: { id: 'template-python', name: 'python', workspace: req.workspace || 'default' }, + metadata: { id: 'template-python', name: 'python', workspace: selectedWorkspace(req) ?? 'default' }, spec: { workload: { image: 'img:v1' } }, }, ], @@ -617,19 +649,21 @@ describe('sandbox templates', () => { expect(got.metadata?.name).toBe('gpu-kata'); expect(listed).toHaveLength(1); expect(deleted).toBe(true); - expect(observed.get).toMatchObject({ name: 'gpu-kata', workspace: 'staging' }); + expect(observed.get).toMatchObject({ name: 'gpu-kata' }); + expect(selectedWorkspace(observed.get ?? {})).toBe('staging'); expect(observed.list).toMatchObject({ limit: 10, offset: 2, - workspace: 'staging', - allWorkspaces: false, labelSelector: 'team=runtime', }); - expect(observed.delete).toMatchObject({ name: 'gpu-kata', workspace: 'staging' }); + expect(selectedWorkspace(observed.list ?? {})).toBe('staging'); + expect(selectsAllWorkspaces(observed.list ?? {})).toBe(false); + expect(observed.delete).toMatchObject({ name: 'gpu-kata' }); + expect(selectedWorkspace(observed.delete ?? {})).toBe('staging'); }); - it('list clears workspace when allWorkspaces is set', async () => { - let observed: { workspace?: string; allWorkspaces?: boolean } = {}; + it('list selects all workspaces explicitly', async () => { + let observed: ScopedRequest = {}; const templates = templateClient({ listSandboxTemplates: (req) => { observed = req; @@ -637,10 +671,10 @@ describe('sandbox templates', () => { }, }); - await templates.list({ workspace: 'staging', allWorkspaces: true }); + await templates.list({ allWorkspaces: true }); - expect(observed.workspace).toBe(''); - expect(observed.allWorkspaces).toBe(true); + expect(selectedWorkspace(observed)).toBeUndefined(); + expect(selectsAllWorkspaces(observed)).toBe(true); }); it('rejects empty names and missing template responses locally', async () => { @@ -738,7 +772,7 @@ describe('Pushable', () => { describe('execInteractive', () => { it('sends start first with tty/cols/rows, streams output, and resolves done', async () => { const cases: string[] = []; - let started: { tty?: boolean; cols?: number; rows?: number; sandboxId?: string } | undefined; + let started: (ScopedRequest & { tty?: boolean; cols?: number; rows?: number }) | undefined; const sandbox = client({ getSandbox: () => readySandbox('sb', 'sb-id-9'), execSandboxInteractive: async function* (requests) { @@ -782,7 +816,7 @@ describe('execInteractive', () => { expect(started?.tty).toBe(true); expect(started?.cols).toBe(120); expect(started?.rows).toBe(40); - expect(started?.sandboxId).toBe('sb-id-9'); + expect(requestSandboxName(started ?? {})).toBe('sb'); expect(out.join('')).toContain('ready\n'); expect(out.join('')).toContain('echo hi'); }); @@ -847,6 +881,7 @@ describe('providers', () => { it('attach/detach assemble the request and map the changed flag + sandbox ref', async () => { let attachReq: { sandboxName?: string; + workspaceScope?: ScopedRequest['workspaceScope']; providerName?: string; expectedResourceVersion?: bigint; } = {}; @@ -866,7 +901,7 @@ describe('providers', () => { }); const attach = await sandbox.attachProvider('sb', 'claude'); - expect(attachReq.sandboxName).toBe('sb'); + expect(requestSandboxName(attachReq)).toBe('sb'); expect(attachReq.providerName).toBe('claude'); expect(attachReq.expectedResourceVersion).toBe(0n); expect(attach.changed).toBe(true); @@ -943,7 +978,8 @@ describe('config / policy', () => { it('setPolicy sends global=false + version pin and (wait) polls until the hash matches', async () => { let updateReq: { - name?: string; + sandboxName?: string; + workspaceScope?: ScopedRequest['workspaceScope']; global?: boolean; expectedResourceVersion?: bigint; policy?: unknown; @@ -984,7 +1020,7 @@ describe('config / policy', () => { }, { wait: true, expectedResourceVersion: '7' }, ); - expect(updateReq.name).toBe('sb'); + expect(requestSandboxName(updateReq)).toBe('sb'); expect(updateReq.global).toBe(false); expect(updateReq.expectedResourceVersion).toBe(7n); expect(updateReq.policy).toBeDefined(); @@ -1017,7 +1053,8 @@ describe('config / policy', () => { it('setSetting upserts a single sandbox-scoped setting (global=false)', async () => { let req: { - name?: string; + sandboxName?: string; + workspaceScope?: ScopedRequest['workspaceScope']; settingKey?: string; global?: boolean; settingValue?: unknown; @@ -1036,7 +1073,7 @@ describe('config / policy', () => { const result = await sandbox.setSetting('sb', 'feature.enabled', { value: { case: 'boolValue', value: true }, }); - expect(req.name).toBe('sb'); + expect(requestSandboxName(req)).toBe('sb'); expect(req.settingKey).toBe('feature.enabled'); expect(req.global).toBe(false); expect(req.settingValue).toMatchObject({ @@ -1153,9 +1190,9 @@ describe('ssh sessions', () => { describe('forward', () => { it('binds a local port and relays bytes both ways, minting + revoking a token', async () => { - let sshReq: { sandboxId?: string } = {}; + let sshReq: ScopedRequest = {}; let revokedToken: string | undefined; - let initFrame: { sandboxId?: string; authorizationToken?: string; target?: unknown } | undefined; + let initFrame: (ScopedRequest & { authorizationToken?: string; target?: unknown }) | undefined; const sandbox = client({ getSandbox: () => readySandbox('sb', 'sb-id-forward'), createSshSession: (req) => { @@ -1206,8 +1243,8 @@ describe('forward', () => { }); expect(echoed).toBe('ping-through-forward'); - expect(sshReq.sandboxId).toBe('sb-id-forward'); - expect(initFrame?.sandboxId).toBe('sb-id-forward'); + expect(requestSandboxName(sshReq)).toBe('sb'); + expect(requestSandboxName(initFrame ?? {})).toBe('sb'); expect(initFrame?.authorizationToken).toBe('fwd-tok'); expect(initFrame?.target).toMatchObject({ case: 'tcp', @@ -1451,7 +1488,10 @@ describe('raw escape hatch', () => { // raw returns the full generated message: the enum stays numeric, where the // curated get() would lowercase status.phase to 'ready'. - const resp = await sandbox.raw.getSandbox({ name: 'sb' }); + const resp = await sandbox.raw.getSandbox({ + sandboxName: 'sb', + workspaceScope: { selection: { case: 'workspace', value: 'default' } }, + }); expect(resp.sandbox?.status?.phase).toBe(SandboxPhase.READY); expect(resp.sandbox?.metadata?.name).toBe('sb'); diff --git a/sdk/typescript/src/client.ts b/sdk/typescript/src/client.ts index 4d1ff362ac..120a5507d3 100644 --- a/sdk/typescript/src/client.ts +++ b/sdk/typescript/src/client.ts @@ -16,7 +16,7 @@ import * as net from 'node:net'; import type { MessageInitShape } from '@bufbuild/protobuf'; import { type CallOptions, type Client, createClient, type Transport } from '@connectrpc/connect'; import { errorCode, fromConnect, SdkError } from './errors.js'; -import type { Provider } from './gen/datamodel_pb.js'; +import type { Provider, WorkspaceSelectorSchema } from './gen/datamodel_pb.js'; import type { Sandbox, SandboxWorkloadTemplate, UpdateConfigResponse } from './gen/openshell_pb.js'; import { type ExecSandboxInputSchema, @@ -86,7 +86,7 @@ export interface Health { export interface SandboxSpec { name?: string; - /** Workspace scope. Omit or use an empty string for the gateway default workspace. */ + /** Workspace name. Omit for `default`; empty strings are invalid. */ workspace?: string; image?: string; labels?: Record; @@ -115,7 +115,7 @@ export interface SandboxSpec { export interface SandboxFromTemplateSpec { name?: string; - /** Workspace scope. Omit or use an empty string for the gateway default workspace. */ + /** Workspace name. Omit for `default`; empty strings are invalid. */ workspace?: string; templateName: string; labels?: Record; @@ -149,36 +149,37 @@ export interface SandboxWorkloadTemplateProvenance { resourceVersion: string; } -export interface ListOptions { +interface PaginationOptions { limit?: number; offset?: number; labelSelector?: string; - /** Workspace scope. Omit or use an empty string for the gateway default workspace. */ - workspace?: string; - /** List across all workspaces. Requires platform admin permission. */ - allWorkspaces?: boolean; } +/** Mutually exclusive named/default or all-workspaces list scope. */ +export type WorkspaceListScope = + | { workspace?: string; allWorkspaces?: false | undefined } + | { workspace?: never; allWorkspaces: true }; + +export type ListOptions = PaginationOptions & WorkspaceListScope; + export interface SandboxWorkspaceOptions { - /** Workspace scope. Omit or use an empty string for the gateway default workspace. */ + /** Workspace name. Omit for `default`; empty strings are invalid. */ workspace?: string; } export type SandboxCallOptions = CallOptions & SandboxWorkspaceOptions; export interface SandboxTemplateWorkspaceOptions { - /** Workspace scope. Omit or use an empty string for the gateway default workspace. */ + /** Workspace name. Omit for `default`; empty strings are invalid. */ workspace?: string; } -export interface SandboxTemplateListOptions extends SandboxTemplateWorkspaceOptions { +export type SandboxTemplateListOptions = WorkspaceListScope & { limit?: number; offset?: number; /** Optional label selector in key=value comma-separated form. */ labelSelector?: string; - /** List templates across all workspaces. Requires platform admin permission. */ - allWorkspaces?: boolean; -} +}; export interface ExecOptions extends SandboxWorkspaceOptions { workdir?: string; @@ -491,8 +492,22 @@ function versionPin(value: string | undefined): bigint { const FORWARD_CHUNK = 64 * 1024; -function workspaceOption(options?: SandboxWorkspaceOptions | null): string { - return options?.workspace ?? ''; +function workspaceName(options?: SandboxWorkspaceOptions | null): string { + const workspace = options?.workspace ?? 'default'; + if (workspace.trim() === '') throw new SdkError('invalid_config', 'workspace must be non-empty'); + return workspace; +} + +function workspaceScope(options?: SandboxWorkspaceOptions | null): MessageInitShape { + return { selection: { case: 'workspace', value: workspaceName(options) } }; +} + +function listWorkspaceScope(options?: WorkspaceListScope | null): MessageInitShape { + return options?.allWorkspaces ? { selection: { case: 'allWorkspaces', value: {} } } : workspaceScope(options); +} + +function sandboxTarget(name: string, options?: SandboxWorkspaceOptions | null) { + return { sandboxName: name, workspaceScope: workspaceScope(options) }; } function requestCallOptions(options?: SandboxCallOptions | null): CallOptions | undefined { @@ -653,7 +668,7 @@ export class SandboxTemplateClient { try { const resp = await this.grpc.createSandboxTemplate({ template, - workspace: options?.workspace ?? '', + workspaceScope: workspaceScope(options), }); return sandboxTemplate(resp.template); } catch (e) { @@ -666,7 +681,7 @@ export class SandboxTemplateClient { try { const resp = await this.grpc.getSandboxTemplate({ name, - workspace: options?.workspace ?? '', + workspaceScope: workspaceScope(options), }); return sandboxTemplate(resp.template); } catch (e) { @@ -676,13 +691,11 @@ export class SandboxTemplateClient { async list(options?: SandboxTemplateListOptions | null): Promise { try { - const allWorkspaces = options?.allWorkspaces ?? false; const resp = await this.grpc.listSandboxTemplates({ limit: options?.limit ?? 0, offset: options?.offset ?? 0, - workspace: allWorkspaces ? '' : (options?.workspace ?? ''), - allWorkspaces, labelSelector: options?.labelSelector ?? '', + workspaceScope: listWorkspaceScope(options), }); return resp.templates; } catch (e) { @@ -695,7 +708,7 @@ export class SandboxTemplateClient { try { const resp = await this.grpc.deleteSandboxTemplate({ name, - workspace: options?.workspace ?? '', + workspaceScope: workspaceScope(options), }); return resp.deleted; } catch (e) { @@ -758,7 +771,7 @@ export class SandboxClient { const resp = await this.grpc.createSandbox({ name: spec.name ?? '', labels: spec.labels ?? {}, - workspace: spec.workspace ?? '', + workspaceScope: workspaceScope(spec), spec: specInit, }); return sandboxRef(resp.sandbox); @@ -773,7 +786,7 @@ export class SandboxClient { const resp = await this.grpc.createSandbox({ name: spec.name ?? '', labels: spec.labels ?? {}, - workspace: spec.workspace ?? '', + workspaceScope: workspaceScope(spec), spec: { providers: spec.providers ?? [], command: spec.command ?? [], @@ -790,10 +803,7 @@ export class SandboxClient { async get(name: string, options?: SandboxCallOptions | null): Promise { try { - const resp = await this.grpc.getSandbox( - { name, workspace: workspaceOption(options) }, - requestCallOptions(options), - ); + const resp = await this.grpc.getSandbox({ ...sandboxTarget(name, options) }, requestCallOptions(options)); return sandboxRef(resp.sandbox); } catch (e) { throw fromConnect(e); @@ -802,13 +812,11 @@ export class SandboxClient { async list(options?: ListOptions | null): Promise { try { - const allWorkspaces = options?.allWorkspaces ?? false; const resp = await this.grpc.listSandboxes({ limit: options?.limit ?? 0, offset: options?.offset ?? 0, labelSelector: options?.labelSelector ?? '', - workspace: allWorkspaces ? '' : (options?.workspace ?? ''), - allWorkspaces, + workspaceScope: listWorkspaceScope(options), }); return resp.sandboxes.map((s) => sandboxRef(s)); } catch (e) { @@ -818,7 +826,7 @@ export class SandboxClient { async delete(name: string, options?: SandboxWorkspaceOptions | null): Promise { try { - const resp = await this.grpc.deleteSandbox({ name, workspace: workspaceOption(options) }); + const resp = await this.grpc.deleteSandbox({ ...sandboxTarget(name, options) }); return resp.deleted; } catch (e) { throw fromConnect(e); @@ -887,14 +895,14 @@ export class SandboxClient { options?: ExecOptions | null, ): AsyncGenerator { try { - // Resolve the sandbox id first, exactly like the gateway client. - const sandbox = await this.get(name, { + // Preserve the existing preflight so lookup failures surface before the stream starts. + await this.get(name, { workspace: options?.workspace, ...(options?.signal ? { signal: options.signal } : {}), }); const stream = this.grpc.execSandbox( { - sandboxId: sandbox.id, + ...sandboxTarget(name, options), command, workdir: options?.workdir ?? '', environment: options?.environment ?? {}, @@ -963,11 +971,8 @@ export class SandboxClient { command: string[], options?: ExecInteractiveOptions | null, ): Promise { - let sandboxId: string; try { - sandboxId = ( - await this.get(name, { workspace: options?.workspace, ...(options?.signal ? { signal: options.signal } : {}) }) - ).id; + await this.get(name, { workspace: options?.workspace, ...(options?.signal ? { signal: options.signal } : {}) }); } catch (e) { throw e instanceof SdkError ? e : fromConnect(e); } @@ -977,7 +982,7 @@ export class SandboxClient { payload: { case: 'start', value: { - sandboxId, + ...sandboxTarget(name, options), command, workdir: options?.workdir ?? '', environment: options?.environment ?? {}, @@ -1109,7 +1114,15 @@ export class SandboxClient { socket.on('error', () => {}); const controller = new AbortController(); controllers.add(controller); - const task = this.forwardConnection(socket, sandboxId, name, targetHost, targetPort, controller.signal) + const task = this.forwardConnection( + socket, + sandboxId, + name, + opts.workspace, + targetHost, + targetPort, + controller.signal, + ) .catch((error: unknown) => { if (!closing) { try { @@ -1189,6 +1202,7 @@ export class SandboxClient { socket: net.Socket, sandboxId: string, name: string, + workspace: string | undefined, targetHost: string, targetPort: number, signal: AbortSignal, @@ -1197,7 +1211,7 @@ export class SandboxClient { const input = new Pushable>(); input.onDrain = () => socket.resume(); try { - const session = await this.grpc.createSshSession({ sandboxId }, { signal }); + const session = await this.grpc.createSshSession({ ...sandboxTarget(name, { workspace }) }, { signal }); // Defense-in-depth: the token feeds forwardTcp authorization, so hold it // to the same trust-boundary contract as createSshSession. A violation // tears down this one socket via the catch below. @@ -1207,7 +1221,7 @@ export class SandboxClient { payload: { case: 'init', value: { - sandboxId, + ...sandboxTarget(name, { workspace }), serviceId: `service-forward:${name}:${targetHost}:${targetPort}`, target: { case: 'tcp', @@ -1270,7 +1284,7 @@ export class SandboxClient { async createSshSession(name: string, options?: SandboxWorkspaceOptions | null): Promise { try { const sandbox = await this.get(name, options); - const resp = await this.grpc.createSshSession({ sandboxId: sandbox.id }); + const resp = await this.grpc.createSshSession({ ...sandboxTarget(name, options) }); // Reject any response outside the proto trust-boundary contract before // handing these values to the caller (they feed OpenSSH ProxyCommand). validateSshResponse(resp, sandbox.id); @@ -1304,10 +1318,9 @@ export class SandboxClient { ): Promise { try { const resp = await this.grpc.attachSandboxProvider({ - sandboxName: name, + ...sandboxTarget(name, options), providerName: provider, expectedResourceVersion: versionPin(options?.expectedResourceVersion), - workspace: workspaceOption(options), }); return { sandbox: sandboxRef(resp.sandbox), changed: resp.attached }; } catch (e) { @@ -1322,10 +1335,9 @@ export class SandboxClient { ): Promise { try { const resp = await this.grpc.detachSandboxProvider({ - sandboxName: name, + ...sandboxTarget(name, options), providerName: provider, expectedResourceVersion: versionPin(options?.expectedResourceVersion), - workspace: workspaceOption(options), }); return { sandbox: sandboxRef(resp.sandbox), changed: resp.detached }; } catch (e) { @@ -1335,7 +1347,9 @@ export class SandboxClient { async listProviders(name: string, options?: SandboxWorkspaceOptions | null): Promise { try { - const resp = await this.grpc.listSandboxProviders({ sandboxName: name, workspace: workspaceOption(options) }); + const resp = await this.grpc.listSandboxProviders({ + ...sandboxTarget(name, options), + }); return resp.providers.map((p) => providerRef(p)); } catch (e) { throw fromConnect(e); @@ -1344,8 +1358,8 @@ export class SandboxClient { async getConfig(name: string, options?: SandboxCallOptions | null): Promise { try { - const sandbox = await this.get(name, options); - const resp = await this.grpc.getSandboxConfig({ sandboxId: sandbox.id }, requestCallOptions(options)); + await this.get(name, options); + const resp = await this.grpc.getSandboxConfig({ ...sandboxTarget(name, options) }, requestCallOptions(options)); return sandboxConfig(resp); } catch (e) { throw e instanceof SdkError ? e : fromConnect(e); @@ -1363,11 +1377,10 @@ export class SandboxClient { ): Promise { try { const resp = await this.grpc.updateConfig({ - name, + ...sandboxTarget(name, options), policy, global: false, expectedResourceVersion: versionPin(options?.expectedResourceVersion), - workspace: workspaceOption(options), }); const result = updateConfigResult(resp); if (options?.wait) @@ -1388,11 +1401,10 @@ export class SandboxClient { ): Promise { try { const resp = await this.grpc.updateConfig({ - name, + ...sandboxTarget(name, options), settingKey: key, settingValue: value, global: false, - workspace: workspaceOption(options), }); return updateConfigResult(resp); } catch (e) { diff --git a/sdk/typescript/src/index.ts b/sdk/typescript/src/index.ts index 31571865be..5d02fe6b61 100644 --- a/sdk/typescript/src/index.ts +++ b/sdk/typescript/src/index.ts @@ -43,6 +43,7 @@ export type { SshSession, UpdateConfigResult, WaitOptions, + WorkspaceListScope, } from './client.js'; export { errorCode, OpenShellClient, SandboxClient, SandboxTemplateClient } from './client.js'; export type { SdkErrorCode } from './errors.js'; diff --git a/skills/openshell-cli/SKILL.md b/skills/openshell-cli/SKILL.md index ed64496190..9bbda7d784 100644 --- a/skills/openshell-cli/SKILL.md +++ b/skills/openshell-cli/SKILL.md @@ -798,6 +798,9 @@ openshell service get my-app web openshell service delete my-app web ``` +Use `openshell service list --all-workspaces` for a Platform Admin view across +workspaces. A sandbox name and `--all-workspaces` are mutually exclusive. + Prefer loopback binds unless the user explicitly needs LAN-visible local access. --- diff --git a/tasks/scripts/generate_python_proto.py b/tasks/scripts/generate_python_proto.py index 76664eb415..cefd2c375b 100644 --- a/tasks/scripts/generate_python_proto.py +++ b/tasks/scripts/generate_python_proto.py @@ -56,6 +56,12 @@ "from . import datamodel_pb2 as datamodel__pb2", ), ], + "python/openshell/_proto/sandbox_pb2.py": [ + ( + r"^import datamodel_pb2 as datamodel__pb2$", + "from . import datamodel_pb2 as datamodel__pb2", + ), + ], "python/openshell/_proto/sandbox_pb2_grpc.py": [ ( r"^import sandbox_pb2 as sandbox__pb2$", diff --git a/tasks/scripts/sync_docs_website.py b/tasks/scripts/sync_docs_website.py index 0e04e8528e..62000394d3 100644 --- a/tasks/scripts/sync_docs_website.py +++ b/tasks/scripts/sync_docs_website.py @@ -1,28 +1,32 @@ #!/usr/bin/env python3 +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + # /// script # requires-python = ">=3.9" # dependencies = [ +# "packaging==25.0", # "PyYAML==6.0.2", # ] # /// -# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - from __future__ import annotations import argparse import re import shutil import sys -import tempfile from dataclasses import dataclass from pathlib import Path from typing import cast import yaml +from packaging.version import InvalidVersion, Version SLUG_RE = re.compile(r"^[A-Za-z0-9._-]+$") +DISPLAY_VERSION_RE = re.compile(r"\bv?(\d+\.\d+\.\d+(?:[.-]?[A-Za-z0-9]+)*)\b") +VERSION_AVAILABILITIES = {"beta", "deprecated", "ga", "stable"} +SNAPSHOT_METADATA_FILE = ".docs-snapshots.yml" YamlMapping = dict[str, object] @@ -31,21 +35,26 @@ class VersionEntry: slug: str display_name: str path: str + availability: str | None = None def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser( - description="Sync or remove one docs snapshot in the docs-website branch." + description="Sync or remove docs snapshots in the docs-website branch." ) parser.add_argument("--operation", choices=["sync", "remove"], default="sync") parser.add_argument("--source-root", type=Path) parser.add_argument("--docs-website-root", required=True, type=Path) parser.add_argument( - "--channel", required=True, choices=["dev", "latest", "version"] + "--channel", required=True, choices=["dev", "latest", "stable", "version"] ) parser.add_argument("--source-ref", default="") + parser.add_argument("--source-sha", default="") + parser.add_argument("--release-version", default="") parser.add_argument("--version-slug", default="") parser.add_argument("--display-name", default="") + parser.add_argument("--availability", default="") + parser.add_argument("--allow-rollback", action="store_true") return parser.parse_args() @@ -59,7 +68,9 @@ def resolve_slug(channel: str, version_slug: str) -> str: if channel == "latest": return "latest" if not version_slug: - raise ValueError("--version-slug is required when --channel=version") + raise ValueError( + "--version-slug is required when --channel=stable or --channel=version" + ) if not SLUG_RE.fullmatch(version_slug): raise ValueError( f"version slug contains unsupported characters: {version_slug}" @@ -79,24 +90,41 @@ def resolve_display_name( return slug +def resolve_availability(channel: str, override: str) -> str | None: + availability = override or ("beta" if channel == "dev" else "") + if not availability: + return None + if availability not in VERSION_AVAILABILITIES: + supported = ", ".join(sorted(VERSION_AVAILABILITIES)) + raise ValueError( + f"unsupported version availability {availability!r}; expected one of: {supported}" + ) + return availability + + +def parse_release_version(value: str) -> Version: + try: + return Version(value.removeprefix("v")) + except InvalidVersion as exc: + raise ValueError(f"invalid release version: {value}") from exc + + +def default_stable_availability(release_version: str) -> str | None: + if parse_release_version(release_version) >= Version("0.1.0"): + return "stable" + return None + + def ensure_existing(path: Path, label: str) -> None: if not path.exists(): raise FileNotFoundError(f"{label} does not exist: {path}") -def reset_directory(src: Path, dst: Path, *, preserve_components: bool) -> None: +def reset_directory(src: Path, dst: Path) -> None: ensure_existing(src, "source directory") - preserved_components: Path | None = None - if preserve_components and (dst / "_components").is_dir(): - preserved_components = Path(tempfile.mkdtemp()) / "_components" - shutil.copytree(dst / "_components", preserved_components) if dst.exists(): shutil.rmtree(dst) shutil.copytree(src, dst) - if preserved_components is not None: - if (dst / "_components").exists(): - shutil.rmtree(dst / "_components") - shutil.copytree(preserved_components, dst / "_components") def merge_directory(src: Path, dst: Path, *, overwrite: bool) -> None: @@ -138,6 +166,110 @@ def write_yaml(path: Path, data: YamlMapping) -> None: ) +def read_snapshot_metadata(path: Path) -> dict[str, dict[str, str]]: + if not path.exists(): + return {} + data = read_yaml(path) + raw_snapshots = data.get("snapshots") + if raw_snapshots is None: + return {} + if not isinstance(raw_snapshots, dict): + raise ValueError(f"expected snapshots mapping in {path}") + + snapshots: dict[str, dict[str, str]] = {} + for raw_slug, raw_snapshot in raw_snapshots.items(): + if not isinstance(raw_slug, str) or not isinstance(raw_snapshot, dict): + raise ValueError(f"invalid snapshot metadata in {path}") + snapshot = cast("YamlMapping", raw_snapshot) + source_ref = snapshot.get("source-ref") + source_sha = snapshot.get("source-sha", "") + version = snapshot.get("version") + if ( + not isinstance(source_ref, str) + or not isinstance(source_sha, str) + or not isinstance(version, str) + ): + raise ValueError(f"invalid snapshot metadata for {raw_slug} in {path}") + snapshots[raw_slug] = { + "source-ref": source_ref, + "source-sha": source_sha, + "version": version, + } + return snapshots + + +def write_snapshot_metadata(path: Path, snapshots: dict[str, dict[str, str]]) -> None: + write_yaml(path, {"snapshots": snapshots}) + + +def seed_mutable_snapshot_metadata( + snapshots: dict[str, dict[str, str]], docs_yml: Path, slug: str +) -> None: + if slug in snapshots: + return + data = read_yaml(docs_yml) + for entry in parse_versions(data.get("versions")): + if entry.slug != slug: + continue + match = DISPLAY_VERSION_RE.search(entry.display_name) + if match is not None: + snapshots[slug] = { + "source-ref": "", + "source-sha": "", + "version": str(parse_release_version(match.group(1))), + } + return + + +def ensure_immutable_snapshot( + snapshots: dict[str, dict[str, str]], + target_fern: Path, + slug: str, + source_sha: str, +) -> None: + existing = snapshots.get(slug) + if existing is not None: + if existing["source-sha"] != source_sha: + raise ValueError( + f"immutable snapshot {slug} already points to " + f"{existing['source-sha']}, not {source_sha}" + ) + return + if (target_fern / f"pages-{slug}").exists(): + raise ValueError( + f"immutable snapshot {slug} already exists without source metadata" + ) + + +def ensure_monotonic_snapshot( + snapshots: dict[str, dict[str, str]], + slug: str, + source_sha: str, + release_version: str, + *, + allow_rollback: bool, +) -> bool: + existing = snapshots.get(slug) + if existing is None: + return True + + incoming_version = parse_release_version(release_version) + existing_version = parse_release_version(existing["version"]) + if incoming_version < existing_version and not allow_rollback: + return False + if ( + incoming_version == existing_version + and bool(existing["source-sha"]) + and existing["source-sha"] != source_sha + and not allow_rollback + ): + raise ValueError( + f"snapshot {slug} version {release_version} already points to " + f"{existing['source-sha']}, not {source_sha}" + ) + return True + + def prefix_path(value: object, pages_dir: str) -> object: if not isinstance(value, str): return value @@ -179,13 +311,21 @@ def parse_versions(raw_versions: object) -> list[VersionEntry]: slug = entry.get("slug") display_name = entry.get("display-name") path = entry.get("path") + availability = entry.get("availability") if ( isinstance(slug, str) and isinstance(display_name, str) and isinstance(path, str) ): entries.append( - VersionEntry(slug=slug, display_name=display_name, path=path) + VersionEntry( + slug=slug, + display_name=display_name, + path=path, + availability=availability + if isinstance(availability, str) + else None, + ) ) return entries @@ -210,14 +350,17 @@ def ordered_entries( def render_versions(entries: list[VersionEntry]) -> list[dict[str, str]]: - return [ - { + rendered: list[dict[str, str]] = [] + for entry in entries: + item = { "display-name": entry.display_name, "path": entry.path, "slug": entry.slug, } - for entry in entries - ] + if entry.availability is not None: + item["availability"] = entry.availability + rendered.append(item) + return rendered def component_dirs(fern_dir: Path) -> list[str]: @@ -246,6 +389,35 @@ def update_docs_yml(docs_yml: Path, updated: VersionEntry, fern_dir: Path) -> No write_yaml(docs_yml, data) +def write_snapshot( + source_docs: Path, + source_fern: Path, + target_fern: Path, + entry: VersionEntry, + *, + refresh_shared: bool, +) -> None: + pages_dir = f"pages-{entry.slug}" + reset_directory(source_docs, target_fern / pages_dir) + if refresh_shared: + merge_directory(source_fern / "assets", target_fern / "assets", overwrite=True) + merge_directory( + source_fern / "components", target_fern / "components", overwrite=True + ) + copy_if_exists(source_fern / "main.css", target_fern / "main.css") + copy_if_exists( + source_fern / "fern.config.json", target_fern / "fern.config.json" + ) + + versions_dir = target_fern / "versions" + versions_dir.mkdir(parents=True, exist_ok=True) + write_yaml( + versions_dir / f"{entry.slug}.yml", + version_navigation(source_docs / "index.yml", pages_dir), + ) + update_docs_yml(target_fern / "docs.yml", entry, target_fern) + + def remove_docs_yml_entry(docs_yml: Path, slug: str, fern_dir: Path) -> None: data = read_yaml(docs_yml) entries = [ @@ -275,50 +447,118 @@ def sync_docs(args: argparse.Namespace) -> None: source_ref = clean_input(args.source_ref) if not source_ref: raise ValueError("--source-ref is required when --operation=sync") + source_sha = clean_input(getattr(args, "source_sha", "")) + if not source_sha: + raise ValueError("--source-sha is required when --operation=sync") + release_version = clean_input(getattr(args, "release_version", "")) version_slug = clean_input(args.version_slug) display_override = clean_input(args.display_name) + availability_override = clean_input(args.availability) + if channel in {"dev", "latest", "stable"} and not release_version: + raise ValueError( + "--release-version is required for dev, latest, and stable channels" + ) slug = resolve_slug(channel, version_slug) display_name = resolve_display_name(channel, slug, source_ref, display_override) - pages_dir = f"pages-{slug}" - refresh_shared = channel in {"dev", "latest"} - - reset_directory( - source_docs, - target_fern / pages_dir, - preserve_components=not refresh_shared, - ) - merge_directory( - source_fern / "assets", target_fern / "assets", overwrite=refresh_shared - ) - merge_directory( - source_fern / "components", target_fern / "components", overwrite=refresh_shared - ) - if refresh_shared: - copy_if_exists(source_fern / "main.css", target_fern / "main.css") - copy_if_exists( - source_fern / "fern.config.json", target_fern / "fern.config.json" + availability = resolve_availability(channel, availability_override) + metadata_path = target_fern / SNAPSHOT_METADATA_FILE + snapshots = read_snapshot_metadata(metadata_path) + docs_yml = target_fern / "docs.yml" + + if channel == "stable": + parsed_version = parse_release_version(release_version) + expected_slug = f"v{parsed_version}" + if slug != expected_slug: + raise ValueError(f"stable version slug must be {expected_slug}, got {slug}") + ensure_immutable_snapshot(snapshots, target_fern, slug, source_sha) + stable_availability = availability or default_stable_availability( + release_version ) + write_snapshot( + source_docs, + source_fern, + target_fern, + VersionEntry( + slug=slug, + display_name=slug, + path=f"./versions/{slug}.yml", + availability=stable_availability, + ), + refresh_shared=False, + ) + snapshots[slug] = { + "source-ref": source_ref, + "source-sha": source_sha, + "version": str(parsed_version), + } - versions_dir = target_fern / "versions" - versions_dir.mkdir(parents=True, exist_ok=True) - write_yaml( - versions_dir / f"{slug}.yml", - version_navigation(source_docs / "index.yml", pages_dir), - ) + seed_mutable_snapshot_metadata(snapshots, docs_yml, "latest") + if ensure_monotonic_snapshot( + snapshots, + "latest", + source_sha, + release_version, + allow_rollback=bool(getattr(args, "allow_rollback", False)), + ): + write_snapshot( + source_docs, + source_fern, + target_fern, + VersionEntry( + slug="latest", + display_name=display_override or f"Latest ({slug})", + path="./versions/latest.yml", + availability=stable_availability, + ), + refresh_shared=False, + ) + snapshots["latest"] = { + "source-ref": source_ref, + "source-sha": source_sha, + "version": str(parsed_version), + } + write_snapshot_metadata(metadata_path, snapshots) + print(f"Synced immutable {slug} docs from {source_ref}") + return + + if channel in {"dev", "latest"}: + seed_mutable_snapshot_metadata(snapshots, docs_yml, slug) + if not ensure_monotonic_snapshot( + snapshots, + slug, + source_sha, + release_version, + allow_rollback=bool(getattr(args, "allow_rollback", False)), + ): + print( + f"Skipped stale {slug} docs {release_version}; " + f"current version is {snapshots[slug]['version']}" + ) + return + else: + ensure_immutable_snapshot(snapshots, target_fern, slug, source_sha) + release_version = release_version or slug.removeprefix("v") - update_docs_yml( - target_fern / "docs.yml", + write_snapshot( + source_docs, + source_fern, + target_fern, VersionEntry( slug=slug, display_name=display_name, path=f"./versions/{slug}.yml", + availability=availability, ), - target_fern, + refresh_shared=channel == "dev", ) + snapshots[slug] = { + "source-ref": source_ref, + "source-sha": source_sha, + "version": release_version, + } + write_snapshot_metadata(metadata_path, snapshots) - print( - f"Synced {channel} docs from {source_ref} to fern/{pages_dir} ({display_name})" - ) + print(f"Synced {channel} docs from {source_ref} to fern/pages-{slug}") def remove_docs(args: argparse.Namespace) -> None: @@ -340,6 +580,11 @@ def remove_docs(args: argparse.Namespace) -> None: version_file.unlink() remove_docs_yml_entry(target_fern / "docs.yml", slug, target_fern) + metadata_path = target_fern / SNAPSHOT_METADATA_FILE + snapshots = read_snapshot_metadata(metadata_path) + if slug in snapshots: + del snapshots[slug] + write_snapshot_metadata(metadata_path, snapshots) print(f"Removed {slug} docs from docs website branch") diff --git a/tasks/scripts/sync_docs_website_test.py b/tasks/scripts/sync_docs_website_test.py index 722f3f48f6..076181ee39 100644 --- a/tasks/scripts/sync_docs_website_test.py +++ b/tasks/scripts/sync_docs_website_test.py @@ -11,23 +11,106 @@ from __future__ import annotations from argparse import Namespace -from typing import TYPE_CHECKING, cast +from pathlib import Path +from typing import cast import pytest import sync_docs_website as sdw import yaml -if TYPE_CHECKING: - from pathlib import Path - def read_yaml(path: Path) -> dict: return yaml.safe_load(path.read_text(encoding="utf-8")) +def read_workflow(name: str) -> dict: + path = Path(__file__).resolve().parents[2] / ".github" / "workflows" / name + return yaml.load(path.read_text(encoding="utf-8"), Loader=yaml.BaseLoader) + + +def test_release_workflows_sync_and_publish_docs_once() -> None: + dev = read_workflow("release-dev.yml") + tag = read_workflow("release-tag.yml") + dev_job = dev["jobs"]["publish-fern-docs"] + tag_job = tag["jobs"]["publish-fern-docs"] + + assert dev_job["needs"] == [ + "compute-versions", + "release-dev", + "release-helm", + "trigger-wheel-publish", + ] + assert dev_job["uses"] == "./.github/workflows/sync-docs.yml" + assert dev_job["with"]["channel"] == "dev" + assert ( + dev_job["with"]["release_version"] + == "${{ needs.compute-versions.outputs.docs_version }}" + ) + assert dev_job["with"]["publish"] == "true" + assert dev_job["with"]["display_name"] == "Dev" + assert dev_job["with"]["availability"] == "beta" + + assert tag_job["needs"] == [ + "compute-versions", + "release", + "publish-sdk-typescript", + "release-helm", + "trigger-wheel-publish", + ] + assert tag_job["uses"] == "./.github/workflows/sync-docs.yml" + assert tag_job["with"]["channel"] == "latest" + assert ( + tag_job["with"]["release_version"] + == "${{ needs.compute-versions.outputs.semver }}" + ) + assert "version_slug" not in tag_job["with"] + assert ( + tag_job["with"]["display_name"] + == "Latest (v${{ needs.compute-versions.outputs.semver }})" + ) + assert tag_job["with"]["publish"] == "true" + assert "is_prerelease != 'true'" in tag_job["if"] + + for workflow_name in ("release-dev.yml", "release-tag.yml"): + workflow_path = ( + Path(__file__).resolve().parents[2] + / ".github" + / "workflows" + / workflow_name + ) + workflow_text = workflow_path.read_text(encoding="utf-8") + assert workflow_text.count("uses: ./.github/workflows/sync-docs.yml") == 1 + assert "fern generate --docs" not in workflow_text + + +def test_sync_workflow_serializes_sync_and_publish() -> None: + workflow = read_workflow("sync-docs.yml") + triggers = workflow["on"] + publish_input = triggers["workflow_call"]["inputs"]["publish"] + assert publish_input["type"] == "boolean" + assert publish_input["default"] == "false" + assert workflow["concurrency"]["group"] == "docs-website" + assert workflow["concurrency"]["queue"] == "max" + publish_workflow = read_workflow("publish-docs-website.yml") + assert publish_workflow["concurrency"]["queue"] == "max" + + steps = workflow["jobs"]["sync"]["steps"] + step_names = [step["name"] for step in steps] + assert step_names.index("Commit docs website changes") < step_names.index( + "Publish Fern docs" + ) + publish_step = next(step for step in steps if step["name"] == "Publish Fern docs") + assert publish_step["if"] == "${{ inputs.publish }}" + assert publish_step["working-directory"] == "docs-website/fern" + update_step = next(step for step in steps if step["name"] == "Update docs snapshot") + assert "git -C source rev-parse HEAD" in update_step["run"] + assert '--source-sha "$SOURCE_SHA"' in update_step["run"] + + def test_resolve_slug_channels() -> None: assert sdw.resolve_slug("dev", "") == "dev" assert sdw.resolve_slug("latest", "") == "latest" + assert sdw.resolve_slug("stable", "v0.1.0") == "v0.1.0" assert sdw.resolve_slug("version", "v0.0.36") == "v0.0.36" @@ -55,6 +138,38 @@ def test_resolve_display_name() -> None: assert sdw.resolve_display_name("dev", "dev", "main", "Custom") == "Custom" +def test_resolve_availability() -> None: + assert sdw.resolve_availability("dev", "") == "beta" + assert sdw.resolve_availability("latest", "") is None + assert sdw.resolve_availability("version", "") is None + assert sdw.resolve_availability("version", "deprecated") == "deprecated" + with pytest.raises(ValueError): + sdw.resolve_availability("dev", "alpha") + + +def test_parse_and_render_versions_preserves_availability() -> None: + raw_versions = [ + { + "display-name": "v0.0.36", + "path": "./versions/v0.0.36.yml", + "slug": "v0.0.36", + "availability": "deprecated", + } + ] + + entries = sdw.parse_versions(raw_versions) + + assert entries == [ + sdw.VersionEntry( + "v0.0.36", + "v0.0.36", + "./versions/v0.0.36.yml", + "deprecated", + ) + ] + assert sdw.render_versions(entries) == raw_versions + + def test_ordered_entries_pins_latest_then_dev() -> None: existing = [ sdw.VersionEntry("v0.0.36", "v0.0.36", "./versions/v0.0.36.yml"), @@ -113,12 +228,26 @@ def _make_docs_website_tree(root: Path) -> None: (fern / "docs.yml").write_text(yaml.safe_dump({"versions": []}), encoding="utf-8") -def test_sync_docs_creates_snapshot(tmp_path: Path) -> None: +def test_sync_docs_creates_planned_latest_and_dev_selector(tmp_path: Path) -> None: source = tmp_path / "source" website = tmp_path / "docs-website" _make_source_tree(source) _make_docs_website_tree(website) + sdw.sync_docs( + Namespace( + operation="sync", + source_root=source, + docs_website_root=website, + channel="latest", + source_ref="release-sha", + source_sha="release-sha", + release_version="0.0.116", + version_slug="", + display_name="Latest (v0.0.116)", + availability="", + ) + ) sdw.sync_docs( Namespace( operation="sync", @@ -126,8 +255,11 @@ def test_sync_docs_creates_snapshot(tmp_path: Path) -> None: docs_website_root=website, channel="dev", source_ref="main", + source_sha="dev-sha", + release_version="0.0.117.dev56", version_slug="", - display_name="", + display_name="Dev", + availability="beta", ) ) @@ -139,12 +271,364 @@ def test_sync_docs_creates_snapshot(tmp_path: Path) -> None: assert version_nav["navigation"][0]["path"] == "../pages-dev/intro.mdx" docs_yml = read_yaml(fern / "docs.yml") - slugs = [entry["slug"] for entry in docs_yml["versions"]] - assert slugs == ["dev"] - assert docs_yml["versions"][0]["path"] == "./versions/dev.yml" + assert docs_yml["versions"] == [ + { + "display-name": "Latest (v0.0.116)", + "path": "./versions/latest.yml", + "slug": "latest", + }, + { + "display-name": "Dev", + "path": "./versions/dev.yml", + "slug": "dev", + "availability": "beta", + }, + ] assert "./components" in docs_yml["experimental"]["mdx-components"] +def test_sync_docs_preserves_other_version_availability(tmp_path: Path) -> None: + source = tmp_path / "source" + website = tmp_path / "docs-website" + _make_source_tree(source) + _make_docs_website_tree(website) + docs_yml_path = website / "fern" / "docs.yml" + docs_yml_path.write_text( + yaml.safe_dump( + { + "versions": [ + { + "display-name": "v0.0.36", + "path": "./versions/v0.0.36.yml", + "slug": "v0.0.36", + "availability": "deprecated", + } + ] + } + ), + encoding="utf-8", + ) + + sdw.sync_docs( + Namespace( + operation="sync", + source_root=source, + docs_website_root=website, + channel="dev", + source_ref="main", + source_sha="dev-sha", + release_version="0.0.117.dev56", + version_slug="", + display_name="Dev (v0.0.117.dev56)", + availability="beta", + ) + ) + + versions = read_yaml(docs_yml_path)["versions"] + assert versions == [ + { + "display-name": "Dev (v0.0.117.dev56)", + "path": "./versions/dev.yml", + "slug": "dev", + "availability": "beta", + }, + { + "display-name": "v0.0.36", + "path": "./versions/v0.0.36.yml", + "slug": "v0.0.36", + "availability": "deprecated", + }, + ] + + +def test_stable_sync_creates_immutable_version_and_promotes_latest( + tmp_path: Path, +) -> None: + source = tmp_path / "source" + website = tmp_path / "docs-website" + _make_source_tree(source) + _make_docs_website_tree(website) + + sdw.sync_docs( + Namespace( + source_root=source, + docs_website_root=website, + channel="stable", + source_ref="v0.2.0", + source_sha="new-sha", + release_version="0.2.0", + version_slug="v0.2.0", + display_name="", + availability="", + allow_rollback=False, + ) + ) + + fern = website / "fern" + assert (fern / "pages-v0.2.0" / "intro.mdx").is_file() + assert (fern / "pages-latest" / "intro.mdx").is_file() + versions = read_yaml(fern / "docs.yml")["versions"] + assert [entry["slug"] for entry in versions] == ["latest", "v0.2.0"] + assert versions[0]["display-name"] == "Latest (v0.2.0)" + assert versions[0]["availability"] == "stable" + assert versions[1]["availability"] == "stable" + snapshots = read_yaml(fern / sdw.SNAPSHOT_METADATA_FILE)["snapshots"] + assert snapshots["latest"] == { + "source-ref": "v0.2.0", + "source-sha": "new-sha", + "version": "0.2.0", + } + assert snapshots["v0.2.0"] == { + "source-ref": "v0.2.0", + "source-sha": "new-sha", + "version": "0.2.0", + } + + +def test_n_minus_one_sync_does_not_move_latest_backwards(tmp_path: Path) -> None: + current = tmp_path / "current" + maintenance = tmp_path / "maintenance" + website = tmp_path / "docs-website" + _make_source_tree(current) + _make_source_tree(maintenance) + (current / "docs" / "intro.mdx").write_text("# Current\n", encoding="utf-8") + (maintenance / "docs" / "intro.mdx").write_text("# Maintenance\n", encoding="utf-8") + _make_docs_website_tree(website) + + for source, source_sha, version in ( + (current, "current-sha", "0.3.1"), + (maintenance, "maintenance-sha", "0.2.7"), + ): + sdw.sync_docs( + Namespace( + source_root=source, + docs_website_root=website, + channel="stable", + source_ref=f"v{version}", + source_sha=source_sha, + release_version=version, + version_slug=f"v{version}", + display_name="", + availability="", + allow_rollback=False, + ) + ) + + fern = website / "fern" + assert (fern / "pages-latest" / "intro.mdx").read_text( + encoding="utf-8" + ) == "# Current\n" + assert (fern / "pages-v0.2.7" / "intro.mdx").read_text( + encoding="utf-8" + ) == "# Maintenance\n" + snapshots = read_yaml(fern / sdw.SNAPSHOT_METADATA_FILE)["snapshots"] + assert snapshots["latest"] == { + "source-ref": "v0.3.1", + "source-sha": "current-sha", + "version": "0.3.1", + } + + +def test_stable_sync_preserves_newer_legacy_latest_without_metadata( + tmp_path: Path, +) -> None: + source = tmp_path / "source" + website = tmp_path / "docs-website" + _make_source_tree(source) + _make_docs_website_tree(website) + fern = website / "fern" + (fern / "pages-latest").mkdir() + (fern / "pages-latest" / "intro.mdx").write_text( + "# Existing latest\n", encoding="utf-8" + ) + (fern / "docs.yml").write_text( + yaml.safe_dump( + { + "versions": [ + { + "display-name": "Latest (v0.3.1)", + "path": "./versions/latest.yml", + "slug": "latest", + } + ] + } + ), + encoding="utf-8", + ) + + sdw.sync_docs( + Namespace( + source_root=source, + docs_website_root=website, + channel="stable", + source_ref="v0.2.7", + source_sha="maintenance-sha", + release_version="0.2.7", + version_slug="v0.2.7", + display_name="", + availability="", + allow_rollback=False, + ) + ) + + assert (fern / "pages-latest" / "intro.mdx").read_text( + encoding="utf-8" + ) == "# Existing latest\n" + snapshots = read_yaml(fern / sdw.SNAPSHOT_METADATA_FILE)["snapshots"] + assert snapshots["latest"] == { + "source-ref": "", + "source-sha": "", + "version": "0.3.1", + } + + +def test_dev_sync_rejects_stale_or_conflicting_updates(tmp_path: Path) -> None: + current = tmp_path / "current" + stale = tmp_path / "stale" + website = tmp_path / "docs-website" + _make_source_tree(current) + _make_source_tree(stale) + (current / "docs" / "intro.mdx").write_text("# Current\n", encoding="utf-8") + (stale / "docs" / "intro.mdx").write_text("# Stale\n", encoding="utf-8") + _make_docs_website_tree(website) + + def sync(source: Path, source_sha: str, version: str) -> None: + sdw.sync_docs( + Namespace( + source_root=source, + docs_website_root=website, + channel="dev", + source_ref="main", + source_sha=source_sha, + release_version=version, + version_slug="", + display_name=f"Dev (v{version})", + availability="beta", + allow_rollback=False, + ) + ) + + sync(current, "current-sha", "0.3.2.dev10") + sync(stale, "stale-sha", "0.3.2.dev9") + intro = website / "fern" / "pages-dev" / "intro.mdx" + assert intro.read_text(encoding="utf-8") == "# Current\n" + + with pytest.raises(ValueError, match="already points to current-sha"): + sync(stale, "other-sha", "0.3.2.dev10") + + +def test_dev_sync_allows_explicit_rollback(tmp_path: Path) -> None: + current = tmp_path / "current" + stale = tmp_path / "stale" + website = tmp_path / "docs-website" + _make_source_tree(current) + _make_source_tree(stale) + (current / "docs" / "intro.mdx").write_text("# Current\n", encoding="utf-8") + (stale / "docs" / "intro.mdx").write_text("# Rolled back\n", encoding="utf-8") + _make_docs_website_tree(website) + + def sync(source: Path, source_sha: str, version: str, allow_rollback: bool) -> None: + sdw.sync_docs( + Namespace( + source_root=source, + docs_website_root=website, + channel="dev", + source_ref="main", + source_sha=source_sha, + release_version=version, + version_slug="", + display_name=f"Dev (v{version})", + availability="beta", + allow_rollback=allow_rollback, + ) + ) + + sync(current, "current-sha", "0.3.2.dev10", False) + sync(stale, "rollback-sha", "0.3.2.dev9", True) + + fern = website / "fern" + assert (fern / "pages-dev" / "intro.mdx").read_text( + encoding="utf-8" + ) == "# Rolled back\n" + snapshots = read_yaml(fern / sdw.SNAPSHOT_METADATA_FILE)["snapshots"] + assert snapshots["dev"] == { + "source-ref": "main", + "source-sha": "rollback-sha", + "version": "0.3.2.dev9", + } + + +def test_immutable_snapshot_cannot_change_source(tmp_path: Path) -> None: + source = tmp_path / "source" + website = tmp_path / "docs-website" + _make_source_tree(source) + _make_docs_website_tree(website) + args = Namespace( + source_root=source, + docs_website_root=website, + channel="stable", + source_ref="main", + source_sha="release-sha", + release_version="0.2.0", + version_slug="v0.2.0", + display_name="", + availability="", + allow_rollback=False, + ) + sdw.sync_docs(args) + + args.source_sha = "different-sha" + with pytest.raises(ValueError, match=r"immutable snapshot v0\.2\.0"): + sdw.sync_docs(args) + + +def test_only_dev_refreshes_shared_fern_files(tmp_path: Path) -> None: + dev = tmp_path / "dev" + release = tmp_path / "release" + website = tmp_path / "docs-website" + _make_source_tree(dev) + _make_source_tree(release) + (dev / "fern" / "components" / "Card.tsx").write_text( + "export const Card = 'dev';\n", encoding="utf-8" + ) + (release / "fern" / "components" / "Card.tsx").write_text( + "export const Card = 'release';\n", encoding="utf-8" + ) + _make_docs_website_tree(website) + + sdw.sync_docs( + Namespace( + source_root=dev, + docs_website_root=website, + channel="dev", + source_ref="main", + source_sha="dev-sha", + release_version="0.2.1.dev1", + version_slug="", + display_name="Dev (v0.2.1.dev1)", + availability="beta", + allow_rollback=False, + ) + ) + sdw.sync_docs( + Namespace( + source_root=release, + docs_website_root=website, + channel="stable", + source_ref="v0.2.0", + source_sha="release-sha", + release_version="0.2.0", + version_slug="v0.2.0", + display_name="", + availability="", + allow_rollback=False, + ) + ) + + card = website / "fern" / "components" / "Card.tsx" + assert card.read_text(encoding="utf-8") == "export const Card = 'dev';\n" + + def test_remove_docs_drops_snapshot(tmp_path: Path) -> None: source = tmp_path / "source" website = tmp_path / "docs-website" @@ -157,8 +641,10 @@ def test_remove_docs_drops_snapshot(tmp_path: Path) -> None: docs_website_root=website, channel="version", source_ref="v0.0.36", + source_sha="release-sha", version_slug="v0.0.36", display_name="", + availability="deprecated", ) sdw.sync_docs(base) @@ -175,6 +661,7 @@ def test_remove_docs_drops_snapshot(tmp_path: Path) -> None: source_ref="", version_slug="v0.0.36", display_name="", + availability="", ) ) @@ -182,3 +669,74 @@ def test_remove_docs_drops_snapshot(tmp_path: Path) -> None: assert not (fern / "versions" / "v0.0.36.yml").exists() docs_yml = read_yaml(fern / "docs.yml") assert [entry["slug"] for entry in docs_yml["versions"]] == [] + + +@pytest.mark.parametrize("channel", ["stable", "version"]) +def test_immutable_snapshot_uses_resolved_commit_identity( + tmp_path: Path, channel: str +) -> None: + source = tmp_path / "source" + website = tmp_path / "docs-website" + _make_source_tree(source) + _make_docs_website_tree(website) + args = Namespace( + source_root=source, + docs_website_root=website, + channel=channel, + source_ref="main", + source_sha="first-sha", + release_version="0.2.0" if channel == "stable" else "", + version_slug="v0.2.0", + display_name="", + availability="", + allow_rollback=False, + ) + sdw.sync_docs(args) + + (source / "docs" / "intro.mdx").write_text("# Changed\n", encoding="utf-8") + args.source_sha = "second-sha" + + with pytest.raises(ValueError, match=r"immutable snapshot v0\.2\.0"): + sdw.sync_docs(args) + assert (website / "fern" / "pages-v0.2.0" / "intro.mdx").read_text( + encoding="utf-8" + ) == "# Intro\n" + + +def test_stable_promotion_replaces_latest_page_components(tmp_path: Path) -> None: + old = tmp_path / "old" + new = tmp_path / "new" + website = tmp_path / "docs-website" + _make_source_tree(old) + _make_source_tree(new) + (old / "docs" / "_components").mkdir() + (old / "docs" / "_components" / "Widget.tsx").write_text( + "export const Widget = 'old';\n", encoding="utf-8" + ) + (new / "docs" / "_components").mkdir() + (new / "docs" / "_components" / "Widget.tsx").write_text( + "export const Widget = 'new';\n", encoding="utf-8" + ) + _make_docs_website_tree(website) + + for source, source_sha, version in ( + (old, "old-sha", "1.0.0"), + (new, "new-sha", "1.1.0"), + ): + sdw.sync_docs( + Namespace( + source_root=source, + docs_website_root=website, + channel="stable", + source_ref=f"v{version}", + source_sha=source_sha, + release_version=version, + version_slug=f"v{version}", + display_name="", + availability="", + allow_rollback=False, + ) + ) + + widget = website / "fern" / "pages-latest" / "_components" / "Widget.tsx" + assert widget.read_text(encoding="utf-8") == "export const Widget = 'new';\n" diff --git a/tasks/test.toml b/tasks/test.toml index bb1fa2e9ab..3df9b7371a 100644 --- a/tasks/test.toml +++ b/tasks/test.toml @@ -19,9 +19,9 @@ depends = [ ["test:docs-website"] description = "Test the docs-website sync script" -# --no-project skips installing the OpenShell package; --with supplies pytest -# and the script's runtime dependency (PyYAML), which lives outside the project env. -run = "uv run --no-project --with pytest --with pyyaml pytest tasks/scripts/sync_docs_website_test.py" +# --no-project skips installing the OpenShell package; --with supplies the test +# dependencies and the script's runtime dependency, which live outside the project env. +run = "uv run --no-project --with pytest --with pytest-asyncio --with pyyaml pytest tasks/scripts/sync_docs_website_test.py" ["test:sbom"] description = "Run SBOM tooling tests"