diff --git a/docs-site/src/content/docs/getting-started/for-agents.md b/docs-site/src/content/docs/getting-started/for-agents.md index 62241df747..b02c39812a 100644 --- a/docs-site/src/content/docs/getting-started/for-agents.md +++ b/docs-site/src/content/docs/getting-started/for-agents.md @@ -34,8 +34,10 @@ second terminal: ocx init ``` -The wizard writes `$OPENCODEX_HOME/config.json` (normally -`~/.opencodex/config.json`). It can also inject the proxy address into Codex's `config.toml` and +The wizard creates `$OPENCODEX_HOME/config.json` (normally +`~/.opencodex/config.json`) only if it is missing. Rerunning init keeps an existing config; it has +no force/overwrite flag. Invalid or concurrently created config is preserved and setup stops. +It can also inject the proxy address into Codex's `config.toml` and install the optional Codex autostart shim. `ocx init` never starts the proxy. For a fully non-interactive setup, configure providers with `ocx provider add` as shown below instead of driving the wizard. diff --git a/docs-site/src/content/docs/getting-started/quickstart.md b/docs-site/src/content/docs/getting-started/quickstart.md index 0328a59899..1fdcf922d8 100644 --- a/docs-site/src/content/docs/getting-started/quickstart.md +++ b/docs-site/src/content/docs/getting-started/quickstart.md @@ -25,6 +25,17 @@ ocx init The result is saved to `$OPENCODEX_HOME/config.json` (default `~/.opencodex/config.json`). +`ocx init` creates a config only when none exists. An existing valid config is kept and setup +exits; use `ocx config` or the dashboard to update it. Invalid, unreadable, or symlinked config +entries are preserved and reported as errors. If another process creates the config during the +wizard, its file wins and setup stops before backup housekeeping or integration prompts. + +EOF or Ctrl+C before creation cancels setup. Cancellation after creation keeps the saved config. +Initial publication requires hard-link support and permission on the config filesystem; failures +stop setup without falling back to an overwrite. If publication or temporary-file cleanup cannot +finish, inspect the config directory before retrying: a complete config or private temporary file +may remain. + :::note[GPT-5.6 rollout entries] The current stable release seeds GPT-5.6 Sol/Terra/Luna for ChatGPT passthrough, OpenAI API-key, OpenRouter, and diff --git a/src/cli/init.ts b/src/cli/init.ts index 72ad3c1b70..f310b7f796 100644 --- a/src/cli/init.ts +++ b/src/cli/init.ts @@ -3,24 +3,44 @@ import { modelSelectionGuidance } from "./model-selection-guidance"; import { initializeProviderModelSelection } from "../providers/initial-model-selection"; import { existsSync, readFileSync, unlinkSync } from "node:fs"; import { injectCodexConfig } from "../codex/inject"; -import { classifyOpenAiTierBackup, getConfigPath, getDefaultConfig, isValidProviderName, preserveOpenAiTierRollbackSnapshot, saveConfig } from "../config"; +import { classifyOpenAiTierBackup, ConfigMutationLockError, getConfigPath, getDefaultConfig, initializePersistedConfigIfMissing, isValidProviderName, observeInitialConfigState, preserveOpenAiTierRollbackSnapshot } from "../config"; +import { InitialConfigPublicationError } from "../config/initialize"; +import { redactUserPath } from "../lib/redact"; import { enrichProviderFromCatalog } from "../oauth/key-providers"; import { deriveInitProviders } from "../providers/derive"; import type { OcxConfig, OcxProviderConfig } from "../types"; -function createPrompt(): { ask(question: string): Promise; close(): void } { +class InitCancelledError extends Error { + constructor(readonly exitCode: 1 | 130) { + super(exitCode === 130 ? "Setup cancelled." : "stdin reached EOF while waiting for input. Re-run `ocx init` in an interactive terminal."); + } +} + +function createPrompt(): { ask(question: string): Promise; throwIfCancelled(): void; close(): void } { const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); let closed = false; - rl.on("close", () => { closed = true; }); + let cancellation: InitCancelledError | undefined; + const onInterrupt = () => { + cancellation = new InitCancelledError(130); + rl.close(); + }; + rl.on("SIGINT", onInterrupt); + process.on("SIGINT", onInterrupt); + rl.on("close", () => { + closed = true; + cancellation ??= new InitCancelledError(1); + process.off("SIGINT", onInterrupt); + rl.off("SIGINT", onInterrupt); + }); return { ask(question: string): Promise { return new Promise((resolve, reject) => { if (closed) { - reject(new Error("stdin closed before the prompt could be answered")); + reject(cancellation ?? new InitCancelledError(1)); return; } const onClose = () => { - reject(new Error("stdin reached EOF while waiting for input")); + reject(cancellation ?? new InitCancelledError(1)); }; rl.once("close", onClose); rl.question(question, answer => { @@ -29,6 +49,9 @@ function createPrompt(): { ask(question: string): Promise; close(): void }); }); }, + throwIfCancelled() { + if (cancellation) throw cancellation; + }, close() { if (!closed) rl.close(); }, @@ -88,7 +111,18 @@ export function cleanupOpenAiTierBackupAfterInit(configPath = getConfigPath()): } export async function runInit(): Promise { + const initial = observeInitialConfigState(); + if (initial === "exists") { + console.log(`Keeping existing config at ${redactUserPath(getConfigPath())}. Use \`ocx config\` or the dashboard to update it.`); + return; + } + if (initial === "invalid") { + console.error(`Cannot initialize ${redactUserPath(getConfigPath())}: existing config is invalid, unreadable, or not a regular file. It has been preserved.`); + process.exitCode = 1; + return; + } const prompt = createPrompt(); + let configCreated = false; try { console.log("\nšŸ”§ opencodex (ocx) setup\n"); @@ -171,7 +205,14 @@ export async function runInit(): Promise { modelDiscovery: { newModelPolicy: "off" }, }; - saveConfig(config); + prompt.throwIfCancelled(); + const outcome = initializePersistedConfigIfMissing(config); + if (outcome !== "created") { + console.error("Config appeared or changed while setup was running; keeping it and stopping setup."); + process.exitCode = 1; + return; + } + configCreated = true; // Init writes a fresh config, so a stale pre-migration backup from a previous // installation would make the next `ocx start` crash on a stale-backup // collision (issue #257). But only a STALE backup (unparseable, or already a @@ -179,23 +220,34 @@ export async function runInit(): Promise { // valid pre-migration (v1) config is a user-intentional rollback point and is // preserved by renaming it out of the collision path (sol review 260722). cleanupOpenAiTierBackupAfterInit(); - console.log(`\nāœ… Config saved to ~/.opencodex/config.json`); + console.log(`\nāœ… Config saved to ${redactUserPath(getConfigPath())}`); if (oauthHint) console.log(`šŸ” Authenticate this provider with: ocx login ${providerName}`); const injectAnswer = await prompt.ask("Inject into Codex config.toml? [Y/n]: "); + prompt.throwIfCancelled(); if (injectAnswer.trim().toLowerCase() !== "n") { console.log("Fetching available models from provider..."); - const result = await injectCodexConfig(port, config); + const result = await injectCodexConfig(port, config, { + beforeClientWrite: () => prompt.throwIfCancelled(), + }).catch(error => { + // The injection/lock boundary may wrap the guard's cancellation error. + prompt.throwIfCancelled(); + throw error; + }); + prompt.throwIfCancelled(); console.log(result.success ? `āœ… ${result.message}` : `āš ļø ${result.message}`); } const shimAnswer = await prompt.ask("Install Codex autostart shim? [Y/n]: "); + prompt.throwIfCancelled(); if (shimAnswer.trim().toLowerCase() !== "n") { try { const { installCodexShim } = await import("../codex/shim"); + prompt.throwIfCancelled(); const result = installCodexShim(); console.log(result.installed ? `āœ… ${result.message}` : `āš ļø ${result.message}`); } catch (err) { + if (err instanceof InitCancelledError) throw err; console.log(`āš ļø Codex autostart shim skipped: ${err instanceof Error ? err.message : String(err)}`); } } @@ -203,13 +255,18 @@ export async function runInit(): Promise { console.log(`\nšŸš€ Setup complete! Run 'ocx start' to start the proxy.`); for (const line of modelSelectionGuidance(providerName)) console.log(line); } catch (error) { - const message = error instanceof Error ? error.message : String(error); - if (/stdin (closed|reached EOF)/i.test(message)) { - console.error(`\nāŒ ${message}. Re-run \`ocx init\` in an interactive terminal.`); + if (error instanceof InitCancelledError) { + console.error(`\nāŒ ${error.message}${configCreated ? " The created config has been kept." : ""}`); + process.exitCode = error.exitCode; + } else { + const message = error instanceof InitialConfigPublicationError + ? `${error.message}${error.publication !== "not-published" ? " Config may already exist; inspect it before retrying." : ""}${error.residualTemp ? " A temporary file could not be removed; inspect the config directory." : ""}` + : error instanceof ConfigMutationLockError + ? "Config initialization could not acquire its write lock. Retry when the other config operation finishes." + : `Setup did not finish.${configCreated ? " The created config has been kept." : " Check the config directory and setup inputs before retrying."}`; + console.error(`\nāŒ ${message}`); process.exitCode = 1; - return; } - throw error; } finally { prompt.close(); } diff --git a/src/config.ts b/src/config.ts index 728b3969ea..d5ef05c33f 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,5 +1,5 @@ import { createHash } from "node:crypto"; -import { chmodSync, constants as fsConstants, copyFileSync, existsSync, linkSync, mkdirSync, readFileSync, truncateSync, unlinkSync, writeFileSync } from "node:fs"; +import { chmodSync, constants as fsConstants, copyFileSync, existsSync, linkSync, lstatSync, mkdirSync, readFileSync, truncateSync, unlinkSync, writeFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { Database } from "bun:sqlite"; import * as z from "zod/v4"; @@ -123,6 +123,7 @@ export { type AtomicWriteIO, } from "./config/atomic-write"; import { getConfigDir, getConfigPath, hardenConfigDir } from "./config/paths"; +import { InitialConfigPublicationError, publishInitialConfigNoReplace, type InitialConfigPublicationIO } from "./config/initialize"; import { describeProxyForLog, readWindowsSystemProxy, @@ -2811,6 +2812,16 @@ export function readConfigDiagnostics(): ConfigDiagnostics { return readConfigFileSnapshot().diagnostics; } +/** Read-only init preflight. Occupied unsafe entries are never treated as absence. */ +export function observeInitialConfigState(): "missing" | "exists" | "invalid" { + try { + if (!lstatSync(getConfigPath()).isFile()) return "invalid"; + } catch (error) { + return isMissingPathError(error) ? "missing" : "invalid"; + } + return readConfigFileSnapshot().diagnostics.source === "file" ? "exists" : "invalid"; +} + /** * The persisted config, plus a digest of the EXACT bytes it was parsed from. * @@ -3123,6 +3134,44 @@ function persistConfigUnlocked(config: OcxConfig): boolean { return true; } +export type PersistedConfigInitializationOutcome = "created" | "exists" | "invalid"; + +/** Initialize only a missing config; ordinary explicit updates still use saveConfig. */ +export function initializePersistedConfigIfMissing( + config: OcxConfig, + io?: Partial, +): PersistedConfigInitializationOutcome { + assertNotRealHomeUnderTest(getConfigDir()); + const before = observeInitialConfigState(); + if (before !== "missing") return before; + let published = false; + try { + const persisted = withConfigMutationLockSync((): OcxConfig | "exists" | "invalid" => { + const current = observeInitialConfigState(); + if (current !== "missing") return current; + const projected = projectCustomModelCatalogMigration(undefined, projectConfigRebaseProvenance(config)); + if (!validateConfigCandidate(projected).ok) throw new Error("Initial configuration is invalid."); + if (!publishInitialConfigNoReplace(getConfigPath(), JSON.stringify(projected, null, 2) + "\n", io)) { + return observeInitialConfigState() === "exists" ? "exists" : "invalid"; + } + published = true; + recordOwnedConfigPath(getConfigDir(), getConfigPath()); + bumpGenerationForCooperatingConfigWrite(); + return projected; + }); + if (typeof persisted === "string") return persisted; + adoptCustomModelCatalogMigration(config, persisted); + if (persisted.configRebaseProvenance === undefined) delete config.configRebaseProvenance; + else config.configRebaseProvenance = structuredClone(persisted.configRebaseProvenance); + clearPendingConfigTopLevelDeletions(config); + refreshUserCostOverlays(persisted); + return "created"; + } catch (cause) { + if (published) throw new InitialConfigPublicationError("published", false, false, { cause }); + throw cause; + } +} + /** Persist `config` to config.json under the config-mutation lock. */ export function saveConfig(config: OcxConfig): void { // Keep the real-home assertion ahead of even lock-directory preparation. diff --git a/src/config/initialize.ts b/src/config/initialize.ts new file mode 100644 index 0000000000..864b09b036 --- /dev/null +++ b/src/config/initialize.ts @@ -0,0 +1,132 @@ +import { + closeSync, constants, fchmodSync, fstatSync, linkSync, lstatSync, + openSync, unlinkSync, writeFileSync, +} from "node:fs"; +import { dirname } from "node:path"; +import { assertNotRealHomeUnderTest } from "../lib/test-home-guard"; +import { forgetEphemeralSecretPath, hardenSecretPath } from "../lib/windows-secret-acl"; +import { isMissingPathError, nextAtomicTempSequence } from "./atomic-write"; + +type PublicationState = "not-published" | "published" | "uncertain"; + +/** Messages contain no candidate bytes or raw filesystem error text. */ +export class InitialConfigPublicationError extends Error { + constructor( + readonly publication: PublicationState, + readonly residualTemp: boolean, + readonly hardLinkUnavailable: boolean, + options?: ErrorOptions, + ) { + super(hardLinkUnavailable + ? "Initial config requires hard-link publication; the filesystem or its permissions denied it." + : "Initial config publication did not finish.", options); + this.name = "InitialConfigPublicationError"; + } +} + +/** Narrow fault boundary; publication must be a single link operation. */ +export interface InitialConfigPublicationIO { + harden(fd: number, temp: string, target: string): void; + write(fd: number, bytes: string): void; + link(temp: string, target: string): void; + unlink(temp: string): void; + close(fd: number): void; +} + +function hardenInitialConfig(fd: number, temp: string, target: string): void { + if (process.platform === "win32") { + hardenSecretPath(temp, { required: true, timeoutMemoKey: target }); + } else { + fchmodSync(fd, 0o600); + } +} + +function identifiesDescriptor(fd: number, path: string): boolean { + const opened = fstatSync(fd); + const entry = lstatSync(path); + return opened.isFile() && entry.isFile() + && opened.dev === entry.dev && opened.ino === entry.ino; +} + +function verifyPrivateTemp(fd: number, temp: string): void { + if (!identifiesDescriptor(fd, temp) + || (process.platform !== "win32" && (fstatSync(fd).mode & 0o777) !== 0o600)) { + throw new Error("Initial config temporary file identity or permissions changed."); + } +} + +function removeOwnedTemp(fd: number, temp: string, unlink: (path: string) => void): boolean { + for (let attempt = 0; attempt < 2; attempt++) { + try { + if (!identifiesDescriptor(fd, temp)) return false; + unlink(temp); + forgetEphemeralSecretPath(temp); + return true; + } catch (error) { + if (isMissingPathError(error)) { + forgetEphemeralSecretPath(temp); + return true; + } + } + } + return false; +} + +/** + * Publish complete bytes without replacing any entry at target. Never truncate: + * even an error from link can mean a remote filesystem already published the inode. + * Cleanup removes only our temporary name, never the target or another inode. + */ +export function publishInitialConfigNoReplace( + target: string, + bytes: string, + io: Partial = {}, +): boolean { + assertNotRealHomeUnderTest(dirname(target)); + const temp = `${target}.ocx.${process.pid}.${nextAtomicTempSequence()}.tmp`; + let fd: number | undefined; + let publication: PublicationState = "not-published"; + let collided = false; + let failure: unknown; + let failed = false; + let hardLinkUnavailable = false; + let residualTemp = false; + try { + fd = openSync(temp, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, 0o600); + (io.harden ?? hardenInitialConfig)(fd, temp, target); + verifyPrivateTemp(fd, temp); + (io.write ?? ((descriptor: number, value: string) => writeFileSync(descriptor, value, { encoding: "utf8" })))(fd, bytes); + verifyPrivateTemp(fd, temp); + try { + publication = "uncertain"; + (io.link ?? linkSync)(temp, target); + publication = "published"; + } catch (error) { + const code = (error as NodeJS.ErrnoException | undefined)?.code; + // EEXIST normally means a competitor won. A shared target means our + // publication may nevertheless have happened (e.g. a remote FS retry). + if (code === "EEXIST" && !identifiesDescriptor(fd, target)) collided = true; + else { + hardLinkUnavailable = ["EOPNOTSUPP", "ENOTSUP", "ENOSYS", "EXDEV", "EPERM"].includes(code ?? ""); + throw error; + } + } + if (!collided && !identifiesDescriptor(fd, target)) { + throw new Error("Initial config published target identity changed."); + } + } catch (error) { + failed = true; + failure = error; + } finally { + if (fd !== undefined) { + // Unlink-only cleanup preserves all bytes if another name shares this inode. + residualTemp = !removeOwnedTemp(fd, temp, io.unlink ?? unlinkSync); + try { (io.close ?? closeSync)(fd); } + catch (error) { if (!failed) failure = error; failed = true; } + } + } + if (failed || residualTemp) { + throw new InitialConfigPublicationError(publication, residualTemp, hardLinkUnavailable, { cause: failure }); + } + return !collided; +} diff --git a/structure/00_overview.md b/structure/00_overview.md index 91ce065f2c..4ca10c0e16 100644 --- a/structure/00_overview.md +++ b/structure/00_overview.md @@ -76,7 +76,7 @@ opencodex state root does not undo those writes. Putting native Codex back is th | Path | Owner | Notes | | --- | --- | --- | -| `~/.opencodex/config.json` | opencodex | Main config written by `ocx init` and the dashboard. Atomic temp-then-rename. | +| `~/.opencodex/config.json` | opencodex | Init creates via private temp plus no-replace hard link; dashboard and explicit updates use atomic replacement. | | `~/.opencodex/auth.json` | opencodex | OAuth tokens; not committed. Multiauth shape: `provider -> { activeAccountId, accounts[] }` (legacy single-credential values normalize on load; a one-time `auth.json.pre-multiauth` backup guards downgrades). ChatGPT scratch OAuth stays separate from the Codex account store. For multi-slot providers, credentials without `accountId`/email replace the active slot on a normal login; an explicit add-account login preserves the prior slot and appends a distinct one. Single-slot providers such as ChatGPT remain replacement-only. | | `~/.opencodex/codex-accounts.json` | opencodex | Hardened main-plus-added credential store used by `openai` in Pool mode. | | `~/.opencodex/catalog-backup.json` | opencodex | One-time pristine Codex catalog backup for restore; per-catalog copies are hashed variants (see [`03_catalog-and-subagents.md`](03_catalog-and-subagents.md)). | diff --git a/structure/02_config-and-codex-home.md b/structure/02_config-and-codex-home.md index ca27674904..9478343d19 100644 --- a/structure/02_config-and-codex-home.md +++ b/structure/02_config-and-codex-home.md @@ -273,6 +273,17 @@ to snapshot persistence instead of relying on the progress argument alone. ### OpenCodex home and live process state +`initializePersistedConfigIfMissing` in `src/config.ts` is the create-only path consumed by +`src/cli/init.ts`. It rechecks absence under the existing config-mutation lock and publishes through +`src/config/initialize.ts`: a private descriptor is hardened before secret bytes are written, then +linked without replacing an occupied destination. Existing invalid or unsafe entries are preserved. +The initializer never truncates a staged inode or rolls back by unlinking the destination; cleanup +only removes its own temporary name. Unsupported/denied links and incomplete cleanup fail explicitly, +and publication followed by a later failure can leave a complete config or private residue. Ordinary +`saveConfig` replacement behavior remains unchanged. This protects init-time config bytes, not a +foreign winner's ownership under future uninstall; the existing ownership manifest and global CLI +shim preflight keep their separate contracts. + `src/config/paths.ts` is the single owner of `OPENCODEX_HOME` expansion and resolution. It exposes the config directory and `config.json` path and retains the existing cache rule: a relative home is resolved once for each distinct raw environment value, so a later working-directory change cannot @@ -284,7 +295,7 @@ identity, and snapshot-guarded removal. `RuntimePortState.attestationSecret` rem owner-only state and is validated before a record is returned. `src/config.ts` re-exports the same symbols for compatibility, but new lifecycle-only callers import the process-state leaf directly. -Both config and process-state writes use `src/config/atomic-write.ts`. The leaf preserves the shared +Replacing config and process-state writes use `src/config/atomic-write.ts`. The leaf preserves the shared process-wide temp sequence, symlink target resolution, real-home test guard, owner manifest, Windows ACL hardening, scrub-before-unlink failure path, and explicit residual-temp errors. A caller must not replace it with a local temp-and-rename shortcut. diff --git a/tests/codex-integration/main-quota-provenance.test.ts b/tests/codex-integration/main-quota-provenance.test.ts index 8262b242d7..72d596e75f 100644 --- a/tests/codex-integration/main-quota-provenance.test.ts +++ b/tests/codex-integration/main-quota-provenance.test.ts @@ -33,6 +33,7 @@ import { } from "../../src/codex/quota"; import { repoPath, repoRoot } from "../helpers/repo-root"; import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { INTERNAL_DEADLINE_MS, SPAWN_BUDGET_MS } from "../helpers/test-budget"; let testDir: string; let previousHome: string | undefined; @@ -307,8 +308,12 @@ describe("main policy quota durability and lifecycle", () => { console.log(JSON.stringify({ before, other, legacy: getAccountQuota("__main__"), policy: getMainPolicyQuota(), credentialMatches: matchesMainQuotaCredential("fixture-bearer-a", "fixture-main-a") })); `; + // The fresh process is the restart oracle, including its module startup. + // Probe 34053484372 retained all assertions and caught an identity-guard + // mutation with this Windows budget; the previous 10s killed a healthy 12s delay. const child = Bun.spawnSync({ - cmd: [process.execPath, "--eval", script], cwd: repoRoot(), env: process.env, timeout: 10_000, + cmd: [process.execPath, "--eval", script], cwd: repoRoot(), env: process.env, + timeout: process.platform === "win32" ? SPAWN_BUDGET_MS - INTERNAL_DEADLINE_MS : 10_000, }); expect(child.exitCode).toBe(0); const result = JSON.parse(child.stdout.toString()); @@ -317,7 +322,7 @@ describe("main policy quota durability and lifecycle", () => { expect(result.legacy).toBeNull(); expect(result.policy).toEqual(quota); expect(result.credentialMatches).toBe(false); - }); + }, SPAWN_BUDGET_MS); } test("unrelated persistence hydrates and retains policy after legacy TTL expiry", () => { diff --git a/tests/codex-integration/native-profile-manager.test.ts b/tests/codex-integration/native-profile-manager.test.ts index 425f0a6c08..d65b02abe9 100644 --- a/tests/codex-integration/native-profile-manager.test.ts +++ b/tests/codex-integration/native-profile-manager.test.ts @@ -132,11 +132,12 @@ async function leavePendingJournal(f: Awaited } /** - * The first Bun child a busy windows-latest shard spawns can take several seconds just to - * boot the TS helper; on run 33595585136 that alone burned a private 5 s wait while the - * child was healthy. The crash case, which is the first spawn in the file, gets a wait - * sized inside its 15 s test budget. On timeout the child's stderr is part of the error so - * a real crash is not mistaken for a slow start. + * Readiness includes booting the Bun child and its TypeScript graph. The first Windows + * spawn can outlast an internal-operation deadline, so the crash case reserves 30 s of + * its existing 45 s spawn budget, leaving 15 s for exit and successor checks. + * Controlled probe 34051272609 reproduced a healthy 17 s readiness delay and still + * rejected a successor-lock-denial mutation; no lock assertion or outer budget changed. + * On timeout the child's stderr distinguishes a reported crash from a missing marker. */ // Gates on a spawned child reaching its marker: 8-19 s on windows-latest (run 33930757649). async function waitForPath(path: string, child?: ReturnType, waitMs = INTERNAL_DEADLINE_MS): Promise { @@ -198,7 +199,11 @@ describe("native main profile transactions", () => { const f = fixture(); const readyPath = join(f.root, "crash-ready"); const child = spawnLockHolder(f, readyPath, join(f.root, "unused-release"), { crash: true }); - await waitForPath(readyPath, child, INTERNAL_DEADLINE_MS); + await waitForPath( + readyPath, + child, + process.platform === "win32" ? SPAWN_BUDGET_MS - INTERNAL_DEADLINE_MS : INTERNAL_DEADLINE_MS, + ); expect(await child.exited).toBe(87); const successor = new NativeProfileManager({ ...f.options, lockWaitMs: 250 }); diff --git a/tests/config/config-mutation-lock.test.ts b/tests/config/config-mutation-lock.test.ts index c0b37c28b4..06a18dd99d 100644 --- a/tests/config/config-mutation-lock.test.ts +++ b/tests/config/config-mutation-lock.test.ts @@ -1,8 +1,10 @@ import { afterEach, beforeEach, expect, test } from "bun:test"; -import { existsSync, mkdtempSync, writeFileSync } from "node:fs"; +import { closeSync, existsSync, linkSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { pathToFileURL } from "node:url"; -import { ConfigMutationLockError, loadConfig, saveConfig, withConfigMutationLockSync } from "../../src/config"; +import { ConfigMutationLockError, deleteConfigTopLevelKey, getConfigPath, initializePersistedConfigIfMissing, loadConfig, observeInitialConfigState, readConfigGeneration, saveConfig, withConfigMutationLockSync } from "../../src/config"; +import { InitialConfigPublicationError, publishInitialConfigNoReplace } from "../../src/config/initialize"; +import { nextAtomicTempSequence } from "../../src/config/atomic-write"; import { CodexCredentialRefreshLockTimeoutError, getCodexAccountCredential, saveCodexAccountCredential } from "../../src/codex/account-store"; import type { OcxConfig } from "../../src/types"; import { ManagementRequest, managementHeaders } from "../helpers/management-auth"; @@ -13,7 +15,13 @@ let testRoot = ""; let previousOpencodexHome: string | undefined; function config(port = 10100): OcxConfig { - return { port, providers: {}, defaultProvider: "openai" }; + // Initial publication validates the candidate before reaching the filesystem; + // unlike a replacing save, it cannot accept a dangling default provider. + return { + port, + providers: { openai: { adapter: "openai-chat", baseUrl: "https://example.test/v1" } }, + defaultProvider: "openai", + }; } async function waitForPath(path: string): Promise { @@ -79,6 +87,11 @@ test("a live cross-process holder is not stolen and runtime writers fail immedia } const startedAt = performance.now(); expect(() => saveConfig(config(20200))).toThrow(ConfigMutationLockError); + // A busy initializer must not steal the holder even when its target is absent. + unlinkSync(getConfigPath()); + expect(() => initializePersistedConfigIfMissing(config(20200))).toThrow(ConfigMutationLockError); + expect(existsSync(getConfigPath())).toBe(false); + writeFileSync(getConfigPath(), JSON.stringify(config())); expect(() => saveCodexAccountCredential("busy-account", { accessToken: "busy-access", refreshToken: "busy-refresh", @@ -139,6 +152,194 @@ test("a throwing mutation releases the lock and leaves writers available", () => expect(loadConfig().port).toBe(50500); }); +const initTemps = () => readdirSync(testRoot).filter(name => name.includes(".ocx.") && name.endsWith(".tmp")); + +test("initial publication rejects a missing default provider before writing candidate bytes", () => { + let wrote = false; + expect(() => initializePersistedConfigIfMissing({ ...config(), providers: {} }, { + write() { wrote = true; }, + })).toThrow("Initial configuration is invalid."); + expect(wrote).toBe(false); + expect(existsSync(getConfigPath())).toBe(false); +}); + +test("initial creation keeps candidate values and existing bytes; the explicit saver still updates", () => { + const candidate = { ...config(21001), operatorNote: "keep unknown fields" }; + expect(initializePersistedConfigIfMissing(candidate)).toBe("created"); + expect(JSON.parse(readFileSync(getConfigPath(), "utf8"))).toEqual(candidate); + if (process.platform !== "win32") expect(lstatSync(getConfigPath()).mode & 0o777).toBe(0o600); + expect(readConfigGeneration()).toMatchObject({ generation: { value: 1 } }); + const bytes = readFileSync(getConfigPath(), "utf8"); + expect(initializePersistedConfigIfMissing(config(21002))).toBe("exists"); + expect(readFileSync(getConfigPath(), "utf8")).toBe(bytes); + saveConfig(config(21003)); + expect(loadConfig().port).toBe(21003); + expect(initTemps()).toEqual([]); +}); + +test.each(["", "not-json\n", '{"port":"broken"}', '\uFEFF{ "port":21002, "providers":{}, "defaultProvider":"openai", "unknown":42 }\n'])( + "init preserves occupied bytes without lock or backup creation: %j", bytes => { + writeFileSync(getConfigPath(), bytes); + const candidate = config(21001); + const original = structuredClone(candidate); + expect(initializePersistedConfigIfMissing(candidate)).toBe(bytes.startsWith("\uFEFF") ? "exists" : "invalid"); + expect(readFileSync(getConfigPath(), "utf8")).toBe(bytes); + expect(candidate).toEqual(original); + expect(readdirSync(testRoot)).toEqual(["config.json"]); + }, +); + +test("init refuses a directory and a dangling symlink without following either", () => { + mkdirSync(getConfigPath()); + expect(observeInitialConfigState()).toBe("invalid"); + expect(initializePersistedConfigIfMissing(config())).toBe("invalid"); + removeTreeWithRetry(getConfigPath()); + const absent = join(testRoot, "absent"); + symlinkSync(absent, getConfigPath(), "file"); + expect(initializePersistedConfigIfMissing(config())).toBe("invalid"); + expect(lstatSync(getConfigPath()).isSymbolicLink()).toBe(true); + expect(existsSync(absent)).toBe(false); +}); + +test("real link collision preserves the winner and does not advance generation or mutate the candidate", () => { + withConfigMutationLockSync(() => {}); + const generation = readConfigGeneration(); + const winner = JSON.stringify(config(21002)) + "\n"; + const candidate = config(21001); + const original = structuredClone(candidate); + expect(initializePersistedConfigIfMissing(candidate, { + link(temp, target) { + writeFileSync(target, winner, { flag: "wx" }); + linkSync(temp, target); + }, + })).toBe("exists"); + expect(readFileSync(getConfigPath(), "utf8")).toBe(winner); + expect(readConfigGeneration()).toEqual(generation); + expect(candidate).toEqual(original); + expect(initTemps()).toEqual([]); +}); + +test("exclusive temp collision does not remove or modify somebody else's file", () => { + const sequence = nextAtomicTempSequence() + 1; + const occupied = `${getConfigPath()}.ocx.${process.pid}.${sequence}.tmp`; + writeFileSync(occupied, "other staged bytes", { flag: "wx" }); + expect(() => publishInitialConfigNoReplace(getConfigPath(), "candidate bytes")).toThrow(InitialConfigPublicationError); + expect(readFileSync(occupied, "utf8")).toBe("other staged bytes"); + expect(existsSync(getConfigPath())).toBe(false); +}); + +test("failed hardening occurs before candidate bytes are written", () => { + let wrote = false; + expect(() => initializePersistedConfigIfMissing(config(), { + harden(_fd, temp) { + expect(readFileSync(temp, "utf8")).toBe(""); + throw new Error("ACL denied"); + }, + write() { wrote = true; }, + })).toThrow(InitialConfigPublicationError); + expect(wrote).toBe(false); + expect(existsSync(getConfigPath())).toBe(false); + expect(initTemps()).toEqual([]); +}); + +test("partial write failure removes only the unpublished temporary name", () => { + expect(() => initializePersistedConfigIfMissing(config(), { + write(fd, bytes) { writeFileSync(fd, bytes.slice(0, 10)); throw new Error("disk full"); }, + })).toThrow(InitialConfigPublicationError); + expect(existsSync(getConfigPath())).toBe(false); + expect(initTemps()).toEqual([]); +}); + +test.each(["EOPNOTSUPP", "ENOTSUP", "ENOSYS", "EXDEV", "EPERM"])("unsupported/denied link %s never falls back to replacement", code => { + try { + initializePersistedConfigIfMissing(config(), { + link() { throw Object.assign(new Error("do not print raw error"), { code }); }, + }); + throw new Error("expected link refusal"); + } catch (error) { + expect(error).toBeInstanceOf(InitialConfigPublicationError); + expect((error as InitialConfigPublicationError).hardLinkUnavailable).toBe(true); + } + expect(existsSync(getConfigPath())).toBe(false); + expect(initTemps()).toEqual([]); +}); + +test("a syscall error after a real link leaves the entire published candidate intact", () => { + const bytes = 'complete candidate bytes\n'; + expect(() => publishInitialConfigNoReplace(getConfigPath(), bytes, { + link(temp, target) { linkSync(temp, target); throw Object.assign(new Error("uncertain completion"), { code: "EIO" }); }, + })).toThrow(InitialConfigPublicationError); + expect(readFileSync(getConfigPath(), "utf8")).toBe(bytes); + expect(initTemps()).toEqual([]); +}); + +test("post-link identity failure cannot remove a concurrent replacement", () => { + expect(() => initializePersistedConfigIfMissing(config(21001), { + link(temp, target) { + linkSync(temp, target); + const replacement = join(testRoot, "replacement"); + writeFileSync(replacement, "concurrent-winner\n"); + renameSync(replacement, target); + }, + })).toThrow(InitialConfigPublicationError); + expect(readFileSync(getConfigPath(), "utf8")).toBe("concurrent-winner\n"); +}); + +test("cleanup failure retains full published/shared bytes and closes the descriptor", () => { + let closed = false; + let failure: unknown; + try { + publishInitialConfigNoReplace(getConfigPath(), "complete bytes", { + unlink() { throw new Error("sharing violation"); }, + close(fd) { closed = true; closeSync(fd); }, + }); + } catch (error) { failure = error; } + expect(failure).toMatchObject({ publication: "published", residualTemp: true }); + expect(closed).toBe(true); + expect(readFileSync(getConfigPath(), "utf8")).toBe("complete bytes"); + const temps = initTemps(); + expect(temps).toHaveLength(1); + expect(readFileSync(join(testRoot, temps[0]!), "utf8")).toBe("complete bytes"); +}); + +test("a shared unpublished inode is never scrubbed", () => { + const otherName = join(testRoot, "shared-candidate"); + expect(() => publishInitialConfigNoReplace(getConfigPath(), "candidate bytes", { + link(temp) { linkSync(temp, otherName); throw new Error("publication failed"); }, + })).toThrow(InitialConfigPublicationError); + expect(readFileSync(otherName, "utf8")).toBe("candidate bytes"); + expect(existsSync(getConfigPath())).toBe(false); + expect(initTemps()).toEqual([]); +}); + +test("a swapped temporary symlink is neither written through nor removed as our inode", () => { + const victim = join(testRoot, "victim"); + writeFileSync(victim, "untouched"); + expect(() => publishInitialConfigNoReplace(getConfigPath(), "candidate bytes", { + harden(_fd, temp) { unlinkSync(temp); symlinkSync(victim, temp, "file"); }, + })).toThrow(InitialConfigPublicationError); + expect(readFileSync(victim, "utf8")).toBe("untouched"); + expect(existsSync(getConfigPath())).toBe(false); + expect(lstatSync(join(testRoot, initTemps()[0]!)).isSymbolicLink()).toBe(true); +}); + +test("descriptor close failure cannot scrub an already published config", () => { + expect(() => publishInitialConfigNoReplace(getConfigPath(), "complete bytes", { + close(fd) { closeSync(fd); throw new Error("close failed"); }, + })).toThrow(InitialConfigPublicationError); + expect(readFileSync(getConfigPath(), "utf8")).toBe("complete bytes"); +}); + +test("successful init adopts deletion provenance before a subsequent explicit save", () => { + const candidate = config(); + deleteConfigTopLevelKey(candidate, "hostname"); + expect(initializePersistedConfigIfMissing(candidate)).toBe("created"); + expect(candidate.configRebaseProvenance).toEqual({ version: 1, deletedTopLevelKeys: ["hostname"] }); + candidate.hostname = "127.0.0.1"; + saveConfig(candidate); + expect(JSON.parse(readFileSync(getConfigPath(), "utf8")).hostname).toBe("127.0.0.1"); +}); + test("management API maps config mutation lock contention to retryable 503", async () => { saveConfig(config()); const readyPath = join(testRoot, "mgmt-holder-ready"); diff --git a/tests/service/init-eof.test.ts b/tests/service/init-eof.test.ts index 2f2de54d5c..e098e5c56c 100644 --- a/tests/service/init-eof.test.ts +++ b/tests/service/init-eof.test.ts @@ -1,9 +1,10 @@ import { afterEach, describe, expect, test } from "bun:test"; -import { existsSync, mkdtempSync} from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { removeTreeWithRetry } from "../helpers/remove-tree"; -import { repoPath } from "../helpers/repo-root"; +import { repoPath, repoRoot } from "../helpers/repo-root"; +import { resolveCodexCoordinatorDatabasePath, resolveEffectiveUserIdentity } from "../../src/codex/user-identity"; async function waitForOutput( stream: ReadableStream, @@ -23,23 +24,70 @@ async function waitForOutput( } } +/** Continue reading after prompt inspection; Response rejects an already disturbed stream. */ +async function remainingOutput(stream: ReadableStream): Promise { + const reader = stream.getReader(); + const decoder = new TextDecoder(); + let output = ""; + try { + for (;;) { + const { value, done } = await reader.read(); + if (done) return output + decoder.decode(); + output += decoder.decode(value, { stream: true }); + } + } finally { + reader.releaseLock(); + } +} + describe("ocx init piped stdin (#754)", () => { const dirs: string[] = []; + const coordinators: string[] = []; + const makeHome = () => { + const home = mkdtempSync(join(tmpdir(), "ocx-init-eof-")); + dirs.push(home); + mkdirSync(join(home, "native"), { mode: 0o700 }); + return home; + }; + const launch = (home: string, command = "init", bootstrap?: string) => Bun.spawn({ + cmd: bootstrap ? [process.execPath, "--eval", bootstrap] : [process.execPath, repoPath("src", "cli", "index.ts"), command], + cwd: repoRoot(), + env: { + ...process.env, OPENCODEX_HOME: home, CODEX_HOME: join(home, "native"), + HOME: home, USERPROFILE: home, XDG_CONFIG_HOME: join(home, "xdg"), + APPDATA: join(home, "appdata"), LOCALAPPDATA: join(home, "localappdata"), + }, + stdin: "pipe", stdout: "pipe", stderr: "pipe", + }); + const stop = async (proc: ReturnType) => { + if (proc.exitCode === null) proc.kill(); + await proc.exited.catch(() => {}); + }; + const reachPortPrompt = async (proc: ReturnType) => { + for (const [question, answer] of [ + ["Select default provider (number):", "999"], + ["Provider name:", "init-fixture"], + ["Base URL (e.g. http://localhost:11434/v1):", "https://example.test/v1"], + ["Adapter [openai-chat]:", ""], + ["API key (optional):", "fixture-init-key"], + ["Default model:", "fixture-model"], + ]) { + await waitForOutput(proc.stdout, question!); + proc.stdin.write(answer + "\n"); + await proc.stdin.flush(); + } + await waitForOutput(proc.stdout, "Proxy port [10100]:"); + }; afterEach(() => { + for (const path of coordinators.splice(0)) { + for (const suffix of ["", "-journal", "-wal", "-shm"]) rmSync(path + suffix, { force: true }); + } while (dirs.length) removeTreeWithRetry(dirs.pop()!); }); test("exits cleanly when stdin closes before the first prompt answer", async () => { - const home = mkdtempSync(join(tmpdir(), "ocx-init-eof-")); - dirs.push(home); - const cli = repoPath("src", "cli", "index.ts"); - const proc = Bun.spawn({ - cmd: [process.execPath, cli, "init"], - env: { ...process.env, OPENCODEX_HOME: home }, - stdin: "pipe", - stdout: "pipe", - stderr: "pipe", - }); + const home = makeHome(); + const proc = launch(home); const stderrPromise = new Response(proc.stderr).text(); try { // Synchronize on the behavior under test, not Windows process startup/import time. @@ -52,8 +100,205 @@ describe("ocx init piped stdin (#754)", () => { expect(stderr.toLowerCase()).toMatch(/stdin (closed|reached eof)/); expect(existsSync(join(home, "config.json"))).toBe(false); } finally { - if (proc.exitCode === null) proc.kill(); - await proc.exited.catch(() => {}); + await stop(proc); } }, 30_000); + + test.each(["init", "setup"])("%s preserves existing config before asking for input", async command => { + const home = makeHome(); + const bytes = '\uFEFF{ "port":21002, "providers":{}, "defaultProvider":"openai", "customNote":"keep" }\n'; + writeFileSync(join(home, "config.json"), bytes); + const proc = launch(home, command); + const stdout = remainingOutput(proc.stdout); + const stderr = new Response(proc.stderr).text(); + try { + expect(await proc.exited).toBe(0); + expect(await stdout).toContain("Keeping existing config"); + expect(await stderr).not.toContain("fixture-init-key"); + expect(readFileSync(join(home, "config.json"), "utf8")).toBe(bytes); + expect(readdirSync(home).filter(name => name.startsWith("config.json"))).toEqual(["config.json"]); + } finally { await stop(proc); } + }, 30_000); + + test.each(["", "broken config\n", '{"port":"invalid"}'])("invalid existing config is preserved: %j", async bytes => { + const home = makeHome(); + writeFileSync(join(home, "config.json"), bytes); + const proc = launch(home); + const stdout = remainingOutput(proc.stdout); + const stderr = new Response(proc.stderr).text(); + try { + expect(await proc.exited).toBe(1); + expect(await stdout).not.toContain("Select default provider"); + expect(await stderr).toContain("preserved"); + expect(readFileSync(join(home, "config.json"), "utf8")).toBe(bytes); + expect(readdirSync(home).filter(name => name.startsWith("config.json"))).toEqual(["config.json"]); + } finally { await stop(proc); } + }, 30_000); + + test("a creator during the wizard wins without backup cleanup or integration prompts", async () => { + const home = makeHome(); + const backup = join(home, "config.json.pre-openai-tiers-v2.bak"); + writeFileSync(backup, "keep even stale backup on refusal"); + const proc = launch(home); + const stderr = new Response(proc.stderr).text(); + try { + await reachPortPrompt(proc); + const winner = '{"port":21002,"providers":{},"defaultProvider":"openai","winner":true}\n'; + writeFileSync(join(home, "config.json"), winner, { flag: "wx" }); + proc.stdin.write("21001\n"); + await proc.stdin.flush(); + const stdout = remainingOutput(proc.stdout); + expect(await proc.exited).toBe(1); + expect(await stderr).toContain("keeping it"); + const rest = await stdout; + expect(rest).not.toMatch(/Inject into|autostart shim|Setup complete/); + expect(readFileSync(join(home, "config.json"), "utf8")).toBe(winner); + expect(readFileSync(backup, "utf8")).toBe("keep even stale backup on refusal"); + } finally { await stop(proc); } + }, 30_000); + + test("EOF at the final pre-publication prompt preserves backups and creates no config", async () => { + const home = makeHome(); + const backup = join(home, "config.json.pre-openai-tiers-v2.bak"); + writeFileSync(backup, "keep backup on cancellation"); + const proc = launch(home); + const stderr = new Response(proc.stderr).text(); + try { + await reachPortPrompt(proc); + proc.stdin.end(); + expect(await proc.exited).toBe(1); + expect(await stderr).toContain("stdin reached EOF"); + expect(existsSync(join(home, "config.json"))).toBe(false); + expect(readFileSync(backup, "utf8")).toBe("keep backup on cancellation"); + } finally { await stop(proc); } + }, 30_000); + + // Windows process.kill does not deliver a POSIX SIGINT to readline. + test.skipIf(process.platform === "win32")("SIGINT settles a pending prompt without creating config", async () => { + const home = makeHome(); + const proc = launch(home); + const stderr = new Response(proc.stderr).text(); + try { + await waitForOutput(proc.stdout, "Select default provider (number):"); + proc.kill("SIGINT"); + expect(await proc.exited).toBe(130); + expect(await stderr).toContain("Setup cancelled"); + expect(existsSync(join(home, "config.json"))).toBe(false); + } finally { await stop(proc); } + }, 30_000); + + // This wraps only observation/error reporting around the REAL lock and injector. + // The holder releases on the signal event, after runInit consumes cancellation. + for (const wrapping of ["throw", "result"] as const) { + test.skipIf(process.platform === "win32")(`SIGINT while injection is queued preserves native bytes (${wrapping})`, async () => { + const home = makeHome(); + const nativeHome = join(home, "native"); + const nativeConfig = join(nativeHome, "config.toml"); + const sentinel = 'model = "gpt-5"\n# queued-init-sentinel\n'; + writeFileSync(nativeConfig, sentinel); + coordinators.push(resolveCodexCoordinatorDatabasePath(resolveEffectiveUserIdentity(), realpathSync.native(nativeHome))); + const bootstrap = ` + import { mock } from "bun:test"; + import { realpathSync } from "node:fs"; + const configApi = await import("./src/config.ts"); + const transition = await import("./src/codex/transition-state.ts"); + const identity = await import("./src/codex/user-identity.ts"); + configApi.withConfigMutationLockSync(() => {}); + if (transition.readCodexTransitionState().kind !== "ready") throw new Error("native coordinator setup failed"); + const path = identity.resolveCodexCoordinatorDatabasePath(identity.resolveEffectiveUserIdentity(), realpathSync.native(process.env.CODEX_HOME)); + const blocker = transition.openCodexCoordinatorTransaction(path); + const lockApi = { ...await import("./src/codex/codex-write-lock.ts") }; + mock.module("./src/codex/codex-write-lock.ts", () => ({ + ...lockApi, + withCodexWriteLock(options, commit) { + let entered = false; + const pending = lockApi.withCodexWriteLock(options, context => { + entered = true; + console.log("INIT_NATIVE_COMMIT_REACHED"); + return commit(context); + }); + // The real async lock runs synchronously up to its first busy retry. + if (entered) throw new Error("native holder was bypassed"); + console.log("INIT_NATIVE_LOCK_WAITING"); + return pending; + }, + })); + const injectApi = { ...await import("./src/codex/inject.ts") }; + mock.module("./src/codex/inject.ts", () => ({ + ...injectApi, + async injectCodexConfig(...args) { + try { return await injectApi.injectCodexConfig(...args); } + catch { + if (${JSON.stringify(wrapping)} === "throw") throw new Error("WRAPPED_INJECTION_RESULT"); + return { success: false, message: "WRAPPED_INJECTION_RESULT" }; + } + }, + })); + process.once("SIGINT", () => queueMicrotask(() => { + blocker.rollback(); blocker.close(); + console.log("INIT_NATIVE_HOLDER_RELEASED"); + })); + process.argv = [process.execPath, "init-fixture", "init"]; + await import("./src/cli/index.ts"); + `; + const proc = launch(home, "init", bootstrap); + const stderr = new Response(proc.stderr).text(); + try { + await reachPortPrompt(proc); + proc.stdin.write("21001\n"); + await proc.stdin.flush(); + await waitForOutput(proc.stdout, "Inject into Codex config.toml? [Y/n]:"); + const created = readFileSync(join(home, "config.json"), "utf8"); + proc.stdin.write("y\n"); + await proc.stdin.flush(); + await waitForOutput(proc.stdout, "INIT_NATIVE_LOCK_WAITING"); + expect(readFileSync(nativeConfig, "utf8")).toBe(sentinel); + proc.kill("SIGINT"); + const stdout = remainingOutput(proc.stdout); + expect(await proc.exited).toBe(130); + const rest = await stdout; + expect(rest).toContain("INIT_NATIVE_HOLDER_RELEASED"); + expect(rest).toContain("INIT_NATIVE_COMMIT_REACHED"); + expect(rest).not.toMatch(/WRAPPED_INJECTION_RESULT|Install Codex autostart shim|Setup complete|āœ…/); + expect(await stderr).toContain("Setup cancelled. The created config has been kept."); + expect(readFileSync(nativeConfig, "utf8")).toBe(sentinel); + expect(readFileSync(join(home, "config.json"), "utf8")).toBe(created); + expect(existsSync(join(nativeHome, "opencodex.config.toml"))).toBe(false); + expect(existsSync(join(nativeHome, "opencodex-journal.json"))).toBe(false); + expect(existsSync(join(home, "codex-shim.json"))).toBe(false); + } finally { await stop(proc); } + }, 30_000); + } + + test.each([false, true])("successful creation survives later cancellation=%s", async cancel => { + const home = makeHome(); + const proc = launch(home); + const stderr = new Response(proc.stderr).text(); + try { + await reachPortPrompt(proc); + proc.stdin.write("21001\n"); + await proc.stdin.flush(); + await waitForOutput(proc.stdout, "Inject into Codex config.toml? [Y/n]:"); + const created = readFileSync(join(home, "config.json"), "utf8"); + if (cancel) proc.stdin.end(); + else { + proc.stdin.write("n\n"); + await proc.stdin.flush(); + await waitForOutput(proc.stdout, "Install Codex autostart shim? [Y/n]:"); + proc.stdin.write("n\n"); + await proc.stdin.flush(); + } + const stdout = remainingOutput(proc.stdout); + expect(await proc.exited).toBe(cancel ? 1 : 0); + const rest = await stdout; + if (cancel) { + expect(await stderr).toContain("created config has been kept"); + expect(rest).not.toContain("Setup complete"); + } else expect(rest).toContain("Setup complete"); + expect(readFileSync(join(home, "config.json"), "utf8")).toBe(created); + expect(JSON.parse(created)).toMatchObject({ port: 21001, defaultProvider: "init-fixture" }); + expect(existsSync(join(home, "native", "config.toml"))).toBe(false); + expect(existsSync(join(home, "codex-shim.json"))).toBe(false); + } finally { await stop(proc); } + }, 30_000); });