/** * Codex Link ("Codex Bağlama") — decisions and side effects. * * When the feature is on and OpenAI Codex is installed on this machine, the IDE * runs a small local Python bridge, points Codex's `config.toml` at it and * registers the bridge as a login item, so Codex keeps working with the IDE's * models while the IDE itself is closed. Turning it off undoes all three. * * Ordering is the safety property: Codex cannot work at all while its config * points at a dead address, so the config line is written only AFTER the bridge * has answered, and it is taken back out if the bridge cannot be brought up. * * Electron-free on purpose. `createCodexLink` takes every effect as a * dependency (files, spawn, HTTP, registry) so the ordering above is provable * in a unit test; `createNodeCodexLinkDeps` is the real wiring main.ts uses. * The text transformations live in `codex-link-config.ts`. */ import { execFile, spawn } from 'node:child_process' import fs from 'node:fs' import path from 'node:path' import { addCodexLinkLine, CODEX_BRIDGE_VERSION, CODEX_LINK_LAUNCHER_NAME, CODEX_LINK_REFRESH_URL, CODEX_LINK_RUN_KEY, CODEX_LINK_RUN_VALUE, CODEX_LINK_STATUS_URL, CODEX_LINK_STOP_URL, codexAutostartPlan, codexBridgeArgs, type CodexBridgeLaunch, inspectCodexConfig, parseCodexLinkSettings, removeCodexLinkLine, serializeCodexBridgeConfig, serializeCodexLinkSettings } from './codex-link-config' import type { CodexLinkError, CodexLinkIdeState, CodexLinkPatch, CodexLinkStatus } from './codex-link-types' import { hiddenWindowsChildOptions } from './windows-child-options' export type { CodexLinkError, CodexLinkIdeState, CodexLinkPatch, CodexLinkStatus } from './codex-link-types' export interface CodexLinkPaths { /** `/codex-baglama.json` */ settingsFile: string /** `/codex-bridge` — the bridge's data folder. */ bridgeHome: string hermesHome: string /** `/codex-bridge/bridge.py` */ bridgeScript: string /** Windowless interpreter, preferred. */ pythonw: string /** Console interpreter, used hidden when pythonw is absent. */ python: string /** `/config.toml` */ codexConfig: string } export interface CodexLinkDeps { paths: CodexLinkPaths exists: (file: string) => boolean /** null when the file is missing; throws when it cannot be read losslessly. */ readText: (file: string) => null | string /** tmp + rename; creates the parent folder. */ writeTextAtomic: (file: string, text: string) => void /** Start the bridge detached and hidden; it must outlive the IDE. */ spawnBridge: (command: string, args: string[], env: Record) => void /** Parsed JSON body, or null when nothing answered within `budgetMs`. */ request: (method: 'GET' | 'POST', url: string, budgetMs: number) => Promise setAutostart: (command: string) => Promise clearAutostart: () => Promise sleep: (ms: number) => Promise now: () => number log: (message: string) => void } // The single wait this feature owns: how long a freshly spawned bridge gets to // answer its status endpoint before the apply gives up. export const CODEX_BRIDGE_START_BUDGET_MS = 45_000 const START_POLL_INTERVAL_MS = 500 // A probe of an already-running (or absent) local bridge. Loopback answers or // refuses at once; the bound only keeps Settings from hanging on a wedged port. const PROBE_BUDGET_MS = 2_000 interface BridgeProbe { ideDurum: CodexLinkIdeState | null ideModel: null | number ozet: boolean | null running: boolean surum: null | string } const NOT_RUNNING: BridgeProbe = { ideDurum: null, ideModel: null, ozet: null, running: false, surum: null } function readProbe(body: unknown): BridgeProbe { if (!body || typeof body !== 'object' || (body as { ok?: unknown }).ok !== true) { return NOT_RUNNING } const reply = body as { ide_durum?: unknown; ide_model?: unknown; ozet?: unknown; surum?: unknown } const state = reply.ide_durum return { ideDurum: state === 'tamam' || state === 'kapali' || state === 'anahtar-yok' ? state : null, ideModel: typeof reply.ide_model === 'number' && Number.isFinite(reply.ide_model) ? reply.ide_model : null, ozet: typeof reply.ozet === 'boolean' ? reply.ozet : null, running: true, surum: typeof reply.surum === 'string' ? reply.surum : null } } function describe(error: unknown): string { return error instanceof Error ? error.message : String(error) } export interface CodexLink { /** Side-effect free: reads the preference, the files and the status endpoint. */ getStatus: () => Promise /** Persist `patch.enabled` (if given), make the machine match, report. */ apply: (patch?: CodexLinkPatch) => Promise } export function createCodexLink(deps: CodexLinkDeps): CodexLink { const { paths } = deps let lastError: CodexLinkError | null = null let queue: Promise = Promise.resolve() const launch: CodexBridgeLaunch = { bridgeHome: paths.bridgeHome, bridgeScript: paths.bridgeScript, hermesHome: paths.hermesHome } const fail = (code: CodexLinkError, error?: unknown) => { lastError = code deps.log(`[codex-link] ${code}${error === undefined ? '' : `: ${describe(error)}`}`) } const readSettings = () => { try { return parseCodexLinkSettings(deps.readText(paths.settingsFile)) } catch { return parseCodexLinkSettings(null) } } // The interpreter to run the bridge with, or null while the IDE's own // first-run setup has not produced the venv yet. const interpreter = () => deps.exists(paths.pythonw) ? paths.pythonw : deps.exists(paths.python) ? paths.python : null // The owner of Codex's base URL. An unreadable config counts as foreign for // the purpose of "do not touch": we only edit what we could read back intact. const readCodexConfig = (): { found: boolean; owner: 'absent' | 'foreign' | 'ours'; text: null | string } => { if (!deps.exists(paths.codexConfig)) { return { found: false, owner: 'absent', text: null } } try { const text = deps.readText(paths.codexConfig) return text === null ? { found: false, owner: 'absent', text: null } : { found: true, owner: inspectCodexConfig(text), text } } catch (error) { deps.log(`[codex-link] config.toml could not be read: ${describe(error)}`) return { found: true, owner: 'foreign', text: null } } } const probe = async (budgetMs = PROBE_BUDGET_MS): Promise => { try { return readProbe(await deps.request('GET', CODEX_LINK_STATUS_URL, budgetMs)) } catch { return NOT_RUNNING } } const statusFrom = ( enabled: boolean, config: { found: boolean; owner: string }, bridge: BridgeProbe ): CodexLinkStatus => ({ enabled, codexFound: config.found, venvReady: interpreter() !== null, foreign: config.owner === 'foreign', bridgeRunning: bridge.running, ideDurum: bridge.ideDurum, ideModel: bridge.ideModel, ozet: bridge.ozet, lastError }) const removeOurLine = (text: null | string): boolean => { if (text === null) { return true } const edit = removeCodexLinkLine(text) if (!edit.changed) { return true } try { deps.writeTextAtomic(paths.codexConfig, edit.text) return true } catch (error) { fail('config-write-failed', error) return false } } // Off: take back everything we put there. Each step is independent — a // failure in one must not leave the others in place. const tearDown = async (config: ReturnType): Promise => { removeOurLine(config.text) try { await deps.clearAutostart() } catch (error) { fail('autostart-failed', error) } // A foreign config means whatever listens on the port serves someone // else's setup; stopping it would break a bridge we do not own. if (config.owner === 'foreign') { return probe() } try { // No answer means nothing of ours is listening; leave it alone. return (await deps.request('POST', CODEX_LINK_STOP_URL, PROBE_BUDGET_MS)) === null ? probe() : NOT_RUNNING } catch { return probe() } } const startBridge = async (python: string): Promise => { try { deps.spawnBridge(python, codexBridgeArgs(launch), { AITURK_CODEX_BRIDGE_HOME: paths.bridgeHome, HERMES_HOME: paths.hermesHome }) } catch (error) { deps.log(`[codex-link] bridge spawn failed: ${describe(error)}`) return NOT_RUNNING } const deadline = deps.now() + CODEX_BRIDGE_START_BUDGET_MS for (;;) { const remaining = deadline - deps.now() if (remaining <= 0) { return NOT_RUNNING } const result = await probe(Math.min(PROBE_BUDGET_MS, remaining)) if (result.running) { return result } await deps.sleep(Math.min(START_POLL_INTERVAL_MS, Math.max(0, deadline - deps.now()))) } } // On: bridge.json → bridge up (verified) → config.toml → login item. const setUp = async (python: string, config: ReturnType): Promise => { if (!deps.exists(paths.bridgeScript)) { fail('bridge-missing', paths.bridgeScript) return probe() } try { deps.writeTextAtomic(path.join(paths.bridgeHome, 'bridge.json'), serializeCodexBridgeConfig()) } catch (error) { fail('bridge-config-failed', error) return probe() } let bridge = await probe() if (bridge.running && bridge.surum !== CODEX_BRIDGE_VERSION) { // A bridge from an older IDE build is still listening: replace it. try { await deps.request('POST', CODEX_LINK_STOP_URL, PROBE_BUDGET_MS) } catch { // It stops answering once it exits; the start below verifies the new one. } await deps.sleep(START_POLL_INTERVAL_MS * 2) bridge = NOT_RUNNING } if (bridge.running) { // Already up: have it re-read the IDE session and model list now (a sign-in // since its last periodic refresh would otherwise show up minutes later). try { const refreshed = readProbe(await deps.request('POST', CODEX_LINK_REFRESH_URL, CODEX_BRIDGE_START_BUDGET_MS)) bridge = refreshed.running ? refreshed : bridge } catch { // The status it already gave stays valid. } } else { bridge = await startBridge(python) } if (!bridge.running) { fail('bridge-start-failed') // Fail open: a line left over from an earlier run would now point Codex // at a dead address. Without it Codex simply talks to OpenAI directly. removeOurLine(config.text) return bridge } const edit = addCodexLinkLine(config.text ?? '') if (edit.changed) { try { deps.writeTextAtomic(paths.codexConfig, edit.text) } catch (error) { fail('config-write-failed', error) return bridge } } // Rewritten on every apply: an IDE update can move the bridge script. try { const plan = codexAutostartPlan(python, launch, path.join(paths.bridgeHome, CODEX_LINK_LAUNCHER_NAME)) if (!plan) { throw new Error('the bridge command does not fit a Run value') } if (plan.launcher) { deps.writeTextAtomic(plan.launcher.path, plan.launcher.source) } await deps.setAutostart(plan.command) } catch (error) { fail('autostart-failed', error) } return bridge } const run = async (patch?: CodexLinkPatch): Promise => { lastError = null if (typeof patch?.enabled === 'boolean') { try { deps.writeTextAtomic(paths.settingsFile, serializeCodexLinkSettings(readSettings().stored, patch.enabled)) } catch (error) { // The preference did not stick, so do not act on it either: what the // machine does must match what the file says after a restart. fail('settings-write-failed', error) } } const { enabled } = readSettings() const config = readCodexConfig() if (!enabled) { return statusFrom(enabled, config, await tearDown(config)) } const python = interpreter() if (!config.found || !python || config.owner === 'foreign') { return statusFrom(enabled, config, await probe()) } return statusFrom(enabled, config, await setUp(python, config)) } return { getStatus: async () => statusFrom(readSettings().enabled, readCodexConfig(), await probe()), // One apply at a time: two interleaved runs could write the config line // from one while the other is tearing the bridge down. apply: patch => { const next = queue.then(() => run(patch)) queue = next.catch(() => undefined) return next } } } // ── Real dependencies ─────────────────────────────────────────────────────── function writeTextAtomic(file: string, text: string): void { fs.mkdirSync(path.dirname(file), { recursive: true }) const tmp = `${file}.${process.pid}.tmp` try { fs.writeFileSync(tmp, text, 'utf8') fs.renameSync(tmp, file) } catch (error) { fs.rmSync(tmp, { force: true }) throw error } } // Decode as UTF-8 and insist the bytes survive the round trip. A config in // another encoding would be corrupted by a rewrite, so refuse to return it. function readTextLossless(file: string): null | string { let bytes: Buffer try { bytes = fs.readFileSync(file) } catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') { return null } throw error } const text = bytes.toString('utf8') if (!Buffer.from(text, 'utf8').equals(bytes)) { throw new Error('file is not valid UTF-8') } return text } function runReg(args: string[]): Promise<{ code: number; stderr: string }> { const reg = path.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'reg.exe') return new Promise(resolve => { execFile(reg, args, hiddenWindowsChildOptions({ encoding: 'utf8' }), (error, _stdout, stderr) => { const exit = error ? (typeof (error as { code?: unknown }).code === 'number' ? (error as any).code : 1) : 0 resolve({ code: exit, stderr: String(stderr || (error ? error.message : '')).trim() }) }) }) } async function requestJson(method: 'GET' | 'POST', url: string, budgetMs: number): Promise { try { const response = await fetch(url, { method, signal: AbortSignal.timeout(Math.max(1, budgetMs)) }) return response.ok ? await response.json() : null } catch { return null } } export function createNodeCodexLinkDeps(paths: CodexLinkPaths, log: (message: string) => void): CodexLinkDeps { return { paths, exists: file => fs.existsSync(file), readText: readTextLossless, writeTextAtomic, spawnBridge: (command, args, env) => { fs.mkdirSync(paths.bridgeHome, { recursive: true }) // Detached with no stdio and unref'd: the bridge is deliberately NOT a // child the IDE cleans up on quit — Codex depends on it staying alive. const child = spawn( command, args, hiddenWindowsChildOptions({ cwd: paths.bridgeHome, detached: true, env: { ...process.env, ...env }, stdio: 'ignore' }) as any ) child.on('error', error => log(`[codex-link] bridge process error: ${error.message}`)) child.unref() }, request: requestJson, setAutostart: async command => { const result = await runReg([ 'add', CODEX_LINK_RUN_KEY, '/v', CODEX_LINK_RUN_VALUE, '/t', 'REG_SZ', '/d', command, '/f' ]) if (result.code !== 0) { throw new Error(`reg add exited ${result.code}: ${result.stderr}`) } }, clearAutostart: async () => { // `reg delete` fails on a value that is not there; absent is already the // state we want, so look first. const present = await runReg(['query', CODEX_LINK_RUN_KEY, '/v', CODEX_LINK_RUN_VALUE]) if (present.code !== 0) { return } const result = await runReg(['delete', CODEX_LINK_RUN_KEY, '/v', CODEX_LINK_RUN_VALUE, '/f']) if (result.code !== 0) { throw new Error(`reg delete exited ${result.code}: ${result.stderr}`) } }, sleep: ms => new Promise(resolve => setTimeout(resolve, ms)), now: () => Date.now(), log } }