From b29cefb3af79b0767f94988bedc5c5d030ec0703 Mon Sep 17 00:00:00 2001 From: Dustin Healy <54083382+dustinhealy@users.noreply.github.com> Date: Fri, 16 Oct 2026 18:35:03 -0700 Subject: [PATCH] fix: resolve packaged pipe addon inside guests --- .github/workflows/ci.yml | 35 ++++++++++++ api/src/entrypoint.sh | 4 ++ tests/sandbox_pipe_addon_image.ts | 92 +++++++++++++++++++++++++++++++ 3 files changed, 131 insertions(+) create mode 100644 tests/sandbox_pipe_addon_image.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 50804e27..f0191f0a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -184,6 +184,14 @@ jobs: --mount "type=bind,source=$GITHUB_WORKSPACE/tests,target=/tests,readonly" \ --entrypoint /usr/local/bin/node \ launcher-guest-test /tests/pipe_runtime_image.cjs + - name: Execute through the packaged guest API without build-stage ENV + run: | + for addon_path in unset empty override; do + docker run --rm --privileged --network none \ + --mount "type=bind,source=$GITHUB_WORKSPACE,target=/work,readonly" \ + --entrypoint bun launcher-guest-test \ + /work/tests/sandbox_pipe_addon_image.ts "$addon_path" + done - name: Load legacy netfilter modules for hosted-app isolation run: sudo modprobe -a ip_tables ip6_tables iptable_filter ip6table_filter xt_conntrack xt_owner ipt_REJECT ip6t_REJECT - name: Verify supervisor filter and anonymous stdio @@ -225,6 +233,33 @@ jobs: nsjail-ipc-test \ python3 /tests/tool_call_pipes.py /policy /broker.cjs /client.py + guest-api-image-tests: + name: Guest API Execution (${{ matrix.dockerfile }}) + runs-on: ubuntu-latest + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + include: + - dockerfile: api/Dockerfile + target: sandbox-build + - dockerfile: docker/Dockerfile.worker-sandbox + target: sandbox-rootfs + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - name: Build the production guest rootfs + run: docker build --target "${{ matrix.target }}" -t guest-api-test -f "${{ matrix.dockerfile }}" . + - name: Execute through the packaged guest API without build-stage ENV + # Test the roots copied into the direct/KVM runners, removing the + # Docker ENV metadata that does not survive that COPY/VM boundary. + run: | + for addon_path in unset empty override; do + docker run --rm --privileged --network none \ + --mount "type=bind,source=$GITHUB_WORKSPACE,target=/work,readonly" \ + --entrypoint bun guest-api-test \ + /work/tests/sandbox_pipe_addon_image.ts "$addon_path" + done + service-unit-tests: name: Service Unit Tests runs-on: ubuntu-latest diff --git a/api/src/entrypoint.sh b/api/src/entrypoint.sh index 0f93981b..a3e9af3b 100755 --- a/api/src/entrypoint.sh +++ b/api/src/entrypoint.sh @@ -3,6 +3,10 @@ set -e echo "Starting NsJail sandbox API..." +# Rootfs COPY carries the addon file, but not the build image's Docker ENV. +# Establish its installed path inside the guest, after the VM/chroot boundary. +export SANDBOX_PIPE_ADDON="${SANDBOX_PIPE_ADDON:-/usr/local/lib/anonymous-pipes.node}" + # Raise the RLIMIT_NOFILE limits so per-job nsjail can set its own soft # limit (SANDBOX_MAX_OPEN_FILES) without EPERM. The AWS Lambda MicroVM base # image ships a 1024 hard cap, below the sandbox default of 2048; nsjail diff --git a/tests/sandbox_pipe_addon_image.ts b/tests/sandbox_pipe_addon_image.ts new file mode 100644 index 00000000..82193ead --- /dev/null +++ b/tests/sandbox_pipe_addon_image.ts @@ -0,0 +1,92 @@ +// Run in a disposable guest rootfs image with --privileged --network none. +// Exercise the installed entrypoint and bundled API, not the IPC test stage: +// Docker ENV metadata is absent when the rootfs is copied into a runner/VM. +import assert from 'node:assert/strict'; +import { generateKeyPairSync } from 'node:crypto'; +import { mkdirSync, writeFileSync, copyFileSync, unlinkSync } from 'node:fs'; +import { spawn } from 'node:child_process'; +import { once } from 'node:events'; +import { + EXECUTION_MANIFEST_HEADER, + executionManifestBodySha256, + signExecutionManifestWithPrivateKey, +} from '../api/src/execution-manifest'; + +const mode = process.argv[2] ?? 'unset'; +assert(['unset', 'empty', 'override'].includes(mode)); +const env = { ...process.env }; +delete env.SANDBOX_PIPE_ADDON; +if (mode === 'empty') env.SANDBOX_PIPE_ADDON = ''; +if (mode === 'override') { + env.SANDBOX_PIPE_ADDON = '/opt/override-pipes.node'; + copyFileSync('/usr/local/lib/anonymous-pipes.node', env.SANDBOX_PIPE_ADDON); + unlinkSync('/usr/local/lib/anonymous-pipes.node'); +} + +// A tiny runtime uses the image's shell, avoiding the unrelated package build. +const runtime = '/pkgs/pipe-smoke/1.0.0'; +mkdirSync(runtime, { recursive: true }); +writeFileSync(`${runtime}/pkg-info.json`, JSON.stringify({ language: 'pipe-smoke', version: '1.0.0' })); +writeFileSync(`${runtime}/.package-installed`, ''); +writeFileSync(`${runtime}/run`, '#!/bin/sh\nexec /bin/sh "$@"\n', { mode: 0o755 }); + +const { privateKey, publicKey } = generateKeyPairSync('ed25519'); +const server = spawn('/sandbox_api/entrypoint.sh', [], { + env: { + ...env, + // Hosted Docker runners do not delegate cgroups. NsJail namespaces, + // per-job identities, seccomp and the supervisor filter remain enabled. + SANDBOX_USE_CGROUPV2: 'false', + SANDBOX_REQUIRE_EGRESS_MANIFEST: 'true', + SANDBOX_EXECUTION_MANIFEST_PUBLIC_KEY: publicKey.export({ type: 'spki', format: 'pem' }).toString(), + SANDBOX_LOG_LEVEL: 'error', + }, + stdio: ['ignore', 'inherit', 'inherit'], +}); +const exited = once(server, 'exit'); +const base = 'http://127.0.0.1:2000/api/v2'; +try { + let ready = false; + for (let attempt = 0; attempt < 100; attempt++) { + assert.equal(server.exitCode, null, 'guest entrypoint exited before API readiness'); + ready = await fetch(`${base}/health`, { signal: AbortSignal.timeout(1000) }) + .then(response => response.ok).catch(() => false); + if (ready) break; + await Bun.sleep(100); + } + assert(ready, 'guest API did not become ready'); + const body = { + session_id: 'pipe-addon-smoke', + language: 'pipe-smoke', + version: '1.0.0', + stdin: 'packaged anonymous pipes work\n', + files: [{ name: 'main.sh', content: 'IFS= read -r line; printf "%s\\n" "$line"; printf "pipe stderr\\n" >&2' }], + }; + const request = (manifest?: string) => fetch(`${base}/execute`, { + method: 'POST', + headers: { 'content-type': 'application/json', ...(manifest ? { [EXECUTION_MANIFEST_HEADER]: manifest } : {}) }, + body: JSON.stringify(body), + signal: AbortSignal.timeout(15000), + }); + assert.equal((await request()).status, 401, 'unsigned execution must be rejected'); + const now = Math.floor(Date.now() / 1000); + const manifest = signExecutionManifestWithPrivateKey({ + v: 1, exec_id: 'pipe-smoke', tenant_id: 'test', user_id: 'test', session_key: 'test', + input_files: [], read_sessions: [], output_session_id: body.session_id, + max_upload_bytes: 1024, max_output_files: 1, max_requests: 10, + iat: now, exp: now + 60, tool_call_socket: false, + execute_body_sha256: executionManifestBodySha256(body), + }, privateKey.export({ type: 'pkcs8', format: 'pem' }).toString()); + const response = await request(manifest); + const result = await response.json(); + assert.equal(response.status, 200, JSON.stringify(result)); + assert.equal(result.run.code, 0, JSON.stringify(result)); + assert.equal(result.run.stdout, body.stdin); + assert.equal(result.run.stderr, 'pipe stderr\n'); + console.log(`PASS: packaged guest execution with ${mode} addon path`); +} finally { + server.kill('SIGTERM'); + const timer = setTimeout(() => server.kill('SIGKILL'), 5000); + await exited; + clearTimeout(timer); +}