Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions api/src/entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
92 changes: 92 additions & 0 deletions tests/sandbox_pipe_addon_image.ts
Original file line number Diff line number Diff line change
@@ -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);
}
Loading