diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8c15cb696ad..00abe5ec24d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,6 +26,10 @@ on: push: branches: [main, preview, dev] paths: + - "Dockerfile" + - "compose.yaml" + - ".dockerignore" + - "docker/**" - "src/**" - "bin/**" - "tests/**" @@ -180,6 +184,10 @@ jobs: # start the workflow so the aggregate check exists, while these # paths decide whether the expensive test jobs need to run. ci: + - 'Dockerfile' + - 'compose.yaml' + - '.dockerignore' + - 'docker/**' - 'src/**' - 'bin/**' - 'tests/**' @@ -423,6 +431,7 @@ jobs: run: | bun x tsc --noEmit bun x tsc --noEmit -p tests/tsconfig.doctor-service-memory-contract.json + bun x tsc --ignoreConfig --noEmit --strict --target ESNext --module ESNext --moduleResolution bundler --types bun-types --skipLibCheck scripts/ci/docker-smoke.ts - name: GUI tests run: cd gui && bun test --isolate tests @@ -908,6 +917,26 @@ jobs: bun run scripts/keyring-smoke.ts ' + # Exercise the source-build Compose contract, including real volume reuse. + # Host fixtures cannot prove image construction or container recreation. + docker-smoke: + name: docker smoke + needs: changes + if: github.event_name != 'pull_request' || needs.changes.outputs.ci == 'true' + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + persist-credentials: false + + - name: Setup project Bun + uses: ./.github/actions/setup-project-bun + + - name: Build, start, and recreate the container + run: bun scripts/ci/docker-smoke.ts + npm-global-smoke: name: npm-global ${{ matrix.os }} needs: changes @@ -986,7 +1015,7 @@ jobs: # direct dependencies only, so a failing `select-windows-runner` would # otherwise reach this gate as nothing at all while its dependents report # `skipped` — which the gate is required to read as a deliberate skip. - needs: [changes, select-windows-runner, test, storage-policy, api-usage, gates, platform-macos, macos-control, platform-windows, keyring-smoke, npm-global-smoke] + needs: [changes, select-windows-runner, test, storage-policy, api-usage, gates, platform-macos, macos-control, platform-windows, keyring-smoke, docker-smoke, npm-global-smoke] runs-on: ubuntu-latest timeout-minutes: 5 steps: diff --git a/Dockerfile b/Dockerfile index 1ed000743d9..5987bfa991d 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 00000000000..c86f4fc7654 --- /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 2334303be71..6a2b2a33bde 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. @@ -287,6 +291,12 @@ unreadable, a non-loopback hub must not be accepted as ready. Never treat livene `docker compose down --volumes` as destructive: it deletes configuration, OAuth credentials, usage history, the data-plane token, and persisted Codex state together. +Cross-platform CI builds the source image and checks startup, data-plane token admission, and +container recreation using an isolated Compose project with throwaway credentials. It verifies that +both named volumes and a synthetic catalog survive replacement. This check does not validate a +real provider account, OAuth callback, custom mount migration, or every CPU architecture; perform +the authenticated routed-response check above for your deployment. + ## Rollback Inspect existing Serve mappings before changing them. `tailscale serve reset` removes every mapping diff --git a/scripts/ci/docker-smoke.ts b/scripts/ci/docker-smoke.ts new file mode 100644 index 00000000000..c2a7ec0ee29 --- /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/tests/ci-workflows/ci-workflows.test.ts b/tests/ci-workflows/ci-workflows.test.ts index ec2c919c456..c967f9d4c0d 100644 --- a/tests/ci-workflows/ci-workflows.test.ts +++ b/tests/ci-workflows/ci-workflows.test.ts @@ -506,17 +506,21 @@ describe("GitHub Actions hardening", () => { // allowlist. PRs always create the workflow and aggregate check; this list // decides whether the costly jobs run. Pin the entire list on both paths. const ciPaths = [ + ".dockerignore", ".gitattributes", ".github/workflows/ci.yml", ".github/workflows/enforce-pr-target.yml", ".github/workflows/release.yml", ".github/workflows/stale-needs-info.yml", ".npmignore", + "Dockerfile", "LICENSE", "README.md", "assets/**", "bin/**", "bun.lock", + "compose.yaml", + "docker/**", "gui/**", "package.json", "scripts/**", @@ -563,7 +567,7 @@ describe("GitHub Actions hardening", () => { expect(scopeIndex).toBeGreaterThan(filterIndex); const scopedCondition = "github.event_name != 'pull_request' || needs.changes.outputs.ci == 'true'"; - for (const jobName of ["test", "storage-policy", "gates", "platform-macos", "keyring-smoke"]) { + for (const jobName of ["test", "storage-policy", "gates", "platform-macos", "keyring-smoke", "docker-smoke"]) { const job = ci.jobs?.[jobName] as { needs?: string; if?: string } | undefined; expect(`${jobName}:${job?.needs}`).toBe(`${jobName}:changes`); expect(`${jobName}:${job?.if}`).toBe(`${jobName}:${scopedCondition}`); @@ -573,6 +577,34 @@ describe("GitHub Actions hardening", () => { expect(macosControlIf?.if).toBe("github.event_name == 'workflow_dispatch'"); }); + test("Docker smoke executes the source-build lifecycle and gates its result", async () => { + const ci = Bun.YAML.parse(await readText(".github/workflows/ci.yml")) as { + jobs?: Record; + steps?: Array<{ name?: string; run?: string; if?: string; "continue-on-error"?: boolean }>; + }>; + }; + const smoke = ci.jobs?.["docker-smoke"]; + expect(smoke?.["runs-on"]).toBe("ubuntu-latest"); + expect(smoke?.["timeout-minutes"]).toBe(20); + expect(smoke?.["continue-on-error"]).toBeUndefined(); + expect(smoke?.permissions).toBeUndefined(); // Inherits workflow contents:read. + const execution = smoke?.steps?.find(step => + hasExactShellCommand(step.run, "bun scripts/ci/docker-smoke.ts")); + expect(execution).toBeDefined(); + expect(execution?.if).toBeUndefined(); + expect(execution?.["continue-on-error"]).toBeUndefined(); + expect(ci.jobs?.ci?.needs).toContain("docker-smoke"); + const typecheck = ci.jobs?.gates?.steps?.find(step => step.name === "Typecheck"); + expect(hasExactShellCommand(typecheck?.run, + "bun x tsc --ignoreConfig --noEmit --strict --target ESNext --module ESNext --moduleResolution bundler --types bun-types --skipLibCheck scripts/ci/docker-smoke.ts", + )).toBe(true); + }); + test("cross-platform CI keeps the GUI lint and build gates", async () => { // Review finding (PR #97): the GUI build gate was silently dropped once; assert the // enhanced gate (PR #99) stays wired so broken GUI builds cannot merge unnoticed. diff --git a/tests/service/container-bootstrap.test.ts b/tests/service/container-bootstrap.test.ts index ada807d1d03..9eec8edd6ba 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");