diff --git a/.github/workflows/extended-ci.yml b/.github/workflows/extended-ci.yml index 50e9d98..26f7415 100644 --- a/.github/workflows/extended-ci.yml +++ b/.github/workflows/extended-ci.yml @@ -3,6 +3,12 @@ name: Extended CI on: merge_group: workflow_dispatch: + inputs: + windows_guest: + description: Run the real OpenSandbox Windows guest lifecycle test + required: false + default: false + type: boolean concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -237,6 +243,109 @@ jobs: if-no-files-found: ignore retention-days: 14 + e2e-opensandbox-windows: + name: E2E (OpenSandbox Windows guest) + if: github.event_name == 'workflow_dispatch' && inputs.windows_guest + runs-on: self-hosted + timeout-minutes: 35 + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + + - name: Check Windows guest prerequisites + id: prerequisites + run: | + available=true + for device in /dev/kvm /dev/net/tun; do + if [ ! -r "$device" ] || [ ! -w "$device" ]; then + echo "::notice::Skipping Windows guest e2e: $device is unavailable or not readable/writable." + available=false + fi + done + if ! docker info >/dev/null 2>&1; then + echo "::notice::Skipping Windows guest e2e: Docker is unavailable." + available=false + fi + echo "available=$available" >> "$GITHUB_OUTPUT" + + - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 + if: steps.prerequisites.outputs.available == 'true' + with: + go-version: "1.25.x" + cache: true + + - name: Install uv + if: steps.prerequisites.outputs.available == 'true' + uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 + + - name: Install OpenSandbox server with Windows profile + if: steps.prerequisites.outputs.available == 'true' + run: | + uv tool install 'opensandbox-server==0.2.1' + echo "$HOME/.local/bin" >> "$GITHUB_PATH" + + - name: Start OpenSandbox server + if: steps.prerequisites.outputs.available == 'true' + run: | + api_key="ci-$(openssl rand -hex 16)" + config="$RUNNER_TEMP/sandbox-windows.toml" + cat > "$config" < "$log" 2>&1 & + echo $! > "$RUNNER_TEMP/opensandbox-windows-server.pid" + for i in $(seq 1 60); do + if curl -fsS http://127.0.0.1:8081/health 2>/dev/null | grep -q healthy; then + break + fi + if [ "$i" -eq 60 ]; then + echo "::error::OpenSandbox server did not become healthy within 120s" + cat "$log" + exit 1 + fi + sleep 2 + done + { + echo "OPENSANDBOX_API_KEY=$api_key" + echo "OPENSANDBOX_BASE_URL=http://127.0.0.1:8081" + echo "OPENSANDBOX_WINDOWS_IMAGE=dockurr/windows:latest" + } >> "$GITHUB_ENV" + + - name: Run real Windows guest lifecycle test + if: steps.prerequisites.outputs.available == 'true' + run: go test -tags e2e -timeout 1800s -count=1 -v -run TestCustomEngine_OpenSandboxWindowsGuest ./e2e + env: + SKILL_UP_WINDOWS_GUEST_E2E: "1" + SKILL_UP_E2E_ARTIFACT_DIR: ${{ github.workspace }}/e2e-opensandbox-windows-artifacts + + - name: Upload Windows guest diagnostics + if: always() && steps.prerequisites.outputs.available == 'true' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: opensandbox-windows-guest + path: | + ${{ runner.temp }}/opensandbox-windows-server.log + e2e-opensandbox-windows-artifacts/ + if-no-files-found: ignore + retention-days: 14 + + - name: Stop OpenSandbox server + if: always() && steps.prerequisites.outputs.available == 'true' + run: | + if [ -f "$RUNNER_TEMP/opensandbox-windows-server.pid" ]; then + kill "$(cat "$RUNNER_TEMP/opensandbox-windows-server.pid")" 2>/dev/null || true + fi + e2e-docker: name: E2E (docker runtime) runs-on: self-hosted diff --git a/CHANGELOG.md b/CHANGELOG.md index 99d8212..c90e8ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,15 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added +- OpenSandbox Windows guest support through typed `environment.platform` and + optional `environment.resources` settings. The runtime now applies guest-OS + path and shell semantics across workspace setup, transfers, Custom Engine + execution, PowerShell script judging, and artifact collection. Extended CI + includes an explicit opt-in real Windows guest lifecycle test. + ## [0.7.0] - 2026-07-16 ### Added diff --git a/docs/guide/windows.md b/docs/guide/windows.md index 2f35237..8655e2b 100644 --- a/docs/guide/windows.md +++ b/docs/guide/windows.md @@ -10,8 +10,8 @@ limitations, and the recommended workflow. - **Build and unit tests** — `go build ./...` and `go test ./...` pass on Windows. CI exercises a `windows-latest` runner alongside Linux. - **The `none` runtime** — commands run on the host through `cmd.exe`. -- **The `opensandbox` runtime** — unaffected by the host OS; it always - executes inside a Linux sandbox. +- **The `opensandbox` runtime** — unaffected by the host OS; it can target a + Linux sandbox or an explicitly configured Windows guest. - **The script judge** — dispatches by file extension (or shebang): | Script | Interpreter on Windows | @@ -42,21 +42,38 @@ generates. Users who want to drive script judges through WSL must arrange path translation upstream and point `SKILL_UP_BASH` at a non-WSL bash — or simply run skill-up inside WSL itself (see "Recommended workflow" below). -## OpenSandbox runtime on Windows +## OpenSandbox Windows guests + +The `opensandbox` runtime talks to a remote OpenSandbox server over HTTP, so the +skill-up host and guest OS are independent. Configure a Windows guest with +`environment.platform`; when omitted, existing Linux behavior is unchanged. + +```yaml +environment: + type: opensandbox + image: dockurr/windows:latest + platform: + os: windows + arch: amd64 + resources: + memory: 16Gi + workspace_mount: C:/workspace + ready_timeout_seconds: 1800 +``` -The `opensandbox` runtime talks to a remote OpenSandbox server over HTTP and -never spawns a host shell. Running `skill-up.exe` on native Windows against a -remote sandbox works today: all host-side path handling already crosses the -host→sandbox boundary through `filepath.ToSlash`, and the sandbox itself is a -Linux container, so the script judge and any agent run inside it behave -exactly as they do on Linux. +Windows defaults are `C:\workspace`, 4 CPU, 8 GiB memory, and 64 GiB disk. +Users may override any resource field without repeating the others. Both Linux +and Windows use the OpenSandbox directory API first and fall back to their +native shell only when the API cannot create or verify a writable directory. +Uploads, downloads, command working directories, script judges, and artifact +collection all use guest OS path semantics even when skill-up runs on another +OS. -OpenSandbox also offers a [**Windows guest profile**](https://github.com/alibaba/OpenSandbox/blob/main/docs/windows-sandbox.md): -the server runs `dockur/windows` (Windows in KVM/QEMU inside a Linux container) -and the API accepts `platform: {"os": "windows", "arch": "amd64"}` on create. -At the time of writing the Go SDK does not yet expose the `Platform` field, so -driving a Windows-guest sandbox from skill-up is blocked on an upstream Go SDK -update — tracked separately. +The OpenSandbox server needs KVM, TUN, sufficient storage, and a Windows-capable +profile; see the upstream +[Windows Sandbox guide](https://github.com/opensandbox-group/OpenSandbox/blob/main/docs/guides/windows-sandbox.md). +Cold boot can take many minutes, so use a long `ready_timeout_seconds` and +persistent server-side storage where appropriate. For a Windows machine that needs the full agent workflow **without** a remote sandbox, run skill-up inside **WSL2**. WSL2 is a Linux environment, so both the @@ -92,6 +109,9 @@ go test -race ./... launched through a bash-based Node/nvm bootstrap. That bootstrap does not run under `cmd.exe`. To run full agent evals on Windows, either install Node.js and the agent CLIs yourself beforehand, or use WSL2. +- **Built-in Agent CLI bootstrap in a Windows OpenSandbox guest** — the runtime + lifecycle is supported, but automatic installation of built-in Agent CLIs is + not. Preinstall the CLI in the guest image or configure a Custom Engine. - **`.ps1` script judges require a Windows target** — when the runtime target is POSIX (for example the `opensandbox` Linux sandbox), only `.sh` scripts are supported. diff --git a/docs/guide/writing-evals.md b/docs/guide/writing-evals.md index 73383fd..05d9544 100644 --- a/docs/guide/writing-evals.md +++ b/docs/guide/writing-evals.md @@ -380,6 +380,39 @@ Common fields: | `kwargs.request_timeout_seconds` | Request timeout for the OpenSandbox SDK. | | `kwargs.file_transfer_parallelism` | Concurrency for directory download. | +OpenSandbox can also target a Windows guest independently of the OS running +skill-up: + +```yaml +environment: + type: opensandbox + image: dockurr/windows:latest + platform: + os: windows + arch: amd64 + resources: # Optional user overrides + cpu: "8" + memory: 16Gi + disk: 128Gi + workspace_mount: C:/workspace + ready_timeout_seconds: 1800 +``` + +`platform.os` accepts `linux` or `windows`; `platform.arch` accepts `amd64` or +`arm64`, and the two fields must be set together. A Windows guest defaults to +`C:\workspace` plus 4 CPU, 8 GiB memory, and 64 GiB disk; partial `resources` +values override those defaults. Linux keeps the SDK defaults unless +`resources` is present, in which case partial values merge over them. An +explicit `workspace_mount` must be absolute for the guest OS (`C:/work` is +accepted and normalized for Windows). The OpenSandbox host must meet the +[Windows profile prerequisites](https://github.com/opensandbox-group/OpenSandbox/blob/main/docs/guides/windows-sandbox.md). + +Windows guests execute `setup_steps` through `cmd.exe`, use Windows path rules +for fixture and Skill transfer, support PowerShell (`.ps1`) script judges, and +can run a locally configured Custom Engine. Automatic installation of the +built-in Agent CLIs into a Windows guest is not yet supported; install the CLI +in the image or use a Custom Engine. + #### Docker configuration When `environment.type: docker` is used, the agent runs inside a local Docker container. This provides container-level isolation (filesystem, process, network) without any remote service dependency. diff --git a/docs/zh/guide/windows.md b/docs/zh/guide/windows.md index b7b9c07..382c5a1 100644 --- a/docs/zh/guide/windows.md +++ b/docs/zh/guide/windows.md @@ -9,7 +9,8 @@ skill-up 原生支持 Windows。本页说明哪些功能可用、当前的限制 - **构建与单元测试** —— `go build ./...` 和 `go test ./...` 在 Windows 上通过。 CI 在 Linux 之外额外运行 `windows-latest` runner。 - **`none` runtime** —— 命令通过 `cmd.exe` 在宿主机上执行。 -- **`opensandbox` runtime** —— 不受宿主机 OS 影响,始终在 Linux 沙箱内执行。 +- **`opensandbox` runtime** —— 不受宿主机 OS 影响,可选择 Linux sandbox, + 也可显式配置 Windows guest。 - **script judge** —— 按文件扩展名(或 shebang)分派解释器: | 脚本 | Windows 上的解释器 | @@ -37,19 +38,35 @@ skill-up 原生支持 Windows。本页说明哪些功能可用、当前的限制 需要走 WSL 的用户请自行处理路径翻译并把 `SKILL_UP_BASH` 指向非 WSL 的 bash, 或者直接在 WSL 内运行 skill-up(见下文「推荐工作流」)。 -## Windows 上的 OpenSandbox runtime +## OpenSandbox Windows guest + +`opensandbox` runtime 通过 HTTP 与远程 OpenSandbox 服务通信,因此 skill-up +宿主机与 guest OS 相互独立。通过 `environment.platform` 选择 Windows;省略时 +保持原有 Linux 行为。 + +```yaml +environment: + type: opensandbox + image: dockurr/windows:latest + platform: + os: windows + arch: amd64 + resources: + memory: 16Gi + workspace_mount: C:/workspace + ready_timeout_seconds: 1800 +``` -`opensandbox` runtime 通过 HTTP 与远程 OpenSandbox 服务器通信,不会启动任何 -宿主机 shell。在原生 Windows 上运行 `skill-up.exe` 连接远程 sandbox 当前即可 -工作:所有宿主机侧的路径处理都已通过 `filepath.ToSlash` 跨越「宿主机→sandbox」 -边界,而 sandbox 本身是 Linux 容器,因此其中的 script judge 和 agent 行为与在 -Linux 上完全一致。 +Windows 默认使用 `C:\workspace`、4 CPU、8 GiB 内存和 64 GiB 磁盘;用户可只覆盖 +需要调整的资源字段。Linux 与 Windows 都优先调用 OpenSandbox 目录 API,只有 +API 无法创建或验证可写目录时才回退到各自的原生 shell。上传、下载、命令工作 +目录、script judge 和 artifact 收集均按 guest OS 的路径规则处理,即使 skill-up +运行在另一种 OS 上也一致。 -OpenSandbox 也提供 [**Windows guest profile**](https://github.com/alibaba/OpenSandbox/blob/main/docs/windows-sandbox.md): -服务端在 Linux 容器里通过 KVM/QEMU 运行 `dockur/windows`,创建 API 接受 -`platform: {"os": "windows", "arch": "amd64"}`。撰写本文时 Go SDK 尚未暴露 -`Platform` 字段,因此从 skill-up 驱动 Windows guest sandbox 依赖上游 Go SDK -补齐 —— 单独跟进。 +OpenSandbox 服务端需要 KVM、TUN、足够的存储空间以及支持 Windows 的 profile; +详见上游 [Windows Sandbox 指南](https://github.com/opensandbox-group/OpenSandbox/blob/main/docs/guides/windows-sandbox.md)。 +Windows 冷启动可能耗时数分钟,应配置较长的 `ready_timeout_seconds`,必要时在 +服务端启用持久存储。 如果某台 Windows 机器需要在**没有**远程 sandbox 的情况下使用完整的 agent 工作流,请在 **WSL2** 中运行 skill-up。WSL2 是 Linux 环境,因此 `none` 与 @@ -82,6 +99,9 @@ go test -race ./... - **原生运行真实 agent** —— Claude Code / Codex / Qoder CLI 通过基于 bash 的 Node/nvm 引导脚本启动,该脚本无法在 `cmd.exe` 下运行。要在 Windows 上运行 完整的 agent 评测,请预先自行安装 Node.js 和对应的 agent CLI,或使用 WSL2。 +- **Windows OpenSandbox guest 中的内置 Agent CLI 引导** —— runtime 生命周期 + 已支持,但尚不会自动安装内置 Agent CLI。请将 CLI 预装进 guest 镜像,或配置 + Custom Engine。 - **`.ps1` script judge 需要 Windows 目标** —— 当 runtime 目标是 POSIX (例如 `opensandbox` 的 Linux 沙箱)时,仅支持 `.sh` 脚本。 - **`cmd.exe` 会展开参数里的 `%VAR%`** —— 当宿主未发现 bash、回退到 diff --git a/docs/zh/guide/writing-evals.md b/docs/zh/guide/writing-evals.md index cfd5c01..1164eaa 100644 --- a/docs/zh/guide/writing-evals.md +++ b/docs/zh/guide/writing-evals.md @@ -362,6 +362,36 @@ environment: | `kwargs.request_timeout_seconds` | OpenSandbox SDK 请求超时时间 | | `kwargs.file_transfer_parallelism` | 目录下载并发度 | +OpenSandbox 还可以在不依赖 skill-up 宿主 OS 的情况下选择 Windows guest: + +```yaml +environment: + type: opensandbox + image: dockurr/windows:latest + platform: + os: windows + arch: amd64 + resources: # 可选的用户覆盖 + cpu: "8" + memory: 16Gi + disk: 128Gi + workspace_mount: C:/workspace + ready_timeout_seconds: 1800 +``` + +`platform.os` 支持 `linux`、`windows`,`platform.arch` 支持 `amd64`、`arm64`, +且两个字段必须同时配置。Windows guest 默认使用 `C:\workspace`、4 CPU、8 GiB +内存和 64 GiB 磁盘,`resources` 可按字段覆盖这些默认值。Linux 在没有 +`resources` 时保持 SDK 默认值;提供部分字段时则在 SDK 默认值上覆盖。显式的 +`workspace_mount` 必须是 guest OS 的绝对路径(Windows 可写成 `C:/work`,运行时 +会规范化)。OpenSandbox 宿主机还需要满足 +[Windows profile 前置条件](https://github.com/opensandbox-group/OpenSandbox/blob/main/docs/guides/windows-sandbox.md)。 + +Windows guest 的 `setup_steps` 通过 `cmd.exe` 执行;fixture 与 Skill 上传遵循 +Windows 路径规则;script judge 支持 PowerShell(`.ps1`);本地 Custom Engine +也可以完成整条评测链路。当前尚不自动向 Windows guest 安装内置 Agent CLI; +请预装到镜像中,或使用 Custom Engine。 + #### Docker 配置 使用 `environment.type: docker` 时,Agent 在本地 Docker 容器中运行,提供容器级别的隔离(文件系统、进程、网络),无需任何远程服务。 diff --git a/e2e/opensandbox_guest_test.go b/e2e/opensandbox_guest_test.go new file mode 100644 index 0000000..9f97931 --- /dev/null +++ b/e2e/opensandbox_guest_test.go @@ -0,0 +1,114 @@ +//go:build e2e + +package e2e + +import ( + "os" + "path/filepath" + "strconv" + "strings" + "testing" + "time" +) + +func TestCustomEngine_OpenSandboxWindowsGuest(t *testing.T) { + apiKey := openSandboxE2EAPIKey() + baseURL := openSandboxE2EBaseURL() + image := os.Getenv("OPENSANDBOX_WINDOWS_IMAGE") + if baseURL == "" { + baseURL = "https://sandbox.example.test" + } + if image == "" { + image = "dockurr/windows:latest" + } + + fixture := filepath.Join(getProjectRoot(), "e2e", "testdata", "opensandbox-windows") + dir := t.TempDir() + if err := os.CopyFS(dir, os.DirFS(fixture)); err != nil { + t.Fatalf("copy Windows fixture: %v", err) + } + evalPath := filepath.Join(dir, "evals", "eval.yaml") + writeFile(t, evalPath, `schema_version: v1alpha1 +environment: + type: opensandbox + image: `+strconv.Quote(image)+` + platform: + os: windows + arch: amd64 + resources: + memory: 8Gi + ready_timeout_seconds: 1800 + sandbox_timeout_seconds: 3600 + env: + VERSION: "11" + setup_steps: + - run: echo setup-complete>setup-marker.txt + kwargs: + base_url: `+strconv.Quote(baseURL)+` + request_timeout_seconds: "1800" +mcp: + servers: [] +skills: [] +engine: + name: windows-custom-engine + custom: + transport: local + response_format: session_result + local: + command: powershell.exe + args: + - -NoProfile + - -ExecutionPolicy + - Bypass + - -File + - ${workspace}\agent.ps1 + - ${input_file} +cases: + files: + - evals/cases/windows.yaml + defaults: + timeout_seconds: 600 + max_turns: 1 + parallelism: 1 + retry_policy: + max_retries: 0 +benchmark: + enabled: false +report: + formats: [json] +`) + validation := Run(t, RunConfig{WorkDir: dir}, "validate", evalPath) + if validation.ExitCode != 0 { + t.Fatalf("Windows guest fixture validation failed: exit=%d\nstdout=%s\nstderr=%s", validation.ExitCode, validation.Stdout, validation.Stderr) + } + if os.Getenv("SKILL_UP_WINDOWS_GUEST_E2E") != "1" { + t.Skip("fixture validated; set SKILL_UP_WINDOWS_GUEST_E2E=1 to run the real Windows guest test") + } + if apiKey == "" || os.Getenv("OPENSANDBOX_BASE_URL") == "" || os.Getenv("OPENSANDBOX_WINDOWS_IMAGE") == "" { + t.Skip("OPENSANDBOX_API_KEY, OPENSANDBOX_BASE_URL, and OPENSANDBOX_WINDOWS_IMAGE are required") + } + + outputDir := t.TempDir() + preserveWorkspaceArtifacts(t, outputDir) + result := Run(t, RunConfig{ + Timeout: 30 * time.Minute, + Env: []string{ + "OPENSANDBOX_API_KEY=" + apiKey, + "OPENSANDBOX_BASE_URL=" + baseURL, + }, + }, "run", evalPath, "--output-dir", outputDir) + if result.ExitCode != 0 { + t.Fatalf("Windows guest run failed: exit=%d\nstdout=%s\nstderr=%s", result.ExitCode, result.Stdout, result.Stderr) + } + if !strings.Contains(result.Stdout, "1 passed") { + t.Fatalf("Windows guest summary did not pass:\n%s", result.Stdout) + } + artifact := filepath.Join(outputDir, "iteration-1", "windows-lifecycle", "with_skill", "outputs", "workspace", "result.txt") + data, err := os.ReadFile(artifact) + if err != nil { + t.Fatalf("read collected Windows artifact %s: %v", artifact, err) + } + if strings.TrimSpace(string(data)) != "windows-guest-complete" { + t.Fatalf("artifact = %q", data) + } +} diff --git a/e2e/testdata/opensandbox-windows/SKILL.md b/e2e/testdata/opensandbox-windows/SKILL.md new file mode 100644 index 0000000..fcb3cdb --- /dev/null +++ b/e2e/testdata/opensandbox-windows/SKILL.md @@ -0,0 +1,4 @@ +# OpenSandbox Windows guest E2E fixture + +This deterministic fixture verifies the complete Windows guest lifecycle with +a local Custom Engine, a PowerShell script judge, and artifact collection. diff --git a/e2e/testdata/opensandbox-windows/evals/cases/windows.yaml b/e2e/testdata/opensandbox-windows/evals/cases/windows.yaml new file mode 100644 index 0000000..341366c --- /dev/null +++ b/e2e/testdata/opensandbox-windows/evals/cases/windows.yaml @@ -0,0 +1,18 @@ +id: windows-lifecycle +title: Windows guest completes the evaluation lifecycle +input: + prompt: Run the deterministic Windows custom engine. +context: + repo_fixture: evals/fixtures/workspace +constraints: + timeout_seconds: 600 + max_turns: 1 +expect: + must_contain: + - windows-custom-engine-handled +collect_artifacts: + - result.txt +judge: + type: script + script_path: evals/fixtures/scripts/check.ps1 + timeout_seconds: 60 diff --git a/e2e/testdata/opensandbox-windows/evals/fixtures/scripts/check.ps1 b/e2e/testdata/opensandbox-windows/evals/fixtures/scripts/check.ps1 new file mode 100644 index 0000000..071b73f --- /dev/null +++ b/e2e/testdata/opensandbox-windows/evals/fixtures/scripts/check.ps1 @@ -0,0 +1,16 @@ +$ErrorActionPreference = "Stop" + +if ($env:EVAL_FINAL_MESSAGE -ne "windows-custom-engine-handled") { + Write-Error "unexpected final message: $($env:EVAL_FINAL_MESSAGE)" + exit 1 +} +if (-not (Test-Path -LiteralPath "setup-marker.txt" -PathType Leaf)) { + Write-Error "setup marker is missing" + exit 1 +} +if ((Get-Content -LiteralPath "result.txt" -Raw) -ne "windows-guest-complete") { + Write-Error "result artifact is missing or invalid" + exit 1 +} + +Write-Output "Windows setup, Custom Engine, and artifact verified" diff --git a/e2e/testdata/opensandbox-windows/evals/fixtures/workspace/agent.ps1 b/e2e/testdata/opensandbox-windows/evals/fixtures/workspace/agent.ps1 new file mode 100644 index 0000000..82bead8 --- /dev/null +++ b/e2e/testdata/opensandbox-windows/evals/fixtures/workspace/agent.ps1 @@ -0,0 +1,20 @@ +param([Parameter(Mandatory = $true)][string]$InputFile) + +$ErrorActionPreference = "Stop" +if (-not (Test-Path -LiteralPath $InputFile -PathType Leaf)) { + throw "SessionInput file is missing: $InputFile" +} +if (-not (Test-Path -LiteralPath "setup-marker.txt" -PathType Leaf)) { + throw "setup step marker is missing" +} + +Set-Content -LiteralPath "result.txt" -Value "windows-guest-complete" -NoNewline +@{ + exit_code = 0 + final_message = "windows-custom-engine-handled" + turns = 1 + transcript = @( + @{ role = "user"; content = "run" } + @{ role = "assistant"; content = "windows-custom-engine-handled" } + ) +} | ConvertTo-Json -Depth 4 -Compress diff --git a/internal/agent/custom.go b/internal/agent/custom.go index 1dff68c..30812f8 100644 --- a/internal/agent/custom.go +++ b/internal/agent/custom.go @@ -11,13 +11,16 @@ import ( "net/http" "net/url" "os" + stdpath "path" "path/filepath" + goruntime "runtime" "strconv" "strings" "time" "github.com/alibaba/skill-up/internal/customengine" "github.com/alibaba/skill-up/internal/logging" + "github.com/alibaba/skill-up/internal/platform" "github.com/alibaba/skill-up/internal/runtime" "github.com/alibaba/skill-up/pkg/transcript" ) @@ -268,8 +271,16 @@ func (a *CustomAgent) clearStaleOutputFile(ctx context.Context, rt Runtime, outp return false } outputFile = safe - q := shellQuote(outputFile) - cmd := "if [ -e " + q + " ]; then rm -f -- " + q + " && printf %s " + shellQuote(customStaleClearedMarker) + "; fi" + quote, quoteErr := rt.Shell().Quoter() + if quoteErr != nil { + logging.DebugContextf(ctx, "CustomAgent: could not select shell quoting for stale output file: %v", quoteErr) + return false + } + q := quote(outputFile) + cmd := "if [ -e " + q + " ]; then rm -f -- " + q + " && printf %s " + quote(customStaleClearedMarker) + "; fi" + if rt.Shell().Family == platform.ShellCmd { + cmd = "if exist " + q + " (del /q " + q + " && echo " + customStaleClearedMarker + ")" + } result, err := rt.Exec(ctx, cmd, ExecOptions{}) if err != nil { logging.DebugContextf(ctx, "CustomAgent: could not clear stale output file %s: %v", outputFile, err) @@ -288,7 +299,11 @@ func (a *CustomAgent) buildLocalExec(ctx context.Context, rt Runtime, opts ExecO if a.containsAPIKey(command) { return "", ExecOptions{}, errSecretInCommand("local.command") } - parts := []string{shellQuote(command)} + quote, err := rt.Shell().Quoter() + if err != nil { + return "", ExecOptions{}, fmt.Errorf("select local command quoter: %w", err) + } + parts := []string{quote(command)} for i, raw := range local.Args { arg, rErr := renderTemplate(raw, vars) if rErr != nil { @@ -297,7 +312,7 @@ func (a *CustomAgent) buildLocalExec(ctx context.Context, rt Runtime, opts ExecO if a.containsAPIKey(arg) { return "", ExecOptions{}, errSecretInCommand(fmt.Sprintf("local.args[%d]", i)) } - parts = append(parts, shellQuote(arg)) + parts = append(parts, quote(arg)) } cwd := rt.Workspace() @@ -1031,8 +1046,8 @@ func (a *CustomAgent) buildBaseVars(rt Runtime, opts ExecOptions, messages []tra return map[string]string{ "workspace": workspace, "prompt": singleTurnPrompt(messages), - "input_file": filepath.Join(workspace, customDefaultInputFile), - "output_file": filepath.Join(workspace, customDefaultOutputFile), + "input_file": runtimePathJoin(rt, customDefaultInputFile), + "output_file": runtimePathJoin(rt, customDefaultOutputFile), "model": formatAgentModel(a.Cfg.ModelProvider, a.Cfg.ModelName), "model_provider": a.Cfg.ModelProvider, "model_name": a.Cfg.ModelName, @@ -1077,7 +1092,7 @@ func (a *CustomAgent) completeTemplateVars(baseVars, renderedKwargs map[string]s // it is consistent regardless of local.cwd (the command sees the same path the // runtime upload/download APIs key on). func resolveCustomIOFiles(rt Runtime, custom *customengine.Config, vars map[string]string) (inputFile, outputFile string, err error) { - inputFile = filepath.Join(rt.Workspace(), customDefaultInputFile) + inputFile = runtimePathJoin(rt, customDefaultInputFile) if custom.Local.InputFile != "" { rendered, rErr := renderTemplate(custom.Local.InputFile, vars) if rErr != nil { @@ -1087,7 +1102,7 @@ func resolveCustomIOFiles(rt Runtime, custom *customengine.Config, vars map[stri return "", "", fmt.Errorf("local.input_file: %w", err) } } - outputFile = filepath.Join(rt.Workspace(), customDefaultOutputFile) + outputFile = runtimePathJoin(rt, customDefaultOutputFile) if custom.Local.OutputFile != "" { rendered, rErr := renderTemplate(custom.Local.OutputFile, vars) if rErr != nil { @@ -1116,6 +1131,9 @@ func workspacePath(rt Runtime, p string) (string, error) { if p == "" { return p, nil } + if rt.Shell().GOOS != goruntime.GOOS { + return lexicalGuestWorkspacePath(rt.Shell().GOOS, rt.Workspace(), p) + } ws, err := filepath.EvalSymlinks(filepath.Clean(rt.Workspace())) if err != nil { // Workspace must exist; if EvalSymlinks fails fall back to the @@ -1148,6 +1166,55 @@ func workspacePath(rt Runtime, p string) (string, error) { return resolved, nil } +func runtimePathJoin(rt Runtime, elem ...string) string { + parts := append([]string{rt.Workspace()}, elem...) + return cleanGuestPath(rt.Shell().GOOS, strings.Join(parts, "/")) +} + +func lexicalGuestWorkspacePath(targetGOOS, workspace, value string) (string, error) { + workspace = cleanGuestPath(targetGOOS, workspace) + value = cleanGuestPath(targetGOOS, value) + abs := strings.HasPrefix(value, "/") + sep := "/" + if targetGOOS == platform.GOOSWindows { + abs = len(value) >= 3 && value[1] == ':' && value[2] == '\\' + sep = `\` + } + if !abs { + if value == ".." || strings.HasPrefix(value, ".."+sep) { + return "", fmt.Errorf("path %q escapes the runtime workspace", value) + } + value = cleanGuestPath(targetGOOS, workspace+sep+value) + } + prefix := workspace + sep + inside := value == workspace || strings.HasPrefix(value, prefix) + if targetGOOS == platform.GOOSWindows { + inside = strings.EqualFold(value, workspace) || strings.HasPrefix(strings.ToLower(value), strings.ToLower(prefix)) + } + if !inside { + return "", fmt.Errorf("path %q escapes the runtime workspace", value) + } + return value, nil +} + +func cleanGuestPath(targetGOOS, value string) string { + if targetGOOS != platform.GOOSWindows { + return stdpath.Clean(strings.ReplaceAll(value, `\`, "/")) + } + value = strings.ReplaceAll(value, `\`, "/") + volume := "" + if len(value) >= 2 && value[1] == ':' { + volume, value = value[:2], value[2:] + } + rooted := strings.HasPrefix(value, "/") + clean := strings.ReplaceAll(stdpath.Clean(value), "/", `\`) + if rooted { + clean = strings.TrimPrefix(clean, `\`) + return volume + `\` + strings.TrimPrefix(clean, `.`+`\`) + } + return volume + clean +} + // resolveExistingPrefix returns abs with its longest existing ancestor passed // through filepath.EvalSymlinks and the trailing unresolved segments // re-appended. When no ancestor exists or EvalSymlinks fails for an unrelated diff --git a/internal/agent/custom_test.go b/internal/agent/custom_test.go index 5e4c747..5fd4638 100644 --- a/internal/agent/custom_test.go +++ b/internal/agent/custom_test.go @@ -10,6 +10,7 @@ import ( "github.com/alibaba/skill-up/internal/config" "github.com/alibaba/skill-up/internal/credential" + "github.com/alibaba/skill-up/internal/platform" "github.com/alibaba/skill-up/internal/runtime" "github.com/alibaba/skill-up/pkg/transcript" ) @@ -24,6 +25,54 @@ func newCustomTestRuntime(t *testing.T) *runtime.NoneRuntime { return rt } +type customGuestPathRuntime struct { + runtime.Runtime + + workspace string + shell platform.Shell +} + +func (r *customGuestPathRuntime) Workspace() string { return r.workspace } +func (r *customGuestPathRuntime) Shell() platform.Shell { return r.shell } + +func TestCustomEngineWindowsGuestPathsAndCommandQuoting(t *testing.T) { + rt := &customGuestPathRuntime{ + workspace: `C:\workspace`, + shell: platform.Shell{GOOS: platform.GOOSWindows, Family: platform.ShellCmd}, + } + + if got, err := workspacePath(rt, `inputs/messages.json`); err != nil || got != `C:\workspace\inputs\messages.json` { + t.Fatalf("workspacePath = %q, %v", got, err) + } + if _, err := workspacePath(rt, `..\escape.json`); err == nil { + t.Fatal("workspacePath accepted Windows traversal") + } + if got := runtimePathJoin(rt, "inputs", "messages.json"); got != `C:\workspace\inputs\messages.json` { + t.Fatalf("runtimePathJoin = %q", got) + } + + ag := customLocalAgent(&config.CustomEngineConfig{}) + custom := &config.CustomEngineConfig{Local: &config.CustomLocalConfig{ + Command: "powershell.exe", + Args: []string{"-File", `${workspace}\agent.ps1`, `${input_file}`}, + }} + vars := map[string]string{ + "workspace": `C:\workspace`, + "input_file": `C:\workspace\inputs\messages.json`, + } + command, opts, err := ag.buildLocalExec(context.Background(), rt, ExecOptions{}, custom, vars, 30) + if err != nil { + t.Fatalf("buildLocalExec: %v", err) + } + want := `"powershell.exe" "-File" "C:\workspace\agent.ps1" "C:\workspace\inputs\messages.json"` + if command != want { + t.Fatalf("command = %q, want %q", command, want) + } + if opts.Cwd != `C:\workspace` { + t.Fatalf("cwd = %q", opts.Cwd) + } +} + func customLocalAgent(custom *config.CustomEngineConfig) *CustomAgent { return NewCustomAgent(Config{Name: "my-agent", Custom: custom}) } diff --git a/internal/config/defaults.yaml b/internal/config/defaults.yaml index f4224c1..89870fb 100644 --- a/internal/config/defaults.yaml +++ b/internal/config/defaults.yaml @@ -3,6 +3,8 @@ schema_version: v1alpha1 environment: type: none workspace_mount: /workspace + # platform and resources are optional OpenSandbox settings; omission keeps + # the existing Linux profile and SDK resource defaults. env: {} mcp: diff --git a/internal/config/schema.go b/internal/config/schema.go index 2e9aa92..7e85e55 100644 --- a/internal/config/schema.go +++ b/internal/config/schema.go @@ -1,6 +1,7 @@ package config import ( + "strings" "time" "github.com/alibaba/skill-up/internal/customengine" @@ -24,6 +25,8 @@ type EvalConfig struct { type Environment struct { Type string `yaml:"type"` // none, opensandbox, docker Image string `yaml:"image,omitempty"` + Platform *Platform `yaml:"platform,omitempty"` + Resources *Resources `yaml:"resources,omitempty"` SandboxTemplate string `yaml:"sandbox_template,omitempty"` WorkspaceMount string `yaml:"workspace_mount,omitempty"` Env map[string]string `yaml:"env,omitempty"` @@ -38,6 +41,20 @@ type Environment struct { AllowedEgress []string `yaml:"allowed_egress,omitempty"` // FQDN/wildcard egress allowlist for allow_declared } +// Platform selects the target operating system and architecture for an +// OpenSandbox runtime. +type Platform struct { + OS string `yaml:"os"` + Arch string `yaml:"arch"` +} + +// Resources optionally overrides OpenSandbox runtime resource limits. +type Resources struct { + CPU string `yaml:"cpu,omitempty"` + Memory string `yaml:"memory,omitempty"` + Disk string `yaml:"disk,omitempty"` +} + // ToRuntimeConfig converts Environment to runtime.Config. func (e Environment) ToRuntimeConfig() runtime.Config { setupSteps := make([]runtime.SetupStep, len(e.SetupSteps)) @@ -45,9 +62,24 @@ func (e Environment) ToRuntimeConfig() runtime.Config { setupSteps[i] = runtime.SetupStep{Run: s.Run} } + var platform *runtime.Platform + if e.Platform != nil { + platform = &runtime.Platform{OS: strings.TrimSpace(e.Platform.OS), Arch: strings.TrimSpace(e.Platform.Arch)} + } + var resources *runtime.Resources + if e.Resources != nil { + resources = &runtime.Resources{ + CPU: strings.TrimSpace(e.Resources.CPU), + Memory: strings.TrimSpace(e.Resources.Memory), + Disk: strings.TrimSpace(e.Resources.Disk), + } + } + return runtime.Config{ Type: e.Type, Image: e.Image, + Platform: platform, + Resources: resources, WorkspaceMount: e.WorkspaceMount, Env: e.Env, SetupSteps: setupSteps, diff --git a/internal/config/schema_test.go b/internal/config/schema_test.go index c52d8b5..21b5911 100644 --- a/internal/config/schema_test.go +++ b/internal/config/schema_test.go @@ -3,6 +3,8 @@ package config import ( "testing" "time" + + "gopkg.in/yaml.v3" ) func TestDefaultEvalConfig(t *testing.T) { //nolint:cyclop,gocyclo // exhaustive default-config field assertions @@ -117,6 +119,51 @@ func TestEnvironmentToRuntimeConfig_OpenSandboxFields(t *testing.T) { } } +func TestEnvironmentToRuntimeConfig_PlatformAndResources(t *testing.T) { + t.Parallel() + + rtCfg := Environment{ + Type: "opensandbox", + Platform: &Platform{OS: " windows ", Arch: " amd64 "}, + Resources: &Resources{CPU: " 8 ", Memory: " 16Gi ", Disk: " 128Gi "}, + }.ToRuntimeConfig() + + if rtCfg.Platform == nil || rtCfg.Platform.OS != "windows" || rtCfg.Platform.Arch != "amd64" { + t.Fatalf("runtime config platform mismatch: %+v", rtCfg.Platform) + } + if rtCfg.Resources == nil || rtCfg.Resources.CPU != "8" || rtCfg.Resources.Memory != "16Gi" || rtCfg.Resources.Disk != "128Gi" { + t.Fatalf("runtime config resources mismatch: %+v", rtCfg.Resources) + } +} + +func TestEnvironmentPlatformAndResourcesYAML(t *testing.T) { + t.Parallel() + + var cfg EvalConfig + err := yaml.Unmarshal([]byte(` +schema_version: v1alpha1 +environment: + type: opensandbox + image: dockurr/windows:latest + platform: + os: windows + arch: amd64 + resources: + cpu: "8" + memory: 16Gi + disk: 128Gi +`), &cfg) + if err != nil { + t.Fatalf("unmarshal eval config: %v", err) + } + if cfg.Environment.Platform == nil || *cfg.Environment.Platform != (Platform{OS: "windows", Arch: "amd64"}) { + t.Fatalf("Environment.Platform = %+v", cfg.Environment.Platform) + } + if cfg.Environment.Resources == nil || *cfg.Environment.Resources != (Resources{CPU: "8", Memory: "16Gi", Disk: "128Gi"}) { + t.Fatalf("Environment.Resources = %+v", cfg.Environment.Resources) + } +} + func TestEnvironmentToRuntimeConfig_OpenSandboxKwargs(t *testing.T) { t.Parallel() diff --git a/internal/config/validator.go b/internal/config/validator.go index f6f5353..efe73f9 100644 --- a/internal/config/validator.go +++ b/internal/config/validator.go @@ -73,6 +73,7 @@ func (v *Validator) ValidateEvalConfig(cfg *EvalConfig) error { if cfg.Environment.Type == runtimeTypeDocker { errs = append(errs, validateDockerEnvironment(cfg.Environment)...) } + errs = append(errs, validateOpenSandboxSettings(cfg.Environment)...) errs = append(errs, validateNetworkPolicy(cfg.Environment)...) @@ -560,6 +561,84 @@ func isValidRuntimeType(t string) bool { return t == runtimeTypeNone || t == runtimeTypeOpenSandbox || t == runtimeTypeDocker } +func validateOpenSandboxSettings(env Environment) []string { + errs := validateOpenSandboxPlatform(env) + errs = append(errs, validateOpenSandboxResources(env)...) + return append(errs, validateOpenSandboxWorkspace(env)...) +} + +func validateOpenSandboxWorkspace(env Environment) []string { + mount := strings.TrimSpace(env.WorkspaceMount) + if env.Type != runtimeTypeOpenSandbox || mount == "" { + return nil + } + targetOS := "linux" + if env.Platform != nil && strings.TrimSpace(env.Platform.OS) != "" { + targetOS = strings.ToLower(strings.TrimSpace(env.Platform.OS)) + } + abs := path.IsAbs(mount) + if targetOS == "windows" { + abs = len(mount) >= 3 && mount[1] == ':' && (mount[2] == '\\' || mount[2] == '/') + } + if !abs { + return []string{fmt.Sprintf("environment.workspace_mount must be an absolute %s guest path, got %q", targetOS, mount)} + } + return nil +} + +func validateOpenSandboxPlatform(env Environment) []string { + if env.Platform == nil { + return nil + } + if env.Type != runtimeTypeOpenSandbox { + return []string{"environment.platform requires environment.type opensandbox"} + } + + var errs []string + osName := strings.TrimSpace(env.Platform.OS) + arch := strings.TrimSpace(env.Platform.Arch) + if osName == "" && arch == "" { + errs = append(errs, "environment.platform.os and environment.platform.arch are required") + } else if (osName == "") != (arch == "") { + errs = append(errs, "environment.platform.os and environment.platform.arch must be set together") + } + if osName != "" && osName != "linux" && osName != "windows" { + errs = append(errs, "environment.platform.os must be one of: linux, windows") + } + if arch != "" && arch != "amd64" && arch != "arm64" { + errs = append(errs, "environment.platform.arch must be one of: amd64, arm64") + } + return errs +} + +func validateOpenSandboxResources(env Environment) []string { + if env.Resources == nil { + return nil + } + if env.Type != runtimeTypeOpenSandbox { + return []string{"environment.resources requires environment.type opensandbox"} + } + if env.Resources.CPU == "" && env.Resources.Memory == "" && env.Resources.Disk == "" { + return []string{"environment.resources must set at least one of: cpu, memory, disk"} + } + + var errs []string + fields := []struct { + name string + value string + }{ + {name: "cpu", value: env.Resources.CPU}, + {name: "memory", value: env.Resources.Memory}, + {name: "disk", value: env.Resources.Disk}, + } + for _, field := range fields { + if field.value != "" && strings.TrimSpace(field.value) == "" { + errs = append(errs, "environment.resources."+field.name+" must not be blank") + } + } + return errs +} + // validateDockerEnvironment collects preflight errors for fields the docker // runtime would otherwise reject only at NewDockerRuntime construction time. // Keep this in sync with the parallel checks in internal/runtime/docker.go diff --git a/internal/config/validator_test.go b/internal/config/validator_test.go index e919a03..d39af6b 100644 --- a/internal/config/validator_test.go +++ b/internal/config/validator_test.go @@ -63,6 +63,140 @@ func TestValidator_ValidateEvalConfig(t *testing.T) { }, wantErr: false, }, + { + name: "valid opensandbox windows platform with partial resource override", + cfg: &EvalConfig{ + SchemaVersion: "v1alpha1", + Environment: Environment{ + Type: "opensandbox", + Image: "dockurr/windows:latest", + Platform: &Platform{OS: "windows", Arch: "amd64"}, + Resources: &Resources{Memory: "16Gi"}, + }, + Engine: EngineConfig{Name: "claude_code"}, + Cases: CasesConfig{Files: []string{"evals/cases/test.yaml"}}, + }, + wantErr: false, + }, + { + name: "platform requires opensandbox runtime", + cfg: &EvalConfig{ + SchemaVersion: "v1alpha1", + Environment: Environment{ + Type: "none", + Platform: &Platform{OS: "windows", Arch: "amd64"}, + }, + Engine: EngineConfig{Name: "claude_code"}, + Cases: CasesConfig{Files: []string{"evals/cases/test.yaml"}}, + }, + wantErr: true, + errMsg: "environment.platform requires environment.type opensandbox", + }, + { + name: "platform requires os and arch together", + cfg: &EvalConfig{ + SchemaVersion: "v1alpha1", + Environment: Environment{ + Type: "opensandbox", + Image: "dockurr/windows:latest", + Platform: &Platform{OS: "windows"}, + }, + Engine: EngineConfig{Name: "claude_code"}, + Cases: CasesConfig{Files: []string{"evals/cases/test.yaml"}}, + }, + wantErr: true, + errMsg: "environment.platform.os and environment.platform.arch must be set together", + }, + { + name: "platform rejects empty block", + cfg: &EvalConfig{ + SchemaVersion: "v1alpha1", + Environment: Environment{ + Type: "opensandbox", + Image: "ubuntu:24.04", + Platform: &Platform{}, + }, + Engine: EngineConfig{Name: "claude_code"}, + Cases: CasesConfig{Files: []string{"evals/cases/test.yaml"}}, + }, + wantErr: true, + errMsg: "environment.platform.os and environment.platform.arch are required", + }, + { + name: "platform rejects unknown os", + cfg: &EvalConfig{ + SchemaVersion: "v1alpha1", + Environment: Environment{ + Type: "opensandbox", + Image: "custom:latest", + Platform: &Platform{OS: "plan9", Arch: "amd64"}, + }, + Engine: EngineConfig{Name: "claude_code"}, + Cases: CasesConfig{Files: []string{"evals/cases/test.yaml"}}, + }, + wantErr: true, + errMsg: "environment.platform.os must be one of: linux, windows", + }, + { + name: "platform rejects unknown arch", + cfg: &EvalConfig{ + SchemaVersion: "v1alpha1", + Environment: Environment{ + Type: "opensandbox", + Image: "custom:latest", + Platform: &Platform{OS: "windows", Arch: "riscv64"}, + }, + Engine: EngineConfig{Name: "claude_code"}, + Cases: CasesConfig{Files: []string{"evals/cases/test.yaml"}}, + }, + wantErr: true, + errMsg: "environment.platform.arch must be one of: amd64, arm64", + }, + { + name: "resources require opensandbox runtime", + cfg: &EvalConfig{ + SchemaVersion: "v1alpha1", + Environment: Environment{ + Type: "docker", + Image: "alpine:3.20", + Resources: &Resources{CPU: "2"}, + }, + Engine: EngineConfig{Name: "claude_code"}, + Cases: CasesConfig{Files: []string{"evals/cases/test.yaml"}}, + }, + wantErr: true, + errMsg: "environment.resources requires environment.type opensandbox", + }, + { + name: "resources reject empty override value", + cfg: &EvalConfig{ + SchemaVersion: "v1alpha1", + Environment: Environment{ + Type: "opensandbox", + Image: "ubuntu:24.04", + Resources: &Resources{CPU: " "}, + }, + Engine: EngineConfig{Name: "claude_code"}, + Cases: CasesConfig{Files: []string{"evals/cases/test.yaml"}}, + }, + wantErr: true, + errMsg: "environment.resources.cpu must not be blank", + }, + { + name: "resources reject empty block", + cfg: &EvalConfig{ + SchemaVersion: "v1alpha1", + Environment: Environment{ + Type: "opensandbox", + Image: "ubuntu:24.04", + Resources: &Resources{}, + }, + Engine: EngineConfig{Name: "claude_code"}, + Cases: CasesConfig{Files: []string{"evals/cases/test.yaml"}}, + }, + wantErr: true, + errMsg: "environment.resources must set at least one of: cpu, memory, disk", + }, { name: "valid config with opensandbox template", cfg: &EvalConfig{ @@ -640,6 +774,49 @@ func TestValidator_ValidateEvalConfig(t *testing.T) { wantErr: true, errMsg: "network_policy requires environment.type opensandbox", }, + { + name: "opensandbox Linux with relative workspace_mount is rejected", + cfg: &EvalConfig{ + SchemaVersion: "v1alpha1", + Environment: Environment{ + Type: "opensandbox", + WorkspaceMount: `C:\work`, + }, + Engine: EngineConfig{Name: "claude_code"}, + Cases: CasesConfig{Files: []string{"evals/cases/test.yaml"}}, + }, + wantErr: true, + errMsg: "environment.workspace_mount must be an absolute linux guest path", + }, + { + name: "opensandbox Windows accepts slash-normalized absolute workspace_mount", + cfg: &EvalConfig{ + SchemaVersion: "v1alpha1", + Environment: Environment{ + Type: "opensandbox", + Platform: &Platform{OS: "windows", Arch: "amd64"}, + WorkspaceMount: "C:/work", + }, + Engine: EngineConfig{Name: "claude_code"}, + Cases: CasesConfig{Files: []string{"evals/cases/test.yaml"}}, + }, + wantErr: false, + }, + { + name: "opensandbox Windows rejects rooted path without drive", + cfg: &EvalConfig{ + SchemaVersion: "v1alpha1", + Environment: Environment{ + Type: "opensandbox", + Platform: &Platform{OS: "windows", Arch: "amd64"}, + WorkspaceMount: `\work`, + }, + Engine: EngineConfig{Name: "claude_code"}, + Cases: CasesConfig{Files: []string{"evals/cases/test.yaml"}}, + }, + wantErr: true, + errMsg: "environment.workspace_mount must be an absolute windows guest path", + }, { name: "valid docker runtime type", cfg: &EvalConfig{ diff --git a/internal/evaluator/artifacts_collect.go b/internal/evaluator/artifacts_collect.go index 285043f..29af13d 100644 --- a/internal/evaluator/artifacts_collect.go +++ b/internal/evaluator/artifacts_collect.go @@ -2,6 +2,7 @@ package evaluator import ( "context" + "fmt" "os" "path/filepath" "strings" @@ -11,6 +12,7 @@ import ( "github.com/alibaba/skill-up/internal/config" "github.com/alibaba/skill-up/internal/logging" + "github.com/alibaba/skill-up/internal/platform" "github.com/alibaba/skill-up/internal/runtime" ) @@ -105,26 +107,33 @@ func (e *defaultEvaluator) collectGlobArtifacts(ctx context.Context, rt runtime. } // listWorkspaceFiles returns the workspace-relative paths (slash-separated, no -// leading "./") of every regular file in the runtime workspace. It uses a -// portable `find . -type f` so it works uniformly across the none, docker, and -// opensandbox runtimes. +// leading "./") of every regular file in the runtime workspace. It selects a +// native POSIX or PowerShell listing command from the runtime target shell, +// which may differ from the skill-up host. // // The `.git` directory is excluded: agent_judge runs commit a baseline into the // workspace (see prepareWorkspaceDiffState), and doublestar's `**`/`*` match // dotfile paths, so a broad glob like "**" would otherwise sweep the entire VCS // object store into the artifacts — framework noise, not agent output. func listWorkspaceFiles(ctx context.Context, rt runtime.Runtime) ([]string, error) { - result, err := rt.Exec(ctx, "find . -type f -not -path './.git/*'", runtime.ExecOptions{Cwd: rt.Workspace()}) + command := "find . -type f -not -path './.git/*'" + if rt.Shell().GOOS == platform.GOOSWindows { + command = `powershell.exe -NoProfile -Command "$root=(Get-Location).Path; Get-ChildItem -LiteralPath . -File -Recurse -Force | Where-Object { $_.FullName -notlike ($root + '\.git\*') } | ForEach-Object { $_.FullName.Substring($root.Length + 1).Replace('\','/') }"` + } + result, err := rt.Exec(ctx, command, runtime.ExecOptions{Cwd: rt.Workspace()}) if err != nil { return nil, err } + if result.ExitCode != 0 { + return nil, fmt.Errorf("list workspace files exited with code %d: %s", result.ExitCode, result.Stderr) + } var files []string for line := range strings.SplitSeq(result.Stdout, "\n") { line = strings.TrimSpace(line) if line == "" { continue } - rel := strings.TrimPrefix(line, "./") + rel := strings.ReplaceAll(strings.TrimPrefix(line, "./"), `\`, "/") if rel == "" { continue } diff --git a/internal/evaluator/artifacts_collect_test.go b/internal/evaluator/artifacts_collect_test.go index 6ef7570..cd18bf9 100644 --- a/internal/evaluator/artifacts_collect_test.go +++ b/internal/evaluator/artifacts_collect_test.go @@ -5,9 +5,11 @@ import ( "os" "path/filepath" "reflect" + "strings" "testing" "github.com/alibaba/skill-up/internal/config" + "github.com/alibaba/skill-up/internal/platform" "github.com/alibaba/skill-up/internal/runtime" ) @@ -32,6 +34,30 @@ func TestResolveCollectArtifacts(t *testing.T) { } } +func TestListWorkspaceFilesUsesWindowsGuestCommand(t *testing.T) { + var gotCommand string + rt := &mockRuntime{ + workspace: `C:\workspace`, + shell: platform.Shell{GOOS: platform.GOOSWindows, Family: platform.ShellCmd}, + execFunc: func(_ context.Context, command string, _ runtime.ExecOptions) (runtime.ExecResult, error) { + gotCommand = command + return runtime.ExecResult{Stdout: "out\\result.json\r\nnotes.txt\r\n"}, nil + }, + } + + got, err := listWorkspaceFiles(context.Background(), rt) + if err != nil { + t.Fatalf("listWorkspaceFiles: %v", err) + } + if !strings.Contains(gotCommand, "Get-ChildItem") { + t.Fatalf("Windows command = %q", gotCommand) + } + want := []string{"out/result.json", "notes.txt"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("files = %#v, want %#v", got, want) + } +} + // newNoneRuntimeWithFiles creates a real none runtime and seeds its workspace // with the given relative path -> content files. func newNoneRuntimeWithFiles(t *testing.T, files map[string]string) runtime.Runtime { diff --git a/internal/evaluator/evaluator_test.go b/internal/evaluator/evaluator_test.go index 5bab6d5..219286b 100644 --- a/internal/evaluator/evaluator_test.go +++ b/internal/evaluator/evaluator_test.go @@ -93,6 +93,7 @@ func (m *mockAgent) Run(ctx context.Context, rt runtime.Runtime, opts agent.Exec type mockRuntime struct { workspace string + shell platform.Shell downloadFileFunc func(ctx context.Context, sourcePath, targetPath string) error downloadFileCall atomic.Int32 downloadDirFunc func(ctx context.Context, sourceDir, targetDir string) error @@ -109,6 +110,9 @@ func (m *mockRuntime) RequiresProcessSandbox() bool { return true } func (m *mockRuntime) MergeEnv(_ map[string]string) {} func (m *mockRuntime) Shell() platform.Shell { + if m.shell.GOOS != "" { + return m.shell + } return platform.Shell{GOOS: platform.GOOSLinux, Family: platform.ShellPOSIX} } func (m *mockRuntime) Start(_ context.Context) error { return nil } diff --git a/internal/judge/interpreter.go b/internal/judge/interpreter.go index 0693419..8b6f9b6 100644 --- a/internal/judge/interpreter.go +++ b/internal/judge/interpreter.go @@ -163,7 +163,7 @@ func planWindowsScript(scriptPath string, shell platform.Shell) (scriptPlan, err // keep EVAL_TRANSCRIPT_PATH in that form (see envPath // below) so POSIX tools inside the script can `cat` it. args := append([]string{}, bashArgs...) - args = append(args, quote(filepath.ToSlash(remoteScript))) + args = append(args, quote(windowsPathToSlash(remoteScript))) return strings.Join(args, " ") }, // Stay inside bash for cleanup too. The .sh case already runs @@ -172,9 +172,9 @@ func planWindowsScript(scriptPath string, shell platform.Shell) (scriptPlan, err // `rm -rf ` avoids the bash -> cmd hop the // other extensions go through. cleanupCommand: func(dir string) string { - return "rm -rf " + quote(filepath.ToSlash(dir)) + return "rm -rf " + quote(windowsPathToSlash(dir)) }, - envPath: filepath.ToSlash, + envPath: windowsPathToSlash, }, nil default: return scriptPlan{}, fmt.Errorf( @@ -186,22 +186,26 @@ func planWindowsScript(scriptPath string, shell platform.Shell) (scriptPlan, err // judgeTempDir returns an absolute temporary directory for a single script // judge run, appropriate for the target OS. -func judgeTempDir(targetGOOS string) string { +func judgeTempDir(targetGOOS, workspace string) string { name := fmt.Sprintf("skill-up-judge-%d", time.Now().UnixNano()) if targetGOOS == platform.GOOSWindows { - return filepath.Join(os.TempDir(), name) + return joinForGOOS(targetGOOS, workspace, ".skill-up", "tmp", name) } - return path.Join("/tmp", name) + return path.Join(workspace, ".skill-up", "tmp", name) } // joinForGOOS joins path elements using the separator of the target OS. func joinForGOOS(targetGOOS string, elem ...string) string { if targetGOOS == platform.GOOSWindows { - return filepath.Join(elem...) + return strings.ReplaceAll(path.Join(strings.ReplaceAll(strings.Join(elem, "/"), `\`, "/")), "/", `\`) } return path.Join(elem...) } +func windowsPathToSlash(value string) string { + return strings.ReplaceAll(value, `\`, "/") +} + // shebangPOSIXShells lists interpreter basenames that the Windows planner // is willing to dispatch through Git Bash. Only `sh` and `bash` are listed: // the Windows `.sh` runner always invokes the discovered bash, so dropping diff --git a/internal/judge/interpreter_test.go b/internal/judge/interpreter_test.go index 4728cd9..df9dd76 100644 --- a/internal/judge/interpreter_test.go +++ b/internal/judge/interpreter_test.go @@ -348,10 +348,13 @@ func TestCleanupCommand_Windows_ShellScriptUsesBashRm(t *testing.T) { } func TestJudgeTempDir(t *testing.T) { - if d := judgeTempDir("linux"); !strings.HasPrefix(d, "/tmp/skill-up-judge-") { + if d := judgeTempDir("linux", "/workspace"); !strings.HasPrefix(d, "/workspace/.skill-up/tmp/skill-up-judge-") { t.Fatalf("posix judgeTempDir = %q, want /tmp/skill-up-judge- prefix", d) } - if d := judgeTempDir("windows"); !strings.Contains(d, "skill-up-judge-") { - t.Fatalf("windows judgeTempDir = %q, want skill-up-judge- substring", d) + if d := judgeTempDir("windows", `C:\workspace`); !strings.HasPrefix(d, `C:\workspace\.skill-up\tmp\skill-up-judge-`) { + t.Fatalf("windows judgeTempDir = %q, want guest workspace temp path", d) + } + if got := joinForGOOS("windows", `C:\workspace`, "nested", "file.txt"); got != `C:\workspace\nested\file.txt` { + t.Fatalf("Windows joinForGOOS = %q", got) } } diff --git a/internal/judge/script.go b/internal/judge/script.go index 16f556c..3139672 100644 --- a/internal/judge/script.go +++ b/internal/judge/script.go @@ -81,7 +81,7 @@ func (j *ScriptJudge) evaluateInRuntime(ctx context.Context, rt evalruntime.Runt return nil, fmt.Errorf("script execution failed: %w", err) } - remoteDir := judgeTempDir(targetGOOS) + remoteDir := judgeTempDir(targetGOOS, rt.Workspace()) remoteScript := joinForGOOS(targetGOOS, remoteDir, plan.uploadName) if err := rt.UploadFile(ctx, j.ScriptPath, remoteScript); err != nil { return nil, fmt.Errorf("script execution failed: upload script judge: %w", err) diff --git a/internal/runtime/opensandbox.go b/internal/runtime/opensandbox.go index 3a320fe..d803c3b 100644 --- a/internal/runtime/opensandbox.go +++ b/internal/runtime/opensandbox.go @@ -6,8 +6,8 @@ import ( "errors" "fmt" "io" + "maps" "os" - "path" "path/filepath" "strconv" "strings" @@ -25,6 +25,7 @@ import ( const ( openSandboxDefaultWorkspace = "/workspace" + openSandboxWindowsWorkspace = `C:\workspace` // openSandboxDefaultImage is intentionally empty: users must supply a // concrete image via environment.image / environment.sandbox_template or // the config schema. Shipping a vendor-specific default leaks internal @@ -42,6 +43,12 @@ const ( const openSandboxDefaultFileTransferParallelism = 8 +var openSandboxWindowsDefaultResources = opensandbox.ResourceLimits{ + "cpu": "4", + "memory": "8Gi", + "disk": "64Gi", +} + // openSandboxUploadBatchSize caps how many files a single UploadFiles request // carries. It bounds the open file descriptors held per batch so uploading a // large directory tree stays well under a typical ulimit -n. @@ -62,70 +69,12 @@ type openSandboxClient interface { var createOpenSandbox = createOpenSandboxCompat -func createOpenSandboxCompat(ctx context.Context, cfg opensandbox.ConnectionConfig, opts opensandbox.SandboxCreateOptions) (openSandboxClient, error) { - if opts.Image == "" { - return nil, errors.New("opensandbox image is required") - } - entrypoint := opts.Entrypoint - if len(entrypoint) == 0 { - entrypoint = opensandbox.DefaultEntrypoint - } - limits := opts.ResourceLimits - if limits == nil { - limits = opensandbox.DefaultResourceLimits - } - timeout := opts.TimeoutSeconds - if opts.ManualCleanup { - timeout = nil - } else if timeout == nil { - t := opensandbox.DefaultTimeoutSeconds - timeout = &t - } - - lifecycle := newOpenSandboxLifecycleClient(cfg) - created, err := lifecycle.CreateSandbox(ctx, opensandbox.CreateSandboxRequest{ - Image: &opensandbox.ImageSpec{URI: opts.Image, Auth: opts.ImageAuth}, - Entrypoint: entrypoint, - ResourceLimits: limits, - Timeout: timeout, - Env: opts.Env, - Metadata: opts.Metadata, - NetworkPolicy: opts.NetworkPolicy, - Volumes: opts.Volumes, - Extensions: opts.Extensions, - }) - if err != nil { - return nil, fmt.Errorf("opensandbox: create sandbox: %w", err) - } - - readyOpts := opensandbox.ReadyOptions{ - Timeout: opts.ReadyTimeout, - PollingInterval: opts.HealthCheckInterval, - HealthCheck: opts.HealthCheck, - } - sb, err := opensandbox.ConnectSandbox(ctx, cfg, created.ID, readyOpts) - if err != nil { - _ = lifecycle.DeleteSandbox(context.WithoutCancel(ctx), created.ID) - return nil, err - } - return sb, nil +var createOpenSandboxSDK = func(ctx context.Context, cfg opensandbox.ConnectionConfig, opts opensandbox.SandboxCreateOptions) (openSandboxClient, error) { + return opensandbox.CreateSandbox(ctx, cfg, opts) } -func newOpenSandboxLifecycleClient(cfg opensandbox.ConnectionConfig) *opensandbox.LifecycleClient { - options := []opensandbox.Option{opensandbox.WithAuthHeader(cfg.GetAuthHeader())} - if cfg.HTTPClient != nil { - options = append(options, opensandbox.WithHTTPClient(cfg.HTTPClient)) - } - if cfg.GetRequestTimeout() > 0 { - options = append(options, opensandbox.WithTimeout(cfg.GetRequestTimeout())) - } - if len(cfg.Headers) > 0 { - options = append(options, opensandbox.WithHeaders(cfg.Headers)) - } - if cfg.Retry != nil { - options = append(options, opensandbox.WithRetry(*cfg.Retry)) - } - return opensandbox.NewLifecycleClient(cfg.GetBaseURL()+"/"+opensandbox.APIVersion, cfg.GetAPIKey(), options...) +func createOpenSandboxCompat(ctx context.Context, cfg opensandbox.ConnectionConfig, opts opensandbox.SandboxCreateOptions) (openSandboxClient, error) { + return createOpenSandboxSDK(ctx, cfg, opts) } // OpenSandboxRuntime executes cases in an OpenSandbox remote sandbox. @@ -141,11 +90,18 @@ type OpenSandboxRuntime struct { // NewOpenSandboxRuntime creates a new OpenSandboxRuntime with the given config. func NewOpenSandboxRuntime(cfg Config) (*OpenSandboxRuntime, error) { - workspace := cfg.WorkspaceMount + workspace := strings.TrimSpace(cfg.WorkspaceMount) + paths := targetPathFor(targetGOOS(cfg)) if workspace == "" { - workspace = openSandboxDefaultWorkspace + if targetGOOS(cfg) == platform.GOOSWindows { + workspace = openSandboxWindowsWorkspace + } else { + workspace = openSandboxDefaultWorkspace + } + } else if !paths.isAbs(workspace) { + return nil, fmt.Errorf("opensandbox workspace_mount must be an absolute %s guest path, got %q", targetGOOS(cfg), workspace) } - return &OpenSandboxRuntime{cfg: cfg, workspace: cleanRemotePath(workspace)}, nil + return &OpenSandboxRuntime{cfg: cfg, workspace: paths.clean(workspace)}, nil } // Create provisions the sandbox environment and creates the runtime workspace. @@ -173,6 +129,8 @@ func (r *OpenSandboxRuntime) Create(ctx context.Context) error { sb, err := createOpenSandbox(ctx, r.connectionConfig(), opensandbox.SandboxCreateOptions{ Image: image, Entrypoint: r.entrypoint(), + Platform: r.platformSpec(), + ResourceLimits: r.resourceLimits(), TimeoutSeconds: durationSecondsPtr(r.cfg.SandboxTimeout), Env: r.cfg.Env, Metadata: r.cfg.Metadata, @@ -249,9 +207,13 @@ func (r *OpenSandboxRuntime) UploadFile(ctx context.Context, sourcePath, targetP if err := r.ensureCreated(); err != nil { return err } - target := r.remotePath(targetPath) - if err := r.ensureDirectory(ctx, path.Dir(target), 755); err != nil { - return fmt.Errorf("failed to create target directory %s: %w", path.Dir(target), err) + target, err := r.remotePath(targetPath) + if err != nil { + return err + } + targetDir := r.paths().dir(target) + if err := r.ensureDirectory(ctx, targetDir, 755); err != nil { + return fmt.Errorf("failed to create target directory %s: %w", targetDir, err) } return r.uploadFiles(ctx, []uploadItem{{source: sourcePath, target: target}}) } @@ -261,8 +223,11 @@ func (r *OpenSandboxRuntime) UploadDir(ctx context.Context, sourceDir, targetDir if err := r.ensureCreated(); err != nil { return err } - targetRoot := r.remotePath(targetDir) - items, dirs, err := collectUploadItems(sourceDir, targetRoot) + targetRoot, err := r.remotePath(targetDir) + if err != nil { + return err + } + items, dirs, err := collectUploadItems(sourceDir, targetRoot, r.paths()) if err != nil { return fmt.Errorf("failed to walk source directory %s: %w", sourceDir, err) } @@ -277,7 +242,7 @@ type uploadItem struct { target string } -func collectUploadItems(sourceDir, targetRoot string) ([]uploadItem, []string, error) { +func collectUploadItems(sourceDir, targetRoot string, paths targetPath) ([]uploadItem, []string, error) { var items []uploadItem dirs := []string{targetRoot} err := filepath.Walk(sourceDir, func(localPath string, info os.FileInfo, walkErr error) error { @@ -291,7 +256,7 @@ func collectUploadItems(sourceDir, targetRoot string) ([]uploadItem, []string, e if rel == "." { return nil } - remotePath := path.Join(targetRoot, filepath.ToSlash(rel)) + remotePath := paths.join(targetRoot, filepath.ToSlash(rel)) if info.IsDir() { dirs = append(dirs, remotePath) return nil @@ -362,7 +327,10 @@ func (r *OpenSandboxRuntime) DownloadFile(ctx context.Context, sourcePath, targe if err := r.ensureCreated(); err != nil { return err } - source := r.remotePath(sourcePath) + source, err := r.remotePath(sourcePath) + if err != nil { + return err + } return r.downloadRemoteFile(ctx, source, targetPath, 0) } @@ -396,7 +364,10 @@ func (r *OpenSandboxRuntime) DownloadDir(ctx context.Context, sourceDir, targetD if err := r.ensureCreated(); err != nil { return err } - source := r.remotePath(sourceDir) + source, err := r.remotePath(sourceDir) + if err != nil { + return err + } files, err := r.sandbox.SearchFiles(ctx, source, "**") if err != nil { return fmt.Errorf("failed to search sandbox directory %s: %w", source, err) @@ -464,14 +435,14 @@ func (r *OpenSandboxRuntime) downloadFiles(ctx context.Context, source, targetDi } func (r *OpenSandboxRuntime) downloadSearchResult(ctx context.Context, source, targetDir string, file opensandbox.FileInfo) error { - rel, err := remoteRelativePath(source, file.Path) + rel, err := r.paths().relative(source, file.Path) if err != nil { return err } if rel == "." { return nil } - targetPath, err := safeLocalTarget(targetDir, rel) + targetPath, err := safeLocalTarget(targetDir, r.paths().toSlash(rel)) if err != nil { return err } @@ -487,10 +458,14 @@ func (r *OpenSandboxRuntime) Exec(ctx context.Context, command string, opts Exec defer span.End() startTime := time.Now() env := mergeEnvMaps(r.cfg.Env, opts.Env) + cwd, err := r.execCwd(opts.Cwd) + if err != nil { + return ExecResult{}, err + } req := opensandbox.RunCommandRequest{ Command: command, - Cwd: r.execCwd(opts.Cwd), + Cwd: cwd, Timeout: int64(opts.TimeoutSec) * 1000, Envs: env, } @@ -558,9 +533,11 @@ func (r *OpenSandboxRuntime) MergeEnv(env map[string]string) { mergeIntoEnvBaseline(&r.cfg.Env, env) } -// Shell reports the POSIX command language used by the current Linux -// OpenSandbox profile. A future Windows profile must return its target shell. +// Shell reports the command language used by the configured guest platform. func (r *OpenSandboxRuntime) Shell() platform.Shell { + if targetGOOS(r.cfg) == platform.GOOSWindows { + return platform.Shell{GOOS: platform.GOOSWindows, Family: platform.ShellCmd} + } return platform.Shell{GOOS: platform.GOOSLinux, Family: platform.ShellPOSIX} } @@ -619,10 +596,50 @@ func (r *OpenSandboxRuntime) entrypoint() []string { if len(r.cfg.Entrypoint) > 0 { return r.cfg.Entrypoint } + if targetGOOS(r.cfg) == platform.GOOSWindows { + return nil + } // The sandbox joins entrypoint items with " && ", so the default must stay a single shell line. return []string{"tail -F /dev/null"} } +func targetGOOS(cfg Config) string { + if cfg.Platform == nil || strings.TrimSpace(cfg.Platform.OS) == "" { + return platform.GOOSLinux + } + return strings.ToLower(strings.TrimSpace(cfg.Platform.OS)) +} + +func (r *OpenSandboxRuntime) platformSpec() *opensandbox.PlatformSpec { + if r.cfg.Platform == nil { + return nil + } + return &opensandbox.PlatformSpec{ + OS: opensandbox.PlatformOS(strings.ToLower(strings.TrimSpace(r.cfg.Platform.OS))), + Arch: opensandbox.PlatformArch(strings.ToLower(strings.TrimSpace(r.cfg.Platform.Arch))), + } +} + +func (r *OpenSandboxRuntime) resourceLimits() opensandbox.ResourceLimits { + var limits opensandbox.ResourceLimits + if targetGOOS(r.cfg) == platform.GOOSWindows { + limits = maps.Clone(openSandboxWindowsDefaultResources) + } else if r.cfg.Resources != nil { + limits = maps.Clone(opensandbox.DefaultResourceLimits) + } + if r.cfg.Resources == nil { + return limits + } + for key, value := range map[string]string{ + "cpu": r.cfg.Resources.CPU, "memory": r.cfg.Resources.Memory, "disk": r.cfg.Resources.Disk, + } { + if value = strings.TrimSpace(value); value != "" { + limits[key] = value + } + } + return limits +} + func (r *OpenSandboxRuntime) networkPolicy() *opensandbox.NetworkPolicy { switch strings.ToLower(strings.TrimSpace(r.cfg.NetworkPolicy)) { case "deny_all": @@ -674,10 +691,10 @@ func (r *OpenSandboxRuntime) ensureDirectory(ctx context.Context, dir string, mo if err == nil { return nil } - quoted := shellquote.QuotePOSIX(dir) - result, execErr := r.runCommand(ctx, "/", "mkdir -p "+quoted+" && test -d "+quoted+" && test -w "+quoted, 30) + command, cwd := r.directoryFallback(dir) + result, execErr := r.runCommand(ctx, cwd, command, 30) if execErr != nil { - return err + return fmt.Errorf("%w; shell verification failed: %w", err, execErr) } if result.ExitCode == 0 { logging.WarnContextf(ctx, "OpenSandboxRuntime: directory API failed for %s, continuing after shell verification: %v", dir, err) @@ -690,26 +707,43 @@ func (r *OpenSandboxRuntime) ensureDirectory(ctx context.Context, dir string, mo } func (r *OpenSandboxRuntime) ensureDirectories(ctx context.Context, dirs []string) error { - if len(dirs) == 0 { - return nil - } - var command strings.Builder - command.WriteString("mkdir -p") for _, dir := range dirs { - command.WriteByte(' ') - command.WriteString(shellquote.QuotePOSIX(dir)) - } - result, err := r.runCommand(ctx, "/", command.String(), 30) - if err != nil { - return fmt.Errorf("mkdir -p failed: %w", err) + if err := r.ensureDirectory(ctx, dir, 755); err != nil { + return err + } } - if result.ExitCode == 0 { - return nil + return nil +} + +func (r *OpenSandboxRuntime) directoryFallback(dir string) (command, cwd string) { + if targetGOOS(r.cfg) != platform.GOOSWindows { + quoted := shellquote.QuotePOSIX(dir) + return "mkdir -p " + quoted + " && test -d " + quoted + " && test -w " + quoted, "/" } - if result.Stderr != "" { - return fmt.Errorf("mkdir -p failed with exit code %d: %s", result.ExitCode, result.Stderr) + + paths := r.paths() + probe := paths.join(dir, ".skill-up-write-probe") + nul := paths.join(dir, "NUL") + quotedDir := quoteCmdPath(dir) + quotedNUL := quoteCmdPath(nul) + quotedProbe := quoteCmdPath(probe) + command = "if not exist " + quotedNUL + " mkdir " + quotedDir + + " & if not exist " + quotedNUL + " exit /b 1" + + " & >" + quotedProbe + " echo probe" + + " & if not exist " + quotedProbe + " exit /b 1" + + " & del /q " + quotedProbe + return command, windowsVolumeRoot(dir) +} + +func quoteCmdPath(value string) string { + return `"` + strings.ReplaceAll(value, "%", "%%") + `"` +} + +func windowsVolumeRoot(value string) string { + if len(value) >= 3 && value[1] == ':' { + return strings.ToUpper(value[:2]) + `\` } - return fmt.Errorf("mkdir -p failed with exit code %d", result.ExitCode) + return `C:\` } func (r *OpenSandboxRuntime) runCommand(ctx context.Context, cwd, command string, timeoutSec int64) (ExecResult, error) { @@ -732,26 +766,19 @@ func (r *OpenSandboxRuntime) ensureCreated() error { return nil } -func (r *OpenSandboxRuntime) execCwd(cwd string) string { +func (r *OpenSandboxRuntime) execCwd(cwd string) (string, error) { if cwd == "" { - return r.workspace + return r.workspace, nil } return r.remotePath(cwd) } -func (r *OpenSandboxRuntime) remotePath(p string) string { - if p == "" || p == "." { - return r.workspace - } - clean := cleanRemotePath(p) - if strings.HasPrefix(clean, "/") { - return clean - } - return path.Join(r.workspace, clean) +func (r *OpenSandboxRuntime) remotePath(value string) (string, error) { + return r.paths().resolve(r.workspace, value) } -func cleanRemotePath(p string) string { - return path.Clean(filepath.ToSlash(p)) +func (r *OpenSandboxRuntime) paths() targetPath { + return targetPathFor(targetGOOS(r.cfg)) } func durationSecondsPtr(d time.Duration) *int { @@ -785,16 +812,7 @@ func executionToResult(exec *opensandbox.Execution) ExecResult { } func remoteRelativePath(root, file string) (string, error) { - root = strings.TrimSuffix(cleanRemotePath(root), "/") - file = cleanRemotePath(file) - if file == root { - return ".", nil - } - prefix := root + "/" - if !strings.HasPrefix(file, prefix) { - return "", fmt.Errorf("sandbox search result %s is outside source directory %s", file, root) - } - return strings.TrimPrefix(file, prefix), nil + return targetPathFor(platform.GOOSLinux).relative(root, file) } func safeLocalTarget(root, rel string) (string, error) { diff --git a/internal/runtime/opensandbox_test.go b/internal/runtime/opensandbox_test.go index 90ab478..10e25e1 100644 --- a/internal/runtime/opensandbox_test.go +++ b/internal/runtime/opensandbox_test.go @@ -8,6 +8,7 @@ import ( "io" "os" "path/filepath" + "reflect" "slices" "strconv" "strings" @@ -55,6 +56,44 @@ func setOpenSandboxTestAuth(t *testing.T) { t.Setenv(openSandboxAPIKeyEnv, "opensandbox-secret") } +func TestCreateOpenSandboxCompatDelegatesAllOptionsToSDK(t *testing.T) { + origCreateSDK := createOpenSandboxSDK + t.Cleanup(func() { createOpenSandboxSDK = origCreateSDK }) + + fake := &fakeOpenSandbox{} + wantCfg := opensandbox.ConnectionConfig{Domain: "https://sandbox.example.test", APIKey: "secret"} + wantOpts := opensandbox.SandboxCreateOptions{ + Image: "dockurr/windows:latest", + TimeoutSeconds: intPtr(600), + Env: map[string]string{"VERSION": "11"}, + Metadata: map[string]string{"case": "windows"}, + Extensions: map[string]string{"profile": "windows"}, + Platform: &opensandbox.PlatformSpec{OS: opensandbox.OSWindows, Arch: opensandbox.ArchAMD64}, + ResourceLimits: opensandbox.ResourceLimits{"cpu": "8", "memory": "16Gi", "disk": "128Gi"}, + } + + var gotCfg opensandbox.ConnectionConfig + var gotOpts opensandbox.SandboxCreateOptions + createOpenSandboxSDK = func(_ context.Context, cfg opensandbox.ConnectionConfig, opts opensandbox.SandboxCreateOptions) (openSandboxClient, error) { + gotCfg, gotOpts = cfg, opts + return fake, nil + } + + got, err := createOpenSandboxCompat(context.Background(), wantCfg, wantOpts) + if err != nil { + t.Fatalf("createOpenSandboxCompat: %v", err) + } + if got != fake { + t.Fatalf("client = %T, want fake client", got) + } + if !reflect.DeepEqual(gotCfg, wantCfg) { + t.Fatalf("connection config = %+v, want %+v", gotCfg, wantCfg) + } + if !reflect.DeepEqual(gotOpts, wantOpts) { + t.Fatalf("create options = %+v, want %+v", gotOpts, wantOpts) + } +} + func TestOpenSandboxCreateUsesSDKOptions(t *testing.T) { origCreate := createOpenSandbox t.Cleanup(func() { createOpenSandbox = origCreate }) @@ -123,6 +162,86 @@ func TestOpenSandboxRuntimeShellIsLinuxPOSIX(t *testing.T) { } } +func TestOpenSandboxRuntimeWindowsProfileDefaults(t *testing.T) { + rt, err := NewOpenSandboxRuntime(Config{ + Platform: &Platform{OS: platform.GOOSWindows, Arch: "amd64"}, + }) + if err != nil { + t.Fatalf("NewOpenSandboxRuntime: %v", err) + } + if got := rt.Workspace(); got != `C:\workspace` { + t.Fatalf("Workspace() = %q, want C:\\workspace", got) + } + wantShell := platform.Shell{GOOS: platform.GOOSWindows, Family: platform.ShellCmd} + if got := rt.Shell(); got != wantShell { + t.Fatalf("Shell() = %+v, want %+v", got, wantShell) + } + if got := rt.entrypoint(); got != nil { + t.Fatalf("entrypoint() = %#v, want nil so the SDK/server apply the Windows profile default", got) + } +} + +func TestOpenSandboxRuntimeRejectsRelativeGuestWorkspace(t *testing.T) { + tests := []Config{ + {WorkspaceMount: "relative/work"}, + {Platform: &Platform{OS: platform.GOOSWindows, Arch: "amd64"}, WorkspaceMount: `\work`}, + } + for _, cfg := range tests { + if _, err := NewOpenSandboxRuntime(cfg); err == nil { + t.Fatalf("NewOpenSandboxRuntime(%+v) accepted relative guest workspace", cfg) + } + } +} + +func TestOpenSandboxCreatePassesWindowsPlatformAndMergedResources(t *testing.T) { + origCreate := createOpenSandbox + t.Cleanup(func() { createOpenSandbox = origCreate }) + setOpenSandboxTestAuth(t) + + var gotOpts opensandbox.SandboxCreateOptions + createOpenSandbox = func(_ context.Context, _ opensandbox.ConnectionConfig, opts opensandbox.SandboxCreateOptions) (openSandboxClient, error) { + gotOpts = opts + return &fakeOpenSandbox{}, nil + } + + rt, err := NewOpenSandboxRuntime(Config{ + Image: "dockurr/windows:latest", + Platform: &Platform{OS: platform.GOOSWindows, Arch: "amd64"}, + Resources: &Resources{Memory: "16Gi"}, + Kwargs: map[string]string{"base_url": "https://sandbox.example.test"}, + Delete: true, + }) + if err != nil { + t.Fatalf("NewOpenSandboxRuntime: %v", err) + } + if err := rt.Create(context.Background()); err != nil { + t.Fatalf("Create: %v", err) + } + + wantPlatform := &opensandbox.PlatformSpec{OS: opensandbox.OSWindows, Arch: opensandbox.ArchAMD64} + if !reflect.DeepEqual(gotOpts.Platform, wantPlatform) { + t.Fatalf("Platform = %+v, want %+v", gotOpts.Platform, wantPlatform) + } + wantResources := opensandbox.ResourceLimits{"cpu": "4", "memory": "16Gi", "disk": "64Gi"} + if !reflect.DeepEqual(gotOpts.ResourceLimits, wantResources) { + t.Fatalf("ResourceLimits = %+v, want %+v", gotOpts.ResourceLimits, wantResources) + } +} + +func TestOpenSandboxLinuxResourceOverridesMergeSDKDefaults(t *testing.T) { + rt, err := NewOpenSandboxRuntime(Config{Resources: &Resources{CPU: "2", Disk: "20Gi"}}) + if err != nil { + t.Fatalf("NewOpenSandboxRuntime: %v", err) + } + want := opensandbox.ResourceLimits{"cpu": "2", "memory": "2Gi", "disk": "20Gi"} + if got := rt.resourceLimits(); !reflect.DeepEqual(got, want) { + t.Fatalf("resourceLimits() = %+v, want %+v", got, want) + } + if got := (&OpenSandboxRuntime{}).resourceLimits(); got != nil { + t.Fatalf("default Linux resourceLimits() = %+v, want nil for SDK defaults", got) + } +} + func TestOpenSandboxCreatePassesNetworkPolicy(t *testing.T) { origCreate := createOpenSandbox t.Cleanup(func() { createOpenSandbox = origCreate }) @@ -338,20 +457,28 @@ func TestOpenSandboxRuntimeStateAndPathHelpers(t *testing.T) { if got := rt.Workspace(); got != testWorkspaceMount { t.Fatalf("Workspace() = %q, want /work", got) } - if got := rt.execCwd(""); got != testWorkspaceMount { + if got, err := rt.execCwd(""); err != nil || got != testWorkspaceMount { t.Fatalf("execCwd empty = %q, want /work", got) } - if got := rt.execCwd("nested"); got != "/work/nested" { + if got, err := rt.execCwd("nested"); err != nil || got != "/work/nested" { t.Fatalf("execCwd nested = %q, want /work/nested", got) } - if got := rt.remotePath("/abs/path"); got != "/abs/path" { + if got, err := rt.remotePath("/abs/path"); err != nil || got != "/abs/path" { t.Fatalf("remotePath absolute = %q, want /abs/path", got) } - if got := rt.remotePath("../escape"); got != "/escape" { - t.Fatalf("remotePath cleans traversal relative to workspace, got %q", got) + if _, err := rt.remotePath("../escape"); err == nil { + t.Fatal("remotePath accepted traversal outside workspace") + } + + windows, err := NewOpenSandboxRuntime(Config{ + Platform: &Platform{OS: platform.GOOSWindows, Arch: "amd64"}, + WorkspaceMount: `C:/work`, + }) + if err != nil { + t.Fatalf("NewOpenSandboxRuntime Windows: %v", err) } - if got := cleanRemotePath("./nested/../file.txt"); got != "file.txt" { - t.Fatalf("cleanRemotePath = %q, want file.txt", got) + if got, err := windows.execCwd(`nested/scripts`); err != nil || got != `C:\work\nested\scripts` { + t.Fatalf("Windows execCwd = %q, err %v", got, err) } } @@ -443,6 +570,28 @@ func TestOpenSandboxDirectoryFallbacks(t *testing.T) { t.Fatalf("fallback command = %#v", fake.execs) } + windowsFake := &fakeOpenSandbox{createDirErr: errors.New("api unavailable")} + windows := &OpenSandboxRuntime{ + cfg: Config{Platform: &Platform{OS: platform.GOOSWindows, Arch: "amd64"}}, + workspace: `C:\workspace`, + sandbox: windowsFake, + } + if err := windows.ensureDirectory(context.Background(), `C:\work\a b`, 0o755); err != nil { + t.Fatalf("Windows ensureDirectory should accept shell-verified directory: %v", err) + } + if len(windowsFake.execs) != 1 { + t.Fatalf("Windows fallback execs = %#v, want one", windowsFake.execs) + } + windowsCommand := windowsFake.execs[0].Command + for _, want := range []string{`if not exist "C:\work\a b\NUL" mkdir "C:\work\a b"`, `.skill-up-write-probe`, `del /q`} { + if !strings.Contains(windowsCommand, want) { + t.Fatalf("Windows fallback command = %q, want %q", windowsCommand, want) + } + } + if windowsFake.execs[0].Cwd != `C:\` { + t.Fatalf("Windows fallback cwd = %q, want C:\\", windowsFake.execs[0].Cwd) + } + exit := 2 fake = &fakeOpenSandbox{ createDirErr: errors.New("api unavailable"), @@ -453,7 +602,7 @@ func TestOpenSandboxDirectoryFallbacks(t *testing.T) { t.Fatalf("ensureDirectory error = %v, want shell stderr", err) } - fake = &fakeOpenSandbox{execResult: &opensandbox.Execution{ExitCode: &exit}} + fake = &fakeOpenSandbox{createDirErr: errors.New("api unavailable"), execResult: &opensandbox.Execution{ExitCode: &exit}} rt = &OpenSandboxRuntime{sandbox: fake} if err := rt.ensureDirectories(context.Background(), []string{"/work/a", "/work/b"}); err == nil || !strings.Contains(err.Error(), "exit code 2") { t.Fatalf("ensureDirectories error = %v, want exit code", err) @@ -786,16 +935,11 @@ func TestOpenSandboxUploadDirUploadsFilesInSingleBatch(t *testing.T) { if string(fake.uploads["/workspace/dest/nested/other.txt"]) != "other" { t.Fatalf("uploaded nested content mismatch: %q", fake.uploads["/workspace/dest/nested/other.txt"]) } - if len(fake.createdDirs) != 0 { - t.Fatalf("created dirs = %#v, want UploadDir to use batched mkdir", fake.createdDirs) - } - if len(fake.execs) != 1 { - t.Fatalf("UploadDir execs = %#v, want one batched mkdir command", fake.execs) + if len(fake.createdDirs) != 3 { + t.Fatalf("created dirs = %#v, want API-first creation for all directories", fake.createdDirs) } - for _, want := range []string{"'/workspace/dest'", "'/workspace/dest/empty'", "'/workspace/dest/nested'"} { - if !strings.Contains(fake.execs[0].Command, want) { - t.Fatalf("UploadDir mkdir command = %q, want %s", fake.execs[0].Command, want) - } + if len(fake.execs) != 0 { + t.Fatalf("UploadDir execs = %#v, want no fallback when directory API succeeds", fake.execs) } if fake.uploadFilesCalls != 1 { t.Fatalf("UploadFiles calls = %d, want a single batched call", fake.uploadFilesCalls) @@ -803,8 +947,26 @@ func TestOpenSandboxUploadDirUploadsFilesInSingleBatch(t *testing.T) { if len(fake.uploadFilesBatchSizes) != 1 || fake.uploadFilesBatchSizes[0] != 2 { t.Fatalf("UploadFiles batch sizes = %#v, want one batch of 2 files", fake.uploadFilesBatchSizes) } - if strings.Contains(fake.execs[0].Command, "tar") { - t.Fatalf("UploadDir exec command = %q, want no tar command", fake.execs[0].Command) +} + +func TestOpenSandboxWindowsUploadDirUsesGuestPaths(t *testing.T) { + dir := t.TempDir() + writeUploadDirFixture(t, dir) + fake := &fakeOpenSandbox{} + rt := &OpenSandboxRuntime{ + cfg: Config{Platform: &Platform{OS: platform.GOOSWindows, Arch: "amd64"}}, + workspace: `C:\workspace`, + sandbox: fake, + } + + if err := rt.UploadDir(context.Background(), dir, `dest\fixtures`); err != nil { + t.Fatalf("UploadDir returned error: %v", err) + } + if string(fake.uploads[`C:\workspace\dest\fixtures\nested\other.txt`]) != "other" { + t.Fatalf("Windows uploaded paths = %#v", fake.uploads) + } + if _, err := rt.Exec(context.Background(), "echo ok", ExecOptions{Cwd: `..\escape`}); err == nil { + t.Fatal("Exec accepted Windows cwd traversal") } } diff --git a/internal/runtime/runtime.go b/internal/runtime/runtime.go index c5681a5..0dc0fe6 100644 --- a/internal/runtime/runtime.go +++ b/internal/runtime/runtime.go @@ -166,6 +166,8 @@ type SetupStep struct { type Config struct { Type string Image string + Platform *Platform + Resources *Resources WorkspaceMount string Env map[string]string SetupSteps []SetupStep @@ -187,6 +189,21 @@ type Config struct { Delete bool } +// Platform selects the target operating system and architecture for a remote +// runtime. It is currently supported by OpenSandbox. +type Platform struct { + OS string + Arch string +} + +// Resources overrides the CPU, memory, and disk limits for a remote runtime. +// Empty fields inherit the runtime profile's defaults. +type Resources struct { + CPU string + Memory string + Disk string +} + // MCPServerConfig describes a single MCP server available to a runtime. type MCPServerConfig struct { Name string diff --git a/internal/runtime/target_path.go b/internal/runtime/target_path.go new file mode 100644 index 0000000..545f5f5 --- /dev/null +++ b/internal/runtime/target_path.go @@ -0,0 +1,141 @@ +package runtime + +import ( + "fmt" + "path" + "strings" + + "github.com/alibaba/skill-up/internal/platform" +) + +// targetPath applies path semantics for a runtime guest independently of the +// skill-up host operating system. +type targetPath struct { + windows bool +} + +func targetPathFor(goos string) targetPath { + return targetPath{windows: goos == platform.GOOSWindows} +} + +func (p targetPath) clean(value string) string { + if !p.windows { + return path.Clean(value) + } + + value = strings.ReplaceAll(value, "/", `\`) + volume := "" + rest := value + if len(rest) >= 2 && rest[1] == ':' { + volume, rest = rest[:2], rest[2:] + } + rooted := strings.HasPrefix(rest, `\`) + clean := path.Clean(strings.ReplaceAll(rest, `\`, "/")) + clean = strings.ReplaceAll(clean, "/", `\`) + if rooted { + clean = strings.TrimPrefix(clean, `\`) + if clean == "" || clean == "." { + return volume + `\` + } + return volume + `\` + clean + } + return volume + clean +} + +func (p targetPath) dir(value string) string { + clean := p.clean(value) + if !p.windows { + return path.Dir(clean) + } + idx := strings.LastIndex(clean, `\`) + if idx < 0 { + return "." + } + if idx == 2 && len(clean) >= 2 && clean[1] == ':' { + return clean[:3] + } + if idx == 0 { + return `\` + } + return clean[:idx] +} + +func (p targetPath) isAbs(value string) bool { + if !p.windows { + return strings.HasPrefix(value, "/") + } + return len(value) >= 3 && value[1] == ':' && (value[2] == '\\' || value[2] == '/') +} + +func (p targetPath) join(base, elem string) string { + if p.isAbs(elem) { + return p.clean(elem) + } + sep := "/" + if p.windows { + sep = `\` + } + return p.clean(strings.TrimSuffix(base, sep) + sep + elem) +} + +func (p targetPath) resolve(workspace, value string) (string, error) { + if value == "" || value == "." { + return p.clean(workspace), nil + } + if p.isAbs(value) { + return p.clean(value), nil + } + clean := p.clean(value) + sep := "/" + if p.windows { + sep = `\` + if strings.Contains(clean, ":") { + return "", fmt.Errorf("unsafe guest path %q", value) + } + } + if clean == ".." || strings.HasPrefix(clean, ".."+sep) { + return "", fmt.Errorf("guest path %q escapes workspace %s", value, workspace) + } + return p.join(workspace, clean), nil +} + +func (p targetPath) relative(root, file string) (string, error) { + root = p.clean(root) + file = p.clean(file) + if p.equal(root, file) { + return ".", nil + } + sep := "/" + if p.windows { + sep = `\` + } + prefix := root + if !strings.HasSuffix(prefix, sep) { + prefix += sep + } + if !p.hasPrefix(file, prefix) { + return "", fmt.Errorf("sandbox search result %s is outside source directory %s", file, root) + } + return file[len(prefix):], nil +} + +func (p targetPath) toSlash(value string) string { + if p.windows { + return strings.ReplaceAll(value, `\`, "/") + } + return value +} + +func (p targetPath) equal(left, right string) bool { + if p.windows { + return strings.EqualFold(left, right) + } + return left == right +} + +func (p targetPath) hasPrefix(value, prefix string) bool { + if p.windows { + return strings.HasPrefix(strings.ToLower(value), strings.ToLower(prefix)) + } + return strings.HasPrefix(value, prefix) +} diff --git a/internal/runtime/target_path_test.go b/internal/runtime/target_path_test.go new file mode 100644 index 0000000..fe1ba47 --- /dev/null +++ b/internal/runtime/target_path_test.go @@ -0,0 +1,87 @@ +package runtime + +import ( + "testing" + + "github.com/alibaba/skill-up/internal/platform" +) + +func TestTargetPathPOSIX(t *testing.T) { + t.Parallel() + + p := targetPathFor(platform.GOOSLinux) + if got := p.clean(`/workspace/src/../result.json`); got != "/workspace/result.json" { + t.Fatalf("clean = %q", got) + } + if got := p.dir(`/workspace/result.json`); got != "/workspace" { + t.Fatalf("dir = %q", got) + } + if !p.isAbs(`/workspace`) || p.isAbs(`workspace`) { + t.Fatal("POSIX absolute-path detection changed") + } + if got, err := p.resolve(`/workspace`, `nested/result.json`); err != nil || got != "/workspace/nested/result.json" { + t.Fatalf("resolve relative = %q, %v", got, err) + } + if got, err := p.resolve(`/workspace`, `/tmp/result.json`); err != nil || got != "/tmp/result.json" { + t.Fatalf("resolve absolute = %q, %v", got, err) + } + if _, err := p.resolve(`/workspace`, `../escape`); err == nil { + t.Fatal("resolve traversal returned nil error") + } + if got, err := p.relative(`/workspace/src`, `/workspace/src/a/b.txt`); err != nil || got != "a/b.txt" { + t.Fatalf("relative = %q, %v", got, err) + } +} + +func TestTargetPathWindows(t *testing.T) { + t.Parallel() + + p := targetPathFor(platform.GOOSWindows) + if got := p.clean(`C:/workspace/src/../result.json`); got != `C:\workspace\result.json` { + t.Fatalf("clean = %q", got) + } + if got := p.dir(`C:\workspace\result.json`); got != `C:\workspace` { + t.Fatalf("dir = %q", got) + } + if !p.isAbs(`C:\workspace`) || !p.isAbs(`c:/workspace`) || p.isAbs(`\workspace`) || p.isAbs(`workspace`) { + t.Fatal("Windows absolute-path detection changed") + } + if got, err := p.resolve(`C:\workspace`, `nested/result.json`); err != nil || got != `C:\workspace\nested\result.json` { + t.Fatalf("resolve relative = %q, %v", got, err) + } + if got, err := p.resolve(`C:\workspace`, `D:/temp/result.json`); err != nil || got != `D:\temp\result.json` { + t.Fatalf("resolve absolute = %q, %v", got, err) + } + if _, err := p.resolve(`C:\workspace`, `..\escape`); err == nil { + t.Fatal("resolve traversal returned nil error") + } + if got, err := p.relative(`C:\Workspace\src`, `c:/workspace/src/a/b.txt`); err != nil || got != `a\b.txt` { + t.Fatalf("relative = %q, %v", got, err) + } + if got := p.toSlash(`a\b.txt`); got != "a/b.txt" { + t.Fatalf("toSlash = %q", got) + } +} + +func TestTargetPathRelativeRejectsOutsideRoot(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + p targetPath + root string + file string + }{ + {name: "posix sibling", p: targetPathFor(platform.GOOSLinux), root: "/workspace/src", file: "/workspace/other.txt"}, + {name: "windows sibling", p: targetPathFor(platform.GOOSWindows), root: `C:\workspace\src`, file: `C:\workspace\other.txt`}, + {name: "windows other drive", p: targetPathFor(platform.GOOSWindows), root: `C:\workspace`, file: `D:\workspace\file.txt`}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + if _, err := tt.p.relative(tt.root, tt.file); err == nil { + t.Fatal("relative outside root returned nil error") + } + }) + } +}