diff --git a/src/main/codex/codex-config-settings-removal.ts b/src/main/codex/codex-config-settings-removal.ts index 7210d921ebe..d562917c6e1 100644 --- a/src/main/codex/codex-config-settings-removal.ts +++ b/src/main/codex/codex-config-settings-removal.ts @@ -1,11 +1,4 @@ -import { - createTomlLineScanState, - getTomlTableHeader, - isTomlStructuralLine, - updateTomlLineScanState -} from './config-toml-line-scan' -import { parseTomlKeyPath, parseTomlTableHeaderPath } from './config-toml-key-path' -import { tuiStructuredKey } from './codex-config-settings-upsert' +import { scanStructuredSettingLines } from './config-toml-promoted-setting-values' export function removePromotedSettingsFromContent( content: string, @@ -15,59 +8,11 @@ export function removePromotedSettingsFromContent( return content } const lines = content.split('\n') - const indexes: number[] = [] - let state = createTomlLineScanState() - let inPreamble = true - let tuiTableSeen = false - let tuiBodyActive = false - - for (let index = 0; index < lines.length; index += 1) { - const line = lines[index] ?? '' - if (isTomlStructuralLine(state)) { - const header = getTomlTableHeader(line) - if (header) { - const table = parseTomlTableHeaderPath(header) - tuiBodyActive = - table !== null && - !table.isArray && - table.segments.length === 1 && - table.segments[0] === 'tui' && - !tuiTableSeen - tuiTableSeen ||= tuiBodyActive - inPreamble = false - state = updateTomlLineScanState(state, line) - continue - } - const parsed = parseTomlKeyPath(line) - if (parsed && line[parsed.end] === '=') { - const structuredKey = getStructuredKey(parsed.segments, inPreamble, tuiBodyActive) - if (structuredKey && removals.has(structuredKey)) { - indexes.push(index) - } - } - } - state = updateTomlLineScanState(state, line) - } - + const indexes = scanStructuredSettingLines(lines) + .filter((setting) => removals.has(setting.structuredKey)) + .map((setting) => setting.index) for (const index of indexes.toReversed()) { lines.splice(index, 1) } return lines.join('\n') } - -function getStructuredKey( - segments: string[], - inPreamble: boolean, - tuiBodyActive: boolean -): string | null { - if (inPreamble && segments.length === 1) { - return segments[0] ?? null - } - if (inPreamble && segments.length === 2 && segments[0] === 'tui') { - return segments[1] ? tuiStructuredKey(segments[1]) : null - } - if (tuiBodyActive && segments.length === 1) { - return segments[0] ? tuiStructuredKey(segments[0]) : null - } - return null -} diff --git a/src/main/codex/codex-config-settings-upsert.ts b/src/main/codex/codex-config-settings-upsert.ts index 1706d76da79..a73701a3886 100644 --- a/src/main/codex/codex-config-settings-upsert.ts +++ b/src/main/codex/codex-config-settings-upsert.ts @@ -9,45 +9,36 @@ import { } from './config-toml-line-scan' import { parseTomlKeyPath, parseTomlTableHeaderPath } from './config-toml-key-path' -const TUI_STRUCTURED_PREFIX = 'tui.' - -// Why: promoted [tui] settings are keyed by structured path (tui.) so their +// Why: promoted table settings are keyed by structured path (.) so their // baseline/update entries can never collide with a top-level key of the same name. -export function tuiStructuredKey(key: string): string { - return `${TUI_STRUCTURED_PREFIX}${key}` +export function tableStructuredKey(table: string, key: string): string { + return `${table}.${key}` } -export function isTuiStructuredKey(structuredKey: string): boolean { - return structuredKey.startsWith(TUI_STRUCTURED_PREFIX) -} - -export function tuiKeyFromStructuredKey(structuredKey: string): string { - return structuredKey.slice(TUI_STRUCTURED_PREFIX.length) -} - -// Why: promoted updates arrive keyed by structured path; the preamble and [tui] -// regions are disjoint, so a mixed batch (e.g. /model + a status-line change) -// composes in one rewrite — top-level keys land in the preamble, tui. -// entries wherever the [tui] placement rule puts them. +// Why: promoted updates arrive keyed by structured path; the preamble and each +// table's region are disjoint, so a mixed batch (e.g. /model + a status-line +// change) composes in one rewrite — top-level keys land in the preamble, +//
. entries wherever the table placement rule puts them. export function upsertPromotedSettingsInContent( content: string, updates: Map ): string { const topLevelUpdates = new Map() - const tuiUpdates = new Map() - for (const [key, raw] of updates) { - if (isTuiStructuredKey(key)) { - tuiUpdates.set(tuiKeyFromStructuredKey(key), raw) - } else { - topLevelUpdates.set(key, raw) + const tableUpdates = new Map>() + for (const [structuredKey, raw] of updates) { + const dot = structuredKey.indexOf('.') + if (dot === -1) { + topLevelUpdates.set(structuredKey, raw) + continue } + const table = structuredKey.slice(0, dot) + const keys = tableUpdates.get(table) ?? new Map() + tableUpdates.set(table, keys.set(structuredKey.slice(dot + 1), raw)) } - let result = content - if (topLevelUpdates.size > 0) { - result = upsertTopLevelSettingsInContent(result, topLevelUpdates) - } - if (tuiUpdates.size > 0) { - result = upsertTuiSettingsInContent(result, tuiUpdates) + let result = + topLevelUpdates.size > 0 ? upsertTopLevelSettingsInContent(content, topLevelUpdates) : content + for (const [table, keys] of tableUpdates) { + result = upsertTableSettingsInContent(result, table, keys) } return result } @@ -114,19 +105,14 @@ type TablePlacementScan = { } /** - * Upserts promoted `[tui]` keys (keyed by bare name) into the system config, - * placing each per the design's total placement rule: replace an existing key - * in place keeping its form; else insert bare into the first `[tui]` body; else - * dotted in the preamble beside existing dotted `tui.*` keys; else create one - * `[tui]` table at EOF for every key that reaches that branch. Rendering follows - * the destination — bare inside a table, dotted in the preamble — so no `tui` - * table is ever defined twice. + * Upserts keys (by bare name) into a single-segment table, placing each per the + * design's total placement rule: replace an existing key in place keeping its + * form; else insert bare into the first `[
]` body; else dotted in the + * preamble beside existing dotted `
.*` keys; else create one `[
]` + * table at EOF for every key that reaches that branch. Rendering follows the + * destination — bare inside a table, dotted in the preamble — so no table is + * ever defined twice. */ -export function upsertTuiSettingsInContent(content: string, updates: Map): string { - return upsertTableSettingsInContent(content, 'tui', updates) -} - -/** Same placement rule as `upsertTuiSettingsInContent`, for any single-segment table. */ export function upsertTableSettingsInContent( content: string, table: string, diff --git a/src/main/codex/codex-shared-server-fix.test.ts b/src/main/codex/codex-shared-server-fix.test.ts new file mode 100644 index 00000000000..43fdc7a00ad --- /dev/null +++ b/src/main/codex/codex-shared-server-fix.test.ts @@ -0,0 +1,216 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { ProcessResult, ProcessSpec } from '../../shared/child-process/run-process' + +const mocks = vi.hoisted(() => ({ + runProcess: vi.fn<(spec: ProcessSpec) => Promise>(), + probeCodexSharedServer: vi.fn<(home: string) => Promise<'live' | 'absent' | 'unknown'>>(), + syncMirror: vi.fn<() => void>() +})) +vi.mock('./codex-config-mirror', () => ({ + syncSystemConfigIntoManagedCodexHome: mocks.syncMirror +})) +vi.mock('../../shared/child-process/run-process', () => ({ runProcess: mocks.runProcess })) +vi.mock('./codex-shared-server-probe', () => ({ + probeCodexSharedServer: mocks.probeCodexSharedServer +})) + +import { + disableCodexSharedServerAutoStart, + disableCodexSharedServerAutoStartOnOrcaMirror, + readFeatureEnabled, + resolveCodexSharedServerBinary, + stopCodexSharedServer +} from './codex-shared-server-fix' + +const FILE_NAME = process.platform === 'win32' ? 'codex.exe' : 'codex' +const LIST_OFF = 'apps stable true\ndaemon_auto_start experimental false\n' +const LIST_ON = 'daemon_auto_start experimental true\n' +let home: string + +function installPackage(...segments: string[]): string { + const binary = join(home, 'packages', ...segments, FILE_NAME) + mkdirSync(join(binary, '..'), { recursive: true }) + writeFileSync(binary, '') + return binary +} + +function result(overrides: Partial = {}): ProcessResult { + return { code: 0, signal: null, stdout: '', stderr: '', timedOut: false, ...overrides } +} + +beforeEach(() => { + vi.resetAllMocks() + home = mkdtempSync(join(tmpdir(), 'codex-shared-server-fix-')) +}) + +afterEach(() => { + rmSync(home, { recursive: true, force: true }) +}) + +describe('resolveCodexSharedServerBinary', () => { + it('prefers the server package over the legacy standalone one', async () => { + const server = installPackage('app-server-daemon', 'current', 'bin') + installPackage('standalone', 'current') + expect(await resolveCodexSharedServerBinary(home)).toBe(server) + }) + + it('falls back to the legacy standalone layout', async () => { + const legacy = installPackage('standalone', 'current') + expect(await resolveCodexSharedServerBinary(home)).toBe(legacy) + }) + + it('is null when no package is installed', async () => { + expect(await resolveCodexSharedServerBinary(home)).toBeNull() + }) +}) + +describe('readFeatureEnabled', () => { + it.each([ + [LIST_OFF, false], + [LIST_ON, true], + ['apps stable true\n', null], + ['daemon_auto_start experimental maybe\n', null], + ['daemon_auto_start_v2 experimental false\n', null] + ])('reads %j as %s', (stdout, expected) => { + expect(readFeatureEnabled(stdout, 'daemon_auto_start')).toBe(expected) + }) +}) + +describe('disableCodexSharedServerAutoStart', () => { + it("runs the server's own Codex on the pane's home, then reads the setting back", async () => { + const binary = installPackage('app-server-daemon', 'current', 'bin') + mocks.runProcess + .mockResolvedValueOnce(result()) + .mockResolvedValueOnce(result({ stdout: LIST_OFF })) + + expect(await disableCodexSharedServerAutoStart(home)).toBe(true) + + const [disable, list] = mocks.runProcess.mock.calls.map(([spec]) => spec) + expect(disable).toMatchObject({ + program: binary, + args: ['features', 'disable', 'daemon_auto_start'], + cwd: dirname(binary), + env: expect.objectContaining({ CODEX_HOME: home }), + timeoutMs: 15_000 + }) + expect(disable?.maxOutputBytes).toBeLessThanOrEqual(64 * 1024) + expect(list).toMatchObject({ program: binary, args: ['features', 'list'] }) + }) + + it.each([ + ['managed config keeps it on', [result(), result({ stdout: LIST_ON })]], + ['the read-back times out', [result(), result({ timedOut: true, code: null })]], + ['the read-back fails', [result(), result({ code: 1, stdout: LIST_OFF })]] + ])('fails when %s', async (_label, results) => { + installPackage('app-server-daemon', 'current', 'bin') + for (const next of results) { + mocks.runProcess.mockResolvedValueOnce(next) + } + expect(await disableCodexSharedServerAutoStart(home)).toBe(false) + }) + + it.each([ + ['exits non-zero, as an old Codex without the feature does', result({ code: 1 })], + ['times out', result({ timedOut: true, code: null })] + ])('skips the read-back when the write %s', async (_label, write) => { + installPackage('app-server-daemon', 'current', 'bin') + mocks.runProcess + .mockResolvedValueOnce(write) + .mockResolvedValueOnce(result({ stdout: LIST_OFF })) + expect(await disableCodexSharedServerAutoStart(home)).toBe(false) + expect(mocks.runProcess).toHaveBeenCalledTimes(1) + }) + + it('fails without running anything when the binary is missing', async () => { + expect(await disableCodexSharedServerAutoStart(home)).toBe(false) + expect(mocks.runProcess).not.toHaveBeenCalled() + }) + + it('fails when the spawn throws', async () => { + installPackage('app-server-daemon', 'current', 'bin') + mocks.runProcess.mockRejectedValueOnce(new Error('ENOENT')) + expect(await disableCodexSharedServerAutoStart(home)).toBe(false) + }) +}) + +describe('disableCodexSharedServerAutoStartOnOrcaMirror', () => { + it('runs the launch mirror pass before and after the write', async () => { + installPackage('app-server-daemon', 'current', 'bin') + const order: string[] = [] + mocks.syncMirror.mockImplementation(() => order.push('sync')) + mocks.runProcess.mockImplementation(async (spec) => { + order.push((spec.args ?? []).join(' ')) + return result({ stdout: LIST_OFF }) + }) + + expect(await disableCodexSharedServerAutoStartOnOrcaMirror(home)).toBe(true) + expect(order).toEqual(['sync', 'features disable daemon_auto_start', 'features list', 'sync']) + expect(mocks.syncMirror).toHaveBeenCalledWith() + }) + + it('still reports the pane fixed when a mirror pass throws', async () => { + installPackage('app-server-daemon', 'current', 'bin') + vi.spyOn(console, 'warn').mockImplementation(() => {}) + mocks.syncMirror.mockImplementation(() => { + throw new Error('EACCES') + }) + mocks.runProcess + .mockResolvedValueOnce(result()) + .mockResolvedValueOnce(result({ stdout: LIST_OFF })) + + expect(await disableCodexSharedServerAutoStartOnOrcaMirror(home)).toBe(true) + expect(mocks.syncMirror).toHaveBeenCalledTimes(2) + }) + + it('reports the write failure even though both passes ran', async () => { + installPackage('app-server-daemon', 'current', 'bin') + mocks.runProcess.mockResolvedValueOnce(result({ code: 1 })) + + expect(await disableCodexSharedServerAutoStartOnOrcaMirror(home)).toBe(false) + expect(mocks.syncMirror).toHaveBeenCalledTimes(2) + }) +}) + +describe('stopCodexSharedServer', () => { + it('succeeds only once a fresh probe finds no server', async () => { + installPackage('app-server-daemon', 'current', 'bin') + mocks.runProcess.mockResolvedValueOnce(result()) + mocks.probeCodexSharedServer.mockResolvedValueOnce('absent') + + expect(await stopCodexSharedServer(home)).toBe(true) + expect(mocks.runProcess.mock.calls[0]?.[0]).toMatchObject({ + args: ['app-server', 'daemon', 'stop'], + env: expect.objectContaining({ CODEX_HOME: home }) + }) + }) + + it('fails when the server is still live after a clean exit', async () => { + installPackage('app-server-daemon', 'current', 'bin') + mocks.runProcess.mockResolvedValueOnce(result()) + mocks.probeCodexSharedServer.mockResolvedValueOnce('live') + expect(await stopCodexSharedServer(home)).toBe(false) + }) + + it('succeeds on a failed exit once the server is gone', async () => { + installPackage('app-server-daemon', 'current', 'bin') + mocks.runProcess.mockResolvedValueOnce(result({ code: 1 })) + mocks.probeCodexSharedServer.mockResolvedValueOnce('absent') + expect(await stopCodexSharedServer(home)).toBe(true) + }) + + it('fails when the probe cannot tell whether the server is gone', async () => { + installPackage('app-server-daemon', 'current', 'bin') + mocks.runProcess.mockResolvedValueOnce(result()) + mocks.probeCodexSharedServer.mockResolvedValueOnce('unknown') + expect(await stopCodexSharedServer(home)).toBe(false) + }) + + it('fails without running anything when the binary is missing and the server is live', async () => { + mocks.probeCodexSharedServer.mockResolvedValueOnce('live') + expect(await stopCodexSharedServer(home)).toBe(false) + expect(mocks.runProcess).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/codex/codex-shared-server-fix.ts b/src/main/codex/codex-shared-server-fix.ts new file mode 100644 index 00000000000..30c1a2f5d77 --- /dev/null +++ b/src/main/codex/codex-shared-server-fix.ts @@ -0,0 +1,119 @@ +import { stat } from 'node:fs/promises' +import { dirname, join } from 'node:path' +import { runProcess } from '../../shared/child-process/run-process' +import { + CODEX_DISABLE_SHARED_SERVER_ARGS, + CODEX_SHARED_SERVER_FEATURE_KEY, + CODEX_STOP_SHARED_SERVER_ARGS +} from '../../shared/codex-shared-server-command' +import { probeCodexSharedServer } from './codex-shared-server-probe' +import { syncSystemConfigIntoManagedCodexHome } from './codex-config-mirror' + +const COMMAND_TIMEOUT_MS = 15_000 +// Why longer: Codex lets running turns drain for up to 60 s by default, then forces after 10 s. +const STOP_TIMEOUT_MS = 75_000 +const MAX_OUTPUT_BYTES = 64 * 1024 + +/** + * The Codex CLI Codex installed under this home's `packages/` (the server's + * package, else the legacy standalone one), so its lifecycle commands match + * the server's version rather than whatever `codex` is on PATH. + */ +export async function resolveCodexSharedServerBinary(codexHome: string): Promise { + const fileName = process.platform === 'win32' ? 'codex.exe' : 'codex' + for (const packageName of ['app-server-daemon', 'standalone']) { + const current = join(codexHome, 'packages', packageName, 'current') + for (const candidate of [join(current, 'bin', fileName), join(current, fileName)]) { + try { + if ((await stat(candidate)).isFile()) { + return candidate + } + } catch { + // Not installed in this layout. + } + } + } + return null +} + +/** The command's stdout when it exited 0 in time; otherwise null. */ +async function runCodex( + codexHome: string, + args: readonly string[], + timeoutMs: number +): Promise { + const program = await resolveCodexSharedServerBinary(codexHome) + if (!program) { + return null + } + try { + const result = await runProcess({ + program, + args, + // Why: the program's own folder exists whenever it resolved. + cwd: dirname(program), + env: { ...process.env, CODEX_HOME: codexHome }, + timeoutMs, + maxOutputBytes: MAX_OUTPUT_BYTES + }) + return result.code === 0 && !result.timedOut ? result.stdout : null + } catch { + return null + } +} + +/** Codex's `features list` row for `key`: `name stage true|false`. */ +export function readFeatureEnabled(stdout: string, key: string): boolean | null { + for (const line of stdout.split(/\r?\n/)) { + const columns = line.trim().split(/\s+/) + if (columns[0] === key) { + const enabled = columns.at(-1) + return enabled === 'true' ? true : enabled === 'false' ? false : null + } + } + return null +} + +/** + * Turns off server sharing in the pane's own home; true only once Codex reads + * it back as off. Orca promotes the change from its mirror home to ~/.codex. + */ +export async function disableCodexSharedServerAutoStart(codexHome: string): Promise { + if ((await runCodex(codexHome, CODEX_DISABLE_SHARED_SERVER_ARGS, COMMAND_TIMEOUT_MS)) === null) { + return false + } + // Why read back: managed config can pin the feature on even when the write exits 0. + const list = await runCodex(codexHome, ['features', 'list'], COMMAND_TIMEOUT_MS) + return list !== null && readFeatureEnabled(list, CODEX_SHARED_SERVER_FEATURE_KEY) === false +} + +/** + * Turn off for a pane on Orca's mirror home. The pass before records the key in + * the promotion baseline (an older Orca's baseline lacks it, which would keep + * the write as a conflict); the pass after promotes the write to ~/.codex. + */ +export async function disableCodexSharedServerAutoStartOnOrcaMirror( + mirrorHome: string +): Promise { + syncOrcaMirrorBestEffort() + const off = await disableCodexSharedServerAutoStart(mirrorHome) + syncOrcaMirrorBestEffort() + return off +} + +/** The mirror pass a terminal launch runs; its outcome is reported by the sync itself. */ +function syncOrcaMirrorBestEffort(): void { + try { + syncSystemConfigIntoManagedCodexHome() + } catch (error) { + // Why: the write alone still fixes Orca's tabs; ~/.codex then catches up at the next launch. + console.warn('[codex-shared-server] mirror sync around Turn off failed:', error) + } +} + +/** Stops this home's shared server; true only once it is proven gone. */ +export async function stopCodexSharedServer(codexHome: string): Promise { + // Why the probe decides: only it shows whether this home's server is actually gone. + await runCodex(codexHome, CODEX_STOP_SHARED_SERVER_ARGS, STOP_TIMEOUT_MS) + return (await probeCodexSharedServer(codexHome)) === 'absent' +} diff --git a/src/main/codex/codex-shared-server-pane.test.ts b/src/main/codex/codex-shared-server-pane.test.ts new file mode 100644 index 00000000000..ef05bf5f006 --- /dev/null +++ b/src/main/codex/codex-shared-server-pane.test.ts @@ -0,0 +1,196 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { CodexPaneAccountRecord } from './codex-pane-account-registry-types' + +const mocks = vi.hoisted(() => ({ + getCodexPaneAccount: vi.fn<(ptyId: string) => CodexPaneAccountRecord | null>(), + probeCodexSharedServer: vi.fn<(home: string) => Promise<'live' | 'absent' | 'unknown'>>(), + getProcessTableSnapshot: vi.fn(), + readWindowsProcessTable: vi.fn(), + isShellStartupEnvProbeSupported: vi.fn<() => boolean>() +})) +vi.mock('./codex-pane-account-registry', () => ({ + getCodexPaneAccount: mocks.getCodexPaneAccount +})) +vi.mock('./codex-shared-server-probe', () => ({ + probeCodexSharedServer: mocks.probeCodexSharedServer +})) +vi.mock('./codex-home-paths', () => ({ + getSystemCodexHomePath: () => '/home/me/.codex', + resolveOrcaManagedCodexHomePath: () => '/data/orca/codex-runtime-home/home' +})) +vi.mock('../../shared/process-table-snapshot-reader', () => ({ + getProcessTableSnapshot: mocks.getProcessTableSnapshot +})) +vi.mock('../windows/windows-process-table', () => ({ + readWindowsProcessTable: mocks.readWindowsProcessTable +})) +vi.mock('../pty/shell-startup-env', () => ({ + isShellStartupEnvProbeSupported: mocks.isShellStartupEnvProbeSupported +})) + +import { + findPaneCodexCommandLine, + isCodexPaneOnOrcaMirrorHome, + isPaneCodexOnSharedServer, + resolveCodexPaneHome +} from './codex-shared-server-pane' + +const SHELL = 100 + +function row(pid: number, ppid: number, command: string) { + return { pid, ppid, stat: 'S+', command } +} + +beforeEach(() => { + vi.clearAllMocks() + mocks.isShellStartupEnvProbeSupported.mockReturnValue(true) +}) + +describe('findPaneCodexCommandLine', () => { + it('takes the launcher line, which carries the argv, over its native child', () => { + const rows = [ + row(SHELL, 1, '-bash'), + row(101, SHELL, 'node /usr/lib/node_modules/@openai/codex/bin/codex.js --no-daemon'), + row(102, 101, '/usr/lib/node_modules/@openai/codex/vendor/codex --no-daemon') + ] + expect(findPaneCodexCommandLine(rows, SHELL)).toBe( + 'node /usr/lib/node_modules/@openai/codex/bin/codex.js --no-daemon' + ) + }) + + it('ignores the shared server a Windows Codex spawns as its own child', () => { + const rows = [ + row(SHELL, 1, 'cmd.exe'), + row(101, SHELL, 'C:\\npm\\codex.exe'), + row(102, 101, '"C:\\h\\codex.exe" app-server --listen unix:// --managed-daemon'), + row(103, 101, '"C:\\h\\codex.exe" app-server daemon pid-update-loop') + ] + expect(findPaneCodexCommandLine(rows, SHELL)).toBe('C:\\npm\\codex.exe') + }) + + it('ignores Codex outside this pane and non-Codex children', () => { + const rows = [row(SHELL, 1, '-zsh'), row(101, SHELL, 'vim'), row(201, 1, 'codex')] + expect(findPaneCodexCommandLine(rows, SHELL)).toBeNull() + }) +}) + +describe('resolveCodexPaneHome', () => { + it.each([ + [{ selectionKey: 'host', accountId: null, homeRoute: 'real-home' }, '/home/me/.codex'], + [ + { + selectionKey: 'host', + accountId: null, + homeRoute: 'real-home', + environmentHomeOverride: { codexHome: '/custom/codex' } + }, + '/custom/codex' + ], + [ + { + selectionKey: 'host', + accountId: null, + homeRoute: 'custom-home', + shellStartupHomeOverride: { home: '/home/me', codexHome: '/rc/codex' } + }, + '/rc/codex' + ], + [{ selectionKey: 'host', accountId: null, homeRoute: 'custom-home' }, null], + [{ selectionKey: 'host', accountId: 'acct', homeRoute: 'account-home' }, null], + [{ selectionKey: 'wsl:Ubuntu', accountId: null, homeRoute: 'real-home' }, null], + [{ selectionKey: 'host', accountId: null }, null] + ] satisfies [CodexPaneAccountRecord, string | null][])( + 'resolves %o to %s', + (record, expected) => { + mocks.getCodexPaneAccount.mockReturnValue(record) + expect(resolveCodexPaneHome('pty')).toBe(expected) + } + ) + + // Why: only Windows still routes the default host lane through the promoted mirror. + it.each([ + [false, '/data/orca/codex-runtime-home/home'], + [true, null] + ])( + 'names the mirror for a legacy shared-home pane only off the real-home route (probe %s)', + (probeSupported, expected) => { + mocks.isShellStartupEnvProbeSupported.mockReturnValue(probeSupported) + mocks.getCodexPaneAccount.mockReturnValue({ + selectionKey: 'host', + accountId: null, + homeRoute: 'shared-home' + }) + expect(resolveCodexPaneHome('pty')).toBe(expected) + } + ) + + it('names no home for a pane with no launch record', () => { + mocks.getCodexPaneAccount.mockReturnValue(null) + expect(resolveCodexPaneHome('pty')).toBeNull() + }) +}) + +describe('isCodexPaneOnOrcaMirrorHome', () => { + it.each([ + ['a Windows shared-home pane', 'shared-home', false, true], + ['a retired shared-home pane off Windows', 'shared-home', true, false], + ['a real-home pane', 'real-home', false, false], + ['a custom-home pane', 'custom-home', false, false] + ] as const)('%s → %s', (_label, homeRoute, probeSupported, expected) => { + mocks.isShellStartupEnvProbeSupported.mockReturnValue(probeSupported) + mocks.getCodexPaneAccount.mockReturnValue({ + selectionKey: 'host', + accountId: null, + homeRoute, + environmentHomeOverride: { codexHome: '/custom/codex' } + }) + expect(isCodexPaneOnOrcaMirrorHome('pty')).toBe(expected) + }) +}) + +describe('isPaneCodexOnSharedServer', () => { + beforeEach(() => { + mocks.getCodexPaneAccount.mockReturnValue({ + selectionKey: 'host', + accountId: null, + homeRoute: 'real-home' + }) + mocks.probeCodexSharedServer.mockResolvedValue('live') + const rows = [row(SHELL, 1, '-bash'), row(101, SHELL, 'codex')] + mocks.getProcessTableSnapshot.mockResolvedValue(rows) + mocks.readWindowsProcessTable.mockResolvedValue(rows) + }) + + it('is true for a typed codex while its home has a live server', async () => { + await expect(isPaneCodexOnSharedServer('pty', SHELL)).resolves.toBe(true) + expect(mocks.probeCodexSharedServer).toHaveBeenCalledWith('/home/me/.codex') + }) + + it.each(['absent', 'unknown'] as const)( + 'is false when the pane home server is %s', + async (state) => { + mocks.probeCodexSharedServer.mockResolvedValue(state) + await expect(isPaneCodexOnSharedServer('pty', SHELL)).resolves.toBe(false) + } + ) + + it('is false when Codex runs with --no-daemon, without probing', async () => { + mocks.getProcessTableSnapshot.mockResolvedValue([ + row(SHELL, 1, '-bash'), + row(101, SHELL, 'codex --no-daemon') + ]) + mocks.readWindowsProcessTable.mockResolvedValue([ + row(SHELL, 1, 'cmd.exe'), + row(101, SHELL, 'codex --no-daemon') + ]) + await expect(isPaneCodexOnSharedServer('pty', SHELL)).resolves.toBe(false) + expect(mocks.probeCodexSharedServer).not.toHaveBeenCalled() + }) + + it('is false when the pane home cannot be named, without reading processes', async () => { + mocks.getCodexPaneAccount.mockReturnValue(null) + await expect(isPaneCodexOnSharedServer('pty', SHELL)).resolves.toBe(false) + expect(mocks.getProcessTableSnapshot).not.toHaveBeenCalled() + expect(mocks.readWindowsProcessTable).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/codex/codex-shared-server-pane.ts b/src/main/codex/codex-shared-server-pane.ts new file mode 100644 index 00000000000..3f010995dbb --- /dev/null +++ b/src/main/codex/codex-shared-server-pane.ts @@ -0,0 +1,87 @@ +import { recognizeAgentProcessFromCommandLine } from '../../shared/agent-process-recognition' +import { codexCommandLineJoinsSharedServer } from '../../shared/codex-shared-server-command' +import { + collectDescendantsFromIndex, + getProcessTableIndex, + type ProcessIdentityRow +} from '../../shared/process-table-index' +import { getProcessTableSnapshot } from '../../shared/process-table-snapshot-reader' +import { readWindowsProcessTable } from '../windows/windows-process-table' +import { isShellStartupEnvProbeSupported } from '../pty/shell-startup-env' +import { getSystemCodexHomePath, resolveOrcaManagedCodexHomePath } from './codex-home-paths' +import { getCodexPaneAccount } from './codex-pane-account-registry' +import { probeCodexSharedServer } from './codex-shared-server-probe' + +type CommandRow = ProcessIdentityRow & { command: string } + +/** + * The outermost Codex under the pane's shell. Outermost because a launcher + * (`node …/codex.js`) carries the argv, and on Windows the shared server it + * starts is its own child. + */ +export function findPaneCodexCommandLine( + rows: readonly CommandRow[], + rootPid: number +): string | null { + let outermost: (CommandRow & { depth: number }) | null = null + for (const row of collectDescendantsFromIndex(getProcessTableIndex(rows), rootPid)) { + if ( + (!outermost || row.depth < outermost.depth) && + recognizeAgentProcessFromCommandLine(row.command, { includeHeadlessOneShot: true })?.agent === + 'codex' + ) { + outermost = row + } + } + return outermost?.command ?? null +} + +/** + * The CODEX_HOME this host pane launched with, or null when it cannot be named. + * A CODEX_HOME the user exports later in the pane's shell is not seen. + */ +export function resolveCodexPaneHome(ptyId: string): string | null { + const record = getCodexPaneAccount(ptyId) + if (record?.selectionKey !== 'host') { + return null + } + const customHome = + record.environmentHomeOverride?.codexHome ?? record.shellStartupHomeOverride?.codexHome + switch (record.homeRoute) { + case 'real-home': + return customHome ?? getSystemCodexHomePath() + case 'custom-home': + return customHome ?? null + case 'shared-home': + // Why: off Windows the mirror is retired and never promoted, so a fix there would be reverted. + return isShellStartupEnvProbeSupported() ? null : resolveOrcaManagedCodexHomePath() + // Why: an unnamed home (managed account, WSL, pre-route record) skips the warning rather than probing the wrong server. + case 'account-home': + case 'wsl-home': + case undefined: + return null + } +} + +/** Whether the pane's home is Orca's mirror, whose settings reach ~/.codex only through promotion. */ +export function isCodexPaneOnOrcaMirrorHome(ptyId: string): boolean { + return ( + getCodexPaneAccount(ptyId)?.homeRoute === 'shared-home' && resolveCodexPaneHome(ptyId) !== null + ) +} + +/** Whether the Codex running in this local pane is a client of Codex's shared server. */ +export async function isPaneCodexOnSharedServer(ptyId: string, rootPid: number): Promise { + const codexHome = resolveCodexPaneHome(ptyId) + if (!codexHome) { + return false + } + const rows: readonly CommandRow[] = + process.platform === 'win32' ? await readWindowsProcessTable() : await getProcessTableSnapshot() + const commandLine = findPaneCodexCommandLine(rows, rootPid) + return ( + commandLine !== null && + codexCommandLineJoinsSharedServer(commandLine) && + (await probeCodexSharedServer(codexHome)) === 'live' + ) +} diff --git a/src/main/codex/codex-shared-server-probe.test.ts b/src/main/codex/codex-shared-server-probe.test.ts new file mode 100644 index 00000000000..8d3e5cdc3df --- /dev/null +++ b/src/main/codex/codex-shared-server-probe.test.ts @@ -0,0 +1,164 @@ +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { createServer, type Server } from 'node:net' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { readWindowsProcessCreationTime } = vi.hoisted(() => ({ + readWindowsProcessCreationTime: vi.fn<(pid: number) => number | null>() +})) +vi.mock('../windows/windows-process-table', () => ({ readWindowsProcessCreationTime })) + +import { runProcess } from '../../shared/child-process/run-process' +import { probeCodexSharedServer } from './codex-shared-server-probe' + +const originalPlatform = process.platform +let home: string +let server: Server | null = null + +function setPlatform(platform: NodeJS.Platform): void { + Object.defineProperty(process, 'platform', { configurable: true, value: platform }) +} + +function listen(): Promise { + mkdirSync(join(home, 'app-server-control'), { recursive: true }) + const listening = createServer((socket) => socket.destroy()) + server = listening + return new Promise((resolve) => + listening.listen(join(home, 'app-server-control', 'app-server-control.sock'), resolve) + ) +} + +function close(): Promise { + const listening = server + server = null + return new Promise((resolve) => (listening ? listening.close(() => resolve()) : resolve())) +} + +beforeEach(() => { + // Why /tmp: a unix socket path must fit sun_path, which a macOS $TMPDIR can exceed. + home = mkdtempSync(join(process.platform === 'win32' ? tmpdir() : '/tmp', 'cxh-')) + readWindowsProcessCreationTime.mockReset() +}) + +function errnoError(code: string): NodeJS.ErrnoException { + return Object.assign(new Error(code), { code }) +} + +afterEach(async () => { + vi.restoreAllMocks() + setPlatform(originalPlatform) + await close() + rmSync(home, { recursive: true, force: true }) +}) + +describe.skipIf(process.platform === 'win32')('probeCodexSharedServer on POSIX', () => { + beforeEach(() => setPlatform('darwin')) + + it('is live while the control socket accepts connections', async () => { + await listen() + await expect(probeCodexSharedServer(home)).resolves.toBe('live') + }) + + it('is absent when no socket exists', async () => { + await expect(probeCodexSharedServer(home)).resolves.toBe('absent') + }) + + it('is absent when a crashed server left its socket file behind', async () => { + const socketPath = join(home, 'app-server-control', 'app-server-control.sock') + mkdirSync(join(home, 'app-server-control'), { recursive: true }) + // Why a killed child: only a crash leaves the socket inode with nobody listening. + await runProcess({ + program: process.execPath, + args: [ + '-e', + `require('node:net').createServer().listen(${JSON.stringify(socketPath)}, () => process.kill(process.pid, 'SIGKILL'))` + ], + timeoutMs: 10_000 + }) + expect(existsSync(socketPath)).toBe(true) + await expect(probeCodexSharedServer(home)).resolves.toBe('absent') + }) + + it('is unknown when the socket path cannot be connected to for another reason', async () => { + // Why a file where the directory goes: connect fails with ENOTDIR, which proves nothing. + writeFileSync(join(home, 'app-server-control'), '') + await expect(probeCodexSharedServer(home)).resolves.toBe('unknown') + }) +}) + +describe('probeCodexSharedServer on Windows', () => { + const START_FILETIME = '134352704749372843' + const START_UNIX_MS = 1_790_796_874_937 + + beforeEach(() => { + setPlatform('win32') + vi.spyOn(process, 'kill').mockReturnValue(true) + }) + + function writeRecord(name: string, record: unknown): void { + mkdirSync(join(home, 'app-server-daemon'), { recursive: true }) + writeFileSync( + join(home, 'app-server-daemon', name), + typeof record === 'string' ? record : JSON.stringify(record) + ) + } + + it('is live when the recorded pid still has the recorded creation time', async () => { + writeRecord('daemon.pid', { pid: 27368, processStartTime: START_FILETIME }) + readWindowsProcessCreationTime.mockReturnValue(START_UNIX_MS) + await expect(probeCodexSharedServer(home)).resolves.toBe('live') + expect(readWindowsProcessCreationTime).toHaveBeenCalledWith(27368) + }) + + it('reads the legacy record name', async () => { + writeRecord('app-server.pid', { pid: 27368, processStartTime: START_FILETIME }) + readWindowsProcessCreationTime.mockReturnValue(START_UNIX_MS) + await expect(probeCodexSharedServer(home)).resolves.toBe('live') + }) + + it('is absent when no record exists', async () => { + await expect(probeCodexSharedServer(home)).resolves.toBe('absent') + }) + + it('is absent when the recorded pid is not running', async () => { + writeRecord('daemon.pid', { pid: 27368, processStartTime: START_FILETIME }) + vi.mocked(process.kill).mockImplementation(() => { + throw errnoError('ESRCH') + }) + await expect(probeCodexSharedServer(home)).resolves.toBe('absent') + expect(readWindowsProcessCreationTime).not.toHaveBeenCalled() + }) + + it('is absent when the pid was reused by a later process', async () => { + writeRecord('daemon.pid', { pid: 27368, processStartTime: START_FILETIME }) + readWindowsProcessCreationTime.mockReturnValue(START_UNIX_MS + 60_000) + await expect(probeCodexSharedServer(home)).resolves.toBe('absent') + }) + + it.each([ + [ + 'access to the pid is denied', + () => + vi.mocked(process.kill).mockImplementation(() => { + throw errnoError('EPERM') + }) + ], + [ + 'the creation time cannot be read', + () => readWindowsProcessCreationTime.mockReturnValue(null) + ], + [ + 'the record holds no parseable start time', + () => { + readWindowsProcessCreationTime.mockReturnValue(START_UNIX_MS) + writeRecord('daemon.pid', { pid: 27368, processStartTime: 'Wed Sep 30 15:33:16 2026' }) + } + ], + ['the record is not JSON', () => writeRecord('daemon.pid', '{')] + ])('is unknown when %s', async (_label, arrange) => { + writeRecord('daemon.pid', { pid: 27368, processStartTime: START_FILETIME }) + arrange() + await expect(probeCodexSharedServer(home)).resolves.toBe('unknown') + }) +}) diff --git a/src/main/codex/codex-shared-server-probe.ts b/src/main/codex/codex-shared-server-probe.ts new file mode 100644 index 00000000000..130a2852e21 --- /dev/null +++ b/src/main/codex/codex-shared-server-probe.ts @@ -0,0 +1,99 @@ +import { readFile } from 'node:fs/promises' +import { createConnection } from 'node:net' +import { join } from 'node:path' +import { codexDaemonSocketPath } from './codex-daemon-socket-path-guard' +import { readWindowsProcessCreationTime } from '../windows/windows-process-table' + +/** `absent` only on proof; anything the probe cannot settle is `unknown`. */ +export type CodexSharedServerState = 'live' | 'absent' | 'unknown' + +const CONNECT_TIMEOUT_MS = 1_000 +// Codex's FILETIME epoch (1601) sits this far before the Unix epoch. +const FILETIME_UNIX_EPOCH_OFFSET_MS = 11_644_473_600_000n + +function errorCode(error: unknown): unknown { + return typeof error === 'object' && error !== null && 'code' in error ? error.code : undefined +} + +// Why connect, not stat: a server that crashed leaves its socket file behind. +function probeSocket(socketPath: string): Promise { + return new Promise((resolve) => { + const socket = createConnection({ path: socketPath }) + const settle = (state: CodexSharedServerState): void => { + socket.destroy() + resolve(state) + } + socket.setTimeout(CONNECT_TIMEOUT_MS, () => settle('unknown')) + socket.once('connect', () => settle('live')) + socket.once('error', (error) => { + const code = errorCode(error) + settle(code === 'ECONNREFUSED' || code === 'ENOENT' ? 'absent' : 'unknown') + }) + }) +} + +async function probeWindowsRecord(path: string): Promise { + let text: string + try { + text = await readFile(path, 'utf8') + } catch (error) { + return errorCode(error) === 'ENOENT' ? 'absent' : 'unknown' + } + let record: unknown + try { + record = JSON.parse(text) + } catch { + return 'unknown' + } + if ( + typeof record !== 'object' || + record === null || + !('pid' in record) || + typeof record.pid !== 'number' || + !Number.isSafeInteger(record.pid) || + record.pid <= 0 + ) { + return 'unknown' + } + try { + process.kill(record.pid, 0) + } catch (error) { + // Why: only ESRCH proves the process is gone; EPERM means it exists. + return errorCode(error) === 'ESRCH' ? 'absent' : 'unknown' + } + const createdAtMs = readWindowsProcessCreationTime(record.pid) + if ( + createdAtMs === null || + !('processStartTime' in record) || + typeof record.processStartTime !== 'string' || + !/^\d+$/.test(record.processStartTime) + ) { + return 'unknown' + } + const startedAtMs = Number( + BigInt(record.processStartTime) / 10_000n - FILETIME_UNIX_EPOCH_OFFSET_MS + ) + // Why: a running pid with another creation time was reused after the server exited. + return Math.abs(createdAtMs - startedAtMs) < 1_000 ? 'live' : 'absent' +} + +/** + * Node cannot open Codex's AF_UNIX socket on Windows (libuv only speaks named + * pipes), so read the server's pid record instead and require the live process + * with that pid to have the recorded creation time, which rules out pid reuse. + */ +async function probeWindowsRecords(codexHome: string): Promise { + const states = await Promise.all( + ['daemon.pid', 'app-server.pid'].map((name) => + probeWindowsRecord(join(codexHome, 'app-server-daemon', name)) + ) + ) + return states.includes('live') ? 'live' : states.includes('unknown') ? 'unknown' : 'absent' +} + +/** Whether Codex's shared server for this CODEX_HOME is accepting clients right now. */ +export function probeCodexSharedServer(codexHome: string): Promise { + return process.platform === 'win32' + ? probeWindowsRecords(codexHome) + : probeSocket(codexDaemonSocketPath(codexHome)) +} diff --git a/src/main/codex/codex-shared-server-turn-off-promotion.test.ts b/src/main/codex/codex-shared-server-turn-off-promotion.test.ts new file mode 100644 index 00000000000..12a773016d1 --- /dev/null +++ b/src/main/codex/codex-shared-server-turn-off-promotion.test.ts @@ -0,0 +1,160 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { homedir, tmpdir } from 'node:os' +import type * as Os from 'node:os' +import { join } from 'node:path' +import type { ProcessResult, ProcessSpec } from '../../shared/child-process/run-process' + +// Why: temp homes exceed sun_path on macOS but not on Linux; keep asserted config bytes host-independent. +vi.mock('./codex-daemon-socket-path-guard', async (importOriginal) => ({ + ...(await importOriginal()), + applyCodexDaemonSocketGuard: (config: string) => config +})) + +const mocks = vi.hoisted(() => ({ + homedir: vi.fn<() => string>(), + runProcess: vi.fn<(spec: ProcessSpec) => Promise>() +})) +vi.mock('node:os', async (importOriginal) => ({ + ...(await importOriginal()), + homedir: mocks.homedir +})) +vi.mock('../../shared/child-process/run-process', () => ({ runProcess: mocks.runProcess })) + +import { disableCodexSharedServerAutoStartOnOrcaMirror } from './codex-shared-server-fix' +import { syncSystemConfigIntoManagedCodexHome } from './codex-config-mirror' +import { upsertTableSettingsInContent } from './codex-config-settings-upsert' + +const FILE_NAME = process.platform === 'win32' ? 'codex.exe' : 'codex' +let tmpHome: string +let userDataDir: string +let previousUserDataPath: string | undefined + +const systemConfigPath = (): string => join(tmpHome, '.codex', 'config.toml') +const mirrorHome = (): string => join(userDataDir, 'codex-runtime-home', 'home') +const mirrorConfigPath = (): string => join(mirrorHome(), 'config.toml') +const baselinePath = (): string => join(mirrorHome(), '.orca-config-settings-baseline.json') +const fakeCodex = (): string => + join(mirrorHome(), 'packages', 'app-server-daemon', 'current', 'bin', FILE_NAME) +const readSystemConfig = (): string => readFileSync(systemConfigPath(), 'utf-8') +const readMirrorConfig = (): string => readFileSync(mirrorConfigPath(), 'utf-8') + +function writeSystemConfig(content: string): void { + mkdirSync(join(tmpHome, '.codex'), { recursive: true }) + writeFileSync(systemConfigPath(), content, 'utf-8') +} + +/** Rewrites the baseline as an Orca from before `daemon_auto_start` was promoted wrote it. */ +function forgetKeyInBaseline(): void { + const baseline = JSON.parse(readFileSync(baselinePath(), 'utf-8')) + delete baseline.settings['features.daemon_auto_start'] + writeFileSync(baselinePath(), JSON.stringify(baseline), 'utf-8') +} + +// Fake `codex`: the disable writes the mirror like Codex 0.159.3 does; the list reads it back. +function fakeCodexRun(spec: ProcessSpec): Promise { + if (spec.program !== fakeCodex() || spec.env?.CODEX_HOME !== mirrorHome()) { + throw new Error(`refusing an unexpected Codex: ${spec.program}`) + } + const done = (stdout = ''): Promise => + Promise.resolve({ code: 0, signal: null, stdout, stderr: '', timedOut: false }) + if ((spec.args ?? []).join(' ') === 'features disable daemon_auto_start') { + const existing = existsSync(mirrorConfigPath()) ? readMirrorConfig() : '' + writeFileSync( + mirrorConfigPath(), + upsertTableSettingsInContent(existing, 'features', new Map([['daemon_auto_start', 'false']])) + ) + return done() + } + const enabled = !/^daemon_auto_start = false$/m.test(readMirrorConfig()) + return done(`daemon_auto_start experimental ${enabled}\n`) +} + +beforeEach(() => { + tmpHome = mkdtempSync(join(tmpdir(), 'orca-turn-off-home-')) + userDataDir = mkdtempSync(join(tmpdir(), 'orca-turn-off-user-data-')) + previousUserDataPath = process.env.ORCA_USER_DATA_PATH + process.env.ORCA_USER_DATA_PATH = userDataDir + mocks.homedir.mockReturnValue(tmpHome) + // Why: promotion writes into homedir()/.codex; refuse to run against the real one. + if (homedir() !== tmpHome) { + throw new Error('node:os homedir mock is not active; refusing to touch the real ~/.codex') + } + mkdirSync(join(fakeCodex(), '..'), { recursive: true }) + writeFileSync(fakeCodex(), '') + mocks.runProcess.mockImplementation(fakeCodexRun) +}) + +afterEach(() => { + rmSync(tmpHome, { recursive: true, force: true }) + rmSync(userDataDir, { recursive: true, force: true }) + if (previousUserDataPath === undefined) { + delete process.env.ORCA_USER_DATA_PATH + } else { + process.env.ORCA_USER_DATA_PATH = previousUserDataPath + } + vi.clearAllMocks() +}) + +const ORDINARY = 'model = "gpt-5"\n\n[tui]\ntheme = "dark"\n' + +describe('Turn off on Orca mirror home', () => { + it.each([ + ['a baseline from an older Orca', forgetKeyInBaseline], + ['a baseline from this build', () => {}] + ])('promotes false to ~/.codex at once under %s', async (_label, arrangeBaseline) => { + writeSystemConfig(ORDINARY) + syncSystemConfigIntoManagedCodexHome() + arrangeBaseline() + + expect(await disableCodexSharedServerAutoStartOnOrcaMirror(mirrorHome())).toBe(true) + + expect(mocks.runProcess.mock.calls.map(([spec]) => spec.program)).toEqual([ + fakeCodex(), + fakeCodex() + ]) + expect(readSystemConfig()).toBe(`${ORDINARY}\n[features]\ndaemon_auto_start = false\n`) + expect(readMirrorConfig()).toContain('daemon_auto_start = false') + // Why: a later launch pass mirrors the promoted source, so the setting must survive it. + syncSystemConfigIntoManagedCodexHome() + expect(readMirrorConfig()).toContain('daemon_auto_start = false') + }) + + // Why: the older Orca had mirrored `true` already, so the pass before records it as + // the ancestor and Turn off is promoted like any in-Codex change, as on a fresh baseline. + it('replaces an explicit true in ~/.codex, as it would on a fresh baseline', async () => { + writeSystemConfig('model = "gpt-5"\n\n[features]\ndaemon_auto_start = true\n') + syncSystemConfigIntoManagedCodexHome() + forgetKeyInBaseline() + + expect(await disableCodexSharedServerAutoStartOnOrcaMirror(mirrorHome())).toBe(true) + + expect(readSystemConfig()).toBe('model = "gpt-5"\n\n[features]\ndaemon_auto_start = false\n') + }) + + it('keeps an explicit true the mirror already disagreed with, as a recorded conflict', async () => { + writeSystemConfig('[features]\ndaemon_auto_start = true\n') + syncSystemConfigIntoManagedCodexHome() + forgetKeyInBaseline() + writeFileSync(mirrorConfigPath(), '[features]\ndaemon_auto_start = false\n') + + expect(await disableCodexSharedServerAutoStartOnOrcaMirror(mirrorHome())).toBe(true) + + // Why: with no recorded ancestor neither side is known to be newer, so both stay. + expect(readSystemConfig()).toBe('[features]\ndaemon_auto_start = true\n') + expect(readMirrorConfig()).toContain('daemon_auto_start = false') + }) + + it('still fixes the pane when ~/.codex/config.toml is missing and the passes skip', async () => { + writeSystemConfig(ORDINARY) + syncSystemConfigIntoManagedCodexHome() + forgetKeyInBaseline() + rmSync(systemConfigPath()) + vi.spyOn(console, 'warn').mockImplementation(() => {}) + + expect(await disableCodexSharedServerAutoStartOnOrcaMirror(mirrorHome())).toBe(true) + + expect(existsSync(systemConfigPath())).toBe(false) + expect(readMirrorConfig()).toContain('daemon_auto_start = false') + }) +}) diff --git a/src/main/codex/config-features-settings-promotion.test.ts b/src/main/codex/config-features-settings-promotion.test.ts new file mode 100644 index 00000000000..4bc827fe8a3 --- /dev/null +++ b/src/main/codex/config-features-settings-promotion.test.ts @@ -0,0 +1,199 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { homedir, tmpdir } from 'node:os' +import type * as Os from 'node:os' +import { join } from 'node:path' + +// Why: temp homes exceed sun_path on macOS but not on Linux; keep asserted config bytes host-independent. +vi.mock('./codex-daemon-socket-path-guard', async (importOriginal) => ({ + ...(await importOriginal()), + applyCodexDaemonSocketGuard: (config: string) => config +})) + +const { homedirMock } = vi.hoisted(() => ({ homedirMock: vi.fn<() => string>() })) + +vi.mock('node:os', async (importOriginal) => ({ + ...(await importOriginal()), + homedir: homedirMock +})) + +import { syncSystemConfigIntoManagedCodexHome } from './codex-config-mirror' +import { upsertTableSettingsInContent } from './codex-config-settings-upsert' +import { CODEX_DAEMON_OVERRIDE_MARKER } from './codex-daemon-socket-path-guard' + +let tmpHome: string +let userDataDir: string +let previousUserDataPath: string | undefined + +beforeEach(() => { + tmpHome = mkdtempSync(join(tmpdir(), 'orca-codex-features-home-')) + userDataDir = mkdtempSync(join(tmpdir(), 'orca-codex-features-user-data-')) + previousUserDataPath = process.env.ORCA_USER_DATA_PATH + process.env.ORCA_USER_DATA_PATH = userDataDir + homedirMock.mockReturnValue(tmpHome) + // Why: promotion writes into homedir()/.codex; refuse to run against the real one. + if (homedir() !== tmpHome) { + throw new Error('node:os homedir mock is not active; refusing to touch the real ~/.codex') + } +}) + +afterEach(() => { + rmSync(tmpHome, { recursive: true, force: true }) + rmSync(userDataDir, { recursive: true, force: true }) + if (previousUserDataPath === undefined) { + delete process.env.ORCA_USER_DATA_PATH + } else { + process.env.ORCA_USER_DATA_PATH = previousUserDataPath + } + vi.clearAllMocks() +}) + +const systemConfigPath = (): string => join(tmpHome, '.codex', 'config.toml') +const runtimeHomeDir = (): string => join(userDataDir, 'codex-runtime-home', 'home') +const runtimeConfigPath = (): string => join(runtimeHomeDir(), 'config.toml') +const baselinePath = (): string => join(runtimeHomeDir(), '.orca-config-settings-baseline.json') +const readSystemConfig = (): string => readFileSync(systemConfigPath(), 'utf-8') +const readRuntimeConfig = (): string => readFileSync(runtimeConfigPath(), 'utf-8') + +function writeSystemConfig(content: string): void { + mkdirSync(join(tmpHome, '.codex'), { recursive: true }) + writeFileSync(systemConfigPath(), content, 'utf-8') +} + +function setRuntimeConfig(content: string): void { + mkdirSync(runtimeHomeDir(), { recursive: true }) + writeFileSync(runtimeConfigPath(), content, 'utf-8') +} + +// Mimics `codex features disable daemon_auto_start` run with CODEX_HOME = Orca's mirror home. +function turnOffInMirror(raw = 'false'): void { + const existing = existsSync(runtimeConfigPath()) ? readRuntimeConfig() : '' + setRuntimeConfig( + upsertTableSettingsInContent(existing, 'features', new Map([['daemon_auto_start', raw]])) + ) +} + +describe('[features].daemon_auto_start write-back promotion', () => { + it('creates ~/.codex/config.toml with the runtime settings when the user has none', () => { + setRuntimeConfig('model = "o4"\n') + syncSystemConfigIntoManagedCodexHome() + turnOffInMirror() + + syncSystemConfigIntoManagedCodexHome() + + expect(readSystemConfig()).toBe('model = "o4"\n\n[features]\ndaemon_auto_start = false\n') + expect(readRuntimeConfig()).toContain('daemon_auto_start = false') + // Why: the next pass mirrors the promoted source, so the setting must survive it. + syncSystemConfigIntoManagedCodexHome() + expect(readRuntimeConfig()).toContain('daemon_auto_start = false') + }) + + it('seeds a blank ~/.codex/config.toml from the runtime instead of a skeleton', () => { + writeSystemConfig(' \n') + setRuntimeConfig('model = "o4"\n') + syncSystemConfigIntoManagedCodexHome() + turnOffInMirror() + + syncSystemConfigIntoManagedCodexHome() + + expect(readSystemConfig()).toBe('model = "o4"\n\n[features]\ndaemon_auto_start = false\n') + expect(readRuntimeConfig()).toContain('model = "o4"') + }) + + it('holds the change while the source is missing and promotes it once the source returns', () => { + writeSystemConfig('model = "gpt-5"\n\n[features]\ndaemon_auto_start = true\n') + syncSystemConfigIntoManagedCodexHome() + rmSync(systemConfigPath()) + turnOffInMirror() + + syncSystemConfigIntoManagedCodexHome() + expect(existsSync(systemConfigPath())).toBe(false) + expect(readRuntimeConfig()).toContain('daemon_auto_start = false') + + writeSystemConfig('model = "gpt-5"\n\n[features]\ndaemon_auto_start = true\n') + syncSystemConfigIntoManagedCodexHome() + expect(readSystemConfig()).toBe('model = "gpt-5"\n\n[features]\ndaemon_auto_start = false\n') + expect(readRuntimeConfig()).toContain('daemon_auto_start = false') + }) + + it('adds the key to an existing [features] table without touching its other keys', () => { + writeSystemConfig('model = "gpt-5"\n\n[features]\ncodex_hooks = true\napps = false\n') + syncSystemConfigIntoManagedCodexHome() + turnOffInMirror() + + syncSystemConfigIntoManagedCodexHome() + + expect(readSystemConfig()).toBe( + 'model = "gpt-5"\n\n[features]\ncodex_hooks = true\napps = false\ndaemon_auto_start = false\n' + ) + // The runtime keeps its own hooks spelling; only the promoted key crosses over. + expect(readRuntimeConfig()).toContain('hooks = true') + const settled = readSystemConfig() + syncSystemConfigIntoManagedCodexHome() + expect(readSystemConfig()).toBe(settled) + }) + + it('appends a [features] table when the source has none', () => { + writeSystemConfig('model = "gpt-5"\n\n[tui]\ntheme = "dark"\n') + syncSystemConfigIntoManagedCodexHome() + turnOffInMirror() + + syncSystemConfigIntoManagedCodexHome() + + expect(readSystemConfig()).toBe( + 'model = "gpt-5"\n\n[tui]\ntheme = "dark"\n\n[features]\ndaemon_auto_start = false\n' + ) + }) + + it('replaces a source daemon_auto_start = true in place', () => { + writeSystemConfig('[features]\nhooks = true\ndaemon_auto_start = true\napps = true\n') + syncSystemConfigIntoManagedCodexHome() + turnOffInMirror() + + syncSystemConfigIntoManagedCodexHome() + + expect(readSystemConfig()).toBe( + '[features]\nhooks = true\ndaemon_auto_start = false\napps = true\n' + ) + expect(readRuntimeConfig()).toContain('daemon_auto_start = false') + }) + + it('keeps a source edit made while the runtime also changed, as for every promoted key', () => { + writeSystemConfig('[features]\ndaemon_auto_start = true\n') + syncSystemConfigIntoManagedCodexHome() + turnOffInMirror() + writeSystemConfig('[features]\ndaemon_auto_start = "outside-edit"\n') + + syncSystemConfigIntoManagedCodexHome() + + expect(readSystemConfig()).toBe('[features]\ndaemon_auto_start = "outside-edit"\n') + expect(readRuntimeConfig()).toContain('daemon_auto_start = "outside-edit"') + }) + + it('keeps the runtime value for a baseline written before the key was promoted', () => { + writeSystemConfig('[features]\ndaemon_auto_start = true\n') + syncSystemConfigIntoManagedCodexHome() + const baseline = JSON.parse(readFileSync(baselinePath(), 'utf-8')) + delete baseline.settings['features.daemon_auto_start'] + writeFileSync(baselinePath(), JSON.stringify(baseline), 'utf-8') + turnOffInMirror() + + // Why: without a recorded ancestor neither side is known to be newer, so both are kept. + syncSystemConfigIntoManagedCodexHome() + syncSystemConfigIntoManagedCodexHome() + + expect(readSystemConfig()).toBe('[features]\ndaemon_auto_start = true\n') + expect(readRuntimeConfig()).toContain('daemon_auto_start = false') + }) + + it("never promotes Orca's own daemon socket override", () => { + writeSystemConfig('model = "gpt-5"\n') + syncSystemConfigIntoManagedCodexHome() + rmSync(systemConfigPath()) + turnOffInMirror(`false ${CODEX_DAEMON_OVERRIDE_MARKER}`) + + syncSystemConfigIntoManagedCodexHome() + + expect(existsSync(systemConfigPath())).toBe(false) + }) +}) diff --git a/src/main/codex/config-settings-promotion.ts b/src/main/codex/config-settings-promotion.ts index 56e499a6c18..2bc0570b16b 100644 --- a/src/main/codex/config-settings-promotion.ts +++ b/src/main/codex/config-settings-promotion.ts @@ -195,8 +195,9 @@ function promoteCodexRuntimeSettingsToSystemUnsafe( // would leave a skeleton the next mirror treats as authoritative, deleting // every other runtime setting (mcp_servers, features). With no system config // the runtime IS the user's config, so carry its ordinary settings across. + // A blank file is seeded the same way, since the mirror skips it as missing. const systemContent = - writeTargetObservation.kind === 'present' + writeTargetObservation.kind === 'present' && writeTargetObservation.value.trim() !== '' ? writeTargetObservation.value : extractOrdinaryCodexSettings(runtimeTomlObservation.value) const withPromotedSettings = upsertPromotedSettingsInContent(systemContent, updates) diff --git a/src/main/codex/config-toml-promoted-setting-values.test.ts b/src/main/codex/config-toml-promoted-setting-values.test.ts new file mode 100644 index 00000000000..b125240da85 --- /dev/null +++ b/src/main/codex/config-toml-promoted-setting-values.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest' +import { readPromotedSettingValuesFromContent } from './config-toml-promoted-setting-values' + +describe('readPromotedSettingValuesFromContent', () => { + it('reads bare, dotted and table-body keys by structured path', () => { + const values = readPromotedSettingValuesFromContent( + 'model = "o4"\ntui.theme = "dark"\n\n[features]\ndaemon_auto_start = false\n' + ) + expect([...values.keys()]).toEqual(['model', 'tui.theme', 'features.daemon_auto_start']) + }) + + it('does not read a quoted key containing a dot as a table key', () => { + const values = readPromotedSettingValuesFromContent( + '"tui.theme" = "dark"\n\n[features]\n"daemon_auto_start.x" = false\n' + ) + expect(values.size).toBe(0) + }) +}) diff --git a/src/main/codex/config-toml-promoted-setting-values.ts b/src/main/codex/config-toml-promoted-setting-values.ts index 543c6476843..2a42a167031 100644 --- a/src/main/codex/config-toml-promoted-setting-values.ts +++ b/src/main/codex/config-toml-promoted-setting-values.ts @@ -6,7 +6,8 @@ import { updateTomlLineScanState } from './config-toml-line-scan' import { parseTomlKeyPath, parseTomlTableHeaderPath } from './config-toml-key-path' -import { tuiStructuredKey } from './codex-config-settings-upsert' +import { tableStructuredKey } from './codex-config-settings-upsert' +import { stripCodexDaemonOverride } from './codex-daemon-socket-path-guard' // Why: only scalars the Codex TUI persists; each key here is written to the user's real ~/.codex, so grow deliberately. export const PROMOTED_CODEX_SETTING_KEYS = [ @@ -16,77 +17,30 @@ export const PROMOTED_CODEX_SETTING_KEYS = [ 'sandbox_mode' ] as const -// Why: the [tui] keys the Codex TUI's user-facing pickers persist (status line, -// terminal title, theme). Like the top-level list, every key here gets written -// into the user's real ~/.codex/config.toml on promotion — grow it deliberately. -export const PROMOTED_CODEX_TUI_SETTING_KEYS = [ - 'status_line', - 'status_line_use_colors', - 'terminal_title', - 'theme' -] as const +// Why: table keys Codex persists from inside a pane — the TUI pickers' [tui] +// keys and the shared-server fix's [features] switch. Like the top-level list, +// every key here gets written into the user's real ~/.codex/config.toml. +export const PROMOTED_CODEX_TABLE_SETTING_KEYS = { + tui: ['status_line', 'status_line_use_colors', 'terminal_title', 'theme'], + features: ['daemon_auto_start'] +} as const // Why: promotion diffs and upserts operate on structured keys — top-level keys -// keep their bare name, [tui] keys are namespaced tui. so their baseline +// keep their bare name, table keys are namespaced
. so their baseline // entries cannot collide with a top-level key of the same name. export const PROMOTED_STRUCTURED_KEYS: readonly string[] = [ ...PROMOTED_CODEX_SETTING_KEYS, - ...PROMOTED_CODEX_TUI_SETTING_KEYS.map(tuiStructuredKey) + ...Object.entries(PROMOTED_CODEX_TABLE_SETTING_KEYS).flatMap(([table, keys]) => + keys.map((key) => tableStructuredKey(table, key)) + ) ] -function isPromotedTuiKey(key: string): boolean { - return (PROMOTED_CODEX_TUI_SETTING_KEYS as readonly string[]).includes(key) -} - -// Returns the structured tui key a scanned line's key represents, or null. In -// the preamble it recognizes the dotted `tui.` form a user may hand-author; -// inside the first `[tui]` table body it recognizes the bare `` form Codex -// writes. Both map to the same structured key so either config shape promotes. -function matchTuiStructuredKey( - keyPath: string[], - inPreamble: boolean, - tuiBodyActive: boolean -): string | null { - if (inPreamble) { - const tuiKey = keyPath.length === 2 && keyPath[0] === 'tui' ? keyPath[1] : null - return tuiKey && isPromotedTuiKey(tuiKey) ? tuiStructuredKey(tuiKey) : null - } - const tuiKey = keyPath.length === 1 ? keyPath[0] : null - return tuiBodyActive && tuiKey && isPromotedTuiKey(tuiKey) ? tuiStructuredKey(tuiKey) : null -} - export type TopLevelSettingValue = { raw: string // Why: a multiline string/array value can't be replaced line-by-line, so it's excluded from promotion. multiline: boolean } -function matchPromotedStructuredKey( - line: string, - inPreamble: boolean, - tuiBodyActive: boolean -): { structuredKey: string; raw: string } | null { - const parsed = parseTomlKeyPath(line) - if (!parsed || line[parsed.end] !== '=') { - return null - } - const raw = line.slice(parsed.end + 1).trim() - const topLevelKey = parsed.segments.length === 1 ? parsed.segments[0] : null - if ( - inPreamble && - topLevelKey && - (PROMOTED_CODEX_SETTING_KEYS as readonly string[]).includes(topLevelKey) - ) { - return { structuredKey: topLevelKey, raw } - } - const tuiKey = matchTuiStructuredKey(parsed.segments, inPreamble, tuiBodyActive) - return tuiKey ? { structuredKey: tuiKey, raw } : null -} - -// Why: top-level preamble scalars keep the historical behavior; [tui] keys are -// collected from the first bare [tui] table body or the dotted preamble form, -// keyed by structured path. Any table header (including [tui.*] subtables) ends -// the [tui] body, and [profiles.*]/other tables are still ignored. export function readPromotedSettingValues(configPath: string): Map { // Why: an unreadable config held no settings only in the sense that we could // not read them. Returning an empty map says the user cleared every promoted @@ -105,41 +59,71 @@ export function readPromotedSettingValuesFromContent( config: string ): Map { const result = new Map() - const lines = config.split('\n') - let state = createTomlLineScanState() - let inPreamble = true - let tuiTableSeen = false - let tuiBodyActive = false - for (const line of lines) { - if (isTomlStructuralLine(state)) { - const header = getTomlTableHeader(line) - if (header) { - const table = parseTomlTableHeaderPath(header) - tuiBodyActive = - table !== null && - !table.isArray && - table.segments.length === 1 && - table.segments[0] === 'tui' && - !tuiTableSeen - if (tuiBodyActive) { - tuiTableSeen = true - } - inPreamble = false - state = updateTomlLineScanState(state, line) - continue - } - const matched = matchPromotedStructuredKey(line, inPreamble, tuiBodyActive) - if (matched) { - const nextState = updateTomlLineScanState(state, line) - result.set(matched.structuredKey, { - raw: matched.raw, - multiline: !isTomlStructuralLine(nextState) - }) - state = nextState - continue - } + // Why: Orca's own daemon override is not a user setting, so it must never promote into ~/.codex. + for (const setting of scanStructuredSettingLines(stripCodexDaemonOverride(config).split('\n'))) { + if (PROMOTED_STRUCTURED_KEYS.includes(setting.structuredKey)) { + result.set(setting.structuredKey, { raw: setting.raw, multiline: setting.multiline }) } - state = updateTomlLineScanState(state, line) } return result } + +type StructuredSettingLine = TopLevelSettingValue & { index: number; structuredKey: string } + +/** + * Every single-line-scannable assignment keyed by structured path: bare preamble + * keys, dotted `
.` preamble keys, and bare keys in the first + * `[
]` body. Subtables, array tables and later bodies are ignored. + */ +export function scanStructuredSettingLines(lines: readonly string[]): StructuredSettingLine[] { + const settings: StructuredSettingLine[] = [] + let state = createTomlLineScanState() + let inPreamble = true + let bodyTable: string | null = null + const seenTables = new Set() + for (const [index, line] of lines.entries()) { + const structural = isTomlStructuralLine(state) + state = updateTomlLineScanState(state, line) + const header = structural ? getTomlTableHeader(line) : null + if (header) { + const table = parseTomlTableHeaderPath(header) + const name = table && !table.isArray && table.segments.length === 1 ? table.segments[0] : null + bodyTable = name && !seenTables.has(name) ? name : null + if (name) { + seenTables.add(name) + } + inPreamble = false + continue + } + const parsed = structural ? parseTomlKeyPath(line) : null + if (!parsed || line[parsed.end] !== '=') { + continue + } + const structuredKey = getStructuredKey(parsed.segments, inPreamble, bodyTable) + if (structuredKey !== null) { + settings.push({ + index, + structuredKey, + raw: line.slice(parsed.end + 1).trim(), + multiline: !isTomlStructuralLine(state) + }) + } + } + return settings +} + +function getStructuredKey( + segments: readonly string[], + inPreamble: boolean, + bodyTable: string | null +): string | null { + const [first, second, ...rest] = segments + // Why: a quoted `"tui.theme"` is one key, not the [tui] table's theme. + if (first === undefined || rest.length > 0 || segments.some((segment) => segment.includes('.'))) { + return null + } + if (!inPreamble) { + return bodyTable !== null && second === undefined ? tableStructuredKey(bodyTable, first) : null + } + return second === undefined ? first : tableStructuredKey(first, second) +} diff --git a/src/main/ipc/pty/ipc/codex-shared-server.test.ts b/src/main/ipc/pty/ipc/codex-shared-server.test.ts new file mode 100644 index 00000000000..94715870d3c --- /dev/null +++ b/src/main/ipc/pty/ipc/codex-shared-server.test.ts @@ -0,0 +1,126 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +type Handler = (event: unknown, args: { id: string }) => Promise +type Session = { id: string; rootProcessId?: number; wslDistro?: string } + +const mocks = vi.hoisted(() => ({ + handlers: new Map(), + sessions: new Array(), + hasProvider: vi.fn<(id: string) => boolean>(), + isPaneCodexOnSharedServer: vi.fn<(id: string, rootPid: number) => Promise>(), + resolveCodexPaneHome: vi.fn<(id: string) => string | null>(), + isOnOrcaMirror: vi.fn<(id: string) => boolean>(), + disable: vi.fn<(home: string) => Promise>(), + disableOnMirror: vi.fn<(home: string) => Promise>(), + stop: vi.fn<(home: string) => Promise>() +})) +vi.mock('../../pty-host-bindings', () => ({ + getPtyIpc: () => ({ + handle: (channel: string, handler: Handler) => mocks.handlers.set(channel, handler) + }) +})) +vi.mock('../../../codex/codex-shared-server-pane', () => ({ + isCodexPaneOnOrcaMirrorHome: mocks.isOnOrcaMirror, + isPaneCodexOnSharedServer: mocks.isPaneCodexOnSharedServer, + resolveCodexPaneHome: mocks.resolveCodexPaneHome +})) +vi.mock('../../../codex/codex-shared-server-fix', () => ({ + disableCodexSharedServerAutoStart: mocks.disable, + disableCodexSharedServerAutoStartOnOrcaMirror: mocks.disableOnMirror, + stopCodexSharedServer: mocks.stop +})) +vi.mock('../provider/registry', () => ({ + hasPtyProviderForInspection: mocks.hasProvider, + getProviderForPty: () => ({ listProcesses: () => Promise.resolve(mocks.sessions) }) +})) + +import { toAppSshPtyId } from '../../../providers/ssh-pty-id' +import { ptyOwnership } from '../provider/ownership-state' +import { installPtyCodexSharedServerIpcHandler } from './codex-shared-server' + +const CHANNELS = [ + 'pty:isCodexOnSharedServer', + 'pty:disableCodexSharedServerAutoStart', + 'pty:stopCodexSharedServer' +] as const + +function invoke(channel: string, id: string): Promise { + const handler = mocks.handlers.get(channel) + if (!handler) { + throw new Error(`missing ${channel}`) + } + return handler({}, { id }) +} + +beforeEach(() => { + vi.clearAllMocks() + mocks.handlers.clear() + ptyOwnership.clear() + mocks.sessions = [{ id: 'local-1', rootProcessId: 100 }] + mocks.hasProvider.mockReturnValue(true) + mocks.isPaneCodexOnSharedServer.mockResolvedValue(true) + mocks.resolveCodexPaneHome.mockReturnValue('/home/me/.codex') + mocks.isOnOrcaMirror.mockReturnValue(false) + mocks.disable.mockResolvedValue(true) + mocks.disableOnMirror.mockResolvedValue(true) + mocks.stop.mockResolvedValue(true) + installPtyCodexSharedServerIpcHandler({ getLocalPtyProviderStartupPromise: () => undefined }) +}) + +describe('Codex shared-server IPC', () => { + it.each(CHANNELS)('%s answers for a local pane', async (channel) => { + expect(await invoke(channel, 'local-1')).toBe(true) + }) + + it('runs the fix against the pane home', async () => { + await invoke('pty:disableCodexSharedServerAutoStart', 'local-1') + await invoke('pty:stopCodexSharedServer', 'local-1') + expect(mocks.resolveCodexPaneHome).toHaveBeenCalledWith('local-1') + expect(mocks.disable).toHaveBeenCalledWith('/home/me/.codex') + expect(mocks.stop).toHaveBeenCalledWith('/home/me/.codex') + // Why: a real-home pane writes ~/.codex itself, so no mirror pass runs around it. + expect(mocks.disableOnMirror).not.toHaveBeenCalled() + }) + + it('turns off a mirror-home pane through the mirror passes, not a bare write', async () => { + mocks.isOnOrcaMirror.mockReturnValue(true) + mocks.resolveCodexPaneHome.mockReturnValue('C:/orca/codex-runtime-home/home') + expect(await invoke('pty:disableCodexSharedServerAutoStart', 'local-1')).toBe(true) + expect(mocks.isOnOrcaMirror).toHaveBeenCalledWith('local-1') + expect(mocks.disableOnMirror).toHaveBeenCalledWith('C:/orca/codex-runtime-home/home') + expect(mocks.disable).not.toHaveBeenCalled() + }) + + const refusals: [string, string, () => void][] = [ + ['a remote runtime pane', 'remote:local-1', () => {}], + ['an SSH pane', toAppSshPtyId('conn-1', 'local-1'), () => {}], + ['a pane routed to an SSH connection', 'local-1', () => ptyOwnership.set('local-1', 'conn-1')], + [ + 'a WSL pane', + 'local-1', + () => (mocks.sessions = [{ id: 'local-1', rootProcessId: 100, wslDistro: 'Ubuntu' }]) + ], + ['a pane with no root pid', 'local-1', () => (mocks.sessions = [{ id: 'local-1' }])], + ['a pane no provider holds', 'local-1', () => mocks.hasProvider.mockReturnValue(false)] + ] + + it.each(CHANNELS.flatMap((channel) => refusals.map((refusal) => [channel, ...refusal] as const)))( + '%s refuses %s', + async (channel, _label, id, arrange) => { + arrange() + expect(await invoke(channel, id)).toBe(false) + expect(mocks.isPaneCodexOnSharedServer).not.toHaveBeenCalled() + expect(mocks.disable).not.toHaveBeenCalled() + expect(mocks.disableOnMirror).not.toHaveBeenCalled() + expect(mocks.stop).not.toHaveBeenCalled() + } + ) + + it.each(CHANNELS.slice(1))('%s runs nothing when the pane has no Codex home', async (channel) => { + mocks.resolveCodexPaneHome.mockReturnValue(null) + expect(await invoke(channel, 'local-1')).toBe(false) + expect(mocks.disable).not.toHaveBeenCalled() + expect(mocks.disableOnMirror).not.toHaveBeenCalled() + expect(mocks.stop).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/ipc/pty/ipc/codex-shared-server.ts b/src/main/ipc/pty/ipc/codex-shared-server.ts new file mode 100644 index 00000000000..33d30d314a9 --- /dev/null +++ b/src/main/ipc/pty/ipc/codex-shared-server.ts @@ -0,0 +1,77 @@ +import { getPtyIpc } from '../../pty-host-bindings' +import { parseAppSshPtyId } from '../../../providers/ssh-pty-id' +import { + isCodexPaneOnOrcaMirrorHome, + isPaneCodexOnSharedServer, + resolveCodexPaneHome +} from '../../../codex/codex-shared-server-pane' +import { + disableCodexSharedServerAutoStart, + disableCodexSharedServerAutoStartOnOrcaMirror, + stopCodexSharedServer +} from '../../../codex/codex-shared-server-fix' +import { ptyOwnership } from '../provider/ownership-state' +import { getProviderForPty, hasPtyProviderForInspection } from '../provider/registry' + +type Deps = { getLocalPtyProviderStartupPromise: () => Promise | undefined } + +/** The pane's root pid when it is a local, non-WSL pane; otherwise null. */ +async function findLocalPaneRootPid(deps: Deps, id: unknown): Promise { + // Why local only: SSH and WSL panes run Codex on another host, which must answer for itself. + if ( + typeof id !== 'string' || + id.startsWith('remote:') || + parseAppSshPtyId(id) || + (ptyOwnership.get(id) ?? null) !== null + ) { + return null + } + // Why: the pre-swap provider does not own restored daemon ids. + await deps.getLocalPtyProviderStartupPromise() + if (!hasPtyProviderForInspection(id)) { + return null + } + const session = (await getProviderForPty(id).listProcesses()).find( + (candidate) => candidate.id === id + ) + return session?.rootProcessId !== undefined && !session.wslDistro ? session.rootProcessId : null +} + +function handleLocalPane( + deps: Deps, + channel: string, + run: (id: string, rootPid: number) => Promise +): void { + getPtyIpc().handle(channel, async (_event, args: { id: string }): Promise => { + try { + const rootPid = await findLocalPaneRootPid(deps, args?.id) + return rootPid === null ? false : await run(args.id, rootPid) + } catch { + return false + } + }) +} + +/** Runs a fix command against the pane's own CODEX_HOME, never a guessed one. */ +function runForPaneHome(fix: (codexHome: string) => Promise) { + return async (id: string): Promise => { + const codexHome = resolveCodexPaneHome(id) + return codexHome ? await fix(codexHome) : false + } +} + +// Why: a real-home pane writes ~/.codex directly; only the mirror needs promotion around the write. +function disableForPane(id: string): Promise { + return runForPaneHome( + isCodexPaneOnOrcaMirrorHome(id) + ? disableCodexSharedServerAutoStartOnOrcaMirror + : disableCodexSharedServerAutoStart + )(id) +} + +// Why its own read: only a pane already showing Codex asks, so no cadence poll pays for argv. +export function installPtyCodexSharedServerIpcHandler(deps: Deps): void { + handleLocalPane(deps, 'pty:isCodexOnSharedServer', isPaneCodexOnSharedServer) + handleLocalPane(deps, 'pty:disableCodexSharedServerAutoStart', disableForPane) + handleLocalPane(deps, 'pty:stopCodexSharedServer', runForPaneHome(stopCodexSharedServer)) +} diff --git a/src/main/ipc/pty/register-handlers.ts b/src/main/ipc/pty/register-handlers.ts index 5c7c897474a..9ce1d4082a4 100644 --- a/src/main/ipc/pty/register-handlers.ts +++ b/src/main/ipc/pty/register-handlers.ts @@ -12,6 +12,7 @@ import { localProvider } from './provider/registry' import { finishPtyShutdown } from './provider/liveness' import type { GetSelectedCodexHomePath, PrepareClaudeAuth } from './host-env/types' import { installPtyInspectIpcHandlers } from './ipc/inspect' +import { installPtyCodexSharedServerIpcHandler } from './ipc/codex-shared-server' import { installPtyKillIpcHandler, stopReplacedPanePty, @@ -118,6 +119,9 @@ export function registerPtyHandlers( ipcMain.removeHandler('pty:getForegroundProcess') ipcMain.removeHandler('pty:inspectProcess') ipcMain.removeHandler('pty:confirmForegroundProcess') + ipcMain.removeHandler('pty:isCodexOnSharedServer') + ipcMain.removeHandler('pty:disableCodexSharedServerAutoStart') + ipcMain.removeHandler('pty:stopCodexSharedServer') ipcMain.removeHandler('pty:getCwd') ipcMain.removeHandler('pty:getSize') ipcMain.removeHandler('pty:getAuthoritativeBufferSnapshotCapabilities') @@ -278,5 +282,6 @@ export function registerPtyHandlers( installPtyWriteIpcHandlers({ mainWindow, runtime }) installPtyResizeVisibilityIpc(session) installPtyInspectIpcHandlers({ getLocalPtyProviderStartupPromise }) + installPtyCodexSharedServerIpcHandler({ getLocalPtyProviderStartupPromise }) installPtyKillIpcHandler(killDeps) } diff --git a/src/main/providers/local-pty-session-operations.ts b/src/main/providers/local-pty-session-operations.ts index 27a151580ce..084179acb17 100644 --- a/src/main/providers/local-pty-session-operations.ts +++ b/src/main/providers/local-pty-session-operations.ts @@ -125,6 +125,7 @@ export async function listLocalPtyProcesses(): Promise { return Array.from(ptyProcesses.entries()).map(([id, proc]) => ({ id, ...(ptyIncarnations.get(id) ? { incarnationId: ptyIncarnations.get(id) } : {}), + ...(proc.pid > 0 ? { rootProcessId: proc.pid } : {}), cwd: ptyInitialCwd.get(id) ?? '', title: proc.process || getPtyShellName(id) || 'shell', ...(ptyWorktreeId.get(id) ? { worktreeId: ptyWorktreeId.get(id) } : {}), diff --git a/src/preload/api/pty-api.ts b/src/preload/api/pty-api.ts index eb45c4dba68..c7c46c7b004 100644 --- a/src/preload/api/pty-api.ts +++ b/src/preload/api/pty-api.ts @@ -126,6 +126,11 @@ export type PtyApi = { } ) => Promise confirmForegroundProcess: (id: string) => Promise + /** Local panes only; false for any other pane. */ + isCodexOnSharedServer: (id: string) => Promise + /** Runs the fix with the pane's own Codex; true only once verified. Local panes only. */ + disableCodexSharedServerAutoStart: (id: string) => Promise + stopCodexSharedServer: (id: string) => Promise getCwd: (id: string) => Promise getSize: (id: string) => Promise<{ cols: number; rows: number } | null> listSessions: (scope?: PtySessionListScope) => Promise diff --git a/src/preload/api/pty-bridge-stream-and-serialization.ts b/src/preload/api/pty-bridge-stream-and-serialization.ts index 557570c8eab..ec5b232acb5 100644 --- a/src/preload/api/pty-bridge-stream-and-serialization.ts +++ b/src/preload/api/pty-bridge-stream-and-serialization.ts @@ -16,6 +16,12 @@ export const ptyStreamAndSerializationApi = { ipcRenderer.invoke('pty:inspectProcess', { id, ...options }), confirmForegroundProcess: (id: string): Promise => ipcRenderer.invoke('pty:confirmForegroundProcess', { id }), + isCodexOnSharedServer: (id: string): Promise => + ipcRenderer.invoke('pty:isCodexOnSharedServer', { id }), + disableCodexSharedServerAutoStart: (id: string): Promise => + ipcRenderer.invoke('pty:disableCodexSharedServerAutoStart', { id }), + stopCodexSharedServer: (id: string): Promise => + ipcRenderer.invoke('pty:stopCodexSharedServer', { id }), getCwd: (id: string): Promise => ipcRenderer.invoke('pty:getCwd', { id }), getSize: (id: string): Promise<{ cols: number; rows: number } | null> => ipcRenderer.invoke('pty:getSize', { id }), diff --git a/src/renderer/src/assets/terminal.css b/src/renderer/src/assets/terminal.css index 0d17b1f77df..7b470a252ed 100644 --- a/src/renderer/src/assets/terminal.css +++ b/src/renderer/src/assets/terminal.css @@ -527,6 +527,29 @@ height: calc(100% - var(--orca-pane-title-height)); /* match margin-top */ } +/* A pane-top banner takes real height: the terminal starts below it and refits. */ +.pane-top-banner { + position: absolute; + top: 0; + left: 0; + right: 0; + z-index: 5; +} + +.pane[data-has-title] .pane-top-banner { + top: var(--orca-pane-title-height); +} + +.pane:has(> .pane-top-banner) .xterm-container { + margin-top: calc(var(--pane-padding-y, 4px) + var(--orca-pane-top-banner-height, 0px)); + height: calc(100% - var(--pane-padding-y, 4px) - var(--orca-pane-top-banner-height, 0px)); +} + +.pane[data-has-title]:has(> .pane-top-banner) .xterm-container { + margin-top: calc(var(--orca-pane-title-height) + var(--orca-pane-top-banner-height, 0px)); + height: calc(100% - var(--orca-pane-title-height) - var(--orca-pane-top-banner-height, 0px)); +} + /* Ghostty-style URL hover: glued to the pane's true bottom-left corner. */ .pane-link-tooltip { position: absolute; diff --git a/src/renderer/src/components/settings/CodexTerminalServerIsolationSetting.tsx b/src/renderer/src/components/settings/CodexTerminalServerIsolationSetting.tsx index 76c43c88b11..6c7d9db22b7 100644 --- a/src/renderer/src/components/settings/CodexTerminalServerIsolationSetting.tsx +++ b/src/renderer/src/components/settings/CodexTerminalServerIsolationSetting.tsx @@ -1,7 +1,12 @@ import type { GlobalSettings } from '../../../../shared/global-settings-types' -import { isCodexTerminalServerIsolationEnabled } from '../../../../shared/codex-terminal-server-isolation' +import { + isCodexSharedServerWarningEnabled, + isCodexTerminalServerIsolationEnabled +} from '../../../../shared/codex-terminal-server-isolation' import { CODEX_TERMINAL_SERVER_ISOLATION_SETTINGS_TARGET_ID } from '@/lib/settings-navigation-types' import { + getCodexSharedServerWarningDescription, + getCodexSharedServerWarningTitle, getCodexTerminalServerIsolationDescription, getCodexTerminalServerIsolationSearchKeywords, getCodexTerminalServerIsolationTitle @@ -35,6 +40,19 @@ export function CodexTerminalServerIsolationSetting({ checked={enabled} onChange={() => void updateSettings({ codexTerminalServerIsolation: !enabled })} /> + {/* Why only with isolation on: off means sharing the server is what the user chose. */} + {enabled ? ( + + void updateSettings({ + codexSharedServerWarning: !isCodexSharedServerWarningEnabled(settings) + }) + } + /> + ) : null} ) diff --git a/src/renderer/src/components/settings/codex-terminal-server-isolation-copy.ts b/src/renderer/src/components/settings/codex-terminal-server-isolation-copy.ts index b48f6de8166..0f82e70f43e 100644 --- a/src/renderer/src/components/settings/codex-terminal-server-isolation-copy.ts +++ b/src/renderer/src/components/settings/codex-terminal-server-isolation-copy.ts @@ -15,6 +15,20 @@ export function getCodexTerminalServerIsolationDescription(): string { ) } +export function getCodexSharedServerWarningTitle(): string { + return translate( + 'settings.agents.codexSharedServerWarning.title', + 'Warn when a Codex tab shares a server' + ) +} + +export function getCodexSharedServerWarningDescription(): string { + return translate( + 'settings.agents.codexSharedServerWarning.description', + 'Shows a notice on a Codex you started yourself when it shares a server with other tabs, because its agent status may be wrong.' + ) +} + export function getCodexTerminalServerIsolationSearchKeywords(): string[] { return searchKeywords([ { diff --git a/src/renderer/src/components/terminal-pane/CodexSharedServerBanner.tsx b/src/renderer/src/components/terminal-pane/CodexSharedServerBanner.tsx new file mode 100644 index 00000000000..434c05b0e85 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/CodexSharedServerBanner.tsx @@ -0,0 +1,207 @@ +import { useEffect, useLayoutEffect, useRef, useState } from 'react' +import { TriangleAlert } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { translate } from '@/i18n/i18n' +import { useAppStore } from '@/store' +import { isCodexSharedServerWarningEnabled } from '../../../../shared/codex-terminal-server-isolation' +import { CodexSharedServerFixDialog } from './CodexSharedServerFixDialog' +import { retireCodexTerminalServerIsolationNotice } from './codex-terminal-server-isolation-notice' + +// Why a ladder: Codex joins or starts the server a few seconds after its process appears. +const CHECK_DELAYS_MS = [1_000, 4_000, 10_000] as const +// Why module scope: a pane remounts on tab switches, and × must hold for the app session. +const dismissedPtyIds = new Set() + +/** Asks on the ladder until an answer is yes; returns a cancel that drops any later answer. */ +function askUntilOnSharedServer( + ask: (ptyId: string) => Promise, + ptyId: string, + onJoined: () => void +): () => void { + let cancelled = false + let timer: ReturnType | undefined + const schedule = (attempt: number): void => { + timer = setTimeout(() => { + void ask(ptyId) + .catch(() => false) + .then((joined) => { + if (cancelled) { + return + } + if (joined) { + onJoined() + } else if (attempt + 1 < CHECK_DELAYS_MS.length) { + schedule(attempt + 1) + } + }) + }, CHECK_DELAYS_MS[attempt]) + } + schedule(0) + return () => { + cancelled = true + clearTimeout(timer) + } +} + +function usePaneCodexOnSharedServer(ptyId: string, enabled: boolean, recheck: number): boolean { + const [joined, setJoined] = useState(false) + useEffect(() => { + if (!enabled) { + return + } + const cancel = askUntilOnSharedServer(window.api.pty.isCodexOnSharedServer, ptyId, () => + setJoined(true) + ) + return () => { + cancel() + setJoined(false) + } + }, [enabled, ptyId, recheck]) + return joined +} + +/** Reserves the banner's height at the top of its pane so the terminal refits below it. */ +function useReservePaneTopSpace(): React.RefObject { + const ref = useRef(null) + useLayoutEffect(() => { + const banner = ref.current + const pane = banner?.parentElement + if (!banner || !pane) { + return + } + const reserve = (): void => { + pane.style.setProperty('--orca-pane-top-banner-height', `${banner.offsetHeight}px`) + } + reserve() + const observer = new ResizeObserver(reserve) + observer.observe(banner) + return () => { + observer.disconnect() + pane.style.removeProperty('--orca-pane-top-banner-height') + } + }, []) + return ref +} + +export function CodexSharedServerBanner({ + ptyId, + paneKey +}: { + ptyId: string + paneKey: string +}): React.JSX.Element | null { + const [dismissed, setDismissed] = useState(() => dismissedPtyIds.has(ptyId)) + const warningEnabled = useAppStore( + (state) => state.settings !== null && isCodexSharedServerWarningEnabled(state.settings) + ) + // Why either signal: a typed codex is seen by the process read, or by its hooks when that read has no command marks. + const codexInPane = useAppStore( + (state) => + state.paneForegroundAgentByPaneKey[paneKey]?.agent === 'codex' || + state.agentStatusByPaneKey[paneKey]?.agentType === 'codex' + ) + // Why a recheck: after the fix stops the server, the banner hides unless a new one is joined. + const [recheck, setRecheck] = useState(0) + const joined = usePaneCodexOnSharedServer( + ptyId, + warningEnabled && codexInPane && !dismissed, + recheck + ) + const [fixOpen, setFixOpen] = useState(false) + // Why fixOpen keeps it: stopping the server ends this pane's Codex, which must not close the dialog. + if (!joined && !fixOpen) { + return null + } + return ( + setRecheck((count) => count + 1)} + onDismiss={() => { + dismissedPtyIds.add(ptyId) + setDismissed(true) + }} + onDontShowAgain={() => + void useAppStore.getState().updateSettings({ codexSharedServerWarning: false }) + } + /> + ) +} + +function CodexSharedServerBannerContent({ + ptyId, + fixOpen, + onFixOpenChange, + onServerStopped, + onDismiss, + onDontShowAgain +}: { + ptyId: string + fixOpen: boolean + onFixOpenChange: (open: boolean) => void + onServerStopped: () => void + onDismiss: () => void + onDontShowAgain: () => void +}): React.JSX.Element { + const ref = useReservePaneTopSpace() + useEffect(retireCodexTerminalServerIsolationNotice, []) + + return ( +
+ {/* Why a container query: split panes are narrow, so actions drop below the text there. + Narrow and wide variants never share a property, so an unlayered utility cannot override them. */} +
+
+
+
+ + + +
+
+ +
+ ) +} diff --git a/src/renderer/src/components/terminal-pane/CodexSharedServerFixDialog.tsx b/src/renderer/src/components/terminal-pane/CodexSharedServerFixDialog.tsx new file mode 100644 index 00000000000..fbde8977c0b --- /dev/null +++ b/src/renderer/src/components/terminal-pane/CodexSharedServerFixDialog.tsx @@ -0,0 +1,288 @@ +import { useEffect, useState } from 'react' +import { Check, Copy, Loader2 } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle +} from '@/components/ui/dialog' +import { translate } from '@/i18n/i18n' +import { + CODEX_DISABLE_AUTO_START_COMMAND, + CODEX_STOP_SHARED_SERVER_COMMAND +} from '../../../../shared/codex-shared-server-command' + +type FixStepStatus = 'idle' | 'running' | 'done' | 'failed' + +function useFixStep(run: () => Promise): { + status: FixStepStatus + start: () => Promise +} { + const [status, setStatus] = useState('idle') + const start = async (): Promise => { + setStatus('running') + const ok = await run().catch(() => false) + setStatus(ok ? 'done' : 'failed') + return ok + } + return { status, start } +} + +function CommandBlock({ command }: { command: string }): React.JSX.Element { + const [copied, setCopied] = useState(false) + useEffect(() => { + if (!copied) { + return + } + const timer = setTimeout(() => setCopied(false), 1_500) + return () => clearTimeout(timer) + }, [copied]) + + return ( +
+ + {command} + + +
+ ) +} + +function FixStep({ + step, + title, + command, + status, + actionLabel, + runningLabel, + doneLabel, + failedLabel, + onAction, + locked = false, + note, + warning +}: { + step: number + title: string + command: string + status: FixStepStatus + actionLabel: string + runningLabel: string + doneLabel: string + failedLabel: string + onAction: () => void + locked?: boolean + note?: string + warning?: string +}): React.JSX.Element { + return ( +
+
+ + {step} + +

{title}

+ {/* Why a fixed width: the label swaps while running, and the row must not shift. */} +
+ {status === 'done' ? ( + + + ) : ( + + )} +
+
+
+ {/* Why always shown: the user sees exactly what Orca runs on their behalf. */} +

+ {translate('terminal.codexSharedServerBanner.runs', 'Runs')} + + {command} + +

+ {note ?

{note}

: null} + {warning ?

{warning}

: null} + {status === 'failed' ? ( + <> +

{failedLabel}

+ + + ) : null} +
+
+ ) +} + +function ConfirmStopDialog({ + open, + onOpenChange, + onConfirm +}: { + open: boolean + onOpenChange: (open: boolean) => void + onConfirm: () => void +}): React.JSX.Element { + return ( + + + + + {translate( + 'terminal.codexSharedServerBanner.confirmStopTitle', + 'Stop the shared server?' + )} + + + {translate( + 'terminal.codexSharedServerBanner.confirmStopDescription', + 'This closes any open Codex sessions that share it, including ones outside Orca.' + )} + + + + + + + + + ) +} + +export function CodexSharedServerFixDialog({ + ptyId, + open, + onOpenChange, + onServerStopped +}: { + ptyId: string + open: boolean + onOpenChange: (open: boolean) => void + onServerStopped: () => void +}): React.JSX.Element { + const turnOff = useFixStep(() => window.api.pty.disableCodexSharedServerAutoStart(ptyId)) + const stop = useFixStep(() => window.api.pty.stopCodexSharedServer(ptyId)) + const [confirmStopOpen, setConfirmStopOpen] = useState(false) + + return ( + + + + + {translate( + 'terminal.codexSharedServerBanner.dialogTitle', + 'Give each Codex tab its own server' + )} + + + {translate( + 'terminal.codexSharedServerBanner.dialogDescription', + 'Codex sessions started directly in a terminal share one background server. Orca keeps the Codex sessions it starts separate. When sessions share a server, closing one can end the others, and agent status can be wrong.' + )} + + +
+ void turnOff.start()} + note={translate( + 'terminal.codexSharedServerBanner.step1Note', + 'This changes your Codex settings, so it also applies outside Orca.' + )} + /> + setConfirmStopOpen(true)} + // Why: with sharing still on, the next Codex restarts the server it just closed sessions to stop. + locked={turnOff.status !== 'done'} + warning={translate( + 'terminal.codexSharedServerBanner.step2Warning', + 'Closes any open Codex sessions that share the server' + )} + /> +
+ +

+ {translate( + 'terminal.codexSharedServerBanner.undo', + 'To undo, run codex features enable daemon_auto_start.' + )} +

+ +
+ { + setConfirmStopOpen(false) + void stop.start().then((ok) => { + if (ok) { + onServerStopped() + } + }) + }} + /> +
+
+ ) +} diff --git a/src/renderer/src/components/terminal-pane/TerminalPaneRuntimePortals.tsx b/src/renderer/src/components/terminal-pane/TerminalPaneRuntimePortals.tsx index 6ab44bb7c5c..ff587ab8b95 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPaneRuntimePortals.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPaneRuntimePortals.tsx @@ -1,5 +1,7 @@ import { createPortal } from 'react-dom' import CodexRestartChip from '../CodexRestartChip' +import { CodexSharedServerBanner } from './CodexSharedServerBanner' +import { makePaneKey } from '../../../../shared/stable-pane-id' import { TerminalSshReconnectOverlay } from './TerminalSshReconnectOverlay' import { TerminalRemoteRuntimeReconnectBanner } from './TerminalRemoteRuntimeReconnectBanner' import { TerminalProcessExitOverlay } from './TerminalProcessExitOverlay' @@ -15,11 +17,12 @@ export function TerminalPaneCodexRestartPortals({ }: { controller: TerminalPaneController }): React.JSX.Element { - const { activePane, isActive, isVisible, managedPanes, paneTransportsRef, savedLayout } = + const { activePane, isActive, isVisible, managedPanes, paneTransportsRef, savedLayout, tabId } = controller return ( <> {managedPanes.map((pane) => { + // Why the saved fallback: a restored pane's transport has no pty id until it reattaches. const ptyId = paneTransportsRef.current.get(pane.id)?.getPtyId() ?? savedLayout.ptyIdsByLeafId?.[pane.leafId] @@ -27,12 +30,19 @@ export function TerminalPaneCodexRestartPortals({ return null } return createPortal( - , + <> + + + , pane.container, `codex-restart-${pane.id}` ) diff --git a/src/renderer/src/components/terminal-pane/codex-shared-server-banner.test.tsx b/src/renderer/src/components/terminal-pane/codex-shared-server-banner.test.tsx new file mode 100644 index 00000000000..2f81ae371e0 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/codex-shared-server-banner.test.tsx @@ -0,0 +1,255 @@ +// @vitest-environment happy-dom + +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { toast } from 'sonner' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useAppStore } from '@/store' +import type { GlobalSettings } from '../../../../shared/global-settings-types' +import { getDefaultSettings } from '../../../../shared/constants' +import { CodexSharedServerBanner } from './CodexSharedServerBanner' +import { + CODEX_DISABLE_AUTO_START_COMMAND, + CODEX_STOP_SHARED_SERVER_COMMAND +} from '../../../../shared/codex-shared-server-command' + +globalThis.IS_REACT_ACT_ENVIRONMENT = true + +const PANE_KEY = 'tab-1:leaf-1' +const TITLE = 'This Codex is sharing a server' +let paneElement: HTMLDivElement +let root: Root +let isCodexOnSharedServer: ReturnType Promise>> +let disableCodexSharedServerAutoStart: ReturnType Promise>> +let stopCodexSharedServer: ReturnType Promise>> +let writeClipboardText: ReturnType Promise>> +let updateSettings: ReturnType) => Promise>> +let nextPtyId = 0 +let ptyId: string + +class ResizeObserverStub { + observe(): void {} + disconnect(): void {} +} + +function setState(settings: Partial, agent: 'codex' | null = 'codex'): void { + useAppStore.setState({ + settings: { ...getDefaultSettings('/home/me'), ...settings }, + paneForegroundAgentByPaneKey: agent ? { [PANE_KEY]: { agent, shellForeground: false } } : {}, + updateSettings + }) +} + +async function renderBanner(): Promise { + await act(async () => { + root.render() + }) +} + +async function advance(ms: number): Promise { + await act(async () => { + await vi.advanceTimersByTimeAsync(ms) + }) +} + +// Why document: the dialog portals out of the pane. +function button(label: string): HTMLButtonElement { + const match = Array.from(document.querySelectorAll('button')).find( + (candidate) => + candidate.textContent?.trim() === label || candidate.getAttribute('aria-label') === label + ) + if (!match) { + throw new Error(`missing ${label} button`) + } + return match +} + +beforeEach(() => { + vi.useFakeTimers() + vi.stubGlobal('ResizeObserver', ResizeObserverStub) + useAppStore.setState(useAppStore.getInitialState(), true) + ptyId = `pty-${(nextPtyId += 1)}` + paneElement = document.createElement('div') + paneElement.className = 'pane' + document.body.appendChild(paneElement) + root = createRoot(paneElement) + isCodexOnSharedServer = vi.fn(() => Promise.resolve(true)) + disableCodexSharedServerAutoStart = vi.fn(() => Promise.resolve(true)) + stopCodexSharedServer = vi.fn(() => Promise.resolve(true)) + writeClipboardText = vi.fn(() => Promise.resolve()) + updateSettings = vi.fn((updates: Partial) => { + setState({ ...useAppStore.getState().settings, ...updates }) + return Promise.resolve() + }) + Object.defineProperty(window, 'api', { + configurable: true, + value: { + pty: { isCodexOnSharedServer, disableCodexSharedServerAutoStart, stopCodexSharedServer }, + ui: { writeClipboardText, set: vi.fn(() => Promise.resolve()) } + } + }) +}) + +afterEach(() => { + act(() => root.unmount()) + paneElement.remove() + vi.unstubAllGlobals() + vi.restoreAllMocks() + vi.useRealTimers() + useAppStore.setState(useAppStore.getInitialState(), true) +}) + +describe('CodexSharedServerBanner', () => { + it('shows the warning and reserves its height at the top of the pane', async () => { + setState({}) + await renderBanner() + expect(paneElement.textContent).not.toContain(TITLE) + + await advance(1_000) + + expect(isCodexOnSharedServer).toHaveBeenCalledWith(ptyId) + expect(paneElement.textContent).toContain('agent status may be wrong') + expect(paneElement.querySelector(':scope > .pane-top-banner')).not.toBeNull() + expect(paneElement.style.getPropertyValue('--orca-pane-top-banner-height')).toMatch(/px$/) + }) + + it("retires the one-time 'runs Codex without its shared server' toast, which it contradicts", async () => { + setState({}) + useAppStore.setState({ codexTerminalServerIsolationNoticeSeen: false }) + const dismiss = vi.spyOn(toast, 'dismiss') + await renderBanner() + await advance(1_000) + + expect(paneElement.textContent).toContain(TITLE) + expect(dismiss).toHaveBeenCalledWith('codex-terminal-server-isolation-notice') + expect(useAppStore.getState().codexTerminalServerIsolationNoticeSeen).toBe(true) + }) + + it('keeps asking while Codex starts, then stops once it has an answer', async () => { + setState({}) + isCodexOnSharedServer.mockResolvedValueOnce(false) + await renderBanner() + await advance(1_000) + expect(paneElement.textContent).toBe('') + await advance(4_000) + expect(paneElement.textContent).toContain(TITLE) + await advance(60_000) + expect(isCodexOnSharedServer).toHaveBeenCalledTimes(2) + }) + + it('shows each command Orca runs, then turns sharing off and reads back success', async () => { + setState({}) + await renderBanner() + await advance(1_000) + await act(async () => button('Fix').click()) + expect(document.body.textContent).toContain(CODEX_DISABLE_AUTO_START_COMMAND) + expect(document.body.textContent).toContain(CODEX_STOP_SHARED_SERVER_COMMAND) + + await act(async () => button('Turn off').click()) + + expect(disableCodexSharedServerAutoStart).toHaveBeenCalledWith(ptyId) + expect(document.body.textContent).toContain('Turned off') + expect(() => button('Copy')).toThrow() + }) + + it('falls back to a copyable command when a step fails', async () => { + disableCodexSharedServerAutoStart.mockResolvedValueOnce(false) + setState({}) + await renderBanner() + await advance(1_000) + await act(async () => button('Fix').click()) + await act(async () => button('Turn off').click()) + + expect(document.body.textContent).toContain("Orca couldn't turn this off.") + expect(document.body.textContent).not.toContain('Turned off') + await act(async () => button('Copy').click()) + expect(writeClipboardText).toHaveBeenCalledWith(CODEX_DISABLE_AUTO_START_COMMAND) + }) + + it('confirms before stopping the server, then hides once it is gone', async () => { + setState({}) + await renderBanner() + await advance(1_000) + await act(async () => button('Fix').click()) + await act(async () => button('Turn off').click()) + await act(async () => button('Stop server').click()) + expect(stopCodexSharedServer).not.toHaveBeenCalled() + expect(document.body.textContent).toContain('Stop the shared server?') + + await act(async () => button('Cancel').click()) + expect(stopCodexSharedServer).not.toHaveBeenCalled() + + await act(async () => button('Stop server').click()) + const confirm = Array.from(document.querySelectorAll('button')).filter( + (candidate) => candidate.textContent?.trim() === 'Stop server' + ) + isCodexOnSharedServer.mockResolvedValue(false) + await act(async () => confirm.at(-1)?.click()) + expect(stopCodexSharedServer).toHaveBeenCalledWith(ptyId) + expect(document.body.textContent).toContain('Stopped') + + await act(async () => button('Done').click()) + await advance(20_000) + expect(paneElement.textContent).toBe('') + }) + + it.each([ + ['before sharing is turned off', false], + ['when turning sharing off failed', true] + ])('keeps Stop server unavailable %s', async (_label, turnOffFails) => { + disableCodexSharedServerAutoStart.mockResolvedValueOnce(false) + setState({}) + await renderBanner() + await advance(1_000) + await act(async () => button('Fix').click()) + if (turnOffFails) { + await act(async () => button('Turn off').click()) + } + expect(button('Stop server').disabled).toBe(true) + }) + + it('dismisses for this pane only, and stays dismissed after a remount', async () => { + setState({}) + await renderBanner() + await advance(1_000) + await act(async () => button('Dismiss').click()) + expect(paneElement.textContent).toBe('') + expect(paneElement.style.getPropertyValue('--orca-pane-top-banner-height')).toBe('') + expect(updateSettings).not.toHaveBeenCalled() + + act(() => root.unmount()) + root = createRoot(paneElement) + await renderBanner() + await advance(20_000) + expect(paneElement.textContent).toBe('') + }) + + it("persists Don't show again as a setting", async () => { + setState({}) + await renderBanner() + await advance(1_000) + await act(async () => button("Don't show again").click()) + expect(updateSettings).toHaveBeenCalledWith({ codexSharedServerWarning: false }) + expect(paneElement.textContent).toBe('') + }) + + it.each([ + ['isolation is off', { codexTerminalServerIsolation: false }], + ["Don't show again was chosen", { codexSharedServerWarning: false }] + ])('never asks or shows when %s', async (_label, settings) => { + setState(settings) + await renderBanner() + await advance(20_000) + expect(isCodexOnSharedServer).not.toHaveBeenCalled() + expect(paneElement.textContent).toBe('') + }) + + it('hides when Codex leaves the pane', async () => { + setState({}) + await renderBanner() + await advance(1_000) + expect(paneElement.textContent).toContain(TITLE) + await act(async () => setState({}, null)) + expect(paneElement.textContent).toBe('') + }) +}) diff --git a/src/renderer/src/components/terminal-pane/codex-terminal-server-isolation-notice.ts b/src/renderer/src/components/terminal-pane/codex-terminal-server-isolation-notice.ts index 7ff018ee6f6..4fe87294bdf 100644 --- a/src/renderer/src/components/terminal-pane/codex-terminal-server-isolation-notice.ts +++ b/src/renderer/src/components/terminal-pane/codex-terminal-server-isolation-notice.ts @@ -49,6 +49,14 @@ function didNoticeInputsChange(state: CodexNoticeState, previous: CodexNoticeSta ) } +const NOTICE_TOAST_ID = 'codex-terminal-server-isolation-notice' + +/** For a pane whose Codex shares the server anyway: its banner says so, and this toast would contradict it. */ +export function retireCodexTerminalServerIsolationNotice(): void { + useAppStore.getState().markCodexTerminalServerIsolationNoticeSeen() + toast.dismiss(NOTICE_TOAST_ID) +} + function showCodexTerminalServerIsolationNotice(): void { // Why mark before showing: seen means shown, so a quit or reload never repeats it. useAppStore.getState().markCodexTerminalServerIsolationNoticeSeen() @@ -59,7 +67,7 @@ function showCodexTerminalServerIsolationNotice(): void { ), { // Why a stable id: a late sync that resets the flag can't stack a second toast. - id: 'codex-terminal-server-isolation-notice', + id: NOTICE_TOAST_ID, description: translate( 'terminal.codexTerminalServerIsolationNotice.description', 'This makes agent status more reliable. You can turn it back on in Settings.' diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index f38bb0588ee..78556ec35b0 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -213,6 +213,10 @@ "codexTerminalServerIsolation": { "title": "Run each Codex terminal on its own server", "description": "Keeps Orca's status and closing tabs working correctly. Turn off to use Codex's shared server and its agents overview. Applies to new terminals." + }, + "codexSharedServerWarning": { + "title": "Warn when a Codex tab shares a server", + "description": "Shows a notice on a Codex you started yourself when it shares a server with other tabs, because its agent status may be wrong." } } }, @@ -18624,6 +18628,36 @@ "title": "Orca now runs Codex without its shared server", "description": "This makes agent status more reliable. You can turn it back on in Settings.", "openSettings": "Open Settings" + }, + "codexSharedServerBanner": { + "title": "This Codex is sharing a server with your other Codex tabs", + "body": "Sessions may end unexpectedly, and agent status may be wrong.", + "fix": "Fix", + "dontShowAgain": "Don't show again", + "dismiss": "Dismiss", + "dialogTitle": "Give each Codex tab its own server", + "dialogDescription": "Codex sessions started directly in a terminal share one background server. Orca keeps the Codex sessions it starts separate. When sessions share a server, closing one can end the others, and agent status can be wrong.", + "copy": "Copy", + "copied": "Copied", + "done": "Done", + "step1Title": "Turn off Codex server sharing", + "step1Note": "This changes your Codex settings, so it also applies outside Orca.", + "step2Title": "Stop the running shared server", + "undo": "To undo, run codex features enable daemon_auto_start.", + "step2Warning": "Closes any open Codex sessions that share the server", + "learnMore": "Learn more", + "turnOff": "Turn off", + "turningOff": "Turning off…", + "turnedOff": "Turned off", + "turnOffFailed": "Orca couldn't turn this off.", + "stopServer": "Stop server", + "stopping": "Stopping…", + "stopped": "Stopped", + "stopFailed": "Orca couldn't stop the server.", + "confirmStopTitle": "Stop the shared server?", + "confirmStopDescription": "This closes any open Codex sessions that share it, including ones outside Orca.", + "cancel": "Cancel", + "runs": "Runs" } }, "fileExplorer": { diff --git a/src/renderer/src/web/preload-api/web-terminal-api.ts b/src/renderer/src/web/preload-api/web-terminal-api.ts index be6dc77cd7d..c4bf707ea3c 100644 --- a/src/renderer/src/web/preload-api/web-terminal-api.ts +++ b/src/renderer/src/web/preload-api/web-terminal-api.ts @@ -40,6 +40,10 @@ export function createPtyApi(): NonNullable['pty']> { inspectProcess: () => Promise.reject(new Error('terminal_liveness_unavailable')), // Why: paired web panes cannot provide a local post-boundary process scan. confirmForegroundProcess: () => Promise.resolve(null), + // Why: a paired client's terminals belong to the host, whose Codex settings this client does not own. + isCodexOnSharedServer: () => Promise.resolve(false), + disableCodexSharedServerAutoStart: () => Promise.resolve(false), + stopCodexSharedServer: () => Promise.resolve(false), getCwd: () => Promise.resolve('~'), getSize: () => Promise.resolve(null), listSessions: () => Promise.resolve([]), diff --git a/src/shared/codex-shared-server-command.test.ts b/src/shared/codex-shared-server-command.test.ts new file mode 100644 index 00000000000..d2f978c1427 --- /dev/null +++ b/src/shared/codex-shared-server-command.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from 'vitest' +import { codexCommandLineJoinsSharedServer } from './codex-shared-server-command' + +const NPM_LAUNCHER = 'node /Users/me/.npm-global/lib/node_modules/@openai/codex/bin/codex.js' +const WINDOWS_LAUNCHER = + '"node" "C:\\Users\\me\\AppData\\Roaming\\npm\\\\node_modules\\@openai\\codex\\bin\\codex.js"' +const WINDOWS_NATIVE = + 'C:\\Users\\me\\AppData\\Roaming\\npm\\node_modules\\@openai\\codex\\node_modules\\@openai\\codex-win32-x64\\vendor\\x86_64-pc-windows-msvc\\bin\\codex.exe' + +describe('codexCommandLineJoinsSharedServer', () => { + it.each([ + ['bare native codex', 'codex'], + ['absolute native codex', '/Users/me/.local/bin/codex'], + ['platform binary', '/opt/codex/codex-aarch64-apple-darwin'], + ['npm node launcher', NPM_LAUNCHER], + ['node launcher with node flags', `node --no-warnings ${NPM_LAUNCHER.slice(5)}`], + ['Windows node launcher with quoted paths', WINDOWS_LAUNCHER], + ['Windows native child', WINDOWS_NATIVE], + ['Windows native child with args', `"${WINDOWS_NATIVE}" --model gpt-5`], + ['resume joins', 'codex resume --last'], + ['fork joins', 'codex fork'], + ['agents joins', 'codex agents'], + ['approval flags keep sharing', 'codex --dangerously-bypass-approvals-and-sandbox'], + ['model and approval values', 'codex -m gpt-5 -a on-request'], + ['a prompt', 'codex fix the flaky test'], + ['a prompt with the apply alias as a word', 'codex "fix a bug in the parser"'], + ['a prompt with a subcommand as a later word', 'codex "update the readme"'], + ['a prompt with review as a later word', 'codex "please review this"'], + ['a space-joined prompt with subcommand words', 'codex please review and update a test'], + ['a prompt after a valueless flag', 'codex --yolo fix a bug'], + ['a Windows path with spaces', '"C:\\Program Files\\My Codex\\codex.exe" fix a bug'], + ['launcher args', `${NPM_LAUNCHER} resume --last`] + ])('%s joins the shared server', (_label, commandLine) => { + expect(codexCommandLineJoinsSharedServer(commandLine)).toBe(true) + }) + + it.each([ + ['no Codex program', 'claude --resume'], + ['an empty command line', ''], + ['--no-daemon', 'codex --no-daemon'], + ['--no-daemon through the launcher', `${NPM_LAUNCHER} --no-daemon`], + ['--no-daemon on Windows', `${WINDOWS_LAUNCHER} --no-daemon`], + ['--oss', 'codex --oss'], + ['--remote', 'codex --remote ws://host:1'], + ['--remote=', 'codex --remote=ws://host:1'], + ['--profile', 'codex --profile work'], + ['-p', 'codex -p work'], + ['-p glued to its value', 'codex -pwork'], + ['--strict-config', 'codex --strict-config'], + ['--dangerously-bypass-hook-trust', 'codex --dangerously-bypass-hook-trust'], + ['--search', 'codex --search'], + ['--approve-for-me', 'codex --approve-for-me'], + ['--not-so-yolo', 'codex --not-so-yolo'], + ['--enable', 'codex --enable worktrees'], + ['--disable=', 'codex --disable=worktrees'], + ['-c', 'codex -c model="o3"'], + ['-c glued', 'codex -cmodel=o3'], + ['--config', 'codex --config model=o3'], + ['--no-daemon before a prompt', 'codex --no-daemon "a"'], + ['exec', 'codex exec "summarize"'], + ['exec alias', 'codex e hi'], + ['exec after a flag value', 'codex -m gpt-5 exec hi'], + ['exec after a flag value through the launcher', `${NPM_LAUNCHER} -m gpt-5 exec hi`], + ['exec after a quoted Windows path', '"C:\\Program Files\\My Codex\\codex.exe" exec hi'], + ['review', 'codex review'], + ['queue', 'codex queue hi'], + ['mcp', 'codex mcp list'], + ['app-server', 'codex app-server --listen unix:// --managed-daemon'], + [ + 'the server Codex spawns on Windows', + `"\\\\?\\${WINDOWS_NATIVE}" app-server daemon pid-update-loop` + ], + ['login', 'codex login'], + ['logout', 'codex logout'], + ['apply alias', 'codex a'], + ['cloud', 'codex cloud'], + ['completion', 'codex completion zsh'], + ['features', 'codex features disable daemon_auto_start'], + ['doctor', 'codex doctor'], + ['plugin', 'codex plugin list'], + ['sandbox', 'codex sandbox macos ls'], + ['debug', 'codex debug models'], + ['an apostrophe in a prompt before --no-daemon', "codex don't touch tests --no-daemon"], + ['an apostrophe through the launcher', `${NPM_LAUNCHER} don't break --oss`] + ])('%s stays off the shared server', (_label, commandLine) => { + expect(codexCommandLineJoinsSharedServer(commandLine)).toBe(false) + }) +}) diff --git a/src/shared/codex-shared-server-command.ts b/src/shared/codex-shared-server-command.ts new file mode 100644 index 00000000000..74c242b4e3b --- /dev/null +++ b/src/shared/codex-shared-server-command.ts @@ -0,0 +1,122 @@ +// Mirrors Codex's own opt-outs (tui/src/daemon_startup.rs `exclusion`). Every +// `-c`/`--enable`/`--disable` counts, although Codex allows a few: skipping one +// only costs a warning, never a false one. +const EMBEDDED_FLAGS: ReadonlySet = new Set([ + '--no-daemon', + '--oss', + '--remote', + '--profile', + '-p', + '--strict-config', + '--dangerously-bypass-hook-trust', + '--search', + '--approve-for-me', + '--not-so-yolo', + '--enable', + '--disable', + '--config', + '-c' +]) + +// Every subcommand in codex-rs/cli/src/main.rs except the ones that open the TUI +// on the shared server: `resume`, `fork` and `agents`. +const NON_TUI_SUBCOMMANDS: ReadonlySet = new Set([ + 'exec', + 'e', + 'review', + 'login', + 'logout', + 'mcp', + 'plugin', + 'app-server', + 'remote-control', + 'app', + 'completion', + 'update', + 'doctor', + 'sandbox', + 'debug', + 'execpolicy', + 'apply', + 'a', + 'queue', + 'archive', + 'delete', + 'migrate-rollouts', + 'unarchive', + 'cloud', + 'cloud-tasks', + 'responses-api-proxy', + 'stdio-to-uds', + 'exec-server', + 'features', + 'tcp-tunnel', + 'help' +]) + +// Flags that never take a value, so the word after one is still the first positional. +const VALUELESS_FLAGS: ReadonlySet = new Set([ + '--dangerously-bypass-approvals-and-sandbox', + '--yolo', + '--no-alt-screen', + '--worktree' +]) + +// The program word: `codex`, `codex.exe`, the npm `codex.js` launcher, or a platform binary. +const CODEX_PROGRAM_RE = /(?:^|[\\/])codex(?:\.(?:js|mjs|cjs|exe|cmd)|-[^\\/]+)?$/i + +function isEmbeddedFlag(word: string): boolean { + const name = word.split('=', 1)[0] ?? word + // Why the prefix check: clap also accepts a short flag glued to its value (`-pwork`, `-cx=1`). + return EMBEDDED_FLAGS.has(name) || /^-[pc][^-]/.test(word) +} + +/** + * True when a Codex process-table command line is an interactive Codex that + * joins its shared server when one is running. Process tables may join argv + * with spaces, so words are split on whitespace (double quotes group, as + * Windows quotes paths); apostrophes never group because a prompt's `don't` + * would swallow the rest. Opt-outs set through env (`CODEX_EXEC_SERVER_URL`, + * workload identity) are invisible here, so those rare panes get a false banner. + */ +export function codexCommandLineJoinsSharedServer(commandLine: string): boolean { + const words = (commandLine.match(/"[^"]*"|\S+/g) ?? []).map((word) => + word.replace(/^["']+|["']+$/g, '') + ) + const programIndex = words.findIndex((word) => CODEX_PROGRAM_RE.test(word)) + if (programIndex === -1) { + return false + } + const args = words.slice(programIndex + 1) + if (args.some(isEmbeddedFlag)) { + return false + } + // Why only the first positional: clap reads a subcommand there, and later words are prompt text. + for (let index = 0; index < args.length; index += 1) { + const word = args[index] + if (word.startsWith('-')) { + continue + } + if (NON_TUI_SUBCOMMANDS.has(word)) { + return false + } + const previous = args[index - 1] + // Why check one more: this word may be the previous flag's value (`-m gpt-5 exec`). + if (!previous?.startsWith('-') || previous.includes('=') || VALUELESS_FLAGS.has(previous)) { + return true + } + } + return true +} + +export const CODEX_SHARED_SERVER_FEATURE_KEY = 'daemon_auto_start' +/** The fix's commands, as argv after the `codex` program. */ +export const CODEX_DISABLE_SHARED_SERVER_ARGS = [ + 'features', + 'disable', + CODEX_SHARED_SERVER_FEATURE_KEY +] as const +export const CODEX_STOP_SHARED_SERVER_ARGS = ['app-server', 'daemon', 'stop'] as const +/** What the fix dialog shows the user it runs. */ +export const CODEX_DISABLE_AUTO_START_COMMAND = `codex ${CODEX_DISABLE_SHARED_SERVER_ARGS.join(' ')}` +export const CODEX_STOP_SHARED_SERVER_COMMAND = `codex ${CODEX_STOP_SHARED_SERVER_ARGS.join(' ')}` diff --git a/src/shared/codex-terminal-server-isolation.ts b/src/shared/codex-terminal-server-isolation.ts index daa68a972d7..333fb91c101 100644 --- a/src/shared/codex-terminal-server-isolation.ts +++ b/src/shared/codex-terminal-server-isolation.ts @@ -1,7 +1,7 @@ import type { GlobalSettings } from './global-settings-types' type CodexTerminalServerIsolationSettings = - | Partial> + | Partial> | null | undefined @@ -14,6 +14,15 @@ export function isCodexTerminalServerIsolationEnabled( return settings?.codexTerminalServerIsolation !== false } +/** The warning belongs to isolation: with it off, sharing the server is what the user chose. */ +export function isCodexSharedServerWarningEnabled( + settings: CodexTerminalServerIsolationSettings +): boolean { + return ( + isCodexTerminalServerIsolationEnabled(settings) && settings?.codexSharedServerWarning !== false + ) +} + /** Opt-out only: with isolation on nothing is injected, so behaviour matches the pre-setting default. */ export function withCodexTerminalServerIsolationEnv( env: Record | undefined, diff --git a/src/shared/default-global-settings.ts b/src/shared/default-global-settings.ts index a146be2a2c2..3c6a9d2aa59 100644 --- a/src/shared/default-global-settings.ts +++ b/src/shared/default-global-settings.ts @@ -223,6 +223,7 @@ export function buildDefaultSettings(args: { agentStatusHooksEnabled: true, agentWorkspaceTrustEnabled: true, codexTerminalServerIsolation: true, + codexSharedServerWarning: true, tabAutoGenerateTitle: false, confirmClosePinnedTab: true, editorPreviewTabsEnabled: true, diff --git a/src/shared/global-settings-types.ts b/src/shared/global-settings-types.ts index 5f2419e9b2a..c1a9b7d2df1 100644 --- a/src/shared/global-settings-types.ts +++ b/src/shared/global-settings-types.ts @@ -424,6 +424,8 @@ export type GlobalSettings = { agentWorkspaceTrustEnabled: boolean /** Why: Codex's shared server runs every tab's hooks with the first tab's env; off opts new terminals back into it. Absent reads as on. */ codexTerminalServerIsolation?: boolean + /** Off hides the banner on a typed `codex` that joined Codex's shared server. Absent reads as on. */ + codexSharedServerWarning?: boolean /** Dismissed freshness tuples: no write authority, just suppress re-nudging the same official placement/revision. */ dismissedSkillFreshnessNudges?: string[] /** Why: generated tab titles are subjective, so they stay opt-in and manual renames win. */