Files
aiturk-hermes-ide/apps/desktop/electron/codex-link.ts
T
yilsemandClaude Opus 5.5 b560950f82 fix(codex-bridge): ship zstd for the agent's Python and refuse to link without it
Codex compresses request bodies with zstd. The agent venv is Python 3.11, which
has no zstd in its standard library, so every Codex request failed on a fresh
install. The matching zstandard build now ships beside the bridge; the status
endpoint reports ok=false when bodies cannot be read, and the IDE then leaves
Codex's config alone. A running bridge from an older build is replaced, and
"Sync now" makes the bridge re-read the IDE session at once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HRDGW1LiKcDUZYUdKBoWYW
2026-10-09 16:27:55 +03:00

537 lines
17 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_BACKUP_SUFFIX,
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
/** Copy `source` to `target` unless `target` already exists. */
backupOnce: (source: string, target: 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.backupOnce(paths.codexConfig, `${paths.codexConfig}${CODEX_LINK_BACKUP_SUFFIX}`)
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,
backupOnce: (source, target) => {
if (!fs.existsSync(target)) {
fs.copyFileSync(source, target, fs.constants.COPYFILE_EXCL)
}
},
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
}
}