feat(settings): Codex Bağlama, on by default

Settings → Codex Bağlama runs the bundled bridge for an OpenAI Codex install on
the same computer: the IDE's models and combos appear in Codex and switching
models keeps the conversation. The IDE points Codex's openai_base_url at the
bridge only after the bridge answers, keeps it running across logins, and
removes every trace when the setting is turned off. A Codex that already uses
another base URL is left untouched.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HRDGW1LiKcDUZYUdKBoWYW
This commit is contained in:
yilsemandClaude Opus 5.5 committed 2026-10-09 15:59:39 +03:00
1 parent 1ce5ab82cf
commit dea67fc153
16 files changed
+2317 -2

No files matched your search

+8
View File
@@ -43,6 +43,14 @@ jobs:
New-Item -ItemType Junction -Path node_modules -Target (Join-Path $deps 'node_modules') | Out-Null
node node_modules/vitest/vitest.mjs run --root apps/desktop --config vitest.mobile.config.ts
exit $LASTEXITCODE
- name: Codex koprusu (Python, bagimliliksiz) testleri
shell: powershell
run: |
$ErrorActionPreference = 'Stop'
$py = (Get-Command python -ErrorAction SilentlyContinue).Source
if ($py) { & $py -m unittest discover -s apps/desktop/assets/codex-bridge }
else { & py -3 -m unittest discover -s apps/desktop/assets/codex-bridge }
exit $LASTEXITCODE
approval-gate-windows:
runs-on: windows-latest
@@ -0,0 +1,287 @@
import { describe, expect, it } from 'vitest'
import {
addCodexLinkLine,
CODEX_LINK_LINE,
CODEX_LINK_RUN_COMMAND_MAX,
codexAutostartPlan,
codexBridgeArgs,
codexBridgeCommandLine,
codexBridgeConfig,
codexBridgeLauncherSource,
inspectCodexConfig,
parseCodexLinkSettings,
removeCodexLinkLine,
serializeCodexLinkSettings
} from './codex-link-config'
const FOREIGN = 'openai_base_url = "http://127.0.0.1:8799/backend-api/codex"'
// Add then remove must hand the user's file back exactly as it was.
function expectRoundTrip(original: string) {
const added = addCodexLinkLine(original)
expect(added.changed).toBe(true)
expect(added.owner).toBe('ours')
expect(inspectCodexConfig(added.text)).toBe('ours')
const removed = removeCodexLinkLine(added.text)
expect(removed.changed).toBe(true)
expect(removed.text).toBe(original)
return added.text
}
describe('the contract line', () => {
it('is the exact text Codex and the bridge agree on', () => {
expect(CODEX_LINK_LINE).toBe(
'openai_base_url = "http://127.0.0.1:8795/backend-api/codex" # AITURK IDE Codex Baglama'
)
})
})
describe('addCodexLinkLine', () => {
it('writes the line into an empty file', () => {
expect(expectRoundTrip('')).toBe(`${CODEX_LINK_LINE}\n`)
})
it('puts the line before the first table when the file only has tables', () => {
const original = '[projects."C:\\\\work"]\ntrust_level = "trusted"\n\n[mcp_servers.x]\ncommand = "node"\n'
const text = expectRoundTrip(original)
expect(text.indexOf(CODEX_LINK_LINE)).toBeLessThan(text.indexOf('[projects.'))
expect(text.endsWith(original)).toBe(true)
})
it('keeps existing top-level keys and tables intact', () => {
const original = '# my config\nmodel = "gpt-6"\napproval_policy = "never"\n\n[features]\nweb = true\n'
const text = expectRoundTrip(original)
expect(text.indexOf(CODEX_LINK_LINE)).toBeLessThan(text.indexOf('[features]'))
expect(text).toContain('model = "gpt-6"\napproval_policy = "never"\n\n[features]\nweb = true\n')
})
it('preserves a CRLF file and writes its own line with CRLF', () => {
const original = 'model = "gpt-6"\r\n\r\n[features]\r\nweb = true\r\n'
const text = expectRoundTrip(original)
expect(text).toBe(`${CODEX_LINK_LINE}\r\n${original}`)
expect(text.replace(/\r\n/g, '')).not.toMatch(/[\r\n]/)
})
it('keeps a byte-order mark at the very start', () => {
const text = expectRoundTrip('\uFEFFmodel = "gpt-6"\n')
expect(text.startsWith(`\uFEFF${CODEX_LINK_LINE}\n`)).toBe(true)
})
it('keeps a file without a trailing newline exactly as it was', () => {
expectRoundTrip('model = "gpt-6"\n[features]\nweb = true')
})
it('is idempotent once our line is there', () => {
const once = addCodexLinkLine('model = "gpt-6"\n[features]\n').text
const twice = addCodexLinkLine(once)
expect(twice.changed).toBe(false)
expect(twice.owner).toBe('ours')
expect(twice.text).toBe(once)
})
it('refreshes a stale copy of our own line in place', () => {
const stale = 'model = "gpt-6"\nopenai_base_url = "http://127.0.0.1:1/x" # AITURK IDE Codex Baglama\n[features]\n'
const edit = addCodexLinkLine(stale)
expect(edit.changed).toBe(true)
expect(edit.text).toBe(`model = "gpt-6"\n${CODEX_LINK_LINE}\n[features]\n`)
})
it('still owns its line after Codex rewrote the config and dropped the comment', () => {
const rewritten = 'openai_base_url = "http://127.0.0.1:8795/backend-api/codex"\nmodel = "x"\n'
expect(inspectCodexConfig(rewritten)).toBe('ours')
expect(removeCodexLinkLine(rewritten).text).toBe('model = "x"\n')
// Another local port is somebody else's bridge.
expect(inspectCodexConfig('openai_base_url = "http://127.0.0.1:8799/backend-api/codex"\n')).toBe('foreign')
})
it('does not touch a foreign openai_base_url and reports it', () => {
for (const original of [
`${FOREIGN}\n[features]\n`,
`model = "gpt-6"\r\n ${FOREIGN} # my own bridge\r\n`,
'"openai_base_url" = "https://example.test/v1"\n',
"'openai_base_url'='https://example.test/v1'\n"
]) {
const edit = addCodexLinkLine(original)
expect(edit).toEqual({ changed: false, owner: 'foreign', text: original })
expect(inspectCodexConfig(original)).toBe('foreign')
}
})
it('does not treat the same key under a table as top-level', () => {
const original = 'model = "gpt-6"\n\n[model_providers.mine]\nopenai_base_url = "https://example.test/v1"\n'
expect(inspectCodexConfig(original)).toBe('absent')
const text = expectRoundTrip(original)
expect(text.indexOf(CODEX_LINK_LINE)).toBeLessThan(text.indexOf('[model_providers.mine]'))
expect(text).toContain('[model_providers.mine]\nopenai_base_url = "https://example.test/v1"\n')
})
it('ignores the key inside a comment', () => {
const original = '# openai_base_url = "https://example.test/v1"\n #openai_base_url = "x"\nmodel = "gpt-6"\n'
expect(inspectCodexConfig(original)).toBe('absent')
expectRoundTrip(original)
})
it('ignores look-alikes inside multi-line values', () => {
// Neither the "key" inside the string nor the "[table]" inside it is real,
// so the genuine foreign key after it must still be found.
const inString = 'notes = """\nopenai_base_url = "nope"\n[not.a.table]\n"""\nmodel = "gpt-6"\n'
expect(inspectCodexConfig(inString)).toBe('absent')
expect(inspectCodexConfig(`${inString}${FOREIGN}\n`)).toBe('foreign')
const inArray = 'notify = [\n "a",\n ["b"]\n]\n'
expect(inspectCodexConfig(`${inArray}${FOREIGN}\n`)).toBe('foreign')
expect(inspectCodexConfig(`literal = '''\n[x]\n'''\n${FOREIGN}\n`)).toBe('foreign')
})
it('is not fooled by a bracket or hash inside a quoted value', () => {
const original = `title = "a [b] # c"\n${FOREIGN}\n`
expect(inspectCodexConfig(original)).toBe('foreign')
})
})
describe('removeCodexLinkLine', () => {
it('removes only our line, wherever the user moved it', () => {
const text = `model = "gpt-6"\r\n${CODEX_LINK_LINE}\r\napproval_policy = "never"\r\n[features]\r\n`
const edit = removeCodexLinkLine(text)
expect(edit).toEqual({
changed: true,
owner: 'absent',
text: 'model = "gpt-6"\r\napproval_policy = "never"\r\n[features]\r\n'
})
})
it('leaves a file without our line untouched', () => {
for (const original of ['', 'model = "gpt-6"\n', `${FOREIGN}\n[features]\n`]) {
expect(removeCodexLinkLine(original).changed).toBe(false)
expect(removeCodexLinkLine(original).text).toBe(original)
}
})
it('never removes a foreign line', () => {
expect(removeCodexLinkLine(`${FOREIGN}\n`)).toEqual({ changed: false, owner: 'foreign', text: `${FOREIGN}\n` })
})
it('leaves our marker alone when it sits under a table', () => {
const original = `[model_providers.mine]\n${CODEX_LINK_LINE}\n`
expect(removeCodexLinkLine(original)).toEqual({ changed: false, owner: 'absent', text: original })
})
})
describe('preference file', () => {
it('is on by default when the file is missing or malformed', () => {
for (const raw of [null, undefined, '', '{', 'null', '[]', '"off"', '{"kapsam":"ide"}', '{"enabled":"false"}']) {
expect(parseCodexLinkSettings(raw).enabled).toBe(true)
}
})
it('is off only when explicitly disabled', () => {
expect(parseCodexLinkSettings('{"enabled": false, "kapsam": "ide"}').enabled).toBe(false)
expect(parseCodexLinkSettings('{"enabled": true}').enabled).toBe(true)
})
it('writes the contract shape and keeps unknown fields', () => {
expect(JSON.parse(serializeCodexLinkSettings({}, false))).toEqual({ enabled: false, kapsam: 'ide' })
const { stored } = parseCodexLinkSettings('{"enabled": false, "kapsam": "hepsi", "gelecek": {"a": 1}}')
expect(JSON.parse(serializeCodexLinkSettings(stored, true))).toEqual({
enabled: true,
gelecek: { a: 1 },
kapsam: 'hepsi'
})
})
})
describe('bridge configuration and command', () => {
const launch = {
bridgeHome: 'C:\\Users\\a b\\AppData\\Local\\TurkServis\\AITURK-IDE\\codex-bridge',
bridgeScript: 'C:\\Program Files\\AITURK IDE\\resources\\codex-bridge\\bridge.py',
hermesHome: 'C:\\Users\\a b\\AppData\\Local\\TurkServis\\AITURK-IDE\\agent'
}
const python = `${launch.hermesHome}\\hermes-agent\\venv\\Scripts\\pythonw.exe`
it('writes the agreed bridge.json', () => {
expect(codexBridgeConfig()).toEqual({
port: 8795,
chatgpt_backend: 'https://chatgpt.com',
routes: {},
nine_router_catalog: { enabled: false },
pano_kanca_user: false,
ozet_kopru: { enabled: true, kapsam: 'hepsi', openai_modeli: 'gpt-6-luna', dongu_siniri: 3 }
})
})
it('passes the same arguments to the spawn and the login item', () => {
expect(codexBridgeArgs(launch)).toEqual([
launch.bridgeScript,
'--home',
launch.bridgeHome,
'--hermes-home',
launch.hermesHome
])
expect(codexBridgeCommandLine(python, launch)).toBe(
`"${python}" "${launch.bridgeScript}" --home "${launch.bridgeHome}" --hermes-home "${launch.hermesHome}"`
)
})
it('drops a trailing separator that would escape the closing quote', () => {
expect(
codexBridgeCommandLine('C:\\py\\pythonw.exe', {
bridgeHome: 'C:\\d\\',
bridgeScript: 'C:\\b.py',
hermesHome: 'C:\\h\\'
})
).toBe('"C:\\py\\pythonw.exe" "C:\\b.py" --home "C:\\d" --hermes-home "C:\\h"')
})
it('uses the direct command for the login item when it fits a Run value', () => {
const short = { bridgeHome: 'C:\\d', bridgeScript: 'C:\\b\\bridge.py', hermesHome: 'C:\\h' }
expect(codexAutostartPlan('C:\\py\\pythonw.exe', short, 'C:\\d\\baslat.pyw')).toEqual({
command: '"C:\\py\\pythonw.exe" "C:\\b\\bridge.py" --home "C:\\d" --hermes-home "C:\\h"',
launcher: null
})
})
it('falls back to a launcher when the direct command is too long for a Run value', () => {
expect(codexBridgeCommandLine(python, launch).length).toBeGreaterThan(CODEX_LINK_RUN_COMMAND_MAX)
const launcherPath = `${launch.bridgeHome}\\baslat.pyw`
const plan = codexAutostartPlan(python, launch, launcherPath)
expect(plan?.command).toBe(`"${python}" "${launcherPath}"`)
expect(plan?.command.length).toBeLessThanOrEqual(CODEX_LINK_RUN_COMMAND_MAX)
expect(plan?.launcher).toEqual({ path: launcherPath, source: codexBridgeLauncherSource(launch) })
// The launcher carries the very same argv as the direct command.
expect(plan?.launcher?.source).toContain(`ARGV = ${JSON.stringify(codexBridgeArgs(launch))}`)
})
it('registers nothing when not even the launcher command fits', () => {
const deep = `C:\\${'x'.repeat(300)}`
expect(codexAutostartPlan(`${deep}\\pythonw.exe`, launch, `${deep}\\baslat.pyw`)).toBeNull()
})
})
+429
View File
@@ -0,0 +1,429 @@
/**
* Codex Link ("Codex Bağlama") — the pure half.
*
* AITURK IDE ships a small local Python bridge that lets the OpenAI Codex
* installed on this machine use the IDE's models and combos. Turning the
* feature on means editing a file the IDE does not own (Codex's `config.toml`)
* and registering a login item, so every text transformation lives here as a
* dependency-free, byte-preserving function that can be proven without
* Electron, a filesystem, or a registry. `codex-link.ts` owns the decisions and
* the side effects.
*
* Invariants:
* - Only the line carrying OUR trailing comment is ever written or removed.
* - An `openai_base_url` that is not ours means someone else's bridge is in
* charge: report `foreign` and change nothing.
* - Everything else in the file — including its line endings and BOM — comes
* back byte for byte.
*/
export const CODEX_LINK_PORT = 8795
export const CODEX_LINK_ORIGIN = `http://127.0.0.1:${CODEX_LINK_PORT}`
export const CODEX_LINK_STATUS_URL = `${CODEX_LINK_ORIGIN}/aiturk/codex-baglama/status`
export const CODEX_LINK_STOP_URL = `${CODEX_LINK_ORIGIN}/aiturk/codex-baglama/stop`
/** The trailing comment that marks the line as ours. */
export const CODEX_LINK_MARKER = '# AITURK IDE Codex Baglama'
// Must equal SURUM in assets/codex-bridge/bridge.py; a running bridge that reports
// another version is restarted so an updated IDE never keeps an old bridge alive.
export const CODEX_BRIDGE_VERSION = '1'
export const CODEX_LINK_LINE = `openai_base_url = "${CODEX_LINK_ORIGIN}/backend-api/codex" ${CODEX_LINK_MARKER}`
export const CODEX_LINK_BACKUP_SUFFIX = '.aiturk-yedek'
export const CODEX_LINK_RUN_KEY = 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run'
export const CODEX_LINK_RUN_VALUE = 'AITURK-IDE-Codex-Baglama'
export const CODEX_LINK_LAUNCHER_NAME = 'baslat.pyw'
// Windows documents Run-key command lines as "no longer than 260 characters".
// A longer value is not reliably started at sign-in, so the direct command is
// only used when it fits (see autostartPlan).
export const CODEX_LINK_RUN_COMMAND_MAX = 260
/** Who owns the top-level `openai_base_url` in a Codex config. */
export type CodexConfigOwner = 'absent' | 'foreign' | 'ours'
interface Line {
eol: string
text: string
}
const BOM = '\uFEFF'
const KEY_PATTERN = /^[ \t]*(?:openai_base_url|"openai_base_url"|'openai_base_url')[ \t]*=/
// Split into lines WITHOUT losing anything: joining `text + eol` of every entry
// reproduces the input exactly, whatever mix of \n, \r\n and \r it used.
function splitLines(source: string): Line[] {
const lines: Line[] = []
let start = 0
while (start < source.length) {
let end = start
while (end < source.length && source[end] !== '\n' && source[end] !== '\r') {
end += 1
}
let eol = ''
if (end < source.length) {
eol = source[end] === '\r' && source[end + 1] === '\n' ? '\r\n' : source[end]
}
lines.push({ eol, text: source.slice(start, end) })
start = end + eol.length
}
return lines
}
function joinLines(lines: Line[]): string {
return lines.map(line => line.text + line.eol).join('')
}
function splitBom(source: string): { body: string; bom: string } {
return source.startsWith(BOM) ? { body: source.slice(1), bom: BOM } : { body: source, bom: '' }
}
type MultilineQuote = "'''" | '"""' | null
interface ValueState {
depth: number
multiline: MultilineQuote
}
// Index just past the `=` of a `key = value` line, or the line length when the
// line holds no value (blank, comment, malformed). Quoted keys may contain `=`.
function valueStart(text: string): number {
let pos = 0
while (pos < text.length) {
const char = text[pos]
if (char === '#') {
return text.length
}
if (char === '=') {
return pos + 1
}
if (char === '"' || char === "'") {
pos += 1
while (pos < text.length && text[pos] !== char) {
pos += char === '"' && text[pos] === '\\' ? 2 : 1
}
}
pos += 1
}
return text.length
}
// Walk a value just far enough to know whether the NEXT line is still inside it
// (multi-line string or array). That is the only TOML the top-level scan needs:
// without it, text inside a multi-line value could pass for a key or a table
// header.
function scanValue(text: string, from: number, state: ValueState): ValueState {
let { depth, multiline } = state
let pos = from
while (pos < text.length) {
if (multiline) {
if (multiline === '"""' && text[pos] === '\\') {
pos += 2
continue
}
if (text.startsWith(multiline, pos)) {
pos += 3
// Up to two quotes may sit directly before the closing delimiter.
while (text[pos] === multiline[0]) {
pos += 1
}
multiline = null
continue
}
pos += 1
continue
}
const char = text[pos]
if (char === '#') {
break
}
if (text.startsWith('"""', pos) || text.startsWith("'''", pos)) {
multiline = text.slice(pos, pos + 3) as MultilineQuote
pos += 3
continue
}
if (char === '"' || char === "'") {
pos += 1
while (pos < text.length && text[pos] !== char) {
pos += char === '"' && text[pos] === '\\' ? 2 : 1
}
pos += 1
continue
}
if (char === '[' || char === '{') {
depth += 1
} else if (char === ']' || char === '}') {
depth = Math.max(0, depth - 1)
}
pos += 1
}
return { depth, multiline }
}
// Indices of the top-level `openai_base_url` lines: those before the first
// `[table]` header, outside comments and multi-line values.
function topLevelEntries(lines: Line[]): { index: number; ours: boolean }[] {
const entries: { index: number; ours: boolean }[] = []
let state: ValueState = { depth: 0, multiline: null }
for (let index = 0; index < lines.length; index += 1) {
const { text } = lines[index]
let from = 0
if (!state.multiline && state.depth === 0) {
if (text.trimStart().startsWith('[')) {
break
}
if (KEY_PATTERN.test(text)) {
// Ours by marker or by value: Codex may rewrite its config and drop the comment.
entries.push({
index,
ours: text.includes(CODEX_LINK_MARKER) || text.includes(`"${CODEX_LINK_ORIGIN}/backend-api/codex"`)
})
}
from = valueStart(text)
}
state = scanValue(text, from, state)
}
return entries
}
function ownerOf(entries: { ours: boolean }[]): CodexConfigOwner {
if (entries.some(entry => !entry.ours)) {
return 'foreign'
}
return entries.length > 0 ? 'ours' : 'absent'
}
/** Who owns the top-level `openai_base_url` of this config text. */
export function inspectCodexConfig(source: string): CodexConfigOwner {
return ownerOf(topLevelEntries(splitLines(splitBom(source).body)))
}
export interface CodexConfigEdit {
changed: boolean
owner: CodexConfigOwner
text: string
}
/**
* Point Codex at the bridge. Idempotent; a foreign `openai_base_url` leaves the
* text untouched and reports `foreign`.
*
* The line goes on the very first line: top-level keys must precede the first
* table header, and the top of the file is the one position that is always
* top-level without having to understand the values below it.
*/
export function addCodexLinkLine(source: string): CodexConfigEdit {
const { body, bom } = splitBom(source)
const lines = splitLines(body)
const entries = topLevelEntries(lines)
const owner = ownerOf(entries)
if (owner === 'foreign') {
return { changed: false, owner, text: source }
}
if (owner === 'ours') {
const [first, ...duplicates] = entries
if (duplicates.length === 0 && lines[first.index].text === CODEX_LINK_LINE) {
return { changed: false, owner, text: source }
}
// A stale copy of our line (older address) or an accidental duplicate:
// normalize to exactly one current line, in place.
const drop = new Set(duplicates.map(entry => entry.index))
lines[first.index] = { ...lines[first.index], text: CODEX_LINK_LINE }
return { changed: true, owner, text: bom + joinLines(lines.filter((_line, index) => !drop.has(index))) }
}
const eol = body.includes('\r\n') ? '\r\n' : '\n'
return { changed: true, owner: 'ours', text: `${bom}${CODEX_LINK_LINE}${eol}${body}` }
}
/** Remove our line (and only ours). A foreign key is left exactly as it was. */
export function removeCodexLinkLine(source: string): CodexConfigEdit {
const { body, bom } = splitBom(source)
const lines = splitLines(body)
const entries = topLevelEntries(lines)
const drop = new Set(entries.filter(entry => entry.ours).map(entry => entry.index))
const remaining = ownerOf(entries.filter(entry => !entry.ours))
if (drop.size === 0) {
return { changed: false, owner: remaining, text: source }
}
return { changed: true, owner: remaining, text: bom + joinLines(lines.filter((_line, index) => !drop.has(index))) }
}
// ── Preference file ─────────────────────────────────────────────────────────
export interface CodexLinkSettings {
enabled: boolean
/** Every field of the stored object, so a rewrite keeps what it did not set. */
stored: Record<string, unknown>
}
/** Missing, unreadable or malformed means ON: the feature is on by default. */
export function parseCodexLinkSettings(raw: null | string | undefined): CodexLinkSettings {
if (typeof raw !== 'string') {
return { enabled: true, stored: {} }
}
try {
const parsed = JSON.parse(raw)
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
return { enabled: true, stored: {} }
}
return { enabled: parsed.enabled !== false, stored: parsed as Record<string, unknown> }
} catch {
return { enabled: true, stored: {} }
}
}
export function serializeCodexLinkSettings(stored: Record<string, unknown>, enabled: boolean): string {
const kapsam = typeof stored.kapsam === 'string' && stored.kapsam ? stored.kapsam : 'ide'
return `${JSON.stringify({ ...stored, enabled, kapsam }, null, 2)}\n`
}
// ── Bridge configuration ────────────────────────────────────────────────────
/** `bridge.json`, rewritten on every apply. */
export function codexBridgeConfig() {
return {
port: CODEX_LINK_PORT,
chatgpt_backend: 'https://chatgpt.com',
routes: {},
nine_router_catalog: { enabled: false },
pano_kanca_user: false,
ozet_kopru: { enabled: true, kapsam: 'hepsi', openai_modeli: 'gpt-6-luna', dongu_siniri: 3 }
}
}
export function serializeCodexBridgeConfig(): string {
return `${JSON.stringify(codexBridgeConfig(), null, 2)}\n`
}
// ── Bridge command line ─────────────────────────────────────────────────────
export interface CodexBridgeLaunch {
bridgeHome: string
bridgeScript: string
hermesHome: string
}
/** Arguments after the interpreter — identical for the spawn and the login item. */
export function codexBridgeArgs({ bridgeHome, bridgeScript, hermesHome }: CodexBridgeLaunch): string[] {
return [bridgeScript, '--home', bridgeHome, '--hermes-home', hermesHome]
}
// A trailing backslash would escape the closing quote (`"C:\dir\"`).
function quoted(value: string): string {
return `"${value.replace(/[\\/]+$/, '')}"`
}
/** `"<pythonw>" "<bridge.py>" --home "<data>" --hermes-home "<agent>"` */
export function codexBridgeCommandLine(python: string, launch: CodexBridgeLaunch): string {
const [script, homeFlag, home, hermesFlag, hermes] = codexBridgeArgs(launch)
return `${quoted(python)} ${quoted(script)} ${homeFlag} ${quoted(home)} ${hermesFlag} ${quoted(hermes)}`
}
/**
* A tiny windowless launcher for the login item, used when the direct command
* would not fit a Run value. It runs the bridge exactly as the direct command
* would: same script, same arguments, the script's folder first on sys.path so
* its helper modules import.
*/
export function codexBridgeLauncherSource(launch: CodexBridgeLaunch): string {
const argv = JSON.stringify(codexBridgeArgs(launch))
return [
'# Written by AITURK IDE (Settings > Codex Baglama). Regenerated on every sync; do not edit.',
'import os',
'import runpy',
'import sys',
'',
`ARGV = ${argv}`,
'',
'sys.argv = list(ARGV)',
'sys.path.insert(0, os.path.dirname(ARGV[0]))',
"runpy.run_path(ARGV[0], run_name='__main__')",
''
].join('\n')
}
export interface CodexAutostartPlan {
command: string
/** Launcher file to write first; null when the direct command is used. */
launcher: { path: string; source: string } | null
}
/**
* The login-item command. Prefer the direct bridge command; fall back to the
* launcher when that exceeds the Run-value limit. Returns null when not even
* the launcher form fits (nothing is registered rather than something that
* silently never starts).
*/
export function codexAutostartPlan(
python: string,
launch: CodexBridgeLaunch,
launcherPath: string
): CodexAutostartPlan | null {
const direct = codexBridgeCommandLine(python, launch)
if (direct.length <= CODEX_LINK_RUN_COMMAND_MAX) {
return { command: direct, launcher: null }
}
const viaLauncher = `${quoted(python)} ${quoted(launcherPath)}`
if (viaLauncher.length > CODEX_LINK_RUN_COMMAND_MAX) {
return null
}
return { command: viaLauncher, launcher: { path: launcherPath, source: codexBridgeLauncherSource(launch) } }
}
+39
View File
@@ -0,0 +1,39 @@
/**
* Codex Link ("Codex Bağlama") — the shape that crosses the IPC bridge.
*
* Types only, so the renderer can import them without pulling main-process
* code into its program. See codex-link.ts for what the fields mean in action.
*/
export type CodexLinkIdeState = 'anahtar-yok' | 'kapali' | 'tamam'
/** Stable codes; the renderer owns the wording. Detail goes to the desktop log. */
export type CodexLinkError =
| 'autostart-failed'
| 'bridge-config-failed'
| 'bridge-missing'
| 'bridge-start-failed'
| 'config-write-failed'
| 'settings-write-failed'
export interface CodexLinkStatus {
enabled: boolean
/** Codex's `config.toml` exists on this machine. */
codexFound: boolean
/** The IDE's own Python environment exists (first-run setup finished). */
venvReady: boolean
/** Codex already uses another bridge; nothing of its setup is touched. */
foreign: boolean
bridgeRunning: boolean
/** From the bridge's status endpoint; null while it is not answering. */
ideDurum: CodexLinkIdeState | null
ideModel: null | number
ozet: boolean | null
lastError: CodexLinkError | null
}
export interface CodexLinkPatch {
enabled?: boolean
/** "Sync now": re-run the apply without changing the preference. */
sync?: boolean
}
+403
View File
@@ -0,0 +1,403 @@
import { describe, expect, it } from 'vitest'
import { CODEX_BRIDGE_START_BUDGET_MS, type CodexLinkDeps, type CodexLinkPaths, createCodexLink } from './codex-link'
import {
CODEX_LINK_LINE,
CODEX_LINK_STATUS_URL,
CODEX_LINK_STOP_URL,
codexBridgeArgs,
codexBridgeConfig
} from './codex-link-config'
const PATHS: CodexLinkPaths = {
settingsFile: 'D:\\ide\\codex-baglama.json',
bridgeHome: 'D:\\ide\\codex-bridge',
hermesHome: 'D:\\ide\\agent',
bridgeScript: 'D:\\app\\codex-bridge\\bridge.py',
pythonw: 'D:\\ide\\agent\\venv\\Scripts\\pythonw.exe',
python: 'D:\\ide\\agent\\venv\\Scripts\\python.exe',
codexConfig: 'D:\\home\\.codex\\config.toml'
}
const BRIDGE_JSON = `${PATHS.bridgeHome}\\bridge.json`
const BACKUP = `${PATHS.codexConfig}.aiturk-yedek`
const STATUS_OK = { ok: true, surum: '1', ide_durum: 'tamam', ide_model: 7, ozet: true }
const USER_CONFIG = 'model = "gpt-6"\r\n\r\n[features]\r\nweb = true\r\n'
interface World {
/** What the machine looks like; mutated by the fake effects. */
files: Map<string, string>
/** Is something answering on the bridge port? */
listening: boolean
/** Version the listening bridge reports before any spawn; a spawn brings the current one. */
staleVersion: null | string
/** Does a spawn bring the bridge up? `false` = it never answers. */
spawnWorks: boolean
/** Spawned bridge only starts answering after this many status probes. */
probesUntilUp: number
autostart: null | string
regFails: boolean
/** Every observable effect, in order. */
events: string[]
clock: number
}
function createWorld(patch: Partial<World> & { files?: Map<string, string> } = {}) {
const world: World = {
autostart: null,
clock: 0,
events: [],
files: new Map([
[PATHS.codexConfig, USER_CONFIG],
[PATHS.bridgeScript, '# bridge'],
[PATHS.pythonw, ''],
[PATHS.python, '']
]),
listening: false,
staleVersion: null,
probesUntilUp: 0,
regFails: false,
spawnWorks: true,
...patch
}
let spawned = false
let probesSinceSpawn = 0
const deps: CodexLinkDeps = {
paths: PATHS,
exists: file => world.files.has(file),
readText: file => world.files.get(file) ?? null,
writeTextAtomic: (file, text) => {
world.events.push(`write ${file}`)
world.files.set(file, text)
},
backupOnce: (source, target) => {
if (!world.files.has(target)) {
world.events.push(`backup ${target}`)
world.files.set(target, world.files.get(source) ?? '')
}
},
spawnBridge: (command, args, env) => {
world.events.push(`spawn ${command} ${JSON.stringify(args)} ${JSON.stringify(env)}`)
spawned = true
probesSinceSpawn = 0
world.staleVersion = null
},
request: async (method, url) => {
if (method === 'GET' && url === CODEX_LINK_STATUS_URL) {
if (spawned && world.spawnWorks && !world.listening) {
probesSinceSpawn += 1
if (probesSinceSpawn > world.probesUntilUp) {
world.listening = true
}
}
if (world.listening) {
world.events.push('status ok')
return world.staleVersion ? { ...STATUS_OK, surum: world.staleVersion } : STATUS_OK
}
return null
}
if (method === 'POST' && url === CODEX_LINK_STOP_URL) {
world.events.push('stop')
if (!world.listening) {
return null
}
world.listening = false
return { ok: true }
}
throw new Error(`unexpected request ${method} ${url}`)
},
setAutostart: async command => {
world.events.push('autostart set')
if (world.regFails) {
throw new Error('reg add exited 1')
}
world.autostart = command
},
clearAutostart: async () => {
world.events.push('autostart clear')
world.autostart = null
},
sleep: async ms => {
world.clock += ms
},
now: () => world.clock,
log: () => undefined
}
return { link: createCodexLink(deps), world }
}
const writes = (world: World) => world.events.filter(event => event.startsWith('write '))
const spawns = (world: World) => world.events.filter(event => event.startsWith('spawn '))
// Effects that change the machine (a status probe does not).
const effects = (world: World) => world.events.filter(event => event !== 'status ok')
describe('applyCodexLink', () => {
it('touches nothing when Codex is not installed', async () => {
const { link, world } = createWorld()
world.files.delete(PATHS.codexConfig)
const status = await link.apply()
expect(status).toMatchObject({ enabled: true, codexFound: false, bridgeRunning: false, lastError: null })
expect(effects(world)).toEqual([])
expect(world.autostart).toBeNull()
})
it('touches nothing when Codex already uses another bridge', async () => {
const foreign = 'openai_base_url = "http://127.0.0.1:8799/backend-api/codex"\n[features]\n'
const { link, world } = createWorld({ listening: true })
world.files.set(PATHS.codexConfig, foreign)
const status = await link.apply({ sync: true })
expect(status).toMatchObject({ enabled: true, codexFound: true, foreign: true, bridgeRunning: true })
expect(effects(world)).toEqual([])
expect(world.files.get(PATHS.codexConfig)).toBe(foreign)
})
it('waits for the IDE setup: no venv, no changes', async () => {
const { link, world } = createWorld()
world.files.delete(PATHS.pythonw)
world.files.delete(PATHS.python)
const status = await link.apply()
expect(status).toMatchObject({ enabled: true, codexFound: true, venvReady: false, foreign: false })
expect(effects(world)).toEqual([])
})
it('brings everything up in the safe order', async () => {
const { link, world } = createWorld({ probesUntilUp: 2 })
const status = await link.apply()
expect(status).toEqual({
enabled: true,
codexFound: true,
venvReady: true,
foreign: false,
bridgeRunning: true,
ideDurum: 'tamam',
ideModel: 7,
ozet: true,
lastError: null
})
const env = { AITURK_CODEX_BRIDGE_HOME: PATHS.bridgeHome, HERMES_HOME: PATHS.hermesHome }
const args = codexBridgeArgs({
bridgeHome: PATHS.bridgeHome,
bridgeScript: PATHS.bridgeScript,
hermesHome: PATHS.hermesHome
})
// bridge.json → start → verified answer → config.toml (backed up) → login item
expect(world.events).toEqual([
`write ${BRIDGE_JSON}`,
`spawn ${PATHS.pythonw} ${JSON.stringify(args)} ${JSON.stringify(env)}`,
'status ok',
`backup ${BACKUP}`,
`write ${PATHS.codexConfig}`,
'autostart set'
])
expect(JSON.parse(world.files.get(BRIDGE_JSON) ?? '')).toEqual(codexBridgeConfig())
expect(world.files.get(PATHS.codexConfig)).toBe(`${CODEX_LINK_LINE}\r\n${USER_CONFIG}`)
expect(world.files.get(BACKUP)).toBe(USER_CONFIG)
expect(world.autostart).toBe(
`"${PATHS.pythonw}" "${PATHS.bridgeScript}" --home "${PATHS.bridgeHome}" --hermes-home "${PATHS.hermesHome}"`
)
})
it('never points Codex at a bridge that did not come up', async () => {
const { link, world } = createWorld({ spawnWorks: false })
const status = await link.apply()
expect(status).toMatchObject({ enabled: true, bridgeRunning: false, lastError: 'bridge-start-failed' })
expect(spawns(world)).toHaveLength(1)
expect(world.files.get(PATHS.codexConfig)).toBe(USER_CONFIG)
expect(world.files.has(BACKUP)).toBe(false)
expect(world.autostart).toBeNull()
expect(writes(world)).toEqual([`write ${BRIDGE_JSON}`])
// The start poll is bounded by its one budget.
expect(world.clock).toBe(CODEX_BRIDGE_START_BUDGET_MS)
})
it('takes its own line back out when the bridge can no longer be started', async () => {
const { link, world } = createWorld({ spawnWorks: false })
world.files.set(PATHS.codexConfig, `${CODEX_LINK_LINE}\r\n${USER_CONFIG}`)
const status = await link.apply()
expect(status.lastError).toBe('bridge-start-failed')
expect(world.files.get(PATHS.codexConfig)).toBe(USER_CONFIG)
})
it('does not start a second bridge when one is already listening', async () => {
const { link, world } = createWorld({ listening: true })
await link.apply()
await link.apply({ sync: true })
expect(spawns(world)).toEqual([])
expect(world.files.get(PATHS.codexConfig)).toBe(`${CODEX_LINK_LINE}\r\n${USER_CONFIG}`)
// The config is written once; the login item is refreshed on every apply.
expect(writes(world).filter(event => event === `write ${PATHS.codexConfig}`)).toHaveLength(1)
expect(world.events.filter(event => event === 'autostart set')).toHaveLength(2)
})
it('replaces a bridge left running by an older IDE build', async () => {
const { link, world } = createWorld({ listening: true, staleVersion: '0' })
const status = await link.apply()
expect(world.events.indexOf('stop')).toBeGreaterThanOrEqual(0)
expect(spawns(world)).toHaveLength(1)
expect(world.events.indexOf('stop')).toBeLessThan(world.events.findIndex(event => event.startsWith('spawn')))
expect(status.bridgeRunning).toBe(true)
expect(world.files.get(PATHS.codexConfig)).toBe(`${CODEX_LINK_LINE}\r\n${USER_CONFIG}`)
})
it('never overwrites the first backup', async () => {
const { link, world } = createWorld({ listening: true })
world.files.set(BACKUP, 'the original original')
await link.apply()
expect(world.files.get(BACKUP)).toBe('the original original')
})
it('falls back to python.exe when pythonw.exe is missing', async () => {
const { link, world } = createWorld()
world.files.delete(PATHS.pythonw)
await link.apply()
expect(spawns(world)[0]).toContain(`spawn ${PATHS.python} `)
expect(world.autostart?.startsWith(`"${PATHS.python}" `)).toBe(true)
})
it('reports a missing bridge script instead of starting anything', async () => {
const { link, world } = createWorld()
world.files.delete(PATHS.bridgeScript)
const status = await link.apply()
expect(status.lastError).toBe('bridge-missing')
expect(effects(world)).toEqual([])
})
it('surfaces a registry failure but keeps the working link', async () => {
const { link, world } = createWorld({ listening: true, regFails: true })
const status = await link.apply()
expect(status).toMatchObject({ bridgeRunning: true, lastError: 'autostart-failed' })
expect(world.files.get(PATHS.codexConfig)).toBe(`${CODEX_LINK_LINE}\r\n${USER_CONFIG}`)
})
it('undoes everything when turned off', async () => {
const { link, world } = createWorld({ listening: true })
await link.apply()
world.events.length = 0
const status = await link.apply({ enabled: false })
expect(status).toMatchObject({ enabled: false, codexFound: true, bridgeRunning: false, lastError: null })
expect(world.events).toEqual([
`write ${PATHS.settingsFile}`,
`write ${PATHS.codexConfig}`,
'autostart clear',
'stop'
])
expect(JSON.parse(world.files.get(PATHS.settingsFile) ?? '')).toEqual({ enabled: false, kapsam: 'ide' })
// Byte for byte what the user had before the feature touched it.
expect(world.files.get(PATHS.codexConfig)).toBe(USER_CONFIG)
expect(world.autostart).toBeNull()
expect(world.listening).toBe(false)
})
it('stays off across applies and comes back when turned on', async () => {
const { link, world } = createWorld()
world.files.set(PATHS.settingsFile, '{"enabled": false, "kapsam": "ide", "not": "ours"}')
expect(await link.apply()).toMatchObject({ enabled: false, bridgeRunning: false })
expect(spawns(world)).toEqual([])
expect(world.files.get(PATHS.codexConfig)).toBe(USER_CONFIG)
expect(await link.apply({ enabled: true })).toMatchObject({ enabled: true, bridgeRunning: true })
expect(JSON.parse(world.files.get(PATHS.settingsFile) ?? '')).toEqual({
enabled: true,
kapsam: 'ide',
not: 'ours'
})
expect(world.files.get(PATHS.codexConfig)).toBe(`${CODEX_LINK_LINE}\r\n${USER_CONFIG}`)
})
it('does not stop a bridge that serves a foreign config when turned off', async () => {
const foreign = 'openai_base_url = "http://127.0.0.1:8799/backend-api/codex"\n'
const { link, world } = createWorld({ listening: true })
world.files.set(PATHS.codexConfig, foreign)
const status = await link.apply({ enabled: false })
expect(status).toMatchObject({ enabled: false, foreign: true, bridgeRunning: true })
expect(world.events).not.toContain('stop')
expect(world.files.get(PATHS.codexConfig)).toBe(foreign)
expect(world.listening).toBe(true)
})
it('runs concurrent applies one after another', async () => {
const { link, world } = createWorld({ probesUntilUp: 3 })
const [first, second] = await Promise.all([link.apply(), link.apply({ enabled: false })])
expect(first.enabled).toBe(true)
expect(second).toMatchObject({ enabled: false, bridgeRunning: false })
expect(world.files.get(PATHS.codexConfig)).toBe(USER_CONFIG)
expect(world.autostart).toBeNull()
})
})
describe('getStatus', () => {
it('reads without changing anything', async () => {
const { link, world } = createWorld({ listening: true })
const status = await link.getStatus()
expect(status).toMatchObject({
enabled: true,
codexFound: true,
venvReady: true,
foreign: false,
bridgeRunning: true,
ideDurum: 'tamam',
ideModel: 7,
ozet: true
})
expect(effects(world)).toEqual([])
})
it('reports a bridge that is not answering', async () => {
const { link, world } = createWorld()
expect(await link.getStatus()).toMatchObject({ bridgeRunning: false, ideDurum: null, ideModel: null, ozet: null })
expect(effects(world)).toEqual([])
})
})
+525
View File
@@ -0,0 +1,525 @@
/**
* 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_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) {
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
}
}
+70
View File
@@ -36,6 +36,7 @@ import { WebSocket as RelayWebSocket } from 'ws'
import { classifyActiveRuntime, needsPackagedRuntimeUpgrade } from './active-runtime-state'
import { createAiturkCliRunner, createAiturkLogin } from './aiturk-login'
import { createCodexLink, createNodeCodexLinkDeps } from './codex-link'
import { createMobileBackendResolver } from './mobile-backend'
import {
createMobileLink,
@@ -13091,6 +13092,9 @@ async function startHermes() {
// accumulated count of the resolved episode.
bootstrapRepairAttempt = 0
// The local backend is up, so the venv the Codex bridge runs on exists.
syncCodexLinkOnBoot()
return {
baseUrl,
mode: 'local',
@@ -17182,6 +17186,72 @@ ipcMain.handle('hermes:quick-entry:settings:set', async (_event, patch) => {
return applyQuickEntrySettings(next)
})
// Codex Link ("Codex Bağlama"): lets the OpenAI Codex on this machine use the
// IDE's models through a small local bridge. Main owns the preference file, the
// bridge process, Codex's config line and the login item; Settings reads the
// resulting state and asks for changes over IPC. Windows only (the login item is
// a Run-key value). All decisions live in electron/codex-link.ts.
const AITURK_IDE_DATA_ROOT = path.dirname(HERMES_HOME)
const codexLink = createCodexLink(
createNodeCodexLinkDeps(
{
settingsFile: path.join(AITURK_IDE_DATA_ROOT, 'codex-baglama.json'),
bridgeHome: path.join(AITURK_IDE_DATA_ROOT, 'codex-bridge'),
hermesHome: HERMES_HOME,
bridgeScript: IS_PACKAGED
? path.join(process.resourcesPath, 'codex-bridge', 'bridge.py')
: path.join(APP_ROOT, 'assets', 'codex-bridge', 'bridge.py'),
pythonw: path.join(VENV_ROOT, 'Scripts', 'pythonw.exe'),
python: getVenvPython(VENV_ROOT),
codexConfig: path.join(process.env.CODEX_HOME || path.join(app.getPath('home'), '.codex'), 'config.toml')
},
rememberLog
)
)
let codexLinkBootSynced = false
// Once per launch, after the local backend is ready. Never allowed to affect
// startup: it is not awaited and every failure ends in the log.
function syncCodexLinkOnBoot() {
if (!IS_WINDOWS || codexLinkBootSynced) {
return
}
codexLinkBootSynced = true
void codexLink.apply().catch(error => rememberLog(`[codex-link] boot sync failed: ${error?.message ?? error}`))
}
// Only the primary window's top-level frame may read or change the link.
function assertCodexLinkSender(event) {
const contents = mainWindow && !mainWindow.isDestroyed() ? mainWindow.webContents : null
if (
!IS_WINDOWS ||
!contents ||
event.sender !== contents ||
(event.senderFrame && event.senderFrame !== contents.mainFrame)
) {
throw new Error('codex-link:unavailable')
}
}
ipcMain.handle('aiturk:codex-link:get', async event => {
assertCodexLinkSender(event)
return codexLink.getStatus()
})
ipcMain.handle('aiturk:codex-link:set', async (event, patch) => {
assertCodexLinkSender(event)
return codexLink.apply({
enabled: typeof patch?.enabled === 'boolean' ? patch.enabled : undefined,
sync: patch?.sync === true
})
})
// Quick window → main → PRIMARY renderer. We never submit here: the renderer
// owns the one prompt-submit path, and forwarding keeps it that way. The
// payload is `{ target, text }` — target routing (current chat / a picked
+10
View File
@@ -290,6 +290,16 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
// Opens the "Telefon bağlantısı" window (electron/mobile-host.mjs).
open: () => ipcRenderer.invoke('aiturk:mobile:open')
},
// "Codex Bağlama" (electron/codex-link.ts). Windows only: absent elsewhere,
// so Settings does not offer a page that cannot do anything.
...(process.platform === 'win32'
? {
codexLink: {
get: () => ipcRenderer.invoke('aiturk:codex-link:get'),
set: patch => ipcRenderer.invoke('aiturk:codex-link:set', patch)
}
}
: {}),
mcpOauth: {
// One-shot loopback listener for MCP OAuth against remote backends: bind
// on this machine, hand redirectUri to mcp.servers.oauth.start, then wait
+8
View File
@@ -299,6 +299,14 @@
"*.dll",
"manifest.json"
]
},
{
"from": "assets/codex-bridge",
"to": "codex-bridge",
"filter": [
"*.py",
"!test_*.py"
]
}
]
},
@@ -0,0 +1,193 @@
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { I18nProvider } from '@/i18n/context'
import type { CodexLinkStatus } from '../../../electron/codex-link-types'
import { CodexLinkSettings } from './codex-link-settings'
const notifyError = vi.hoisted(() => vi.fn())
vi.mock('@/store/notifications', () => ({ notifyError }))
const RUNNING: CodexLinkStatus = {
enabled: true,
codexFound: true,
venvReady: true,
foreign: false,
bridgeRunning: true,
ideDurum: 'tamam',
ideModel: 12,
ozet: true,
lastError: null
}
const get = vi.fn<() => Promise<CodexLinkStatus>>()
const set = vi.fn<(patch: { enabled?: boolean; sync?: boolean }) => Promise<CodexLinkStatus>>()
function renderPage(locale: 'en' | 'tr' = 'tr') {
return render(
<I18nProvider configClient={null} initialLocale={locale}>
<CodexLinkSettings />
</I18nProvider>
)
}
function statusTexts() {
return screen.getAllByRole('listitem').map(item => item.textContent)
}
beforeEach(() => {
;(window as unknown as { hermesDesktop: unknown }).hermesDesktop = { codexLink: { get, set } }
})
afterEach(() => {
cleanup()
vi.resetAllMocks()
delete (window as unknown as { hermesDesktop?: unknown }).hermesDesktop
})
describe('Codex Link settings', () => {
it('explains the feature and shows a healthy link in Turkish', async () => {
get.mockResolvedValue(RUNNING)
renderPage()
expect(await screen.findByText('Köprü çalışıyor.')).toBeTruthy()
expect(statusTexts()).toEqual(['Bu bilgisayarda Codex bulundu.', 'Köprü çalışıyor.', "Codex'e 12 model eklendi."])
expect(
screen.getByText(
"AITURK IDE'deki modelleri ve comboları bu bilgisayardaki Codex'e ekler; modeller arasında geçerken sohbet bağlamını korur."
)
).toBeTruthy()
expect(screen.getByText("Codex'te yeni modelleri görmek için Codex'i yeniden açın.")).toBeTruthy()
expect(screen.getByRole('switch', { name: 'Codex Bağlama' }).getAttribute('aria-checked')).toBe('true')
expect(set).not.toHaveBeenCalled()
})
it('renders in English for other locales', async () => {
get.mockResolvedValue(RUNNING)
renderPage('en')
expect(await screen.findByText('The bridge is running.')).toBeTruthy()
expect(statusTexts()).toEqual([
'Codex was found on this computer.',
'The bridge is running.',
'12 models added to Codex.'
])
expect(screen.getByRole('button', { name: 'Sync now' })).toBeTruthy()
})
it('says only that Codex is missing when it is not installed', async () => {
get.mockResolvedValue({ ...RUNNING, codexFound: false, bridgeRunning: false, ideDurum: null, ideModel: null })
renderPage()
expect(await screen.findByText('Bu bilgisayarda Codex bulunamadı.')).toBeTruthy()
expect(statusTexts()).toEqual(['Bu bilgisayarda Codex bulunamadı.'])
})
it('says another bridge is in charge and does not claim ours is running', async () => {
get.mockResolvedValue({ ...RUNNING, foreign: true })
renderPage()
expect(await screen.findByText(/başka bir köprü kullanıyor/)).toBeTruthy()
expect(statusTexts()).toEqual([
'Bu bilgisayarda Codex bulundu.',
'Bu bilgisayarda Codex başka bir köprü kullanıyor; ayarına dokunulmadı.'
])
})
it('says it is waiting for the IDE setup', async () => {
get.mockResolvedValue({ ...RUNNING, venvReady: false, bridgeRunning: false, ideDurum: null, ideModel: null })
renderPage()
expect(await screen.findByText('IDE kurulumu tamamlanınca etkinleşecek.')).toBeTruthy()
expect(screen.queryByText('Köprü çalışmıyor.')).toBeNull()
})
it('asks for a sign-in when the bridge has no IDE session', async () => {
get.mockResolvedValue({ ...RUNNING, ideDurum: 'anahtar-yok', ideModel: 0 })
renderPage()
expect(await screen.findByText('IDE oturumu bulunamadı, giriş yapın.')).toBeTruthy()
expect(screen.queryByText(/model eklendi/)).toBeNull()
})
it('shows a stopped bridge together with the last error', async () => {
get.mockResolvedValue({
...RUNNING,
bridgeRunning: false,
ideDurum: null,
ideModel: null,
lastError: 'bridge-start-failed'
})
renderPage()
expect(await screen.findByText('Köprü çalışmıyor.')).toBeTruthy()
expect(screen.getByText('Köprü başlatılamadı; Codex ayarına dokunulmadı.')).toBeTruthy()
})
it('turns the link off through the bridge and adopts the reply', async () => {
get.mockResolvedValue(RUNNING)
set.mockResolvedValue({ ...RUNNING, enabled: false, bridgeRunning: false, ideDurum: null, ideModel: null })
renderPage()
await screen.findByText('Köprü çalışıyor.')
fireEvent.click(screen.getByRole('switch', { name: 'Codex Bağlama' }))
expect(set).toHaveBeenCalledWith({ enabled: false })
expect(await screen.findByText('Codex Bağlama kapalı. Codex kendi ayarlarıyla çalışıyor.')).toBeTruthy()
expect(screen.getByRole('switch', { name: 'Codex Bağlama' }).getAttribute('aria-checked')).toBe('false')
})
it('rolls the switch back and reports when the change fails', async () => {
get.mockResolvedValue(RUNNING)
set.mockRejectedValue(new Error('codex-link:unavailable'))
renderPage()
await screen.findByText('Köprü çalışıyor.')
fireEvent.click(screen.getByRole('switch', { name: 'Codex Bağlama' }))
await waitFor(() => expect(notifyError).toHaveBeenCalledWith(expect.any(Error), 'Codex Bağlama değiştirilemedi.'))
expect(screen.getByRole('switch', { name: 'Codex Bağlama' }).getAttribute('aria-checked')).toBe('true')
expect(screen.getByText('Köprü çalışıyor.')).toBeTruthy()
})
it('syncs on demand and keeps the button disabled while it runs', async () => {
get.mockResolvedValue({ ...RUNNING, bridgeRunning: false, ideDurum: null, ideModel: null })
let finish: (status: CodexLinkStatus) => void = () => undefined
set.mockReturnValue(
new Promise<CodexLinkStatus>(resolve => {
finish = resolve
})
)
renderPage()
await screen.findByText('Köprü çalışmıyor.')
fireEvent.click(screen.getByRole('button', { name: 'Şimdi eşitle' }))
expect(set).toHaveBeenCalledTimes(1)
expect(set).toHaveBeenCalledWith({ sync: true })
const pending = screen.getByRole('button', { name: 'Eşitleniyor…' }) as HTMLButtonElement
expect(pending.disabled).toBe(true)
fireEvent.click(pending)
expect(set).toHaveBeenCalledTimes(1)
finish(RUNNING)
expect(await screen.findByText('Köprü çalışıyor.')).toBeTruthy()
expect((screen.getByRole('button', { name: 'Şimdi eşitle' }) as HTMLButtonElement).disabled).toBe(false)
})
it('offers nothing to click where the desktop bridge is absent', () => {
delete (window as unknown as { hermesDesktop?: unknown }).hermesDesktop
renderPage()
expect(screen.getByText('Codex Bağlama yalnızca Windows masaüstü uygulamasında kullanılabilir.')).toBeTruthy()
expect(screen.queryByRole('switch')).toBeNull()
expect(get).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,233 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Switch } from '@/components/ui/switch'
import { type CodexLinkCopy, useCodexLinkCopy } from '@/i18n/codex-link'
import { triggerHaptic } from '@/lib/haptics'
import { AlertTriangle, Check, CircleIcon, Link, RefreshCw } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { notifyError } from '@/store/notifications'
import type { CodexLinkPatch, CodexLinkStatus } from '../../../electron/codex-link-types'
import { EmptyState, ListRow, ListRowSkeleton, SettingsContent, SettingsSection } from './primitives'
type Tone = 'muted' | 'ok' | 'warn'
interface StatusLine {
id: string
text: string
tone: Tone
}
/**
* What the user needs to know, top-down, stopping at the first fact that makes
* the rest moot (no Codex → nothing else to say; another bridge → ours is not
* involved; setup pending → the bridge cannot be running yet).
*/
export function codexLinkStatusLines(status: CodexLinkStatus, copy: CodexLinkCopy): StatusLine[] {
const lines: StatusLine[] = []
const error = status.lastError ? [{ id: 'error', text: copy.errors[status.lastError], tone: 'warn' as const }] : []
if (!status.codexFound) {
return [{ id: 'codex', text: copy.codexMissing, tone: 'muted' }, ...error]
}
lines.push({ id: 'codex', text: copy.codexFound, tone: 'ok' })
if (status.foreign) {
return [...lines, { id: 'foreign', text: copy.foreign, tone: 'warn' }, ...error]
}
if (!status.enabled) {
return [...lines, { id: 'off', text: copy.off, tone: 'muted' }, ...error]
}
if (!status.venvReady) {
return [...lines, { id: 'venv', text: copy.venvPending, tone: 'muted' }, ...error]
}
if (!status.bridgeRunning) {
return [...lines, { id: 'bridge', text: copy.bridgeStopped, tone: 'warn' }, ...error]
}
lines.push({ id: 'bridge', text: copy.bridgeRunning, tone: 'ok' })
if (status.ideDurum === 'anahtar-yok') {
lines.push({ id: 'ide', text: copy.signIn, tone: 'warn' })
} else if (status.ideDurum === 'tamam' && status.ideModel !== null) {
lines.push({ id: 'ide', text: copy.models(status.ideModel), tone: 'ok' })
}
return [...lines, ...error]
}
const TONE_ICON = { muted: CircleIcon, ok: Check, warn: AlertTriangle } as const
const TONE_CLASS: Record<Tone, string> = {
muted: 'text-(--ui-text-tertiary)',
ok: 'text-(--ui-text-secondary)',
warn: 'text-amber-500/90'
}
// Phone-sized hit area around the compact switch: the visible control keeps the
// design-system size, the label that wraps it is the 44px target.
const TOUCH_TARGET = 'flex min-h-11 min-w-11 cursor-pointer items-center @2xl:justify-end'
/**
* Settings → Codex Link. Main owns everything real (the preference file, the
* bridge, Codex's config, the login item); this page reads the state it reports
* and asks for changes, adopting whatever comes back as the truth.
*/
export function CodexLinkSettings() {
const copy = useCodexLinkCopy()
const bridge = typeof window === 'undefined' ? undefined : window.hermesDesktop?.codexLink
const [status, setStatus] = useState<CodexLinkStatus | null>(null)
const [loadFailed, setLoadFailed] = useState(false)
const [busy, setBusy] = useState<'sync' | 'toggle' | null>(null)
// A slow initial read must not overwrite the answer to a newer change.
const generation = useRef(0)
useEffect(() => {
if (!bridge) {
return
}
const ticket = generation.current
bridge.get().then(
next => {
if (generation.current === ticket) {
setStatus(next)
}
},
() => {
if (generation.current === ticket) {
setLoadFailed(true)
}
}
)
}, [bridge])
const change = useCallback(
async (patch: CodexLinkPatch, kind: 'sync' | 'toggle') => {
if (!bridge || busy) {
return
}
const before = status
generation.current += 1
setBusy(kind)
setLoadFailed(false)
// The switch moves at once; main's reply (or the rollback) has the last word.
if (typeof patch.enabled === 'boolean' && before) {
setStatus({ ...before, enabled: patch.enabled })
}
try {
setStatus(await bridge.set(patch))
} catch (error) {
setStatus(before)
notifyError(error, copy.saveFailed)
} finally {
setBusy(null)
}
},
[bridge, busy, copy.saveFailed, status]
)
if (!bridge) {
return (
<SettingsContent>
<EmptyState description={copy.unavailable} title={copy.title} />
</SettingsContent>
)
}
const lines = status ? codexLinkStatusLines(status, copy) : []
return (
<SettingsContent>
<SettingsSection icon={Link} title={copy.title}>
<ListRow
action={
<label className={TOUCH_TARGET}>
<Switch
aria-label={copy.title}
checked={status?.enabled ?? true}
disabled={!status || busy !== null}
onCheckedChange={enabled => {
triggerHaptic('selection')
void change({ enabled }, 'toggle')
}}
/>
</label>
}
description={copy.description}
title={copy.title}
/>
{!status && !loadFailed && <ListRowSkeleton wide />}
{loadFailed && !status && (
<p className="py-3 text-[length:var(--conversation-caption-font-size)] text-amber-500/90" role="alert">
{copy.loadFailed}
</p>
)}
{status && (
<ListRow
below={
<ul aria-live="polite" className="mt-1.5 grid gap-1.5">
{lines.map(line => {
const Icon = TONE_ICON[line.tone]
return (
<li
className={cn(
'flex min-w-0 items-start gap-2 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height)',
TONE_CLASS[line.tone]
)}
data-tone={line.tone}
key={line.id}
>
<Icon aria-hidden className="mt-0.5 size-3.5 shrink-0" />
<span className="min-w-0 break-words">{line.text}</span>
</li>
)
})}
</ul>
}
title={copy.statusTitle}
wide
/>
)}
<ListRow
action={
<Button
className="min-h-11 w-full @2xl:w-auto"
disabled={!status || busy !== null}
onClick={() => {
triggerHaptic('open')
void change({ sync: true }, 'sync')
}}
type="button"
variant="secondary"
>
<RefreshCw />
{busy === 'sync' ? copy.syncing : copy.sync}
</Button>
}
description={copy.syncDesc}
title={copy.syncTitle}
/>
<p className="pt-2 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
{copy.restartNote}
</p>
</SettingsSection>
</SettingsContent>
)
}
+21 -1
View File
@@ -9,6 +9,7 @@ import { Tip } from '@/components/ui/tooltip'
import { getHermesConfigDefaults, getHermesConfigRecord, saveHermesConfig } from '@/hermes'
import { useI18n } from '@/i18n'
import { useAiturkCopy } from '@/i18n/aiturk'
import { useCodexLinkCopy } from '@/i18n/codex-link'
import { triggerHaptic } from '@/lib/haptics'
import {
Archive,
@@ -20,6 +21,7 @@ import {
Info,
Keyboard,
KeyRound,
Link,
Package,
RefreshCw,
Search,
@@ -46,6 +48,7 @@ import { SKILLS_ROUTE } from '../routes'
import { AboutSettings } from './about-settings'
import { AppearanceSettings } from './appearance-settings'
import { BillingSettings } from './billing'
import { CodexLinkSettings } from './codex-link-settings'
import { ConfigSettings } from './config-settings'
import { SECTIONS } from './constants'
import { GatewaySettings } from './gateway-settings'
@@ -68,6 +71,7 @@ const SETTINGS_VIEWS: readonly SettingsViewId[] = [
'keys',
'notifications',
'billing',
'codex-link',
'plugins',
'sessions',
'about'
@@ -76,6 +80,7 @@ const SETTINGS_VIEWS: readonly SettingsViewId[] = [
export function SettingsView({ onClose, onConfigSaved, onMainModelChanged }: SettingsPageProps) {
const { t } = useI18n()
const aiturkCopy = useAiturkCopy()
const codexLinkCopy = useCodexLinkCopy()
const navigate = useNavigate()
const { hash, pathname, search } = useLocation()
@@ -267,6 +272,19 @@ export function SettingsView({ onClose, onConfigSaved, onMainModelChanged }: Set
}
]
: []),
// "Codex Bağlama": present only where the desktop bridge offers it
// (Windows). A stale ?tab=codex-link elsewhere renders its own notice.
...(window.hermesDesktop?.codexLink
? [
{
active: activeView === 'codex-link',
icon: Link,
id: 'codex-link',
label: codexLinkCopy.nav,
onSelect: () => setActiveView('codex-link')
}
]
: []),
{
active: activeView === 'keybinds',
icon: Keyboard,
@@ -320,7 +338,7 @@ export function SettingsView({ onClose, onConfigSaved, onMainModelChanged }: Set
onSelect: () => setActiveView('about')
}
],
[activeView, keysView, providerView, t, aiturkCopy, setActiveView, openProviderView, openKeysView]
[activeView, keysView, providerView, t, aiturkCopy, codexLinkCopy, setActiveView, openProviderView, openKeysView]
)
// Type-to-search: printable keystrokes on the Settings surface (outside any
@@ -439,6 +457,8 @@ export function SettingsView({ onClose, onConfigSaved, onMainModelChanged }: Set
<BillingSettings />
) : activeView === 'plugins' ? (
<PluginsSettings />
) : activeView === 'codex-link' ? (
<CodexLinkSettings />
) : (
<SessionsSettings />
)
+1
View File
@@ -7,6 +7,7 @@ import type { EnvVarInfo } from '@/types/hermes'
export type SettingsView =
| 'about'
| 'billing'
| 'codex-link'
| 'connections'
| 'gateway'
| 'keybinds'
+8
View File
@@ -1,6 +1,7 @@
import type { GatewayWsUrlResult } from '@hermes/shared'
import type { TranslucencyState } from '@hermes/shared/translucency'
import type { CodexLinkPatch, CodexLinkStatus } from '../electron/codex-link-types'
import type { PoolLimits } from '../electron/pool-limits'
import type { WakeIndicatorState } from './lib/wake-indicator'
@@ -335,6 +336,13 @@ declare global {
aiturkMobile?: {
open: () => Promise<{ ok: true }>
}
/** "Codex Bağlama" (electron/codex-link.ts): lets the Codex on this machine
* use the IDE's models. `get` only reads; `set` applies and returns the
* state that actually resulted. Windows only. */
codexLink?: {
get: () => Promise<CodexLinkStatus>
set: (patch: CodexLinkPatch) => Promise<CodexLinkStatus>
}
/** One-shot loopback callback listener for MCP OAuth against remote
* backends (electron/mcp-oauth-callback-ipc.ts): bind on THIS machine,
* pass redirectUri as client_redirect_uri to mcp.servers.oauth.start,
+80
View File
@@ -0,0 +1,80 @@
import type { CodexLinkError } from '../../electron/codex-link-types'
import { useI18n } from './context'
// Copy for Settings → Codex Link ("Codex Bağlama"). Kept beside the other
// AITURK-distribution strings (see ./aiturk): Turkish for `tr`, English for
// every other locale.
const en = {
nav: 'Codex Link',
title: 'Codex Link',
description:
'Adds the models and combos from AITURK IDE to the Codex on this computer and keeps the conversation context when you switch between models.',
unavailable: 'Codex Link is only available in the Windows desktop app.',
loadFailed: 'Could not read the Codex Link status.',
saveFailed: 'Could not change Codex Link.',
statusTitle: 'Status',
off: 'Codex Link is off. Codex runs with its own settings.',
codexFound: 'Codex was found on this computer.',
codexMissing: 'Codex was not found on this computer.',
foreign: 'Codex on this computer uses another bridge; its settings were not touched.',
venvPending: 'It will turn on once the IDE setup is complete.',
bridgeRunning: 'The bridge is running.',
bridgeStopped: 'The bridge is not running.',
models: (count: number) => (count === 1 ? '1 model added to Codex.' : `${count} models added to Codex.`),
signIn: 'No IDE session was found; sign in.',
errors: {
'autostart-failed': 'Could not set the bridge to start with Windows.',
'bridge-config-failed': 'Could not write the bridge settings.',
'bridge-missing': 'The bridge files are missing from this installation.',
'bridge-start-failed': 'The bridge could not be started; the Codex settings were left unchanged.',
'config-write-failed': 'Could not write the Codex settings file.',
'settings-write-failed': 'Could not save this setting.'
} satisfies Record<CodexLinkError, string>,
syncTitle: 'Sync now',
syncDesc: 'Checks the bridge and the Codex settings again.',
sync: 'Sync now',
syncing: 'Syncing…',
restartNote: 'Reopen Codex to see new models there.'
}
const tr: typeof en = {
nav: 'Codex Bağlama',
title: 'Codex Bağlama',
description:
"AITURK IDE'deki modelleri ve comboları bu bilgisayardaki Codex'e ekler; modeller arasında geçerken sohbet bağlamını korur.",
unavailable: 'Codex Bağlama yalnızca Windows masaüstü uygulamasında kullanılabilir.',
loadFailed: 'Codex Bağlama durumu okunamadı.',
saveFailed: 'Codex Bağlama değiştirilemedi.',
statusTitle: 'Durum',
off: 'Codex Bağlama kapalı. Codex kendi ayarlarıyla çalışıyor.',
codexFound: 'Bu bilgisayarda Codex bulundu.',
codexMissing: 'Bu bilgisayarda Codex bulunamadı.',
foreign: 'Bu bilgisayarda Codex başka bir köprü kullanıyor; ayarına dokunulmadı.',
venvPending: 'IDE kurulumu tamamlanınca etkinleşecek.',
bridgeRunning: 'Köprü çalışıyor.',
bridgeStopped: 'Köprü çalışmıyor.',
models: (count: number) => `Codex'e ${count} model eklendi.`,
signIn: 'IDE oturumu bulunamadı, giriş yapın.',
errors: {
'autostart-failed': 'Köprü Windows açılışında başlayacak şekilde ayarlanamadı.',
'bridge-config-failed': 'Köprü ayarları yazılamadı.',
'bridge-missing': 'Köprü dosyaları bu kurulumda bulunamadı.',
'bridge-start-failed': 'Köprü başlatılamadı; Codex ayarına dokunulmadı.',
'config-write-failed': 'Codex ayar dosyası yazılamadı.',
'settings-write-failed': 'Bu ayar kaydedilemedi.'
},
syncTitle: 'Şimdi eşitle',
syncDesc: 'Köprüyü ve Codex ayarını yeniden denetler.',
sync: 'Şimdi eşitle',
syncing: 'Eşitleniyor…',
restartNote: "Codex'te yeni modelleri görmek için Codex'i yeniden açın."
}
export type CodexLinkCopy = typeof en
export function useCodexLinkCopy(): CodexLinkCopy {
const { locale } = useI18n()
return locale === 'tr' ? tr : en
}
+2 -1
View File
@@ -6,7 +6,8 @@ export default defineConfig({
include: [
'electron/mobile-*.test.ts',
'electron/first-run-setup*.test.ts',
'electron/primary-backend-startup.test.ts'
'electron/primary-backend-startup.test.ts',
'electron/codex-link*.test.ts'
]
}
})