From a0abcb6818bfb2fcb1adf4bdecf097d81ea9c9be Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Tue, 29 Sep 2026 21:44:27 -0400 Subject: [PATCH] feat(settings): let Codex terminals opt back into Codex's shared server (#23929) * feat(settings): add a setting to run Codex terminals on Codex's shared server Off injects ORCA_CODEX_ISOLATE=0 into new terminals on every host (local, daemon, SSH relay, WSL), which the codex shell wrapper from #23900 reads to skip --no-daemon. On (the default) injects nothing. * feat(terminal): announce per-terminal Codex servers once Shows a one-time toast the first time a Codex terminal starts, with an Open Settings action that lands on the new Codex server setting. The seen flag persists in UI state and is set when the toast is shown. * fix(settings): drop the status warning from the Codex server setting * fix(terminal): simplify the Codex shared-server notice * fix(terminal): say agent status in the Codex notice * fix(settings): hide the Codex server setting from paired web search Gives its search entry an id and gates it with includeCodexTerminalServerIsolation, as the other host-only rows are, so a paired web client cannot find a row it never renders. Its search keywords now use catalogued agents-search keys instead of missing ones; CODEX_ISOLATE_ENV is no longer exported; the toast id comment names the case it guards. * test(settings): assert the Codex server search gate through the metadata builder Calling getAgentsPaneSearchEntries directly stayed green with the web gate deleted; the metadata builder test fails without it. --- .../ipc/spawn-options-codex-isolation.test.ts | 68 ++++++++ src/main/ipc/pty/ipc/spawn-options.ts | 9 +- src/main/ipc/pty/runtime/spawn-options.ts | 3 + src/main/pty/wsl-orca-env.test.ts | 2 + .../pty-handler-spawn-environment.test.ts | 11 ++ .../src/app-shell/use-app-shell-services.ts | 2 + .../components/settings/AgentsPane.test.tsx | 12 ++ .../src/components/settings/AgentsPane.tsx | 9 +- .../CodexTerminalServerIsolationSetting.tsx | 41 +++++ .../src/components/settings/agents-search.ts | 39 +++-- .../codex-terminal-server-isolation-copy.ts | 32 ++++ ...x-terminal-server-isolation-notice.test.ts | 152 ++++++++++++++++++ .../codex-terminal-server-isolation-notice.ts | 111 +++++++++++++ ...settings-navigation-capability-sections.ts | 3 +- .../useSettingsNavigationMetadata.test.ts | 16 ++ .../src/i18n/en-runtime-required.json | 11 +- src/renderer/src/i18n/locales/en.json | 16 ++ .../src/lib/settings-navigation-types.ts | 2 + .../slices/ui/ui-slice-contract-contextual.ts | 2 + .../slices/ui/ui-slice-hydration-actions.ts | 2 + .../store/slices/ui/ui-slice-trust-actions.ts | 10 ++ src/shared/codex-terminal-server-isolation.ts | 26 +++ src/shared/default-global-settings.ts | 1 + src/shared/global-settings-types.ts | 2 + src/shared/persisted-ui-state-types.ts | 2 + src/shared/rpc-contract/client-ui-params.ts | 1 + 26 files changed, 568 insertions(+), 17 deletions(-) create mode 100644 src/main/ipc/pty/ipc/spawn-options-codex-isolation.test.ts create mode 100644 src/renderer/src/components/settings/CodexTerminalServerIsolationSetting.tsx create mode 100644 src/renderer/src/components/settings/codex-terminal-server-isolation-copy.ts create mode 100644 src/renderer/src/components/terminal-pane/codex-terminal-server-isolation-notice.test.ts create mode 100644 src/renderer/src/components/terminal-pane/codex-terminal-server-isolation-notice.ts create mode 100644 src/shared/codex-terminal-server-isolation.ts 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(),