From e00d5c341778b81cc09dca7cef56e20d776316ee Mon Sep 17 00:00:00 2001 From: t Date: Mon, 7 Sep 2026 07:41:25 +0900 Subject: [PATCH 1/7] test(container): verify build startup and volume recreation [skip ci] Exercise the source-build contract from #3421/#3604 and separate-home persistence from #3747 using disposable Docker resources. Preserve original token and both named volumes through container replacement; verify admission without upstream inference. Independent source/security review completed; actual Docker networking and runtime acceptance remain pending final CI. Co-authored-by: Buseong Kim Co-authored-by: Ingwannu --- scripts/ci/docker-smoke.ts | 338 +++++++++++++++++++++++++++++++++++++ 1 file changed, 338 insertions(+) create mode 100644 scripts/ci/docker-smoke.ts diff --git a/scripts/ci/docker-smoke.ts b/scripts/ci/docker-smoke.ts new file mode 100644 index 00000000000..0745f6bd9e5 --- /dev/null +++ b/scripts/ci/docker-smoke.ts @@ -0,0 +1,338 @@ +/** 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"); + +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'; + const homes = ['/home/bun/.opencodex', '/home/bun/.codex']; + 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'); + }); + console.log(JSON.stringify(hashes)); +`; + +async function state() { + const hashes = JSON.parse(await compose(["exec", "-T", "hub", "bun", "-e", stateProbe])) 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"); + 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" } }, + // Block upstream egress even if an admission regression reaches a provider path. + networks: { default: { internal: true } }, + }), { 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("bootstrap and seed synthetic catalog"); + await compose(["run", "--rm", "-T", "--no-deps", "hub", "bun", "run", "docker/bootstrap-token.ts"], `${token}\n`); + await compose(["run", "--rm", "-T", "--no-deps", "hub", "bun", "-e", + "import { writeFileSync } from 'node:fs'; writeFileSync('/home/bun/.codex/opencodex-catalog.json', await Bun.stdin.text(), { mode: 0o600, flag: 'wx' });"], fixture); + 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(); + 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"); From 513391e8fa94716867ac127821beaa003ddbf48f Mon Sep 17 00:00:00 2001 From: t Date: Mon, 7 Sep 2026 07:49:57 +0900 Subject: [PATCH 2/7] test(container): isolate synthetic inference without internal networking [skip ci] CI run 34064754947 built and started the image, then proved internal-only networking suppressed host port publication. Keep actual Compose networking and seed a checked loopback-only provider into disposable state before startup. Verify loaded configuration and hashes through recreation. Co-authored-by: Buseong Kim Co-authored-by: Ingwannu --- scripts/ci/docker-smoke.ts | 46 +++++++++++++++++++++++++++++++++----- 1 file changed, 41 insertions(+), 5 deletions(-) diff --git a/scripts/ci/docker-smoke.ts b/scripts/ci/docker-smoke.ts index 0745f6bd9e5..6bcf56f1647 100644 --- a/scripts/ci/docker-smoke.ts +++ b/scripts/ci/docker-smoke.ts @@ -126,6 +126,20 @@ const fixture = JSON.stringify({ models: [{ const token = randomBytes(32).toString("hex"); const replacement = randomBytes(32).toString("hex"); const sha256 = (value: string) => createHash("sha256").update(value).digest("hex"); +let seededConfigHash = ""; + +// 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?.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; @@ -169,6 +183,7 @@ async function inspect() { const stateProbe = ` import { readFileSync, statSync, writeFileSync } from 'node:fs'; import { createHash } from 'node:crypto'; + ${fixtureConfigCheck} const homes = ['/home/bun/.opencodex', '/home/bun/.codex']; const uid = process.getuid(); if (uid === 0) throw new Error('root user'); @@ -192,6 +207,7 @@ const stateProbe = ` async function state() { const hashes = JSON.parse(await compose(["exec", "-T", "hub", "bun", "-e", stateProbe])) as string[]; check(hashes.length === 3 && hashes.every(hash => /^[a-f0-9]{64}$/.test(hash)), "invalid state evidence"); + check(hashes[0] === seededConfigHash, "seeded config changed"); check(hashes[1] === sha256(`${token}\n`) && hashes[2] === sha256(fixture), "token/catalog changed"); return JSON.stringify(hashes); } @@ -280,8 +296,6 @@ async function main() { writeFileSync(join(scratch, "empty.env"), "", { mode: 0o600 }); writeFileSync(join(scratch, "override.json"), JSON.stringify({ services: { hub: { image, restart: "no" } }, - // Block upstream egress even if an admission regression reaches a provider path. - networks: { default: { internal: true } }, }), { mode: 0o600 }); env = { PATH: process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin", TMPDIR: scratch, @@ -293,10 +307,32 @@ async function main() { progress("validate and build"); await compose(["config", "--quiet"]); await build(); - progress("bootstrap and seed synthetic catalog"); + progress("verify shipped config and seed loopback-only fixture"); + seededConfigHash = await compose(["run", "--rm", "-T", "--no-deps", "hub", "bun", "-e", + ` + import { readFileSync, writeFileSync } from 'node:fs'; + import { createHash } from 'node:crypto'; + import { atomicWriteFile } from './src/config/atomic-write.ts'; + const { shipped, catalog } = JSON.parse(await Bun.stdin.text()); + const path = '/home/bun/.opencodex/config.json'; + 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' } }; + config.defaultProvider = 'smoke'; + atomicWriteFile(path, JSON.stringify(config) + '\\n'); + ${fixtureConfigCheck} + writeFileSync('/home/bun/.codex/opencodex-catalog.json', catalog, { mode: 0o600, flag: 'wx' }); + console.log(createHash('sha256').update(readFileSync(path)).digest('hex')); + `], JSON.stringify({ shipped: readFileSync(join(root, "docker/config.json"), "utf8"), catalog: fixture })); + 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`); - await compose(["run", "--rm", "-T", "--no-deps", "hub", "bun", "-e", - "import { writeFileSync } from 'node:fs'; writeFileSync('/home/bun/.codex/opencodex-catalog.json', await Bun.stdin.text(), { mode: 0o600, flag: 'wx' });"], fixture); progress("start and check admission"); await compose(["up", "--no-build", "--wait", "--wait-timeout", "120", "hub"], undefined, 150_000); const first = await inspect(); From 619f7a7b0ecfdc3dd32cf8b399dd2851e3002f10 Mon Sep 17 00:00:00 2001 From: t Date: Mon, 7 Sep 2026 08:01:39 +0900 Subject: [PATCH 3/7] test(container): declare the synthetic loopback destination [skip ci] The Docker diagnostic failed because loadConfig correctly rejects private destinations unless explicitly allowed. A bounded remote fixture reproduced the fallback and passed with the test-only flag. Validate the fixture before startup and report fixed seed-phase codes without exposing child errors. Co-authored-by: Buseong Kim Co-authored-by: Ingwannu --- scripts/ci/docker-smoke.ts | 26 +++++++++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/scripts/ci/docker-smoke.ts b/scripts/ci/docker-smoke.ts index 6bcf56f1647..eb488cf3b12 100644 --- a/scripts/ci/docker-smoke.ts +++ b/scripts/ci/docker-smoke.ts @@ -136,6 +136,7 @@ const fixtureConfigCheck = ` 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'); @@ -308,13 +309,18 @@ async function main() { await compose(["config", "--quiet"]); await build(); progress("verify shipped config and seed loopback-only fixture"); - seededConfigHash = await compose(["run", "--rm", "-T", "--no-deps", "hub", "bun", "-e", + 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'; - import { atomicWriteFile } from './src/config/atomic-write.ts'; + // 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'); } @@ -323,13 +329,27 @@ async function main() { || 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' } }; + 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`); From d175335943da2a2e64c5dac92a99a99295199335 Mon Sep 17 00:00:00 2001 From: t Date: Mon, 7 Sep 2026 08:12:44 +0900 Subject: [PATCH 4/7] test(container): verify first-start migrations before persistence baseline [skip ci] Remote source startup proved the expected tier/subagent migrations and schema-default persistence, followed by byte-stable second startup. Verify exact seed bytes before startup, six explicit migration/default additions at readiness, and an immutable post-start hash through bootstrap refusal and recreation. Co-authored-by: Buseong Kim Co-authored-by: Ingwannu --- scripts/ci/docker-smoke.ts | 51 +++++++++++++++++++++++++++++++++++--- 1 file changed, 47 insertions(+), 4 deletions(-) diff --git a/scripts/ci/docker-smoke.ts b/scripts/ci/docker-smoke.ts index eb488cf3b12..e26b11de349 100644 --- a/scripts/ci/docker-smoke.ts +++ b/scripts/ci/docker-smoke.ts @@ -127,6 +127,7 @@ 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. @@ -184,6 +185,9 @@ async function inspect() { 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']; const uid = process.getuid(); @@ -202,14 +206,51 @@ const stateProbe = ` 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() { - const hashes = JSON.parse(await compose(["exec", "-T", "hub", "bun", "-e", stateProbe])) as string[]; +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[0] === seededConfigHash, "seeded config changed"); 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); } @@ -353,11 +394,13 @@ async function main() { 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(); + 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`); From 15fa571554d8eda59b91ca05a931dcb98c2c8a2c Mon Sep 17 00:00:00 2001 From: t Date: Mon, 7 Sep 2026 08:54:51 +0900 Subject: [PATCH 5/7] fix(container): retain routed catalog across managed recreation [skip ci] Real Docker CI exposed foreground shutdown stripping routed catalog rows despite persistent volumes. Declare the existing service lifecycle mode in the runtime image so Docker-managed stop/recreate preserves routed state. Keep the routed fixture and exact-byte checks. Two independent lifecycle/security reviews accepted this bounded packaging change; isolated CLI before/after reproduced the defect and preservation with service mode. Co-authored-by: Buseong Kim Co-authored-by: Ingwannu --- Dockerfile | 3 +++ .../025_container_lifecycle_mode.md | 10 ++++++++++ docs-site/src/content/docs/guides/remote-hub.md | 6 +++++- scripts/ci/docker-smoke.ts | 1 + tests/service/container-bootstrap.test.ts | 1 + 5 files changed, 20 insertions(+), 1 deletion(-) create mode 100644 devlog/_plan/260907_platform_validation/025_container_lifecycle_mode.md 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..460fe4651d5 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/scripts/ci/docker-smoke.ts b/scripts/ci/docker-smoke.ts index e26b11de349..c2a7ec0ee29 100644 --- a/scripts/ci/docker-smoke.ts +++ b/scripts/ci/docker-smoke.ts @@ -190,6 +190,7 @@ const stateProbe = ` 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'); 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"); From 91fba4b4cd3d31e79b7d404b3d201c7b9896f67d Mon Sep 17 00:00:00 2001 From: t Date: Mon, 7 Sep 2026 07:41:25 +0900 Subject: [PATCH 6/7] ci: gate source-build Docker lifecycle verification [skip ci] Add Docker packaging paths to the existing CI scope, execute the isolated lifecycle smoke, and include it in the aggregate gate. Extend the workflow oracle and typecheck the new script. Final integration will be dispatched explicitly with lane=all; redundant lower-layer runs are intentionally skipped. Co-authored-by: Buseong Kim Co-authored-by: Ingwannu --- .github/workflows/ci.yml | 31 ++++++++++++++++- .../src/content/docs/guides/remote-hub.md | 6 ++++ tests/ci-workflows/ci-workflows.test.ts | 34 ++++++++++++++++++- 3 files changed, 69 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8c15cb696ad..a6c0e7ab1a5 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 --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/docs-site/src/content/docs/guides/remote-hub.md b/docs-site/src/content/docs/guides/remote-hub.md index 460fe4651d5..6a2b2a33bde 100644 --- a/docs-site/src/content/docs/guides/remote-hub.md +++ b/docs-site/src/content/docs/guides/remote-hub.md @@ -291,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/tests/ci-workflows/ci-workflows.test.ts b/tests/ci-workflows/ci-workflows.test.ts index ec2c919c456..77192bbb480 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 --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. From 6f2ad1ef32c113ff5827c58959c85cefa923b15c Mon Sep 17 00:00:00 2001 From: t Date: Mon, 7 Sep 2026 07:52:43 +0900 Subject: [PATCH 7/7] ci: select explicit-file typecheck mode for TypeScript 7 [skip ci] Final run 34064754947 passed root typechecks but rejected the standalone smoke-script command with TS5112. Explicit --ignoreConfig retains all strict script flags and preserves the existing root/project checks. Co-authored-by: Buseong Kim Co-authored-by: Ingwannu --- .github/workflows/ci.yml | 2 +- tests/ci-workflows/ci-workflows.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a6c0e7ab1a5..00abe5ec24d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -431,7 +431,7 @@ jobs: run: | bun x tsc --noEmit bun x tsc --noEmit -p tests/tsconfig.doctor-service-memory-contract.json - bun x tsc --noEmit --strict --target ESNext --module ESNext --moduleResolution bundler --types bun-types --skipLibCheck scripts/ci/docker-smoke.ts + 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 diff --git a/tests/ci-workflows/ci-workflows.test.ts b/tests/ci-workflows/ci-workflows.test.ts index 77192bbb480..c967f9d4c0d 100644 --- a/tests/ci-workflows/ci-workflows.test.ts +++ b/tests/ci-workflows/ci-workflows.test.ts @@ -601,7 +601,7 @@ describe("GitHub Actions hardening", () => { 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 --noEmit --strict --target ESNext --module ESNext --moduleResolution bundler --types bun-types --skipLibCheck scripts/ci/docker-smoke.ts", + "bun x tsc --ignoreConfig --noEmit --strict --target ESNext --module ESNext --moduleResolution bundler --types bun-types --skipLibCheck scripts/ci/docker-smoke.ts", )).toBe(true); });