Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
ff5996d
fix(config): add exclusive initial configuration publication [skip ci]
invalid-email-address Sep 6, 2026
dcfae46
fix(cli): preserve existing config during setup [skip ci]
invalid-email-address Sep 6, 2026
0390ce9
Merge current dev into track 3 validation head [skip ci]
invalid-email-address Sep 6, 2026
f437219
test(config): use a valid initial publication fixture [skip ci]
invalid-email-address Sep 6, 2026
eca7b63
Merge validated fixture correction from initializer layer [skip ci]
invalid-email-address Sep 6, 2026
2c44fb7
test(cli): drain stdout after prompt reads [skip ci]
invalid-email-address Sep 6, 2026
4c30386
Merge Windows diagnostic contract correction [skip ci]
invalid-email-address Sep 6, 2026
93184f6
Merge corrected parent layers for final verification [skip ci]
invalid-email-address Sep 6, 2026
867dcb7
test(windows): reserve spawn time for profile readiness [skip ci]
invalid-email-address Sep 6, 2026
618297d
Merge refreshed lower layer for final track 3 CI [skip ci]
invalid-email-address Sep 6, 2026
6401e23
Merge refreshed lower layer for final track 3 CI [skip ci]
invalid-email-address Sep 6, 2026
7c6fc74
test(windows): budget quota restart process startup [skip ci]
invalid-email-address Sep 6, 2026
517e8df
Merge refreshed lower layer for final track 3 CI [skip ci]
invalid-email-address Sep 6, 2026
1cbb177
Merge refreshed lower layer for final track 3 CI [skip ci]
invalid-email-address Sep 6, 2026
d760f66
Merge current integration base into track 3 [skip ci]
invalid-email-address Sep 6, 2026
9ea8967
Merge current integration base into track 3 [skip ci]
invalid-email-address Sep 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions docs-site/src/content/docs/getting-started/for-agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
11 changes: 11 additions & 0 deletions docs-site/src/content/docs/getting-started/quickstart.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
83 changes: 70 additions & 13 deletions src/cli/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>; 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<string>; 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<string> {
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 => {
Expand All @@ -29,6 +49,9 @@ function createPrompt(): { ask(question: string): Promise<string>; close(): void
});
});
},
throwIfCancelled() {
if (cancellation) throw cancellation;
},
close() {
if (!closed) rl.close();
},
Expand Down Expand Up @@ -88,7 +111,18 @@ export function cleanupOpenAiTierBackupAfterInit(configPath = getConfigPath()):
}

export async function runInit(): Promise<void> {
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");

Expand Down Expand Up @@ -171,45 +205,68 @@ export async function runInit(): Promise<void> {
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
// post-migration v2 snapshot) may be deleted; a backup that still parses as a
// 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)}`);
}
}

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();
}
Expand Down
51 changes: 50 additions & 1 deletion src/config.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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.
*
Expand Down Expand Up @@ -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<InitialConfigPublicationIO>,
): 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.
Expand Down
132 changes: 132 additions & 0 deletions src/config/initialize.ts
Original file line number Diff line number Diff line change
@@ -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<InitialConfigPublicationIO> = {},
): 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;
}
Loading
Loading