From 1ef326ab2075a9a05a8b11183c4b812461f21ba0 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Mon, 7 Sep 2026 21:49:42 -0400 Subject: [PATCH 1/6] fix: clamp runtime timeout caps at the sandbox limit (#148) * fix: clamp runtime timeout caps at the sandbox limit * test: use target-compatible timeout assertions * test: preserve timeout fixture tuple types --- api/README.md | 12 ++++++ api/src/api/v2-timeout.test.ts | 74 ++++++++++++++++++++++++++++++++++ api/src/api/v2.ts | 11 +++-- 3 files changed, 94 insertions(+), 3 deletions(-) create mode 100644 api/src/api/v2-timeout.test.ts diff --git a/api/README.md b/api/README.md index e0e8a6d0..bbbe36ec 100644 --- a/api/README.md +++ b/api/README.md @@ -109,3 +109,15 @@ 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 ``` + +### Requested runtime caps + +`POST /api/v2/execute` treats `run_timeout` as an upper bound in milliseconds. +A request above the effective runtime limit is clamped to that limit, including +language and package overrides. Smaller caps are preserved, and omission uses +the runtime default. Compile, CPU, and memory constraints retain their existing +validation behavior. + +Roll out this sandbox behavior before enabling timeout forwarding in the +service's plain `/exec` handler. Older sandboxes reject caps above their local +runtime limit; older services remain compatible with updated sandboxes. diff --git a/api/src/api/v2-timeout.test.ts b/api/src/api/v2-timeout.test.ts new file mode 100644 index 00000000..463ed370 --- /dev/null +++ b/api/src/api/v2-timeout.test.ts @@ -0,0 +1,74 @@ +import { afterAll, afterEach, beforeAll, expect, test } from 'bun:test'; +import express from 'express'; +import { mkdtemp, rm, writeFile } from 'fs/promises'; +import type { Server } from 'http'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { config } from '../config'; +import { Job } from '../job'; +import { loadPackage } from '../runtime'; +import router from './v2'; + +let server: Server; +let url: string; +let directory: string; +const language = 'runtime-timeout-cap-test'; +const originalPrime = Job.prototype.prime; +const originalExecute = Job.prototype.execute; +const originalCleanup = Job.prototype.cleanup; +const requireManifest = config.require_execution_manifest; +const observed: number[] = []; + +beforeAll(async () => { + directory = await mkdtemp(join(tmpdir(), 'runtime-timeout-')); + await writeFile(join(directory, 'pkg-info.json'), JSON.stringify({ + language, version: '1.0.0', aliases: [], + limit_overrides: { run_timeout: 15000, compile_timeout: 5000 }, + })); + loadPackage(directory); + const app = express(); + app.use(router); + await new Promise((resolve) => { server = app.listen(0, '127.0.0.1', () => resolve()); }); + const address = server.address(); + url = `http://127.0.0.1:${typeof address === 'object' && address ? address.port : 0}/execute`; +}); + +afterAll(async () => { + await new Promise((resolve) => server.close(() => resolve())); + await rm(directory, { recursive: true, force: true }); +}); +afterEach(() => { + Job.prototype.prime = originalPrime; + Job.prototype.execute = originalExecute; + Job.prototype.cleanup = originalCleanup; + config.require_execution_manifest = requireManifest; + observed.length = 0; +}); + +test('caps execution at the effective language runtime limit without rejecting larger caller caps', async () => { + config.require_execution_manifest = false; + Job.prototype.prime = async function () { observed.push(this.timeouts.run); }; + Job.prototype.execute = async function () { return {} as Awaited>; }; + Job.prototype.cleanup = async function () {}; + for (const [input, expected] of [[25000, 15000], [15000, 15000], [1000, 1000], [null, 15000], [undefined, 15000]] as const) { + const response = await fetch(url, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ language, version: '1.0.0', run_timeout: input, files: [{ name: 'main.txt', content: 'test' }] }), + }); + expect(response.status, await response.text()).toBe(200); + expect(observed[observed.length - 1]).toBe(expected); + } +}); + +test('invalid runtime types and compile limit violations still fail before priming', async () => { + config.require_execution_manifest = false; + Job.prototype.prime = async function () { observed.push(this.timeouts.run); }; + for (const limits of [{ run_timeout: '1000' }, { run_timeout: -1 }, { compile_timeout: 6000 }]) { + const response = await fetch(url, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ language, version: '1.0.0', ...limits, files: [{ name: 'main.txt', content: 'test' }] }), + }); + expect(response.status).toBe(400); + } + expect(observed).toEqual([]); +}); diff --git a/api/src/api/v2.ts b/api/src/api/v2.ts index a883dc72..e2f252ce 100644 --- a/api/src/api/v2.ts +++ b/api/src/api/v2.ts @@ -253,7 +253,7 @@ function getJob( const { session_id, language, version, args, stdin, files, compile_memory_limit, run_memory_limit, - run_timeout, compile_timeout, + compile_timeout, run_cpu_time, compile_cpu_time, env_vars, } = body; @@ -288,7 +288,12 @@ function getJob( throw { message: 'files must include at least one runnable source file' }; } - validateConstraints(body, rt); + // A runtime timeout is a cap, not a request to exceed the runtime's own + // limit. Resolve it here, where language/package overrides are available. + const runTimeout = typeof body.run_timeout === 'number' && rt.timeouts.run > 0 + ? Math.min(body.run_timeout, rt.timeouts.run) + : body.run_timeout; + validateConstraints({ ...body, run_timeout: runTimeout }, rt); /* Session mode is per-request opt-in: only run in the persistent workspace * when THIS request carried a valid X-Runtime-Session-Id. A headerless or @@ -329,7 +334,7 @@ function getJob( stdin: stdin ?? '', files, timeouts: { - run: run_timeout ?? rt.timeouts.run, + run: runTimeout ?? rt.timeouts.run, compile: compile_timeout ?? rt.timeouts.compile, }, cpu_times: { From 394edafa560d1b7616fe002c28d4a40a9b034c7f Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Mon, 7 Sep 2026 21:51:00 -0400 Subject: [PATCH 2/6] fix: honor timeout caps on plain execution requests (#145) * fix: honor timeout caps on plain execution requests * docs: publish the execution timeout contract --- service/openapi.yml | 11 ++++ service/src/service/exec-timeout.test.ts | 75 ++++++++++++++++++++++++ service/src/service/router.ts | 11 +++- service/src/types/service.ts | 2 + 4 files changed, 98 insertions(+), 1 deletion(-) create mode 100644 service/src/service/exec-timeout.test.ts diff --git a/service/openapi.yml b/service/openapi.yml index 78ba082f..a257fd18 100644 --- a/service/openapi.yml +++ b/service/openapi.yml @@ -138,6 +138,17 @@ components: - code - lang properties: + timeout: + type: number + nullable: true + minimum: 0 + exclusiveMinimum: true + description: >- + Optional runtime cap in milliseconds. Positive finite values are + rounded up to whole milliseconds and clamped to JOB_TIMEOUT, then + to the sandbox's effective language runtime limit. Omitted or null + values preserve the worker's runtime default. This caps execution, + not queueing or request transport time. code: type: string lang: diff --git a/service/src/service/exec-timeout.test.ts b/service/src/service/exec-timeout.test.ts new file mode 100644 index 00000000..70e00d17 --- /dev/null +++ b/service/src/service/exec-timeout.test.ts @@ -0,0 +1,75 @@ +import { expect, test } from 'bun:test'; +import { resolve } from 'path'; + +test('/exec validates timeout before enqueue and forwards its cap to both language queues', async () => { + // Isolate infrastructure mocks; exercise the real router, timeout policy, + // payload builder, and security preparation without Redis or a sandbox. + const probe = Bun.spawn([process.execPath, '-e', ` + import { mock } from 'bun:test'; + import assert from 'node:assert/strict'; + const passthrough = (_req, _res, next) => next(); + mock.module('./src/middleware/auth', () => ({ sessionAuth: passthrough })); + mock.module('./src/middleware/limits', () => ({ + executionLimiter: passthrough, uploadLimiter: passthrough, + downloadLimiter: passthrough, fetchLimiter: passthrough, + })); + mock.module('./src/lifecycle', () => ({ + checkServiceStartUp: () => false, checkServiceShutDown: () => false, + })); + let writes = 0; + let submitted = []; + const queue = (name) => ({ add: async (_type, data) => { + submitted.push({ name, data }); + return { waitUntilFinished: async () => ({ ok: true }), remove: async () => {} }; + }}); + mock.module('./src/queue', () => ({ + pyQueue: queue('python'), otherQueue: queue('other'), + pyQueueEvents: {}, otherQueueEvents: {}, queueNames: { python: 'python', other: 'other' }, + connection: { set: async () => { writes++; return 'OK'; } }, + })); + const { env } = await import('./src/config'); + env.JOB_TIMEOUT = 15000; + env.RUNTIME_SESSION_MODE = 'stateless'; + env.SANDBOX_BACKEND = 'http'; + env.HARDENED_SANDBOX_MODE = false; + env.EGRESS_GRANT_SECRET = ''; + env.EXECUTION_MANIFEST_SECRET = ''; + const { default: router } = await import('./src/service/router'); + const handler = router.stack.find(layer => layer.route?.path === '/exec').route.stack.at(-1).handle; + async function request(lang, timeout) { + submitted = []; + writes = 0; + const req = { + body: { code: 'print(1)', lang, timeout }, headers: {}, header: () => undefined, on: () => {}, + codeApiPrincipal: { userId: 'user', tenantId: 'tenant', principalSource: 'none' }, + codeApiAuthContext: { userId: 'user', tenantId: 'tenant' }, + }; + const res = { status(code) { this.statusCode = code; return this; }, json(body) { this.body = body; return this; } }; + await handler(req, res); + return res; + } + for (const lang of ['py', 'bash']) { + for (const [input, expected] of [[1000, 1000], [1000.1, 1001], [0.1, 1], [999999, 15000], [null, undefined], [undefined, undefined]]) { + const res = await request(lang, input); + assert.equal(res.statusCode, 200, JSON.stringify(res.body)); + assert.equal(submitted.length, 1); + assert.equal(submitted[0].name, lang === 'py' ? 'python' : 'other'); + assert.equal(submitted[0].data.payload.run_timeout, expected); + if (expected === undefined) assert.equal('run_timeout' in submitted[0].data.payload, false); + } + } + for (const input of [0, -1, '1000', true, {}, [], NaN, Infinity]) { + const res = await request('py', input); + assert.equal(res.statusCode, 400); + assert.match(res.body.error, /timeout must be a positive number of milliseconds/); + assert.equal(submitted.length, 0); + assert.equal(writes, 0, 'invalid timeout must not register a session'); + } + console.log('EXEC_TIMEOUT_OK'); + `], { cwd: resolve(__dirname, '../..'), stdout: 'pipe', stderr: 'pipe' }); + const [exitCode, stdout, stderr] = await Promise.all([ + probe.exited, new Response(probe.stdout).text(), new Response(probe.stderr).text(), + ]); + expect(exitCode, `${stdout}\n${stderr}`).toBe(0); + expect(stdout).toContain('EXEC_TIMEOUT_OK'); +}, 15000); diff --git a/service/src/service/router.ts b/service/src/service/router.ts index f1a840be..f89bbb17 100644 --- a/service/src/service/router.ts +++ b/service/src/service/router.ts @@ -25,7 +25,7 @@ import { Jobs, Languages } from '../enum'; import { FileRefAuthorizationError, authorizeRequestedFiles } from './file-authorization'; import { createUploadSessionRegistrar } from './upload-session'; import { recordSessionOwnership } from '../session-ownership'; -import { prepareSandboxJobSecurity } from '../sandbox-egress'; +import { normalizeProgrammaticTimeoutMs, prepareSandboxJobSecurity } from '../sandbox-egress'; import { BridgeWorkerSelectionError, CODEAPI_BRIDGE_WORKER_HEADER, @@ -147,6 +147,14 @@ router.post('/exec', executionLimiter, async (req: t.AuthenticatedRequest, res) return res.status(400).json({ error: `Unsupported language: ${rawLang}` }); } + // An omitted cap keeps the worker's existing language-specific default. + let timeout: number | undefined; + try { + if (body.timeout != null) timeout = normalizeProgrammaticTimeoutMs(body.timeout); + } catch (error) { + return res.status(400).json({ error: (error as Error).message }); + } + let bridgeWorkerId: string | undefined; try { const bridgeSelection = resolveBridgeWorkerSelection({ @@ -250,6 +258,7 @@ router.post('/exec', executionLimiter, async (req: t.AuthenticatedRequest, res) isPyPlot, session_id, }); + if (timeout != null) rawPayload.run_timeout = timeout; const sandboxSecurity = prepareSandboxJobSecurity({ req, executionId: execution_id, diff --git a/service/src/types/service.ts b/service/src/types/service.ts index 0f97a532..2a90eac7 100644 --- a/service/src/types/service.ts +++ b/service/src/types/service.ts @@ -133,6 +133,8 @@ export type ExecuteResponse = { }; export interface RequestBody { + /** Optional positive runtime cap in milliseconds, clamped to JOB_TIMEOUT. */ + timeout?: number; code: string; lang: string; args?: string[]; From 6da7d0aeca5592c031fd6cbcad2b5996f6d25991 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Mon, 7 Sep 2026 21:51:09 -0400 Subject: [PATCH 3/6] fix: validate private storage through open descriptors (#147) --- packages/code/src/storage.test.ts | 143 +++++++++++++++++++++++++++++- packages/code/src/storage.ts | 10 ++- 2 files changed, 151 insertions(+), 2 deletions(-) diff --git a/packages/code/src/storage.test.ts b/packages/code/src/storage.test.ts index f3e8243e..e246ff85 100644 --- a/packages/code/src/storage.test.ts +++ b/packages/code/src/storage.test.ts @@ -1,5 +1,17 @@ import assert from 'node:assert/strict'; -import { chmod, mkdir, mkdtemp, open, readdir, rm, stat, symlink, writeFile } from 'node:fs/promises'; +import { + chmod, + mkdir, + mkdtemp, + open, + readFile, + readdir, + rename, + rm, + stat, + symlink, + writeFile, +} from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import test from 'node:test'; @@ -142,6 +154,135 @@ test('paired identity is persisted atomically with owner-only permissions', asyn } }); +test('identity saves validate permissions on the open temporary file', async (t) => { + const directory = await mkdtemp(join(tmpdir(), 'librechat-code-save-race-')); + const path = join(directory, 'identity.json'); + const displaced = join(directory, 'displaced.tmp'); + const identity = { + protocolVersion: 1 as const, + workerId: 'vm-1', + codeApiUrl: 'https://code.example/v1', + credential: 'must-not-be-written', + expiresAt: new Date(Date.now() + 300_000).toISOString(), + publicKey: 'public-key', + privateKey: 'private-key', + }; + try { + const probe = await open(directory, 'r'); + const fileHandlePrototype = Object.getPrototypeOf(probe) as { + chmod(mode: number): Promise; + }; + await probe.close(); + const originalChmod = fileHandlePrototype.chmod; + let swapped = false; + t.mock.method( + fileHandlePrototype, + 'chmod', + async function (this: FileHandle, mode: number) { + if (mode !== 0o600 || swapped) return originalChmod.call(this, mode); + swapped = true; + await originalChmod.call(this, 0o666); + const temporary = (await readdir(directory)).find((name) => + name.endsWith('.tmp'), + ); + assert.ok(temporary); + const temporaryPath = join(directory, temporary); + await rename(temporaryPath, displaced); + await writeFile(temporaryPath, 'owner-only decoy', { mode: 0o600 }); + }, + ); + + await assert.rejects( + saveBridgeIdentity(path, identity), + /Cannot restrict .*identity\.json.*mode 666/, + ); + assert.equal(await readFile(displaced, 'utf8'), ''); + assert.equal((await stat(displaced)).mode & 0o777, 0o666); + await assert.rejects(stat(path), { code: 'ENOENT' }); + } finally { + await rm(directory, { recursive: true, force: true }); + } +}); + +test('pairing reservations validate permissions on the open destination', async (t) => { + const directory = await mkdtemp(join(tmpdir(), 'librechat-code-reserve-race-')); + const path = join(directory, 'identity.json'); + const displaced = join(directory, 'displaced.json'); + try { + const probe = await open(directory, 'r'); + const fileHandlePrototype = Object.getPrototypeOf(probe) as { + chmod(mode: number): Promise; + }; + await probe.close(); + const originalChmod = fileHandlePrototype.chmod; + let swapped = false; + t.mock.method( + fileHandlePrototype, + 'chmod', + async function (this: FileHandle, mode: number) { + if (mode !== 0o600 || swapped) return originalChmod.call(this, mode); + swapped = true; + await originalChmod.call(this, 0o666); + await rename(path, displaced); + await writeFile(path, 'owner-only decoy', { mode: 0o600 }); + }, + ); + + await assert.rejects( + assertIdentityPathIsPrivate(path), + /Cannot restrict .*identity\.json.*mode 666/, + ); + assert.equal(await readFile(displaced, 'utf8'), ''); + assert.equal((await stat(displaced)).mode & 0o777, 0o666); + await assert.rejects(stat(path), { code: 'ENOENT' }); + } finally { + await rm(directory, { recursive: true, force: true }); + } +}); + +test('quarantine saves validate permissions on the open marker file', async (t) => { + const directory = await mkdtemp(join(tmpdir(), 'librechat-code-quarantine-race-')); + const path = join(directory, 'quarantine.json'); + const displaced = join(directory, 'displaced.json'); + const record = { + version: 1 as const, + workerId: 'vm-1', + workspaceId: 'primary', + quarantinedAt: new Date().toISOString(), + reason: 'must-not-be-written', + }; + try { + const probe = await open(directory, 'r'); + const fileHandlePrototype = Object.getPrototypeOf(probe) as { + chmod(mode: number): Promise; + }; + await probe.close(); + const originalChmod = fileHandlePrototype.chmod; + let swapped = false; + t.mock.method( + fileHandlePrototype, + 'chmod', + async function (this: FileHandle, mode: number) { + if (mode !== 0o600 || swapped) return originalChmod.call(this, mode); + swapped = true; + await originalChmod.call(this, 0o666); + await rename(path, displaced); + await writeFile(path, 'owner-only decoy', { mode: 0o600 }); + }, + ); + + await assert.rejects( + saveWorkspaceMutationQuarantine(path, record), + /Cannot restrict .*quarantine\.json.*mode 666/, + ); + assert.equal(await readFile(displaced, 'utf8'), ''); + assert.equal((await stat(displaced)).mode & 0o777, 0o666); + await assert.rejects(stat(path), { code: 'ENOENT' }); + } finally { + await rm(directory, { recursive: true, force: true }); + } +}); + test('workspace mutation quarantine persists until explicitly cleared', async (t) => { const directory = await mkdtemp(join(tmpdir(), 'librechat-code-quarantine-')); const path = join(directory, 'state', 'quarantine.json'); diff --git a/packages/code/src/storage.ts b/packages/code/src/storage.ts index af2f1295..1cbb7093 100644 --- a/packages/code/src/storage.ts +++ b/packages/code/src/storage.ts @@ -225,7 +225,15 @@ async function readGuardedFile( } async function assertOwnerOnlyFile(handle: FileHandle, path: string): Promise { - const mode = (await handle.stat()).mode & 0o777; + const metadata = await handle.stat(); + const self = process.getuid?.(); + if (self !== undefined && !isTrustedOwner(metadata.uid, self)) { + throw new BridgeProtocolError( + `${path} is owned by another account (uid ${metadata.uid}), ` + + 'which can rewrite it. Keep worker credentials on a path this account owns.', + ); + } + const mode = metadata.mode & 0o777; if ((mode & 0o077) !== 0) { throw new BridgeProtocolError( `Cannot restrict ${path} to owner-only access (mode ${mode.toString(8)}). ` + From dc34012558e21f1058f68425715be83ddcf40a63 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Mon, 7 Sep 2026 23:27:48 -0400 Subject: [PATCH 4/6] fix: bind GitHub App token requests to the enterprise host (#150) * fix: bind GitHub App token requests to the enterprise host * fix: reject empty API URL query and fragment suffixes --- packages/code/README.md | 9 +++++-- packages/code/src/cli.test.ts | 41 ++++++++++++++++++++++++++++ packages/code/src/cli.ts | 1 + packages/code/src/github.test.ts | 46 ++++++++++++++++++++++++++++++++ packages/code/src/github.ts | 33 ++++++++++++++--------- 5 files changed, 116 insertions(+), 14 deletions(-) diff --git a/packages/code/README.md b/packages/code/README.md index 84adfc91..9dd3019a 100644 --- a/packages/code/README.md +++ b/packages/code/README.md @@ -158,8 +158,13 @@ authentication is configured. The worker identity, GitHub App key path, token source variables, and mutation-quarantine record remain denied to sandboxed commands. -For GitHub Enterprise Server, set `LIBRECHAT_CODE_GITHUB_HOST` to its hostname -and `LIBRECHAT_CODE_GITHUB_API_URL` to its HTTPS API base URL. GitHub +For GitHub Enterprise Server, set `LIBRECHAT_CODE_GITHUB_HOST` to its hostname. +App authentication defaults to `https:///api/v3`; GitHub.com continues to +use `https://api.github.com`. Set `LIBRECHAT_CODE_GITHUB_API_URL` to override +the HTTPS API base URL, including a custom port or path. Its hostname must +match the configured Git host (with `api.github.com` corresponding to +`github.com`), and it must not contain credentials, a query, or a fragment. +App token requests do not follow redirects. GitHub authentication currently requires the `native-srt` command sandbox. Every clone, commit, or push command still crosses LibreChat's tool-approval policy; the credential boundary does not grant approval by itself. diff --git a/packages/code/src/cli.test.ts b/packages/code/src/cli.test.ts index ba195dbc..f978fce5 100644 --- a/packages/code/src/cli.test.ts +++ b/packages/code/src/cli.test.ts @@ -371,3 +371,44 @@ test('CLI treats a whitespace-only file relay upstream as disabled', () => { /LIBRECHAT_CODE_(?:EXECUTION_MANIFEST_PUBLIC_KEY|FILE_RELAY_IMAGE) is required/, ); }); + +test('CLI host-only enterprise configuration sends App JWTs to GHES, never GitHub.com', async (t) => { + const { generateKeyPairSync } = await import('node:crypto'); + const { mkdtemp, rm, writeFile } = await import('node:fs/promises'); + const { tmpdir } = await import('node:os'); + const { join } = await import('node:path'); + const directory = await mkdtemp(join(tmpdir(), 'cli-ghes-')); + t.after(() => rm(directory, { recursive: true, force: true })); + const privateKeyPath = join(directory, 'app.pem'); + const preload = join(directory, 'fetch.mjs'); + const { privateKey } = generateKeyPairSync('rsa', { modulusLength: 2048 }); + await writeFile(privateKeyPath, privateKey.export({ type: 'pkcs8', format: 'pem' }), { mode: 0o600 }); + await writeFile(preload, ` + globalThis.fetch = async (input) => { + console.error('GITHUB_REQUEST:' + String(input)); + throw new Error('test stopped before network delivery'); + }; + `); + const result = spawnSync(process.execPath, [ + '--import', preload, fileURLToPath(new URL('./cli.js', import.meta.url)), + ], { + encoding: 'utf8', timeout: 10000, + env: { + ...process.env, + LIBRECHAT_CODE_URL: 'http://127.0.0.1:1/v1', + LIBRECHAT_CODE_WORKER_TOKEN: 'worker-secret', + LIBRECHAT_CODE_WORKER_ID: 'engineering-vm', + LIBRECHAT_CODE_WORKER_DIR: directory, + LIBRECHAT_CODE_ALLOW_WORKSPACE_COMMANDS: 'true', + LIBRECHAT_CODE_GITHUB_TOKEN: undefined, + LIBRECHAT_CODE_GITHUB_APP_ID: '123', + LIBRECHAT_CODE_GITHUB_INSTALLATION_ID: '456', + LIBRECHAT_CODE_GITHUB_PRIVATE_KEY_FILE: privateKeyPath, + LIBRECHAT_CODE_GITHUB_HOST: 'GitHub.Example.Test', + LIBRECHAT_CODE_GITHUB_API_URL: undefined, + }, + }); + assert.equal(result.status, 1, result.stderr); + assert.match(result.stderr, /GITHUB_REQUEST:https:\/\/github\.example\.test\/api\/v3\/app\/installations\/456\/access_tokens/); + assert.doesNotMatch(result.stderr, /GITHUB_REQUEST:https:\/\/api\.github\.com/); +}); diff --git a/packages/code/src/cli.ts b/packages/code/src/cli.ts index e9442df9..28103891 100644 --- a/packages/code/src/cli.ts +++ b/packages/code/src/cli.ts @@ -195,6 +195,7 @@ function githubCredentials(): { appId: appId!, installationId: installationId!, privateKeyPath: privateKeyPath!, + host, apiUrl, }), }; diff --git a/packages/code/src/github.test.ts b/packages/code/src/github.test.ts index 81dbb2c3..bd62acfd 100644 --- a/packages/code/src/github.test.ts +++ b/packages/code/src/github.test.ts @@ -229,3 +229,49 @@ test('allows the GitHub LFS object delivery hosts', () => { assert.ok(GITHUB_ALLOWED_DOMAINS.includes('*.githubusercontent.com')); assert.ok(GITHUB_ALLOWED_DOMAINS.includes('github-cloud.s3.amazonaws.com')); }); + +test('App JWT requests use the resolved public or enterprise endpoint', async (t) => { + const directory = await mkdtemp(join(tmpdir(), 'github-endpoint-')); + t.after(() => rm(directory, { recursive: true, force: true })); + const privateKeyPath = join(directory, 'app.pem'); + const { privateKey } = generateKeyPairSync('rsa', { modulusLength: 2048 }); + await writeFile(privateKeyPath, privateKey.export({ type: 'pkcs8', format: 'pem' }), { mode: 0o600 }); + for (const [host, apiUrl, expected] of [ + [undefined, undefined, 'https://api.github.com'], + ['GitHub.COM', undefined, 'https://api.github.com'], + ['GitHub.Example.Test', undefined, 'https://github.example.test/api/v3'], + [undefined, 'https://github.example.test/api/v3/', 'https://github.example.test/api/v3'], + ['github.example.test', 'https://github.example.test:8443/custom/api/', 'https://github.example.test:8443/custom/api'], + ]) { + let calls = 0; + const provider = new GitHubAppCredentialProvider({ + appId: '123', installationId: '456', privateKeyPath, host, apiUrl, + now: () => new Date('2030-01-01T00:00:00Z'), + fetch: async (input, init) => { + calls++; + assert.equal(String(input), `${expected}/app/installations/456/access_tokens`); + assert.equal(init?.method, 'POST'); + assert.equal(init?.redirect, 'error'); + assert.match(new Headers(init?.headers).get('authorization')!, /^Bearer eyJ/); + return new Response(JSON.stringify({ token: 'ghs_abcdefghijklmnopqrstuvwxyz', expires_at: '2030-01-01T01:00:00Z' }), { status: 201 }); + }, + }); + await provider.getCredential(); + await provider.getCredential(); + assert.equal(calls, 1); + } +}); + +test('invalid or mismatched App endpoints are refused before any private key access', () => { + for (const apiUrl of [ + 'https://api.github.com', 'https://other.example.test/api/v3', + 'http://github.example.test/api/v3', 'https://user:password@github.example.test/api/v3', + 'https://github.example.test/api/v3?query=1', 'https://github.example.test/api/v3#fragment', + 'https://github.example.test/api/v3?', 'https://github.example.test/api/v3#', + ]) { + assert.throws(() => new GitHubAppCredentialProvider({ + appId: '123', installationId: '456', privateKeyPath: '/must-not-be-read', + host: 'github.example.test', apiUrl, + }), /must match|HTTPS URL without credentials|query or fragment/); + } +}); diff --git a/packages/code/src/github.ts b/packages/code/src/github.ts index e71188c8..7b1f12b1 100644 --- a/packages/code/src/github.ts +++ b/packages/code/src/github.ts @@ -29,6 +29,8 @@ export interface GitHubAppCredentialProviderOptions { installationId: string; privateKeyPath: string; apiUrl?: string; + /** Git HTTPS hostname; non-public hosts default to the GHES /api/v3 base. */ + host?: string; fetch?: typeof globalThis.fetch; now?: () => Date; platform?: NodeJS.Platform; @@ -95,6 +97,7 @@ function createAppJwt(appId: string, privateKey: string, now: Date): string { export class GitHubAppCredentialProvider implements GitHubCredentialProvider { private cached?: GitHubCredential; + private readonly apiUrl: string; constructor(private readonly options: GitHubAppCredentialProviderOptions) { if ((options.platform ?? process.platform) === 'win32') { @@ -107,14 +110,23 @@ export class GitHubAppCredentialProvider implements GitHubCredentialProvider { 'GitHub App installation ID', options.installationId, ); - if (options.apiUrl != null) { - const apiUrl = new URL(options.apiUrl); - if (apiUrl.protocol !== 'https:' || apiUrl.username || apiUrl.password) { - throw new Error( - 'GitHub API URL must be an HTTPS URL without credentials', - ); - } + const host = options.host == null ? undefined : normalizeGitHubHost(options.host); + const apiUrl = new URL(options.apiUrl ?? ( + host != null && host !== 'github.com' + ? `https://${host}/api/v3` + : 'https://api.github.com' + )); + if (apiUrl.protocol !== 'https:' || apiUrl.username || apiUrl.password) { + throw new Error('GitHub API URL must be an HTTPS URL without credentials'); } + if (/[?#]/.test(apiUrl.href)) { + throw new Error('GitHub API URL must not contain a query or fragment'); + } + const apiHost = apiUrl.hostname === 'api.github.com' ? 'github.com' : apiUrl.hostname; + if (host != null && host !== apiHost) { + throw new Error('LIBRECHAT_CODE_GITHUB_HOST must match the GitHub App API hostname'); + } + this.apiUrl = apiUrl.href.replace(/\/+$/, ''); } async getCredential(signal?: AbortSignal): Promise { @@ -127,15 +139,12 @@ export class GitHubAppCredentialProvider implements GitHubCredentialProvider { } const privateKey = await readPrivateKey(this.options.privateKeyPath); const jwt = createAppJwt(this.options.appId, privateKey, now); - const apiUrl = (this.options.apiUrl ?? 'https://api.github.com').replace( - /\/+$/, - '', - ); const request = this.options.fetch ?? globalThis.fetch; const response = await request( - `${apiUrl}/app/installations/${this.options.installationId}/access_tokens`, + `${this.apiUrl}/app/installations/${this.options.installationId}/access_tokens`, { method: 'POST', + redirect: 'error', headers: { Accept: 'application/vnd.github+json', Authorization: `Bearer ${jwt}`, From 5fdeeebec551f2e8525f865ac81e82572e7ea03b Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Mon, 7 Sep 2026 23:28:03 -0400 Subject: [PATCH 5/6] fix(code): support declared Node engine range (#151) --- .github/workflows/ci.yml | 10 ++++++---- packages/code/src/worker.ts | 1 - 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a1a4b6e3..9c7b75d2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -125,8 +125,12 @@ jobs: run: bun run test code-package-tests: - name: Code Package Tests + name: Code Package Tests (Node ${{ matrix.node-version }}) runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + node-version: ['20.11.0', '22.21.0', '24.16.0'] defaults: run: working-directory: packages/code @@ -135,9 +139,7 @@ jobs: - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 with: - # The suite fails 47 worker tests on Node 22 despite the package's - # ">=20.11" engines range, so CI pins the version it is green on. - node-version: 24.16.0 + node-version: ${{ matrix.node-version }} cache: npm cache-dependency-path: packages/code/package-lock.json diff --git a/packages/code/src/worker.ts b/packages/code/src/worker.ts index 657ba726..1fda70f7 100644 --- a/packages/code/src/worker.ts +++ b/packages/code/src/worker.ts @@ -1647,7 +1647,6 @@ export class BridgeWorker { signal?.addEventListener('abort', abortRequest, { once: true }); } const timeout = setTimeout(abortRequest, timeoutMs); - timeout.unref?.(); try { return await this.request(url, body, controller.signal); } finally { From 725f79900bf06298653668a029eefd69460d900e Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Mon, 7 Sep 2026 23:28:32 -0400 Subject: [PATCH 6/6] fix: use runner DNS for KVM artifact service discovery (#152) * fix: use runner DNS for KVM artifact service discovery * fix: initialize KVM DNS before every guest executable * fix: pass only guest arguments to libkrun exec --- .github/workflows/ci.yml | 3 + README.md | 17 ++++ api/Dockerfile | 7 +- api/src/guest-dns.sh | 49 +++++++++++ docker/Dockerfile.worker-sandbox | 7 +- launcher/Dockerfile | 4 +- launcher/entrypoint.sh | 62 ++------------ launcher/src/main.rs | 12 ++- tests/kvm_guest_dns.sh | 138 +++++++++++++++++++++++++++++++ 9 files changed, 239 insertions(+), 60 deletions(-) create mode 100644 api/src/guest-dns.sh create mode 100755 tests/kvm_guest_dns.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9c7b75d2..6f79328e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,6 +31,9 @@ jobs: - name: Block-root package delivery run: tests/block_root_package_delivery.sh + - name: KVM guest resolver handoff + run: tests/kvm_guest_dns.sh + - name: Sandbox-runner liveness checks run: tests/sandbox_runner_healthcheck.sh diff --git a/README.md b/README.md index c2eeb7eb..1e6432f7 100644 --- a/README.md +++ b/README.md @@ -143,6 +143,23 @@ virtio-fs mount. The first image build takes longer because it compiles the language runtimes, but package-heavy workloads do not accumulate host file descriptors in the launcher. +KVM guests use the runner container's `/etc/resolv.conf`, including Docker's +embedded resolver or Kubernetes nameservers and search domains. The launcher +preserves service hostnames instead of pinning their startup IP addresses. +Both baked and directory rootfs images contain a resolver symlink whose target +is populated by a guest wrapper in private `/run` runtime storage before any +`LAUNCHER_EXEC` executable starts; the +read-only root disk does not need modification at boot. Rebuild the runner +image to pick up this layout change. A missing resolver handoff fails startup +rather than leaving the guest with an unrelated public DNS server. + +To validate a deployment, execute code that creates a file in `/mnt/data`, +confirm the response includes its file reference, and download it. Recreate the +egress gateway with a different container IP while leaving the runner alive, +then repeat after DNS caches expire. The file must still upload and download; +`artifact_delivery` must not report a failure. `tests/kvm_guest_dns.sh` checks +the resolver handoff and rootfs assembly without requiring KVM. + Setting `KVM_ENABLED=false` still selects the directory-root target and the host package mount automatically for direct NsJail development. diff --git a/api/Dockerfile b/api/Dockerfile index 26877009..66db9e3d 100644 --- a/api/Dockerfile +++ b/api/Dockerfile @@ -148,6 +148,7 @@ RUN rm -f /usr/bin/nsenter /usr/bin/unshare /usr/bin/chroot /usr/sbin/chroot \ 2>/dev/null || true COPY api/src/entrypoint.sh ./entrypoint.sh +COPY api/src/guest-dns.sh ./guest-dns.sh COPY api/src/hosted-app-launcher.sh /usr/local/bin/codeapi-hosted-app-launcher RUN chmod +x ./entrypoint.sh /usr/local/bin/codeapi-hosted-app-launcher @@ -266,7 +267,8 @@ COPY --from=sandbox-build / /sandbox-rootfs/ COPY --from=package-builder /pkgs /sandbox-rootfs/pkgs COPY docker/build-rootfs-image.sh /usr/local/bin/build-rootfs-image.sh -RUN sed -i '/^cgroup_mem_swap_max/d' /sandbox-rootfs/sandbox_api/config/sandbox.cfg +RUN sed -i '/^cgroup_mem_swap_max/d' /sandbox-rootfs/sandbox_api/config/sandbox.cfg \ + && bash /sandbox-rootfs/sandbox_api/guest-dns.sh --prepare-rootfs /sandbox-rootfs RUN chmod +x /usr/local/bin/build-rootfs-image.sh \ && /usr/local/bin/build-rootfs-image.sh /sandbox-rootfs /sandbox-rootfs.img @@ -284,7 +286,8 @@ FROM sandbox-runner-base AS sandbox-runner COPY --from=sandbox-build / /sandbox-rootfs/ -RUN sed -i '/^cgroup_mem_swap_max/d' /sandbox-rootfs/sandbox_api/config/sandbox.cfg +RUN sed -i '/^cgroup_mem_swap_max/d' /sandbox-rootfs/sandbox_api/config/sandbox.cfg \ + && bash /sandbox-rootfs/sandbox_api/guest-dns.sh --prepare-rootfs /sandbox-rootfs RUN mkdir -p /host-packages diff --git a/api/src/guest-dns.sh b/api/src/guest-dns.sh new file mode 100644 index 00000000..9795ca7f --- /dev/null +++ b/api/src/guest-dns.sh @@ -0,0 +1,49 @@ +#!/bin/bash +# The guest root may be read-only. Bake the link, populate its target only +# after /run is mounted, and leave direct NsJail/Lambda resolvers untouched. + +prepare_guest_dns() { + local root="$1" + mkdir -p "$root/run" + rm -f "$root/etc/resolv.conf" + ln -s ../run/codeapi-resolver/resolv.conf "$root/etc/resolv.conf" +} + +configure_guest_dns() { + local root="${1:-}" + local target="$root/run/codeapi-resolver" + if [ ! -L "$root/etc/resolv.conf" ] || \ + [ "$(readlink "$root/etc/resolv.conf")" != '../run/codeapi-resolver/resolv.conf' ]; then + return 0 + fi + if ! printf '%s\n' "${SANDBOX_RESOLV_CONF:-}" | grep -Eq '^[[:space:]]*nameserver[[:space:]]+[^[:space:]#]'; then + echo 'ERROR: KVM guest requires resolver configuration from launcher-entrypoint.sh' >&2 + return 1 + fi + # A fresh, root-owned directory prevents a sandbox UID from replacing DNS + # configuration in the runtime mount. Never reuse a pre-existing entry. + (umask 077; mkdir "$target") || return 1 + printf '%s\n' "$SANDBOX_RESOLV_CONF" > "$target/resolv.conf" || return 1 + chmod 600 "$target/resolv.conf" || return 1 + unset SANDBOX_RESOLV_CONF +} + +run_guest_command() { + local root="$1" + shift + # This runs for every LAUNCHER_EXEC, before the selected executable. Keep + # DNS separate from /tmp, which the normal API entrypoint mounts later. + mount -t tmpfs -o size=1m,mode=0755 tmpfs "$root/run" || return 1 + configure_guest_dns "$root" || return 1 + exec -- "$@" +} + +if [ "${BASH_SOURCE[0]}" = "$0" ]; then + set -e + case "${1:-}" in + --prepare-rootfs) prepare_guest_dns "${2:?rootfs path required}" ;; + --configure) configure_guest_dns "${2:-}" ;; + --exec) run_guest_command "" "${2:?guest executable required}" ;; + *) echo 'usage: guest-dns.sh --prepare-rootfs ROOTFS | --configure [ROOTFS] | --exec EXECUTABLE' >&2; exit 2 ;; + esac +fi diff --git a/docker/Dockerfile.worker-sandbox b/docker/Dockerfile.worker-sandbox index c18eab82..7156719c 100644 --- a/docker/Dockerfile.worker-sandbox +++ b/docker/Dockerfile.worker-sandbox @@ -152,6 +152,7 @@ RUN bun install --frozen-lockfile --production COPY --from=sandbox-builder /app/.build ./.build COPY api/config ./config COPY api/src/entrypoint.sh ./entrypoint.sh +COPY api/src/guest-dns.sh ./guest-dns.sh RUN chmod +x ./entrypoint.sh RUN mkdir -p /pkgs /tmp/sandbox @@ -200,7 +201,8 @@ COPY --from=sandbox-rootfs / /sandbox-rootfs/ COPY --from=package-builder /pkgs /sandbox-rootfs/pkgs COPY docker/build-rootfs-image.sh /usr/local/bin/build-rootfs-image.sh -RUN sed -i '/^cgroup_mem_swap_max/d' /sandbox-rootfs/sandbox_api/config/sandbox.cfg +RUN sed -i '/^cgroup_mem_swap_max/d' /sandbox-rootfs/sandbox_api/config/sandbox.cfg \ + && bash /sandbox-rootfs/sandbox_api/guest-dns.sh --prepare-rootfs /sandbox-rootfs RUN chmod +x /usr/local/bin/build-rootfs-image.sh \ && /usr/local/bin/build-rootfs-image.sh /sandbox-rootfs /sandbox-rootfs.img @@ -236,7 +238,7 @@ ENV PATH="/root/.bun/bin:${PATH}" COPY --from=launcher-builder /launcher/target/release/sandbox-launcher /usr/local/bin/launcher COPY --from=nsjail-builder /usr/local/bin/sandbox-rootfs-setup /sandbox-rootfs-setup -# --- Launcher entrypoint (DNS resolution + socat relay before VM boot) --- +# --- Launcher entrypoint (resolver configuration before VM boot) --- COPY launcher/entrypoint.sh /usr/local/bin/launcher-entrypoint.sh COPY docker/start-direct-sandbox.sh /usr/local/bin/start-direct-sandbox.sh RUN chmod +x /usr/local/bin/launcher-entrypoint.sh /usr/local/bin/start-direct-sandbox.sh @@ -268,6 +270,7 @@ FROM worker-sandbox-base AS worker-sandbox-legacy COPY --from=sandbox-rootfs / /sandbox-rootfs/ RUN sed -i '/^cgroup_mem_swap_max/d' /sandbox-rootfs/sandbox_api/config/sandbox.cfg \ + && bash /sandbox-rootfs/sandbox_api/guest-dns.sh --prepare-rootfs /sandbox-rootfs \ && mkdir -p /host-packages # KVM production default. The package tree is part of the read-only block root, diff --git a/launcher/Dockerfile b/launcher/Dockerfile index 1a077e12..b2b02386 100644 --- a/launcher/Dockerfile +++ b/launcher/Dockerfile @@ -93,6 +93,7 @@ RUN rm -f /usr/bin/nsenter /usr/bin/unshare /usr/bin/chroot /usr/sbin/chroot \ 2>/dev/null || true COPY api/src/entrypoint.sh ./entrypoint.sh +COPY api/src/guest-dns.sh ./guest-dns.sh RUN chmod +x ./entrypoint.sh # ============================================================================ @@ -132,7 +133,8 @@ COPY --from=nsjail-builder /usr/local/bin/sandbox-rootfs-setup /sandbox-rootfs-s COPY --from=sandbox-build / /sandbox-rootfs/ -RUN sed -i '/^cgroup_mem_swap_max/d' /sandbox-rootfs/sandbox_api/config/sandbox.cfg +RUN sed -i '/^cgroup_mem_swap_max/d' /sandbox-rootfs/sandbox_api/config/sandbox.cfg \ + && bash /sandbox-rootfs/sandbox_api/guest-dns.sh --prepare-rootfs /sandbox-rootfs RUN mkdir -p /host-packages diff --git a/launcher/entrypoint.sh b/launcher/entrypoint.sh index a6db4cf5..0369ec2a 100644 --- a/launcher/entrypoint.sh +++ b/launcher/entrypoint.sh @@ -1,59 +1,15 @@ #!/bin/bash set -e -# Resolve Docker Compose service names to IPs before entering the microVM. -# libkrun's TSI networking doesn't have access to Docker's embedded DNS (127.0.0.11), -# so DNS-based service discovery won't work inside the guest. - -resolve_url() { - local var_name="$1" - local url="${!var_name}" - [ -z "$url" ] && return - - local proto="${url%%://*}" - local rest="${url#*://}" - local host_port="${rest%%/*}" - local path="/${rest#*/}" - [ "$rest" = "$host_port" ] && path="" - local host="${host_port%%:*}" - local port="${host_port#*:}" - [ "$host" = "$port" ] && port="" - - # Skip if already an IP - echo "$host" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$' && return - - local ip - ip=$(getent hosts "$host" 2>/dev/null | awk '{print $1}' | head -1) - if [ -n "$ip" ]; then - local new_url="${proto}://${ip}" - [ -n "$port" ] && new_url="${new_url}:${port}" - new_url="${new_url}${path}" - export "$var_name"="$new_url" - echo "[entrypoint] ${var_name}: ${host} -> ${ip}" - fi -} - -resolve_host_port() { - local var_name="$1" - local val="${!var_name}" - [ -z "$val" ] && return - - local host="${val%%:*}" - local port="${val#*:}" - - echo "$host" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$' && return - - local ip - ip=$(getent hosts "$host" 2>/dev/null | awk '{print $1}' | head -1) - if [ -n "$ip" ]; then - export "$var_name"="${ip}:${port}" - echo "[entrypoint] ${var_name}: ${host} -> ${ip}" - fi -} - -resolve_url EGRESS_GATEWAY_URL -resolve_url FILE_SERVER_URL -resolve_host_port SANDBOX_FORWARD_TARGET +# TSI opens guest sockets in this container's network namespace. Keep service +# names intact so new connections can resolve replacements after a restart. +# Forward the resolver and search domains supplied by Docker or Kubernetes, +# rather than pinning endpoint IPs or baking a deployment-specific nameserver. +export SANDBOX_RESOLV_CONF="$(cat /etc/resolv.conf)" +if ! printf '%s\n' "$SANDBOX_RESOLV_CONF" | grep -Eq '^[[:space:]]*nameserver[[:space:]]+[^[:space:]#]'; then + echo 'ERROR: runner /etc/resolv.conf has no nameserver' >&2 + exit 1 +fi if [ "${LAUNCHER_FILTER_VSOCK_ENOTCONN:-true}" = "true" ]; then # libkrun can emit this benign TSI/vsock teardown line after the guest has diff --git a/launcher/src/main.rs b/launcher/src/main.rs index 5376b07c..cbc4b5fc 100644 --- a/launcher/src/main.rs +++ b/launcher/src/main.rs @@ -424,6 +424,7 @@ fn is_allowed_guest_env_key(key: &str, egress_gateway_enabled: bool) -> bool { "SANDBOX_EXECUTE_BODY_LIMIT", "SANDBOX_EXECUTION_MANIFEST_PUBLIC_KEY", "SANDBOX_FORWARD_TARGET", + "SANDBOX_RESOLV_CONF", "SANDBOX_LIMIT_OVERRIDES", "SANDBOX_LOG_LEVEL", "SANDBOX_MAX_CONCURRENT_JOBS", @@ -518,7 +519,8 @@ fn main() { let root_device_c = cstr(&root_device); let root_fstype_c = cstr(&root_fstype); let root_options_c = cstr(&root_options); - let exec_c = cstr(&exec_path); + // Always initialize guest DNS, including when LAUNCHER_EXEC overrides the API. + let exec_c = cstr("/bin/bash"); let port_map_strs = vec![cstr("2000:2000")]; let port_map_ptrs = null_term(&port_map_strs); @@ -533,7 +535,12 @@ fn main() { .collect(); let env_ptrs = null_term(&env_strs); - let argv_strs: Vec = vec![cstr(&exec_path)]; + // krun_set_exec supplies argv[0]; this array contains arguments only. + let argv_strs: Vec = vec![ + cstr("/sandbox_api/guest-dns.sh"), + cstr("--exec"), + cstr(&exec_path), + ]; let argv_ptrs = null_term(&argv_strs); let rlimit_strs: Vec = vec![guest_nofile_rlimit(nofile_target)]; @@ -645,6 +652,7 @@ mod tests { "SANDBOX_DISABLE_NETWORKING", "SANDBOX_ALLOWED_LOCAL_NETWORK_PORT", "SANDBOX_FORWARD_TARGET", + "SANDBOX_RESOLV_CONF", "SANDBOX_EXECUTION_MANIFEST_PUBLIC_KEY", "SANDBOX_RUN_TIMEOUT", "NSJAIL_CONFIG", diff --git a/tests/kvm_guest_dns.sh b/tests/kvm_guest_dns.sh new file mode 100755 index 00000000..e00e3e38 --- /dev/null +++ b/tests/kvm_guest_dns.sh @@ -0,0 +1,138 @@ +#!/usr/bin/env bash +set -euo pipefail +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +TEST_DIR="$(mktemp -d)" +trap 'chmod -R u+w "$TEST_DIR"; rm -rf "$TEST_DIR"' EXIT +source "$ROOT/api/src/guest-dns.sh" + +# Configure the baked link while writable, then only the runtime /run target. +mkdir -p "$TEST_DIR/guest/etc" "$TEST_DIR/guest/run" +printf 'nameserver 1.1.1.1\n' > "$TEST_DIR/guest/etc/resolv.conf" +prepare_guest_dns "$TEST_DIR/guest" +[[ "$(readlink "$TEST_DIR/guest/etc/resolv.conf")" == '../run/codeapi-resolver/resolv.conf' ]] +chmod 555 "$TEST_DIR/guest/etc" +SANDBOX_RESOLV_CONF=$'nameserver 127.0.0.11\noptions ndots:0' +configure_guest_dns "$TEST_DIR/guest" +printf 'nameserver 127.0.0.11\noptions ndots:0\n' > "$TEST_DIR/expected" +cmp "$TEST_DIR/expected" "$TEST_DIR/guest/etc/resolv.conf" +[[ ! -v SANDBOX_RESOLV_CONF ]] +# Ownership protection: no group/other permissions on the runtime directory. +[[ "$(ls -ld "$TEST_DIR/guest/run/codeapi-resolver" | cut -c1-10)" == 'drwx------' ]] + +# A fresh boot can use Kubernetes DNS/search paths without rebuilding the root. +rm -rf "$TEST_DIR/guest/run/codeapi-resolver" +SANDBOX_RESOLV_CONF=$'nameserver 10.96.0.10\nsearch tenant.svc.cluster.local svc.cluster.local cluster.local\noptions ndots:5' +printf '%s\n' "$SANDBOX_RESOLV_CONF" > "$TEST_DIR/expected" +configure_guest_dns "$TEST_DIR/guest" +cmp "$TEST_DIR/expected" "$TEST_DIR/guest/etc/resolv.conf" + +# Never reuse a stale directory or follow an attacker-controlled runtime link. +SANDBOX_RESOLV_CONF='nameserver 127.0.0.11' +if configure_guest_dns "$TEST_DIR/guest" 2>/dev/null; then + echo 'accepted pre-existing runtime DNS directory' >&2; exit 1 +fi +rm -rf "$TEST_DIR/guest/run/codeapi-resolver" +mkdir "$TEST_DIR/foreign" +ln -s "$TEST_DIR/foreign" "$TEST_DIR/guest/run/codeapi-resolver" +if configure_guest_dns "$TEST_DIR/guest" 2>/dev/null; then + echo 'accepted runtime DNS symlink' >&2; exit 1 +fi +[[ ! -e "$TEST_DIR/foreign/resolv.conf" ]] +rm "$TEST_DIR/guest/run/codeapi-resolver" +unset SANDBOX_RESOLV_CONF +if configure_guest_dns "$TEST_DIR/guest" 2>/dev/null; then + echo 'accepted missing guest resolver' >&2; exit 1 +fi +SANDBOX_RESOLV_CONF='# no nameserver' +if configure_guest_dns "$TEST_DIR/guest" 2>/dev/null; then + echo 'accepted empty guest resolver' >&2; exit 1 +fi + +# Direct NsJail and Lambda retain the resolver managed by their container. +mkdir -p "$TEST_DIR/direct/etc" +printf 'nameserver 192.0.2.53\n' > "$TEST_DIR/direct/etc/resolv.conf" +cp "$TEST_DIR/direct/etc/resolv.conf" "$TEST_DIR/expected" +configure_guest_dns "$TEST_DIR/direct" +cmp "$TEST_DIR/expected" "$TEST_DIR/direct/etc/resolv.conf" + +# Exercise the actual launcher script up to exec, substituting only its binary. +# Service names (including HTTPS authority and IPv6) must never be rewritten. +mkdir "$TEST_DIR/bin" +cat > "$TEST_DIR/bin/launcher" <<'STUB' +#!/usr/bin/env bash +set -euo pipefail +[[ "$EGRESS_GATEWAY_URL" == 'https://egress_gateway:3190/base' ]] +[[ "$FILE_SERVER_URL" == 'http://[::1]:3000/base' ]] +[[ "$SANDBOX_FORWARD_TARGET" == 'tool_call_server:3033' ]] +printf '%s\n' "$SANDBOX_RESOLV_CONF" > "$TEST_RESOLVER_OUTPUT" +STUB +cat > "$TEST_DIR/bin/getent" <<'STUB' +#!/usr/bin/env bash +printf '192.0.2.99 stale-address\n' +STUB +chmod +x "$TEST_DIR/bin/launcher" "$TEST_DIR/bin/getent" +sed "s|/usr/local/bin/launcher|$TEST_DIR/bin/launcher|g" "$ROOT/launcher/entrypoint.sh" > "$TEST_DIR/entrypoint.sh" +PATH="$TEST_DIR/bin:$PATH" \ +EGRESS_GATEWAY_URL='https://egress_gateway:3190/base' \ +FILE_SERVER_URL='http://[::1]:3000/base' \ +SANDBOX_FORWARD_TARGET='tool_call_server:3033' \ +LAUNCHER_FILTER_VSOCK_ENOTCONN=false \ +TEST_RESOLVER_OUTPUT="$TEST_DIR/forwarded" \ +bash "$TEST_DIR/entrypoint.sh" +printf '%s\n' "$(cat /etc/resolv.conf)" > "$TEST_DIR/expected" +cmp "$TEST_DIR/expected" "$TEST_DIR/forwarded" + +# Every rootfs assembly path must prepare DNS after COPY, before disk creation. +python3 - "$ROOT" <<'PY' +from pathlib import Path +import sys +root = Path(sys.argv[1]) +for name, count in [('api/Dockerfile', 2), ('docker/Dockerfile.worker-sandbox', 2), ('launcher/Dockerfile', 1)]: + text = (root / name).read_text() + assert text.count('--prepare-rootfs /sandbox-rootfs') == count, name + assert 'COPY api/src/guest-dns.sh ./guest-dns.sh' in text, name + for stage in text.split('\nFROM '): + if 'COPY --from=sandbox-' in stage and ' / /sandbox-rootfs/' in stage: + assert stage.index(' / /sandbox-rootfs/') < stage.index('--prepare-rootfs /sandbox-rootfs'), name + if '/usr/local/bin/build-rootfs-image.sh /sandbox-rootfs /sandbox-rootfs.img' in stage: + assert stage.index('--prepare-rootfs /sandbox-rootfs') < stage.index('/usr/local/bin/build-rootfs-image.sh /sandbox-rootfs /sandbox-rootfs.img'), name +text = (root / 'launcher/src/main.rs').read_text() +assert '"SANDBOX_RESOLV_CONF"' in text.split('const ALLOW_EXACT:')[1].split('];')[0] +argv = text.split('let argv_strs:')[1].split('let argv_ptrs:')[0] +# libkrun init supplies argv[0]. Repeating the binary here makes Bash try to +# interpret /bin/bash itself as a shell script instead of the DNS wrapper. +assert 'cstr("/bin/bash")' not in argv +assert 'cstr("/sandbox_api/guest-dns.sh")' in argv +assert 'cstr("--exec")' in argv and 'cstr(&exec_path)' in argv +assert 'let exec_c = cstr("/bin/bash")' in text +PY +# The wrapper configures DNS before a custom guest executable, independently +# of the normal API entrypoint and its later /tmp mount. +rm -rf "$TEST_DIR/guest/run/codeapi-resolver" +cat > "$TEST_DIR/bin/mount" <<'STUB' +#!/usr/bin/env bash +set -euo pipefail +[[ "$*" == "-t tmpfs -o size=1m,mode=0755 tmpfs $TEST_GUEST_ROOT/run" ]] +[[ "${TEST_MOUNT_FAIL:-false}" != true ]] +STUB +cat > "$TEST_DIR/bin/custom-guest" <<'STUB' +#!/usr/bin/env bash +set -euo pipefail +[[ "$(cat "$TEST_GUEST_ROOT/etc/resolv.conf")" == 'nameserver 127.0.0.11' ]] +[[ ! -v SANDBOX_RESOLV_CONF ]] +echo 'custom guest DNS ready' +STUB +chmod +x "$TEST_DIR/bin/mount" "$TEST_DIR/bin/custom-guest" +PATH="$TEST_DIR/bin:$PATH" TEST_GUEST_ROOT="$TEST_DIR/guest" \ +SANDBOX_RESOLV_CONF='nameserver 127.0.0.11' \ +bash -c 'source "$1"; run_guest_command "$2" "$3"' -- \ + "$ROOT/api/src/guest-dns.sh" "$TEST_DIR/guest" "$TEST_DIR/bin/custom-guest" + +if PATH="$TEST_DIR/bin:$PATH" TEST_GUEST_ROOT="$TEST_DIR/guest" \ +TEST_MOUNT_FAIL=true SANDBOX_RESOLV_CONF='nameserver 127.0.0.11' \ +bash -c 'source "$1"; run_guest_command "$2" "$3"' -- \ + "$ROOT/api/src/guest-dns.sh" "$TEST_DIR/guest" "$TEST_DIR/bin/custom-guest" > "$TEST_DIR/failed-boot"; then + echo 'started custom guest despite failed runtime mount' >&2; exit 1 +fi +[[ ! -s "$TEST_DIR/failed-boot" ]] +printf 'KVM guest DNS checks passed\n'