Skip to content
Open
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
3 changes: 3 additions & 0 deletions NOTICE
Original file line number Diff line number Diff line change
@@ -1,2 +1,5 @@
Code Interpreter
Copyright 2026 ClickHouse, Inc.

This product includes RTK v0.45.0 (https://github.com/rtk-ai/rtk),
Copyright 2024 Patrick Szymkowiak, licensed under the Apache License 2.0.
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,28 @@ descriptors in the launcher.
Setting `KVM_ENABLED=false` still selects the directory-root target and the
host package mount automatically for direct NsJail development.

## Optional Bash output filtering

Bash executions can opt into [RTK](https://github.com/rtk-ai/rtk) command
rewriting per request by setting `shell_output_filter` to `rtk`:

```json
{
"lang": "bash",
"code": "ls -la",
"shell_output_filter": "rtk"
}
```

Omitting the field (or setting it to `raw`) preserves the original execution
path, which makes side-by-side evaluation straightforward. RTK `v0.45.0` is
source-pinned to its immutable release commit and preinstalled in the sandbox
image; it can also be invoked directly from Bash. Rewriting happens inside
NsJail and fails open to the original script when RTK does not support a
command or cannot run. Prometheus
metrics expose Bash execution counts, outcomes, and stdout/stderr byte sizes by
the `raw` or `rtk` filter without adding request-specific labels.

Local Docker Compose files set `CODEAPI_INTERNAL_SERVICE_TOKEN` to a shared
development value by default. Production deployments must override it with a
strong secret; when it is unset, file object routes and Tool Call Server
Expand Down
16 changes: 16 additions & 0 deletions api/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -60,9 +60,24 @@ RUN apt-install \
RUN mkdir -p /pkgs

COPY docker/package-init.sh /package-init.sh
COPY docker/bash-run.sh /bash-run.sh
COPY javascript-packages.txt /javascript-packages.txt
RUN chmod +x /package-init.sh && FORCE_REBUILD=true /package-init.sh

# ============================================================================
# Stage 1c: Build the RTK binary against the sandbox's glibc baseline. The
# upstream arm64 release requires a newer glibc than Debian bookworm provides.
# ============================================================================
FROM rust:1.91-bookworm AS rtk-builder

ARG RTK_COMMIT=b34be37caf3796b69a50952a28e60e32b5daad43
RUN git clone --depth 1 --branch v0.45.0 https://github.com/rtk-ai/rtk.git /rtk \
&& cd /rtk \
&& git checkout "$RTK_COMMIT" \
&& test "$(git rev-parse HEAD)" = "$RTK_COMMIT" \
&& cargo build --locked --release \
&& ./target/release/rtk --version

# ============================================================================
# Stage 2: Build sandbox rootfs
# ============================================================================
Expand Down Expand Up @@ -108,6 +123,7 @@ RUN sed -i '/en_US.UTF-8/s/^# //g' /etc/locale.gen && locale-gen

COPY --from=nsjail-builder /nsjail/nsjail /usr/sbin/nsjail
COPY --from=nsjail-builder /usr/local/bin/spec-guard /usr/local/bin/spec-guard
COPY --from=rtk-builder /rtk/target/release/rtk /usr/local/bin/rtk
RUN chmod +x /usr/sbin/nsjail

# The tool-call socket proxy must run under Node, not Bun. Bun's node:http
Expand Down
4 changes: 4 additions & 0 deletions api/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,3 +109,7 @@ curl -s http://localhost:2000/api/v2/execute \
-H 'Content-Type: application/json' \
-d '{"language":"python","version":"3.14.4","files":[{"content":"print(42)"}]}' | jq
```

For Bash, add `"shell_output_filter":"rtk"` to opt into compact RTK command
rewrites for that request. Omit the field or use `"raw"` to preserve the
unfiltered path. Other runtimes reject the field.
23 changes: 22 additions & 1 deletion api/src/api/v2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,11 @@ import {
import { EXECUTION_MANIFEST_HEADER, ExecutionManifestError, type ExecutionManifestClaims } from '../execution-manifest';
import { verifyExecuteRequestManifest } from '../execution-manifest-request';
import { EGRESS_GRANT_HEADER } from '../egress';
import { activeSandboxExecutions, recordSandboxExecution } from '../metrics';
import {
activeSandboxExecutions,
recordSandboxExecution,
recordSandboxOutputBytes,
} from '../metrics';
import { classifySandboxSafeError } from '../safe-error';
import { withSpan } from '../telemetry';
import { checkSandboxWorkspaceHealth } from '../workspace-isolation';
Expand All @@ -31,6 +35,10 @@ import {
pruneInputCache,
storeCachedInputs,
} from '../session-inputs';
import {
resolveShellOutputFilter,
type ShellOutputFilter,
} from '../../../shared/shell-output-filter';

const router = express.Router();
const SYNTHETIC_PRINCIPAL_SOURCE = 'synthetic_test';
Expand Down Expand Up @@ -132,6 +140,7 @@ export interface ExecuteRequestBody {
output_session_id?: string;
language: string;
version: string;
shell_output_filter?: ShellOutputFilter;
args?: string[];
stdin?: string;
files: TFile[];
Expand Down Expand Up @@ -266,6 +275,7 @@ function getJob(
if (!rt) {
throw { message: `${language}-${version} runtime is unknown` };
}
const shellOutputFilter = resolveShellOutputFilter(body.shell_output_filter, rt.language);

if (
rt.language !== 'file' &&
Expand Down Expand Up @@ -327,6 +337,7 @@ function getJob(
compile: compile_memory_limit ?? rt.memory_limits.compile,
},
extra_env_vars: sanitizeEnvVars(env_vars),
shell_output_filter: shellOutputFilter,
output_session_id: body.output_session_id,
egress_grant: egressGrantToken,
tool_call_socket_enabled: toolCallSocketEnabled,
Expand Down Expand Up @@ -398,6 +409,7 @@ router.post('/execute', express.json({ limit: config.execute_body_limit }), asyn
let cleanedUp = false;
let activeExecution = false;
let metricsLanguage = 'unknown';
let metricsOutputFilter: ShellOutputFilter = 'raw';
let metricsOutcome: Parameters<typeof recordSandboxExecution>[0]['outcome'] = 'execution_error';
let primeCompleted = false;

Expand Down Expand Up @@ -466,6 +478,7 @@ router.post('/execute', express.json({ limit: config.execute_body_limit }), asyn
req.headers[RUNTIME_SESSION_ID_HEADER],
);
metricsLanguage = job.runtime.language;
metricsOutputFilter = job.shellOutputFilter;
markActiveExecution();
} catch (error) {
metricsOutcome = 'bad_request';
Expand Down Expand Up @@ -510,6 +523,13 @@ router.post('/execute', express.json({ limit: config.execute_body_limit }), asyn
result.run = result.compile;
}

recordSandboxOutputBytes({
language: job.runtime.language,
outputFilter: job.shellOutputFilter,
stdout: result.run?.stdout ?? '',
stderr: result.run?.stderr ?? '',
});

if (result.files && result.files.length > 0) {
/* Upload returns the set of file IDs that were actually transferred to
* the file server. Files we minted IDs for but failed to ship (e.g. the
Expand Down Expand Up @@ -584,6 +604,7 @@ router.post('/execute', express.json({ limit: config.execute_body_limit }), asyn
}
recordSandboxExecution({
language: metricsLanguage,
outputFilter: metricsOutputFilter,
outcome: metricsOutcome,
durationSeconds: (performance.now() - started) / 1000,
});
Expand Down
86 changes: 86 additions & 0 deletions api/src/bash-run.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
import { execFileSync } from 'node:child_process';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';

const wrapper = path.resolve(__dirname, '../../docker/bash-run.sh');

describe('Bash RTK run wrapper', () => {
let tempDir: string;
let sourcePath: string;
let mockBin: string;

beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codeapi-rtk-test-'));
sourcePath = path.join(tempDir, 'script.sh');
mockBin = path.join(tempDir, 'bin');
fs.mkdirSync(mockBin);
fs.writeFileSync(sourcePath, "printf 'raw:%s\\n' \"${1:-none}\"\n");
fs.writeFileSync(path.join(mockBin, 'rtk'), `#!/bin/bash
case "\${MOCK_RTK_STATUS:-0}" in
0|3) printf '%s\\n' 'printf '\"'\"'filtered:%s\\n'\"'\"' "$1"' ;;
*) printf '%s\\n' 'printf '\"'\"'must-not-run\\n'\"'\"'' ;;
esac
exit "\${MOCK_RTK_STATUS:-0}"
`);
fs.chmodSync(path.join(mockBin, 'rtk'), 0o755);
});

afterEach(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
});

function run(options: {
filter?: 'raw' | 'rtk';
status?: number;
args?: string[];
} = {}): string {
return execFileSync('bash', [wrapper, sourcePath, ...(options.args ?? [])], {
encoding: 'utf8',
env: {
...process.env,
PATH: `${mockBin}:${process.env.PATH ?? '/usr/bin:/bin'}`,
CODEAPI_SHELL_OUTPUT_FILTER: options.filter ?? 'raw',
MOCK_RTK_STATUS: String(options.status ?? 0),
},
});
}

it('preserves raw execution when the request does not opt in', () => {
expect(run()).toBe('raw:none\n');
});

it('executes RTK rewrites and preserves user script arguments', () => {
expect(run({ filter: 'rtk', args: ['argument'] })).toBe('filtered:argument\n');
});

it('preserves the submitted path as $0 and BASH_ARGV0 for rewrites', () => {
fs.writeFileSync(path.join(mockBin, 'rtk'), `#!/bin/bash
printf '%s\\n' 'printf "identity:%s:%s:%s\\n" "$0" "$BASH_ARGV0" "$1"'
`);
fs.chmodSync(path.join(mockBin, 'rtk'), 0o755);

expect(run({ filter: 'rtk', args: ['argument'] })).toBe(
`identity:${sourcePath}:${sourcePath}:argument\n`,
);
});

it('preserves file execution for scripts that inspect BASH_SOURCE', () => {
fs.writeFileSync(
sourcePath,
'printf \'identity:%s:%s\\n\' "${BASH_SOURCE[0]}" "$0"\n',
);

expect(run({ filter: 'rtk' })).toBe(`identity:${sourcePath}:${sourcePath}\n`);
});

it('accepts RTK ask rewrites because the API request already authorizes execution', () => {
expect(run({ filter: 'rtk', status: 3 })).toBe('filtered:\n');
});

it('fails open to the original script when RTK cannot rewrite the command', () => {
expect(run({ filter: 'rtk', status: 1, args: ['original'] })).toBe('raw:original\n');
expect(run({ filter: 'rtk', status: 2, args: ['original'] })).toBe('raw:original\n');
});
});
12 changes: 12 additions & 0 deletions api/src/job-helpers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
inputsLiveUnder,
mapWithConcurrency,
mimeTypeFor,
filterExtraEnvVars,
} from './job';
import type { Runtime } from './runtime';
import type { TFile } from './job';
Expand Down Expand Up @@ -41,6 +42,17 @@ function makeRuntime(overrides: Partial<Runtime> & { language: string; pkgdir: s
};
}

describe('filterExtraEnvVars', () => {
it('prevents callers from overriding internal RTK controls', () => {
expect(filterExtraEnvVars({
CODEAPI_SHELL_OUTPUT_FILTER: 'rtk',
RTK_DB_PATH: '/mnt/data/history.db',
RTK_TEE: '1',
USER_VALUE: 'allowed',
})).toEqual({ USER_VALUE: 'allowed' });
});
});

describe('resolveOriginalName', () => {
function responseWithHeader(value?: string): Response {
const headers = new Headers();
Expand Down
19 changes: 19 additions & 0 deletions api/src/job.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import {
isValidFilePath,
} from './validation';
import { cachedInputResponse, inputCacheKey, openCachedInput } from './session-inputs';
import type { ShellOutputFilter } from '../../shared/shell-output-filter';

export {
DIRKEEP,
Expand Down Expand Up @@ -490,6 +491,9 @@ export const RESERVED_ENV_KEYS: ReadonlySet<string> = new Set([
'HOME',
'PATH',
'TOOL_CALL_SOCKET',
'CODEAPI_SHELL_OUTPUT_FILTER',
'RTK_DB_PATH',
'RTK_TEE',
'PYTHONPATH',
'PYTHONSTARTUP',
'PYTHONHOME',
Expand Down Expand Up @@ -689,6 +693,7 @@ export class Job {
cpu_times: { run: number; compile: number };
memory_limits: { run: number; compile: number };
extra_env_vars?: Record<string, string>;
shellOutputFilter: ShellOutputFilter;
egressGrantToken?: string;
toolCallSocketEnabled: boolean;
isSynthetic: boolean;
Expand Down Expand Up @@ -728,6 +733,7 @@ export class Job {
cpu_times: { run: number; compile: number };
memory_limits: { run: number; compile: number };
extra_env_vars?: Record<string, string>;
shell_output_filter?: ShellOutputFilter;
output_session_id?: string;
egress_grant?: string;
tool_call_socket_enabled?: boolean;
Expand Down Expand Up @@ -766,6 +772,7 @@ export class Job {
this.cpu_times = opts.cpu_times;
this.memory_limits = opts.memory_limits;
this.extra_env_vars = opts.extra_env_vars;
this.shellOutputFilter = opts.shell_output_filter ?? 'raw';
this.egressGrantToken = opts.egress_grant;
this.toolCallSocketEnabled = opts.tool_call_socket_enabled === true;
this.isSynthetic = opts.is_synthetic === true;
Expand Down Expand Up @@ -1486,6 +1493,18 @@ export class Job {
HOME: '/mnt/data',
};

if (
script === 'run'
&& this.runtime.language === 'bash'
&& this.shellOutputFilter === 'rtk'
) {
envVars.CODEAPI_SHELL_OUTPUT_FILTER = 'rtk';
/* Keep RTK bookkeeping ephemeral so a stateless run cannot create
* recoverable artifacts or pollute generated-file discovery. */
envVars.RTK_DB_PATH = '/tmp/rtk-history.db';
envVars.RTK_TEE = '0';
}

let extraPkgdirs: string[] | undefined;
if (this.runtime.language === 'bash') {
const linkTarget: { nodeModulesPath?: string } = {};
Expand Down
38 changes: 38 additions & 0 deletions api/src/metrics.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import client, { Counter, Gauge, Histogram, register } from 'prom-client';
import type { NextFunction, Request, Response } from 'express';
import type { ShellOutputFilter } from '../../shared/shell-output-filter';

client.collectDefaultMetrics({ register });

Expand Down Expand Up @@ -29,6 +30,19 @@ export const sandboxExecutionDuration = new Histogram({
buckets: [0.1, 0.5, 1, 2.5, 5, 10, 15, 30, 60, 120, 300],
});

export const sandboxShellOutputFilterExecutions = new Counter({
name: 'codeapi_sandbox_shell_output_filter_executions_total',
help: 'Total Bash sandbox executions by request-scoped output filter and outcome',
labelNames: ['shell_output_filter', 'outcome'] as const,
});

export const sandboxShellOutputBytes = new Histogram({
name: 'codeapi_sandbox_shell_output_bytes',
help: 'Bash sandbox output size by request-scoped output filter and stream',
labelNames: ['shell_output_filter', 'stream'] as const,
buckets: [0, 64, 256, 1024, 4096, 16_384, 65_536, 262_144, 1_048_576],
});

export const activeSandboxExecutions = new Gauge({
name: 'codeapi_sandbox_active_executions',
help: 'Number of sandbox executions currently past request validation',
Expand Down Expand Up @@ -82,12 +96,36 @@ export function httpMetricsMiddleware(req: Request, res: Response, next: NextFun

export function recordSandboxExecution(params: {
language: string;
outputFilter?: ShellOutputFilter;
outcome: 'success' | 'manifest_error' | 'bad_request' | 'validation_error' | 'execution_error';
durationSeconds: number;
}): void {
const labels = { language: params.language || 'unknown', outcome: params.outcome };
sandboxExecutions.inc(labels);
sandboxExecutionDuration.observe(labels, params.durationSeconds);
if (params.language === 'bash') {
sandboxShellOutputFilterExecutions.inc({
shell_output_filter: params.outputFilter ?? 'raw',
outcome: params.outcome,
});
}
}

export function recordSandboxOutputBytes(params: {
language: string;
outputFilter: ShellOutputFilter;
stdout: string;
stderr: string;
}): void {
if (params.language !== 'bash') return;
sandboxShellOutputBytes.observe(
{ shell_output_filter: params.outputFilter, stream: 'stdout' },
Buffer.byteLength(params.stdout),
);
sandboxShellOutputBytes.observe(
{ shell_output_filter: params.outputFilter, stream: 'stderr' },
Buffer.byteLength(params.stderr),
);
}

export async function metricsHandler(_req: Request, res: Response): Promise<void> {
Expand Down
Loading