diff --git a/Dockerfile b/Dockerfile index 1ed000743d..5987bfa991 100644 --- a/Dockerfile +++ b/Dockerfile @@ -25,7 +25,10 @@ RUN cd gui && bun run build FROM ${BUN_IMAGE} AS runtime WORKDIR /home/bun/app +# Docker supervises this foreground process; retain routed state on stop/recreate. +# This uses the existing service lifecycle mode and does not install a service manager. ENV NODE_ENV=production \ + OCX_SERVICE=1 \ OPENCODEX_HOME=/home/bun/.opencodex \ CODEX_HOME=/home/bun/.codex \ OCX_API_TOKEN_FILE=/home/bun/.opencodex/service-api-token diff --git a/devlog/_plan/260907_platform_validation/025_container_lifecycle_mode.md b/devlog/_plan/260907_platform_validation/025_container_lifecycle_mode.md new file mode 100644 index 0000000000..c86f4fc765 --- /dev/null +++ b/devlog/_plan/260907_platform_validation/025_container_lifecycle_mode.md @@ -0,0 +1,10 @@ +# Container lifecycle mode + +Amendment after real Docker recreation verification. Docker supervises the foreground hub and must retain persisted routed state across replacement. + +MODIFY Dockerfile runtime ENV: set existing OCX_SERVICE=1, with no service manager installation or privilege change. Preserve image digest, foreground CMD, listener authentication, separate writable homes and read-only root. +MODIFY scripts/ci/docker-smoke.ts: assert the actual container process receives service lifecycle mode. Retain the routed synthetic slug and exact token/catalog/config hashes across graceful recreation. +MODIFY tests/service/container-bootstrap.test.ts: include the runtime ENV declaration in the existing packaging contract. +MODIFY docs-site/src/content/docs/guides/remote-hub.md: document service-mode foreground lifecycle, Compose restart/recreation, and the limit on other dashboard restart paths. + +Independent Astra high lifecycle/security review accepted the bounded packaging change. Actual remote CLI comparison confirmed preservation with service mode. Final image CI must prove the same real container lifecycle; no local tests or Docker execution. This does not change shared CLI cleanup, restart policy, or authentication code. diff --git a/docs-site/src/content/docs/guides/remote-hub.md b/docs-site/src/content/docs/guides/remote-hub.md index 2334303be7..460fe4651d 100644 --- a/docs-site/src/content/docs/guides/remote-hub.md +++ b/docs-site/src/content/docs/guides/remote-hub.md @@ -167,7 +167,11 @@ opencodex does not publish an official container image. The repository does main [`compose.yaml`](https://github.com/lidge-jun/opencodex/blob/main/compose.yaml), and a narrow `.dockerignore`. The build pins the multi-platform Bun 1.4.0 image index by digest, runs the proxy as the non-root `bun` user, keeps the root filesystem read-only, drops Linux capabilities, and publishes -only the data listener on the host's `127.0.0.1:10100` by default. +only the data listener on the host's `127.0.0.1:10100` by default. The foreground process uses +`OCX_SERVICE=1`, so stopping or recreating the container preserves routed Codex state instead +of restoring a native desktop configuration. Docker supplies supervision; no OS service manager +is installed in the image. Use Compose to restart/recreate the container; this does not extend +support to every dashboard restart path. The image seeds a first-run `hub` configuration that binds the container listener to `0.0.0.0`. Before the first normal start, stream a freshly generated data-plane token into the bootstrap helper. diff --git a/docs-site/src/content/docs/reference/proxy-formats.md b/docs-site/src/content/docs/reference/proxy-formats.md index 7008194e19..cb97ad7076 100644 --- a/docs-site/src/content/docs/reference/proxy-formats.md +++ b/docs-site/src/content/docs/reference/proxy-formats.md @@ -409,7 +409,30 @@ default provider is enabled and is not itself an OpenAI-family entry; account-qu such as `side/gpt-5.6-sol` still fail closed. The proxy logs one notice per provider when this fallback engages. Configurations with an enabled canonical `openai` provider are unchanged. -Native compact responses are buffered with a 32 MiB maximum, including responses whose declared +Inbound bodies on both `/v1/responses` and `/v1/responses/compact` retain the shared 256 MiB +wire/decompression admission limit. Application-level size rejection returns HTTP 413 with +`type` and `code` both `invalid_request_error`. Its message includes a bounded diagnostic suffix, +for example: + +```text +Decompressed request body exceeds 268435456 bytes [measurement=decoded_lower_bound; bytes=268435457] +``` + +| Measurement | Meaning of `bytes` | +| --- | --- | +| `declared_wire` | Numeric `Content-Length` declared by the sender; rejected before reading, not a measured decoded size | +| `observed_wire_lower_bound` | Wire bytes encountered when reading stopped; the complete body may be larger | +| `decoded_exact` | Exact size of the buffer supplied to the identity decoder or returned by a decoder | +| `decoded_lower_bound` | Admission limit plus one after inflation aborts; a lower bound, never the exact decoded size | + +The suffix contains only a fixed category and a finite numeric byte value. Rejected bodies are +not read or inflated further, parsed for item counts, or retained for diagnostics. Legacy errors +without measurement provenance retain the limit-only message. Bun's listener can reject an +oversized wire body before application diagnostics run, so not every 413 carries this suffix. +A lower-bound diagnostic cannot establish the complete compact payload size. The admission +limit and retry behavior are unchanged. + +Native compact responses are buffered with a separate 32 MiB maximum, including responses whose declared `Content-Length` already exceeds the limit. The compact-specific failures include: | Status | Type or code | Meaning | diff --git a/scripts/ci/docker-smoke.ts b/scripts/ci/docker-smoke.ts new file mode 100644 index 0000000000..c2a7ec0ee2 --- /dev/null +++ b/scripts/ci/docker-smoke.ts @@ -0,0 +1,438 @@ +/** Hosted Linux Docker acceptance only; never uses provider credentials or inference. */ +import { spawn } from "node:child_process"; +import { createHash, randomBytes } from "node:crypto"; +import { chmodSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, rmSync, rmdirSync, utimesSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; + +const root = resolve(import.meta.dir, "../.."); +const project = `ocx-smoke-${randomBytes(12).toString("hex")}`; +const image = `${project}:local`; +const cancelled = new AbortController(); +const outputLimit = 8 * 1024 * 1024; +let stage = "initialization"; +let scratch = ""; +let composeArgs: string[] = []; +let env: Record = {}; + +class SmokeFailure extends Error {} + +function check(ok: unknown, message: string): asserts ok { + if (!ok) throw new SmokeFailure(message); +} + +// Do not include arguments, child output, HTTP bodies, or arbitrary error messages in diagnostics. +function progress(name: string): void { + stage = name; + console.log(`docker-smoke: ${name}`); +} + +async function run(args: string[], input?: string, timeout = 30_000, cleanup = false) { + if (!cleanup) cancelled.signal.throwIfAborted(); + return await new Promise<{ code: number | null; out: string }>((accept, reject) => { + const child = spawn(args[0]!, args.slice(1), { + cwd: root, env, detached: true, stdio: ["pipe", "pipe", "pipe"], + }); + const chunks: Buffer[] = []; + let bytes = 0; + let failed = false; + let killTimer: ReturnType | undefined; + let reapTimer: ReturnType | undefined; + const killGroup = (signal: NodeJS.Signals) => { + if (child.pid) { + try { process.kill(-child.pid, signal); } catch { /* already exited */ } + } + }; + const stop = () => { + if (failed) return; + failed = true; + killGroup("SIGTERM"); + killTimer = setTimeout(() => killGroup("SIGKILL"), 1_000); + // A daemon/plugin retaining a pipe must not keep the harness alive indefinitely. + reapTimer = setTimeout(() => { + child.stdout.destroy(); child.stderr.destroy(); child.stdin.destroy(); + finish(); + child.unref(); + reject(new SmokeFailure("child did not close within the termination deadline")); + }, 4_000); + }; + const timer = setTimeout(stop, timeout); + const finish = () => { + clearTimeout(timer); clearTimeout(killTimer); clearTimeout(reapTimer); + cancelled.signal.removeEventListener("abort", stop); + }; + if (!cleanup) cancelled.signal.addEventListener("abort", stop, { once: true }); + const collect = (data: Buffer, stdout: boolean) => { + bytes += data.length; + if (bytes > outputLimit) stop(); + else if (stdout) chunks.push(data); + }; + child.stdout.on("data", (data: Buffer) => collect(data, true)); + child.stderr.on("data", (data: Buffer) => collect(data, false)); + child.stdin.on("error", () => { /* EPIPE is possible on the refused bootstrap. */ }); + child.on("error", () => { finish(); reject(new SmokeFailure("child could not start")); }); + child.on("close", code => { + // A terminated CLI can close its pipes before its plugin exits. + if (failed) killGroup("SIGKILL"); + finish(); + if (failed) reject(new SmokeFailure("child exceeded time/output limit or was cancelled")); + else accept({ code, out: Buffer.concat(chunks).toString("utf8") }); + }); + child.stdin.end(input); + }); +} + +async function command(args: string[], input?: string, timeout?: number, cleanup = false) { + const result = await run(args, input, timeout, cleanup); + check(result.code === 0, `command exited ${result.code ?? "by signal"}`); + return result.out.trim(); +} + +function compose(args: string[], input?: string, timeout?: number, cleanup = false) { + return command(["docker", ...composeArgs, ...args], input, timeout, cleanup); +} + +async function build() { + const directory = join(root, "src/generated"); + const manifest = join(directory, "compatibility-version.json"); + const directoryStat = lstatSync(directory, { throwIfNoEntry: false }); + const hadDirectory = directoryStat !== undefined; + check(!directoryStat || directoryStat.isDirectory(), "unsafe generated directory"); + const originalStat = lstatSync(manifest, { throwIfNoEntry: false }); + check(!originalStat || originalStat.isFile(), "unsafe existing manifest"); + check(!originalStat || originalStat.size <= 8 * 1024 * 1024, "existing manifest exceeds limit"); + const original = originalStat ? readFileSync(manifest) : undefined; + try { + progress("generate compatibility manifest"); + await command([process.execPath, "scripts/generate-compatibility-version.ts"]); + progress("build Docker image"); + await compose(["build", "hub"], undefined, 600_000); + } finally { + if (original && originalStat) { + writeFileSync(manifest, original); + chmodSync(manifest, originalStat.mode & 0o777); + utimesSync(manifest, originalStat.atime, originalStat.mtime); + } else { + rmSync(manifest, { force: true }); + } + if (!hadDirectory && existsSync(directory)) rmdirSync(directory); + } +} + +const fixture = JSON.stringify({ models: [{ + slug: "smoke/synthetic", display_name: "Smoke fixture", description: "Synthetic catalog only", + priority: 1, visibility: "list", base_instructions: "Synthetic", input_modalities: ["text"], +}] }); +const token = randomBytes(32).toString("hex"); +const replacement = randomBytes(32).toString("hex"); +const sha256 = (value: string) => createHash("sha256").update(value).digest("hex"); +let seededConfigHash = ""; +let readyConfigHash = ""; + +// Check the loader, including its schema-repair/default-provider fallback, before server startup +// and again in each running container. This isolates synthetic inference, not all process egress. +const fixtureConfigCheck = ` + const { loadConfig } = await import('./src/config.ts'); + const effective = loadConfig(); + const provider = effective.providers.smoke; + if (Object.keys(effective.providers).join(',') !== 'smoke' || effective.defaultProvider !== 'smoke' + || provider?.adapter !== 'openai-responses' || provider?.authMode !== 'local' + || provider?.allowPrivateNetwork !== true + || provider?.baseUrl !== 'http://127.0.0.1:9/v1' || provider?.codexAccountMode !== undefined || provider?.apiKey + || effective.runtimeRole !== 'hub' || effective.hostname !== '0.0.0.0' || effective.port !== 10100 + || effective.codexAutoStart !== false || effective.codexShimAutoRestore !== false) throw new Error('unsafe effective fixture config'); +`; + +interface Container { + Id: string; + State: { Running: boolean; Health?: { Status: string } }; + HostConfig: { ReadonlyRootfs: boolean; CapDrop: string[]; SecurityOpt: string[]; Privileged: boolean }; + Config: { Image: string; Labels: Record }; + NetworkSettings: { Ports: Record | null> }; + Mounts: Array<{ Type: string; Name?: string; Destination: string; RW: boolean }>; +} + +async function inspect() { + const id = await compose(["ps", "-q", "hub"]); + check(/^[a-f0-9]{64}$/.test(id), "expected exactly one container"); + const rows = JSON.parse(await command(["docker", "inspect", id])) as Container[]; + check(rows.length === 1, "unexpected inspect result"); + const container = rows[0]!; + check(container.Id === id && container.Config.Image === image + && container.Config.Labels["com.docker.compose.project"] === project, "container identity mismatch"); + check(container.State.Running && container.State.Health?.Status === "healthy", "container not healthy"); + check(container.HostConfig.ReadonlyRootfs && !container.HostConfig.Privileged + && container.HostConfig.CapDrop.includes("ALL") + && container.HostConfig.SecurityOpt.some(value => /^no-new-privileges(?::true)?$/.test(value)), "restrictions missing"); + const ports = Object.entries(container.NetworkSettings.Ports).filter(([, entries]) => entries?.length); + check(ports.length === 1 && ports[0]![0] === "10100/tcp", "unexpected published port"); + const bindings = ports[0]![1]!; + check(bindings.length === 1 && bindings[0]!.HostIp === "127.0.0.1", "non-loopback publication"); + const port = Number(bindings[0]!.HostPort); + check(Number.isInteger(port) && port > 0 && port <= 65535, "invalid host port"); + const volumes = [".opencodex", ".codex"].map(home => { + const mounts = container.Mounts.filter(mount => mount.Destination === `/home/bun/${home}`); + check(mounts.length === 1, "missing home mount"); + const mount = mounts[0]!; + check(mount.Type === "volume" && mount.RW && mount.Name?.startsWith(`${project}_`), "unexpected home volume"); + return mount.Name; + }); + check(volumes[0] !== volumes[1], "homes share a volume"); + return { id, volumes, url: `http://127.0.0.1:${port}` }; +} + +// This runs as the image's user. Only hashes/metadata leave the container, never file bytes. +const stateProbe = ` + import { readFileSync, statSync, writeFileSync } from 'node:fs'; + import { createHash } from 'node:crypto'; + import { isDeepStrictEqual } from 'node:util'; + const phase = await Bun.stdin.text(); + if (!['seed', 'first-ready', 'steady'].includes(phase)) throw new Error('invalid state phase'); + ${fixtureConfigCheck} + const homes = ['/home/bun/.opencodex', '/home/bun/.codex']; + if (process.env.OCX_SERVICE !== '1') throw new Error('image service lifecycle mode missing'); + const uid = process.getuid(); + if (uid === 0) throw new Error('root user'); + const status = readFileSync('/proc/self/status', 'utf8'); + if (!/^CapEff:\\s+0+$/m.test(status) || !/^NoNewPrivs:\\s+1$/m.test(status)) throw new Error('effective restrictions'); + for (const home of homes) { + const s = statSync(home); + if (s.uid !== uid || (s.mode & 0o777) !== 0o700) throw new Error('home permissions'); + } + try { writeFileSync('/home/bun/app/.smoke-root-write', 'x'); throw new Error('writable root'); } + catch (e) { if (e.code !== 'EROFS') throw e; } + const paths = [homes[0] + '/config.json', homes[0] + '/service-api-token', homes[1] + '/opencodex-catalog.json']; + const hashes = paths.map(path => { + const s = statSync(path); + if (s.uid !== uid || (s.mode & 0o777) !== 0o600 || s.size > 65536) throw new Error('file permissions/size'); + return createHash('sha256').update(readFileSync(path)).digest('hex'); + }); + // The immutable shipped config was byte-verified before fixture creation. Reconstruct only + // the deliberate fixture route edits, then compare every original key on disk (not loader defaults). + const seed = JSON.parse(readFileSync('docker/config.json', 'utf8')); + seed.providers = { smoke: { adapter: 'openai-responses', baseUrl: 'http://127.0.0.1:9/v1', authMode: 'local', allowPrivateNetwork: true } }; + seed.defaultProvider = 'smoke'; + const persisted = JSON.parse(readFileSync(paths[0], 'utf8')); + const loaded = JSON.parse(JSON.stringify(effective)); + for (const key of Object.keys(seed)) { + for (const config of [persisted, loaded]) { + if (!Object.hasOwn(config, key) || !isDeepStrictEqual(config[key], seed[key])) throw new Error('seed semantics changed'); + } + } + // Independent oracle measured by isolated startup; update only for an intentional contract change. + // Do not derive expected values from runtime migration/default helpers. + const additions = { + appOwnedMemoryBudgetMb: 256, fastRows: true, managementUsageMaxReadBytes: 67108864, + openaiProviderTierVersion: 2, + subagentModels: ['gpt-6-astra', 'gpt-5.6-sol', 'gpt-5.6-terra', 'gpt-5.6-luna', 'gpt-5.5'], + subagentModelsVersion: 1, + }; + for (const config of [persisted, loaded]) { + if (Object.keys(config).some(key => !Object.hasOwn(seed, key) && !Object.hasOwn(additions, key))) throw new Error('unexpected startup config addition'); + for (const [key, expected] of Object.entries(additions)) { + if (phase !== 'seed' || Object.hasOwn(config, key)) { + if (!Object.hasOwn(config, key) || !isDeepStrictEqual(config[key], expected)) throw new Error('startup oracle mismatch'); + } + } + } + if (phase === 'seed' && Object.keys(persisted).some(key => !Object.hasOwn(seed, key))) throw new Error('premature seed addition'); + console.log(JSON.stringify(hashes)); +`; + +async function state(phase: "seed" | "first-ready" | "steady" = "steady") { + const invocation = phase === "seed" ? ["run", "--rm", "-T", "--no-deps"] : ["exec", "-T"]; + const hashes = JSON.parse(await compose([...invocation, "hub", "bun", "-e", stateProbe], phase)) as string[]; + check(hashes.length === 3 && hashes.every(hash => /^[a-f0-9]{64}$/.test(hash)), "invalid state evidence"); + check(hashes[1] === sha256(`${token}\n`) && hashes[2] === sha256(fixture), "token/catalog changed"); + if (phase === "first-ready") { + check(!readyConfigHash, "post-start config baseline already established"); + // stateProbe has checked persisted/effective semantics and the independent startup oracle. + readyConfigHash = hashes[0]!; + } else { + check(hashes[0] === (phase === "seed" ? seededConfigHash : readyConfigHash), + phase === "seed" ? "seeded config changed before startup" : "post-start config changed"); + } + return JSON.stringify(hashes); +} + +async function request(url: string, path: string, secret?: string) { + const controller = new AbortController(); + const abort = () => controller.abort(); + cancelled.signal.throwIfAborted(); + cancelled.signal.addEventListener("abort", abort, { once: true }); + const timer = setTimeout(abort, 5_000); + try { + const post = path !== "/healthz" && path !== "/readyz" && path !== "/v1/catalog"; + const response = await fetch(`${url}${path}`, { + method: post ? "POST" : "GET", redirect: "error", signal: controller.signal, + headers: { ...(secret ? { "x-opencodex-api-key": secret } : {}), ...(post ? { "content-type": "application/json" } : {}) }, + // Never send an authorized inference request, even with synthetic input. + body: post ? '{"model":"smoke/synthetic","input":[]}' : undefined, + }); + const reader = response.body?.getReader(); + const chunks: Uint8Array[] = []; + let size = 0; + try { + while (reader) { + const next = await reader.read(); + if (next.done) break; + size += next.value.length; + check(size <= 64 * 1024, "HTTP body exceeds limit"); + chunks.push(next.value); + } + } finally { controller.abort(); reader?.releaseLock(); } + return { status: response.status, body: Buffer.concat(chunks).toString("utf8") }; + } finally { + clearTimeout(timer); + cancelled.signal.removeEventListener("abort", abort); + } +} + +async function acceptance(url: string) { + check((await request(url, "/healthz")).status === 200, "liveness failed"); + const deadline = Date.now() + 60_000; + while (true) { + const ready = await request(url, "/readyz"); + const body = JSON.parse(ready.body) as { status?: string }; + if (ready.status === 200 && body.status === "ready") break; + check(ready.status === 503 && body.status === "pending" && Date.now() < deadline, "readiness failed"); + await Bun.sleep(500); + } + for (const path of ["/v1/catalog", "/v1/responses", "/v1/responses/compact"]) { + for (const secret of [undefined, replacement]) { + const result = await request(url, path, secret); + check(result.status === 401, `${path} ${secret ? "wrong" : "missing"} token returned ${result.status}, expected 401`); + } + } + const catalog = await request(url, "/v1/catalog", token); + check(catalog.status === 200 && catalog.body === fixture, "catalog not served exactly"); +} + +async function cleanup() { + let failed = false; + const attempt = async (action: () => Promise) => { + try { await action(); } catch { failed = true; } + }; + if (composeArgs.length) { + await attempt(() => compose(["down", "--volumes", "--remove-orphans", "--timeout", "10"], undefined, 45_000, true)); + for (const kind of ["container", "volume", "network"]) { + await attempt(async () => { + const remaining = await command(["docker", kind, "ls", "-q", ...(kind === "container" ? ["-a"] : []), + "--filter", `label=com.docker.compose.project=${project}`], undefined, 15_000, true); + check(!remaining, "project resources remain"); + }); + } + await attempt(async () => { + const ids = await command(["docker", "image", "ls", "-q", "--filter", `reference=${image}`], undefined, 15_000, true); + if (ids) await command(["docker", "image", "rm", image], undefined, 30_000, true); + check(!await command(["docker", "image", "ls", "-q", "--filter", `reference=${image}`], undefined, 15_000, true), "image remains"); + }); + } + try { if (scratch) rmSync(scratch, { recursive: true, force: true, maxRetries: 0 }); } catch { failed = true; } + check(!failed, "cleanup incomplete"); +} + +async function main() { + check(process.platform === "linux", "requires a disposable Linux Docker runner"); + scratch = mkdtempSync(join(tmpdir(), `${project}-`)); + mkdirSync(join(scratch, "docker"), { mode: 0o700 }); + writeFileSync(join(scratch, "empty.env"), "", { mode: 0o600 }); + writeFileSync(join(scratch, "override.json"), JSON.stringify({ + services: { hub: { image, restart: "no" } }, + }), { mode: 0o600 }); + env = { + PATH: process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin", TMPDIR: scratch, + DOCKER_CONFIG: join(scratch, "docker"), DOCKER_HOST: "unix:///var/run/docker.sock", + COMPOSE_DISABLE_ENV_FILE: "1", OPENCODEX_BIND_ADDRESS: "127.0.0.1", OPENCODEX_PORT: "0", + }; + composeArgs = ["compose", "--project-name", project, "--project-directory", root, + "--env-file", join(scratch, "empty.env"), "-f", join(root, "compose.yaml"), "-f", join(scratch, "override.json")]; + progress("validate and build"); + await compose(["config", "--quiet"]); + await build(); + progress("verify shipped config and seed loopback-only fixture"); + const seeded = await run(["docker", ...composeArgs, "run", "--rm", "-T", "--no-deps", "hub", "bun", "-e", + ` + import { readFileSync, writeFileSync } from 'node:fs'; + import { createHash } from 'node:crypto'; + // Exit codes are fixed diagnostic markers; never serialize the caught exception. + let seedStage = 70; + try { + const { atomicWriteFile } = await import('./src/config/atomic-write.ts'); + seedStage = 71; + const { shipped, catalog } = JSON.parse(await Bun.stdin.text()); + const path = '/home/bun/.opencodex/config.json'; + seedStage = 72; + if (readFileSync(path, 'utf8') !== shipped || readFileSync('docker/config.json', 'utf8') !== shipped) { + throw new Error('shipped config mismatch'); + } + const config = JSON.parse(shipped); + if (config.runtimeRole !== 'hub' || config.hostname !== '0.0.0.0' || config.port !== 10100 + || config.codexAutoStart !== false || config.codexShimAutoRestore !== false) throw new Error('shipped runtime contract'); + // Port 9 has no listener in this image. Replace all provider routes before any server starts; + // even an admission regression cannot send these synthetic requests to a real provider. + config.providers = { smoke: { adapter: 'openai-responses', baseUrl: 'http://127.0.0.1:9/v1', authMode: 'local', allowPrivateNetwork: true } }; + config.defaultProvider = 'smoke'; + seedStage = 73; + const { validateConfigCandidate } = await import('./src/config.ts'); + if (!validateConfigCandidate(config).ok) throw new Error('invalid fixture'); + seedStage = 74; + atomicWriteFile(path, JSON.stringify(config) + '\\n'); + seedStage = 75; + ${fixtureConfigCheck} + seedStage = 76; + writeFileSync('/home/bun/.codex/opencodex-catalog.json', catalog, { mode: 0o600, flag: 'wx' }); + seedStage = 77; + console.log(createHash('sha256').update(readFileSync(path)).digest('hex')); + } catch { process.exitCode = seedStage; } + `], JSON.stringify({ shipped: readFileSync(join(root, "docker/config.json"), "utf8"), catalog: fixture })); + const seedFailures: Record = { + 70: "imports", 71: "input", 72: "shipped config contract", 73: "fixture validation", + 74: "atomic config write", 75: "effective config", 76: "catalog write", 77: "config hash", + }; + check(seeded.code === 0, `seed failed: ${seedFailures[seeded.code ?? -1] ?? "unclassified child failure"} (exit ${seeded.code ?? "signal"})`); + seededConfigHash = seeded.out.trim(); + check(/^[a-f0-9]{64}$/.test(seededConfigHash), "invalid seeded config evidence"); + progress("bootstrap throwaway token"); + await compose(["run", "--rm", "-T", "--no-deps", "hub", "bun", "run", "docker/bootstrap-token.ts"], `${token}\n`); + progress("verify exact seed state before startup"); + await state("seed"); + progress("start and check admission"); + await compose(["up", "--no-build", "--wait", "--wait-timeout", "120", "hub"], undefined, 150_000); + const first = await inspect(); + await acceptance(first.url); + const before = await state("first-ready"); + progress("refuse token replacement"); + const refused = await run(["docker", ...composeArgs, "run", "--rm", "-T", "--no-deps", "hub", + "bun", "run", "docker/bootstrap-token.ts"], `${replacement}\n`); + check(refused.code === 1, "bootstrap did not refuse replacement"); + check(await state() === before, "state changed after refused bootstrap"); + await acceptance(first.url); + progress("replace container and verify persistence"); + await compose(["up", "--no-build", "--force-recreate", "--wait", "--wait-timeout", "120", "hub"], undefined, 150_000); + const second = await inspect(); + check(second.id !== first.id && JSON.stringify(second.volumes) === JSON.stringify(first.volumes), "replacement/volume identity failed"); + check(await state() === before, "persistent state changed"); + await acceptance(second.url); +} + +const abort = () => cancelled.abort(); +process.once("SIGINT", abort); +process.once("SIGTERM", abort); +const deadline = setTimeout(abort, 16 * 60_000); +try { + await main(); +} catch (error) { + const reason = error instanceof SmokeFailure ? error.message : "unexpected failure; details suppressed"; + console.error(`docker-smoke: failed at ${stage}: ${reason}`); + process.exitCode = 1; +} finally { + clearTimeout(deadline); + try { await cleanup(); } catch { + console.error("docker-smoke: cleanup incomplete"); + process.exitCode = 1; + } + process.removeListener("SIGINT", abort); + process.removeListener("SIGTERM", abort); +} +if (!process.exitCode) console.log("docker-smoke: build/start/recreate acceptance passed; cleanup complete"); diff --git a/src/server/request-decompress.ts b/src/server/request-decompress.ts index 0710470346..297c77a9d1 100644 --- a/src/server/request-decompress.ts +++ b/src/server/request-decompress.ts @@ -27,14 +27,39 @@ export class UnsupportedContentEncodingError extends Error { } } +export type BodySizeMeasurement = + | "declared_wire" + | "observed_wire_lower_bound" + | "decoded_exact" + | "decoded_lower_bound"; + export class DecompressedBodyTooLargeError extends Error { - constructor(readonly bytes: number, limit: number = MAX_DECOMPRESSED_BODY_BYTES) { - super(`Decompressed request body exceeds ${limit} bytes`); + readonly measurement: BodySizeMeasurement | null; + + constructor( + readonly bytes: number, + readonly limit: number = MAX_DECOMPRESSED_BODY_BYTES, + measurement: BodySizeMeasurement | null = null, + ) { + // Legacy callers supply no provenance. Only fixed categories and finite + // numbers may reach the public message, including calls from untyped code. + const category = measurement === "declared_wire" || measurement === "observed_wire_lower_bound" + || measurement === "decoded_exact" || measurement === "decoded_lower_bound" + ? measurement : null; + const suffix = category !== null && Number.isFinite(bytes) && bytes >= 0 + && Number.isFinite(limit) && limit >= 0 + ? ` [measurement=${category}; bytes=${bytes}]` : ""; + super(`Decompressed request body exceeds ${Number.isFinite(limit) ? limit : "unknown"} bytes${suffix}`); + this.measurement = category; } } -function assertBodySizeWithinLimit(body: Uint8Array, maxBytes: number): Uint8Array { - if (body.byteLength > maxBytes) throw new DecompressedBodyTooLargeError(body.byteLength, maxBytes); +function assertBodySizeWithinLimit( + body: Uint8Array, + maxBytes: number, + measurement: BodySizeMeasurement = "decoded_exact", +): Uint8Array { + if (body.byteLength > maxBytes) throw new DecompressedBodyTooLargeError(body.byteLength, maxBytes, measurement); return body; } @@ -112,7 +137,7 @@ async function readRequestBodyBytesCapped( if (!value || value.byteLength === 0) continue; if (value.byteLength > maxBytes - retainedBytes) { - const error = new DecompressedBodyTooLargeError(retainedBytes + value.byteLength, maxBytes); + const error = new DecompressedBodyTooLargeError(retainedBytes + value.byteLength, maxBytes, "observed_wire_lower_bound"); cancel(error); throw error; } @@ -173,7 +198,8 @@ export function decodeRequestBody( else throw new UnsupportedContentEncodingError(encoding); } catch (err) { if ((err as NodeJS.ErrnoException | null)?.code === "ERR_BUFFER_TOO_LARGE") { - throw new DecompressedBodyTooLargeError(maxBytes + 1, maxBytes); + // Inflation stopped at the cap; the full decoded size was never measured. + throw new DecompressedBodyTooLargeError(maxBytes + 1, maxBytes, "decoded_lower_bound"); } throw err; } @@ -198,7 +224,7 @@ export async function readBoundedJsonRequestBody( // Reject an honest oversized declaration before reading. Missing, malformed, // and dishonest declarations remain bounded by the streaming reader below. if (declaredLength !== null && declaredLength > maxBytes) { - const error = new DecompressedBodyTooLargeError(declaredLength, maxBytes); + const error = new DecompressedBodyTooLargeError(declaredLength, maxBytes, "declared_wire"); cancelStreamWithoutWaiting(req.body, error); throw error; } @@ -211,7 +237,7 @@ export async function readBoundedJsonRequestBody( } finally { releaseReservation?.(); } - assertBodySizeWithinLimit(raw, maxBytes); + assertBodySizeWithinLimit(raw, maxBytes, "observed_wire_lower_bound"); const releaseRaw = budget?.observeAcceptedRequestCopy(raw.byteLength); let releaseDecoded: (() => void) | undefined; let releaseText: (() => void) | undefined; diff --git a/tests/service/container-bootstrap.test.ts b/tests/service/container-bootstrap.test.ts index ada807d1d0..9eec8edd6b 100644 --- a/tests/service/container-bootstrap.test.ts +++ b/tests/service/container-bootstrap.test.ts @@ -63,6 +63,7 @@ describe("container deployment contract", () => { const runtime = readFileSync(repoPath("Dockerfile"), "utf8").split(" AS runtime")[1]!; expect(runtime).toContain("OPENCODEX_HOME=/home/bun/.opencodex"); expect(runtime).toContain("CODEX_HOME=/home/bun/.codex"); + expect(runtime).toContain("OCX_SERVICE=1"); expect(runtime).toContain("install -d -m 0700 -o bun -g bun /home/bun/.opencodex /home/bun/.codex"); expect(runtime).toContain('VOLUME ["/home/bun/.opencodex", "/home/bun/.codex"]'); expect(runtime).toContain("USER bun"); diff --git a/tests/usage/request-decompress.test.ts b/tests/usage/request-decompress.test.ts index 7a536600cc..96a8f5a67a 100644 --- a/tests/usage/request-decompress.test.ts +++ b/tests/usage/request-decompress.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from "bun:test"; +import { deflateRawSync, deflateSync } from "node:zlib"; import { DecompressedBodyTooLargeError, decodeRequestBody, @@ -9,11 +10,35 @@ import { } from "../../src/server/request-decompress"; import { MANAGEMENT_JSON_BODY_MAX_BYTES } from "../../src/server/management/body"; import { handleManagementAPI } from "../../src/server/management-api"; +import { decodeRequestErrorResponse } from "../../src/server/responses/core"; import type { OcxConfig } from "../../src/types"; const PAYLOAD = { model: "gpt-5.5", input: "hello", stream: true }; const PAYLOAD_BYTES = new TextEncoder().encode(JSON.stringify(PAYLOAD)); +async function captureBodyTooLarge(run: () => unknown): Promise { + try { + await run(); + } catch (error) { + if (!(error instanceof DecompressedBodyTooLargeError)) throw error; + return error; + } + throw new Error("Expected body admission to reject"); +} + +async function expectBodyLimitResponse(error: DecompressedBodyTooLargeError, message: string): Promise { + expect(error.message).toBe(message); + expect(message.length).toBeLessThan(200); + for (const label of ["responses", "responses-compact"]) { + const response = decodeRequestErrorResponse(error, label); + expect(response.status).toBe(413); + expect(response.headers.get("retry-after")).toBeNull(); + expect(await response.json()).toEqual({ + error: { message, type: "invalid_request_error", code: "invalid_request_error" }, + }); + } +} + interface TrackedBodyStats { pulls: number; cancelled: number; @@ -48,6 +73,36 @@ function trackedBodyStream( return { body, stats }; } +describe("DecompressedBodyTooLargeError", () => { + test("preserves one- and two-argument constructors without guessing measurement provenance", async () => { + const legacy = new DecompressedBodyTooLargeError(268435457); + expect(legacy).toMatchObject({ bytes: 268435457, limit: 268435456, measurement: null }); + await expectBodyLimitResponse(legacy, "Decompressed request body exceeds 268435456 bytes"); + const custom = new DecompressedBodyTooLargeError(6, 5); + expect(custom).toMatchObject({ bytes: 6, limit: 5, measurement: null }); + await expectBodyLimitResponse(custom, "Decompressed request body exceeds 5 bytes"); + }); + + test("keeps untyped categories and non-finite numbers out of the message", async () => { + const untyped: DecompressedBodyTooLargeError = Reflect.construct(DecompressedBodyTooLargeError, [ + 6, 5, "private-header-context window".repeat(100), + ]); + expect(untyped.measurement).toBeNull(); + await expectBodyLimitResponse(untyped, "Decompressed request body exceeds 5 bytes"); + for (const bytes of [NaN, Infinity, -Infinity, -1]) { + const error = new DecompressedBodyTooLargeError(bytes, 5, "declared_wire"); + await expectBodyLimitResponse(error, "Decompressed request body exceeds 5 bytes"); + } + for (const limit of [NaN, Infinity, -Infinity]) { + const error = new DecompressedBodyTooLargeError(6, limit, "declared_wire"); + await expectBodyLimitResponse(error, "Decompressed request body exceeds unknown bytes"); + } + const huge = new DecompressedBodyTooLargeError(Number.MAX_VALUE, 5, "declared_wire"); + await expectBodyLimitResponse(huge, + "Decompressed request body exceeds 5 bytes [measurement=declared_wire; bytes=1.7976931348623157e+308]"); + }); +}); + describe("decodeRequestBody", () => { test("passes identity and absent encodings through untouched", () => { expect(decodeRequestBody(PAYLOAD_BYTES, null)).toBe(PAYLOAD_BYTES); @@ -78,10 +133,11 @@ describe("decodeRequestBody", () => { expect(new TextDecoder().decode(decodeRequestBody(compressed, "x-gzip"))).toBe(JSON.stringify(PAYLOAD)); }); - test("round-trips deflate", () => { - const compressed = Bun.deflateSync(PAYLOAD_BYTES); - expect(new TextDecoder().decode(decodeRequestBody(compressed, "deflate"))).toBe(JSON.stringify(PAYLOAD)); - }); + for (const [label, compress] of [["wrapped", deflateSync], ["raw", deflateRawSync], ["Bun raw", Bun.deflateSync]] as const) { + test(`round-trips ${label} deflate`, () => { + expect(new TextDecoder().decode(decodeRequestBody(compress(PAYLOAD_BYTES), "deflate"))).toBe(JSON.stringify(PAYLOAD)); + }); + } test("is case/whitespace tolerant on the encoding token", () => { const compressed = Bun.zstdCompressSync(PAYLOAD_BYTES); @@ -104,15 +160,39 @@ describe("decodeRequestBody", () => { expect(() => decodeRequestBody(compressed, "zstd")).toThrow(DecompressedBodyTooLargeError); }); - test("aborts DURING inflation via maxOutputLength — activation per codec (injected cap)", () => { + test("reports exact identity size at the decoder boundary", async () => { + for (const encoding of [null, "", "identity"]) { + const error = await captureBodyTooLarge(() => decodeRequestBody(Uint8Array.of(1, 2, 3, 4, 5, 6), encoding, 5)); + expect(error).toMatchObject({ bytes: 6, limit: 5, measurement: "decoded_exact" }); + await expectBodyLimitResponse(error, "Decompressed request body exceeds 5 bytes [measurement=decoded_exact; bytes=6]"); + } + }); + + test("aborts DURING inflation and reports only a decoded lower bound for every codec", async () => { // Review finding (PR #96): the cap must fire inside zlib, not after full allocation. // A small injected cap keeps the test cheap while exercising the exact // ERR_BUFFER_TOO_LARGE -> DecompressedBodyTooLargeError path. const CAP = 1024; const inflates64k = new Uint8Array(64 * 1024); - expect(() => decodeRequestBody(Bun.zstdCompressSync(inflates64k), "zstd", CAP)).toThrow(DecompressedBodyTooLargeError); - expect(() => decodeRequestBody(Bun.gzipSync(inflates64k), "gzip", CAP)).toThrow(DecompressedBodyTooLargeError); - expect(() => decodeRequestBody(Bun.deflateSync(inflates64k), "deflate", CAP)).toThrow(DecompressedBodyTooLargeError); + for (const [encoding, compressed] of [ + ["zstd", Bun.zstdCompressSync(inflates64k)], + ["gzip", Bun.gzipSync(inflates64k)], + ["x-gzip", Bun.gzipSync(inflates64k)], + ["deflate", deflateSync(inflates64k)], + ["deflate", deflateRawSync(inflates64k)], + ["deflate", Bun.deflateSync(inflates64k)], + ] as const) { + expect(compressed.byteLength).toBeLessThan(CAP); + // Exercise the streaming reader too: these invalid-JSON bytes must be + // rejected by inflation before text decoding or JSON parsing. + const req = new Request("http://localhost/v1/responses/compact", { + method: "POST", headers: { "content-encoding": encoding }, body: compressed, + }); + const error = await captureBodyTooLarge(() => readBoundedJsonRequestBody(req, CAP)); + expect(error).toMatchObject({ bytes: 1025, limit: 1024, measurement: "decoded_lower_bound" }); + await expectBodyLimitResponse(error, + "Decompressed request body exceeds 1024 bytes [measurement=decoded_lower_bound; bytes=1025]"); + } }); test("injected cap still admits bodies within the limit", () => { @@ -134,6 +214,20 @@ describe("decodeRequestBody", () => { }); describe("readJsonRequestBody", () => { + test("reports a compressed declaration without reading or echoing request metadata", async () => { + const { body, stats } = trackedBodyStream([Bun.gzipSync(PAYLOAD_BYTES)]); + const req = new Request("http://localhost/v1/responses/compact?private-query", { + method: "POST", + headers: { "content-length": "00001025", "content-encoding": "gzip", "x-private-marker": "private-header" }, + body, + }); + const error = await captureBodyTooLarge(() => readBoundedJsonRequestBody(req, 1024)); + expect(error).toMatchObject({ bytes: 1025, limit: 1024, measurement: "declared_wire" }); + await expectBodyLimitResponse(error, + "Decompressed request body exceeds 1024 bytes [measurement=declared_wire; bytes=1025]"); + expect(stats).toEqual({ pulls: 0, cancelled: 1, sentinelPulled: false }); + }); + test("rejects and cancels declared over-cap bodies before reading", async () => { const { body, stats } = trackedBodyStream([PAYLOAD_BYTES]); const req = new Request("http://localhost/v1/responses", { @@ -142,7 +236,10 @@ describe("readJsonRequestBody", () => { body, }); - await expect(readJsonRequestBody(req)).rejects.toBeInstanceOf(DecompressedBodyTooLargeError); + const error = await captureBodyTooLarge(() => readJsonRequestBody(req)); + expect(error).toMatchObject({ bytes: 268435457, limit: 268435456, measurement: "declared_wire" }); + await expectBodyLimitResponse(error, + "Decompressed request body exceeds 268435456 bytes [measurement=declared_wire; bytes=268435457]"); expect(stats.pulls).toBe(0); expect(stats.cancelled).toBe(1); }); @@ -160,8 +257,10 @@ describe("readJsonRequestBody", () => { ], { sentinel }); const req = new Request("http://localhost/api/optional", { method: "POST", headers, body }); - await expect(readBoundedJsonRequestBody(req, 5, undefined, { emptyBodyFallback: {} })) - .rejects.toBeInstanceOf(DecompressedBodyTooLargeError); + const error = await captureBodyTooLarge(() => readBoundedJsonRequestBody(req, 5, undefined, { emptyBodyFallback: {} })); + expect(error).toMatchObject({ bytes: 6, limit: 5, measurement: "observed_wire_lower_bound" }); + await expectBodyLimitResponse(error, + "Decompressed request body exceeds 5 bytes [measurement=observed_wire_lower_bound; bytes=6]"); expect(stats).toEqual({ pulls: 2, cancelled: 1, sentinelPulled: false }); }); } @@ -252,8 +351,10 @@ describe("readJsonRequestBody", () => { body: oversizedWireBody, }); expect(req.headers.get("content-length")).toBeNull(); - await expect(readBoundedJsonRequestBody(req, 1024, undefined, { emptyBodyFallback: {} })) - .rejects.toBeInstanceOf(DecompressedBodyTooLargeError); + const error = await captureBodyTooLarge(() => readBoundedJsonRequestBody(req, 1024, undefined, { emptyBodyFallback: {} })); + expect(error).toMatchObject({ bytes: oversizedWireBody.byteLength, limit: 1024, measurement: "observed_wire_lower_bound" }); + await expectBodyLimitResponse(error, + `Decompressed request body exceeds 1024 bytes [measurement=observed_wire_lower_bound; bytes=${oversizedWireBody.byteLength}]`); }); test("parses an uncompressed request without touching arrayBuffer path", async () => {