diff --git a/.github/workflows/real-e2e.yml b/.github/workflows/real-e2e.yml index 24fc84b87..da43c9634 100644 --- a/.github/workflows/real-e2e.yml +++ b/.github/workflows/real-e2e.yml @@ -50,6 +50,8 @@ jobs: - name: Clean up previous E2E resources run: | docker ps -aq --filter "label=opensandbox" | xargs -r docker rm -f || true + docker rm -f opensandbox-e2e-credential-vault-target || true + docker ps -aq --filter "label=opensandbox.e2e=credential-vault" | xargs -r docker rm -f || true docker rm -f opensandbox-e2e-redis || true # Remove root-owned files from previous sandbox runs by mounting parent dir docker run --rm -v /tmp:/host_tmp alpine rm -rf /host_tmp/opensandbox-e2e || true @@ -118,60 +120,10 @@ jobs: if: always() run: | docker ps -aq --filter "label=opensandbox" | xargs -r docker rm -f || true - docker rm -f opensandbox-e2e-redis || true - docker run --rm -v /tmp:/host_tmp alpine rm -rf /host_tmp/opensandbox-e2e || true - - python-credential-vault-e2e: - name: Python Credential Vault E2E (docker bridge) - runs-on: self-hosted - env: - UV_BIN: /home/admin/.local/bin - steps: - - name: Checkout code - uses: actions/checkout@v6 - - - name: Clean Docker runner cache - run: bash scripts/ci-docker-cleanup.sh - - - name: Set up uv PATH and verify - run: | - echo "${UV_BIN}" >> "$GITHUB_PATH" - export PATH="${UV_BIN}:${PATH}" - uv --version - uv run python --version - - - name: Clean up previous Credential Vault E2E resources - run: | - docker ps -aq --filter "label=opensandbox.e2e=credential-vault" | xargs -r docker rm -f || true docker rm -f opensandbox-e2e-credential-vault-target || true - docker run --rm -v /tmp:/host_tmp alpine rm -rf \ - /host_tmp/opensandbox-e2e \ - /host_tmp/opensandbox-credential-vault-e2e || true - docker image prune -f || true - - - name: Run Credential Vault E2E - run: ./scripts/python-credential-vault-e2e.sh - - - name: Eval server logs - if: ${{ always() }} - run: cat /tmp/opensandbox-credential-vault-e2e/server.log || true - - - name: Upload Credential Vault E2E logs - if: always() - uses: actions/upload-artifact@v7 - with: - name: credential-vault-e2e-logs - path: /tmp/opensandbox-credential-vault-e2e/ - retention-days: 5 - - - name: Clean up after Credential Vault E2E - if: always() - run: | docker ps -aq --filter "label=opensandbox.e2e=credential-vault" | xargs -r docker rm -f || true - docker rm -f opensandbox-e2e-credential-vault-target || true - docker run --rm -v /tmp:/host_tmp alpine rm -rf \ - /host_tmp/opensandbox-e2e \ - /host_tmp/opensandbox-credential-vault-e2e || true + docker rm -f opensandbox-e2e-redis || true + docker run --rm -v /tmp:/host_tmp alpine rm -rf /host_tmp/opensandbox-e2e || true java-e2e: name: Java E2E (docker bridge) @@ -208,6 +160,8 @@ jobs: - name: Clean up previous E2E resources run: | docker ps -aq --filter "label=opensandbox" | xargs -r docker rm -f || true + docker rm -f opensandbox-e2e-credential-vault-target || true + docker ps -aq --filter "label=opensandbox.e2e=credential-vault" | xargs -r docker rm -f || true docker rm -f opensandbox-e2e-redis || true docker run --rm -v /tmp:/host_tmp alpine rm -rf /host_tmp/opensandbox-e2e || true docker image prune -f || true @@ -284,6 +238,8 @@ jobs: if: always() run: | docker ps -aq --filter "label=opensandbox" | xargs -r docker rm -f || true + docker rm -f opensandbox-e2e-credential-vault-target || true + docker ps -aq --filter "label=opensandbox.e2e=credential-vault" | xargs -r docker rm -f || true docker rm -f opensandbox-e2e-redis || true docker run --rm -v /tmp:/host_tmp alpine rm -rf /host_tmp/opensandbox-e2e || true @@ -327,6 +283,8 @@ jobs: - name: Clean up previous E2E resources run: | docker ps -aq --filter "label=opensandbox" | xargs -r docker rm -f || true + docker rm -f opensandbox-e2e-credential-vault-target || true + docker ps -aq --filter "label=opensandbox.e2e=credential-vault" | xargs -r docker rm -f || true docker run --rm -v /tmp:/host_tmp alpine rm -rf /host_tmp/opensandbox-e2e || true docker image prune -f || true @@ -382,6 +340,8 @@ jobs: if: always() run: | docker ps -aq --filter "label=opensandbox" | xargs -r docker rm -f || true + docker rm -f opensandbox-e2e-credential-vault-target || true + docker ps -aq --filter "label=opensandbox.e2e=credential-vault" | xargs -r docker rm -f || true docker run --rm -v /tmp:/host_tmp alpine rm -rf /host_tmp/opensandbox-e2e || true csharp-e2e: @@ -414,6 +374,8 @@ jobs: - name: Clean up previous E2E resources run: | docker ps -aq --filter "label=opensandbox" | xargs -r docker rm -f || true + docker rm -f opensandbox-e2e-credential-vault-target || true + docker ps -aq --filter "label=opensandbox.e2e=credential-vault" | xargs -r docker rm -f || true docker run --rm -v /tmp:/host_tmp alpine rm -rf /host_tmp/opensandbox-e2e || true docker image prune -f || true @@ -468,6 +430,8 @@ jobs: if: always() run: | docker ps -aq --filter "label=opensandbox" | xargs -r docker rm -f || true + docker rm -f opensandbox-e2e-credential-vault-target || true + docker ps -aq --filter "label=opensandbox.e2e=credential-vault" | xargs -r docker rm -f || true docker run --rm -v /tmp:/host_tmp alpine rm -rf /host_tmp/opensandbox-e2e || true go-e2e: @@ -502,6 +466,8 @@ jobs: - name: Clean up previous E2E resources run: | docker ps -aq --filter "label=opensandbox" | xargs -r docker rm -f || true + docker rm -f opensandbox-e2e-credential-vault-target || true + docker ps -aq --filter "label=opensandbox.e2e=credential-vault" | xargs -r docker rm -f || true docker run --rm -v /tmp:/host_tmp alpine rm -rf /host_tmp/opensandbox-e2e || true docker image prune -f || true @@ -556,4 +522,6 @@ jobs: if: always() run: | docker ps -aq --filter "label=opensandbox" | xargs -r docker rm -f || true + docker rm -f opensandbox-e2e-credential-vault-target || true + docker ps -aq --filter "label=opensandbox.e2e=credential-vault" | xargs -r docker rm -f || true docker run --rm -v /tmp:/host_tmp alpine rm -rf /host_tmp/opensandbox-e2e || true diff --git a/README.md b/README.md index 230b36187..9a392a626 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,7 @@ OpenSandbox is now listed in the [CNCF Landscape](https://landscape.cncf.io/?ite - **Sandbox Runtime**: Built-in lifecycle management supporting Docker and [high-performance Kubernetes runtime](./kubernetes), enabling both local runs and large-scale distributed scheduling. - **Sandbox Environments**: Built-in Command, Filesystem, and Code Interpreter implementations. Examples cover Coding Agents (e.g., Claude Code), browser automation (Chrome, Playwright), and desktop environments (VNC, VS Code). - **Network Policy**: Unified [Ingress Gateway](components/ingress) with multiple routing strategies plus per-sandbox [egress controls](components/egress). +- **Credential Vault**: [Secure credential injection](docs/credential-vault.md) for sandbox outbound requests without exposing real secrets to workloads. - **Strong Isolation**: Supports secure container runtimes like gVisor, Kata Containers, and Firecracker microVM for enhanced isolation between sandbox workloads and the host. See [Secure Container Runtime Guide](docs/secure-container.md) for details. ## SDKs diff --git a/docs/credential-vault.md b/docs/credential-vault.md index ec96eb79f..b2169ffe3 100644 --- a/docs/credential-vault.md +++ b/docs/credential-vault.md @@ -5,8 +5,9 @@ Credential Vault is OpenSandbox's outbound credential broker for sandboxed agent ## How It Works Credential Vault is implemented by the egress sidecar. A sandbox must be created -with both a `network_policy` and `credential_proxy.enabled = true` in the Python -SDK. The lifecycle API field name is `credentialProxy.enabled`. +with both an outbound `network_policy` / `networkPolicy` and Credential Proxy +enabled. The lifecycle API field name is `credentialProxy.enabled`; SDKs expose +that field using their language-specific naming conventions. At a high level: @@ -82,11 +83,27 @@ X-Client-Secret: ## Requirements - Server config sets `[egress].image`. -- Sandbox create request includes `network_policy`. -- Sandbox create request sets `credential_proxy=CredentialProxyConfig(enabled=True)`. +- Sandbox create request includes an outbound network policy. +- Sandbox create request enables Credential Proxy. - The sandbox image has the tools you want to run. For Claude Code, use an image with Node.js and npm, such as the OpenSandbox code-interpreter image. +## SDK Quick Reference + +All sandbox SDKs use the same wire contract. The main differences are naming and +language style: + +| SDK | Enable proxy on sandbox create | Vault entry point | Create / patch methods | +| --- | --- | --- | --- | +| Python | `credential_proxy=CredentialProxyConfig(enabled=True)` | `sandbox.credential_vault` | `create(...)`, `patch(...)` | +| Go | `CredentialProxy: &opensandbox.CredentialProxyConfig{Enabled: true}` | `sandbox.CredentialVault(ctx)` or sandbox helpers | `CreateCredentialVault(ctx, req)`, `PatchCredentialVault(ctx, req)` | +| JavaScript/TypeScript | `credentialProxy: { enabled: true }` | `sandbox.credentialVault` | `create(request)`, `patch(request)` | +| Kotlin/JVM | `.credentialProxyEnabled(true)` or `.credentialProxy { enabled(true) }` | `sandbox.credentialVault()` | `create(request)`, `patch(request)` | +| C#/.NET | `CredentialProxy = new CredentialProxyConfig { Enabled = true }` | `sandbox.CredentialVault` or sandbox helpers | `CreateCredentialVaultAsync(...)`, `PatchCredentialVaultAsync(...)` | + +The vault APIs return sanitized metadata. Plaintext credential values are +write-only and are not returned by `get`, `list`, or patch responses. + ## Claude Code With Anthropic This example installs Claude Code in the sandbox and calls the official @@ -190,6 +207,61 @@ header from Credential Vault. If your environment uses a private npm mirror, replace `registry.npmjs.org` in the network policy and the `npm install` command with that mirror host. +## Git And Curl With Vault-Injected Credentials + +Credential Vault can also protect credentials used by command-line tools such as +`git` and `curl`. Keep the command free of real secrets and bind the request +shape to the credential in Vault instead. + +For a private Git repository, store a base64-encoded `username:token` value and +bind it with `basic` auth: + +```python +Credential(name="git-basic", source={"value": ""}) + +CredentialBinding( + name="git-basic", + match={ + "schemes": ["https"], + "ports": [443], + "hosts": ["git.example.com"], + "paths": ["/org/private-repo.git*"], + }, + auth={"type": "basic", "credential": "git-basic"}, +) +``` + +Then run the normal URL without embedding credentials: + +```bash +GIT_TERMINAL_PROMPT=0 git clone https://git.example.com/org/private-repo.git +``` + +For an API request that expects a token header, bind the path and method to an +`apiKey` auth rule: + +```python +Credential(name="api-token", source={"value": ""}) + +CredentialBinding( + name="api-token", + match={ + "schemes": ["https"], + "ports": [443], + "hosts": ["api.example.com"], + "methods": ["GET"], + "paths": ["/v1/projects/123/variables"], + }, + auth={"type": "apiKey", "name": "PRIVATE-TOKEN", "credential": "api-token"}, +) +``` + +The sandbox command stays secret-free: + +```bash +curl -fsS https://api.example.com/v1/projects/123/variables +``` + ## Binding Guidance - Use `defaultAction="deny"` and only allow the service hosts required by the diff --git a/docs/credential-vault_zh.md b/docs/credential-vault_zh.md index 9c83415f2..d11e82ad2 100644 --- a/docs/credential-vault_zh.md +++ b/docs/credential-vault_zh.md @@ -4,7 +4,7 @@ Credential Vault 是 OpenSandbox 为沙箱内 Agent 和开发工具提供的出 ## 原理 -Credential Vault 由 egress sidecar 提供。通过 Python SDK 创建沙箱时需要同时设置 `network_policy` 和 `credential_proxy.enabled = true`;对应的 lifecycle API 字段名是 `credentialProxy.enabled`。 +Credential Vault 由 egress sidecar 提供。创建沙箱时需要同时设置出站 `network_policy` / `networkPolicy`,并启用 Credential Proxy。对应的 lifecycle API 字段名是 `credentialProxy.enabled`;各语言 SDK 会按本语言命名习惯暴露这个字段。 整体流程如下: @@ -72,10 +72,24 @@ X-Client-Secret: ## 前置条件 - Server 配置中设置了 `[egress].image`。 -- 创建沙箱时传入 `network_policy`。 -- 创建沙箱时设置 `credential_proxy=CredentialProxyConfig(enabled=True)`。 +- 创建沙箱时传入出站网络策略。 +- 创建沙箱时启用 Credential Proxy。 - 沙箱镜像包含要运行的工具。运行 Claude Code 时,可以使用包含 Node.js 和 npm 的 OpenSandbox code-interpreter 镜像。 +## SDK 快速对照 + +所有 sandbox SDK 使用同一套 wire contract,主要差异是命名和语言风格: + +| SDK | 创建沙箱时启用 proxy | Vault 入口 | create / patch 方法 | +| --- | --- | --- | --- | +| Python | `credential_proxy=CredentialProxyConfig(enabled=True)` | `sandbox.credential_vault` | `create(...)`, `patch(...)` | +| Go | `CredentialProxy: &opensandbox.CredentialProxyConfig{Enabled: true}` | `sandbox.CredentialVault(ctx)` 或 sandbox helper | `CreateCredentialVault(ctx, req)`, `PatchCredentialVault(ctx, req)` | +| JavaScript/TypeScript | `credentialProxy: { enabled: true }` | `sandbox.credentialVault` | `create(request)`, `patch(request)` | +| Kotlin/JVM | `.credentialProxyEnabled(true)` 或 `.credentialProxy { enabled(true) }` | `sandbox.credentialVault()` | `create(request)`, `patch(request)` | +| C#/.NET | `CredentialProxy = new CredentialProxyConfig { Enabled = true }` | `sandbox.CredentialVault` 或 sandbox helper | `CreateCredentialVaultAsync(...)`, `PatchCredentialVaultAsync(...)` | + +Vault API 返回的是脱敏后的 metadata。明文 credential value 是 write-only 的,不会通过 `get`、`list` 或 patch response 返回。 + ## Claude Code 调用 Anthropic 官方 API 下面的示例会在沙箱中安装 Claude Code,并访问 Anthropic 官方 API 地址。真实 API key 从宿主机环境变量读取并写入 Credential Vault;沙箱中只放一个假的 `ANTHROPIC_API_KEY`。 @@ -173,6 +187,57 @@ finally: Claude Code 进程读取到的是假的 `ANTHROPIC_API_KEY`,但访问 `api.anthropic.com/v1/*` 时,Credential Vault 会在出站 HTTPS 请求上注入真实的 `x-api-key` header。如果你的环境使用 npm 私有镜像,需要把 network policy 和 `npm install` 命令中的 `registry.npmjs.org` 替换成对应镜像域名。 +## Git 和 curl 使用 Vault 注入凭证 + +Credential Vault 也可以保护 `git`、`curl` 这类命令行工具使用的凭证。命令中不要携带真实密钥,而是把请求形状绑定到 Vault 中的 credential。 + +对于私有 Git 仓库,可以把 base64 编码后的 `username:token` 存入 Vault,并用 `basic` auth 绑定: + +```python +Credential(name="git-basic", source={"value": ""}) + +CredentialBinding( + name="git-basic", + match={ + "schemes": ["https"], + "ports": [443], + "hosts": ["git.example.com"], + "paths": ["/org/private-repo.git*"], + }, + auth={"type": "basic", "credential": "git-basic"}, +) +``` + +然后在沙箱中执行不带凭证的普通 URL: + +```bash +GIT_TERMINAL_PROMPT=0 git clone https://git.example.com/org/private-repo.git +``` + +对于需要 token header 的 API 请求,可以把 method 和 path 绑定到 `apiKey` auth: + +```python +Credential(name="api-token", source={"value": ""}) + +CredentialBinding( + name="api-token", + match={ + "schemes": ["https"], + "ports": [443], + "hosts": ["api.example.com"], + "methods": ["GET"], + "paths": ["/v1/projects/123/variables"], + }, + auth={"type": "apiKey", "name": "PRIVATE-TOKEN", "credential": "api-token"}, +) +``` + +沙箱命令本身仍然不包含密钥: + +```bash +curl -fsS https://api.example.com/v1/projects/123/variables +``` + ## 使用建议 - 使用 `defaultAction="deny"`,只 allow 工具实际需要访问的服务域名。 diff --git a/scripts/credential-vault-e2e-target.sh b/scripts/credential-vault-e2e-target.sh new file mode 100644 index 000000000..4ff094a3b --- /dev/null +++ b/scripts/credential-vault-e2e-target.sh @@ -0,0 +1,76 @@ +#!/bin/bash +# +# Copyright 2026 Alibaba Group Holding Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +: "${OPENSANDBOX_CREDENTIAL_VAULT_E2E_TARGET_CONTAINER:=opensandbox-e2e-credential-vault-target}" +: "${OPENSANDBOX_CREDENTIAL_VAULT_E2E_TARGET_HOST:=credential-vault-e2e.opensandbox.test}" +: "${OPENSANDBOX_CREDENTIAL_VAULT_E2E_TARGET_IMAGE:=python:3.11-alpine}" +: "${OPENSANDBOX_CREDENTIAL_VAULT_E2E_LABEL_KEY:=opensandbox.e2e}" +: "${OPENSANDBOX_CREDENTIAL_VAULT_E2E_LABEL_VALUE:=credential-vault}" + +setup_credential_vault_e2e_target() { + local repo_root="${REPO_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}" + local label="${OPENSANDBOX_CREDENTIAL_VAULT_E2E_LABEL_KEY}=${OPENSANDBOX_CREDENTIAL_VAULT_E2E_LABEL_VALUE}" + + docker pull "${OPENSANDBOX_CREDENTIAL_VAULT_E2E_TARGET_IMAGE}" + docker rm -f "${OPENSANDBOX_CREDENTIAL_VAULT_E2E_TARGET_CONTAINER}" >/dev/null 2>&1 || true + docker ps -aq --filter "label=${label}" | xargs -r docker rm -f || true + + docker run -d \ + --name "${OPENSANDBOX_CREDENTIAL_VAULT_E2E_TARGET_CONTAINER}" \ + --label "${label}" \ + -v "${repo_root}/tests/python/tests/support:/srv:ro" \ + "${OPENSANDBOX_CREDENTIAL_VAULT_E2E_TARGET_IMAGE}" \ + python /srv/credential_vault_echo_server.py >/dev/null + + local target_ready="false" + for _ in $(seq 1 30); do + if docker exec "${OPENSANDBOX_CREDENTIAL_VAULT_E2E_TARGET_CONTAINER}" python - <<'PY' >/dev/null 2>&1 +import urllib.request +urllib.request.urlopen("http://127.0.0.1/healthz", timeout=1).read() +PY + then + target_ready="true" + break + fi + sleep 1 + done + + if [ "${target_ready}" != "true" ]; then + docker logs "${OPENSANDBOX_CREDENTIAL_VAULT_E2E_TARGET_CONTAINER}" || true + echo "Credential Vault E2E target container did not become ready" >&2 + return 1 + fi + + local target_ip + target_ip="$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' \ + "${OPENSANDBOX_CREDENTIAL_VAULT_E2E_TARGET_CONTAINER}")" + if [ -z "${target_ip}" ]; then + docker logs "${OPENSANDBOX_CREDENTIAL_VAULT_E2E_TARGET_CONTAINER}" || true + echo "Failed to determine Credential Vault E2E target container IP" >&2 + return 1 + fi + + export OPENSANDBOX_CREDENTIAL_VAULT_E2E_TARGET_HOST + export OPENSANDBOX_CREDENTIAL_VAULT_E2E_TARGET_IP="${target_ip}" + export OPENSANDBOX_CREDENTIAL_VAULT_E2E_LABEL_KEY + export OPENSANDBOX_CREDENTIAL_VAULT_E2E_LABEL_VALUE +} + +cleanup_credential_vault_e2e_target() { + local label="${OPENSANDBOX_CREDENTIAL_VAULT_E2E_LABEL_KEY}=${OPENSANDBOX_CREDENTIAL_VAULT_E2E_LABEL_VALUE}" + docker rm -f "${OPENSANDBOX_CREDENTIAL_VAULT_E2E_TARGET_CONTAINER}" >/dev/null 2>&1 || true + docker ps -aq --filter "label=${label}" | xargs -r docker rm -f || true +} diff --git a/scripts/csharp-e2e.sh b/scripts/csharp-e2e.sh index 567628b7c..6e76d0b24 100755 --- a/scripts/csharp-e2e.sh +++ b/scripts/csharp-e2e.sh @@ -21,13 +21,20 @@ RUN_CODE_INTERPRETER_E2E=${RUN_CODE_INTERPRETER_E2E:-false} REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" SERVER_PID="" +source "${REPO_ROOT}/scripts/credential-vault-e2e-target.sh" + cleanup_server() { if [ -n "${SERVER_PID}" ]; then kill "${SERVER_PID}" 2>/dev/null || true wait "${SERVER_PID}" 2>/dev/null || true fi } -trap cleanup_server EXIT + +cleanup() { + cleanup_server + cleanup_credential_vault_e2e_target +} +trap cleanup EXIT # build execd image locally (context must include internal/) docker build -f components/execd/Dockerfile -t opensandbox/execd:local "${REPO_ROOT}" @@ -52,6 +59,9 @@ docker run --rm -v opensandbox-e2e-pvc-test:/data alpine sh -c "\ echo 'pvc-subpath-marker' > /data/datasets/train/marker.txt" echo "-------- CSHARP E2E test logs for execd --------" > /tmp/opensandbox-e2e/logs/execd.log +export OPENSANDBOX_CREDENTIAL_VAULT_E2E_SANDBOX_IMAGE="${OPENSANDBOX_CREDENTIAL_VAULT_E2E_SANDBOX_IMAGE:-opensandbox/code-interpreter:${TAG}}" +setup_credential_vault_e2e_target + # setup server cd server : > server.log diff --git a/scripts/go-e2e.sh b/scripts/go-e2e.sh index d12065282..2e7fdf41b 100755 --- a/scripts/go-e2e.sh +++ b/scripts/go-e2e.sh @@ -20,13 +20,20 @@ TAG=${TAG:-latest} REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" SERVER_PID="" +source "${REPO_ROOT}/scripts/credential-vault-e2e-target.sh" + cleanup_server() { if [ -n "${SERVER_PID}" ]; then kill "${SERVER_PID}" 2>/dev/null || true wait "${SERVER_PID}" 2>/dev/null || true fi } -trap cleanup_server EXIT + +cleanup() { + cleanup_server + cleanup_credential_vault_e2e_target +} +trap cleanup EXIT # build execd image locally (context must include internal/) docker build -f components/execd/Dockerfile -t opensandbox/execd:local "${REPO_ROOT}" @@ -52,6 +59,9 @@ docker run --rm -v opensandbox-e2e-pvc-test:/data alpine sh -c "\ echo 'pvc-subpath-marker' > /data/datasets/train/marker.txt" echo "-------- GO E2E test logs for execd --------" > /tmp/opensandbox-e2e/logs/execd.log +export OPENSANDBOX_CREDENTIAL_VAULT_E2E_SANDBOX_IMAGE="${OPENSANDBOX_CREDENTIAL_VAULT_E2E_SANDBOX_IMAGE:-opensandbox/code-interpreter:${TAG}}" +setup_credential_vault_e2e_target + # setup server cd server export OPENSANDBOX_INSECURE_SERVER=YES diff --git a/scripts/java-e2e.sh b/scripts/java-e2e.sh index 48d67e2c1..6ab75e0e8 100644 --- a/scripts/java-e2e.sh +++ b/scripts/java-e2e.sh @@ -21,13 +21,20 @@ RUN_CODE_INTERPRETER_E2E=${RUN_CODE_INTERPRETER_E2E:-false} REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" SERVER_PID="" +source "${REPO_ROOT}/scripts/credential-vault-e2e-target.sh" + cleanup_server() { if [ -n "${SERVER_PID}" ]; then kill "${SERVER_PID}" 2>/dev/null || true wait "${SERVER_PID}" 2>/dev/null || true fi } -trap cleanup_server EXIT + +cleanup() { + cleanup_server + cleanup_credential_vault_e2e_target +} +trap cleanup EXIT # build execd image locally (context must include internal/) docker build -f components/execd/Dockerfile -t opensandbox/execd:local "${REPO_ROOT}" @@ -53,6 +60,9 @@ docker run --rm -v opensandbox-e2e-pvc-test:/data alpine sh -c "\ echo 'pvc-subpath-marker' > /data/datasets/train/marker.txt" echo "-------- JAVA E2E test logs for execd --------" > /tmp/opensandbox-e2e/logs/execd.log +export OPENSANDBOX_CREDENTIAL_VAULT_E2E_SANDBOX_IMAGE="${OPENSANDBOX_CREDENTIAL_VAULT_E2E_SANDBOX_IMAGE:-opensandbox/code-interpreter:${TAG}}" +setup_credential_vault_e2e_target + # setup server cd server export OPENSANDBOX_INSECURE_SERVER=YES diff --git a/scripts/javascript-e2e.sh b/scripts/javascript-e2e.sh index 2adf1c3bb..a256bfcf0 100644 --- a/scripts/javascript-e2e.sh +++ b/scripts/javascript-e2e.sh @@ -21,13 +21,20 @@ RUN_CODE_INTERPRETER_E2E=${RUN_CODE_INTERPRETER_E2E:-false} REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" SERVER_PID="" +source "${REPO_ROOT}/scripts/credential-vault-e2e-target.sh" + cleanup_server() { if [ -n "${SERVER_PID}" ]; then kill "${SERVER_PID}" 2>/dev/null || true wait "${SERVER_PID}" 2>/dev/null || true fi } -trap cleanup_server EXIT + +cleanup() { + cleanup_server + cleanup_credential_vault_e2e_target +} +trap cleanup EXIT # build execd image locally (context must include internal/) docker build -f components/execd/Dockerfile -t opensandbox/execd:local "${REPO_ROOT}" @@ -53,6 +60,9 @@ docker run --rm -v opensandbox-e2e-pvc-test:/data alpine sh -c "\ echo 'pvc-subpath-marker' > /data/datasets/train/marker.txt" echo "-------- JAVASCRIPT E2E test logs for execd --------" > /tmp/opensandbox-e2e/logs/execd.log +export OPENSANDBOX_CREDENTIAL_VAULT_E2E_SANDBOX_IMAGE="${OPENSANDBOX_CREDENTIAL_VAULT_E2E_SANDBOX_IMAGE:-opensandbox/code-interpreter:${TAG}}" +setup_credential_vault_e2e_target + # setup server cd server export OPENSANDBOX_INSECURE_SERVER=YES diff --git a/scripts/python-credential-vault-e2e.sh b/scripts/python-credential-vault-e2e.sh deleted file mode 100755 index 210fdbc62..000000000 --- a/scripts/python-credential-vault-e2e.sh +++ /dev/null @@ -1,143 +0,0 @@ -#!/bin/bash -# Copyright 2026 Alibaba Group Holding Ltd. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -set -euo pipefail - -TAG=${TAG:-latest} -SERVER_PORT=${OPENSANDBOX_CREDENTIAL_VAULT_E2E_SERVER_PORT:-32889} -LOG_DIR=${OPENSANDBOX_CREDENTIAL_VAULT_E2E_LOG_DIR:-/tmp/opensandbox-credential-vault-e2e} -TARGET_CONTAINER=${OPENSANDBOX_CREDENTIAL_VAULT_E2E_TARGET_CONTAINER:-opensandbox-e2e-credential-vault-target} -TARGET_IMAGE=${OPENSANDBOX_CREDENTIAL_VAULT_E2E_TARGET_IMAGE:-python:3.11-alpine} -SANDBOX_IMAGE=${OPENSANDBOX_SANDBOX_DEFAULT_IMAGE:-opensandbox/code-interpreter:${TAG}} -E2E_LABEL_KEY=${OPENSANDBOX_CREDENTIAL_VAULT_E2E_LABEL_KEY:-opensandbox.e2e} -E2E_LABEL_VALUE=${OPENSANDBOX_CREDENTIAL_VAULT_E2E_LABEL_VALUE:-credential-vault} -E2E_LABEL="${E2E_LABEL_KEY}=${E2E_LABEL_VALUE}" - -export HOME="${HOME:-/home/admin}" -export OPENSANDBOX_CREDENTIAL_VAULT_E2E_LABEL_KEY="${E2E_LABEL_KEY}" -export OPENSANDBOX_CREDENTIAL_VAULT_E2E_LABEL_VALUE="${E2E_LABEL_VALUE}" -REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" -SERVER_PID="" - -cleanup() { - if [ -n "${SERVER_PID}" ]; then - kill "${SERVER_PID}" 2>/dev/null || true - fi - docker rm -f "${TARGET_CONTAINER}" >/dev/null 2>&1 || true - docker ps -aq --filter "label=${E2E_LABEL}" | xargs -r docker rm -f || true - docker run --rm -v /tmp:/host_tmp alpine rm -rf /host_tmp/opensandbox-e2e >/dev/null 2>&1 || true -} -trap cleanup EXIT - -wait_http() { - local url="$1" - python3 - "$url" <<'PY' -import sys -import time -import urllib.request - -url = sys.argv[1] -deadline = time.monotonic() + 60 -last_error = None -while time.monotonic() < deadline: - try: - with urllib.request.urlopen(url, timeout=1) as response: - if response.status < 500: - raise SystemExit(0) - except Exception as exc: - last_error = exc - time.sleep(1) -raise SystemExit(f"Timed out waiting for {url}: {last_error}") -PY -} - -mkdir -p "${LOG_DIR}" /tmp/opensandbox-e2e/host-volume-test /tmp/opensandbox-e2e/logs -chmod -R 755 /tmp/opensandbox-e2e - -cd "${REPO_ROOT}" - -docker build -f components/execd/Dockerfile -t opensandbox/execd:local "${REPO_ROOT}" -docker build -f components/egress/Dockerfile -t opensandbox/egress:local "${REPO_ROOT}" -docker pull "${SANDBOX_IMAGE}" -docker pull "${TARGET_IMAGE}" - -docker rm -f "${TARGET_CONTAINER}" >/dev/null 2>&1 || true -docker run -d \ - --name "${TARGET_CONTAINER}" \ - --label "${E2E_LABEL}" \ - -v "${REPO_ROOT}/tests/python/tests/support:/srv:ro" \ - "${TARGET_IMAGE}" \ - python /srv/credential_vault_echo_server.py >/dev/null - -TARGET_READY=false -for _ in $(seq 1 30); do - if docker exec "${TARGET_CONTAINER}" python - <<'PY' >/dev/null 2>&1 -import urllib.request -urllib.request.urlopen("http://127.0.0.1/healthz", timeout=1).read() -PY - then - TARGET_READY=true - break - fi - sleep 1 -done -if [ "${TARGET_READY}" != "true" ]; then - docker logs "${TARGET_CONTAINER}" || true - echo "Credential Vault E2E target container did not become ready" >&2 - exit 1 -fi - -TARGET_IP="$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "${TARGET_CONTAINER}")" -if [ -z "${TARGET_IP}" ]; then - docker logs "${TARGET_CONTAINER}" || true - echo "Failed to determine Credential Vault E2E target container IP" >&2 - exit 1 -fi - -cat > "${HOME}/.sandbox.toml" < "${LOG_DIR}/server.log" 2>&1 & -SERVER_PID=$! - -wait_http "http://127.0.0.1:${SERVER_PORT}/health" - -cd "${REPO_ROOT}/tests/python" -uv sync --all-extras -export OPENSANDBOX_TEST_DOMAIN="localhost:${SERVER_PORT}" -export OPENSANDBOX_TEST_PROTOCOL="http" -export OPENSANDBOX_TEST_API_KEY="" -export OPENSANDBOX_SANDBOX_DEFAULT_IMAGE="${SANDBOX_IMAGE}" -export OPENSANDBOX_CREDENTIAL_VAULT_E2E_TARGET_IP="${TARGET_IP}" -uv run pytest tests/test_credential_vault_e2e.py -v diff --git a/scripts/python-e2e.sh b/scripts/python-e2e.sh index b032080d6..9278d32ee 100755 --- a/scripts/python-e2e.sh +++ b/scripts/python-e2e.sh @@ -25,13 +25,20 @@ RUN_CODE_INTERPRETER_E2E=${RUN_CODE_INTERPRETER_E2E:-false} REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" SERVER_PID="" +source "${REPO_ROOT}/scripts/credential-vault-e2e-target.sh" + cleanup_server() { if [ -n "${SERVER_PID}" ]; then kill "${SERVER_PID}" 2>/dev/null || true wait "${SERVER_PID}" 2>/dev/null || true fi } -trap cleanup_server EXIT + +cleanup() { + cleanup_server + cleanup_credential_vault_e2e_target +} +trap cleanup EXIT # build execd image locally (context must include internal/) docker build -f components/execd/Dockerfile -t opensandbox/execd:local "${REPO_ROOT}" @@ -57,6 +64,9 @@ docker run --rm -v opensandbox-e2e-pvc-test:/data alpine sh -c "\ echo 'pvc-subpath-marker' > /data/datasets/train/marker.txt" echo "-------- PYTHON E2E test logs for execd --------" > /tmp/opensandbox-e2e/logs/execd.log +export OPENSANDBOX_CREDENTIAL_VAULT_E2E_SANDBOX_IMAGE="${OPENSANDBOX_CREDENTIAL_VAULT_E2E_SANDBOX_IMAGE:-opensandbox/code-interpreter:${TAG}}" +setup_credential_vault_e2e_target + # setup server cd server export OPENSANDBOX_INSECURE_SERVER=YES diff --git a/sdks/sandbox/csharp/README.md b/sdks/sandbox/csharp/README.md index e35bdaff9..ae9756d0e 100644 --- a/sdks/sandbox/csharp/README.md +++ b/sdks/sandbox/csharp/README.md @@ -304,6 +304,7 @@ var sandbox = await Sandbox.CreateAsync(new SandboxCreateOptions | `Env` | Environment variables | `{}` | | `Metadata` | Custom metadata tags | `{}` | | `NetworkPolicy` | Optional outbound network policy (egress) | - | +| `CredentialProxy` | Optional Credential Vault proxy startup settings | - | | `Volumes` | Optional storage mounts (`Host` / `PVC`, supports `ReadOnly` and `SubPath`) | - | | `Extensions` | Extra server-defined fields | `{}` | | `SkipHealthCheck` | Skip readiness checks (`Running` + health check) | `false` | @@ -361,7 +362,64 @@ await sandbox.PatchEgressRulesAsync(new[] }); ``` -### 5. Timeout and Retry Behavior +### 5. Credential Vault + +Credential Vault injects outbound credentials from the egress sidecar while +keeping real secrets out of sandbox environment variables, commands, files, and +logs. Create the sandbox with `CredentialProxy` enabled, then write credentials +and bindings through `sandbox.CredentialVault` or the sandbox helper methods. + +```csharp +var sandbox = await Sandbox.CreateAsync(new SandboxCreateOptions +{ + ConnectionConfig = config, + Image = "python:3.11", + NetworkPolicy = new NetworkPolicy + { + DefaultAction = NetworkRuleAction.Deny, + Egress = new List + { + new() { Action = NetworkRuleAction.Allow, Target = "api.example.com" } + } + }, + CredentialProxy = new CredentialProxyConfig { Enabled = true } +}); + +await sandbox.CreateCredentialVaultAsync( + new[] + { + new Credential + { + Name = "api-token", + Source = new InlineCredentialSource { Value = "" } + } + }, + new[] + { + new CredentialBinding + { + Name = "api-token", + Match = new CredentialMatch + { + Schemes = new[] { "https" }, + Ports = new[] { 443 }, + Hosts = new[] { "api.example.com" }, + Paths = new[] { "/v1/*" } + }, + Auth = new CredentialAuth + { + Type = "apiKey", + Name = "x-api-key", + Credential = "api-token" + } + } + }); +``` + +See [Credential Vault](../../../docs/credential-vault.md) for auth types, +binding guidance, and Git/curl examples. + +### 6. Timeout and Retry Behavior - `ConnectionConfig.RequestTimeoutSeconds` controls timeout for SDK HTTP calls. - `RunCommandOptions.TimeoutSeconds` controls command execution timeout for command runs. @@ -370,7 +428,7 @@ await sandbox.PatchEgressRulesAsync(new[] - `ReadyTimeoutSeconds` controls how long `CreateAsync` / `ConnectAsync` waits for readiness. - The SDK does not automatically retry failed API requests; implement retries in caller code where appropriate. -### 6. Resource Cleanup +### 7. Resource Cleanup Both `Sandbox` and `SandboxManager` implement `IAsyncDisposable`. Use `await using` or call `DisposeAsync()` when done. diff --git a/sdks/sandbox/csharp/README_zh.md b/sdks/sandbox/csharp/README_zh.md index 28b3f373a..681e89549 100644 --- a/sdks/sandbox/csharp/README_zh.md +++ b/sdks/sandbox/csharp/README_zh.md @@ -288,6 +288,7 @@ var sandbox = await Sandbox.CreateAsync(new SandboxCreateOptions | `Env` | 环境变量 | `{}` | | `Metadata` | 自定义元数据标签 | `{}` | | `NetworkPolicy` | 可选的出站网络策略(egress) | - | +| `CredentialProxy` | 可选的 Credential Vault proxy 启动配置 | - | | `Volumes` | 可选存储挂载(`Host` / `PVC`,支持 `ReadOnly` 与 `SubPath`) | - | | `Extensions` | 额外的服务器定义字段 | `{}` | | `SkipHealthCheck` | 跳过就绪检查(`Running` + 健康检查) | `false` | @@ -338,7 +339,13 @@ await sandbox.PatchEgressRulesAsync(new[] }); ``` -### 4. 资源清理 +### 4. Credential Vault + +Credential Vault 可以由 egress sidecar 在出站请求中注入凭证,避免真实密钥进入沙箱环境变量、命令参数、文件或日志。创建沙箱时设置 `CredentialProxy = new CredentialProxyConfig { Enabled = true }`,然后通过 `sandbox.CreateCredentialVaultAsync(...)` / `PatchCredentialVaultAsync(...)` 写入 credentials 和 bindings。 + +更多 auth 类型、binding 规则和 Git/curl 示例请参考 [Credential Vault](../../../docs/credential-vault_zh.md)。 + +### 5. 资源清理 `Sandbox` 和 `SandboxManager` 都实现了 `IAsyncDisposable`。完成后使用 `await using` 或调用 `DisposeAsync()`。 diff --git a/sdks/sandbox/csharp/src/OpenSandbox/Adapters/EgressAdapter.cs b/sdks/sandbox/csharp/src/OpenSandbox/Adapters/EgressAdapter.cs index dd913783a..2faf611ca 100644 --- a/sdks/sandbox/csharp/src/OpenSandbox/Adapters/EgressAdapter.cs +++ b/sdks/sandbox/csharp/src/OpenSandbox/Adapters/EgressAdapter.cs @@ -21,7 +21,7 @@ namespace OpenSandbox.Adapters; -internal sealed class EgressAdapter : IEgress +internal sealed class EgressAdapter : IEgress, ICredentialVault { private readonly HttpClientWrapper _client; @@ -30,6 +30,88 @@ public EgressAdapter(HttpClientWrapper client) _client = client ?? throw new ArgumentNullException(nameof(client)); } + public async Task CreateAsync( + IReadOnlyList credentials, + IReadOnlyList bindings, + CancellationToken cancellationToken = default) + { + var request = new CredentialVaultCreateRequest + { + Credentials = credentials, + Bindings = bindings + }; + + return await _client.PostAsync( + "/credential-vault", + request, + cancellationToken).ConfigureAwait(false); + } + + public async Task GetAsync(CancellationToken cancellationToken = default) + { + return await _client.GetAsync( + "/credential-vault", + cancellationToken: cancellationToken).ConfigureAwait(false); + } + + public async Task PatchAsync( + CredentialVaultPatchRequest request, + CancellationToken cancellationToken = default) + { + if (request == null) + { + throw new ArgumentNullException(nameof(request)); + } + + return await _client.PatchAsync( + "/credential-vault", + request, + cancellationToken).ConfigureAwait(false); + } + + public async Task DeleteAsync(CancellationToken cancellationToken = default) + { + await _client.DeleteAsync("/credential-vault", cancellationToken: cancellationToken).ConfigureAwait(false); + } + + public async Task> ListCredentialsAsync( + CancellationToken cancellationToken = default) + { + var response = await _client.GetAsync( + "/credential-vault/credentials", + cancellationToken: cancellationToken).ConfigureAwait(false); + + return response.Credentials; + } + + public async Task GetCredentialAsync( + string name, + CancellationToken cancellationToken = default) + { + return await _client.GetAsync( + $"/credential-vault/credentials/{EncodePathSegment(name)}", + cancellationToken: cancellationToken).ConfigureAwait(false); + } + + public async Task> ListBindingsAsync( + CancellationToken cancellationToken = default) + { + var response = await _client.GetAsync( + "/credential-vault/bindings", + cancellationToken: cancellationToken).ConfigureAwait(false); + + return response.Bindings; + } + + public async Task GetBindingAsync( + string name, + CancellationToken cancellationToken = default) + { + return await _client.GetAsync( + $"/credential-vault/bindings/{EncodePathSegment(name)}", + cancellationToken: cancellationToken).ConfigureAwait(false); + } + public async Task GetPolicyAsync(CancellationToken cancellationToken = default) { var response = await _client.GetAsync("/policy", cancellationToken: cancellationToken).ConfigureAwait(false); @@ -100,4 +182,14 @@ private static NetworkRuleAction ParseNetworkRuleAction(string? action) _ => throw new SandboxApiException($"Invalid network rule action: {action ?? ""}") }; } + + private static string EncodePathSegment(string value) + { + if (value == null) + { + throw new ArgumentNullException(nameof(value)); + } + + return Uri.EscapeDataString(value); + } } diff --git a/sdks/sandbox/csharp/src/OpenSandbox/Factory/DefaultAdapterFactory.cs b/sdks/sandbox/csharp/src/OpenSandbox/Factory/DefaultAdapterFactory.cs index b3cc583e8..f3c04b0a8 100644 --- a/sdks/sandbox/csharp/src/OpenSandbox/Factory/DefaultAdapterFactory.cs +++ b/sdks/sandbox/csharp/src/OpenSandbox/Factory/DefaultAdapterFactory.cs @@ -91,9 +91,11 @@ public EgressStack CreateEgressStack(CreateEgressStackOptions options) headers, options.LoggerFactory.CreateLogger("OpenSandbox.HttpClientWrapper")); + var egress = new EgressAdapter(clientWrapper); return new EgressStack { - Egress = new EgressAdapter(clientWrapper) + Egress = egress, + CredentialVault = egress }; } } diff --git a/sdks/sandbox/csharp/src/OpenSandbox/Factory/IAdapterFactory.cs b/sdks/sandbox/csharp/src/OpenSandbox/Factory/IAdapterFactory.cs index ac3de7a99..9112bbae3 100644 --- a/sdks/sandbox/csharp/src/OpenSandbox/Factory/IAdapterFactory.cs +++ b/sdks/sandbox/csharp/src/OpenSandbox/Factory/IAdapterFactory.cs @@ -130,6 +130,8 @@ public class CreateEgressStackOptions public class EgressStack { public required IEgress Egress { get; init; } + + public ICredentialVault? CredentialVault { get; init; } } /// diff --git a/sdks/sandbox/csharp/src/OpenSandbox/Models/Sandboxes.cs b/sdks/sandbox/csharp/src/OpenSandbox/Models/Sandboxes.cs index 40a027a32..3c9c64164 100644 --- a/sdks/sandbox/csharp/src/OpenSandbox/Models/Sandboxes.cs +++ b/sdks/sandbox/csharp/src/OpenSandbox/Models/Sandboxes.cs @@ -131,6 +131,384 @@ public class NetworkPolicy public List? Egress { get; set; } } +/// +/// Credential Vault proxy startup settings. +/// +public class CredentialProxyConfig +{ + /// + /// Gets or sets whether transparent MITM support for Credential Vault injection is enabled. + /// + [JsonPropertyName("enabled")] + public bool Enabled { get; set; } +} + +/// +/// Write-only inline credential material for Credential Vault. +/// +public class InlineCredentialSource +{ + /// + /// Gets or sets the credential source type. + /// + [JsonPropertyName("type")] + public string Type { get; set; } = "inline"; + + /// + /// Gets or sets the inline credential value. + /// + [JsonPropertyName("value")] + public required string Value { get; set; } +} + +/// +/// Sandbox-local Credential Vault credential. +/// +public class Credential +{ + /// + /// Gets or sets the sandbox-local credential name. + /// + [JsonPropertyName("name")] + public required string Name { get; set; } + + /// + /// Gets or sets the write-only credential source. + /// + [JsonPropertyName("source")] + public required InlineCredentialSource Source { get; set; } +} + +/// +/// Request match for a Credential Vault binding. +/// +public class CredentialMatch +{ + /// + /// Gets or sets the request schemes to match. + /// + [JsonPropertyName("schemes")] + public IReadOnlyList? Schemes { get; set; } + + /// + /// Gets or sets the request ports to match. + /// + [JsonPropertyName("ports")] + public IReadOnlyList? Ports { get; set; } + + /// + /// Gets or sets exact FQDNs or leftmost-label wildcards. + /// + [JsonPropertyName("hosts")] + public required IReadOnlyList Hosts { get; set; } + + /// + /// Gets or sets the HTTP methods to match. + /// + [JsonPropertyName("methods")] + public IReadOnlyList? Methods { get; set; } + + /// + /// Gets or sets the request paths to match. + /// + [JsonPropertyName("paths")] + public IReadOnlyList? Paths { get; set; } +} + +/// +/// Custom header injection entry. +/// +public class CustomHeaderEntry +{ + /// + /// Gets or sets the header name. + /// + [JsonPropertyName("name")] + public required string Name { get; set; } + + /// + /// Gets or sets the credential name used as the header value. + /// + [JsonPropertyName("credential")] + public required string Credential { get; set; } +} + +/// +/// Typed Credential Vault auth rule. +/// +public class CredentialAuth +{ + /// + /// Gets or sets the auth rule type: bearer, basic, apiKey, or customHeaders. + /// + [JsonPropertyName("type")] + public required string Type { get; set; } + + /// + /// Gets or sets the referenced credential name for bearer, basic, or apiKey auth. + /// + [JsonPropertyName("credential")] + public string? Credential { get; set; } + + /// + /// Gets or sets the API key header or query parameter name. + /// + [JsonPropertyName("name")] + public string? Name { get; set; } + + /// + /// Gets or sets custom header injection entries. + /// + [JsonPropertyName("headers")] + public IReadOnlyList? Headers { get; set; } +} + +/// +/// Sandbox-local Credential Vault binding. +/// +public class CredentialBinding +{ + /// + /// Gets or sets the sandbox-local binding name. + /// + [JsonPropertyName("name")] + public required string Name { get; set; } + + /// + /// Gets or sets the request match. + /// + [JsonPropertyName("match")] + public required CredentialMatch Match { get; set; } + + /// + /// Gets or sets the auth injection rule. + /// + [JsonPropertyName("auth")] + public required CredentialAuth Auth { get; set; } +} + +/// +/// Sanitized credential metadata returned by Credential Vault. +/// +public class CredentialMetadata +{ + /// + /// Gets or sets the credential name. + /// + [JsonPropertyName("name")] + public required string Name { get; set; } + + /// + /// Gets or sets the credential source type. + /// + [JsonPropertyName("sourceType")] + public required string SourceType { get; set; } + + /// + /// Gets or sets the credential revision. + /// + [JsonPropertyName("revision")] + public int Revision { get; set; } +} + +/// +/// Sanitized auth metadata returned for a Credential Vault binding. +/// +public class CredentialAuthMetadata +{ + /// + /// Gets or sets the auth rule type. + /// + [JsonPropertyName("type")] + public required string Type { get; set; } + + /// + /// Gets or sets the API key header or query parameter name when applicable. + /// + [JsonPropertyName("name")] + public string? Name { get; set; } +} + +/// +/// Sanitized binding metadata returned by Credential Vault. +/// +public class CredentialBindingMetadata +{ + /// + /// Gets or sets the binding name. + /// + [JsonPropertyName("name")] + public required string Name { get; set; } + + /// + /// Gets or sets the binding revision. + /// + [JsonPropertyName("revision")] + public int Revision { get; set; } + + /// + /// Gets or sets the sanitized request match. + /// + [JsonPropertyName("match")] + public CredentialMatch? Match { get; set; } + + /// + /// Gets or sets the sanitized auth metadata. + /// + [JsonPropertyName("auth")] + public CredentialAuthMetadata? Auth { get; set; } +} + +/// +/// Sanitized Credential Vault state. +/// +public class CredentialVaultState +{ + /// + /// Gets or sets the vault revision. + /// + [JsonPropertyName("revision")] + public int Revision { get; set; } + + /// + /// Gets or sets sanitized credential metadata. + /// + [JsonPropertyName("credentials")] + public required IReadOnlyList Credentials { get; set; } + + /// + /// Gets or sets sanitized binding metadata. + /// + [JsonPropertyName("bindings")] + public required IReadOnlyList Bindings { get; set; } +} + +/// +/// Sanitized Credential Vault credential list response. +/// +public class CredentialListResponse +{ + /// + /// Gets or sets the vault revision. + /// + [JsonPropertyName("revision")] + public int Revision { get; set; } + + /// + /// Gets or sets sanitized credential metadata. + /// + [JsonPropertyName("credentials")] + public required IReadOnlyList Credentials { get; set; } +} + +/// +/// Sanitized Credential Vault binding list response. +/// +public class CredentialBindingListResponse +{ + /// + /// Gets or sets the vault revision. + /// + [JsonPropertyName("revision")] + public int Revision { get; set; } + + /// + /// Gets or sets sanitized binding metadata. + /// + [JsonPropertyName("bindings")] + public required IReadOnlyList Bindings { get; set; } +} + +/// +/// Initial Credential Vault creation request. +/// +public class CredentialVaultCreateRequest +{ + /// + /// Gets or sets credentials to create. + /// + [JsonPropertyName("credentials")] + public required IReadOnlyList Credentials { get; set; } + + /// + /// Gets or sets bindings to create. + /// + [JsonPropertyName("bindings")] + public required IReadOnlyList Bindings { get; set; } +} + +/// +/// Atomic credential mutation set for Credential Vault patch. +/// +public class CredentialMutationSet +{ + /// + /// Gets or sets credentials to add. + /// + [JsonPropertyName("add")] + public IReadOnlyList? Add { get; set; } + + /// + /// Gets or sets credentials to replace. + /// + [JsonPropertyName("replace")] + public IReadOnlyList? Replace { get; set; } + + /// + /// Gets or sets credential names to delete. + /// + [JsonPropertyName("delete")] + public IReadOnlyList? Delete { get; set; } +} + +/// +/// Atomic binding mutation set for Credential Vault patch. +/// +public class CredentialBindingMutationSet +{ + /// + /// Gets or sets bindings to add. + /// + [JsonPropertyName("add")] + public IReadOnlyList? Add { get; set; } + + /// + /// Gets or sets bindings to replace. + /// + [JsonPropertyName("replace")] + public IReadOnlyList? Replace { get; set; } + + /// + /// Gets or sets binding names to delete. + /// + [JsonPropertyName("delete")] + public IReadOnlyList? Delete { get; set; } +} + +/// +/// Credential Vault patch request. +/// +public class CredentialVaultPatchRequest +{ + /// + /// Gets or sets the optional optimistic concurrency guard. + /// + [JsonPropertyName("expectedRevision")] + public int? ExpectedRevision { get; set; } + + /// + /// Gets or sets credential mutations. + /// + [JsonPropertyName("credentials")] + public CredentialMutationSet? Credentials { get; set; } + + /// + /// Gets or sets binding mutations. + /// + [JsonPropertyName("bindings")] + public CredentialBindingMutationSet? Bindings { get; set; } +} + /// /// Host path bind mount backend for a volume. /// @@ -435,6 +813,12 @@ public class CreateSandboxRequest [JsonPropertyName("networkPolicy")] public NetworkPolicy? NetworkPolicy { get; set; } + /// + /// Gets or sets optional Credential Vault proxy startup settings. + /// + [JsonPropertyName("credentialProxy")] + public CredentialProxyConfig? CredentialProxy { get; set; } + /// /// Gets or sets an optional platform constraint for sandbox provisioning. /// diff --git a/sdks/sandbox/csharp/src/OpenSandbox/Options.cs b/sdks/sandbox/csharp/src/OpenSandbox/Options.cs index cd5fb121a..205757c5d 100644 --- a/sdks/sandbox/csharp/src/OpenSandbox/Options.cs +++ b/sdks/sandbox/csharp/src/OpenSandbox/Options.cs @@ -75,6 +75,11 @@ public class SandboxCreateOptions /// public NetworkPolicy? NetworkPolicy { get; set; } + /// + /// Gets or sets optional Credential Vault proxy startup settings. + /// + public CredentialProxyConfig? CredentialProxy { get; set; } + /// /// Gets or sets an optional runtime platform constraint for sandbox provisioning. /// diff --git a/sdks/sandbox/csharp/src/OpenSandbox/Sandbox.cs b/sdks/sandbox/csharp/src/OpenSandbox/Sandbox.cs index a93193f66..13d779b45 100644 --- a/sdks/sandbox/csharp/src/OpenSandbox/Sandbox.cs +++ b/sdks/sandbox/csharp/src/OpenSandbox/Sandbox.cs @@ -64,6 +64,11 @@ public sealed class Sandbox : IAsyncDisposable /// public IExecdMetrics Metrics { get; } + /// + /// Gets the sandbox-scoped Credential Vault service. + /// + public ICredentialVault CredentialVault { get; } + private readonly IEgress _egress; private readonly ISandboxes _sandboxes; @@ -91,7 +96,8 @@ private Sandbox( ISandboxFiles files, IExecdHealth health, IExecdMetrics metrics, - IEgress egress) + IEgress egress, + ICredentialVault? credentialVault) { Id = id; ConnectionConfig = connectionConfig; @@ -107,6 +113,9 @@ private Sandbox( Health = health; Metrics = metrics; _egress = egress; + CredentialVault = credentialVault + ?? egress as ICredentialVault + ?? new UnavailableCredentialVault(); } /// @@ -188,6 +197,7 @@ public static async Task CreateAsync( Egress = options.NetworkPolicy.Egress } : null, + CredentialProxy = options.CredentialProxy, Volumes = options.Volumes, Extensions = options.Extensions?.ToDictionary(kv => kv.Key, kv => (object)kv.Value) }; @@ -245,7 +255,8 @@ public static async Task CreateAsync( execdStack.Files, execdStack.Health, execdStack.Metrics, - egressStack.Egress); + egressStack.Egress, + egressStack.CredentialVault); if (!options.SkipHealthCheck) { @@ -369,7 +380,8 @@ public static async Task ConnectAsync( execdStack.Files, execdStack.Health, execdStack.Metrics, - egressStack.Egress); + egressStack.Egress, + egressStack.CredentialVault); if (!options.SkipHealthCheck) { @@ -610,6 +622,101 @@ public async Task DeleteEgressRulesAsync( await _egress.DeleteRulesAsync(targets, cancellationToken).ConfigureAwait(false); } + /// + /// Creates a sandbox-local Credential Vault. + /// + /// Credentials to create. + /// Bindings to create. + /// Cancellation token. + /// Sanitized Credential Vault state. + public async Task CreateCredentialVaultAsync( + IReadOnlyList credentials, + IReadOnlyList bindings, + CancellationToken cancellationToken = default) + { + return await CredentialVault.CreateAsync(credentials, bindings, cancellationToken).ConfigureAwait(false); + } + + /// + /// Gets sanitized Credential Vault state. + /// + /// Cancellation token. + /// Sanitized Credential Vault state. + public async Task GetCredentialVaultAsync(CancellationToken cancellationToken = default) + { + return await CredentialVault.GetAsync(cancellationToken).ConfigureAwait(false); + } + + /// + /// Atomically patches sandbox-local credentials and bindings. + /// + /// Patch request. + /// Cancellation token. + /// Sanitized Credential Vault state. + public async Task PatchCredentialVaultAsync( + CredentialVaultPatchRequest request, + CancellationToken cancellationToken = default) + { + return await CredentialVault.PatchAsync(request, cancellationToken).ConfigureAwait(false); + } + + /// + /// Deletes the sandbox-local Credential Vault. + /// + /// Cancellation token. + public async Task DeleteCredentialVaultAsync(CancellationToken cancellationToken = default) + { + await CredentialVault.DeleteAsync(cancellationToken).ConfigureAwait(false); + } + + /// + /// Lists sanitized credential metadata. + /// + /// Cancellation token. + /// Sanitized credential metadata. + public async Task> ListCredentialVaultCredentialsAsync( + CancellationToken cancellationToken = default) + { + return await CredentialVault.ListCredentialsAsync(cancellationToken).ConfigureAwait(false); + } + + /// + /// Gets sanitized metadata for one credential. + /// + /// Credential name. + /// Cancellation token. + /// Sanitized credential metadata. + public async Task GetCredentialVaultCredentialAsync( + string name, + CancellationToken cancellationToken = default) + { + return await CredentialVault.GetCredentialAsync(name, cancellationToken).ConfigureAwait(false); + } + + /// + /// Lists sanitized binding metadata. + /// + /// Cancellation token. + /// Sanitized binding metadata. + public async Task> ListCredentialVaultBindingsAsync( + CancellationToken cancellationToken = default) + { + return await CredentialVault.ListBindingsAsync(cancellationToken).ConfigureAwait(false); + } + + /// + /// Gets sanitized metadata for one binding. + /// + /// Binding name. + /// Cancellation token. + /// Sanitized binding metadata. + public async Task GetCredentialVaultBindingAsync( + string name, + CancellationToken cancellationToken = default) + { + return await CredentialVault.GetBindingAsync(name, cancellationToken).ConfigureAwait(false); + } + /// /// Gets the endpoint for a port. /// @@ -761,4 +868,45 @@ internal static IReadOnlyDictionary MergeHeaders( return merged; } + + private sealed class UnavailableCredentialVault : ICredentialVault + { + private const string Message = + "Credential Vault is not available for this adapter factory. Provide EgressStack.CredentialVault to use Credential Vault with a custom adapter."; + + public Task CreateAsync( + IReadOnlyList credentials, + IReadOnlyList bindings, + CancellationToken cancellationToken = default) => + Task.FromException(CreateException()); + + public Task GetAsync(CancellationToken cancellationToken = default) => + Task.FromException(CreateException()); + + public Task PatchAsync( + CredentialVaultPatchRequest request, + CancellationToken cancellationToken = default) => + Task.FromException(CreateException()); + + public Task DeleteAsync(CancellationToken cancellationToken = default) => + Task.FromException(CreateException()); + + public Task> ListCredentialsAsync(CancellationToken cancellationToken = default) => + Task.FromException>(CreateException()); + + public Task GetCredentialAsync( + string name, + CancellationToken cancellationToken = default) => + Task.FromException(CreateException()); + + public Task> ListBindingsAsync(CancellationToken cancellationToken = default) => + Task.FromException>(CreateException()); + + public Task GetBindingAsync( + string name, + CancellationToken cancellationToken = default) => + Task.FromException(CreateException()); + + private static InvalidArgumentException CreateException() => new(Message); + } } diff --git a/sdks/sandbox/csharp/src/OpenSandbox/Services/IEgress.cs b/sdks/sandbox/csharp/src/OpenSandbox/Services/IEgress.cs index 5f8fde0b4..dc88be659 100644 --- a/sdks/sandbox/csharp/src/OpenSandbox/Services/IEgress.cs +++ b/sdks/sandbox/csharp/src/OpenSandbox/Services/IEgress.cs @@ -16,6 +16,37 @@ namespace OpenSandbox.Services; +/// +/// Service interface for sandbox-scoped Credential Vault operations. +/// +public interface ICredentialVault +{ + Task CreateAsync( + IReadOnlyList credentials, + IReadOnlyList bindings, + CancellationToken cancellationToken = default); + + Task GetAsync(CancellationToken cancellationToken = default); + + Task PatchAsync( + CredentialVaultPatchRequest request, + CancellationToken cancellationToken = default); + + Task DeleteAsync(CancellationToken cancellationToken = default); + + Task> ListCredentialsAsync(CancellationToken cancellationToken = default); + + Task GetCredentialAsync( + string name, + CancellationToken cancellationToken = default); + + Task> ListBindingsAsync(CancellationToken cancellationToken = default); + + Task GetBindingAsync( + string name, + CancellationToken cancellationToken = default); +} + /// /// Service interface for direct egress sidecar operations. /// diff --git a/sdks/sandbox/csharp/tests/OpenSandbox.Tests/EgressAdapterCredentialVaultTests.cs b/sdks/sandbox/csharp/tests/OpenSandbox.Tests/EgressAdapterCredentialVaultTests.cs new file mode 100644 index 000000000..669875da8 --- /dev/null +++ b/sdks/sandbox/csharp/tests/OpenSandbox.Tests/EgressAdapterCredentialVaultTests.cs @@ -0,0 +1,277 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System.Net; +using System.Text; +using System.Text.Json; +using FluentAssertions; +using OpenSandbox.Adapters; +using OpenSandbox.Internal; +using OpenSandbox.Models; +using Xunit; + +namespace OpenSandbox.Tests; + +public class EgressAdapterCredentialVaultTests +{ + [Fact] + public async Task CreateAsync_ShouldSendCredentialVaultPayloadAndEndpointHeaders() + { + var handler = new CaptureHandler(_ => CredentialVaultStateResponse()); + var adapter = CreateAdapter(handler, new Dictionary + { + ["X-Global"] = "global", + ["OPENSANDBOX-EGRESS-AUTH"] = "egress-token" + }); + + var state = await adapter.CreateAsync( + [ + new Credential + { + Name = "api-token", + Source = new InlineCredentialSource { Value = "test-token" } + } + ], + [ + new CredentialBinding + { + Name = "api-binding", + Match = new CredentialMatch + { + Hosts = ["api.example.com"], + Schemes = ["https"], + Ports = [443], + Methods = ["GET"], + Paths = ["/v1/*"] + }, + Auth = new CredentialAuth + { + Type = "apiKey", + Name = "X-Test-Key", + Credential = "api-token" + } + } + ]); + + handler.Requests.Should().ContainSingle(); + var request = handler.Requests[0]; + request.Method.Should().Be(HttpMethod.Post); + request.PathAndQuery.Should().Be("/credential-vault"); + request.Headers.Should().Contain("X-Global", "global"); + request.Headers.Should().Contain("OPENSANDBOX-EGRESS-AUTH", "egress-token"); + + using var json = JsonDocument.Parse(request.Body!); + var root = json.RootElement; + root.GetProperty("credentials")[0].GetProperty("name").GetString().Should().Be("api-token"); + root.GetProperty("credentials")[0].GetProperty("source").GetProperty("type").GetString().Should().Be("inline"); + root.GetProperty("credentials")[0].GetProperty("source").GetProperty("value").GetString().Should().Be("test-token"); + root.GetProperty("bindings")[0].GetProperty("auth").GetProperty("type").GetString().Should().Be("apiKey"); + root.GetProperty("bindings")[0].GetProperty("auth").GetProperty("name").GetString().Should().Be("X-Test-Key"); + root.GetProperty("bindings")[0].GetProperty("match").GetProperty("hosts")[0].GetString().Should().Be("api.example.com"); + state.Revision.Should().Be(3); + } + + [Fact] + public async Task PatchAsync_ShouldSendExpectedRevisionAndMutationSets() + { + var handler = new CaptureHandler(_ => CredentialVaultStateResponse(revision: 4)); + var adapter = CreateAdapter(handler); + + var state = await adapter.PatchAsync(new CredentialVaultPatchRequest + { + ExpectedRevision = 3, + Credentials = new CredentialMutationSet + { + Add = + [ + new Credential + { + Name = "replacement-token", + Source = new InlineCredentialSource { Value = "replacement-value" } + } + ], + Delete = ["old-token"] + }, + Bindings = new CredentialBindingMutationSet + { + Delete = ["old-binding"] + } + }); + + handler.Requests.Should().ContainSingle(); + var request = handler.Requests[0]; + request.Method.Should().Be(HttpMethod.Patch); + request.PathAndQuery.Should().Be("/credential-vault"); + + using var json = JsonDocument.Parse(request.Body!); + var root = json.RootElement; + root.GetProperty("expectedRevision").GetInt32().Should().Be(3); + root.GetProperty("credentials").GetProperty("add")[0].GetProperty("name").GetString().Should().Be("replacement-token"); + root.GetProperty("credentials").GetProperty("delete")[0].GetString().Should().Be("old-token"); + root.GetProperty("bindings").GetProperty("delete")[0].GetString().Should().Be("old-binding"); + state.Revision.Should().Be(4); + } + + [Fact] + public async Task ListGetAndDeleteAsync_ShouldUseCredentialVaultRoutes() + { + var handler = new CaptureHandler(request => + { + return request.Method.Method switch + { + "GET" when request.RequestUri!.PathAndQuery == "/credential-vault/credentials" => """ + { + "revision": 5, + "credentials": [ + { "name": "api-token", "sourceType": "inline", "revision": 1 } + ] + } + """, + "GET" when request.RequestUri!.PathAndQuery == "/credential-vault/credentials/api%2Ftoken" => """ + { "name": "api/token", "sourceType": "inline", "revision": 2 } + """, + "GET" when request.RequestUri!.PathAndQuery == "/credential-vault/bindings" => """ + { + "revision": 5, + "bindings": [ + { "name": "api-binding", "revision": 1, "auth": { "type": "bearer" } } + ] + } + """, + "GET" when request.RequestUri!.PathAndQuery == "/credential-vault/bindings/api%20binding" => """ + { "name": "api binding", "revision": 2, "auth": { "type": "bearer" } } + """, + _ => "{}" + }; + }); + handler.StatusCodeSelector = request => + request.Method == HttpMethod.Delete ? HttpStatusCode.NoContent : HttpStatusCode.OK; + var adapter = CreateAdapter(handler); + + var credentials = await adapter.ListCredentialsAsync(); + var credential = await adapter.GetCredentialAsync("api/token"); + var bindings = await adapter.ListBindingsAsync(); + var binding = await adapter.GetBindingAsync("api binding"); + await adapter.DeleteAsync(); + + credentials.Should().ContainSingle().Which.Name.Should().Be("api-token"); + credential.Name.Should().Be("api/token"); + bindings.Should().ContainSingle().Which.Auth!.Type.Should().Be("bearer"); + binding.Name.Should().Be("api binding"); + handler.Requests.Select(r => r.PathAndQuery).Should().Equal( + "/credential-vault/credentials", + "/credential-vault/credentials/api%2Ftoken", + "/credential-vault/bindings", + "/credential-vault/bindings/api%20binding", + "/credential-vault"); + handler.Requests.Last().Method.Should().Be(HttpMethod.Delete); + } + + [Fact] + public async Task GetAsync_ShouldParseSanitizedStateWithoutCredentialValues() + { + var handler = new CaptureHandler(_ => """ + { + "revision": 7, + "credentials": [ + { + "name": "api-token", + "sourceType": "inline", + "revision": 3, + "source": { "type": "inline", "value": "server-should-not-return-values" } + } + ], + "bindings": [ + { + "name": "api-binding", + "revision": 4, + "match": { "hosts": ["api.example.com"] }, + "auth": { "type": "apiKey", "name": "X-Test-Key" } + } + ] + } + """); + var adapter = CreateAdapter(handler); + + var state = await adapter.GetAsync(); + + state.Revision.Should().Be(7); + state.Credentials.Should().ContainSingle().Which.SourceType.Should().Be("inline"); + state.Bindings.Should().ContainSingle().Which.Auth!.Name.Should().Be("X-Test-Key"); + JsonSerializer.Serialize(state).Should().NotContain("server-should-not-return-values"); + } + + private static EgressAdapter CreateAdapter( + HttpMessageHandler handler, + IReadOnlyDictionary? headers = null) + { + var client = new HttpClient(handler); + var wrapper = new HttpClientWrapper(client, "http://egress.local", headers); + return new EgressAdapter(wrapper); + } + + private static string CredentialVaultStateResponse(int revision = 3) + { + return $$""" + { + "revision": {{revision}}, + "credentials": [ + { "name": "api-token", "sourceType": "inline", "revision": 1 } + ], + "bindings": [ + { + "name": "api-binding", + "revision": 1, + "match": { "hosts": ["api.example.com"] }, + "auth": { "type": "apiKey", "name": "X-Test-Key" } + } + ] + } + """; + } + + private sealed class CaptureHandler(Func payloadSelector) : HttpMessageHandler + { + public List Requests { get; } = []; + + public Func StatusCodeSelector { get; set; } = _ => HttpStatusCode.OK; + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + var body = request.Content == null + ? null + : await request.Content.ReadAsStringAsync().ConfigureAwait(false); + Requests.Add(new CapturedRequest( + request.Method, + request.RequestUri?.PathAndQuery, + request.Headers.ToDictionary(header => header.Key, header => string.Join(",", header.Value)), + body)); + + var statusCode = StatusCodeSelector(request); + var response = new HttpResponseMessage(statusCode); + if (statusCode != HttpStatusCode.NoContent) + { + response.Content = new StringContent(payloadSelector(request), Encoding.UTF8, "application/json"); + } + + return response; + } + } + + private sealed record CapturedRequest( + HttpMethod Method, + string? PathAndQuery, + IReadOnlyDictionary Headers, + string? Body); +} diff --git a/sdks/sandbox/csharp/tests/OpenSandbox.Tests/SandboxEgressLifecycleTests.cs b/sdks/sandbox/csharp/tests/OpenSandbox.Tests/SandboxEgressLifecycleTests.cs index 0168ed977..8e4a4707f 100644 --- a/sdks/sandbox/csharp/tests/OpenSandbox.Tests/SandboxEgressLifecycleTests.cs +++ b/sdks/sandbox/csharp/tests/OpenSandbox.Tests/SandboxEgressLifecycleTests.cs @@ -31,7 +31,8 @@ public async Task CreateAsync_ShouldBuildEgressStackOnce_AndReuseItForOperations { var sandboxes = new StubSandboxes(); var egress = new StubEgress(); - var adapterFactory = new StubAdapterFactory(sandboxes, egress); + var credentialVault = new StubCredentialVault(); + var adapterFactory = new StubAdapterFactory(sandboxes, egress, credentialVault); var sandbox = await Sandbox.CreateAsync(new SandboxCreateOptions { @@ -56,6 +57,8 @@ await sandbox.PatchEgressRulesAsync([new NetworkRule Target = "www.github.com" }]); await sandbox.DeleteEgressRulesAsync(["www.github.com", "*.blocked.org"]); + await sandbox.CredentialVault.GetAsync(); + await sandbox.GetCredentialVaultAsync(); sandboxes.EndpointCalls.Should().Equal(Constants.DefaultExecdPort, Constants.DefaultEgressPort); adapterFactory.EgressStackCallCount.Should().Be(1); @@ -63,9 +66,41 @@ await sandbox.PatchEgressRulesAsync([new NetworkRule egress.GetPolicyCallCount.Should().Be(1); egress.PatchRulesCallCount.Should().Be(1); egress.DeleteRulesCallCount.Should().Be(1); + credentialVault.GetVaultCallCount.Should().Be(2); egress.LastDeleteTargets.Should().Equal("www.github.com", "*.blocked.org"); } + [Fact] + public async Task CreateAsync_ShouldAcceptCustomEgressWithoutCredentialVaultMethods() + { + var sandboxes = new StubSandboxes(); + var egress = new StubEgress(); + var adapterFactory = new StubAdapterFactory(sandboxes, egress); + + var sandbox = await Sandbox.CreateAsync(new SandboxCreateOptions + { + Image = "python:3.12", + ConnectionConfig = new ConnectionConfig(new ConnectionConfigOptions + { + Domain = "127.0.0.1:8080", + Protocol = ConnectionProtocol.Http + }), + AdapterFactory = adapterFactory, + SkipHealthCheck = true, + Diagnostics = new SdkDiagnosticsOptions + { + LoggerFactory = NullLoggerFactory.Instance + } + }); + + await sandbox.GetEgressPolicyAsync(); + Func act = () => sandbox.CredentialVault.GetAsync(); + + egress.GetPolicyCallCount.Should().Be(1); + await act.Should().ThrowAsync() + .WithMessage("Credential Vault is not available for this adapter factory*"); + } + [Fact] public async Task CreateAsync_ShouldAcceptWindowsHostPath() { @@ -82,6 +117,10 @@ public async Task CreateAsync_ShouldAcceptWindowsHostPath() }), AdapterFactory = adapterFactory, SkipHealthCheck = true, + CredentialProxy = new CredentialProxyConfig + { + Enabled = true + }, Volumes = [ new Volume @@ -98,6 +137,8 @@ public async Task CreateAsync_ShouldAcceptWindowsHostPath() }); sandboxes.LastCreateRequest.Should().NotBeNull(); + sandboxes.LastCreateRequest!.CredentialProxy.Should().NotBeNull(); + sandboxes.LastCreateRequest!.CredentialProxy!.Enabled.Should().BeTrue(); sandboxes.LastCreateRequest!.Volumes.Should().NotBeNull(); sandboxes.LastCreateRequest.Volumes!.Should().ContainSingle(); sandboxes.LastCreateRequest.Volumes![0].Host!.Path.Should().Be("D:/sandbox-mnt/ReMe"); @@ -174,11 +215,16 @@ private sealed class StubAdapterFactory : IAdapterFactory { private readonly ISandboxes _sandboxes; private readonly IEgress _egress; + private readonly ICredentialVault? _credentialVault; - public StubAdapterFactory(ISandboxes sandboxes, IEgress egress) + public StubAdapterFactory( + ISandboxes sandboxes, + IEgress egress, + ICredentialVault? credentialVault = null) { _sandboxes = sandboxes; _egress = egress; + _credentialVault = credentialVault; } public int EgressStackCallCount { get; private set; } @@ -212,7 +258,8 @@ public EgressStack CreateEgressStack(CreateEgressStackOptions options) LastEgressBaseUrl = options.EgressBaseUrl; return new EgressStack { - Egress = _egress + Egress = _egress, + CredentialVault = _credentialVault }; } } @@ -335,6 +382,87 @@ public Task DeleteRulesAsync(IReadOnlyList targets, CancellationToken ca } } + private sealed class StubCredentialVault : ICredentialVault + { + public int GetVaultCallCount { get; private set; } + + public Task CreateAsync( + IReadOnlyList credentials, + IReadOnlyList bindings, + CancellationToken cancellationToken = default) + { + return Task.FromResult(CreateVaultState()); + } + + public Task GetAsync(CancellationToken cancellationToken = default) + { + GetVaultCallCount++; + return Task.FromResult(CreateVaultState()); + } + + public Task PatchAsync( + CredentialVaultPatchRequest request, + CancellationToken cancellationToken = default) + { + return Task.FromResult(CreateVaultState()); + } + + public Task DeleteAsync(CancellationToken cancellationToken = default) => Task.CompletedTask; + + public Task> ListCredentialsAsync(CancellationToken cancellationToken = default) + { + return Task.FromResult>(CreateVaultState().Credentials); + } + + public Task GetCredentialAsync( + string name, + CancellationToken cancellationToken = default) + { + return Task.FromResult(CreateVaultState().Credentials[0]); + } + + public Task> ListBindingsAsync(CancellationToken cancellationToken = default) + { + return Task.FromResult>(CreateVaultState().Bindings); + } + + public Task GetBindingAsync( + string name, + CancellationToken cancellationToken = default) + { + return Task.FromResult(CreateVaultState().Bindings[0]); + } + + private static CredentialVaultState CreateVaultState() + { + return new CredentialVaultState + { + Revision = 1, + Credentials = + [ + new CredentialMetadata + { + Name = "api-token", + SourceType = "inline", + Revision = 1 + } + ], + Bindings = + [ + new CredentialBindingMetadata + { + Name = "api-binding", + Revision = 1, + Auth = new CredentialAuthMetadata + { + Type = "bearer" + } + } + ] + }; + } + } + private sealed class StubFiles : ISandboxFiles { public Task> GetFileInfoAsync(IEnumerable paths, CancellationToken cancellationToken = default) => diff --git a/sdks/sandbox/go/README.md b/sdks/sandbox/go/README.md index a5ac75d53..c2c16a8d8 100644 --- a/sdks/sandbox/go/README.md +++ b/sdks/sandbox/go/README.md @@ -102,6 +102,60 @@ updated, err := egress.PatchPolicy(ctx, []opensandbox.NetworkRule{ }) ``` +### Use Credential Vault + +Credential Vault injects outbound credentials from the egress sidecar while +keeping real secrets out of sandbox environment variables, commands, files, and +logs. Create the sandbox with `CredentialProxy` enabled, then write credentials +and bindings through the sandbox helpers or `EgressClient`. + +```go +sandbox, err := manager.Create(ctx, opensandbox.SandboxCreateOptions{ + Image: "python:3.11", + NetworkPolicy: &opensandbox.NetworkPolicy{ + DefaultAction: "deny", + Egress: []opensandbox.NetworkRule{ + {Action: "allow", Target: "api.example.com"}, + }, + }, + CredentialProxy: &opensandbox.CredentialProxyConfig{Enabled: true}, +}) +if err != nil { + return err +} + +_, err = sandbox.CreateCredentialVault(ctx, opensandbox.CredentialVaultCreateRequest{ + Credentials: []opensandbox.Credential{ + { + Name: "api-token", + Source: opensandbox.InlineCredentialSource{ + Type: opensandbox.CredentialSourceInline, + Value: "", + }, + }, + }, + Bindings: []opensandbox.CredentialBinding{ + { + Name: "api-token", + Match: opensandbox.CredentialMatch{ + Schemes: []opensandbox.CredentialScheme{opensandbox.CredentialSchemeHTTPS}, + Ports: []int{443}, + Hosts: []string{"api.example.com"}, + Paths: []string{"/v1/*"}, + }, + Auth: opensandbox.CredentialAuth{ + Type: opensandbox.CredentialAuthAPIKey, + Name: "x-api-key", + Credential: "api-token", + }, + }, + }, +}) +``` + +See [Credential Vault](../../../docs/credential-vault.md) for auth types, +binding guidance, and Git/curl examples. + ## API Reference ### LifecycleClient @@ -184,6 +238,14 @@ Created with `NewEgressClient(baseURL, authToken string, opts ...Option)`. |--------|-------------| | `GetPolicy(ctx)` | Get current egress policy | | `PatchPolicy(ctx, rules)` | Merge rules into current policy | +| `CreateCredentialVault(ctx, req)` | Create sandbox-local Credential Vault state | +| `GetCredentialVault(ctx)` | Get sanitized Credential Vault state | +| `PatchCredentialVault(ctx, req)` | Atomically mutate credentials and bindings | +| `DeleteCredentialVault(ctx)` | Delete sandbox-local Credential Vault state | +| `ListCredentialVaultCredentials(ctx)` | List sanitized credential metadata | +| `GetCredentialVaultCredential(ctx, name)` | Get sanitized metadata for one credential | +| `ListCredentialVaultBindings(ctx)` | List sanitized binding metadata | +| `GetCredentialVaultBinding(ctx, name)` | Get sanitized metadata for one binding | ## SSE Streaming diff --git a/sdks/sandbox/go/credential_vault_test.go b/sdks/sandbox/go/credential_vault_test.go new file mode 100644 index 000000000..f0ba48abe --- /dev/null +++ b/sdks/sandbox/go/credential_vault_test.go @@ -0,0 +1,418 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package opensandbox + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" +) + +func TestCreateCredentialVaultPayloadAndHeaders(t *testing.T) { + _, client := newEgressServer(t, func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + require.Equal(t, "/credential-vault", r.URL.Path) + require.Equal(t, "test-egress-token", r.Header.Get(egressAuthHeader)) + + var body map[string]any + require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + require.Equal(t, map[string]any{ + "credentials": []any{ + map[string]any{ + "name": "api-token", + "source": map[string]any{ + "type": "inline", + "value": "dummy-inline-value", + }, + }, + }, + "bindings": []any{ + map[string]any{ + "name": "api-binding", + "match": map[string]any{ + "schemes": []any{"https"}, + "ports": []any{float64(443)}, + "hosts": []any{"api.example.com"}, + "methods": []any{"GET"}, + "paths": []any{"/v1/*"}, + }, + "auth": map[string]any{ + "type": "apiKey", + "name": "X-Api-Key", + "credential": "api-token", + }, + }, + }, + }, body) + + jsonResponse(w, http.StatusCreated, CredentialVaultState{ + Revision: 1, + Credentials: []CredentialMetadata{ + {Name: "api-token", SourceType: "inline", Revision: 1}, + }, + Bindings: []CredentialBindingMetadata{ + { + Name: "api-binding", + Revision: 1, + Match: &CredentialMatch{Hosts: []string{"api.example.com"}}, + Auth: &CredentialAuthMetadata{Type: "apiKey", Name: "X-Api-Key"}, + }, + }, + }) + }) + + got, err := client.CreateCredentialVault(context.Background(), sampleCredentialVaultCreateRequest()) + require.NoErrorf(t, err, "CreateCredentialVault") + require.Equal(t, 1, got.Revision) + require.Len(t, got.Credentials, 1) + require.Equal(t, "inline", got.Credentials[0].SourceType) + require.Len(t, got.Bindings, 1) + require.NotNil(t, got.Bindings[0].Auth) + require.Equal(t, "apiKey", got.Bindings[0].Auth.Type) +} + +func TestInlineCredentialSourceDefaultsTypeWhenMarshaled(t *testing.T) { + body, err := json.Marshal(InlineCredentialSource{Value: "dummy-inline-value"}) + require.NoError(t, err) + + var got map[string]any + require.NoError(t, json.Unmarshal(body, &got)) + require.Equal(t, map[string]any{ + "type": "inline", + "value": "dummy-inline-value", + }, got) +} + +func TestPatchCredentialVaultPayload(t *testing.T) { + expectedRevision := 3 + + _, client := newEgressServer(t, func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPatch, r.Method) + require.Equal(t, "/credential-vault", r.URL.Path) + + var body map[string]any + require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + require.Equal(t, map[string]any{ + "expectedRevision": float64(3), + "credentials": map[string]any{ + "add": []any{ + map[string]any{ + "name": "trace-token", + "source": map[string]any{ + "type": "inline", + "value": "dummy-trace-value", + }, + }, + }, + "replace": []any{ + map[string]any{ + "name": "api-token", + "source": map[string]any{ + "type": "inline", + "value": "dummy-replacement-value", + }, + }, + }, + "delete": []any{"old-token"}, + }, + "bindings": map[string]any{ + "add": []any{ + map[string]any{ + "name": "trace-binding", + "match": map[string]any{ + "hosts": []any{"trace.example.com"}, + }, + "auth": map[string]any{ + "type": "customHeaders", + "headers": []any{ + map[string]any{ + "name": "X-Trace-Token", + "credential": "trace-token", + }, + }, + }, + }, + }, + "delete": []any{"old-binding"}, + }, + }, body) + + jsonResponse(w, http.StatusOK, CredentialVaultState{ + Revision: 4, + Credentials: []CredentialMetadata{{Name: "api-token", SourceType: "inline", Revision: 4}}, + Bindings: []CredentialBindingMetadata{{Name: "trace-binding", Revision: 1}}, + }) + }) + + got, err := client.PatchCredentialVault(context.Background(), CredentialVaultPatchRequest{ + ExpectedRevision: &expectedRevision, + Credentials: &CredentialMutationSet{ + Add: []Credential{ + { + Name: "trace-token", + Source: InlineCredentialSource{Type: CredentialSourceInline, Value: "dummy-trace-value"}, + }, + }, + Replace: []Credential{ + { + Name: "api-token", + Source: InlineCredentialSource{Type: CredentialSourceInline, Value: "dummy-replacement-value"}, + }, + }, + Delete: []string{"old-token"}, + }, + Bindings: &CredentialBindingMutationSet{ + Add: []CredentialBinding{ + { + Name: "trace-binding", + Match: CredentialMatch{Hosts: []string{"trace.example.com"}}, + Auth: CredentialAuth{ + Type: CredentialAuthCustomHeaders, + Headers: []CustomHeaderEntry{ + {Name: "X-Trace-Token", Credential: "trace-token"}, + }, + }, + }, + }, + Delete: []string{"old-binding"}, + }, + }) + require.NoErrorf(t, err, "PatchCredentialVault") + require.Equal(t, 4, got.Revision) +} + +func TestCredentialVaultGetListDeleteRoutes(t *testing.T) { + var ( + mu sync.Mutex + seen []string + ) + + _, client := newEgressServer(t, func(w http.ResponseWriter, r *http.Request) { + requestLine := r.Method + " " + r.URL.RequestURI() + mu.Lock() + seen = append(seen, requestLine) + mu.Unlock() + + switch requestLine { + case "GET /credential-vault": + jsonResponse(w, http.StatusOK, CredentialVaultState{ + Revision: 7, + Credentials: []CredentialMetadata{{Name: "api-token", SourceType: "inline", Revision: 2}}, + Bindings: []CredentialBindingMetadata{{Name: "api-binding", Revision: 3}}, + }) + case "GET /credential-vault/credentials": + jsonResponse(w, http.StatusOK, CredentialListResponse{ + Revision: 7, + Credentials: []CredentialMetadata{{Name: "api-token", SourceType: "inline", Revision: 2}}, + }) + case "GET /credential-vault/credentials/api%2Ftoken%20one": + jsonResponse(w, http.StatusOK, CredentialMetadata{ + Name: "api/token one", + SourceType: "inline", + Revision: 2, + }) + case "GET /credential-vault/bindings": + jsonResponse(w, http.StatusOK, CredentialBindingListResponse{ + Revision: 7, + Bindings: []CredentialBindingMetadata{ + {Name: "api-binding", Revision: 3}, + }, + }) + case "GET /credential-vault/bindings/api%2Fbinding%20one": + jsonResponse(w, http.StatusOK, CredentialBindingMetadata{ + Name: "api/binding one", + Revision: 3, + }) + case "DELETE /credential-vault": + w.WriteHeader(http.StatusNoContent) + default: + t.Fatalf("unexpected request %s", requestLine) + } + }) + + state, err := client.GetCredentialVault(context.Background()) + require.NoErrorf(t, err, "GetCredentialVault") + require.Equal(t, 7, state.Revision) + + credentials, err := client.ListCredentialVaultCredentials(context.Background()) + require.NoErrorf(t, err, "ListCredentialVaultCredentials") + require.Equal(t, 7, credentials.Revision) + + credential, err := client.GetCredentialVaultCredential(context.Background(), "api/token one") + require.NoErrorf(t, err, "GetCredentialVaultCredential") + require.Equal(t, "api/token one", credential.Name) + + bindings, err := client.ListCredentialVaultBindings(context.Background()) + require.NoErrorf(t, err, "ListCredentialVaultBindings") + require.Equal(t, 7, bindings.Revision) + + binding, err := client.GetCredentialVaultBinding(context.Background(), "api/binding one") + require.NoErrorf(t, err, "GetCredentialVaultBinding") + require.Equal(t, "api/binding one", binding.Name) + + require.NoErrorf(t, client.DeleteCredentialVault(context.Background()), "DeleteCredentialVault") + + require.Equal(t, []string{ + "GET /credential-vault", + "GET /credential-vault/credentials", + "GET /credential-vault/credentials/api%2Ftoken%20one", + "GET /credential-vault/bindings", + "GET /credential-vault/bindings/api%2Fbinding%20one", + "DELETE /credential-vault", + }, seen) +} + +func TestSandboxCredentialVaultForwardsEndpointHeaders(t *testing.T) { + endpointHeaders := map[string]string{ + "OPENSANDBOX-EGRESS-AUTH": "egress-token-from-endpoint", + "X-Route-Hint": "credential-vault-vip", + } + + egressSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + for k, want := range endpointHeaders { + if got := r.Header.Get(k); got != want { + t.Fatalf("header %s = %q, want %q", k, got, want) + } + } + require.Equal(t, http.MethodPost, r.Method) + require.Equal(t, "/credential-vault", r.URL.Path) + jsonResponse(w, http.StatusCreated, CredentialVaultState{Revision: 1}) + })) + defer egressSrv.Close() + + lifecycleSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/endpoints/") { + jsonResponse(w, http.StatusOK, Endpoint{ + Endpoint: egressSrv.URL, + Headers: endpointHeaders, + }) + return + } + w.WriteHeader(http.StatusNotFound) + })) + defer lifecycleSrv.Close() + + config := ConnectionConfig{Domain: lifecycleSrv.URL} + sb := &Sandbox{ + id: "sbx-credential-vault-headers", + config: &config, + lifecycle: config.lifecycleClient(), + } + + got, err := sb.CreateCredentialVault(context.Background(), sampleCredentialVaultCreateRequest()) + require.NoErrorf(t, err, "CreateCredentialVault") + require.Equal(t, 1, got.Revision) +} + +func TestCredentialVaultStateDoesNotRetainPlaintextSecretFields(t *testing.T) { + var got CredentialVaultState + require.NoError(t, json.Unmarshal([]byte(`{ + "revision": 7, + "credentials": [ + { + "name": "api-token", + "sourceType": "inline", + "revision": 2, + "source": {"type": "inline", "value": "dummy-inline-value"}, + "value": "dummy-inline-value" + } + ], + "bindings": [ + { + "name": "api-binding", + "revision": 3, + "match": {"hosts": ["api.example.com"]}, + "auth": { + "type": "bearer", + "name": "Authorization", + "credential": "api-token", + "headers": [{"name": "X-Token", "credential": "api-token"}] + } + } + ] + }`), &got)) + require.Len(t, got.Credentials, 1) + require.Len(t, got.Bindings, 1) + require.NotNil(t, got.Bindings[0].Auth) + + data, err := json.Marshal(got) + require.NoError(t, err) + encoded := string(data) + for _, forbidden := range []string{ + "dummy-inline-value", + `"source"`, + `"value"`, + `"headers"`, + `"credential":`, + } { + if strings.Contains(encoded, forbidden) { + t.Fatalf("sanitized state retained forbidden field/value %q in %s", forbidden, encoded) + } + } +} + +func TestCreateSandboxRequestIncludesCredentialProxy(t *testing.T) { + data, err := json.Marshal(CreateSandboxRequest{ + Image: &ImageSpec{URI: "python:3.12"}, + Entrypoint: []string{"/bin/sh"}, + ResourceLimits: ResourceLimits{"cpu": "500m"}, + CredentialProxy: &CredentialProxyConfig{Enabled: true}, + }) + require.NoError(t, err) + + var body map[string]any + require.NoError(t, json.Unmarshal(data, &body)) + require.Equal(t, map[string]any{"enabled": true}, body["credentialProxy"]) + + var req CreateSandboxRequest + require.NoError(t, json.Unmarshal(data, &req)) + require.NotNil(t, req.CredentialProxy) + require.Equal(t, true, req.CredentialProxy.Enabled) +} + +func sampleCredentialVaultCreateRequest() CredentialVaultCreateRequest { + return CredentialVaultCreateRequest{ + Credentials: []Credential{ + { + Name: "api-token", + Source: InlineCredentialSource{ + Type: CredentialSourceInline, + Value: "dummy-inline-value", + }, + }, + }, + Bindings: []CredentialBinding{ + { + Name: "api-binding", + Match: CredentialMatch{ + Schemes: []CredentialScheme{CredentialSchemeHTTPS}, + Ports: []int{443}, + Hosts: []string{"api.example.com"}, + Methods: []string{"GET"}, + Paths: []string{"/v1/*"}, + }, + Auth: CredentialAuth{ + Type: CredentialAuthAPIKey, + Name: "X-Api-Key", + Credential: "api-token", + }, + }, + }, + } +} diff --git a/sdks/sandbox/go/egress.go b/sdks/sandbox/go/egress.go index d0536ea23..6c76f082d 100644 --- a/sdks/sandbox/go/egress.go +++ b/sdks/sandbox/go/egress.go @@ -14,7 +14,10 @@ package opensandbox -import "context" +import ( + "context" + "net/url" +) // EgressClient provides methods for the OpenSandbox Egress API. // It connects to the egress sidecar endpoint running inside a specific sandbox. @@ -67,3 +70,74 @@ func (c *EgressClient) DeletePolicy(ctx context.Context, targets []string) (*Pol } return &resp, nil } + +// CreateCredentialVault creates the initial sandbox-local Credential Vault +// revision and activates it in Credential Proxy. +func (c *EgressClient) CreateCredentialVault(ctx context.Context, req CredentialVaultCreateRequest) (*CredentialVaultState, error) { + var resp CredentialVaultState + if err := c.doRequest(ctx, "POST", "/credential-vault", req, &resp); err != nil { + return nil, err + } + return &resp, nil +} + +// GetCredentialVault returns sanitized Credential Vault state. Plaintext +// credential values are never part of the returned model. +func (c *EgressClient) GetCredentialVault(ctx context.Context) (*CredentialVaultState, error) { + var resp CredentialVaultState + if err := c.doRequest(ctx, "GET", "/credential-vault", nil, &resp); err != nil { + return nil, err + } + return &resp, nil +} + +// PatchCredentialVault atomically mutates sandbox-local credentials and +// bindings. +func (c *EgressClient) PatchCredentialVault(ctx context.Context, req CredentialVaultPatchRequest) (*CredentialVaultState, error) { + var resp CredentialVaultState + if err := c.doRequest(ctx, "PATCH", "/credential-vault", req, &resp); err != nil { + return nil, err + } + return &resp, nil +} + +// DeleteCredentialVault deletes the sandbox-local Credential Vault. +func (c *EgressClient) DeleteCredentialVault(ctx context.Context) error { + return c.doRequest(ctx, "DELETE", "/credential-vault", nil, nil) +} + +// ListCredentialVaultCredentials returns sanitized credential metadata. +func (c *EgressClient) ListCredentialVaultCredentials(ctx context.Context) (*CredentialListResponse, error) { + var resp CredentialListResponse + if err := c.doRequest(ctx, "GET", "/credential-vault/credentials", nil, &resp); err != nil { + return nil, err + } + return &resp, nil +} + +// GetCredentialVaultCredential returns sanitized metadata for one credential. +func (c *EgressClient) GetCredentialVaultCredential(ctx context.Context, name string) (*CredentialMetadata, error) { + var resp CredentialMetadata + if err := c.doRequest(ctx, "GET", "/credential-vault/credentials/"+url.PathEscape(name), nil, &resp); err != nil { + return nil, err + } + return &resp, nil +} + +// ListCredentialVaultBindings returns sanitized binding metadata. +func (c *EgressClient) ListCredentialVaultBindings(ctx context.Context) (*CredentialBindingListResponse, error) { + var resp CredentialBindingListResponse + if err := c.doRequest(ctx, "GET", "/credential-vault/bindings", nil, &resp); err != nil { + return nil, err + } + return &resp, nil +} + +// GetCredentialVaultBinding returns sanitized metadata for one binding. +func (c *EgressClient) GetCredentialVaultBinding(ctx context.Context, name string) (*CredentialBindingMetadata, error) { + var resp CredentialBindingMetadata + if err := c.doRequest(ctx, "GET", "/credential-vault/bindings/"+url.PathEscape(name), nil, &resp); err != nil { + return nil, err + } + return &resp, nil +} diff --git a/sdks/sandbox/go/sandbox.go b/sdks/sandbox/go/sandbox.go index c57c71c3b..1176083a5 100644 --- a/sdks/sandbox/go/sandbox.go +++ b/sdks/sandbox/go/sandbox.go @@ -52,6 +52,9 @@ type SandboxCreateOptions struct { // NetworkPolicy for egress control. NetworkPolicy *NetworkPolicy + // CredentialProxy enables Credential Vault transparent proxy support. + CredentialProxy *CredentialProxyConfig + // Volumes to mount. Volumes []Volume @@ -125,18 +128,19 @@ func CreateSandbox(ctx context.Context, config ConnectionConfig, opts SandboxCre lc := config.lifecycleClient() req := CreateSandboxRequest{ - Image: nil, - SnapshotID: opts.SnapshotID, - Entrypoint: entrypoint, - ResourceLimits: limits, - Timeout: timeout, - Env: opts.Env, - SecureAccess: opts.SecureAccess, - Metadata: opts.Metadata, - NetworkPolicy: opts.NetworkPolicy, - Volumes: opts.Volumes, - Extensions: opts.Extensions, - Platform: opts.Platform, + Image: nil, + SnapshotID: opts.SnapshotID, + Entrypoint: entrypoint, + ResourceLimits: limits, + Timeout: timeout, + Env: opts.Env, + SecureAccess: opts.SecureAccess, + Metadata: opts.Metadata, + NetworkPolicy: opts.NetworkPolicy, + CredentialProxy: opts.CredentialProxy, + Volumes: opts.Volumes, + Extensions: opts.Extensions, + Platform: opts.Platform, } if opts.Image != "" { req.Image = &ImageSpec{URI: opts.Image, Auth: opts.ImageAuth} diff --git a/sdks/sandbox/go/sandbox_egress.go b/sdks/sandbox/go/sandbox_egress.go index baa32403d..1b8e9eac0 100644 --- a/sdks/sandbox/go/sandbox_egress.go +++ b/sdks/sandbox/go/sandbox_egress.go @@ -40,3 +40,85 @@ func (s *Sandbox) DeleteEgressRules(ctx context.Context, targets []string) (*Pol } return s.egress.DeletePolicy(ctx, targets) } + +// CredentialVault returns the sandbox-scoped egress client used for Credential +// Vault operations. +func (s *Sandbox) CredentialVault(ctx context.Context) (*EgressClient, error) { + if err := s.resolveEgress(ctx); err != nil { + return nil, err + } + return s.egress, nil +} + +// CreateCredentialVault creates the initial sandbox-local Credential Vault. +func (s *Sandbox) CreateCredentialVault(ctx context.Context, req CredentialVaultCreateRequest) (*CredentialVaultState, error) { + client, err := s.CredentialVault(ctx) + if err != nil { + return nil, err + } + return client.CreateCredentialVault(ctx, req) +} + +// GetCredentialVault returns sanitized Credential Vault state. +func (s *Sandbox) GetCredentialVault(ctx context.Context) (*CredentialVaultState, error) { + client, err := s.CredentialVault(ctx) + if err != nil { + return nil, err + } + return client.GetCredentialVault(ctx) +} + +// PatchCredentialVault atomically mutates sandbox-local credentials and +// bindings. +func (s *Sandbox) PatchCredentialVault(ctx context.Context, req CredentialVaultPatchRequest) (*CredentialVaultState, error) { + client, err := s.CredentialVault(ctx) + if err != nil { + return nil, err + } + return client.PatchCredentialVault(ctx, req) +} + +// DeleteCredentialVault deletes the sandbox-local Credential Vault. +func (s *Sandbox) DeleteCredentialVault(ctx context.Context) error { + client, err := s.CredentialVault(ctx) + if err != nil { + return err + } + return client.DeleteCredentialVault(ctx) +} + +// ListCredentialVaultCredentials returns sanitized credential metadata. +func (s *Sandbox) ListCredentialVaultCredentials(ctx context.Context) (*CredentialListResponse, error) { + client, err := s.CredentialVault(ctx) + if err != nil { + return nil, err + } + return client.ListCredentialVaultCredentials(ctx) +} + +// GetCredentialVaultCredential returns sanitized metadata for one credential. +func (s *Sandbox) GetCredentialVaultCredential(ctx context.Context, name string) (*CredentialMetadata, error) { + client, err := s.CredentialVault(ctx) + if err != nil { + return nil, err + } + return client.GetCredentialVaultCredential(ctx, name) +} + +// ListCredentialVaultBindings returns sanitized binding metadata. +func (s *Sandbox) ListCredentialVaultBindings(ctx context.Context) (*CredentialBindingListResponse, error) { + client, err := s.CredentialVault(ctx) + if err != nil { + return nil, err + } + return client.ListCredentialVaultBindings(ctx) +} + +// GetCredentialVaultBinding returns sanitized metadata for one binding. +func (s *Sandbox) GetCredentialVaultBinding(ctx context.Context, name string) (*CredentialBindingMetadata, error) { + client, err := s.CredentialVault(ctx) + if err != nil { + return nil, err + } + return client.GetCredentialVaultBinding(ctx, name) +} diff --git a/sdks/sandbox/go/types.go b/sdks/sandbox/go/types.go index ac03c97fc..da44f0d13 100644 --- a/sdks/sandbox/go/types.go +++ b/sdks/sandbox/go/types.go @@ -17,6 +17,7 @@ package opensandbox import ( + "encoding/json" "fmt" "time" ) @@ -140,20 +141,27 @@ type NetworkRule struct { Target string `json:"target"` } +// CredentialProxyConfig enables Credential Vault transparent proxy support at +// sandbox startup. +type CredentialProxyConfig struct { + Enabled bool `json:"enabled"` +} + // CreateSandboxRequest is the request body for creating a new sandbox. type CreateSandboxRequest struct { - Image *ImageSpec `json:"image,omitempty"` - SnapshotID string `json:"snapshotId,omitempty"` - Timeout *int `json:"timeout,omitempty"` - ResourceLimits ResourceLimits `json:"resourceLimits"` - Env map[string]string `json:"env,omitempty"` - SecureAccess bool `json:"secureAccess,omitempty"` - Metadata map[string]string `json:"metadata,omitempty"` - Entrypoint []string `json:"entrypoint,omitempty"` - NetworkPolicy *NetworkPolicy `json:"networkPolicy,omitempty"` - Volumes []Volume `json:"volumes,omitempty"` - Extensions map[string]string `json:"extensions,omitempty"` - Platform *PlatformSpec `json:"platform,omitempty"` + Image *ImageSpec `json:"image,omitempty"` + SnapshotID string `json:"snapshotId,omitempty"` + Timeout *int `json:"timeout,omitempty"` + ResourceLimits ResourceLimits `json:"resourceLimits"` + Env map[string]string `json:"env,omitempty"` + SecureAccess bool `json:"secureAccess,omitempty"` + Metadata map[string]string `json:"metadata,omitempty"` + Entrypoint []string `json:"entrypoint,omitempty"` + NetworkPolicy *NetworkPolicy `json:"networkPolicy,omitempty"` + CredentialProxy *CredentialProxyConfig `json:"credentialProxy,omitempty"` + Volumes []Volume `json:"volumes,omitempty"` + Extensions map[string]string `json:"extensions,omitempty"` + Platform *PlatformSpec `json:"platform,omitempty"` } // SandboxInfo represents a runtime execution environment provisioned from a @@ -255,6 +263,164 @@ type PolicyStatusResponse struct { Policy *NetworkPolicy `json:"policy,omitempty"` } +// CredentialSourceType is the credential source discriminator. +type CredentialSourceType string + +const ( + // CredentialSourceInline carries write-only inline credential material. + CredentialSourceInline CredentialSourceType = "inline" +) + +// InlineCredentialSource contains write-only credential material. Values sent +// in this model are never returned by Credential Vault state endpoints. +type InlineCredentialSource struct { + Type CredentialSourceType `json:"type"` + Value string `json:"value"` +} + +// MarshalJSON defaults the only supported source type so callers can use the +// natural zero-value form InlineCredentialSource{Value: secret}. +func (s InlineCredentialSource) MarshalJSON() ([]byte, error) { + type inlineCredentialSource InlineCredentialSource + source := inlineCredentialSource(s) + if source.Type == "" { + source.Type = CredentialSourceInline + } + return json.Marshal(source) +} + +// Credential is a sandbox-local Credential Vault credential create/update +// model. +type Credential struct { + Name string `json:"name"` + Source InlineCredentialSource `json:"source"` +} + +// CredentialScheme is a request scheme matched by a Credential Vault binding. +type CredentialScheme string + +const ( + CredentialSchemeHTTPS CredentialScheme = "https" + CredentialSchemeHTTP CredentialScheme = "http" +) + +// CredentialMatch selects outbound requests where a Credential Vault binding +// applies. +type CredentialMatch struct { + Schemes []CredentialScheme `json:"schemes,omitempty"` + Ports []int `json:"ports,omitempty"` + Hosts []string `json:"hosts"` + Methods []string `json:"methods,omitempty"` + Paths []string `json:"paths,omitempty"` +} + +// CustomHeaderEntry describes one custom header injection rule. +type CustomHeaderEntry struct { + Name string `json:"name"` + Credential string `json:"credential"` +} + +// CredentialAuthType is the Credential Vault auth discriminator. +type CredentialAuthType string + +const ( + CredentialAuthBearer CredentialAuthType = "bearer" + CredentialAuthBasic CredentialAuthType = "basic" + CredentialAuthAPIKey CredentialAuthType = "apiKey" + CredentialAuthCustomHeaders CredentialAuthType = "customHeaders" +) + +// CredentialAuth configures how a binding injects credential material into +// matching outbound requests. +type CredentialAuth struct { + Type CredentialAuthType `json:"type"` + Credential string `json:"credential,omitempty"` + Name string `json:"name,omitempty"` + Headers []CustomHeaderEntry `json:"headers,omitempty"` +} + +// CredentialBinding is a sandbox-local Credential Vault binding create/update +// model. +type CredentialBinding struct { + Name string `json:"name"` + Match CredentialMatch `json:"match"` + Auth CredentialAuth `json:"auth"` +} + +// CredentialVaultCreateRequest creates the initial sandbox-local Credential +// Vault revision. +type CredentialVaultCreateRequest struct { + Credentials []Credential `json:"credentials"` + Bindings []CredentialBinding `json:"bindings"` +} + +// CredentialMutationSet describes atomic credential changes for a vault patch. +type CredentialMutationSet struct { + Add []Credential `json:"add,omitempty"` + Replace []Credential `json:"replace,omitempty"` + Delete []string `json:"delete,omitempty"` +} + +// CredentialBindingMutationSet describes atomic binding changes for a vault +// patch. +type CredentialBindingMutationSet struct { + Add []CredentialBinding `json:"add,omitempty"` + Replace []CredentialBinding `json:"replace,omitempty"` + Delete []string `json:"delete,omitempty"` +} + +// CredentialVaultPatchRequest atomically mutates credentials and bindings. If +// ExpectedRevision is set, the sidecar applies it as an optimistic concurrency +// guard. +type CredentialVaultPatchRequest struct { + ExpectedRevision *int `json:"expectedRevision,omitempty"` + Credentials *CredentialMutationSet `json:"credentials,omitempty"` + Bindings *CredentialBindingMutationSet `json:"bindings,omitempty"` +} + +// CredentialMetadata is sanitized credential metadata returned by Credential +// Vault. It intentionally does not include source values. +type CredentialMetadata struct { + Name string `json:"name"` + SourceType string `json:"sourceType"` + Revision int `json:"revision"` +} + +// CredentialAuthMetadata is sanitized auth metadata returned by Credential +// Vault. It intentionally does not include credential references or values. +type CredentialAuthMetadata struct { + Type string `json:"type"` + Name string `json:"name,omitempty"` +} + +// CredentialBindingMetadata is sanitized binding metadata returned by +// Credential Vault. +type CredentialBindingMetadata struct { + Name string `json:"name"` + Revision int `json:"revision"` + Match *CredentialMatch `json:"match,omitempty"` + Auth *CredentialAuthMetadata `json:"auth,omitempty"` +} + +// CredentialVaultState is sanitized Credential Vault state. +type CredentialVaultState struct { + Revision int `json:"revision"` + Credentials []CredentialMetadata `json:"credentials"` + Bindings []CredentialBindingMetadata `json:"bindings"` +} + +// CredentialListResponse is the credential metadata list response. +type CredentialListResponse struct { + Revision int `json:"revision"` + Credentials []CredentialMetadata `json:"credentials"` +} + +// CredentialBindingListResponse is the binding metadata list response. +type CredentialBindingListResponse struct { + Revision int `json:"revision"` + Bindings []CredentialBindingMetadata `json:"bindings"` +} + // ErrorResponse is the standard error response for non-2xx HTTP responses. type ErrorResponse struct { Code string `json:"code"` diff --git a/sdks/sandbox/javascript/README.md b/sdks/sandbox/javascript/README.md index ec9bc8b3b..4cb19b0ec 100644 --- a/sdks/sandbox/javascript/README.md +++ b/sdks/sandbox/javascript/README.md @@ -258,6 +258,7 @@ const config2 = new ConnectionConfig({ | `env` | Environment variables | `{}` | | `metadata` | Custom metadata tags | `{}` | | `networkPolicy` | Optional outbound network policy (egress) | - | +| `credentialProxy` | Optional Credential Vault proxy startup settings | - | | `extensions` | Extra server-defined fields | `{}` | | `skipHealthCheck` | Skip readiness checks (`Running` + health check) | `false` | | `healthCheck` | Custom readiness check | - | @@ -298,7 +299,45 @@ await sandbox.patchEgressRules([ ]); ``` -### 4. Resource cleanup +### 4. Credential Vault + +Credential Vault injects outbound credentials from the egress sidecar while +keeping real secrets out of sandbox environment variables, commands, files, and +logs. Create the sandbox with `credentialProxy` enabled, then write credentials +and bindings through `sandbox.credentialVault`. + +```ts +const sandbox = await Sandbox.create({ + connectionConfig: config, + image: "python:3.11", + networkPolicy: { + defaultAction: "deny", + egress: [{ action: "allow", target: "api.example.com" }], + }, + credentialProxy: { enabled: true }, +}); + +await sandbox.credentialVault.create({ + credentials: [{ name: "api-token", source: { value: "" } }], + bindings: [ + { + name: "api-token", + match: { + schemes: ["https"], + ports: [443], + hosts: ["api.example.com"], + paths: ["/v1/*"], + }, + auth: { type: "apiKey", name: "x-api-key", credential: "api-token" }, + }, + ], +}); +``` + +See [Credential Vault](../../../docs/credential-vault.md) for auth types, +binding guidance, and Git/curl examples. + +### 5. Resource cleanup Both `Sandbox` and `SandboxManager` own a scoped HTTP agent when running on Node.js so you can safely reuse the same `ConnectionConfig`. Once you are finished interacting diff --git a/sdks/sandbox/javascript/README_zh.md b/sdks/sandbox/javascript/README_zh.md index 8edc40353..d2e8e2ded 100644 --- a/sdks/sandbox/javascript/README_zh.md +++ b/sdks/sandbox/javascript/README_zh.md @@ -258,6 +258,7 @@ const config2 = new ConnectionConfig({ | `env` | 环境变量 | `{}` | | `metadata` | 自定义元数据标签 | `{}` | | `networkPolicy` | 可选的出站网络策略(egress) | - | +| `credentialProxy` | 可选的 Credential Vault proxy 启动配置 | - | | `extensions` | 额外的服务端扩展字段 | `{}` | | `skipHealthCheck` | 跳过就绪检测(`Running` + 健康检查) | `false` | | `healthCheck` | 自定义就绪检查 | - | @@ -291,7 +292,13 @@ await sandbox.patchEgressRules([ ]); ``` -### 4. 资源清理 +### 4. Credential Vault + +Credential Vault 可以由 egress sidecar 在出站请求中注入凭证,避免真实密钥进入沙箱环境变量、命令参数、文件或日志。创建沙箱时设置 `credentialProxy: { enabled: true }`,然后通过 `sandbox.credentialVault.create(...)` / `patch(...)` 写入 credentials 和 bindings。 + +更多 auth 类型、binding 规则和 Git/curl 示例请参考 [Credential Vault](../../../docs/credential-vault_zh.md)。 + +### 5. 资源清理 在 Node.js 环境下,`Sandbox` 和 `SandboxManager` 会拥有各自的 HTTP agent,因此即使多个实例共享同一个 `ConnectionConfig` 也不会互相影响。SDK 会借助 `ConnectionConfig.withTransportIfMissing()` 复刻每个实例的 transport。完成使用后调用 `sandbox.close()` / `manager.close()` 来释放底层连接池; diff --git a/sdks/sandbox/javascript/src/adapters/egressAdapter.ts b/sdks/sandbox/javascript/src/adapters/egressAdapter.ts index e2262ddae..dfe26ac1a 100644 --- a/sdks/sandbox/javascript/src/adapters/egressAdapter.ts +++ b/sdks/sandbox/javascript/src/adapters/egressAdapter.ts @@ -15,8 +15,19 @@ import type { EgressClient } from "../openapi/egressClient.js"; import { throwOnOpenApiFetchError } from "./openapiError.js"; import type { paths as EgressPaths } from "../api/egress.js"; -import type { NetworkPolicy, NetworkRule } from "../models/sandboxes.js"; -import type { Egress } from "../services/egress.js"; +import { SandboxApiException, SandboxError } from "../core/exceptions.js"; +import type { + CredentialBindingMetadata, + CredentialBindingListResponse, + CredentialListResponse, + CredentialMetadata, + CredentialVaultCreateRequest, + CredentialVaultPatchRequest, + CredentialVaultState, + NetworkPolicy, + NetworkRule, +} from "../models/sandboxes.js"; +import type { CredentialVault, Egress } from "../services/egress.js"; type ApiGetPolicyOk = EgressPaths["/policy"]["get"]["responses"][200]["content"]["application/json"]; @@ -25,8 +36,364 @@ type ApiPatchRulesRequest = type ApiDeleteRulesRequest = EgressPaths["/policy"]["delete"]["requestBody"]["content"]["application/json"]; -export class EgressAdapter implements Egress { - constructor(private readonly client: EgressClient) {} +export interface EgressRawHttpOptions { + /** + * Base URL to the sandbox egress sidecar API. + */ + baseUrl: string; + /** + * Headers applied to direct Credential Vault requests. + */ + headers?: Record; + /** + * Custom fetch implementation. + */ + fetch?: typeof fetch; +} + +type JsonObject = Record; + +function stripTrailingSlashes(s: string): string { + let end = s.length; + while (end > 0 && s.charCodeAt(end - 1) === 47) { + end -= 1; + } + return end === s.length ? s : s.slice(0, end); +} + +function expectObject(value: unknown, context: string): JsonObject { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error(`${context}: expected object`); + } + return value as JsonObject; +} + +function expectString(value: unknown, context: string): string { + if (typeof value !== "string") { + throw new Error(`${context}: expected string`); + } + return value; +} + +function expectNumber(value: unknown, context: string): number { + if (typeof value !== "number" || !Number.isInteger(value)) { + throw new Error(`${context}: expected integer`); + } + return value; +} + +function expectArray( + value: unknown, + context: string, + mapItem: (item: unknown, context: string) => T, +): T[] { + if (!Array.isArray(value)) { + throw new Error(`${context}: expected array`); + } + return value.map((item, index) => mapItem(item, `${context}[${index}]`)); +} + +function optionalStringArray(value: unknown, context: string): string[] | undefined { + if (value == null) return undefined; + return expectArray(value, context, expectString); +} + +function optionalNumberArray(value: unknown, context: string): number[] | undefined { + if (value == null) return undefined; + return expectArray(value, context, expectNumber); +} + +function sanitizeCredentialMatch( + value: unknown, + context: string, +): CredentialVaultState["bindings"][number]["match"] { + if (value == null) return undefined; + const raw = expectObject(value, context); + const match: NonNullable = { + hosts: expectArray(raw.hosts, `${context}.hosts`, expectString), + }; + const schemes = optionalStringArray(raw.schemes, `${context}.schemes`); + if (schemes) { + match.schemes = schemes.map((scheme, index) => { + if (scheme !== "https" && scheme !== "http") { + throw new Error(`${context}.schemes[${index}]: expected "https" or "http"`); + } + return scheme; + }); + } + const ports = optionalNumberArray(raw.ports, `${context}.ports`); + if (ports) match.ports = ports; + const methods = optionalStringArray(raw.methods, `${context}.methods`); + if (methods) match.methods = methods; + const paths = optionalStringArray(raw.paths, `${context}.paths`); + if (paths) match.paths = paths; + return match; +} + +function sanitizeCredentialAuthMetadata( + value: unknown, + context: string, +): CredentialBindingMetadata["auth"] { + if (value == null) return undefined; + const raw = expectObject(value, context); + const auth: NonNullable = { + type: expectString(raw.type, `${context}.type`), + }; + if (raw.name != null) { + auth.name = expectString(raw.name, `${context}.name`); + } + return auth; +} + +function sanitizeCredentialMetadata(value: unknown, context: string): CredentialMetadata { + const raw = expectObject(value, context); + return { + name: expectString(raw.name, `${context}.name`), + sourceType: expectString(raw.sourceType, `${context}.sourceType`), + revision: expectNumber(raw.revision, `${context}.revision`), + }; +} + +function sanitizeCredentialBindingMetadata( + value: unknown, + context: string, +): CredentialBindingMetadata { + const raw = expectObject(value, context); + const binding: CredentialBindingMetadata = { + name: expectString(raw.name, `${context}.name`), + revision: expectNumber(raw.revision, `${context}.revision`), + }; + const match = sanitizeCredentialMatch(raw.match, `${context}.match`); + if (match) binding.match = match; + const auth = sanitizeCredentialAuthMetadata(raw.auth, `${context}.auth`); + if (auth) binding.auth = auth; + return binding; +} + +function sanitizeCredentialVaultState( + value: unknown, + operation: string, +): CredentialVaultState { + const raw = expectObject(value, `${operation} response`); + return { + revision: expectNumber(raw.revision, `${operation} response.revision`), + credentials: expectArray( + raw.credentials, + `${operation} response.credentials`, + sanitizeCredentialMetadata, + ), + bindings: expectArray( + raw.bindings, + `${operation} response.bindings`, + sanitizeCredentialBindingMetadata, + ), + }; +} + +function sanitizeCredentialListResponse( + value: unknown, + operation: string, +): CredentialMetadata[] { + const raw = expectObject(value, `${operation} response`); + const response: CredentialListResponse = { + revision: expectNumber(raw.revision, `${operation} response.revision`), + credentials: expectArray( + raw.credentials, + `${operation} response.credentials`, + sanitizeCredentialMetadata, + ), + }; + return response.credentials; +} + +function sanitizeCredentialBindingListResponse( + value: unknown, + operation: string, +): CredentialBindingMetadata[] { + const raw = expectObject(value, `${operation} response`); + const response: CredentialBindingListResponse = { + revision: expectNumber(raw.revision, `${operation} response.revision`), + bindings: expectArray( + raw.bindings, + `${operation} response.bindings`, + sanitizeCredentialBindingMetadata, + ), + }; + return response.bindings; +} + +export class EgressAdapter implements Egress, CredentialVault { + private readonly rawBaseUrl?: string; + private readonly rawHeaders: Record; + private readonly rawFetch: typeof fetch; + + constructor( + private readonly client: EgressClient, + rawHttp?: EgressRawHttpOptions, + ) { + this.rawBaseUrl = rawHttp ? stripTrailingSlashes(rawHttp.baseUrl) : undefined; + this.rawHeaders = rawHttp?.headers ?? {}; + this.rawFetch = rawHttp?.fetch ?? fetch; + } + + private credentialVaultUrl(path: string): string { + if (!this.rawBaseUrl) { + throw new Error("Credential Vault transport is not configured"); + } + return `${this.rawBaseUrl}${path}`; + } + + private async readErrorResponse(response: Response): Promise<{ + code: string; + message: string; + rawBody: unknown; + }> { + const text = await response.text(); + if (!text) { + const message = `HTTP ${response.status}`; + return { code: SandboxError.UNEXPECTED_RESPONSE, message, rawBody: undefined }; + } + + try { + const rawBody = JSON.parse(text) as unknown; + if (rawBody && typeof rawBody === "object") { + const obj = rawBody as JsonObject; + const code = typeof obj.code === "string" ? obj.code : SandboxError.UNEXPECTED_RESPONSE; + const message = typeof obj.message === "string" ? obj.message : text; + return { code, message, rawBody }; + } + return { code: SandboxError.UNEXPECTED_RESPONSE, message: text, rawBody }; + } catch { + return { code: SandboxError.UNEXPECTED_RESPONSE, message: text, rawBody: text }; + } + } + + private async requestJson( + method: string, + path: string, + operation: string, + jsonBody?: unknown, + ): Promise { + const headers = new Headers(this.rawHeaders); + headers.set("accept", "application/json"); + const init: RequestInit = { method, headers }; + if (jsonBody !== undefined) { + headers.set("content-type", "application/json"); + init.body = JSON.stringify(jsonBody); + } + + let response: Response; + try { + response = await this.rawFetch(this.credentialVaultUrl(path), init); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + throw new SandboxApiException({ + message: `${operation} failed: ${message}`, + cause: err, + error: new SandboxError(SandboxError.UNEXPECTED_RESPONSE, message), + }); + } + + if (!response.ok) { + const { code, message, rawBody } = await this.readErrorResponse(response); + throw new SandboxApiException({ + message, + statusCode: response.status, + requestId: response.headers.get("x-request-id") ?? undefined, + error: new SandboxError(code, message), + rawBody, + }); + } + + if (response.status === 204) { + return undefined; + } + const text = await response.text(); + if (!text) { + return undefined; + } + try { + return JSON.parse(text) as unknown; + } catch (err) { + throw new SandboxApiException({ + message: `${operation} failed: invalid JSON response`, + cause: err, + statusCode: response.status, + requestId: response.headers.get("x-request-id") ?? undefined, + error: new SandboxError(SandboxError.UNEXPECTED_RESPONSE, "Invalid JSON response"), + rawBody: text, + }); + } + } + + async create(request: CredentialVaultCreateRequest): Promise { + const payload = await this.requestJson( + "POST", + "/credential-vault", + "Create credential vault", + request, + ); + return sanitizeCredentialVaultState(payload, "Create credential vault"); + } + + async get(): Promise { + const payload = await this.requestJson( + "GET", + "/credential-vault", + "Get credential vault", + ); + return sanitizeCredentialVaultState(payload, "Get credential vault"); + } + + async patch(request: CredentialVaultPatchRequest): Promise { + const payload = await this.requestJson( + "PATCH", + "/credential-vault", + "Patch credential vault", + request, + ); + return sanitizeCredentialVaultState(payload, "Patch credential vault"); + } + + async delete(): Promise { + await this.requestJson("DELETE", "/credential-vault", "Delete credential vault"); + } + + async listCredentials(): Promise { + const payload = await this.requestJson( + "GET", + "/credential-vault/credentials", + "List credential vault credentials", + ); + return sanitizeCredentialListResponse(payload, "List credential vault credentials"); + } + + async getCredential(name: string): Promise { + const payload = await this.requestJson( + "GET", + `/credential-vault/credentials/${encodeURIComponent(name)}`, + "Get credential vault credential", + ); + return sanitizeCredentialMetadata(payload, "Get credential vault credential response"); + } + + async listBindings(): Promise { + const payload = await this.requestJson( + "GET", + "/credential-vault/bindings", + "List credential vault bindings", + ); + return sanitizeCredentialBindingListResponse(payload, "List credential vault bindings"); + } + + async getBinding(name: string): Promise { + const payload = await this.requestJson( + "GET", + `/credential-vault/bindings/${encodeURIComponent(name)}`, + "Get credential vault binding", + ); + return sanitizeCredentialBindingMetadata(payload, "Get credential vault binding response"); + } async getPolicy(): Promise { const { data, error, response } = await this.client.GET("/policy"); diff --git a/sdks/sandbox/javascript/src/api/egress.ts b/sdks/sandbox/javascript/src/api/egress.ts index 934e869d7..7b8d55a31 100644 --- a/sdks/sandbox/javascript/src/api/egress.ts +++ b/sdks/sandbox/javascript/src/api/egress.ts @@ -129,6 +129,282 @@ export interface paths { }; trace?: never; }; + "/credential-vault": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get sanitized Credential Vault state */ + get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Sanitized Credential Vault state. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CredentialVaultState"]; + }; + }; + 401: components["responses"]["Unauthorized"]; + 404: components["responses"]["NotFound"]; + }; + }; + put?: never; + /** + * Create a sandbox-local Credential Vault + * @description Create the initial sandbox-local Credential Vault revision and activate it + * in Credential Proxy. Inline credential values are write-only and are never + * returned by this API. + */ + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CredentialVaultCreateRequest"]; + }; + }; + responses: { + /** @description Credential Vault created and acknowledged. */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CredentialVaultState"]; + }; + }; + 400: components["responses"]["BadRequest"]; + 401: components["responses"]["Unauthorized"]; + 409: components["responses"]["Conflict"]; + 412: components["responses"]["PreconditionFailed"]; + }; + }; + /** Delete the sandbox-local Credential Vault */ + delete: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Credential Vault deleted. */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + 401: components["responses"]["Unauthorized"]; + 404: components["responses"]["NotFound"]; + }; + }; + options?: never; + head?: never; + /** Atomically mutate sandbox-local credentials and bindings */ + patch: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CredentialVaultMutationRequest"]; + }; + }; + responses: { + /** @description Mutation applied and acknowledged. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CredentialVaultState"]; + }; + }; + 400: components["responses"]["BadRequest"]; + 401: components["responses"]["Unauthorized"]; + 404: components["responses"]["NotFound"]; + 409: components["responses"]["Conflict"]; + 412: components["responses"]["PreconditionFailed"]; + }; + }; + trace?: never; + }; + "/credential-vault/credentials": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List sanitized credential metadata */ + get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Sanitized credential metadata. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CredentialListResponse"]; + }; + }; + 401: components["responses"]["Unauthorized"]; + 404: components["responses"]["NotFound"]; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/credential-vault/credentials/{credential_name}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get sanitized metadata for one credential */ + get: { + parameters: { + query?: never; + header?: never; + path: { + credential_name: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Sanitized credential metadata. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CredentialMetadata"]; + }; + }; + 401: components["responses"]["Unauthorized"]; + 404: components["responses"]["NotFound"]; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/credential-vault/bindings": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List sanitized credential binding metadata */ + get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Sanitized binding metadata. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CredentialBindingListResponse"]; + }; + }; + 401: components["responses"]["Unauthorized"]; + 404: components["responses"]["NotFound"]; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/credential-vault/bindings/{binding_name}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get sanitized metadata for one binding */ + get: { + parameters: { + query?: never; + header?: never; + path: { + binding_name: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Sanitized binding metadata. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CredentialBindingMetadata"]; + }; + }; + 401: components["responses"]["Unauthorized"]; + 404: components["responses"]["NotFound"]; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; } export type webhooks = Record; export interface components { @@ -179,6 +455,138 @@ export interface components { */ target: string; }; + CredentialVaultCreateRequest: { + credentials: components["schemas"]["Credential"][]; + bindings: components["schemas"]["CredentialBinding"][]; + }; + CredentialVaultMutationRequest: { + /** @description Optional optimistic concurrency guard. */ + expectedRevision?: number; + credentials?: components["schemas"]["CredentialMutationSet"]; + bindings?: components["schemas"]["CredentialBindingMutationSet"]; + }; + CredentialMutationSet: { + add?: components["schemas"]["Credential"][]; + replace?: components["schemas"]["Credential"][]; + delete?: string[]; + }; + CredentialBindingMutationSet: { + add?: components["schemas"]["CredentialBinding"][]; + replace?: components["schemas"]["CredentialBinding"][]; + delete?: string[]; + }; + CredentialVaultState: { + revision: number; + credentials: components["schemas"]["CredentialMetadata"][]; + bindings: components["schemas"]["CredentialBindingMetadata"][]; + }; + CredentialListResponse: { + revision: number; + credentials: components["schemas"]["CredentialMetadata"][]; + }; + CredentialBindingListResponse: { + revision: number; + bindings: components["schemas"]["CredentialBindingMetadata"][]; + }; + CredentialMetadata: { + name: string; + sourceType: string; + revision: number; + }; + CredentialBindingMetadata: { + name: string; + revision: number; + match?: components["schemas"]["CredentialMatch"]; + auth?: components["schemas"]["CredentialAuthMetadata"]; + }; + Credential: { + name: string; + source: components["schemas"]["InlineCredentialSource"]; + }; + InlineCredentialSource: { + /** @enum {string} */ + type: "inline"; + value: string; + }; + CredentialBinding: { + name: string; + match: components["schemas"]["CredentialMatch"]; + auth: components["schemas"]["CredentialAuth"]; + }; + CredentialMatch: { + /** + * @default [ + * "https" + * ] + */ + schemes: ("https" | "http")[]; + /** + * @default [ + * 443 + * ] + */ + ports: number[]; + hosts: string[]; + /** + * @default [ + * "GET", + * "POST", + * "PUT", + * "PATCH", + * "DELETE" + * ] + */ + methods: string[]; + /** + * @default [ + * "/*" + * ] + */ + paths: string[]; + }; + CredentialAuth: components["schemas"]["BearerCredentialAuth"] | components["schemas"]["BasicCredentialAuth"] | components["schemas"]["ApiKeyCredentialAuth"] | components["schemas"]["CustomHeadersCredentialAuth"]; + BearerCredentialAuth: { + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "bearer"; + credential: string; + }; + BasicCredentialAuth: { + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "basic"; + /** @description Credential containing pre-encoded base64(username:password). */ + credential: string; + }; + ApiKeyCredentialAuth: { + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "apiKey"; + name: string; + credential: string; + }; + CustomHeadersCredentialAuth: { + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "customHeaders"; + headers: components["schemas"]["CustomHeaderEntry"][]; + }; + CustomHeaderEntry: { + name: string; + credential: string; + }; + CredentialAuthMetadata: { + type: string; + name?: string; + }; }; responses: { /** @description The request was invalid or malformed. */ @@ -199,6 +607,33 @@ export interface components { "text/plain": string; }; }; + /** @description The requested Credential Vault resource was not found. */ + NotFound: { + headers: { + [name: string]: unknown; + }; + content: { + "text/plain": string; + }; + }; + /** @description The request conflicts with the current Credential Vault revision. */ + Conflict: { + headers: { + [name: string]: unknown; + }; + content: { + "text/plain": string; + }; + }; + /** @description Credential Proxy prerequisites are not ready. */ + PreconditionFailed: { + headers: { + [name: string]: unknown; + }; + content: { + "text/plain": string; + }; + }; /** @description The sidecar failed to apply or fetch policy state. */ InternalServerError: { headers: { diff --git a/sdks/sandbox/javascript/src/api/lifecycle.ts b/sdks/sandbox/javascript/src/api/lifecycle.ts index c469af230..17f1f5b68 100644 --- a/sdks/sandbox/javascript/src/api/lifecycle.ts +++ b/sdks/sandbox/javascript/src/api/lifecycle.ts @@ -1114,6 +1114,13 @@ export interface components { * the sidecar starts in allow-all mode until updated. */ networkPolicy?: components["schemas"]["NetworkPolicy"]; + /** + * @description Optional Credential Vault proxy startup settings. Set `enabled: true` + * to enable transparent MITM support for credential injection. Plain + * `networkPolicy` does not enable transparent MITM unless this option + * is set. + */ + credentialProxy?: components["schemas"]["CredentialProxyConfig"]; /** * @description Opts the sandbox into secured access for endpoint access. * This is currently supported only for Kubernetes sandboxes exposed @@ -1227,6 +1234,20 @@ export interface components { /** @description List of egress rules evaluated in order. */ egress?: components["schemas"]["NetworkRule"][]; }; + /** + * @description Credential Vault proxy startup settings. This is an explicit opt-in for + * transparent MITM support used by credential injection; plain egress + * network policy remains DNS/FQDN policy enforcement only. + */ + CredentialProxyConfig: { + /** + * @description When true, the server starts the egress sidecar with transparent + * MITM enabled and installs the runtime-managed MITM CA bundle into + * the sandbox container. Requires `networkPolicy`. + * @default false + */ + enabled: boolean; + }; NetworkRule: { /** * @description Whether to allow or deny matching targets. diff --git a/sdks/sandbox/javascript/src/factory/adapterFactory.ts b/sdks/sandbox/javascript/src/factory/adapterFactory.ts index c029be8eb..0dd9e935f 100644 --- a/sdks/sandbox/javascript/src/factory/adapterFactory.ts +++ b/sdks/sandbox/javascript/src/factory/adapterFactory.ts @@ -14,7 +14,7 @@ import type { ConnectionConfig } from "../config/connection.js"; import type { SandboxFiles } from "../services/filesystem.js"; -import type { Egress } from "../services/egress.js"; +import type { CredentialVault, Egress } from "../services/egress.js"; import type { ExecdCommands } from "../services/execdCommands.js"; import type { ExecdHealth } from "../services/execdHealth.js"; import type { ExecdMetrics } from "../services/execdMetrics.js"; @@ -50,6 +50,7 @@ export interface CreateEgressStackOptions { export interface EgressStack { egress: Egress; + credentialVault?: CredentialVault; } /** diff --git a/sdks/sandbox/javascript/src/factory/defaultAdapterFactory.ts b/sdks/sandbox/javascript/src/factory/defaultAdapterFactory.ts index 6effe76f9..4fb97ac68 100644 --- a/sdks/sandbox/javascript/src/factory/defaultAdapterFactory.ts +++ b/sdks/sandbox/javascript/src/factory/defaultAdapterFactory.ts @@ -87,8 +87,14 @@ export class DefaultAdapterFactory implements AdapterFactory { headers, fetch: opts.connectionConfig.fetch, }); + const egress = new EgressAdapter(egressClient, { + baseUrl: opts.egressBaseUrl, + fetch: opts.connectionConfig.fetch, + headers, + }); return { - egress: new EgressAdapter(egressClient), + egress, + credentialVault: egress, }; } } diff --git a/sdks/sandbox/javascript/src/index.ts b/sdks/sandbox/javascript/src/index.ts index 56d4d8e53..c205cff4f 100644 --- a/sdks/sandbox/javascript/src/index.ts +++ b/sdks/sandbox/javascript/src/index.ts @@ -30,11 +30,29 @@ export { ConnectionConfig } from "./config/connection.js"; export type { ConnectionConfigOptions, ConnectionProtocol } from "./config/connection.js"; export type { + Credential, + CredentialAuth, + CredentialAuthMetadata, + CredentialBinding, + CredentialBindingListResponse, + CredentialBindingMetadata, + CredentialBindingMutationSet, + CredentialListResponse, + CredentialMatch, + CredentialMatchScheme, + CredentialMetadata, + CredentialMutationSet, + CredentialProxyConfig, + CredentialVaultCreateRequest, + CredentialVaultPatchRequest, + CredentialVaultState, CreateSnapshotRequest, CreateSandboxRequest, CreateSandboxResponse, + CustomHeaderEntry, Endpoint, Host, + InlineCredentialSource, ListSnapshotsParams, ListSnapshotsResponse, ListSandboxesParams, @@ -57,6 +75,7 @@ export type { } from "./models/sandboxes.js"; export type { Sandboxes } from "./services/sandboxes.js"; +export type { CredentialVault, Egress } from "./services/egress.js"; export { SandboxManager } from "./manager.js"; export type { SandboxFilter, SandboxManagerOptions } from "./manager.js"; diff --git a/sdks/sandbox/javascript/src/internal.ts b/sdks/sandbox/javascript/src/internal.ts index 1646c0e07..cdaff43c5 100644 --- a/sdks/sandbox/javascript/src/internal.ts +++ b/sdks/sandbox/javascript/src/internal.ts @@ -27,13 +27,18 @@ export { createLifecycleClient } from "./openapi/lifecycleClient.js"; export type { LifecycleClient } from "./openapi/lifecycleClient.js"; export { createExecdClient } from "./openapi/execdClient.js"; export type { ExecdClient } from "./openapi/execdClient.js"; +export { createEgressClient } from "./openapi/egressClient.js"; +export type { EgressClient } from "./openapi/egressClient.js"; // OpenAPI schema types (NOT stable public API; internal-only). export type { paths as LifecyclePaths } from "./api/lifecycle.js"; export type { paths as ExecdPaths } from "./api/execd.js"; +export type { paths as EgressPaths } from "./api/egress.js"; export { SandboxesAdapter } from "./adapters/sandboxesAdapter.js"; +export { EgressAdapter } from "./adapters/egressAdapter.js"; +export type { EgressRawHttpOptions } from "./adapters/egressAdapter.js"; export { HealthAdapter } from "./adapters/healthAdapter.js"; export { MetricsAdapter } from "./adapters/metricsAdapter.js"; export { FilesystemAdapter } from "./adapters/filesystemAdapter.js"; -export { CommandsAdapter } from "./adapters/commandsAdapter.js"; \ No newline at end of file +export { CommandsAdapter } from "./adapters/commandsAdapter.js"; diff --git a/sdks/sandbox/javascript/src/models/sandboxes.ts b/sdks/sandbox/javascript/src/models/sandboxes.ts index cdfb10212..9c86ca77a 100644 --- a/sdks/sandbox/javascript/src/models/sandboxes.ts +++ b/sdks/sandbox/javascript/src/models/sandboxes.ts @@ -73,6 +73,182 @@ export interface NetworkPolicy extends Record { egress?: NetworkRule[]; } +// ============================================================================ +// Credential Vault Models +// ============================================================================ + +export interface CredentialProxyConfig extends Record { + /** + * Enable transparent MITM support required by Credential Vault injection. + */ + enabled?: boolean; +} + +export interface InlineCredentialSource extends Record { + /** + * Credential source type. Defaults to "inline" when omitted. + */ + type?: "inline"; + /** + * Write-only inline credential value. This field is accepted in create/patch + * requests and is never present in Credential Vault state responses. + */ + value: string; +} + +export interface Credential extends Record { + /** + * Sandbox-local credential name. + */ + name: string; + /** + * Write-only credential source. + */ + source: InlineCredentialSource; +} + +export type CredentialMatchScheme = "https" | "http"; + +export interface CredentialMatch extends Record { + /** + * URL schemes to match. Defaults to HTTPS in the sidecar. + */ + schemes?: CredentialMatchScheme[]; + /** + * Destination ports to match. Defaults to 443 in the sidecar. + */ + ports?: number[]; + /** + * Exact FQDNs or leftmost-label wildcards. + */ + hosts: string[]; + /** + * HTTP methods to match. + */ + methods?: string[]; + /** + * Request paths to match. + */ + paths?: string[]; +} + +export interface CustomHeaderEntry extends Record { + /** + * Header name to inject. + */ + name: string; + /** + * Name of the sandbox-local credential to inject as this header value. + */ + credential: string; +} + +export type CredentialAuth = + | { + type: "bearer"; + credential: string; + } + | { + type: "basic"; + /** + * Credential containing pre-encoded base64(username:password). + */ + credential: string; + } + | { + type: "apiKey"; + name: string; + credential: string; + } + | { + type: "customHeaders"; + headers: CustomHeaderEntry[]; + }; + +export interface CredentialBinding extends Record { + /** + * Sandbox-local binding name. + */ + name: string; + /** + * Request match for this binding. + */ + match: CredentialMatch; + /** + * Auth injection rule for this binding. + */ + auth: CredentialAuth; +} + +export interface CredentialMetadata { + name: string; + /** + * Source type only; plaintext source material is not returned. + */ + sourceType: string; + revision: number; +} + +export interface CredentialAuthMetadata { + type: string; + /** + * Public auth parameter name, such as an API key header name. + */ + name?: string; +} + +export interface CredentialBindingMetadata { + name: string; + revision: number; + match?: CredentialMatch; + /** + * Sanitized auth metadata. Plaintext credential references and values are not returned. + */ + auth?: CredentialAuthMetadata; +} + +export interface CredentialVaultState { + revision: number; + credentials: CredentialMetadata[]; + bindings: CredentialBindingMetadata[]; +} + +export interface CredentialListResponse { + revision: number; + credentials: CredentialMetadata[]; +} + +export interface CredentialBindingListResponse { + revision: number; + bindings: CredentialBindingMetadata[]; +} + +export interface CredentialMutationSet extends Record { + add?: Credential[]; + replace?: Credential[]; + delete?: string[]; +} + +export interface CredentialBindingMutationSet extends Record { + add?: CredentialBinding[]; + replace?: CredentialBinding[]; + delete?: string[]; +} + +export interface CredentialVaultCreateRequest extends Record { + credentials: Credential[]; + bindings: CredentialBinding[]; +} + +export interface CredentialVaultPatchRequest extends Record { + /** + * Optional optimistic concurrency guard. + */ + expectedRevision?: number; + credentials?: CredentialMutationSet; + bindings?: CredentialBindingMutationSet; +} + // ============================================================================ // Volume Models // ============================================================================ @@ -263,6 +439,10 @@ export interface CreateSandboxRequest extends Record { * Optional outbound network policy for the sandbox. */ networkPolicy?: NetworkPolicy; + /** + * Optional Credential Vault proxy startup settings. + */ + credentialProxy?: CredentialProxyConfig; /** * Optional list of volume mounts for persistent storage. */ diff --git a/sdks/sandbox/javascript/src/sandbox.ts b/sdks/sandbox/javascript/src/sandbox.ts index 6ad984322..3751dd9e4 100644 --- a/sdks/sandbox/javascript/src/sandbox.ts +++ b/sdks/sandbox/javascript/src/sandbox.ts @@ -23,7 +23,7 @@ import { } from "./core/constants.js"; import { ConnectionConfig, type ConnectionConfigOptions } from "./config/connection.js"; import type { SandboxFiles } from "./services/filesystem.js"; -import type { Egress } from "./services/egress.js"; +import type { CredentialVault, Egress } from "./services/egress.js"; import { createDefaultAdapterFactory } from "./factory/defaultAdapterFactory.js"; import type { AdapterFactory } from "./factory/adapterFactory.js"; @@ -33,6 +33,7 @@ import type { ExecdHealth } from "./services/execdHealth.js"; import type { ExecdMetrics } from "./services/execdMetrics.js"; import type { CreateSandboxRequest, + CredentialProxyConfig, Endpoint, NetworkPolicy, NetworkRule, @@ -46,6 +47,44 @@ import type { import { SandboxReadyTimeoutException } from "./core/exceptions.js"; const HOST_PATH_PATTERN = /^([/]|[A-Za-z]:[\\/])/; +const CREDENTIAL_VAULT_METHODS = [ + "create", + "get", + "patch", + "delete", + "listCredentials", + "getCredential", + "listBindings", + "getBinding", +] as const; + +function isCredentialVault(value: unknown): value is CredentialVault { + if (typeof value !== "object" || value == null) { + return false; + } + const candidate = value as Record; + return CREDENTIAL_VAULT_METHODS.every( + (method) => typeof candidate[method] === "function" + ); +} + +function unavailableCredentialVault(): CredentialVault { + const fail = async (..._args: unknown[]): Promise => { + throw new Error( + "Credential Vault is not available for this adapter factory. Provide EgressStack.credentialVault to use Credential Vault with a custom adapter." + ); + }; + return { + create: fail, + get: fail, + patch: fail, + delete: fail, + listCredentials: fail, + getCredential: fail, + listBindings: fail, + getBinding: fail, + }; +} export interface SandboxCreateOptions { /** @@ -86,6 +125,12 @@ export interface SandboxCreateOptions { * If provided without defaultAction, defaults to "deny". */ networkPolicy?: NetworkPolicy; + /** + * Optional Credential Vault proxy startup settings. + * + * Set `enabled: true` to opt into transparent MITM support used by credential injection. + */ + credentialProxy?: CredentialProxyConfig; /** * Optional list of volume mounts for persistent storage. * Each volume specifies a backend (host path, PVC, or OSSFS) and mount configuration. @@ -195,6 +240,10 @@ export class Sandbox { readonly files: SandboxFiles; readonly health: ExecdHealth; readonly metrics: ExecdMetrics; + /** + * Sandbox-scoped Credential Vault operations. + */ + readonly credentialVault: CredentialVault; /** * Internal state kept out of the public instance shape. @@ -223,9 +272,16 @@ export class Sandbox { health: ExecdHealth; metrics: ExecdMetrics; egress: Egress; + credentialVault?: CredentialVault; }) { this.id = opts.id; this.connectionConfig = opts.connectionConfig; + const credentialVault = + opts.credentialVault ?? + (isCredentialVault(opts.egress) + ? opts.egress + : unavailableCredentialVault()); + Sandbox._priv.set(this, { adapterFactory: opts.adapterFactory, lifecycleBaseUrl: opts.lifecycleBaseUrl, @@ -238,6 +294,7 @@ export class Sandbox { this.files = opts.files; this.health = opts.health; this.metrics = opts.metrics; + this.credentialVault = credentialVault; } static async create(opts: SandboxCreateOptions): Promise { @@ -311,6 +368,7 @@ export class Sandbox { defaultAction: opts.networkPolicy.defaultAction ?? "deny", } : undefined, + credentialProxy: opts.credentialProxy, volumes: opts.volumes, extensions: opts.extensions ?? {}, platform: opts.platform, @@ -343,7 +401,7 @@ export class Sandbox { execdBaseUrl, endpointHeaders: endpoint.headers, }); - const { egress } = adapterFactory.createEgressStack({ + const { egress, credentialVault } = adapterFactory.createEgressStack({ connectionConfig, egressBaseUrl, endpointHeaders: egressEndpoint.headers, @@ -361,6 +419,7 @@ export class Sandbox { health, metrics, egress, + credentialVault, }); if (!(opts.skipHealthCheck ?? false)) { @@ -427,7 +486,7 @@ export class Sandbox { execdBaseUrl, endpointHeaders: endpoint.headers, }); - const { egress } = adapterFactory.createEgressStack({ + const { egress, credentialVault } = adapterFactory.createEgressStack({ connectionConfig, egressBaseUrl, endpointHeaders: egressEndpoint.headers, @@ -445,6 +504,7 @@ export class Sandbox { health, metrics, egress, + credentialVault, }); if (!(opts.skipHealthCheck ?? false)) { diff --git a/sdks/sandbox/javascript/src/services/egress.ts b/sdks/sandbox/javascript/src/services/egress.ts index ff9efe3b7..66ab3d9bf 100644 --- a/sdks/sandbox/javascript/src/services/egress.ts +++ b/sdks/sandbox/javascript/src/services/egress.ts @@ -12,7 +12,50 @@ // See the License for the specific language governing permissions and // limitations under the License. -import type { NetworkPolicy, NetworkRule } from "../models/sandboxes.js"; +import type { + CredentialBindingMetadata, + CredentialMetadata, + CredentialVaultCreateRequest, + CredentialVaultPatchRequest, + CredentialVaultState, + NetworkPolicy, + NetworkRule, +} from "../models/sandboxes.js"; + +export interface CredentialVault { + /** + * Create the sandbox-local Credential Vault and activate its initial revision. + */ + create(request: CredentialVaultCreateRequest): Promise; + /** + * Get sanitized Credential Vault state. + */ + get(): Promise; + /** + * Atomically patch sandbox-local credentials and bindings. + */ + patch(request: CredentialVaultPatchRequest): Promise; + /** + * Delete the sandbox-local Credential Vault. + */ + delete(): Promise; + /** + * List sanitized credential metadata. + */ + listCredentials(): Promise; + /** + * Get sanitized metadata for one credential. + */ + getCredential(name: string): Promise; + /** + * List sanitized binding metadata. + */ + listBindings(): Promise; + /** + * Get sanitized metadata for one binding. + */ + getBinding(name: string): Promise; +} export interface Egress { getPolicy(): Promise; diff --git a/sdks/sandbox/javascript/tests/credential-vault.test.mjs b/sdks/sandbox/javascript/tests/credential-vault.test.mjs new file mode 100644 index 000000000..3f9477274 --- /dev/null +++ b/sdks/sandbox/javascript/tests/credential-vault.test.mjs @@ -0,0 +1,272 @@ +import assert from "node:assert/strict"; +import { readdir, readFile } from "node:fs/promises"; +import test from "node:test"; + +import { EgressAdapter } from "../dist/internal.js"; + +const credential = { + name: "api-token", + source: { value: "write-only-value" }, +}; + +const binding = { + name: "api-binding", + match: { + schemes: ["https"], + hosts: ["api.example.com"], + methods: ["GET", "POST"], + paths: ["/v1/*"], + }, + auth: { + type: "apiKey", + name: "X-API-Key", + credential: "api-token", + }, +}; + +const sanitizedCredential = { + name: "api-token", + sourceType: "inline", + revision: 7, +}; + +const sanitizedBinding = { + name: "api-binding", + revision: 5, + match: { + schemes: ["https"], + hosts: ["api.example.com"], + methods: ["GET", "POST"], + paths: ["/v1/*"], + }, + auth: { + type: "apiKey", + name: "X-API-Key", + }, +}; + +function stateResponse() { + return { + revision: 9, + credentials: [ + { + ...sanitizedCredential, + source: { type: "inline", value: "write-only-value" }, + }, + ], + bindings: [ + { + ...sanitizedBinding, + auth: { + ...sanitizedBinding.auth, + credential: "api-token", + headers: [{ name: "X-Extra", credential: "api-token" }], + }, + }, + ], + }; +} + +function jsonResponse(body, init = {}) { + return new Response(body == null ? null : JSON.stringify(body), { + status: init.status ?? 200, + headers: { + "content-type": "application/json", + ...(init.headers ?? {}), + }, + }); +} + +function createAdapter() { + const requests = []; + const fetchImpl = async (input, init = {}) => { + const url = new URL(String(input)); + const bodyText = typeof init.body === "string" ? init.body : undefined; + const request = { + method: init.method ?? "GET", + url: String(input), + pathname: url.pathname, + headers: Object.fromEntries(new Headers(init.headers).entries()), + body: bodyText ? JSON.parse(bodyText) : undefined, + }; + requests.push(request); + + if (request.pathname === "/credential-vault") { + if (request.method === "DELETE") { + return new Response(null, { status: 204 }); + } + return jsonResponse(stateResponse()); + } + if (request.pathname === "/credential-vault/credentials") { + return jsonResponse({ + revision: 9, + credentials: [stateResponse().credentials[0]], + }); + } + if (request.pathname === "/credential-vault/credentials/api-token%2Fprimary") { + return jsonResponse(stateResponse().credentials[0]); + } + if (request.pathname === "/credential-vault/bindings") { + return jsonResponse({ + revision: 9, + bindings: [stateResponse().bindings[0]], + }); + } + if (request.pathname === "/credential-vault/bindings/api%20binding") { + return jsonResponse(stateResponse().bindings[0]); + } + return jsonResponse({ code: "NOT_FOUND", message: "not found" }, { status: 404 }); + }; + + const policyClient = { + GET() { + throw new Error("policy GET was not expected"); + }, + PATCH() { + throw new Error("policy PATCH was not expected"); + }, + DELETE() { + throw new Error("policy DELETE was not expected"); + }, + }; + + return { + adapter: new EgressAdapter(policyClient, { + baseUrl: "https://egress.example", + fetch: fetchImpl, + headers: { + "OPEN-SANDBOX-API-KEY": "sdk-key", + "x-endpoint-token": "route-token", + }, + }), + requests, + }; +} + +test("EgressAdapter sends Credential Vault JSON payloads with endpoint headers", async () => { + const { adapter, requests } = createAdapter(); + + const created = await adapter.create({ + credentials: [credential], + bindings: [binding], + }); + + await adapter.patch({ + expectedRevision: 9, + credentials: { + add: [credential], + replace: [credential], + delete: ["old-token"], + }, + bindings: { + add: [binding], + replace: [binding], + delete: ["old-binding"], + }, + }); + const current = await adapter.get(); + const credentials = await adapter.listCredentials(); + const oneCredential = await adapter.getCredential("api-token/primary"); + const bindings = await adapter.listBindings(); + const oneBinding = await adapter.getBinding("api binding"); + await adapter.delete(); + + assert.deepEqual(created, { + revision: 9, + credentials: [sanitizedCredential], + bindings: [sanitizedBinding], + }); + assert.deepEqual(current, created); + assert.deepEqual(credentials, [sanitizedCredential]); + assert.deepEqual(oneCredential, sanitizedCredential); + assert.deepEqual(bindings, [sanitizedBinding]); + assert.deepEqual(oneBinding, sanitizedBinding); + + assert.deepEqual( + requests.map((request) => [request.method, request.pathname]), + [ + ["POST", "/credential-vault"], + ["PATCH", "/credential-vault"], + ["GET", "/credential-vault"], + ["GET", "/credential-vault/credentials"], + ["GET", "/credential-vault/credentials/api-token%2Fprimary"], + ["GET", "/credential-vault/bindings"], + ["GET", "/credential-vault/bindings/api%20binding"], + ["DELETE", "/credential-vault"], + ], + ); + assert.equal(requests[0].headers["open-sandbox-api-key"], "sdk-key"); + assert.equal(requests[0].headers["x-endpoint-token"], "route-token"); + assert.equal(requests[0].headers["content-type"], "application/json"); + assert.equal(requests[2].headers["content-type"], undefined); + assert.deepEqual(requests[0].body, { + credentials: [credential], + bindings: [binding], + }); + assert.deepEqual(requests[1].body, { + expectedRevision: 9, + credentials: { + add: [credential], + replace: [credential], + delete: ["old-token"], + }, + bindings: { + add: [binding], + replace: [binding], + delete: ["old-binding"], + }, + }); +}); + +test("Credential Vault state omits plaintext secret fields", async () => { + const { adapter } = createAdapter(); + const state = await adapter.get(); + + assert.equal(Object.hasOwn(state.credentials[0], "source"), false); + assert.equal(Object.hasOwn(state.bindings[0].auth, "credential"), false); + assert.equal(Object.hasOwn(state.bindings[0].auth, "headers"), false); + + const dts = await readDistDeclarations(); + const inlineCredentialSource = declarationBlock(dts, "InlineCredentialSource"); + const credentialMetadata = declarationBlock(dts, "CredentialMetadata"); + const authMetadata = declarationBlock(dts, "CredentialAuthMetadata"); + const bindingMetadata = declarationBlock(dts, "CredentialBindingMetadata"); + + assert.match(inlineCredentialSource, /\btype\?: "inline"/); + assert.doesNotMatch(credentialMetadata, /extends Record/); + assert.doesNotMatch(credentialMetadata, /\bsource\s*[?:]/); + assert.doesNotMatch(authMetadata, /extends Record/); + assert.doesNotMatch(authMetadata, /\bcredential\s*[?:]/); + assert.doesNotMatch(authMetadata, /\bheaders\s*[?:]/); + assert.doesNotMatch(bindingMetadata, /extends Record/); + assert.match(bindingMetadata, /\bauth\??: CredentialAuthMetadata/); +}); + +test("Egress declarations keep Credential Vault optional for custom adapters", async () => { + const dts = await readDistDeclarations(); + const egress = declarationBlock(dts, "Egress"); + const egressStack = declarationBlock(dts, "EgressStack"); + + assert.doesNotMatch(egress, /extends CredentialVault/); + assert.doesNotMatch(egress, /\bcreate\(/); + assert.match(egressStack, /\begress: Egress/); + assert.match(egressStack, /\bcredentialVault\??: CredentialVault/); +}); + +async function readDistDeclarations() { + const distDir = new URL("../dist/", import.meta.url); + const entries = await readdir(distDir); + const declarations = entries.filter((entry) => entry.endsWith(".d.ts")); + const contents = await Promise.all( + declarations.map((entry) => readFile(new URL(entry, distDir), "utf8")), + ); + return contents.join("\n"); +} + +function declarationBlock(dts, name) { + const start = dts.indexOf(`interface ${name}`); + assert.notEqual(start, -1, `missing ${name} declaration`); + const end = dts.indexOf("\n}", start); + assert.notEqual(end, -1, `missing ${name} declaration end`); + return dts.slice(start, end + 2); +} diff --git a/sdks/sandbox/javascript/tests/sandbox.create.test.mjs b/sdks/sandbox/javascript/tests/sandbox.create.test.mjs index 47c9d2ed6..4160f18d0 100644 --- a/sdks/sandbox/javascript/tests/sandbox.create.test.mjs +++ b/sdks/sandbox/javascript/tests/sandbox.create.test.mjs @@ -9,11 +9,11 @@ import { Sandbox, } from "../dist/index.js"; -function createAdapterFactory() { +function createAdapterFactory({ includeCredentialVault = true } = {}) { const recordedRequests = []; const endpointCalls = []; const egressStackCalls = []; - const egressService = { + const policyOnlyEgressService = { async getPolicy() { return { defaultAction: "deny", @@ -21,7 +21,35 @@ function createAdapterFactory() { }; }, async patchRules() {}, + async deleteRules() {}, }; + const credentialVaultService = { + async create() { + return { revision: 1, credentials: [], bindings: [] }; + }, + async get() { + return { revision: 1, credentials: [], bindings: [] }; + }, + async patch() { + return { revision: 2, credentials: [], bindings: [] }; + }, + async delete() {}, + async listCredentials() { + return []; + }, + async getCredential(name) { + return { name, sourceType: "inline", revision: 1 }; + }, + async listBindings() { + return []; + }, + async getBinding(name) { + return { name, revision: 1 }; + }, + }; + const egressService = includeCredentialVault + ? { ...policyOnlyEgressService, ...credentialVaultService } + : policyOnlyEgressService; const sandboxes = { async createSandbox(req) { recordedRequests.push(req); @@ -96,6 +124,21 @@ test("Sandbox.create forwards secureAccess", async () => { assert.equal(recordedRequests[0].secureAccess, true); }); +test("Sandbox.create forwards credentialProxy", async () => { + const { adapterFactory, recordedRequests } = createAdapterFactory(); + + await Sandbox.create({ + adapterFactory, + connectionConfig: { domain: "http://127.0.0.1:8080" }, + image: "python:3.12", + credentialProxy: { enabled: true }, + skipHealthCheck: true, + }); + + assert.equal(recordedRequests.length, 1); + assert.deepEqual(recordedRequests[0].credentialProxy, { enabled: true }); +}); + test("Sandbox.create forwards windows platform values", async () => { const { adapterFactory, recordedRequests } = createAdapterFactory(); @@ -224,11 +267,33 @@ test("Sandbox creates and reuses egress service during sandbox lifecycle", async await sandbox.getEgressPolicy(); await sandbox.patchEgressRules([{ action: "allow", target: "www.github.com" }]); + const vaultState = await sandbox.credentialVault.get(); assert.deepEqual(endpointCalls, [DEFAULT_EXECD_PORT, DEFAULT_EGRESS_PORT]); assert.equal(egressStackCalls.length, 1); assert.equal(egressStackCalls[0].egressBaseUrl, `http://127.0.0.1:${DEFAULT_EGRESS_PORT}`); assert.deepEqual(egressStackCalls[0].endpointHeaders, { "x-port": String(DEFAULT_EGRESS_PORT) }); + assert.deepEqual(vaultState, { revision: 1, credentials: [], bindings: [] }); +}); + +test("Sandbox.create accepts custom egress adapters without Credential Vault methods", async () => { + const { adapterFactory } = createAdapterFactory({ includeCredentialVault: false }); + + const sandbox = await Sandbox.create({ + adapterFactory, + connectionConfig: { domain: "http://127.0.0.1:8080" }, + image: "python:3.12", + skipHealthCheck: true, + }); + + assert.deepEqual(await sandbox.getEgressPolicy(), { + defaultAction: "deny", + egress: [{ action: "allow", target: "pypi.org" }], + }); + await assert.rejects( + () => sandbox.credentialVault.get(), + /Credential Vault is not available/ + ); }); test("Sandbox.create passes OSSFS volume to request", async () => { diff --git a/sdks/sandbox/kotlin/README.md b/sdks/sandbox/kotlin/README.md index add5b5b61..f9e447dc7 100644 --- a/sdks/sandbox/kotlin/README.md +++ b/sdks/sandbox/kotlin/README.md @@ -322,6 +322,7 @@ The `Sandbox.builder()` allows configuring the sandbox environment. | `metadata` | Custom metadata tags | Empty | | `extensions` | Opaque server-side extension parameters | Empty | | `networkPolicy` | Optional outbound network policy (egress) | - | +| `credentialProxy` | Optional Credential Vault proxy startup settings | - | | `readyTimeout` | Max time to wait for sandbox to be ready | 30 seconds | Note: metadata keys under `opensandbox.io/` are reserved for system-managed @@ -377,3 +378,70 @@ sandbox.patchEgressRules( ) ); ``` + +### 4. Credential Vault + +Credential Vault injects outbound credentials from the egress sidecar while +keeping real secrets out of sandbox environment variables, commands, files, and +logs. Create the sandbox with `credentialProxyEnabled(true)`, then write +credentials and bindings through `sandbox.credentialVault()`. + +```java +import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.Credential; +import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.CredentialAuth; +import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.CredentialBinding; +import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.CredentialMatch; +import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.CredentialVaultCreateRequest; +import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.NetworkPolicy; +import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.NetworkRule; +import java.util.List; + +Sandbox sandbox = Sandbox.builder() + .connectionConfig(config) + .image("python:3.11") + .networkPolicy( + NetworkPolicy.builder() + .defaultAction(NetworkPolicy.DefaultAction.DENY) + .addEgress( + NetworkRule.builder() + .action(NetworkRule.Action.ALLOW) + .target("api.example.com") + .build() + ) + .build() + ) + .credentialProxyEnabled(true) + .build(); + +sandbox.credentialVault().create( + CredentialVaultCreateRequest.builder() + .credentials( + List.of( + Credential.builder() + .name("api-token") + .inlineSource("") + .build() + ) + ) + .bindings( + List.of( + CredentialBinding.builder() + .name("api-token") + .match( + CredentialMatch.builder() + .schemes(CredentialMatch.Scheme.HTTPS) + .ports(443) + .hosts("api.example.com") + .paths("/v1/*") + .build() + ) + .auth(CredentialAuth.apiKey("x-api-key", "api-token")) + .build() + ) + ) + .build() +); +``` + +See [Credential Vault](../../../docs/credential-vault.md) for auth types, +binding guidance, and Git/curl examples. diff --git a/sdks/sandbox/kotlin/README_zh.md b/sdks/sandbox/kotlin/README_zh.md index 3a93792a4..d2c00451a 100644 --- a/sdks/sandbox/kotlin/README_zh.md +++ b/sdks/sandbox/kotlin/README_zh.md @@ -320,6 +320,7 @@ ConnectionConfig sharedConfig = ConnectionConfig.builder() | `env` | 环境变量 | 空 | | `metadata` | 自定义元数据标签 | 空 | | `networkPolicy` | 可选的出站网络策略(egress) | - | +| `credentialProxy` | 可选的 Credential Vault proxy 启动配置 | - | | `readyTimeout` | 等待沙箱就绪的最大时间 | 30 秒 | 注意:`opensandbox.io/` 前缀下的 metadata key 属于系统保留标签,服务端会拒绝用户传入。 @@ -367,3 +368,9 @@ sandbox.patchEgressRules( ) ); ``` + +### 4. Credential Vault + +Credential Vault 可以由 egress sidecar 在出站请求中注入凭证,避免真实密钥进入沙箱环境变量、命令参数、文件或日志。创建沙箱时调用 `.credentialProxyEnabled(true)`,然后通过 `sandbox.credentialVault().create(...)` / `patch(...)` 写入 credentials 和 bindings。 + +更多 auth 类型、binding 规则和 Git/curl 示例请参考 [Credential Vault](../../../docs/credential-vault_zh.md)。 diff --git a/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/Sandbox.kt b/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/Sandbox.kt index b490015c1..8524ae15c 100644 --- a/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/Sandbox.kt +++ b/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/Sandbox.kt @@ -24,6 +24,7 @@ import com.alibaba.opensandbox.sandbox.domain.exceptions.SandboxReadyTimeoutExce import com.alibaba.opensandbox.sandbox.domain.models.diagnostics.DiagnosticContent import com.alibaba.opensandbox.sandbox.domain.models.execd.DEFAULT_EGRESS_PORT import com.alibaba.opensandbox.sandbox.domain.models.execd.DEFAULT_EXECD_PORT +import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.CredentialProxyConfig import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.NetworkPolicy import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.NetworkRule import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.PlatformSpec @@ -35,6 +36,7 @@ import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.SandboxRenewRespo import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.SnapshotInfo import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.Volume import com.alibaba.opensandbox.sandbox.domain.services.Commands +import com.alibaba.opensandbox.sandbox.domain.services.CredentialVault import com.alibaba.opensandbox.sandbox.domain.services.Diagnostics import com.alibaba.opensandbox.sandbox.domain.services.Egress import com.alibaba.opensandbox.sandbox.domain.services.Filesystem @@ -91,6 +93,7 @@ class Sandbox internal constructor( private val healthService: Health, private val metricsService: Metrics, private val egressService: Egress, + private val credentialVaultService: CredentialVault, private val customHealthCheck: ((sandbox: Sandbox) -> Boolean)? = null, private val httpClientProvider: HttpClientProvider, private val diagnosticsService: Diagnostics, @@ -124,6 +127,14 @@ class Sandbox internal constructor( */ fun metrics() = metricsService + /** + * Provides access to sandbox-scoped Credential Vault operations. + * + * Credential Vault writes go directly to the sandbox egress sidecar and + * preserve endpoint routing/auth headers resolved for this sandbox. + */ + fun credentialVault(): CredentialVault = credentialVaultService + /** * Provides access to sandbox diagnostic log and event descriptors. * @@ -222,7 +233,7 @@ class Sandbox internal constructor( DEFAULT_EGRESS_PORT, connectionConfig.useServerProxy, ) - val egressService = factory.createEgress(egressEndpoint) + val egressStack = factory.createEgressStack(egressEndpoint) val diagnosticsService = factory.createDiagnostics() val sandbox = @@ -233,7 +244,8 @@ class Sandbox internal constructor( commandService = commandService, metricsService = metricsService, healthService = healthService, - egressService = egressService, + egressService = egressStack.egress, + credentialVaultService = egressStack.credentialVault, customHealthCheck = healthCheck, httpClientProvider = httpClientProvider, diagnosticsService = diagnosticsService, @@ -290,6 +302,7 @@ class Sandbox internal constructor( * @param readyTimeout Timeout for waiting for sandbox readiness * @param resource Resource limits (optional) * @param networkPolicy Optional outbound network policy (egress) + * @param credentialProxy Optional Credential Vault proxy startup settings * @param secureAccess Whether to enable secured access for sandbox endpoints * @param connectionConfig Connection configuration * @param healthCheck Custom health check function (optional) @@ -310,6 +323,7 @@ class Sandbox internal constructor( resource: Map, platform: PlatformSpec?, networkPolicy: NetworkPolicy?, + credentialProxy: CredentialProxyConfig?, secureAccess: Boolean, connectionConfig: ConnectionConfig, healthCheck: ((Sandbox) -> Boolean)? = null, @@ -329,18 +343,19 @@ class Sandbox internal constructor( ) { sandboxService -> val response = sandboxService.createSandbox( - imageSpec, - entrypoint, - env, - metadata, - timeout, - resource, - networkPolicy, - extensions, - volumes, - platform, - secureAccess, - snapshotId, + spec = imageSpec, + entrypoint = entrypoint, + env = env, + metadata = metadata, + timeout = timeout, + resource = resource, + networkPolicy = networkPolicy, + credentialProxy = credentialProxy, + extensions = extensions, + volumes = volumes, + platform = platform, + secureAccess = secureAccess, + snapshotId = snapshotId, ) InitializationResult.NewSandbox(response.id) } @@ -899,6 +914,11 @@ class Sandbox internal constructor( */ private var networkPolicy: NetworkPolicy? = null + /** + * Optional Credential Vault proxy startup settings. + */ + private var credentialProxy: CredentialProxyConfig? = null + /** * Enables secured access for sandbox endpoints. */ @@ -1123,6 +1143,33 @@ class Sandbox internal constructor( return this } + /** + * Sets Credential Vault proxy startup settings for this sandbox. + */ + fun credentialProxy(credentialProxy: CredentialProxyConfig): Builder { + this.credentialProxy = credentialProxy + return this + } + + /** + * Enables or disables transparent Credential Vault proxying. + */ + @JvmOverloads + fun credentialProxyEnabled(enabled: Boolean = true): Builder { + this.credentialProxy = CredentialProxyConfig.builder().enabled(enabled).build() + return this + } + + /** + * Configures Credential Vault proxy startup settings. + */ + fun credentialProxy(configure: CredentialProxyConfig.Builder.() -> Unit): Builder { + val builder = CredentialProxyConfig.builder() + builder.configure() + this.credentialProxy = builder.build() + return this + } + /** * Enables or disables secured access for sandbox endpoints. * @@ -1344,6 +1391,7 @@ class Sandbox internal constructor( resource = resource, platform = platform, networkPolicy = networkPolicy, + credentialProxy = credentialProxy, secureAccess = secureAccess, extensions = extensions, connectionConfig = connectionConfig ?: ConnectionConfig.builder().build(), diff --git a/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/domain/models/sandboxes/CredentialVaultModels.kt b/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/domain/models/sandboxes/CredentialVaultModels.kt new file mode 100644 index 000000000..beba33284 --- /dev/null +++ b/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/domain/models/sandboxes/CredentialVaultModels.kt @@ -0,0 +1,584 @@ +/* + * Copyright 2025 Alibaba Group Holding Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.alibaba.opensandbox.sandbox.domain.models.sandboxes + +/** + * Credential Vault proxy startup settings. + * + * This model mirrors the lifecycle API shape. Credential values and bindings + * are still managed through the sandbox-scoped egress Credential Vault API. + */ +class CredentialProxyConfig private constructor( + val enabled: Boolean, +) { + companion object { + @JvmStatic + fun builder(): Builder = Builder() + + @JvmStatic + fun enabled(): CredentialProxyConfig = builder().enabled(true).build() + } + + class Builder { + private var enabled: Boolean = false + + fun enabled(enabled: Boolean): Builder { + this.enabled = enabled + return this + } + + fun build(): CredentialProxyConfig = CredentialProxyConfig(enabled) + } +} + +/** + * Write-only inline credential material for Credential Vault. + */ +class InlineCredentialSource private constructor( + val value: String, + val type: String, +) { + companion object { + const val TYPE_INLINE = "inline" + + @JvmStatic + fun builder(): Builder = Builder() + + @JvmStatic + fun of(value: String): InlineCredentialSource = builder().value(value).build() + } + + class Builder { + private var value: String? = null + private var type: String = TYPE_INLINE + + fun value(value: String): Builder { + require(value.isNotEmpty()) { "Credential source value cannot be empty" } + this.value = value + return this + } + + fun type(type: String): Builder { + require(type == TYPE_INLINE) { "Credential source type must be inline" } + this.type = type + return this + } + + fun build(): InlineCredentialSource { + val valueValue = value ?: throw IllegalArgumentException("Credential source value must be specified") + return InlineCredentialSource(value = valueValue, type = type) + } + } +} + +/** + * Sandbox-local Credential Vault credential. + */ +class Credential private constructor( + val name: String, + val source: InlineCredentialSource, +) { + companion object { + @JvmStatic + fun builder(): Builder = Builder() + } + + class Builder { + private var name: String? = null + private var source: InlineCredentialSource? = null + + fun name(name: String): Builder { + require(name.isNotBlank()) { "Credential name cannot be blank" } + this.name = name + return this + } + + fun source(source: InlineCredentialSource): Builder { + this.source = source + return this + } + + fun inlineSource(value: String): Builder { + this.source = InlineCredentialSource.of(value) + return this + } + + fun build(): Credential { + val nameValue = name ?: throw IllegalArgumentException("Credential name must be specified") + val sourceValue = source ?: throw IllegalArgumentException("Credential source must be specified") + return Credential(name = nameValue, source = sourceValue) + } + } +} + +/** + * Request match for a Credential Vault binding. + */ +class CredentialMatch private constructor( + val schemes: List?, + val ports: List?, + val hosts: List, + val methods: List?, + val paths: List?, +) { + enum class Scheme { + HTTPS, + HTTP, + } + + companion object { + @JvmStatic + fun builder(): Builder = Builder() + } + + class Builder { + private var schemes: List? = null + private var ports: List? = null + private var hosts: List? = null + private var methods: List? = null + private var paths: List? = null + + fun schemes(schemes: List): Builder { + require(schemes.isNotEmpty()) { "Credential match schemes cannot be empty when provided" } + this.schemes = schemes.toList() + return this + } + + fun schemes(vararg schemes: Scheme): Builder = schemes(schemes.toList()) + + fun ports(ports: List): Builder { + require(ports.isNotEmpty()) { "Credential match ports cannot be empty when provided" } + require(ports.all { it in 1..65535 }) { "Credential match ports must be between 1 and 65535" } + this.ports = ports.toList() + return this + } + + fun ports(vararg ports: Int): Builder = ports(ports.toList()) + + fun hosts(hosts: List): Builder { + require(hosts.isNotEmpty()) { "Credential match hosts cannot be empty" } + require(hosts.all { it.isNotBlank() }) { "Credential match host cannot be blank" } + this.hosts = hosts.toList() + return this + } + + fun hosts(vararg hosts: String): Builder = hosts(hosts.toList()) + + fun methods(methods: List): Builder { + require(methods.isNotEmpty()) { "Credential match methods cannot be empty when provided" } + require(methods.all { it.isNotBlank() }) { "Credential match method cannot be blank" } + this.methods = methods.toList() + return this + } + + fun methods(vararg methods: String): Builder = methods(methods.toList()) + + fun paths(paths: List): Builder { + require(paths.isNotEmpty()) { "Credential match paths cannot be empty when provided" } + require(paths.all { it.isNotBlank() }) { "Credential match path cannot be blank" } + this.paths = paths.toList() + return this + } + + fun paths(vararg paths: String): Builder = paths(paths.toList()) + + fun build(): CredentialMatch { + val hostsValue = hosts ?: throw IllegalArgumentException("Credential match hosts must be specified") + return CredentialMatch( + schemes = schemes, + ports = ports, + hosts = hostsValue, + methods = methods, + paths = paths, + ) + } + } +} + +/** + * Custom header injection entry. + */ +class CustomHeaderEntry private constructor( + val name: String, + val credential: String, +) { + companion object { + @JvmStatic + fun builder(): Builder = Builder() + } + + class Builder { + private var name: String? = null + private var credential: String? = null + + fun name(name: String): Builder { + require(name.isNotBlank()) { "Custom header name cannot be blank" } + this.name = name + return this + } + + fun credential(credential: String): Builder { + require(credential.isNotBlank()) { "Custom header credential cannot be blank" } + this.credential = credential + return this + } + + fun build(): CustomHeaderEntry { + val nameValue = name ?: throw IllegalArgumentException("Custom header name must be specified") + val credentialValue = credential ?: throw IllegalArgumentException("Custom header credential must be specified") + return CustomHeaderEntry(name = nameValue, credential = credentialValue) + } + } +} + +/** + * Typed Credential Vault auth rule. + */ +class CredentialAuth private constructor( + val type: Type, + val credential: String?, + val name: String?, + val headers: List?, +) { + enum class Type { + BEARER, + BASIC, + API_KEY, + CUSTOM_HEADERS, + } + + companion object { + @JvmStatic + fun builder(): Builder = Builder() + + @JvmStatic + fun bearer(credential: String): CredentialAuth = builder().type(Type.BEARER).credential(credential).build() + + @JvmStatic + fun basic(credential: String): CredentialAuth = builder().type(Type.BASIC).credential(credential).build() + + @JvmStatic + fun apiKey( + name: String, + credential: String, + ): CredentialAuth = builder().type(Type.API_KEY).name(name).credential(credential).build() + + @JvmStatic + fun customHeaders(headers: List): CredentialAuth = builder().type(Type.CUSTOM_HEADERS).headers(headers).build() + } + + class Builder { + private var type: Type? = null + private var credential: String? = null + private var name: String? = null + private var headers: List? = null + + fun type(type: Type): Builder { + this.type = type + return this + } + + fun credential(credential: String): Builder { + require(credential.isNotBlank()) { "Credential auth credential cannot be blank" } + this.credential = credential + return this + } + + fun name(name: String): Builder { + require(name.isNotBlank()) { "Credential auth name cannot be blank" } + this.name = name + return this + } + + fun headers(headers: List): Builder { + require(headers.isNotEmpty()) { "Credential auth headers cannot be empty" } + this.headers = headers.toList() + return this + } + + fun headers(vararg headers: CustomHeaderEntry): Builder = headers(headers.toList()) + + fun build(): CredentialAuth { + val typeValue = type ?: throw IllegalArgumentException("Credential auth type must be specified") + when (typeValue) { + Type.BEARER, Type.BASIC -> { + if (credential == null) { + throw IllegalArgumentException("Credential auth credential must be specified") + } + } + Type.API_KEY -> { + if (name == null || credential == null) { + throw IllegalArgumentException("API key auth name and credential must be specified") + } + } + Type.CUSTOM_HEADERS -> { + if (headers.isNullOrEmpty()) { + throw IllegalArgumentException("Custom headers auth headers must be specified") + } + } + } + return CredentialAuth(type = typeValue, credential = credential, name = name, headers = headers) + } + } +} + +/** + * Sandbox-local Credential Vault binding. + */ +class CredentialBinding private constructor( + val name: String, + val match: CredentialMatch, + val auth: CredentialAuth, +) { + companion object { + @JvmStatic + fun builder(): Builder = Builder() + } + + class Builder { + private var name: String? = null + private var match: CredentialMatch? = null + private var auth: CredentialAuth? = null + + fun name(name: String): Builder { + require(name.isNotBlank()) { "Credential binding name cannot be blank" } + this.name = name + return this + } + + fun match(match: CredentialMatch): Builder { + this.match = match + return this + } + + fun auth(auth: CredentialAuth): Builder { + this.auth = auth + return this + } + + fun build(): CredentialBinding { + val nameValue = name ?: throw IllegalArgumentException("Credential binding name must be specified") + val matchValue = match ?: throw IllegalArgumentException("Credential binding match must be specified") + val authValue = auth ?: throw IllegalArgumentException("Credential binding auth must be specified") + return CredentialBinding(name = nameValue, match = matchValue, auth = authValue) + } + } +} + +/** + * Credential Vault create request. + */ +class CredentialVaultCreateRequest private constructor( + val credentials: List, + val bindings: List, +) { + companion object { + @JvmStatic + fun builder(): Builder = Builder() + } + + class Builder { + private var credentials: List = emptyList() + private var bindings: List = emptyList() + + fun credentials(credentials: List): Builder { + this.credentials = credentials.toList() + return this + } + + fun bindings(bindings: List): Builder { + this.bindings = bindings.toList() + return this + } + + fun build(): CredentialVaultCreateRequest = CredentialVaultCreateRequest(credentials = credentials, bindings = bindings) + } +} + +/** + * Atomic credential mutation set for Credential Vault patch. + */ +class CredentialMutationSet private constructor( + val add: List?, + val replace: List?, + val delete: List?, +) { + companion object { + @JvmStatic + fun builder(): Builder = Builder() + } + + class Builder { + private var add: List? = null + private var replace: List? = null + private var delete: List? = null + + fun add(add: List): Builder { + this.add = add.toList() + return this + } + + fun replace(replace: List): Builder { + this.replace = replace.toList() + return this + } + + fun delete(delete: List): Builder { + require(delete.all { it.isNotBlank() }) { "Credential delete name cannot be blank" } + this.delete = delete.toList() + return this + } + + fun delete(vararg delete: String): Builder = delete(delete.toList()) + + fun build(): CredentialMutationSet = CredentialMutationSet(add = add, replace = replace, delete = delete) + } +} + +/** + * Atomic binding mutation set for Credential Vault patch. + */ +class CredentialBindingMutationSet private constructor( + val add: List?, + val replace: List?, + val delete: List?, +) { + companion object { + @JvmStatic + fun builder(): Builder = Builder() + } + + class Builder { + private var add: List? = null + private var replace: List? = null + private var delete: List? = null + + fun add(add: List): Builder { + this.add = add.toList() + return this + } + + fun replace(replace: List): Builder { + this.replace = replace.toList() + return this + } + + fun delete(delete: List): Builder { + require(delete.all { it.isNotBlank() }) { "Credential binding delete name cannot be blank" } + this.delete = delete.toList() + return this + } + + fun delete(vararg delete: String): Builder = delete(delete.toList()) + + fun build(): CredentialBindingMutationSet = CredentialBindingMutationSet(add = add, replace = replace, delete = delete) + } +} + +/** + * Credential Vault patch request. + */ +class CredentialVaultPatchRequest private constructor( + val expectedRevision: Int?, + val credentials: CredentialMutationSet?, + val bindings: CredentialBindingMutationSet?, +) { + companion object { + @JvmStatic + fun builder(): Builder = Builder() + } + + class Builder { + private var expectedRevision: Int? = null + private var credentials: CredentialMutationSet? = null + private var bindings: CredentialBindingMutationSet? = null + + fun expectedRevision(expectedRevision: Int?): Builder { + this.expectedRevision = expectedRevision + return this + } + + fun credentials(credentials: CredentialMutationSet?): Builder { + this.credentials = credentials + return this + } + + fun bindings(bindings: CredentialBindingMutationSet?): Builder { + this.bindings = bindings + return this + } + + fun build(): CredentialVaultPatchRequest = + CredentialVaultPatchRequest( + expectedRevision = expectedRevision, + credentials = credentials, + bindings = bindings, + ) + } +} + +/** + * Sanitized credential metadata returned by Credential Vault. + */ +class CredentialMetadata( + val name: String, + val sourceType: String, + val revision: Int, +) + +/** + * Sanitized auth metadata returned for a Credential Vault binding. + */ +class CredentialAuthMetadata( + val type: String, + val name: String?, +) + +/** + * Sanitized binding metadata returned by Credential Vault. + */ +class CredentialBindingMetadata( + val name: String, + val revision: Int, + val match: CredentialMatch?, + val auth: CredentialAuthMetadata?, +) + +/** + * Sanitized Credential Vault state. + */ +class CredentialVaultState( + val revision: Int, + val credentials: List, + val bindings: List, +) + +/** + * Sanitized Credential Vault credential list response. + */ +class CredentialListResponse( + val revision: Int, + val credentials: List, +) + +/** + * Sanitized Credential Vault binding list response. + */ +class CredentialBindingListResponse( + val revision: Int, + val bindings: List, +) diff --git a/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/domain/pool/PoolCreationSpec.kt b/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/domain/pool/PoolCreationSpec.kt index cce0270c9..1fa362fc3 100644 --- a/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/domain/pool/PoolCreationSpec.kt +++ b/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/domain/pool/PoolCreationSpec.kt @@ -17,6 +17,7 @@ package com.alibaba.opensandbox.sandbox.domain.pool import com.alibaba.opensandbox.sandbox.Sandbox +import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.CredentialProxyConfig import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.NetworkPolicy import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.PlatformSpec import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.SandboxImageSpec @@ -35,6 +36,7 @@ import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.Volume * @property metadata User-defined metadata. * @property extensions Optional extension parameters for server-side customized behaviors. * @property networkPolicy Optional outbound network policy. + * @property credentialProxy Optional Credential Vault proxy startup settings. * @property platform Optional runtime platform constraint. * @property secureAccess Whether to enable secured access for sandbox endpoints. * @property volumes Optional volume mounts. @@ -47,6 +49,7 @@ class PoolCreationSpec private constructor( val metadata: Map = emptyMap(), val extensions: Map = emptyMap(), val networkPolicy: NetworkPolicy? = null, + val credentialProxy: CredentialProxyConfig? = null, val platform: PlatformSpec? = null, val secureAccess: Boolean = false, val volumes: List? = null, @@ -74,6 +77,7 @@ class PoolCreationSpec private constructor( private var metadata: Map = emptyMap() private var extensions: Map = emptyMap() private var networkPolicy: NetworkPolicy? = null + private var credentialProxy: CredentialProxyConfig? = null private var platform: PlatformSpec? = null private var secureAccess: Boolean = false private var volumes: List? = null @@ -185,6 +189,24 @@ class PoolCreationSpec private constructor( return this } + fun credentialProxy(credentialProxy: CredentialProxyConfig?): Builder { + this.credentialProxy = credentialProxy + return this + } + + @JvmOverloads + fun credentialProxyEnabled(enabled: Boolean = true): Builder { + this.credentialProxy = CredentialProxyConfig.builder().enabled(enabled).build() + return this + } + + fun credentialProxy(configure: CredentialProxyConfig.Builder.() -> Unit): Builder { + val builder = CredentialProxyConfig.builder() + builder.configure() + this.credentialProxy = builder.build() + return this + } + fun platform(platform: PlatformSpec?): Builder { this.platform = platform return this @@ -229,6 +251,7 @@ class PoolCreationSpec private constructor( metadata = metadata, extensions = extensions, networkPolicy = networkPolicy, + credentialProxy = credentialProxy, platform = platform, secureAccess = secureAccess, volumes = volumes, diff --git a/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/domain/services/Egress.kt b/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/domain/services/Egress.kt index dc0b44b37..d7aac8feb 100644 --- a/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/domain/services/Egress.kt +++ b/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/domain/services/Egress.kt @@ -16,9 +16,60 @@ package com.alibaba.opensandbox.sandbox.domain.services +import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.Credential +import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.CredentialBinding +import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.CredentialBindingMetadata +import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.CredentialBindingMutationSet +import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.CredentialMetadata +import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.CredentialMutationSet +import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.CredentialVaultCreateRequest +import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.CredentialVaultPatchRequest +import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.CredentialVaultState import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.NetworkPolicy import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.NetworkRule +interface CredentialVault { + fun create(request: CredentialVaultCreateRequest): CredentialVaultState + + fun create( + credentials: List, + bindings: List, + ): CredentialVaultState = + create( + CredentialVaultCreateRequest.builder() + .credentials(credentials) + .bindings(bindings) + .build(), + ) + + fun get(): CredentialVaultState + + fun patch(request: CredentialVaultPatchRequest): CredentialVaultState + + fun patch( + expectedRevision: Int? = null, + credentials: CredentialMutationSet? = null, + bindings: CredentialBindingMutationSet? = null, + ): CredentialVaultState = + patch( + CredentialVaultPatchRequest.builder() + .expectedRevision(expectedRevision) + .credentials(credentials) + .bindings(bindings) + .build(), + ) + + fun delete() + + fun listCredentials(): List + + fun getCredential(name: String): CredentialMetadata + + fun listBindings(): List + + fun getBinding(name: String): CredentialBindingMetadata +} + interface Egress { fun getPolicy(): NetworkPolicy diff --git a/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/domain/services/Sandboxes.kt b/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/domain/services/Sandboxes.kt index 480624fef..1db9f8b73 100644 --- a/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/domain/services/Sandboxes.kt +++ b/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/domain/services/Sandboxes.kt @@ -16,6 +16,7 @@ package com.alibaba.opensandbox.sandbox.domain.services +import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.CredentialProxyConfig import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.NetworkPolicy import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.PagedSandboxInfos import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.PagedSnapshotInfos @@ -71,6 +72,45 @@ interface Sandboxes { snapshotId: String? = null, ): SandboxCreateResponse + /** + * Creates a sandbox with optional Credential Vault proxy startup settings. + */ + fun createSandbox( + spec: SandboxImageSpec?, + entrypoint: List?, + env: Map, + metadata: Map, + timeout: Duration?, + resource: Map, + networkPolicy: NetworkPolicy?, + extensions: Map, + volumes: List?, + platform: PlatformSpec? = null, + secureAccess: Boolean = false, + snapshotId: String? = null, + credentialProxy: CredentialProxyConfig?, + ): SandboxCreateResponse { + if (credentialProxy == null) { + return createSandbox( + spec = spec, + entrypoint = entrypoint, + env = env, + metadata = metadata, + timeout = timeout, + resource = resource, + networkPolicy = networkPolicy, + extensions = extensions, + volumes = volumes, + platform = platform, + secureAccess = secureAccess, + snapshotId = snapshotId, + ) + } + throw UnsupportedOperationException( + "Credential Vault proxy is not supported by this Sandboxes implementation", + ) + } + /** * Retrieves information about an existing sandbox. * diff --git a/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/infrastructure/adapters/converter/SandboxModelConverter.kt b/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/infrastructure/adapters/converter/SandboxModelConverter.kt index d6b99a3e5..93a7b434b 100644 --- a/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/infrastructure/adapters/converter/SandboxModelConverter.kt +++ b/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/infrastructure/adapters/converter/SandboxModelConverter.kt @@ -28,6 +28,7 @@ import com.alibaba.opensandbox.sandbox.api.models.RenewSandboxExpirationRequest import com.alibaba.opensandbox.sandbox.api.models.RenewSandboxExpirationResponse import com.alibaba.opensandbox.sandbox.api.models.Snapshot import com.alibaba.opensandbox.sandbox.api.models.execd.Metrics +import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.CredentialProxyConfig import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.Host import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.NetworkPolicy import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.NetworkRule @@ -49,6 +50,7 @@ import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.SnapshotStatus import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.Volume import java.time.Duration import java.time.OffsetDateTime +import com.alibaba.opensandbox.sandbox.api.models.CredentialProxyConfig as ApiCredentialProxyConfig import com.alibaba.opensandbox.sandbox.api.models.Host as ApiHost import com.alibaba.opensandbox.sandbox.api.models.NetworkPolicy as ApiNetworkPolicy import com.alibaba.opensandbox.sandbox.api.models.NetworkRule as ApiNetworkRule @@ -187,6 +189,10 @@ internal object SandboxModelConverter { .build() } + fun CredentialProxyConfig.toApiCredentialProxyConfig(): ApiCredentialProxyConfig { + return ApiCredentialProxyConfig(enabled = this.enabled) + } + /** * Converts Domain Host -> API Host */ @@ -251,6 +257,7 @@ internal object SandboxModelConverter { resource: Map, platform: PlatformSpec?, networkPolicy: NetworkPolicy?, + credentialProxy: CredentialProxyConfig?, secureAccess: Boolean, extensions: Map, volumes: List?, @@ -266,6 +273,7 @@ internal object SandboxModelConverter { resourceLimits = resource, platform = platform?.toApiPlatformSpec(), networkPolicy = networkPolicy?.toApiNetworkPolicy(), + credentialProxy = credentialProxy?.toApiCredentialProxyConfig(), secureAccess = secureAccess, extensions = extensions, volumes = volumes?.map { it.toApiVolume() }, diff --git a/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/infrastructure/adapters/service/EgressAdapter.kt b/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/infrastructure/adapters/service/EgressAdapter.kt index dc4460065..173da5c27 100644 --- a/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/infrastructure/adapters/service/EgressAdapter.kt +++ b/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/infrastructure/adapters/service/EgressAdapter.kt @@ -18,34 +18,195 @@ package com.alibaba.opensandbox.sandbox.infrastructure.adapters.service import com.alibaba.opensandbox.sandbox.HttpClientProvider import com.alibaba.opensandbox.sandbox.api.egress.PolicyApi +import com.alibaba.opensandbox.sandbox.domain.exceptions.SandboxApiException +import com.alibaba.opensandbox.sandbox.domain.exceptions.SandboxError +import com.alibaba.opensandbox.sandbox.domain.exceptions.SandboxError.Companion.UNEXPECTED_RESPONSE +import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.Credential +import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.CredentialAuth +import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.CredentialAuthMetadata +import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.CredentialBinding +import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.CredentialBindingListResponse +import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.CredentialBindingMetadata +import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.CredentialBindingMutationSet +import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.CredentialListResponse +import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.CredentialMatch +import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.CredentialMetadata +import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.CredentialMutationSet +import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.CredentialVaultCreateRequest +import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.CredentialVaultPatchRequest +import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.CredentialVaultState +import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.CustomHeaderEntry +import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.InlineCredentialSource import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.NetworkPolicy import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.NetworkRule import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.SandboxEndpoint +import com.alibaba.opensandbox.sandbox.domain.services.CredentialVault import com.alibaba.opensandbox.sandbox.domain.services.Egress import com.alibaba.opensandbox.sandbox.infrastructure.adapters.converter.SandboxModelConverter.toApiEgressNetworkRule import com.alibaba.opensandbox.sandbox.infrastructure.adapters.converter.SandboxModelConverter.toDomainEgressNetworkPolicy +import com.alibaba.opensandbox.sandbox.infrastructure.adapters.converter.jsonParser +import com.alibaba.opensandbox.sandbox.infrastructure.adapters.converter.parseSandboxError import com.alibaba.opensandbox.sandbox.infrastructure.adapters.converter.toSandboxException +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.int +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody import org.slf4j.LoggerFactory internal class EgressAdapter( private val httpClientProvider: HttpClientProvider, private val egressEndpoint: SandboxEndpoint, -) : Egress { +) : Egress, CredentialVault { + companion object { + private val JSON_MEDIA_TYPE = "application/json".toMediaType() + } + private val logger = LoggerFactory.getLogger(EgressAdapter::class.java) + private val egressBaseUrl = "${httpClientProvider.config.protocol}://${egressEndpoint.endpoint}" + private val egressClient = + httpClientProvider.httpClient.newBuilder() + .addInterceptor { chain -> + val requestBuilder = chain.request().newBuilder() + egressEndpoint.headers.forEach { (key, value) -> + requestBuilder.header(key, value) + } + chain.proceed(requestBuilder.build()) + } + .build() private val api = PolicyApi( - "${httpClientProvider.config.protocol}://${egressEndpoint.endpoint}", - httpClientProvider.httpClient.newBuilder() - .addInterceptor { chain -> - val requestBuilder = chain.request().newBuilder() - egressEndpoint.headers.forEach { (key, value) -> - requestBuilder.header(key, value) - } - chain.proceed(requestBuilder.build()) - } - .build(), + egressBaseUrl, + egressClient, ) + override fun create(request: CredentialVaultCreateRequest): CredentialVaultState { + return try { + val responseBody = + requestJson( + method = "POST", + operation = "Create credential vault", + jsonBody = request.toJsonObject(), + ) ?: throw IllegalStateException("Credential Vault create response did not contain body") + responseBody.toCredentialVaultState() + } catch (e: Exception) { + logger.error("Failed to create credential vault via endpoint {}", egressEndpoint.endpoint, e) + throw e.toSandboxException() + } + } + + override fun get(): CredentialVaultState { + return try { + val responseBody = + requestJson( + method = "GET", + operation = "Get credential vault", + ) ?: throw IllegalStateException("Credential Vault get response did not contain body") + responseBody.toCredentialVaultState() + } catch (e: Exception) { + logger.error("Failed to get credential vault via endpoint {}", egressEndpoint.endpoint, e) + throw e.toSandboxException() + } + } + + override fun patch(request: CredentialVaultPatchRequest): CredentialVaultState { + return try { + val responseBody = + requestJson( + method = "PATCH", + operation = "Patch credential vault", + jsonBody = request.toJsonObject(), + ) ?: throw IllegalStateException("Credential Vault patch response did not contain body") + responseBody.toCredentialVaultState() + } catch (e: Exception) { + logger.error("Failed to patch credential vault via endpoint {}", egressEndpoint.endpoint, e) + throw e.toSandboxException() + } + } + + override fun delete() { + try { + requestJson( + method = "DELETE", + operation = "Delete credential vault", + ) + } catch (e: Exception) { + logger.error("Failed to delete credential vault via endpoint {}", egressEndpoint.endpoint, e) + throw e.toSandboxException() + } + } + + override fun listCredentials(): List { + return try { + val responseBody = + requestJson( + method = "GET", + operation = "List credential vault credentials", + pathSegments = listOf("credentials"), + ) ?: throw IllegalStateException("Credential Vault credentials response did not contain body") + responseBody.toCredentialListResponse().credentials + } catch (e: Exception) { + logger.error("Failed to list credential vault credentials via endpoint {}", egressEndpoint.endpoint, e) + throw e.toSandboxException() + } + } + + override fun getCredential(name: String): CredentialMetadata { + return try { + val responseBody = + requestJson( + method = "GET", + operation = "Get credential vault credential", + pathSegments = listOf("credentials", name), + ) ?: throw IllegalStateException("Credential Vault credential response did not contain body") + responseBody.toCredentialMetadata() + } catch (e: Exception) { + logger.error("Failed to get credential vault credential via endpoint {}", egressEndpoint.endpoint, e) + throw e.toSandboxException() + } + } + + override fun listBindings(): List { + return try { + val responseBody = + requestJson( + method = "GET", + operation = "List credential vault bindings", + pathSegments = listOf("bindings"), + ) ?: throw IllegalStateException("Credential Vault bindings response did not contain body") + responseBody.toCredentialBindingListResponse().bindings + } catch (e: Exception) { + logger.error("Failed to list credential vault bindings via endpoint {}", egressEndpoint.endpoint, e) + throw e.toSandboxException() + } + } + + override fun getBinding(name: String): CredentialBindingMetadata { + return try { + val responseBody = + requestJson( + method = "GET", + operation = "Get credential vault binding", + pathSegments = listOf("bindings", name), + ) ?: throw IllegalStateException("Credential Vault binding response did not contain body") + responseBody.toCredentialBindingMetadata() + } catch (e: Exception) { + logger.error("Failed to get credential vault binding via endpoint {}", egressEndpoint.endpoint, e) + throw e.toSandboxException() + } + } + override fun getPolicy(): NetworkPolicy { return try { val policy = @@ -75,4 +236,238 @@ internal class EgressAdapter( throw e.toSandboxException() } } + + private fun requestJson( + method: String, + operation: String, + pathSegments: List = emptyList(), + jsonBody: JsonObject? = null, + ): String? { + val requestBuilder = + Request.Builder() + .url(credentialVaultUrl(pathSegments)) + .header("Accept", "application/json") + + when (method) { + "GET" -> requestBuilder.get() + "DELETE" -> requestBuilder.delete() + else -> + requestBuilder.method( + method, + (jsonBody ?: buildJsonObject { }).toString().toRequestBody(JSON_MEDIA_TYPE), + ) + } + + egressClient.newCall(requestBuilder.build()).execute().use { response -> + val responseBody = response.body?.string().orEmpty() + if (response.isSuccessful) { + return if (response.code == 204 || responseBody.isBlank()) null else responseBody + } + throw SandboxApiException( + message = "$operation failed. Status code: ${response.code}, Body: $responseBody", + statusCode = response.code, + error = parseSandboxError(responseBody) ?: SandboxError(UNEXPECTED_RESPONSE, responseBody.takeIf { it.isNotBlank() }), + requestId = response.header("X-Request-ID"), + ) + } + } + + private fun credentialVaultUrl(pathSegments: List): HttpUrl { + val builder = egressBaseUrl.toHttpUrl().newBuilder().addPathSegment("credential-vault") + pathSegments.forEach { builder.addPathSegment(it) } + return builder.build() + } + + private fun CredentialVaultCreateRequest.toJsonObject(): JsonObject = + buildJsonObject { + put("credentials", credentials.toCredentialJsonArray()) + put("bindings", bindings.toBindingJsonArray()) + } + + private fun CredentialVaultPatchRequest.toJsonObject(): JsonObject = + buildJsonObject { + expectedRevision?.let { put("expectedRevision", JsonPrimitive(it)) } + credentials?.let { put("credentials", it.toJsonObject()) } + bindings?.let { put("bindings", it.toJsonObject()) } + } + + private fun CredentialMutationSet.toJsonObject(): JsonObject = + buildJsonObject { + add?.let { put("add", it.toCredentialJsonArray()) } + replace?.let { put("replace", it.toCredentialJsonArray()) } + delete?.let { put("delete", it.toStringJsonArray()) } + } + + private fun CredentialBindingMutationSet.toJsonObject(): JsonObject = + buildJsonObject { + add?.let { put("add", it.toBindingJsonArray()) } + replace?.let { put("replace", it.toBindingJsonArray()) } + delete?.let { put("delete", it.toStringJsonArray()) } + } + + private fun List.toCredentialJsonArray(): JsonArray = JsonArray(map { it.toJsonObject() }) + + private fun Credential.toJsonObject(): JsonObject = + buildJsonObject { + put("name", JsonPrimitive(name)) + put("source", source.toJsonObject()) + } + + private fun InlineCredentialSource.toJsonObject(): JsonObject = + buildJsonObject { + put("type", JsonPrimitive(type)) + put("value", JsonPrimitive(value)) + } + + private fun List.toBindingJsonArray(): JsonArray = JsonArray(map { it.toJsonObject() }) + + private fun CredentialBinding.toJsonObject(): JsonObject = + buildJsonObject { + put("name", JsonPrimitive(name)) + put("match", match.toJsonObject()) + put("auth", auth.toJsonObject()) + } + + private fun CredentialMatch.toJsonObject(): JsonObject = + buildJsonObject { + schemes?.let { put("schemes", it.map { scheme -> scheme.wireName() }.toStringJsonArray()) } + ports?.let { put("ports", JsonArray(it.map { port -> JsonPrimitive(port) })) } + put("hosts", hosts.toStringJsonArray()) + methods?.let { put("methods", it.toStringJsonArray()) } + paths?.let { put("paths", it.toStringJsonArray()) } + } + + private fun CredentialAuth.toJsonObject(): JsonObject = + buildJsonObject { + put("type", JsonPrimitive(type.wireName())) + when (type) { + CredentialAuth.Type.BEARER, CredentialAuth.Type.BASIC -> { + put("credential", JsonPrimitive(credential ?: throw IllegalStateException("Credential auth credential missing"))) + } + CredentialAuth.Type.API_KEY -> { + put("name", JsonPrimitive(name ?: throw IllegalStateException("Credential auth name missing"))) + put("credential", JsonPrimitive(credential ?: throw IllegalStateException("Credential auth credential missing"))) + } + CredentialAuth.Type.CUSTOM_HEADERS -> { + put("headers", JsonArray(headers.orEmpty().map { it.toJsonObject() })) + } + } + } + + private fun CustomHeaderEntry.toJsonObject(): JsonObject = + buildJsonObject { + put("name", JsonPrimitive(name)) + put("credential", JsonPrimitive(credential)) + } + + private fun List.toStringJsonArray(): JsonArray = JsonArray(map { JsonPrimitive(it) }) + + private fun CredentialMatch.Scheme.wireName(): String = + when (this) { + CredentialMatch.Scheme.HTTPS -> "https" + CredentialMatch.Scheme.HTTP -> "http" + } + + private fun CredentialAuth.Type.wireName(): String = + when (this) { + CredentialAuth.Type.BEARER -> "bearer" + CredentialAuth.Type.BASIC -> "basic" + CredentialAuth.Type.API_KEY -> "apiKey" + CredentialAuth.Type.CUSTOM_HEADERS -> "customHeaders" + } + + private fun String.toCredentialVaultState(): CredentialVaultState { + val root = jsonParser.parseToJsonElement(this).jsonObject + return CredentialVaultState( + revision = root.requiredInt("revision"), + credentials = root.requiredArray("credentials").map { it.jsonObject.toCredentialMetadata() }, + bindings = root.requiredArray("bindings").map { it.jsonObject.toCredentialBindingMetadata() }, + ) + } + + private fun String.toCredentialListResponse(): CredentialListResponse { + val root = jsonParser.parseToJsonElement(this).jsonObject + return CredentialListResponse( + revision = root.requiredInt("revision"), + credentials = root.requiredArray("credentials").map { it.jsonObject.toCredentialMetadata() }, + ) + } + + private fun String.toCredentialBindingListResponse(): CredentialBindingListResponse { + val root = jsonParser.parseToJsonElement(this).jsonObject + return CredentialBindingListResponse( + revision = root.requiredInt("revision"), + bindings = root.requiredArray("bindings").map { it.jsonObject.toCredentialBindingMetadata() }, + ) + } + + private fun String.toCredentialMetadata(): CredentialMetadata = jsonParser.parseToJsonElement(this).jsonObject.toCredentialMetadata() + + private fun String.toCredentialBindingMetadata(): CredentialBindingMetadata = + jsonParser.parseToJsonElement(this).jsonObject.toCredentialBindingMetadata() + + private fun JsonObject.toCredentialMetadata(): CredentialMetadata = + CredentialMetadata( + name = requiredString("name"), + sourceType = requiredString("sourceType"), + revision = requiredInt("revision"), + ) + + private fun JsonObject.toCredentialBindingMetadata(): CredentialBindingMetadata = + CredentialBindingMetadata( + name = requiredString("name"), + revision = requiredInt("revision"), + match = optionalObject("match")?.toCredentialMatch(), + auth = optionalObject("auth")?.toCredentialAuthMetadata(), + ) + + private fun JsonObject.toCredentialMatch(): CredentialMatch { + val builder = CredentialMatch.builder().hosts(requiredStringArray("hosts")) + optionalStringArray("schemes")?.let { schemes -> + builder.schemes(schemes.map { it.toCredentialMatchScheme() }) + } + optionalIntArray("ports")?.let { builder.ports(it) } + optionalStringArray("methods")?.let { builder.methods(it) } + optionalStringArray("paths")?.let { builder.paths(it) } + return builder.build() + } + + private fun JsonObject.toCredentialAuthMetadata(): CredentialAuthMetadata = + CredentialAuthMetadata( + type = requiredString("type"), + name = optionalString("name"), + ) + + private fun String.toCredentialMatchScheme(): CredentialMatch.Scheme = + when (lowercase()) { + "https" -> CredentialMatch.Scheme.HTTPS + "http" -> CredentialMatch.Scheme.HTTP + else -> throw IllegalStateException("Unsupported Credential Match scheme: $this") + } + + private fun JsonObject.requiredString(name: String): String = + get(name)?.jsonPrimitive?.content + ?: throw IllegalStateException("Credential Vault response missing required string field: $name") + + private fun JsonObject.optionalString(name: String): String? = get(name)?.takeUnless { it is JsonNull }?.jsonPrimitive?.contentOrNull + + private fun JsonObject.requiredInt(name: String): Int = + get(name)?.jsonPrimitive?.int + ?: throw IllegalStateException("Credential Vault response missing required integer field: $name") + + private fun JsonObject.requiredArray(name: String): JsonArray = + get(name)?.jsonArray + ?: throw IllegalStateException("Credential Vault response missing required array field: $name") + + private fun JsonObject.optionalObject(name: String): JsonObject? = get(name)?.takeUnless { it is JsonNull }?.jsonObject + + private fun JsonObject.optionalArray(name: String): JsonArray? = get(name)?.takeUnless { it is JsonNull }?.jsonArray + + private fun JsonObject.requiredStringArray(name: String): List = requiredArray(name).map { it.requiredStringValue() } + + private fun JsonObject.optionalStringArray(name: String): List? = optionalArray(name)?.map { it.requiredStringValue() } + + private fun JsonObject.optionalIntArray(name: String): List? = optionalArray(name)?.map { it.jsonPrimitive.int } + + private fun JsonElement.requiredStringValue(): String = jsonPrimitive.content } diff --git a/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/infrastructure/adapters/service/SandboxesAdapter.kt b/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/infrastructure/adapters/service/SandboxesAdapter.kt index b1b4dcf71..621c53a6a 100644 --- a/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/infrastructure/adapters/service/SandboxesAdapter.kt +++ b/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/infrastructure/adapters/service/SandboxesAdapter.kt @@ -23,6 +23,7 @@ import com.alibaba.opensandbox.sandbox.api.infrastructure.Serializer import com.alibaba.opensandbox.sandbox.domain.exceptions.SandboxApiException import com.alibaba.opensandbox.sandbox.domain.exceptions.SandboxError import com.alibaba.opensandbox.sandbox.domain.exceptions.SandboxError.Companion.UNEXPECTED_RESPONSE +import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.CredentialProxyConfig import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.NetworkPolicy import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.PagedSandboxInfos import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.PagedSnapshotInfos @@ -86,6 +87,37 @@ internal class SandboxesAdapter( platform: PlatformSpec?, secureAccess: Boolean, snapshotId: String?, + ): SandboxCreateResponse = + createSandbox( + spec = spec, + entrypoint = entrypoint, + env = env, + metadata = metadata, + timeout = timeout, + resource = resource, + networkPolicy = networkPolicy, + extensions = extensions, + volumes = volumes, + platform = platform, + secureAccess = secureAccess, + snapshotId = snapshotId, + credentialProxy = null, + ) + + override fun createSandbox( + spec: SandboxImageSpec?, + entrypoint: List?, + env: Map, + metadata: Map, + timeout: Duration?, + resource: Map, + networkPolicy: NetworkPolicy?, + extensions: Map, + volumes: List?, + platform: PlatformSpec?, + secureAccess: Boolean, + snapshotId: String?, + credentialProxy: CredentialProxyConfig?, ): SandboxCreateResponse { logger.info("Creating sandbox with startup source: {}", spec?.image ?: snapshotId) @@ -100,6 +132,7 @@ internal class SandboxesAdapter( resource = resource, platform = platform, networkPolicy = networkPolicy, + credentialProxy = credentialProxy, secureAccess = secureAccess, extensions = extensions, volumes = volumes, diff --git a/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/infrastructure/factory/AdapterFactory.kt b/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/infrastructure/factory/AdapterFactory.kt index 5fb387216..9cc52f410 100644 --- a/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/infrastructure/factory/AdapterFactory.kt +++ b/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/infrastructure/factory/AdapterFactory.kt @@ -19,6 +19,7 @@ package com.alibaba.opensandbox.sandbox.infrastructure.factory import com.alibaba.opensandbox.sandbox.HttpClientProvider import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.SandboxEndpoint import com.alibaba.opensandbox.sandbox.domain.services.Commands +import com.alibaba.opensandbox.sandbox.domain.services.CredentialVault import com.alibaba.opensandbox.sandbox.domain.services.Diagnostics import com.alibaba.opensandbox.sandbox.domain.services.Egress import com.alibaba.opensandbox.sandbox.domain.services.Filesystem @@ -42,6 +43,11 @@ import com.alibaba.opensandbox.sandbox.infrastructure.adapters.service.Sandboxes internal class AdapterFactory( private val httpClientProvider: HttpClientProvider, ) { + data class EgressStack( + val egress: Egress, + val credentialVault: CredentialVault, + ) + fun createSandboxes(): Sandboxes { return SandboxesAdapter(httpClientProvider) } @@ -58,8 +64,12 @@ internal class AdapterFactory( return CommandsAdapter(httpClientProvider, endpoint) } - fun createEgress(endpoint: SandboxEndpoint): Egress { - return EgressAdapter(httpClientProvider, endpoint) + fun createEgressStack(endpoint: SandboxEndpoint): EgressStack { + val adapter = EgressAdapter(httpClientProvider, endpoint) + return EgressStack( + egress = adapter, + credentialVault = adapter, + ) } fun createMetrics(endpoint: SandboxEndpoint): Metrics { diff --git a/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/pool/SandboxPool.kt b/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/pool/SandboxPool.kt index f7a8cd784..a36ef802e 100644 --- a/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/pool/SandboxPool.kt +++ b/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/pool/SandboxPool.kt @@ -878,6 +878,7 @@ internal fun PoolCreationSpec.applyToBuilder(builder: Sandbox.Builder): Sandbox. .secureAccess(secureAccess) networkPolicy?.let { configuredBuilder.networkPolicy(it) } + credentialProxy?.let { configuredBuilder.credentialProxy(it) } platform?.let { configuredBuilder.platform(it) } return configuredBuilder } diff --git a/sdks/sandbox/kotlin/sandbox/src/test/kotlin/com/alibaba/opensandbox/sandbox/SandboxTest.kt b/sdks/sandbox/kotlin/sandbox/src/test/kotlin/com/alibaba/opensandbox/sandbox/SandboxTest.kt index 295f92e15..683723496 100644 --- a/sdks/sandbox/kotlin/sandbox/src/test/kotlin/com/alibaba/opensandbox/sandbox/SandboxTest.kt +++ b/sdks/sandbox/kotlin/sandbox/src/test/kotlin/com/alibaba/opensandbox/sandbox/SandboxTest.kt @@ -26,6 +26,7 @@ import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.SandboxInfo import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.SandboxMetrics import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.SandboxRenewResponse import com.alibaba.opensandbox.sandbox.domain.services.Commands +import com.alibaba.opensandbox.sandbox.domain.services.CredentialVault import com.alibaba.opensandbox.sandbox.domain.services.Diagnostics import com.alibaba.opensandbox.sandbox.domain.services.Egress import com.alibaba.opensandbox.sandbox.domain.services.Filesystem @@ -69,6 +70,9 @@ class SandboxTest { @MockK lateinit var egressService: Egress + @MockK + lateinit var credentialVaultService: CredentialVault + @MockK lateinit var diagnosticsService: Diagnostics @@ -97,6 +101,7 @@ class SandboxTest { healthService = healthService, metricsService = metricsService, egressService = egressService, + credentialVaultService = credentialVaultService, customHealthCheck = null, httpClientProvider = httpClientProvider, diagnosticsService = diagnosticsService, @@ -118,6 +123,11 @@ class SandboxTest { assertSame(metricsService, sandbox.metrics()) } + @Test + fun `credentialVault should return credential vault service`() { + assertSame(credentialVaultService, sandbox.credentialVault()) + } + @Test fun `diagnostics should return diagnostics service`() { assertSame(diagnosticsService, sandbox.diagnostics()) diff --git a/sdks/sandbox/kotlin/sandbox/src/test/kotlin/com/alibaba/opensandbox/sandbox/domain/services/SandboxesCompatibilityTest.kt b/sdks/sandbox/kotlin/sandbox/src/test/kotlin/com/alibaba/opensandbox/sandbox/domain/services/SandboxesCompatibilityTest.kt new file mode 100644 index 000000000..fa91ef436 --- /dev/null +++ b/sdks/sandbox/kotlin/sandbox/src/test/kotlin/com/alibaba/opensandbox/sandbox/domain/services/SandboxesCompatibilityTest.kt @@ -0,0 +1,170 @@ +/* + * Copyright 2026 Alibaba Group Holding Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.alibaba.opensandbox.sandbox.domain.services + +import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.CredentialProxyConfig +import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.NetworkPolicy +import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.NetworkRule +import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.PagedSandboxInfos +import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.PagedSnapshotInfos +import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.PlatformSpec +import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.SandboxCreateResponse +import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.SandboxEndpoint +import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.SandboxFilter +import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.SandboxImageSpec +import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.SandboxInfo +import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.SandboxRenewResponse +import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.SnapshotFilter +import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.SnapshotInfo +import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.Volume +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import java.time.Duration +import java.time.OffsetDateTime + +class SandboxesCompatibilityTest { + @Test + fun `custom implementation can implement previous createSandbox signature`() { + val sandboxes = LegacySandboxes() + + val response = + sandboxes.createSandbox( + spec = null, + entrypoint = null, + env = emptyMap(), + metadata = emptyMap(), + timeout = null, + resource = emptyMap(), + networkPolicy = null, + extensions = emptyMap(), + volumes = null, + credentialProxy = null, + ) + + assertEquals("legacy-sandbox", response.id) + } + + @Test + fun `default credential proxy overload rejects unsupported non-null config`() { + val sandboxes = LegacySandboxes() + + val error = + assertThrows(UnsupportedOperationException::class.java) { + sandboxes.createSandbox( + spec = null, + entrypoint = null, + env = emptyMap(), + metadata = emptyMap(), + timeout = null, + resource = emptyMap(), + networkPolicy = null, + extensions = emptyMap(), + volumes = null, + credentialProxy = CredentialProxyConfig.enabled(), + ) + } + + assertTrue(error.message!!.contains("Credential Vault proxy is not supported")) + } + + @Test + fun `custom egress implementation can stay policy only`() { + val egress: Egress = PolicyOnlyEgress() + + assertEquals(NetworkPolicy.DefaultAction.DENY, egress.getPolicy().defaultAction) + } + + private class LegacySandboxes : Sandboxes { + override fun createSandbox( + spec: SandboxImageSpec?, + entrypoint: List?, + env: Map, + metadata: Map, + timeout: Duration?, + resource: Map, + networkPolicy: NetworkPolicy?, + extensions: Map, + volumes: List?, + platform: PlatformSpec?, + secureAccess: Boolean, + snapshotId: String?, + ): SandboxCreateResponse = SandboxCreateResponse("legacy-sandbox") + + override fun getSandboxInfo(sandboxId: String): SandboxInfo = unsupported() + + override fun listSandboxes(filter: SandboxFilter): PagedSandboxInfos = unsupported() + + override fun patchSandboxMetadata( + sandboxId: String, + patch: Map, + ): SandboxInfo = unsupported() + + override fun renewSandboxExpiration( + sandboxId: String, + newExpirationTime: OffsetDateTime, + ): SandboxRenewResponse = unsupported() + + override fun createSnapshot( + sandboxId: String, + name: String?, + ): SnapshotInfo = unsupported() + + override fun getSnapshot(snapshotId: String): SnapshotInfo = unsupported() + + override fun listSnapshots(filter: SnapshotFilter): PagedSnapshotInfos = unsupported() + + override fun deleteSnapshot(snapshotId: String) = unsupported() + + override fun getSandboxEndpoint( + sandboxId: String, + port: Int, + ): SandboxEndpoint = unsupported() + + override fun getSandboxEndpoint( + sandboxId: String, + port: Int, + useServerProxy: Boolean, + ): SandboxEndpoint = unsupported() + + override fun getSignedSandboxEndpoint( + sandboxId: String, + port: Int, + expires: Long, + useServerProxy: Boolean, + ): SandboxEndpoint = unsupported() + + override fun pauseSandbox(sandboxId: String) = unsupported() + + override fun resumeSandbox(sandboxId: String) = unsupported() + + override fun killSandbox(sandboxId: String) = unsupported() + + private fun unsupported(): Nothing = throw UnsupportedOperationException("not used") + } + + private class PolicyOnlyEgress : Egress { + override fun getPolicy(): NetworkPolicy = NetworkPolicy.builder().build() + + override fun patchRules(rules: List) { + } + + override fun deleteRules(targets: List) { + } + } +} diff --git a/sdks/sandbox/kotlin/sandbox/src/test/kotlin/com/alibaba/opensandbox/sandbox/infrastructure/adapters/service/EgressAdapterTest.kt b/sdks/sandbox/kotlin/sandbox/src/test/kotlin/com/alibaba/opensandbox/sandbox/infrastructure/adapters/service/EgressAdapterTest.kt new file mode 100644 index 000000000..fed95d630 --- /dev/null +++ b/sdks/sandbox/kotlin/sandbox/src/test/kotlin/com/alibaba/opensandbox/sandbox/infrastructure/adapters/service/EgressAdapterTest.kt @@ -0,0 +1,360 @@ +/* + * Copyright 2025 Alibaba Group Holding Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.alibaba.opensandbox.sandbox.infrastructure.adapters.service + +import com.alibaba.opensandbox.sandbox.HttpClientProvider +import com.alibaba.opensandbox.sandbox.config.ConnectionConfig +import com.alibaba.opensandbox.sandbox.domain.exceptions.SandboxApiException +import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.Credential +import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.CredentialAuth +import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.CredentialBinding +import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.CredentialBindingMutationSet +import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.CredentialMatch +import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.CredentialMutationSet +import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.CredentialVaultPatchRequest +import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.CustomHeaderEntry +import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.SandboxEndpoint +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +class EgressAdapterTest { + private lateinit var mockWebServer: MockWebServer + private lateinit var egressAdapter: EgressAdapter + private lateinit var httpClientProvider: HttpClientProvider + + @BeforeEach + fun setUp() { + mockWebServer = MockWebServer() + mockWebServer.start() + + val host = mockWebServer.hostName + val port = mockWebServer.port + val endpoint = + SandboxEndpoint( + endpoint = "$host:$port", + headers = mapOf("X-Egress-Token" to "route-token"), + ) + + val config = + ConnectionConfig.builder() + .domain("$host:$port") + .protocol("http") + .headers(mapOf("X-Client-Trace" to "trace-1")) + .build() + + httpClientProvider = HttpClientProvider(config) + egressAdapter = EgressAdapter(httpClientProvider, endpoint) + } + + @AfterEach + fun tearDown() { + mockWebServer.shutdown() + httpClientProvider.close() + } + + @Test + fun `create sends credential vault payload with endpoint headers and parses sanitized state`() { + mockWebServer.enqueue( + MockResponse() + .setResponseCode(201) + .setBody( + """ + { + "revision": 1, + "credentials": [ + {"name": "bearer-token", "sourceType": "inline", "revision": 1} + ], + "bindings": [ + { + "name": "github-api", + "revision": 1, + "match": { + "schemes": ["https"], + "ports": [443], + "hosts": ["api.github.com"], + "methods": ["GET"], + "paths": ["/repos/*"] + }, + "auth": {"type": "bearer"} + } + ] + } + """.trimIndent(), + ), + ) + + val match = + CredentialMatch.builder() + .schemes(CredentialMatch.Scheme.HTTPS) + .ports(443) + .hosts("api.github.com") + .methods("GET") + .paths("/repos/*") + .build() + val result = + egressAdapter.create( + credentials = + listOf( + credential("bearer-token"), + credential("basic-token"), + credential("api-key-token"), + credential("custom-header-token"), + ), + bindings = + listOf( + CredentialBinding.builder() + .name("github-api") + .match(match) + .auth(CredentialAuth.bearer("bearer-token")) + .build(), + CredentialBinding.builder() + .name("basic-api") + .match(match) + .auth(CredentialAuth.basic("basic-token")) + .build(), + CredentialBinding.builder() + .name("api-key-api") + .match(match) + .auth(CredentialAuth.apiKey("X-Api-Key", "api-key-token")) + .build(), + CredentialBinding.builder() + .name("custom-header-api") + .match(match) + .auth( + CredentialAuth.customHeaders( + listOf( + CustomHeaderEntry.builder() + .name("X-Custom-Token") + .credential("custom-header-token") + .build(), + ), + ), + ) + .build(), + ), + ) + + val request = mockWebServer.takeRequest() + assertEquals("POST", request.method) + assertEquals("/credential-vault", request.path) + assertEquals("route-token", request.getHeader("X-Egress-Token")) + assertEquals("trace-1", request.getHeader("X-Client-Trace")) + + val payload = Json.parseToJsonElement(request.body.readUtf8()).jsonObject + val credentials = payload["credentials"]!!.jsonArray + assertEquals(4, credentials.size) + val firstCredential = credentials[0].jsonObject + assertEquals("bearer-token", firstCredential["name"]!!.jsonPrimitive.content) + assertEquals("inline", firstCredential["source"]!!.jsonObject["type"]!!.jsonPrimitive.content) + assertEquals("dummy-bearer-token", firstCredential["source"]!!.jsonObject["value"]!!.jsonPrimitive.content) + + val bindings = payload["bindings"]!!.jsonArray + assertEquals("bearer", bindings[0].jsonObject["auth"]!!.jsonObject["type"]!!.jsonPrimitive.content) + assertEquals("basic", bindings[1].jsonObject["auth"]!!.jsonObject["type"]!!.jsonPrimitive.content) + val apiKeyAuth = bindings[2].jsonObject["auth"]!!.jsonObject + assertEquals("apiKey", apiKeyAuth["type"]!!.jsonPrimitive.content) + assertEquals("X-Api-Key", apiKeyAuth["name"]!!.jsonPrimitive.content) + val customHeadersAuth = bindings[3].jsonObject["auth"]!!.jsonObject + assertEquals("customHeaders", customHeadersAuth["type"]!!.jsonPrimitive.content) + assertEquals( + "X-Custom-Token", + customHeadersAuth["headers"]!!.jsonArray[0].jsonObject["name"]!!.jsonPrimitive.content, + ) + + assertEquals(1, result.revision) + assertEquals("bearer-token", result.credentials.single().name) + assertEquals("inline", result.credentials.single().sourceType) + assertEquals("github-api", result.bindings.single().name) + assertEquals("bearer", result.bindings.single().auth?.type) + assertEquals(listOf("api.github.com"), result.bindings.single().match?.hosts) + } + + @Test + fun `patch sends expected revision and mutation sets`() { + mockWebServer.enqueue( + MockResponse() + .setResponseCode(200) + .setBody( + """ + { + "revision": 2, + "credentials": [ + {"name": "new-token", "sourceType": "inline", "revision": 2} + ], + "bindings": [] + } + """.trimIndent(), + ), + ) + + val patch = + CredentialVaultPatchRequest.builder() + .expectedRevision(1) + .credentials( + CredentialMutationSet.builder() + .add(listOf(credential("new-token"))) + .delete("old-token") + .build(), + ) + .bindings( + CredentialBindingMutationSet.builder() + .delete("old-binding") + .build(), + ) + .build() + + val result = egressAdapter.patch(patch) + + val request = mockWebServer.takeRequest() + assertEquals("PATCH", request.method) + assertEquals("/credential-vault", request.path) + val payload = Json.parseToJsonElement(request.body.readUtf8()).jsonObject + assertEquals("1", payload["expectedRevision"]!!.jsonPrimitive.content) + val credentialMutations = payload["credentials"]!!.jsonObject + assertEquals("new-token", credentialMutations["add"]!!.jsonArray[0].jsonObject["name"]!!.jsonPrimitive.content) + assertEquals("old-token", credentialMutations["delete"]!!.jsonArray[0].jsonPrimitive.content) + val bindingMutations = payload["bindings"]!!.jsonObject + assertEquals("old-binding", bindingMutations["delete"]!!.jsonArray[0].jsonPrimitive.content) + assertEquals(2, result.revision) + } + + @Test + fun `get list and delete use credential vault endpoints`() { + mockWebServer.enqueue(MockResponse().setBody(vaultStateResponse())) + mockWebServer.enqueue( + MockResponse() + .setBody( + """ + { + "revision": 3, + "credentials": [ + {"name": "token-one", "sourceType": "inline", "revision": 3} + ] + } + """.trimIndent(), + ), + ) + mockWebServer.enqueue( + MockResponse() + .setBody("""{"name": "token/with space", "sourceType": "inline", "revision": 3}"""), + ) + mockWebServer.enqueue( + MockResponse() + .setBody( + """ + { + "revision": 3, + "bindings": [ + {"name": "binding-one", "revision": 3, "auth": {"type": "apiKey", "name": "X-Api-Key"}} + ] + } + """.trimIndent(), + ), + ) + mockWebServer.enqueue( + MockResponse() + .setBody("""{"name": "binding/with space", "revision": 3, "auth": {"type": "basic"}}"""), + ) + mockWebServer.enqueue(MockResponse().setResponseCode(204)) + + val state = egressAdapter.get() + val credentials = egressAdapter.listCredentials() + val credential = egressAdapter.getCredential("token/with space") + val bindings = egressAdapter.listBindings() + val binding = egressAdapter.getBinding("binding/with space") + egressAdapter.delete() + + assertEquals(3, state.revision) + assertEquals("token-one", credentials.single().name) + assertEquals("token/with space", credential.name) + assertEquals("binding-one", bindings.single().name) + assertEquals("apiKey", bindings.single().auth?.type) + assertEquals("X-Api-Key", bindings.single().auth?.name) + assertEquals("binding/with space", binding.name) + + val getStateRequest = mockWebServer.takeRequest() + assertEquals("GET", getStateRequest.method) + assertEquals("/credential-vault", getStateRequest.path) + assertEquals("/credential-vault/credentials", mockWebServer.takeRequest().path) + assertEquals("/credential-vault/credentials/token%2Fwith%20space", mockWebServer.takeRequest().path) + assertEquals("/credential-vault/bindings", mockWebServer.takeRequest().path) + assertEquals("/credential-vault/bindings/binding%2Fwith%20space", mockWebServer.takeRequest().path) + val deleteRequest = mockWebServer.takeRequest() + assertEquals("DELETE", deleteRequest.method) + assertEquals("/credential-vault", deleteRequest.path) + } + + @Test + fun `get credential maps error response`() { + mockWebServer.enqueue( + MockResponse() + .setResponseCode(404) + .setHeader("X-Request-ID", "req-404") + .setBody("""{"code":"VAULT_NOT_FOUND","message":"missing"}"""), + ) + + val exception = + assertThrows(SandboxApiException::class.java) { + egressAdapter.getCredential("missing") + } + + assertEquals(404, exception.statusCode) + assertEquals("VAULT_NOT_FOUND", exception.error.code) + assertEquals("req-404", exception.requestId) + } + + @Test + fun `credential vault state does not expose credential values`() { + mockWebServer.enqueue(MockResponse().setBody(vaultStateResponse())) + + val state = egressAdapter.get() + + assertEquals(1, state.credentials.size) + assertFalse(state.credentials.single().javaClass.declaredFields.any { it.name == "value" }) + assertTrue(state.bindings.single().auth?.name == null) + } + + private fun credential(name: String): Credential = + Credential.builder() + .name(name) + .inlineSource("dummy-$name") + .build() + + private fun vaultStateResponse(): String = + """ + { + "revision": 3, + "credentials": [ + {"name": "token-one", "sourceType": "inline", "revision": 3} + ], + "bindings": [ + {"name": "binding-one", "revision": 3, "auth": {"type": "bearer"}} + ] + } + """.trimIndent() +} diff --git a/sdks/sandbox/kotlin/sandbox/src/test/kotlin/com/alibaba/opensandbox/sandbox/infrastructure/adapters/service/SandboxesAdapterTest.kt b/sdks/sandbox/kotlin/sandbox/src/test/kotlin/com/alibaba/opensandbox/sandbox/infrastructure/adapters/service/SandboxesAdapterTest.kt index 8a51a432c..06fd68417 100644 --- a/sdks/sandbox/kotlin/sandbox/src/test/kotlin/com/alibaba/opensandbox/sandbox/infrastructure/adapters/service/SandboxesAdapterTest.kt +++ b/sdks/sandbox/kotlin/sandbox/src/test/kotlin/com/alibaba/opensandbox/sandbox/infrastructure/adapters/service/SandboxesAdapterTest.kt @@ -19,6 +19,7 @@ package com.alibaba.opensandbox.sandbox.infrastructure.adapters.service import com.alibaba.opensandbox.sandbox.HttpClientProvider import com.alibaba.opensandbox.sandbox.config.ConnectionConfig import com.alibaba.opensandbox.sandbox.domain.exceptions.SandboxApiException +import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.CredentialProxyConfig import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.NetworkPolicy import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.NetworkRule import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.OSSFS @@ -119,6 +120,7 @@ class SandboxesAdapterTest { volumes = null, secureAccess = true, snapshotId = null, + credentialProxy = CredentialProxyConfig.enabled(), ) // Verify request @@ -147,6 +149,9 @@ class SandboxesAdapterTest { assertNotNull(gotPlatform, "platform should be present in createSandbox request") assertEquals("linux", gotPlatform!!["os"]!!.jsonPrimitive.content) assertEquals("arm64", gotPlatform["arch"]!!.jsonPrimitive.content) + val gotCredentialProxy = payload["credentialProxy"]?.jsonObject + assertNotNull(gotCredentialProxy, "credentialProxy should be present in createSandbox request") + assertEquals("true", gotCredentialProxy!!["enabled"]!!.jsonPrimitive.content) assertEquals("true", payload["secureAccess"]!!.jsonPrimitive.content) // Verify response @@ -187,6 +192,7 @@ class SandboxesAdapterTest { extensions = emptyMap(), volumes = null, secureAccess = false, + credentialProxy = null, ) val request = mockWebServer.takeRequest() @@ -227,6 +233,7 @@ class SandboxesAdapterTest { volumes = null, secureAccess = false, snapshotId = null, + credentialProxy = null, ) assertEquals("manual-sbx", result.id) @@ -262,6 +269,7 @@ class SandboxesAdapterTest { volumes = null, secureAccess = false, snapshotId = "snap-123", + credentialProxy = null, ) val request = mockWebServer.takeRequest() @@ -301,6 +309,7 @@ class SandboxesAdapterTest { volumes = null, secureAccess = false, snapshotId = "snap-123", + credentialProxy = null, ) val request = mockWebServer.takeRequest() @@ -509,6 +518,7 @@ class SandboxesAdapterTest { volumes = volumes, secureAccess = false, snapshotId = null, + credentialProxy = null, ) val request = mockWebServer.takeRequest() diff --git a/sdks/sandbox/kotlin/sandbox/src/test/kotlin/com/alibaba/opensandbox/sandbox/pool/SandboxPoolTest.kt b/sdks/sandbox/kotlin/sandbox/src/test/kotlin/com/alibaba/opensandbox/sandbox/pool/SandboxPoolTest.kt index a52452e53..b93c8a400 100644 --- a/sdks/sandbox/kotlin/sandbox/src/test/kotlin/com/alibaba/opensandbox/sandbox/pool/SandboxPoolTest.kt +++ b/sdks/sandbox/kotlin/sandbox/src/test/kotlin/com/alibaba/opensandbox/sandbox/pool/SandboxPoolTest.kt @@ -22,6 +22,7 @@ import com.alibaba.opensandbox.sandbox.config.ConnectionConfig import com.alibaba.opensandbox.sandbox.domain.exceptions.PoolAcquireFailedException import com.alibaba.opensandbox.sandbox.domain.exceptions.PoolEmptyException import com.alibaba.opensandbox.sandbox.domain.exceptions.PoolNotRunningException +import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.CredentialProxyConfig import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.Host import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.NetworkPolicy import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.NetworkRule @@ -405,6 +406,22 @@ class SandboxPoolTest { assertSame(platform, platformField.get(builder)) } + @Test + fun `applyToBuilder propagates pool creation spec credential proxy to sandbox builder`() { + val credentialProxy = CredentialProxyConfig.enabled() + val spec = + PoolCreationSpec.builder() + .image("ubuntu:22.04") + .credentialProxy(credentialProxy) + .build() + + val builder = spec.applyToBuilder(Sandbox.builder()) + + val credentialProxyField = builder.javaClass.getDeclaredField("credentialProxy") + credentialProxyField.isAccessible = true + assertSame(credentialProxy, credentialProxyField.get(builder)) + } + @Test fun `pool creation spec builder convenience methods align with sandbox builder semantics`() { val volume = diff --git a/sdks/sandbox/python/README.md b/sdks/sandbox/python/README.md index 098aafe45..3909f470f 100644 --- a/sdks/sandbox/python/README.md +++ b/sdks/sandbox/python/README.md @@ -448,6 +448,7 @@ The `Sandbox.create()` allows configuring the sandbox environment. | `env` | Environment variables | Empty | | `metadata` | Custom metadata tags | Empty | | `network_policy` | Optional outbound network policy (egress) | - | +| `credential_proxy` | Optional Credential Vault proxy startup settings | - | | `ready_timeout` | Max time to wait for sandbox to be ready | 30 seconds | Note: metadata keys under `opensandbox.io/` are reserved for system-managed @@ -493,3 +494,49 @@ await sandbox.patch_egress_rules( ] ) ``` + +### 4. Credential Vault + +Credential Vault injects outbound credentials from the egress sidecar while +keeping real secrets out of sandbox environment variables, commands, files, and +logs. Create the sandbox with `credential_proxy` enabled, then write credentials +and bindings through `sandbox.credential_vault`. + +```python +from opensandbox.models.sandboxes import ( + Credential, + CredentialBinding, + CredentialProxyConfig, + NetworkPolicy, + NetworkRule, +) + +sandbox = await Sandbox.create( + "python:3.11", + connection_config=config, + network_policy=NetworkPolicy( + defaultAction="deny", + egress=[NetworkRule(action="allow", target="api.example.com")], + ), + credential_proxy=CredentialProxyConfig(enabled=True), +) + +await sandbox.credential_vault.create( + credentials=[Credential(name="api-token", source={"value": ""})], + bindings=[ + CredentialBinding( + name="api-token", + match={ + "schemes": ["https"], + "ports": [443], + "hosts": ["api.example.com"], + "paths": ["/v1/*"], + }, + auth={"type": "apiKey", "name": "x-api-key", "credential": "api-token"}, + ) + ], +) +``` + +See [Credential Vault](../../../docs/credential-vault.md) for auth types, +binding guidance, and Git/curl examples. diff --git a/sdks/sandbox/python/README_zh.md b/sdks/sandbox/python/README_zh.md index ec9ba8a6f..6a8a2f361 100644 --- a/sdks/sandbox/python/README_zh.md +++ b/sdks/sandbox/python/README_zh.md @@ -439,6 +439,7 @@ config = ConnectionConfig( | `env` | 环境变量 | 空 | | `metadata` | 自定义元数据标签 | 空 | | `network_policy` | 可选的出站网络策略(egress) | - | +| `credential_proxy` | 可选的 Credential Vault proxy 启动配置 | - | | `ready_timeout` | 等待沙箱就绪的最大时间 | 30 秒 | 注意:`opensandbox.io/` 前缀下的 metadata key 属于系统保留标签,服务端会拒绝用户传入。 @@ -476,3 +477,9 @@ await sandbox.patch_egress_rules( ] ) ``` + +### 4. Credential Vault + +Credential Vault 可以由 egress sidecar 在出站请求中注入凭证,避免真实密钥进入沙箱环境变量、命令参数、文件或日志。创建沙箱时设置 `credential_proxy=CredentialProxyConfig(enabled=True)`,然后通过 `sandbox.credential_vault.create(...)` / `patch(...)` 写入 credentials 和 bindings。 + +更多 auth 类型、binding 规则和 Git/curl 示例请参考 [Credential Vault](../../../docs/credential-vault_zh.md)。 diff --git a/tests/csharp/OpenSandbox.E2ETests/CredentialVaultE2ETests.cs b/tests/csharp/OpenSandbox.E2ETests/CredentialVaultE2ETests.cs new file mode 100644 index 000000000..fe6a98e84 --- /dev/null +++ b/tests/csharp/OpenSandbox.E2ETests/CredentialVaultE2ETests.cs @@ -0,0 +1,381 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System.Text.Json; +using OpenSandbox.Models; +using Xunit; + +namespace OpenSandbox.E2ETests; + +[Collection("CSharp E2E Tests")] +public class CredentialVaultE2ETests : IClassFixture +{ + private const string DefaultTargetHost = "credential-vault-e2e.opensandbox.test"; + + private static readonly IReadOnlyDictionary SecretValues = new Dictionary + { + ["bearer-token"] = "vault-bearer-token", + ["basic-token"] = "dXNlcjpwYXNz", + ["api-key-token"] = "vault-api-key-token", + ["client-id"] = "vault-client-id", + ["client-secret"] = "vault-client-secret", + ["runtime-token"] = "vault-runtime-token", + ["runtime-token-replaced"] = "vault-runtime-token-replaced" + }; + + private readonly E2ETestFixture _fixture; + + public CredentialVaultE2ETests(E2ETestFixture fixture) + { + _fixture = fixture; + } + + [Fact(Timeout = 5 * 60 * 1000)] + public async Task CredentialVault_Injects_All_Auth_Types() + { + var targetIp = CredentialVaultTargetIp(); + if (targetIp is null) + { + return; + } + + var sandbox = await CreateCredentialVaultSandboxAsync(); + + try + { + var state = await sandbox.CreateCredentialVaultAsync( + Credentials( + "bearer-token", + "basic-token", + "api-key-token", + "client-id", + "client-secret", + "runtime-token", + "runtime-token-replaced"), + new[] + { + Binding("bearer", "/bearer", BearerAuth("bearer-token")), + Binding("basic", "/basic", BasicAuth("basic-token")), + Binding("api-key", "/api-key", ApiKeyAuth("X-Api-Key", "api-key-token")), + Binding( + "custom-headers", + "/custom-headers", + CustomHeadersAuth( + new CustomHeaderEntry { Name = "X-Client-Id", Credential = "client-id" }, + new CustomHeaderEntry { Name = "X-Client-Secret", Credential = "client-secret" })) + }); + + var authTypes = state.Bindings + .Select(binding => binding.Auth?.Type) + .Where(type => type is not null) + .Select(type => type!) + .ToHashSet(StringComparer.Ordinal); + Assert.True( + new HashSet(StringComparer.Ordinal) + { + "bearer", + "basic", + "apiKey", + "customHeaders" + }.SetEquals(authTypes)); + AssertStateDoesNotContainSecrets(state); + + foreach (var path in new[] { "/bearer", "/basic", "/api-key", "/custom-headers" }) + { + using var response = await CurlJsonAsync(sandbox, targetIp, path); + AssertJsonCase(response, path.TrimStart('/'), expectedOk: true, Array.Empty()); + } + } + finally + { + await KillSandboxAsync(sandbox); + } + } + + [Fact(Timeout = 5 * 60 * 1000)] + public async Task CredentialVault_Runtime_Mutation_Adds_Replaces_And_Deletes_Binding() + { + var targetIp = CredentialVaultTargetIp(); + if (targetIp is null) + { + return; + } + + var sandbox = await CreateCredentialVaultSandboxAsync(); + + try + { + var state = await sandbox.CreateCredentialVaultAsync(Array.Empty(), Array.Empty()); + Assert.Equal(1, state.Revision); + Assert.Empty(state.Credentials); + Assert.Empty(state.Bindings); + + state = await sandbox.PatchCredentialVaultAsync(new CredentialVaultPatchRequest + { + ExpectedRevision = state.Revision, + Credentials = new CredentialMutationSet + { + Add = new[] { Credential("runtime-token", "runtime-token") } + }, + Bindings = new CredentialBindingMutationSet + { + Add = new[] + { + Binding( + "runtime-added", + "/runtime-added", + ApiKeyAuth("X-Runtime-Token", "runtime-token")) + } + } + }); + Assert.Equal(2, state.Revision); + Assert.Equal(new[] { "runtime-token" }, state.Credentials.Select(credential => credential.Name)); + Assert.Equal(new[] { "runtime-added" }, state.Bindings.Select(binding => binding.Name)); + AssertStateDoesNotContainSecrets(state); + + using (var response = await CurlJsonAsync(sandbox, targetIp, "/runtime-added")) + { + AssertJsonCase(response, "runtime-added", expectedOk: true, Array.Empty()); + } + + state = await sandbox.PatchCredentialVaultAsync(new CredentialVaultPatchRequest + { + ExpectedRevision = state.Revision, + Bindings = new CredentialBindingMutationSet + { + Delete = new[] { "runtime-added" } + } + }); + Assert.Equal(3, state.Revision); + Assert.Empty(state.Bindings); + + state = await sandbox.PatchCredentialVaultAsync(new CredentialVaultPatchRequest + { + ExpectedRevision = state.Revision, + Credentials = new CredentialMutationSet + { + Replace = new[] { Credential("runtime-token", "runtime-token-replaced") } + }, + Bindings = new CredentialBindingMutationSet + { + Add = new[] + { + Binding( + "runtime-replaced", + "/runtime-replaced", + ApiKeyAuth("X-Runtime-Token", "runtime-token")) + } + } + }); + Assert.Equal(4, state.Revision); + Assert.Equal(new[] { "runtime-token" }, state.Credentials.Select(credential => credential.Name)); + Assert.Equal(new[] { "runtime-replaced" }, state.Bindings.Select(binding => binding.Name)); + AssertStateDoesNotContainSecrets(state); + + using (var response = await CurlJsonAsync(sandbox, targetIp, "/runtime-replaced")) + { + AssertJsonCase(response, "runtime-replaced", expectedOk: true, Array.Empty()); + } + + using (var response = await CurlJsonAsync(sandbox, targetIp, "/runtime-added", failOnHttpError: false)) + { + AssertJsonCase(response, "runtime-added", expectedOk: false, new[] { "x-runtime-token" }); + } + + state = await sandbox.PatchCredentialVaultAsync(new CredentialVaultPatchRequest + { + ExpectedRevision = state.Revision, + Bindings = new CredentialBindingMutationSet + { + Delete = new[] { "runtime-replaced" } + } + }); + Assert.Equal(5, state.Revision); + Assert.Empty(state.Bindings); + + state = await sandbox.PatchCredentialVaultAsync(new CredentialVaultPatchRequest + { + ExpectedRevision = state.Revision, + Credentials = new CredentialMutationSet + { + Delete = new[] { "runtime-token" } + } + }); + Assert.Equal(6, state.Revision); + Assert.Empty(state.Credentials); + } + finally + { + await KillSandboxAsync(sandbox); + } + } + + private async Task CreateCredentialVaultSandboxAsync() + { + return await Sandbox.CreateAsync(new SandboxCreateOptions + { + ConnectionConfig = _fixture.ConnectionConfig, + Image = Environment.GetEnvironmentVariable("OPENSANDBOX_CREDENTIAL_VAULT_E2E_SANDBOX_IMAGE") + ?? _fixture.DefaultImage, + Resource = new Dictionary + { + ["cpu"] = Environment.GetEnvironmentVariable("OPENSANDBOX_E2E_SANDBOX_CPU") ?? "1", + ["memory"] = Environment.GetEnvironmentVariable("OPENSANDBOX_E2E_SANDBOX_MEMORY") ?? "2Gi" + }, + ReadyTimeoutSeconds = 90, + TimeoutSeconds = 5 * 60, + NetworkPolicy = new NetworkPolicy + { + DefaultAction = NetworkRuleAction.Allow, + Egress = new List + { + new() { Action = NetworkRuleAction.Allow, Target = CredentialVaultTargetHost() } + } + }, + CredentialProxy = new CredentialProxyConfig { Enabled = true }, + Metadata = new Dictionary + { + [Environment.GetEnvironmentVariable("OPENSANDBOX_CREDENTIAL_VAULT_E2E_LABEL_KEY") ?? "opensandbox.e2e"] = + Environment.GetEnvironmentVariable("OPENSANDBOX_CREDENTIAL_VAULT_E2E_LABEL_VALUE") ?? "credential-vault" + } + }); + } + + private static IReadOnlyList Credentials(params string[] names) + { + return names.Select(name => Credential(name, name)).ToList(); + } + + private static Credential Credential(string name, string valueName) + { + return new Credential + { + Name = name, + Source = new InlineCredentialSource { Value = SecretValues[valueName] } + }; + } + + private static CredentialBinding Binding(string name, string path, CredentialAuth auth) + { + return new CredentialBinding + { + Name = name, + Match = new CredentialMatch + { + Schemes = new[] { "http" }, + Ports = new[] { 80 }, + Hosts = new[] { CredentialVaultTargetHost() }, + Methods = new[] { "GET" }, + Paths = new[] { path } + }, + Auth = auth + }; + } + + private static CredentialAuth BearerAuth(string credential) + { + return new CredentialAuth { Type = "bearer", Credential = credential }; + } + + private static CredentialAuth BasicAuth(string credential) + { + return new CredentialAuth { Type = "basic", Credential = credential }; + } + + private static CredentialAuth ApiKeyAuth(string name, string credential) + { + return new CredentialAuth { Type = "apiKey", Name = name, Credential = credential }; + } + + private static CredentialAuth CustomHeadersAuth(params CustomHeaderEntry[] headers) + { + return new CredentialAuth { Type = "customHeaders", Headers = headers }; + } + + private static async Task CurlJsonAsync( + Sandbox sandbox, + string targetIp, + string path, + bool failOnHttpError = true) + { + var failFlag = failOnHttpError ? "--fail " : ""; + var command = + $"curl {failFlag}--silent --show-error --connect-timeout 5 --max-time 20 " + + $"--resolve {CredentialVaultTargetHost()}:80:{targetIp} " + + $"http://{CredentialVaultTargetHost()}{path}"; + foreach (var secret in SecretValues.Values) + { + Assert.DoesNotContain(secret, command, StringComparison.Ordinal); + } + + var result = await sandbox.Commands.RunAsync(command); + Assert.Null(result.Error); + Assert.Equal(0, result.ExitCode); + var stdout = string.Join("", result.Logs.Stdout.Select(output => output.Text)); + Assert.False(string.IsNullOrWhiteSpace(stdout)); + return JsonDocument.Parse(stdout); + } + + private static void AssertJsonCase( + JsonDocument payload, + string expectedCase, + bool expectedOk, + IReadOnlyList expectedMissingOrInvalid) + { + var root = payload.RootElement; + Assert.Equal(expectedOk, root.GetProperty("ok").GetBoolean()); + Assert.Equal(expectedCase, root.GetProperty("case").GetString()); + var missingOrInvalid = root + .GetProperty("missingOrInvalid") + .EnumerateArray() + .Select(item => item.GetString() ?? string.Empty) + .ToArray(); + Assert.Equal(expectedMissingOrInvalid, missingOrInvalid); + } + + private static void AssertStateDoesNotContainSecrets(CredentialVaultState state) + { + var payload = JsonSerializer.Serialize(state); + foreach (var secret in SecretValues.Values) + { + Assert.DoesNotContain(secret, payload, StringComparison.Ordinal); + } + } + + private static string CredentialVaultTargetHost() + { + return Environment.GetEnvironmentVariable("OPENSANDBOX_CREDENTIAL_VAULT_E2E_TARGET_HOST") + ?? DefaultTargetHost; + } + + private static string? CredentialVaultTargetIp() + { + var targetIp = Environment.GetEnvironmentVariable("OPENSANDBOX_CREDENTIAL_VAULT_E2E_TARGET_IP"); + return string.IsNullOrWhiteSpace(targetIp) ? null : targetIp; + } + + private static async Task KillSandboxAsync(Sandbox sandbox) + { + try + { + await sandbox.KillAsync(); + } + catch (Exception ex) + { + Console.Error.WriteLine($"KillSandboxAsync: sandbox.KillAsync() failed during cleanup: {ex}"); + } + + await sandbox.DisposeAsync(); + } +} diff --git a/tests/go/credential_vault_e2e_test.go b/tests/go/credential_vault_e2e_test.go new file mode 100644 index 000000000..2809927b3 --- /dev/null +++ b/tests/go/credential_vault_e2e_test.go @@ -0,0 +1,361 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package e2e + +import ( + "context" + "encoding/json" + "os" + "testing" + "time" + + "github.com/alibaba/OpenSandbox/sdks/sandbox/go" + "github.com/stretchr/testify/require" +) + +const credentialVaultDefaultTargetHost = "credential-vault-e2e.opensandbox.test" + +var credentialVaultSecrets = map[string]string{ + "bearer-token": "vault-bearer-token", + "basic-token": "dXNlcjpwYXNz", + "api-key-token": "vault-api-key-token", + "client-id": "vault-client-id", + "client-secret": "vault-client-secret", + "runtime-token": "vault-runtime-token", + "runtime-token-replaced": "vault-runtime-token-replaced", +} + +func TestCredentialVaultInjectsAllAuthTypes(t *testing.T) { + targetIP := credentialVaultTargetIP(t) + ctx, sb := createCredentialVaultSandbox(t) + + state, err := sb.CreateCredentialVault(ctx, opensandbox.CredentialVaultCreateRequest{ + Credentials: credentialVaultCredentials( + "bearer-token", + "basic-token", + "api-key-token", + "client-id", + "client-secret", + "runtime-token", + "runtime-token-replaced", + ), + Bindings: []opensandbox.CredentialBinding{ + credentialVaultBinding("bearer", "/bearer", opensandbox.CredentialAuth{ + Type: opensandbox.CredentialAuthBearer, + Credential: "bearer-token", + }), + credentialVaultBinding("basic", "/basic", opensandbox.CredentialAuth{ + Type: opensandbox.CredentialAuthBasic, + Credential: "basic-token", + }), + credentialVaultBinding("api-key", "/api-key", opensandbox.CredentialAuth{ + Type: opensandbox.CredentialAuthAPIKey, + Name: "X-Api-Key", + Credential: "api-key-token", + }), + credentialVaultBinding("custom-headers", "/custom-headers", opensandbox.CredentialAuth{ + Type: opensandbox.CredentialAuthCustomHeaders, + Headers: []opensandbox.CustomHeaderEntry{ + {Name: "X-Client-Id", Credential: "client-id"}, + {Name: "X-Client-Secret", Credential: "client-secret"}, + }, + }), + }, + }) + require.NoError(t, err) + + statePayload, err := json.Marshal(state) + require.NoError(t, err) + for _, secret := range credentialVaultSecrets { + require.NotContains(t, string(statePayload), secret) + } + + gotAuthTypes := map[string]bool{} + for _, binding := range state.Bindings { + if binding.Auth != nil { + gotAuthTypes[binding.Auth.Type] = true + } + } + require.Equal(t, map[string]bool{ + "bearer": true, + "basic": true, + "apiKey": true, + "customHeaders": true, + }, gotAuthTypes) + + for _, path := range []string{"/bearer", "/basic", "/api-key", "/custom-headers"} { + response := credentialVaultCurlJSON(t, ctx, sb, targetIP, path, true) + require.Equal(t, true, response["ok"]) + require.Equal(t, path[1:], response["case"]) + require.Empty(t, stringSliceFromJSON(t, response["missingOrInvalid"])) + } +} + +func TestCredentialVaultRuntimeMutationAddsReplacesAndDeletesBinding(t *testing.T) { + targetIP := credentialVaultTargetIP(t) + ctx, sb := createCredentialVaultSandbox(t) + + state, err := sb.CreateCredentialVault(ctx, opensandbox.CredentialVaultCreateRequest{}) + require.NoError(t, err) + require.Equal(t, 1, state.Revision) + require.Empty(t, state.Credentials) + require.Empty(t, state.Bindings) + + state, err = sb.PatchCredentialVault(ctx, opensandbox.CredentialVaultPatchRequest{ + ExpectedRevision: intPtr(state.Revision), + Credentials: &opensandbox.CredentialMutationSet{ + Add: credentialVaultCredentials("runtime-token"), + }, + Bindings: &opensandbox.CredentialBindingMutationSet{ + Add: []opensandbox.CredentialBinding{ + credentialVaultBinding("runtime-added", "/runtime-added", opensandbox.CredentialAuth{ + Type: opensandbox.CredentialAuthAPIKey, + Name: "X-Runtime-Token", + Credential: "runtime-token", + }), + }, + }, + }) + require.NoError(t, err) + require.Equal(t, 2, state.Revision) + require.Len(t, state.Credentials, 1) + require.Equal(t, "runtime-token", state.Credentials[0].Name) + require.Len(t, state.Bindings, 1) + require.Equal(t, "runtime-added", state.Bindings[0].Name) + + response := credentialVaultCurlJSON(t, ctx, sb, targetIP, "/runtime-added", true) + require.Equal(t, true, response["ok"]) + require.Equal(t, "runtime-added", response["case"]) + require.Empty(t, stringSliceFromJSON(t, response["missingOrInvalid"])) + + state, err = sb.PatchCredentialVault(ctx, opensandbox.CredentialVaultPatchRequest{ + ExpectedRevision: intPtr(state.Revision), + Bindings: &opensandbox.CredentialBindingMutationSet{Delete: []string{"runtime-added"}}, + }) + require.NoError(t, err) + require.Equal(t, 3, state.Revision) + require.Empty(t, state.Bindings) + + state, err = sb.PatchCredentialVault(ctx, opensandbox.CredentialVaultPatchRequest{ + ExpectedRevision: intPtr(state.Revision), + Credentials: &opensandbox.CredentialMutationSet{ + Replace: []opensandbox.Credential{ + credentialVaultCredential("runtime-token", "runtime-token-replaced"), + }, + }, + Bindings: &opensandbox.CredentialBindingMutationSet{ + Add: []opensandbox.CredentialBinding{ + credentialVaultBinding("runtime-replaced", "/runtime-replaced", opensandbox.CredentialAuth{ + Type: opensandbox.CredentialAuthAPIKey, + Name: "X-Runtime-Token", + Credential: "runtime-token", + }), + }, + }, + }) + require.NoError(t, err) + require.Equal(t, 4, state.Revision) + require.Len(t, state.Credentials, 1) + require.Equal(t, "runtime-token", state.Credentials[0].Name) + require.Len(t, state.Bindings, 1) + require.Equal(t, "runtime-replaced", state.Bindings[0].Name) + + statePayload, err := json.Marshal(state) + require.NoError(t, err) + require.NotContains(t, string(statePayload), credentialVaultSecrets["runtime-token"]) + require.NotContains(t, string(statePayload), credentialVaultSecrets["runtime-token-replaced"]) + + response = credentialVaultCurlJSON(t, ctx, sb, targetIP, "/runtime-replaced", true) + require.Equal(t, true, response["ok"]) + require.Equal(t, "runtime-replaced", response["case"]) + require.Empty(t, stringSliceFromJSON(t, response["missingOrInvalid"])) + + response = credentialVaultCurlJSON(t, ctx, sb, targetIP, "/runtime-added", false) + require.Equal(t, false, response["ok"]) + require.Equal(t, "runtime-added", response["case"]) + require.Equal(t, []string{"x-runtime-token"}, stringSliceFromJSON(t, response["missingOrInvalid"])) + + state, err = sb.PatchCredentialVault(ctx, opensandbox.CredentialVaultPatchRequest{ + ExpectedRevision: intPtr(state.Revision), + Bindings: &opensandbox.CredentialBindingMutationSet{Delete: []string{"runtime-replaced"}}, + }) + require.NoError(t, err) + require.Equal(t, 5, state.Revision) + require.Empty(t, state.Bindings) + + state, err = sb.PatchCredentialVault(ctx, opensandbox.CredentialVaultPatchRequest{ + ExpectedRevision: intPtr(state.Revision), + Credentials: &opensandbox.CredentialMutationSet{Delete: []string{"runtime-token"}}, + }) + require.NoError(t, err) + require.Equal(t, 6, state.Revision) + require.Empty(t, state.Credentials) +} + +func credentialVaultTargetHost() string { + if host := os.Getenv("OPENSANDBOX_CREDENTIAL_VAULT_E2E_TARGET_HOST"); host != "" { + return host + } + return credentialVaultDefaultTargetHost +} + +func credentialVaultTargetIP(t *testing.T) string { + t.Helper() + targetIP := os.Getenv("OPENSANDBOX_CREDENTIAL_VAULT_E2E_TARGET_IP") + if targetIP == "" { + t.Skip("set OPENSANDBOX_CREDENTIAL_VAULT_E2E_TARGET_IP to run Credential Vault E2E") + } + return targetIP +} + +func createCredentialVaultSandbox(t *testing.T) (context.Context, *opensandbox.Sandbox) { + t.Helper() + + image := os.Getenv("OPENSANDBOX_CREDENTIAL_VAULT_E2E_SANDBOX_IMAGE") + if image == "" { + image = getSandboxImage() + } + + config := connectionConfigForStreaming(t) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + t.Cleanup(cancel) + + sb, err := opensandbox.CreateSandbox(ctx, config, opensandbox.SandboxCreateOptions{ + Image: image, + ResourceLimits: credentialVaultSandboxResource(), + ReadyTimeout: 90 * time.Second, + NetworkPolicy: &opensandbox.NetworkPolicy{ + DefaultAction: "allow", + Egress: []opensandbox.NetworkRule{{Action: "allow", Target: credentialVaultTargetHost()}}, + }, + CredentialProxy: &opensandbox.CredentialProxyConfig{Enabled: true}, + Metadata: map[string]string{ + credentialVaultLabelKey(): credentialVaultLabelValue(), + }, + }) + require.NoError(t, err) + t.Cleanup(func() { _ = sb.Kill(context.Background()) }) + return ctx, sb +} + +func credentialVaultSandboxResource() opensandbox.ResourceLimits { + cpu := os.Getenv("OPENSANDBOX_E2E_SANDBOX_CPU") + if cpu == "" { + cpu = "1" + } + memory := os.Getenv("OPENSANDBOX_E2E_SANDBOX_MEMORY") + if memory == "" { + memory = "2Gi" + } + return opensandbox.ResourceLimits{"cpu": cpu, "memory": memory} +} + +func credentialVaultCredentials(names ...string) []opensandbox.Credential { + credentials := make([]opensandbox.Credential, 0, len(names)) + for _, name := range names { + credentials = append(credentials, credentialVaultCredential(name, name)) + } + return credentials +} + +func credentialVaultCredential(name, valueName string) opensandbox.Credential { + return opensandbox.Credential{ + Name: name, + Source: opensandbox.InlineCredentialSource{ + Type: opensandbox.CredentialSourceInline, + Value: credentialVaultSecrets[valueName], + }, + } +} + +func credentialVaultBinding(name, path string, auth opensandbox.CredentialAuth) opensandbox.CredentialBinding { + return opensandbox.CredentialBinding{ + Name: name, + Match: opensandbox.CredentialMatch{ + Schemes: []opensandbox.CredentialScheme{opensandbox.CredentialSchemeHTTP}, + Ports: []int{80}, + Hosts: []string{credentialVaultTargetHost()}, + Methods: []string{"GET"}, + Paths: []string{path}, + }, + Auth: auth, + } +} + +func credentialVaultCurlJSON( + t *testing.T, + ctx context.Context, + sb *opensandbox.Sandbox, + targetIP string, + path string, + failOnHTTPError bool, +) map[string]any { + t.Helper() + failFlag := "" + if failOnHTTPError { + failFlag = "--fail " + } + command := "curl " + failFlag + + "--silent --show-error --connect-timeout 5 --max-time 20 " + + "--resolve " + credentialVaultTargetHost() + ":80:" + targetIP + " " + + "http://" + credentialVaultTargetHost() + path + for _, secret := range credentialVaultSecrets { + require.NotContains(t, command, secret) + } + + exec, err := sb.RunCommand(ctx, command, nil) + require.NoError(t, err) + require.Nil(t, exec.Error) + require.NotNil(t, exec.ExitCode) + require.Equal(t, 0, *exec.ExitCode) + + stdout := exec.Text() + require.NotEmpty(t, stdout) + + var response map[string]any + require.NoError(t, json.Unmarshal([]byte(stdout), &response)) + return response +} + +func stringSliceFromJSON(t *testing.T, value any) []string { + t.Helper() + values, ok := value.([]any) + require.Truef(t, ok, "expected []any, got %T", value) + result := make([]string, 0, len(values)) + for _, value := range values { + text, ok := value.(string) + require.Truef(t, ok, "expected string, got %T", value) + result = append(result, text) + } + return result +} + +func credentialVaultLabelKey() string { + if key := os.Getenv("OPENSANDBOX_CREDENTIAL_VAULT_E2E_LABEL_KEY"); key != "" { + return key + } + return "opensandbox.e2e" +} + +func credentialVaultLabelValue() string { + if value := os.Getenv("OPENSANDBOX_CREDENTIAL_VAULT_E2E_LABEL_VALUE"); value != "" { + return value + } + return "credential-vault" +} + +func intPtr(v int) *int { + return &v +} diff --git a/tests/java/src/test/java/com/alibaba/opensandbox/e2e/BaseE2ETest.java b/tests/java/src/test/java/com/alibaba/opensandbox/e2e/BaseE2ETest.java index 99cd73d58..b4f395f03 100644 --- a/tests/java/src/test/java/com/alibaba/opensandbox/e2e/BaseE2ETest.java +++ b/tests/java/src/test/java/com/alibaba/opensandbox/e2e/BaseE2ETest.java @@ -41,6 +41,11 @@ public abstract class BaseE2ETest { private static final String PROP_DOMAIN = "opensandbox.test.domain"; private static final String PROP_PROTOCOL = "opensandbox.test.protocol"; private static final String PROP_IMG_DEFAULT = "opensandbox.sandbox.default.image"; + private static final String ENV_API_KEY = "OPENSANDBOX_TEST_API_KEY"; + private static final String ENV_DOMAIN = "OPENSANDBOX_TEST_DOMAIN"; + private static final String ENV_PROTOCOL = "OPENSANDBOX_TEST_PROTOCOL"; + private static final String ENV_IMG_DEFAULT = "OPENSANDBOX_SANDBOX_DEFAULT_IMAGE"; + private static final String ENV_USE_SERVER_PROXY = "OPENSANDBOX_TEST_USE_SERVER_PROXY"; // ========================================== // Shared State (Static) @@ -54,17 +59,17 @@ public abstract class BaseE2ETest { } protected static String getSandboxImage() { - return testProperties.getProperty(PROP_IMG_DEFAULT); + return configValue(PROP_IMG_DEFAULT, ENV_IMG_DEFAULT, "opensandbox/code-interpreter:latest"); } protected static ConnectionConfig createConnectionConfig(boolean useServerProxy) { - String protocol = testProperties.getProperty(PROP_PROTOCOL, "https"); + String protocol = configValue(PROP_PROTOCOL, ENV_PROTOCOL, "http"); return ConnectionConfig.builder() - .apiKey(testProperties.getProperty(PROP_API_KEY)) - .domain(testProperties.getProperty(PROP_DOMAIN)) - .requestTimeout(Duration.ofMinutes(1)) + .apiKey(configValue(PROP_API_KEY, ENV_API_KEY, "e2e-test")) + .domain(configValue(PROP_DOMAIN, ENV_DOMAIN, "localhost:8080")) + .requestTimeout(Duration.ofMinutes(3)) .protocol(protocol) - .useServerProxy(useServerProxy) + .useServerProxy(useServerProxy || shouldUseServerProxy()) .build(); } @@ -82,16 +87,30 @@ private static void loadTestProperties() { } private static void initializeSharedConfig() { - String protocol = testProperties.getProperty(PROP_PROTOCOL, "https"); + String protocol = configValue(PROP_PROTOCOL, ENV_PROTOCOL, "http"); sharedConnectionConfig = ConnectionConfig.builder() - .apiKey(testProperties.getProperty(PROP_API_KEY)) - .domain(testProperties.getProperty(PROP_DOMAIN)) - .requestTimeout(Duration.ofMinutes(1)) + .apiKey(configValue(PROP_API_KEY, ENV_API_KEY, "e2e-test")) + .domain(configValue(PROP_DOMAIN, ENV_DOMAIN, "localhost:8080")) + .requestTimeout(Duration.ofMinutes(3)) .protocol(protocol) + .useServerProxy(shouldUseServerProxy()) .build(); } + private static String configValue(String propertyKey, String envKey, String defaultValue) { + String envValue = System.getenv(envKey); + if (envValue != null && !envValue.isBlank()) { + return envValue; + } + return testProperties.getProperty(propertyKey, defaultValue); + } + + private static boolean shouldUseServerProxy() { + String envValue = System.getenv(ENV_USE_SERVER_PROXY); + return envValue != null && Boolean.parseBoolean(envValue); + } + @BeforeEach void beforeEach(TestInfo testInfo) { logger.info("=== Starting test: {} ===", testInfo.getDisplayName()); diff --git a/tests/java/src/test/java/com/alibaba/opensandbox/e2e/CredentialVaultE2ETest.java b/tests/java/src/test/java/com/alibaba/opensandbox/e2e/CredentialVaultE2ETest.java new file mode 100644 index 000000000..287fef6b7 --- /dev/null +++ b/tests/java/src/test/java/com/alibaba/opensandbox/e2e/CredentialVaultE2ETest.java @@ -0,0 +1,406 @@ +/* + * Copyright 2026 Alibaba Group Holding Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.alibaba.opensandbox.e2e; + +import static org.junit.jupiter.api.Assertions.*; + +import com.alibaba.opensandbox.sandbox.Sandbox; +import com.alibaba.opensandbox.sandbox.domain.models.execd.executions.Execution; +import com.alibaba.opensandbox.sandbox.domain.models.execd.executions.OutputMessage; +import com.alibaba.opensandbox.sandbox.domain.models.execd.executions.RunCommandRequest; +import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.Credential; +import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.CredentialAuth; +import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.CredentialBinding; +import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.CredentialBindingMetadata; +import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.CredentialBindingMutationSet; +import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.CredentialMatch; +import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.CredentialMetadata; +import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.CredentialMutationSet; +import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.CredentialProxyConfig; +import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.CredentialVaultCreateRequest; +import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.CredentialVaultPatchRequest; +import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.CredentialVaultState; +import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.CustomHeaderEntry; +import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.NetworkPolicy; +import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.NetworkRule; +import java.time.Duration; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; + +@Tag("e2e") +@DisplayName("Credential Vault E2E Tests (JVM/Kotlin SDK)") +class CredentialVaultE2ETest extends BaseE2ETest { + + private static final String DEFAULT_TARGET_HOST = "credential-vault-e2e.opensandbox.test"; + private static final Map SECRET_VALUES = + Map.of( + "bearer-token", "vault-bearer-token", + "basic-token", "dXNlcjpwYXNz", + "api-key-token", "vault-api-key-token", + "client-id", "vault-client-id", + "client-secret", "vault-client-secret", + "runtime-token", "vault-runtime-token", + "runtime-token-replaced", "vault-runtime-token-replaced"); + + @Test + @Timeout(value = 5, unit = TimeUnit.MINUTES) + void credentialVaultInjectsAllAuthTypes() { + String targetIp = credentialVaultTargetIp(); + Sandbox sandbox = createCredentialVaultSandbox(); + + try { + CredentialVaultState state = + sandbox.credentialVault() + .create( + CredentialVaultCreateRequest.builder() + .credentials( + credentials( + "bearer-token", + "basic-token", + "api-key-token", + "client-id", + "client-secret", + "runtime-token", + "runtime-token-replaced")) + .bindings( + List.of( + binding( + "bearer", + "/bearer", + CredentialAuth.bearer( + "bearer-token")), + binding( + "basic", + "/basic", + CredentialAuth.basic( + "basic-token")), + binding( + "api-key", + "/api-key", + CredentialAuth.apiKey( + "X-Api-Key", + "api-key-token")), + binding( + "custom-headers", + "/custom-headers", + CredentialAuth.customHeaders( + List.of( + CustomHeaderEntry + .builder() + .name( + "X-Client-Id") + .credential( + "client-id") + .build(), + CustomHeaderEntry + .builder() + .name( + "X-Client-Secret") + .credential( + "client-secret") + .build()))))) + .build()); + + Set authTypes = + state.getBindings().stream() + .map(CredentialBindingMetadata::getAuth) + .filter(auth -> auth != null) + .map(auth -> auth.getType()) + .collect(Collectors.toSet()); + assertEquals(Set.of("bearer", "basic", "apiKey", "customHeaders"), authTypes); + + for (String path : List.of("/bearer", "/basic", "/api-key", "/custom-headers")) { + String response = curlJson(sandbox, targetIp, path, true); + assertJsonCase(response, path.substring(1), true, "[]"); + } + } finally { + killSandbox(sandbox); + } + } + + @Test + @Timeout(value = 5, unit = TimeUnit.MINUTES) + void credentialVaultRuntimeMutationAddsReplacesAndDeletesBinding() { + String targetIp = credentialVaultTargetIp(); + Sandbox sandbox = createCredentialVaultSandbox(); + + try { + CredentialVaultState state = + sandbox.credentialVault() + .create( + CredentialVaultCreateRequest.builder() + .credentials(List.of()) + .bindings(List.of()) + .build()); + assertEquals(1, state.getRevision()); + assertTrue(state.getCredentials().isEmpty()); + assertTrue(state.getBindings().isEmpty()); + + state = + sandbox.credentialVault() + .patch( + CredentialVaultPatchRequest.builder() + .expectedRevision(state.getRevision()) + .credentials( + CredentialMutationSet.builder() + .add( + List.of( + credential( + "runtime-token", + "runtime-token"))) + .build()) + .bindings( + CredentialBindingMutationSet.builder() + .add( + List.of( + binding( + "runtime-added", + "/runtime-added", + CredentialAuth + .apiKey( + "X-Runtime-Token", + "runtime-token")))) + .build()) + .build()); + assertEquals(2, state.getRevision()); + assertEquals(List.of("runtime-token"), credentialNames(state.getCredentials())); + assertEquals(List.of("runtime-added"), bindingNames(state.getBindings())); + + String response = curlJson(sandbox, targetIp, "/runtime-added", true); + assertJsonCase(response, "runtime-added", true, "[]"); + + state = + sandbox.credentialVault() + .patch( + CredentialVaultPatchRequest.builder() + .expectedRevision(state.getRevision()) + .bindings( + CredentialBindingMutationSet.builder() + .delete("runtime-added") + .build()) + .build()); + assertEquals(3, state.getRevision()); + assertTrue(state.getBindings().isEmpty()); + + state = + sandbox.credentialVault() + .patch( + CredentialVaultPatchRequest.builder() + .expectedRevision(state.getRevision()) + .credentials( + CredentialMutationSet.builder() + .replace( + List.of( + credential( + "runtime-token", + "runtime-token-replaced"))) + .build()) + .bindings( + CredentialBindingMutationSet.builder() + .add( + List.of( + binding( + "runtime-replaced", + "/runtime-replaced", + CredentialAuth + .apiKey( + "X-Runtime-Token", + "runtime-token")))) + .build()) + .build()); + assertEquals(4, state.getRevision()); + assertEquals(List.of("runtime-token"), credentialNames(state.getCredentials())); + assertEquals(List.of("runtime-replaced"), bindingNames(state.getBindings())); + + response = curlJson(sandbox, targetIp, "/runtime-replaced", true); + assertJsonCase(response, "runtime-replaced", true, "[]"); + + response = curlJson(sandbox, targetIp, "/runtime-added", false); + assertJsonCase(response, "runtime-added", false, "[\"x-runtime-token\"]"); + + state = + sandbox.credentialVault() + .patch( + CredentialVaultPatchRequest.builder() + .expectedRevision(state.getRevision()) + .bindings( + CredentialBindingMutationSet.builder() + .delete("runtime-replaced") + .build()) + .build()); + assertEquals(5, state.getRevision()); + assertTrue(state.getBindings().isEmpty()); + + state = + sandbox.credentialVault() + .patch( + CredentialVaultPatchRequest.builder() + .expectedRevision(state.getRevision()) + .credentials( + CredentialMutationSet.builder() + .delete("runtime-token") + .build()) + .build()); + assertEquals(6, state.getRevision()); + assertTrue(state.getCredentials().isEmpty()); + } finally { + killSandbox(sandbox); + } + } + + private static Sandbox createCredentialVaultSandbox() { + String image = + envOrDefault( + "OPENSANDBOX_CREDENTIAL_VAULT_E2E_SANDBOX_IMAGE", getSandboxImage()); + Map resource = new HashMap<>(); + resource.put("cpu", envOrDefault("OPENSANDBOX_E2E_SANDBOX_CPU", "1")); + resource.put("memory", envOrDefault("OPENSANDBOX_E2E_SANDBOX_MEMORY", "2Gi")); + + return Sandbox.builder() + .connectionConfig(createConnectionConfig(false)) + .image(image) + .resource(resource) + .timeout(Duration.ofMinutes(5)) + .readyTimeout(Duration.ofSeconds(90)) + .networkPolicy( + NetworkPolicy.builder() + .defaultAction(NetworkPolicy.DefaultAction.ALLOW) + .addEgress( + NetworkRule.builder() + .action(NetworkRule.Action.ALLOW) + .target(credentialVaultTargetHost()) + .build()) + .build()) + .credentialProxy(CredentialProxyConfig.enabled()) + .metadata( + Map.of( + envOrDefault( + "OPENSANDBOX_CREDENTIAL_VAULT_E2E_LABEL_KEY", + "opensandbox.e2e"), + envOrDefault( + "OPENSANDBOX_CREDENTIAL_VAULT_E2E_LABEL_VALUE", + "credential-vault"))) + .build(); + } + + private static List credentials(String... names) { + return java.util.Arrays.stream(names) + .map(name -> credential(name, name)) + .collect(Collectors.toList()); + } + + private static Credential credential(String name, String valueName) { + return Credential.builder().name(name).inlineSource(SECRET_VALUES.get(valueName)).build(); + } + + private static CredentialBinding binding(String name, String path, CredentialAuth auth) { + return CredentialBinding.builder() + .name(name) + .match( + CredentialMatch.builder() + .schemes(CredentialMatch.Scheme.HTTP) + .ports(80) + .hosts(credentialVaultTargetHost()) + .methods("GET") + .paths(path) + .build()) + .auth(auth) + .build(); + } + + private static String curlJson( + Sandbox sandbox, String targetIp, String path, boolean failOnHttpError) { + String failFlag = failOnHttpError ? "--fail " : ""; + String command = + "curl " + + failFlag + + "--silent --show-error --connect-timeout 5 --max-time 20 " + + "--resolve " + + credentialVaultTargetHost() + + ":80:" + + targetIp + + " http://" + + credentialVaultTargetHost() + + path; + for (String secret : SECRET_VALUES.values()) { + assertFalse(command.contains(secret), "command must not contain secret material"); + } + + Execution execution = + sandbox.commands().run(RunCommandRequest.builder().command(command).build()); + assertNull(execution.getError(), "curl command failed"); + assertEquals(0, execution.getExitCode()); + String stdout = + execution.getLogs().getStdout().stream() + .map(OutputMessage::getText) + .collect(Collectors.joining()); + assertFalse(stdout.isBlank(), "curl response must not be blank"); + return stdout; + } + + private static void assertJsonCase( + String payload, String expectedCase, boolean expectedOk, String expectedMissing) { + assertTrue(payload.contains("\"ok\":" + expectedOk), payload); + assertTrue(payload.contains("\"case\":\"" + expectedCase + "\""), payload); + assertTrue(payload.contains("\"missingOrInvalid\":" + expectedMissing), payload); + } + + private static List credentialNames(List credentials) { + return credentials.stream().map(CredentialMetadata::getName).collect(Collectors.toList()); + } + + private static List bindingNames(List bindings) { + return bindings.stream().map(CredentialBindingMetadata::getName).collect(Collectors.toList()); + } + + private static String credentialVaultTargetHost() { + return envOrDefault("OPENSANDBOX_CREDENTIAL_VAULT_E2E_TARGET_HOST", DEFAULT_TARGET_HOST); + } + + private static String credentialVaultTargetIp() { + String targetIp = System.getenv("OPENSANDBOX_CREDENTIAL_VAULT_E2E_TARGET_IP"); + Assumptions.assumeTrue( + targetIp != null && !targetIp.isBlank(), + "Set OPENSANDBOX_CREDENTIAL_VAULT_E2E_TARGET_IP to run Credential Vault E2E"); + return targetIp; + } + + private static String envOrDefault(String name, String defaultValue) { + String value = System.getenv(name); + return value == null || value.isBlank() ? defaultValue : value; + } + + private static void killSandbox(Sandbox sandbox) { + try { + sandbox.kill(); + } catch (Exception ignored) { + } + try { + sandbox.close(); + } catch (Exception ignored) { + } + } +} diff --git a/tests/javascript/tests/test_credential_vault_e2e.test.ts b/tests/javascript/tests/test_credential_vault_e2e.test.ts new file mode 100644 index 000000000..f36b36072 --- /dev/null +++ b/tests/javascript/tests/test_credential_vault_e2e.test.ts @@ -0,0 +1,272 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { expect, test } from "vitest"; + +import { Sandbox, type Credential, type CredentialAuth, type CredentialBinding } from "@alibaba-group/opensandbox"; + +import { createConnectionConfig, getSandboxImage } from "./base_e2e.ts"; + +const DEFAULT_TARGET_HOST = "credential-vault-e2e.opensandbox.test"; + +const SECRET_VALUES: Record = { + "bearer-token": "vault-bearer-token", + "basic-token": "dXNlcjpwYXNz", + "api-key-token": "vault-api-key-token", + "client-id": "vault-client-id", + "client-secret": "vault-client-secret", + "runtime-token": "vault-runtime-token", + "runtime-token-replaced": "vault-runtime-token-replaced", +}; + +const credentialVaultTest = process.env.OPENSANDBOX_CREDENTIAL_VAULT_E2E_TARGET_IP ? test : test.skip; + +credentialVaultTest("credential vault injects all auth types", async () => { + const targetIp = credentialVaultTargetIp(); + const sandbox = await createCredentialVaultSandbox(); + + try { + const state = await sandbox.credentialVault.create({ + credentials: credentialVaultCredentials( + "bearer-token", + "basic-token", + "api-key-token", + "client-id", + "client-secret", + "runtime-token", + "runtime-token-replaced", + ), + bindings: [ + credentialVaultBinding("bearer", "/bearer", { type: "bearer", credential: "bearer-token" }), + credentialVaultBinding("basic", "/basic", { type: "basic", credential: "basic-token" }), + credentialVaultBinding("api-key", "/api-key", { + type: "apiKey", + name: "X-Api-Key", + credential: "api-key-token", + }), + credentialVaultBinding("custom-headers", "/custom-headers", { + type: "customHeaders", + headers: [ + { name: "X-Client-Id", credential: "client-id" }, + { name: "X-Client-Secret", credential: "client-secret" }, + ], + }), + ], + }); + + const statePayload = JSON.stringify(state); + for (const secret of Object.values(SECRET_VALUES)) { + expect(statePayload).not.toContain(secret); + } + expect(new Set(state.bindings.map((binding) => binding.auth?.type))).toEqual( + new Set(["bearer", "basic", "apiKey", "customHeaders"]), + ); + + for (const path of ["/bearer", "/basic", "/api-key", "/custom-headers"]) { + const response = await curlJson(sandbox, targetIp, path); + expect(response.ok).toBe(true); + expect(response.case).toBe(path.slice(1)); + expect(response.missingOrInvalid).toEqual([]); + } + } finally { + await killSandbox(sandbox); + } +}, 5 * 60_000); + +credentialVaultTest("credential vault runtime mutation adds replaces and deletes binding", async () => { + const targetIp = credentialVaultTargetIp(); + const sandbox = await createCredentialVaultSandbox(); + + try { + let state = await sandbox.credentialVault.create({ credentials: [], bindings: [] }); + expect(state.revision).toBe(1); + expect(state.credentials).toEqual([]); + expect(state.bindings).toEqual([]); + + state = await sandbox.credentialVault.patch({ + expectedRevision: state.revision, + credentials: { + add: [credentialVaultCredential("runtime-token", "runtime-token")], + }, + bindings: { + add: [ + credentialVaultBinding("runtime-added", "/runtime-added", { + type: "apiKey", + name: "X-Runtime-Token", + credential: "runtime-token", + }), + ], + }, + }); + expect(state.revision).toBe(2); + expect(state.credentials.map((credential) => credential.name)).toEqual(["runtime-token"]); + expect(state.bindings.map((binding) => binding.name)).toEqual(["runtime-added"]); + expect(JSON.stringify(state)).not.toContain(SECRET_VALUES["runtime-token"]); + + let response = await curlJson(sandbox, targetIp, "/runtime-added"); + expect(response.ok).toBe(true); + expect(response.case).toBe("runtime-added"); + expect(response.missingOrInvalid).toEqual([]); + + state = await sandbox.credentialVault.patch({ + expectedRevision: state.revision, + bindings: { delete: ["runtime-added"] }, + }); + expect(state.revision).toBe(3); + expect(state.bindings).toEqual([]); + + state = await sandbox.credentialVault.patch({ + expectedRevision: state.revision, + credentials: { + replace: [credentialVaultCredential("runtime-token", "runtime-token-replaced")], + }, + bindings: { + add: [ + credentialVaultBinding("runtime-replaced", "/runtime-replaced", { + type: "apiKey", + name: "X-Runtime-Token", + credential: "runtime-token", + }), + ], + }, + }); + expect(state.revision).toBe(4); + expect(state.credentials.map((credential) => credential.name)).toEqual(["runtime-token"]); + expect(state.bindings.map((binding) => binding.name)).toEqual(["runtime-replaced"]); + + const statePayload = JSON.stringify(state); + expect(statePayload).not.toContain(SECRET_VALUES["runtime-token"]); + expect(statePayload).not.toContain(SECRET_VALUES["runtime-token-replaced"]); + + response = await curlJson(sandbox, targetIp, "/runtime-replaced"); + expect(response.ok).toBe(true); + expect(response.case).toBe("runtime-replaced"); + expect(response.missingOrInvalid).toEqual([]); + + response = await curlJson(sandbox, targetIp, "/runtime-added", false); + expect(response.ok).toBe(false); + expect(response.case).toBe("runtime-added"); + expect(response.missingOrInvalid).toEqual(["x-runtime-token"]); + + state = await sandbox.credentialVault.patch({ + expectedRevision: state.revision, + bindings: { delete: ["runtime-replaced"] }, + }); + expect(state.revision).toBe(5); + expect(state.bindings).toEqual([]); + + state = await sandbox.credentialVault.patch({ + expectedRevision: state.revision, + credentials: { delete: ["runtime-token"] }, + }); + expect(state.revision).toBe(6); + expect(state.credentials).toEqual([]); + } finally { + await killSandbox(sandbox); + } +}, 5 * 60_000); + +function credentialVaultTargetHost(): string { + return process.env.OPENSANDBOX_CREDENTIAL_VAULT_E2E_TARGET_HOST ?? DEFAULT_TARGET_HOST; +} + +function credentialVaultTargetIp(): string { + const targetIp = process.env.OPENSANDBOX_CREDENTIAL_VAULT_E2E_TARGET_IP; + if (!targetIp) { + throw new Error("set OPENSANDBOX_CREDENTIAL_VAULT_E2E_TARGET_IP to run Credential Vault E2E"); + } + return targetIp; +} + +async function createCredentialVaultSandbox(): Promise { + const image = process.env.OPENSANDBOX_CREDENTIAL_VAULT_E2E_SANDBOX_IMAGE ?? getSandboxImage(); + return Sandbox.create({ + connectionConfig: createConnectionConfig(), + image, + resource: { + cpu: process.env.OPENSANDBOX_E2E_SANDBOX_CPU ?? "1", + memory: process.env.OPENSANDBOX_E2E_SANDBOX_MEMORY ?? "2Gi", + }, + readyTimeoutSeconds: 90, + timeoutSeconds: 5 * 60, + networkPolicy: { + defaultAction: "allow", + egress: [{ action: "allow", target: credentialVaultTargetHost() }], + }, + credentialProxy: { enabled: true }, + metadata: { + [process.env.OPENSANDBOX_CREDENTIAL_VAULT_E2E_LABEL_KEY ?? "opensandbox.e2e"]: + process.env.OPENSANDBOX_CREDENTIAL_VAULT_E2E_LABEL_VALUE ?? "credential-vault", + }, + }); +} + +function credentialVaultCredentials(...names: string[]): Credential[] { + return names.map((name) => credentialVaultCredential(name, name)); +} + +function credentialVaultCredential(name: string, valueName: string): Credential { + return { + name, + source: { + type: "inline", + value: SECRET_VALUES[valueName], + }, + }; +} + +function credentialVaultBinding(name: string, path: string, auth: CredentialAuth): CredentialBinding { + return { + name, + match: { + schemes: ["http"], + ports: [80], + hosts: [credentialVaultTargetHost()], + methods: ["GET"], + paths: [path], + }, + auth, + }; +} + +async function curlJson( + sandbox: Sandbox, + targetIp: string, + path: string, + failOnHttpError = true, +): Promise> { + const failFlag = failOnHttpError ? "--fail " : ""; + const command = + `curl ${failFlag}--silent --show-error --connect-timeout 5 --max-time 20 ` + + `--resolve ${credentialVaultTargetHost()}:80:${targetIp} ` + + `http://${credentialVaultTargetHost()}${path}`; + for (const secret of Object.values(SECRET_VALUES)) { + expect(command).not.toContain(secret); + } + + const result = await sandbox.commands.run(command); + expect(result.error).toBeUndefined(); + expect(result.exitCode).toBe(0); + const stdout = result.logs.stdout.map((part) => part.text).join(""); + expect(stdout).not.toBe(""); + return JSON.parse(stdout) as Record; +} + +async function killSandbox(sandbox: Sandbox): Promise { + try { + await sandbox.kill(); + } finally { + await sandbox.close(); + } +} diff --git a/tests/python/README.md b/tests/python/README.md index 699671696..a61cde786 100644 --- a/tests/python/README.md +++ b/tests/python/README.md @@ -26,12 +26,13 @@ uv run pytest tests/test_credential_vault_e2e.py Redis-backed pool E2E tests are skipped unless `OPENSANDBOX_TEST_REDIS_URL` is set, for example `redis://127.0.0.1:6379/0`. -Credential Vault E2E tests require Docker and a local target service. The -repository script starts the local OpenSandbox server, the target service, and -the focused pytest suite: +Credential Vault E2E tests require a reachable target service and +`OPENSANDBOX_CREDENTIAL_VAULT_E2E_TARGET_IP`. The repository E2E scripts start +the target service and run the Vault tests as part of each language's normal +E2E suite: ```bash -../../scripts/python-credential-vault-e2e.sh +../../scripts/python-e2e.sh ``` ### Notes about asyncio + shared Sandbox diff --git a/tests/python/tests/test_credential_vault_e2e.py b/tests/python/tests/test_credential_vault_e2e.py index 2066f543d..a59b73b69 100644 --- a/tests/python/tests/test_credential_vault_e2e.py +++ b/tests/python/tests/test_credential_vault_e2e.py @@ -36,7 +36,6 @@ create_connection_config_sync, get_e2e_sandbox_resource, get_sandbox_image, - is_kubernetes_runtime, ) TARGET_HOST = os.getenv( @@ -64,8 +63,6 @@ @pytest.fixture(scope="module") def credential_vault_target_ip() -> str: - if is_kubernetes_runtime(): - pytest.skip("Credential Vault docker E2E starts a local Docker target service") if not TARGET_IP: pytest.skip("Set OPENSANDBOX_CREDENTIAL_VAULT_E2E_TARGET_IP to run this E2E") return TARGET_IP @@ -251,7 +248,9 @@ def test_credential_vault_runtime_mutation_adds_replaces_and_deletes_binding( def _create_credential_proxy_sandbox() -> tuple[object, SandboxSync]: cfg = create_connection_config_sync() sandbox = SandboxSync.create( - image=SandboxImageSpec(get_sandbox_image()), + image=SandboxImageSpec( + os.getenv("OPENSANDBOX_CREDENTIAL_VAULT_E2E_SANDBOX_IMAGE", get_sandbox_image()) + ), resource=get_e2e_sandbox_resource(), connection_config=cfg, timeout=timedelta(minutes=5),