Combos that come from 9Router, directly or through the gateway's 9router provider, are no longer added to Codex's model list; the gateway's own combos keep their plain names. The IDE no longer leaves a config.toml backup behind: turning the setting off already restores the file byte for byte. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HRDGW1LiKcDUZYUdKBoWYW
528 lines
16 KiB
TypeScript
528 lines
16 KiB
TypeScript
/**
|
|
* 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 {
|
|
/** `<data root>/codex-baglama.json` */
|
|
settingsFile: string
|
|
/** `<data root>/codex-bridge` — the bridge's data folder. */
|
|
bridgeHome: string
|
|
hermesHome: string
|
|
/** `<resources>/codex-bridge/bridge.py` */
|
|
bridgeScript: string
|
|
/** Windowless interpreter, preferred. */
|
|
pythonw: string
|
|
/** Console interpreter, used hidden when pythonw is absent. */
|
|
python: string
|
|
/** `<CODEX_HOME or ~/.codex>/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<string, string>) => void
|
|
/** Parsed JSON body, or null when nothing answered within `budgetMs`. */
|
|
request: (method: 'GET' | 'POST', url: string, budgetMs: number) => Promise<unknown>
|
|
setAutostart: (command: string) => Promise<void>
|
|
clearAutostart: () => Promise<void>
|
|
sleep: (ms: number) => Promise<void>
|
|
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 = 10_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<CodexLinkStatus>
|
|
/** Persist `patch.enabled` (if given), make the machine match, report. */
|
|
apply: (patch?: CodexLinkPatch) => Promise<CodexLinkStatus>
|
|
}
|
|
|
|
export function createCodexLink(deps: CodexLinkDeps): CodexLink {
|
|
const { paths } = deps
|
|
let lastError: CodexLinkError | null = null
|
|
let queue: Promise<unknown> = 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<BridgeProbe> => {
|
|
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<typeof readCodexConfig>): Promise<BridgeProbe> => {
|
|
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<BridgeProbe> => {
|
|
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<typeof readCodexConfig>): Promise<BridgeProbe> => {
|
|
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<CodexLinkStatus> => {
|
|
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<unknown> {
|
|
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
|
|
}
|
|
}
|