diff --git a/src/main/ipc/pty/ipc/spawn-options-codex-isolation.test.ts b/src/main/ipc/pty/ipc/spawn-options-codex-isolation.test.ts new file mode 100644 index 00000000000..0375ca7f88f --- /dev/null +++ b/src/main/ipc/pty/ipc/spawn-options-codex-isolation.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it, vi } from 'vitest' +import { getDefaultSettings } from '../../../../shared/constants' +import type { GlobalSettings } from '../../../../shared/global-settings-types' +import { buildRuntimePtySpawnOptions } from '../runtime/spawn-options' +import { createRuntimePtySpawnState } from '../runtime/spawn-state' +import type { PtyRuntimeControllerDeps } from '../runtime/controller-deps' +import { buildPtyIpcSpawnOptions } from './spawn-options' +import { createPtyIpcSpawnState } from './spawn-state' +import type { PtySpawnIpcDeps } from './spawn-types' + +function settingsWith(isolation: boolean | undefined): GlobalSettings { + const settings = getDefaultSettings('/tmp') + if (isolation === undefined) { + delete settings.codexTerminalServerIsolation + return settings + } + return { ...settings, codexTerminalServerIsolation: isolation } +} + +async function ipcSpawnEnv( + isolation: boolean | undefined, + connectionId: string | null +): Promise | undefined> { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: buildPtyIpcSpawnOptions only reads the members stubbed here; the rest belong to later spawn stages this test never runs. + const deps = { + transitionSpawnHiddenRendererPtyDeliveryState: vi.fn(), + getSettings: () => settingsWith(isolation), + runtime: { registerPreAllocatedHandleForPty: vi.fn() } + } as unknown as PtySpawnIpcDeps + const ctx = createPtyIpcSpawnState(deps, { cols: 80, rows: 24, connectionId }) + ctx.env = { KEEP: '1' } + await buildPtyIpcSpawnOptions(ctx) + return ctx.spawnOptions.env +} + +async function runtimeSpawnEnv( + isolation: boolean | undefined, + connectionId: string | null +): Promise | undefined> { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: buildRuntimePtySpawnOptions only reads getSettings here; the rest belong to later spawn stages this test never runs. + const deps = { getSettings: () => settingsWith(isolation) } as unknown as PtyRuntimeControllerDeps + const ctx = createRuntimePtySpawnState(deps, { cols: 80, rows: 24, connectionId }) + ctx.env = { KEEP: '1' } + await buildRuntimePtySpawnOptions(ctx) + return ctx.spawnOptions.env +} + +// Why both hosts: SSH panes reach the relay with this env untouched, local ones reach +// node-pty or the daemon; WSL forwarding is covered by wsl-orca-env.test.ts. +describe.each([ + ['renderer spawn', ipcSpawnEnv], + ['runtime spawn', runtimeSpawnEnv] +])('%s: Codex terminal server isolation', (_name, spawnEnv) => { + it.each([null, 'ssh-1'])( + 'injects nothing while the setting is on (connection %s)', + async (id) => { + expect(await spawnEnv(true, id)).toEqual({ KEEP: '1' }) + expect(await spawnEnv(undefined, id)).toEqual({ KEEP: '1' }) + } + ) + + it.each([null, 'ssh-1'])( + 'opts new terminals out with ORCA_CODEX_ISOLATE=0 when off (connection %s)', + async (id) => { + expect(await spawnEnv(false, id)).toEqual({ KEEP: '1', ORCA_CODEX_ISOLATE: '0' }) + } + ) +}) diff --git a/src/main/ipc/pty/ipc/spawn-options.ts b/src/main/ipc/pty/ipc/spawn-options.ts index 00d0e3e2d8d..d0a3ba8cef1 100644 --- a/src/main/ipc/pty/ipc/spawn-options.ts +++ b/src/main/ipc/pty/ipc/spawn-options.ts @@ -23,6 +23,7 @@ import { ptySizes } from '../delivery/visibility-state' import { shouldSeedPreAttachPtySize } from '../delivery/attached-pty-size' import { getStartupTerminalIngressIntent } from '../../terminal-startup-color-query-replies' import { resolveConfiguredTerminalShellArgs } from '../configured-terminal-shell-args' +import { withCodexTerminalServerIsolationEnv } from '../../../../shared/codex-terminal-server-isolation' import { planCodexNoDaemonLaunch } from '../../../pty/codex-no-daemon-launch-command' import type { PtyIpcSpawnState } from './spawn-state' import { applyAgentWorkspaceTrustToSpawn } from '../../../agent-workspace-trust-spawn' @@ -32,9 +33,11 @@ export async function buildPtyIpcSpawnOptions( ctx: PtyIpcSpawnState ): Promise<{ isReattach: true } | null> { const args = ctx.args - ctx.spawnEnv = ctx.preAllocatedHandle - ? { ...ctx.env, ORCA_TERMINAL_HANDLE: ctx.preAllocatedHandle } - : ctx.env + // Why here: every provider (local, daemon, SSH relay, WSL) spawns from this env. + ctx.spawnEnv = withCodexTerminalServerIsolationEnv( + ctx.preAllocatedHandle ? { ...ctx.env, ORCA_TERMINAL_HANDLE: ctx.preAllocatedHandle } : ctx.env, + ctx.deps.getSettings?.() + ) const envToDelete = ctx.claudeAuth?.stripAuthEnv ? [...CLAUDE_AUTH_ENV_VARS, 'ANTHROPIC_CUSTOM_HEADERS'] : undefined diff --git a/src/main/ipc/pty/runtime/spawn-options.ts b/src/main/ipc/pty/runtime/spawn-options.ts index cf71d6cb22a..5f64779b364 100644 --- a/src/main/ipc/pty/runtime/spawn-options.ts +++ b/src/main/ipc/pty/runtime/spawn-options.ts @@ -23,6 +23,7 @@ import { CLAUDE_AUTH_ENV_VARS } from '../../../claude-accounts/environment' import { LEGACY_TERMINAL_SHIM_REMOTE_ENV_KEYS } from '../../../pty/legacy-terminal-shim-dir' import { PI_PROCESS_OWNER_ENV_KEYS } from '../../../pty/pi-process-owner-env' import { resolveConfiguredTerminalShellArgs } from '../configured-terminal-shell-args' +import { withCodexTerminalServerIsolationEnv } from '../../../../shared/codex-terminal-server-isolation' import { planCodexNoDaemonLaunch } from '../../../pty/codex-no-daemon-launch-command' import { resolveStablePaneOwner } from '../pane/stable-owner' import { getStartupTerminalIngressIntent } from '../../terminal-startup-color-query-replies' @@ -42,6 +43,8 @@ export async function buildRuntimePtySpawnOptions( > { const args = ctx.args + // Why here: every provider (local, daemon, SSH relay, WSL) spawns from this env. + ctx.env = withCodexTerminalServerIsolationEnv(ctx.env, ctx.deps.getSettings?.()) const authEnvToDelete = ctx.claudeAuth?.stripAuthEnv ? [...CLAUDE_AUTH_ENV_VARS, 'ANTHROPIC_CUSTOM_HEADERS'] : undefined diff --git a/src/main/pty/wsl-orca-env.test.ts b/src/main/pty/wsl-orca-env.test.ts index 1df30613076..a108a1ae55f 100644 --- a/src/main/pty/wsl-orca-env.test.ts +++ b/src/main/pty/wsl-orca-env.test.ts @@ -64,6 +64,7 @@ describe('addOrcaWslInteropEnv', () => { ORCA_CLI_COMMAND: 'orca-ide', ORCA_WSL_CLI_DIR: 'C:\\Users\\jin\\AppData\\Roaming\\Orca\\wsl-managed-cli\\hash', ORCA_CODEX_LAUNCH_PREFLIGHT: 'C:\\Program Files\\Orca\\resources\\bin\\orca.exe', + ORCA_CODEX_ISOLATE: '0', ORCA_OMP_FRESH_CONFIG: 'C:\\Orca\\fresh-session.yml', ORCA_OMP_STATUS_EXTENSION: 'C:\\Users\\jin\\.omp\\agent\\extensions\\orca-agent-status.ts', ORCA_PRIME_AGENT_STATUS_EXTENSION: 'C:\\stale\\orca-agent-status.ts', @@ -90,6 +91,7 @@ describe('addOrcaWslInteropEnv', () => { expect(env.WSLENV).toContain('ORCA_CLI_COMMAND/u') expect(env.WSLENV).toContain('ORCA_WSL_CLI_DIR/p') expect(env.WSLENV).toContain('ORCA_CODEX_LAUNCH_PREFLIGHT/p') + expect(env.WSLENV).toContain('ORCA_CODEX_ISOLATE/u') expect(env.WSLENV).toContain('ORCA_OMP_STATUS_EXTENSION/p') expect(env.WSLENV).toContain('ORCA_OMP_FRESH_CONFIG/p') expect(env.WSLENV).not.toContain('ORCA_PRIME_AGENT_STATUS_EXTENSION') diff --git a/src/relay/pty-handler-spawn-environment.test.ts b/src/relay/pty-handler-spawn-environment.test.ts index 57f041c2b8f..659d9fbc627 100644 --- a/src/relay/pty-handler-spawn-environment.test.ts +++ b/src/relay/pty-handler-spawn-environment.test.ts @@ -363,6 +363,17 @@ describe('PtyHandler', () => { } ) + it('keeps the Codex server opt-out the client asked for', async () => { + await dispatcher.callRequest('pty.spawn', { + cols: 80, + rows: 24, + env: { ORCA_CODEX_ISOLATE: '0' } + }) + + const spawnEnv = mockPtySpawn.mock.calls.at(-1)?.[2]?.env as Record + expect(spawnEnv.ORCA_CODEX_ISOLATE).toBe('0') + }) + it('drops an ORCA_HISTFILE handed over in the client env', async () => { await dispatcher.callRequest('pty.spawn', { cols: 80, diff --git a/src/renderer/src/app-shell/use-app-shell-services.ts b/src/renderer/src/app-shell/use-app-shell-services.ts index 47c846040a7..89dcbe673d1 100644 --- a/src/renderer/src/app-shell/use-app-shell-services.ts +++ b/src/renderer/src/app-shell/use-app-shell-services.ts @@ -19,6 +19,7 @@ import { useWebSessionTabsSync } from '../runtime/web-session-tabs-sync' import { useLocalStructuredSessionTabsSync } from '../runtime/local-structured-session-tabs-sync' import { useRemoteRuntimeRecoveryTriggers } from '../runtime/use-remote-runtime-recovery-triggers' import { useBrowserIdentityMigrationNotice } from '../components/browser-pane/browser-user-agent-migration-notice' +import { useCodexTerminalServerIsolationNotice } from '../components/terminal-pane/codex-terminal-server-isolation-notice' /** * App-level subscriptions that must outlive any individual surface. Each one is here because @@ -52,4 +53,5 @@ export function useAppShellServices(options: { floatingPanelVisible: boolean }): usePrimarySelectionPaste(primarySelectionMiddleClickPaste) useOsc52ClipboardDefaultOnNotice(persistedUIReady) useBrowserIdentityMigrationNotice() + useCodexTerminalServerIsolationNotice() } diff --git a/src/renderer/src/components/settings/AgentsPane.test.tsx b/src/renderer/src/components/settings/AgentsPane.test.tsx index 45ce068790c..b3a282a72e0 100644 --- a/src/renderer/src/components/settings/AgentsPane.test.tsx +++ b/src/renderer/src/components/settings/AgentsPane.test.tsx @@ -13,6 +13,7 @@ import { getAgentWorkspaceTrustTitle } from './agent-workspace-trust-copy' import { getAgentAwakeDescription, getAgentAwakeTitle } from './agent-awake-copy' +import { getCodexTerminalServerIsolationTitle } from './codex-terminal-server-isolation-copy' import { AgentAwakeSetting } from './AgentAwakeSetting' import { AgentRuntimeSetting } from './AgentRuntimeSetting' import type * as AgentRuntimeSettingModule from './AgentRuntimeSetting' @@ -283,6 +284,17 @@ describe('AgentsPane', () => { } }) + it('keeps the host-only Codex server row out of paired web clients', () => { + Reflect.set(globalThis, '__ORCA_WEB_CLIENT__', true) + try { + expect(renderPane(getDefaultSettings('/tmp'))).not.toContain( + getCodexTerminalServerIsolationTitle() + ) + } finally { + Reflect.deleteProperty(globalThis, '__ORCA_WEB_CLIENT__') + } + }) + it('renders the agent runtime control on Windows-class hosts', () => { const markup = renderPane( { diff --git a/src/renderer/src/components/settings/AgentsPane.tsx b/src/renderer/src/components/settings/AgentsPane.tsx index c348ecea339..b4d9be1ceea 100644 --- a/src/renderer/src/components/settings/AgentsPane.tsx +++ b/src/renderer/src/components/settings/AgentsPane.tsx @@ -8,6 +8,7 @@ import { useAppStore } from '@/store' import { AgentAwakeSetting } from './AgentAwakeSetting' import { AgentCacheTimerSection } from './AgentCacheTimerSection' import { AgentRuntimeSetting } from './AgentRuntimeSetting' +import { CodexTerminalServerIsolationSetting } from './CodexTerminalServerIsolationSetting' import { buildCodexSessionSourceHomeControl } from './codex-session-source-home-control' import { getAgentGeneratedTabTitlesDescription, @@ -260,7 +261,13 @@ export function AgentsPane({ /> {!isPairedWebClientWindow() ? ( - + <> + + + ) : null} {!isPairedWebClientWindow() ? ( diff --git a/src/renderer/src/components/settings/CodexTerminalServerIsolationSetting.tsx b/src/renderer/src/components/settings/CodexTerminalServerIsolationSetting.tsx new file mode 100644 index 00000000000..76c43c88b11 --- /dev/null +++ b/src/renderer/src/components/settings/CodexTerminalServerIsolationSetting.tsx @@ -0,0 +1,41 @@ +import type { GlobalSettings } from '../../../../shared/global-settings-types' +import { isCodexTerminalServerIsolationEnabled } from '../../../../shared/codex-terminal-server-isolation' +import { CODEX_TERMINAL_SERVER_ISOLATION_SETTINGS_TARGET_ID } from '@/lib/settings-navigation-types' +import { + getCodexTerminalServerIsolationDescription, + getCodexTerminalServerIsolationSearchKeywords, + getCodexTerminalServerIsolationTitle +} from './codex-terminal-server-isolation-copy' +import { SearchableSetting } from './SearchableSetting' +import { SettingsSwitchRow } from './SettingsFormControls' + +type CodexTerminalServerIsolationSettingProps = { + settings: GlobalSettings + updateSettings: (updates: Partial) => void | Promise +} + +export function CodexTerminalServerIsolationSetting({ + settings, + updateSettings +}: CodexTerminalServerIsolationSettingProps): React.JSX.Element { + const title = getCodexTerminalServerIsolationTitle() + const description = getCodexTerminalServerIsolationDescription() + const enabled = isCodexTerminalServerIsolationEnabled(settings) + return ( +
+ + void updateSettings({ codexTerminalServerIsolation: !enabled })} + /> + +
+ ) +} diff --git a/src/renderer/src/components/settings/agents-search.ts b/src/renderer/src/components/settings/agents-search.ts index 9be62273d1e..78baab279fb 100644 --- a/src/renderer/src/components/settings/agents-search.ts +++ b/src/renderer/src/components/settings/agents-search.ts @@ -20,6 +20,11 @@ import { getAgentWorkspaceTrustTitle } from './agent-workspace-trust-copy' import { getAgentCacheTimerSearchEntries } from './agent-cache-timer-search' +import { + getCodexTerminalServerIsolationDescription, + getCodexTerminalServerIsolationSearchKeywords, + getCodexTerminalServerIsolationTitle +} from './codex-terminal-server-isolation-copy' import { translate } from '@/i18n/i18n' import { searchKeywords, translateSearchKeyword, uniqueKeywords } from './settings-search-keywords' import { createLocalizedCatalog } from '@/i18n/localized-catalog' @@ -68,11 +73,13 @@ type AgentsPaneSearchOptions = { includeAgentAwake?: boolean includeAgentRuntime?: boolean includeAgentWorkspaceTrust?: boolean + includeCodexTerminalServerIsolation?: boolean } const AGENT_AWAKE_SEARCH_ENTRY_ID = 'agent-awake' const AGENT_WORKSPACE_TRUST_SEARCH_ENTRY_ID = 'agent-workspace-trust' const AGENT_RUNTIME_SEARCH_ENTRY_ID = 'agent-runtime' +const CODEX_TERMINAL_SERVER_ISOLATION_SEARCH_ENTRY_ID = 'codex-terminal-server-isolation' const getAllAgentsPaneSearchEntries = createLocalizedCatalog(() => [ { @@ -121,6 +128,12 @@ const getAllAgentsPaneSearchEntries = createLocalizedCatalog(() => [ description: getAgentWorkspaceTrustDescription(), keywords: getAgentWorkspaceTrustSearchKeywords() }, + { + title: getCodexTerminalServerIsolationTitle(), + id: CODEX_TERMINAL_SERVER_ISOLATION_SEARCH_ENTRY_ID, + description: getCodexTerminalServerIsolationDescription(), + keywords: getCodexTerminalServerIsolationSearchKeywords() + }, { title: getAgentGeneratedTabTitlesTitle(), description: getAgentGeneratedTabTitlesDescription(), @@ -159,15 +172,23 @@ const getAllAgentsPaneSearchEntries = createLocalizedCatalog(() => [ export function getAgentsPaneSearchEntries({ includeAgentAwake = true, includeAgentRuntime = true, - includeAgentWorkspaceTrust = true + includeAgentWorkspaceTrust = true, + includeCodexTerminalServerIsolation = true }: AgentsPaneSearchOptions = {}) { - const entries = getAllAgentsPaneSearchEntries() - return entries.filter( - (entry) => - (!('id' in entry) || entry.id !== AGENT_RUNTIME_SEARCH_ENTRY_ID || includeAgentRuntime) && - (!('id' in entry) || entry.id !== AGENT_AWAKE_SEARCH_ENTRY_ID || includeAgentAwake) && - (!('id' in entry) || - entry.id !== AGENT_WORKSPACE_TRUST_SEARCH_ENTRY_ID || - includeAgentWorkspaceTrust) + const hiddenIds = new Set() + if (!includeAgentAwake) { + hiddenIds.add(AGENT_AWAKE_SEARCH_ENTRY_ID) + } + if (!includeAgentRuntime) { + hiddenIds.add(AGENT_RUNTIME_SEARCH_ENTRY_ID) + } + if (!includeAgentWorkspaceTrust) { + hiddenIds.add(AGENT_WORKSPACE_TRUST_SEARCH_ENTRY_ID) + } + if (!includeCodexTerminalServerIsolation) { + hiddenIds.add(CODEX_TERMINAL_SERVER_ISOLATION_SEARCH_ENTRY_ID) + } + return getAllAgentsPaneSearchEntries().filter( + (entry) => !('id' in entry) || !hiddenIds.has(entry.id) ) } 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 new file mode 100644 index 00000000000..b48f6de8166 --- /dev/null +++ b/src/renderer/src/components/settings/codex-terminal-server-isolation-copy.ts @@ -0,0 +1,32 @@ +import { translate } from '@/i18n/i18n' +import { searchKeywords } from './settings-search-keywords' + +export function getCodexTerminalServerIsolationTitle(): string { + return translate( + 'settings.agents.codexTerminalServerIsolation.title', + 'Run each Codex terminal on its own server' + ) +} + +export function getCodexTerminalServerIsolationDescription(): string { + return translate( + 'settings.agents.codexTerminalServerIsolation.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." + ) +} + +export function getCodexTerminalServerIsolationSearchKeywords(): string[] { + return searchKeywords([ + { + key: 'auto.components.settings.agents.search.5ded38b843', + fallback: 'codex', + englishOnly: true + }, + { key: 'auto.components.settings.agents.search.7e15b89f6e', fallback: 'server' }, + { key: 'auto.components.settings.agents.search.ff457e1e7b', fallback: 'daemon' }, + { key: 'auto.components.settings.agents.search.9f3becc4e8', fallback: 'shared' }, + { key: 'auto.components.settings.agents.search.34337ed5c7', fallback: 'isolate' }, + { key: 'auto.components.settings.agents.search.9a84e65118', fallback: 'agents overview' }, + { key: 'auto.components.settings.agents.search.6984d4291a', fallback: 'status' } + ]) +} diff --git a/src/renderer/src/components/terminal-pane/codex-terminal-server-isolation-notice.test.ts b/src/renderer/src/components/terminal-pane/codex-terminal-server-isolation-notice.test.ts new file mode 100644 index 00000000000..ce7ee221898 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/codex-terminal-server-isolation-notice.test.ts @@ -0,0 +1,152 @@ +// @vitest-environment happy-dom + +import { act, createElement } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { CODEX_TERMINAL_SERVER_ISOLATION_SETTINGS_TARGET_ID } from '@/lib/settings-navigation-types' +import { useCodexTerminalServerIsolationNotice } from './codex-terminal-server-isolation-notice' + +// Why a real zustand store double: the hook relies on subscribe/setState semantics. +const { toastInfoMock, harness } = vi.hoisted(() => ({ + toastInfoMock: vi.fn(), + harness: { setState: (_patch: Record, _replace?: true): void => {} } +})) + +vi.mock('sonner', () => ({ toast: { info: toastInfoMock } })) + +vi.mock('@/store', async () => { + const { createStore } = await import('zustand/vanilla') + const backing = createStore>()(() => ({})) + harness.setState = (patch, replace) => + replace ? backing.setState(patch, true) : backing.setState(patch) + const useAppStore = (selector: (state: Record) => T): T => + selector(backing.getState()) + return { useAppStore: Object.assign(useAppStore, backing) } +}) + +const store = { + setState: (patch: Record, replace?: true) => harness.setState(patch, replace) +} +let seen = false + +const openSettingsPage = vi.fn() +const openSettingsTarget = vi.fn() +const mountedRoots: Root[] = [] + +function resetStore(overrides: Record = {}): void { + seen = false + store.setState( + { + persistedUIReady: true, + codexTerminalServerIsolationNoticeSeen: false, + settings: { codexTerminalServerIsolation: true }, + tabsByWorktree: {}, + agentStatusByPaneKey: {}, + paneForegroundAgentByPaneKey: {}, + openSettingsPage, + openSettingsTarget, + markCodexTerminalServerIsolationNoticeSeen: () => { + seen = true + store.setState({ codexTerminalServerIsolationNoticeSeen: true }) + }, + ...overrides + }, + true + ) +} + +function HookProbe(): null { + useCodexTerminalServerIsolationNotice() + return null +} + +async function mountProbe(): Promise { + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + mountedRoots.push(root) + await act(async () => { + root.render(createElement(HookProbe)) + }) +} + +const codexTab = { 'wt-1': [{ id: 'tab-1', launchAgent: 'codex' }] } + +describe('useCodexTerminalServerIsolationNotice', () => { + beforeEach(() => { + toastInfoMock.mockReset() + openSettingsPage.mockReset() + openSettingsTarget.mockReset() + resetStore() + }) + + afterEach(() => { + for (const root of mountedRoots.splice(0)) { + act(() => root.unmount()) + } + document.body.innerHTML = '' + }) + + it('shows once when the first Codex terminal starts, and marks it seen', async () => { + await mountProbe() + expect(toastInfoMock).not.toHaveBeenCalled() + + act(() => store.setState({ tabsByWorktree: codexTab })) + act(() => store.setState({ agentStatusByPaneKey: { 'tab-2:leaf': { agentType: 'codex' } } })) + + expect(toastInfoMock).toHaveBeenCalledTimes(1) + expect(toastInfoMock.mock.calls[0]?.[1]).toMatchObject({ duration: Infinity }) + expect(seen).toBe(true) + }) + + it.each([ + [ + 'a typed codex seen by hooks', + { agentStatusByPaneKey: { 'tab-1:leaf': { agentType: 'codex' } } } + ], + [ + 'a typed codex in the foreground', + { paneForegroundAgentByPaneKey: { 'tab-1:leaf': { agent: 'codex' } } } + ] + ])('also triggers on %s', async (_name, patch) => { + await mountProbe() + act(() => store.setState(patch)) + expect(toastInfoMock).toHaveBeenCalledTimes(1) + }) + + it('never shows for other agents', async () => { + await mountProbe() + act(() => + store.setState({ + tabsByWorktree: { 'wt-1': [{ id: 'tab-1', launchAgent: 'claude' }] }, + agentStatusByPaneKey: { 'tab-1:leaf': { agentType: 'claude' } }, + paneForegroundAgentByPaneKey: { 'tab-1:leaf': { agent: 'opencode' } } + }) + ) + expect(toastInfoMock).not.toHaveBeenCalled() + }) + + it.each([ + ['it was already seen', { codexTerminalServerIsolationNoticeSeen: true }], + ['the user turned the setting off', { settings: { codexTerminalServerIsolation: false } }], + ['persisted UI has not hydrated', { persistedUIReady: false }] + ])('stays quiet when %s', async (_name, overrides) => { + resetStore({ ...overrides, tabsByWorktree: codexTab }) + await mountProbe() + expect(toastInfoMock).not.toHaveBeenCalled() + }) + + it('opens Settings at the Codex server setting', async () => { + resetStore({ tabsByWorktree: codexTab }) + await mountProbe() + + toastInfoMock.mock.calls[0]?.[1]?.action.onClick() + + expect(openSettingsPage).toHaveBeenCalledTimes(1) + expect(openSettingsTarget).toHaveBeenCalledWith({ + pane: 'agents', + repoId: null, + sectionId: CODEX_TERMINAL_SERVER_ISOLATION_SETTINGS_TARGET_ID + }) + }) +}) 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 new file mode 100644 index 00000000000..7ff018ee6f6 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/codex-terminal-server-isolation-notice.ts @@ -0,0 +1,111 @@ +import { useEffect } from 'react' +import { toast } from 'sonner' +import { translate } from '@/i18n/i18n' +import { useAppStore } from '@/store' +import type { AppState } from '@/store/types' +import { isPairedWebClientWindow } from '@/lib/desktop-window-chrome' +import { CODEX_TERMINAL_SERVER_ISOLATION_SETTINGS_TARGET_ID } from '@/lib/settings-navigation-types' +import { isCodexTerminalServerIsolationEnabled } from '../../../../shared/codex-terminal-server-isolation' + +type CodexNoticeState = Pick< + AppState, + | 'persistedUIReady' + | 'codexTerminalServerIsolationNoticeSeen' + | 'settings' + | 'tabsByWorktree' + | 'agentStatusByPaneKey' + | 'paneForegroundAgentByPaneKey' +> + +// Why three sources: Orca-launched tabs, hook-reported agents (SSH too), and a typed `codex` seen locally. +function hasCodexTerminal(state: CodexNoticeState): boolean { + return ( + Object.values(state.tabsByWorktree).some((tabs) => + tabs.some((tab) => tab.launchAgent === 'codex') + ) || + Object.values(state.agentStatusByPaneKey).some((entry) => entry.agentType === 'codex') || + Object.values(state.paneForegroundAgentByPaneKey).some((entry) => entry.agent === 'codex') + ) +} + +export function shouldShowCodexTerminalServerIsolationNotice(state: CodexNoticeState): boolean { + return ( + state.persistedUIReady && + !state.codexTerminalServerIsolationNoticeSeen && + state.settings !== null && + // Why: a user who already opted out needs no announcement of the default. + isCodexTerminalServerIsolationEnabled(state.settings) && + hasCodexTerminal(state) + ) +} + +function didNoticeInputsChange(state: CodexNoticeState, previous: CodexNoticeState): boolean { + return ( + state.persistedUIReady !== previous.persistedUIReady || + state.settings !== previous.settings || + state.tabsByWorktree !== previous.tabsByWorktree || + state.agentStatusByPaneKey !== previous.agentStatusByPaneKey || + state.paneForegroundAgentByPaneKey !== previous.paneForegroundAgentByPaneKey + ) +} + +function showCodexTerminalServerIsolationNotice(): void { + // Why mark before showing: seen means shown, so a quit or reload never repeats it. + useAppStore.getState().markCodexTerminalServerIsolationNoticeSeen() + toast.info( + translate( + 'terminal.codexTerminalServerIsolationNotice.title', + 'Orca now runs Codex without its shared server' + ), + { + // Why a stable id: a late sync that resets the flag can't stack a second toast. + id: 'codex-terminal-server-isolation-notice', + description: translate( + 'terminal.codexTerminalServerIsolationNotice.description', + 'This makes agent status more reliable. You can turn it back on in Settings.' + ), + duration: Infinity, + action: { + label: translate( + 'terminal.codexTerminalServerIsolationNotice.openSettings', + 'Open Settings' + ), + onClick: () => { + const store = useAppStore.getState() + store.openSettingsPage() + store.openSettingsTarget({ + pane: 'agents', + repoId: null, + sectionId: CODEX_TERMINAL_SERVER_ISOLATION_SETTINGS_TARGET_ID + }) + } + } + } + ) +} + +export function useCodexTerminalServerIsolationNotice(): void { + const seen = useAppStore((s) => s.codexTerminalServerIsolationNoticeSeen) + + useEffect(() => { + // Why: a paired web client's terminals follow the host's setting, not this window's. + if (seen || isPairedWebClientWindow()) { + return + } + if (shouldShowCodexTerminalServerIsolationNotice(useAppStore.getState())) { + showCodexTerminalServerIsolationNotice() + return + } + // Why a filtered subscription: a selector would rescan every tab on each store write. + const unsubscribe = useAppStore.subscribe((state, previous) => { + if ( + didNoticeInputsChange(state, previous) && + shouldShowCodexTerminalServerIsolationNotice(state) + ) { + unsubscribe() + showCodexTerminalServerIsolationNotice() + } + }) + return unsubscribe + }, [seen]) +} diff --git a/src/renderer/src/hooks/settings-navigation-capability-sections.ts b/src/renderer/src/hooks/settings-navigation-capability-sections.ts index 61875e3bb60..e06a1ae450a 100644 --- a/src/renderer/src/hooks/settings-navigation-capability-sections.ts +++ b/src/renderer/src/hooks/settings-navigation-capability-sections.ts @@ -43,7 +43,8 @@ export function buildCapabilitySettingsSections({ searchEntries: getAgentsPaneSearchEntries({ includeAgentAwake: !isWebClient, includeAgentRuntime: isLocalWindowsHost, - includeAgentWorkspaceTrust: !isWebClient + includeAgentWorkspaceTrust: !isWebClient, + includeCodexTerminalServerIsolation: !isWebClient }), group: 'capabilities' }, diff --git a/src/renderer/src/hooks/useSettingsNavigationMetadata.test.ts b/src/renderer/src/hooks/useSettingsNavigationMetadata.test.ts index e0e4761aad0..d3e75ca301b 100644 --- a/src/renderer/src/hooks/useSettingsNavigationMetadata.test.ts +++ b/src/renderer/src/hooks/useSettingsNavigationMetadata.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import { buildSettingsNavigationMetadata } from './useSettingsNavigationMetadata' import type { Repo } from '../../../shared/repo-types' +import { getCodexTerminalServerIsolationTitle } from '../components/settings/codex-terminal-server-isolation-copy' const repo = { id: 'repo-1', @@ -179,6 +180,21 @@ describe('settings navigation metadata', () => { ) }) + it('lists the host-only Codex server setting in desktop Agents search only', () => { + const agentTitles = (isWebClient: boolean): string[] | undefined => + buildSettingsNavigationMetadata({ + isMac: false, + isWindows: false, + isWebClient, + repos: [repo] + }) + .find((section) => section.id === 'agents') + ?.searchEntries.map((entry) => entry.title) + + expect(agentTitles(false)).toContain(getCodexTerminalServerIsolationTitle()) + expect(agentTitles(true)).not.toContain(getCodexTerminalServerIsolationTitle()) + }) + it('keeps the Browser shortcut searchable for a capable web runtime', () => { const sections = buildSettingsNavigationMetadata({ isMac: false, diff --git a/src/renderer/src/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json index 1f62a31e0d5..c22722f37bd 100644 --- a/src/renderer/src/i18n/en-runtime-required.json +++ b/src/renderer/src/i18n/en-runtime-required.json @@ -1588,14 +1588,12 @@ "042c551bc5": "config", "0d1c334987": "lid", "0d752916f8": "hooks", - "agent-trust-trust": "trust", - "agent-trust-folder": "folder", - "agent-trust-worktree": "worktree", "13b20636a6": "waiting", "167daeb5e9": "command", "2afd3b5858": "enable", "2e188c771c": "hide", "32836788b0": "generated title", + "34337ed5c7": "isolate", "48f84d10f1": "running", "52115d0d7c": "auto", "5784ae8c43": "rename", @@ -1605,16 +1603,22 @@ "66b6b82eb4": "awake", "6956646a1e": "title", "6984d4291a": "status", + "7e15b89f6e": "server", "845ad9128a": "power", "848dcae8d3": "generated", "8599603496": "done", "87fffe6c20": "show", "8a17fd6026": "stable", "966890236d": "name", + "9a84e65118": "agents overview", + "9f3becc4e8": "shared", "a6d594c17d": "install", "a79d266f71": "session", "afbf35be68": "stable session", "affbf130f6": "working", + "agent-trust-folder": "folder", + "agent-trust-trust": "trust", + "agent-trust-worktree": "worktree", "be59907510": "override", "be7ea3553b": "tab", "c1317fe641": "restore", @@ -1627,6 +1631,7 @@ "ef804b7337": "Agent Location", "f2932bf22b": "detected", "f412abbba5": "claude", + "ff457e1e7b": "daemon", "ff8de8a2ad": "display" } }, diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 9dc86eadf8d..57db8797e06 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -208,6 +208,12 @@ "restartRequired": "Restart required", "unavailable": "Browser identity is unavailable." } + }, + "agents": { + "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." + } } }, "menu": { @@ -9190,6 +9196,11 @@ "042c551bc5": "config", "f412abbba5": "claude", "5ded38b843": "codex", + "7e15b89f6e": "server", + "ff457e1e7b": "daemon", + "9f3becc4e8": "shared", + "34337ed5c7": "isolate", + "9a84e65118": "agents overview", "be7ea3553b": "tab", "6956646a1e": "title", "32836788b0": "generated title", @@ -18523,6 +18534,11 @@ "title": "Could not save terminal session", "capacity": "The device saving this terminal reported full storage or an exceeded storage quota. Free space on that device, then try again.", "unknown": "Orca could not save the terminal state. Try again. If this continues, share the Orca logs with support so we can identify the cause." + }, + "codexTerminalServerIsolationNotice": { + "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" } }, "fileExplorer": { diff --git a/src/renderer/src/lib/settings-navigation-types.ts b/src/renderer/src/lib/settings-navigation-types.ts index 9c82da972e7..9aafdc3649c 100644 --- a/src/renderer/src/lib/settings-navigation-types.ts +++ b/src/renderer/src/lib/settings-navigation-types.ts @@ -66,6 +66,8 @@ export const BROWSER_TERMINAL_LINK_ACTIONS_SETTINGS_TARGET_ID = 'browser-termina export const BROWSER_CLIENT_HOSTED_REMOTE_SETTINGS_TARGET_ID = 'browser-client-hosted-remote' export const BROWSER_SSH_WORKSPACE_ROUTING_SETTINGS_TARGET_ID = 'browser-ssh-workspace-routing' export const BROWSER_USER_AGENT_SETTINGS_TARGET_ID = 'browser-user-agent' +export const CODEX_TERMINAL_SERVER_ISOLATION_SETTINGS_TARGET_ID = + 'agents-codex-terminal-server-isolation' export const GLOBAL_WORKTREE_VISIBILITY_SETTINGS_TARGET_ID = 'general-global-worktree-visibility' export type SettingsNavigationTarget = { diff --git a/src/renderer/src/store/slices/ui/ui-slice-contract-contextual.ts b/src/renderer/src/store/slices/ui/ui-slice-contract-contextual.ts index 0f997f5aeea..4aaec0e5df6 100644 --- a/src/renderer/src/store/slices/ui/ui-slice-contract-contextual.ts +++ b/src/renderer/src/store/slices/ui/ui-slice-contract-contextual.ts @@ -114,4 +114,6 @@ export type UISliceContextual = { dismissUsagePercentageDisplayChangeNotice: () => void usageEmptyStateDismissed: boolean dismissUsageEmptyState: () => void + codexTerminalServerIsolationNoticeSeen: boolean + markCodexTerminalServerIsolationNoticeSeen: () => void } diff --git a/src/renderer/src/store/slices/ui/ui-slice-hydration-actions.ts b/src/renderer/src/store/slices/ui/ui-slice-hydration-actions.ts index 6ee5424e480..74f45118b09 100644 --- a/src/renderer/src/store/slices/ui/ui-slice-hydration-actions.ts +++ b/src/renderer/src/store/slices/ui/ui-slice-hydration-actions.ts @@ -242,6 +242,8 @@ export function createUiHydrationActions(set: UISliceSet, _get: UISliceGet): Par ui.usagePercentageDisplayChangeNoticeDismissed === true, // Why: default false so existing users still see the CTA; only explicit dismissal persists true. usageEmptyStateDismissed: ui.usageEmptyStateDismissed === true, + codexTerminalServerIsolationNoticeSeen: + ui.codexTerminalServerIsolationNoticeSeen === true, ...hydrateAgentReadState(ui), workspaceCleanupDismissals: sanitizeWorkspaceCleanupDismissals( ui.workspaceCleanup?.dismissals diff --git a/src/renderer/src/store/slices/ui/ui-slice-trust-actions.ts b/src/renderer/src/store/slices/ui/ui-slice-trust-actions.ts index 8111c8f8f1f..dabf69bb29d 100644 --- a/src/renderer/src/store/slices/ui/ui-slice-trust-actions.ts +++ b/src/renderer/src/store/slices/ui/ui-slice-trust-actions.ts @@ -138,6 +138,16 @@ export function createUiTrustActions(set: UISliceSet, _get: UISliceGet): Partial } window.api.ui.set({ usageEmptyStateDismissed: true }).catch(console.error) return { usageEmptyStateDismissed: true } + }), + // Why default true: nothing may toast before hydration reads the persisted flag. + codexTerminalServerIsolationNoticeSeen: true, + markCodexTerminalServerIsolationNoticeSeen: () => + set((s) => { + if (s.codexTerminalServerIsolationNoticeSeen) { + return s + } + window.api.ui.set({ codexTerminalServerIsolationNoticeSeen: true }).catch(console.error) + return { codexTerminalServerIsolationNoticeSeen: true } }) } } diff --git a/src/shared/codex-terminal-server-isolation.ts b/src/shared/codex-terminal-server-isolation.ts new file mode 100644 index 00000000000..daa68a972d7 --- /dev/null +++ b/src/shared/codex-terminal-server-isolation.ts @@ -0,0 +1,26 @@ +import type { GlobalSettings } from './global-settings-types' + +type CodexTerminalServerIsolationSettings = + | Partial> + | null + | undefined + +// Why this name: the codex shell wrapper (codex-shell-launch-preflight.ts) reads it to skip --no-daemon. +const CODEX_ISOLATE_ENV = 'ORCA_CODEX_ISOLATE' + +export function isCodexTerminalServerIsolationEnabled( + settings: CodexTerminalServerIsolationSettings +): boolean { + return settings?.codexTerminalServerIsolation !== false +} + +/** Opt-out only: with isolation on nothing is injected, so behaviour matches the pre-setting default. */ +export function withCodexTerminalServerIsolationEnv( + env: Record | undefined, + settings: CodexTerminalServerIsolationSettings +): Record | undefined { + if (isCodexTerminalServerIsolationEnabled(settings)) { + return env + } + return { ...env, [CODEX_ISOLATE_ENV]: '0' } +} diff --git a/src/shared/default-global-settings.ts b/src/shared/default-global-settings.ts index 33deed6de9c..066fe355182 100644 --- a/src/shared/default-global-settings.ts +++ b/src/shared/default-global-settings.ts @@ -221,6 +221,7 @@ export function buildDefaultSettings(args: { agentYoloDefaultsMigrated: true, agentStatusHooksEnabled: true, agentWorkspaceTrustEnabled: true, + codexTerminalServerIsolation: true, tabAutoGenerateTitle: false, confirmClosePinnedTab: true, editorPreviewTabsEnabled: true, diff --git a/src/shared/global-settings-types.ts b/src/shared/global-settings-types.ts index eedb140c3ad..d8634ac7a53 100644 --- a/src/shared/global-settings-types.ts +++ b/src/shared/global-settings-types.ts @@ -419,6 +419,8 @@ export type GlobalSettings = { agentStatusHooksEnabled: boolean /** Pre-trust the worktree or folder Orca starts an agent in, so its "trust this folder?" prompt is skipped. Defaults on. */ 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 /** 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. */ diff --git a/src/shared/persisted-ui-state-types.ts b/src/shared/persisted-ui-state-types.ts index 97e7afced95..f4cbca03f8b 100644 --- a/src/shared/persisted-ui-state-types.ts +++ b/src/shared/persisted-ui-state-types.ts @@ -170,6 +170,8 @@ export type PersistedUIState = { usagePercentageDisplayChangeNoticeDismissed?: boolean /** User-hidden empty-state usage CTA; permanently hides the "Connect AI accounts" prompt even if providers are later disconnected. */ usageEmptyStateDismissed?: boolean + /** One-shot toast announcing per-terminal Codex servers; set when shown, so absent means not yet seen. */ + codexTerminalServerIsolationNoticeSeen?: boolean /** URL for new browser tabs; null = blank tab. */ browserDefaultUrl?: string | null browserDefaultSearchEngine?: 'google' | 'duckduckgo' | 'bing' | 'kagi' | null diff --git a/src/shared/rpc-contract/client-ui-params.ts b/src/shared/rpc-contract/client-ui-params.ts index c5a5cd2195b..a039594604f 100644 --- a/src/shared/rpc-contract/client-ui-params.ts +++ b/src/shared/rpc-contract/client-ui-params.ts @@ -238,6 +238,7 @@ export const UiUpdateFields = z projectOrderManualDefaultNoticeDismissed: z.boolean().optional(), usagePercentageDisplayChangeNoticeDismissed: z.boolean().optional(), usageEmptyStateDismissed: z.boolean().optional(), + codexTerminalServerIsolationNoticeSeen: z.boolean().optional(), petVisible: z.boolean().optional(), petId: z.string().optional(), customPets: UnknownRecordArray.optional(),