From ffd950a220ec61a3dcac128bfc5da67a8390dbfe Mon Sep 17 00:00:00 2001 From: SSU-WEI HUANG Date: Wed, 28 Oct 2026 12:12:55 +0800 Subject: [PATCH] fix(codex): drop safe-yolo from Codex permission modes (#2183) * fix(codex): drop safe-yolo from Codex permission modes Every Codex session runs on the native shared runtime (runCodex is runSharedCodex), which rejects safe-yolo. The mode stayed in CODEX_PERMISSION_MODES and six filters hid it again in the web picker, composer, hub permission-mode route, hapi set-mode and both native apps. API callers that validate against the flavor catalog (e.g. a spawn with permissionMode: 'safe-yolo') still passed it through and only failed inside the runner. Remove it from the Codex catalog (shared, iOS, Android, fixture) and delete the filters. autoReview is the native replacement. Safe Yolo stays for Gemini, Kimi and Copilot. Co-Authored-By: Claude Opus 5.5 * test(ios): Codex option list now has four modes Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- .../companion/feature/chat/ChatViewModel.kt | 4 +-- .../feature/chat/PermissionModeChip.kt | 4 +-- .../feature/newsession/NewSessionForm.kt | 4 +-- .../feature/newsession/NewSessionViewModel.kt | 2 +- .../feature/chat/PermissionModeChipTest.kt | 7 ------ .../newsession/NewSessionViewModelTest.kt | 2 +- .../kotlin/app/hapi/protocol/catalog/Modes.kt | 6 +---- .../app/hapi/protocol/catalog/CatalogTest.kt | 2 +- cli/src/codex/codexLocalLauncher.test.ts | 25 +------------------ cli/src/codex/codexLocalLauncher.ts | 2 +- cli/src/codex/codexRemoteLauncher.ts | 2 +- cli/src/codex/shared/launch.test.ts | 1 - cli/src/codex/shared/launch.ts | 3 +-- cli/src/codex/shared/root.ts | 3 +-- cli/src/codex/utils/appServerConfig.test.ts | 13 +--------- cli/src/codex/utils/permissionHandler.test.ts | 19 +------------- .../codex/utils/permissionModeConfig.test.ts | 8 ------ cli/src/codex/utils/permissionModeConfig.ts | 9 ------- cli/src/codex/utils/slashCommands.ts | 2 +- cli/src/modules/pingPeer/pingPeer.ts | 5 +--- .../pingPeer/setPeerPermissionMode.test.ts | 6 ++--- docs/api/client-contract/rest.md | 2 -- docs/guide/codex-shared-sessions.md | 5 ++-- hub/src/web/routes/sessions.test.ts | 6 ++--- hub/src/web/routes/sessions.ts | 3 --- .../Features/NewSession/NewSessionModel.swift | 2 +- ios/HapiTests/SessionConfigTests.swift | 1 - .../Chat/ChatInteractionState.swift | 4 +-- .../NewSession/NewSessionForm.swift | 2 +- .../Catalog/PermissionModes.swift | 7 +----- .../HapiClientTests/NewSessionFormTests.swift | 2 +- .../HapiProtocolTests/CatalogTests.swift | 4 +-- shared/fixtures/catalogs/modes.json | 5 ---- shared/src/modes.test.ts | 3 +-- shared/src/modes.ts | 7 +----- .../AssistantChat/HappyComposer.tsx | 6 ++--- .../components/NewSession/PermissionField.tsx | 4 +-- .../NewSession/newSessionFormDraft.ts | 4 +-- web/src/components/NewSession/preferences.ts | 6 ++--- web/src/components/SessionChat.tsx | 1 - web/src/lib/codexFamilyPermissionAgents.ts | 2 +- 41 files changed, 44 insertions(+), 161 deletions(-) diff --git a/android/app/src/main/kotlin/app/hapi/companion/feature/chat/ChatViewModel.kt b/android/app/src/main/kotlin/app/hapi/companion/feature/chat/ChatViewModel.kt index ccefd920..ae23c456 100644 --- a/android/app/src/main/kotlin/app/hapi/companion/feature/chat/ChatViewModel.kt +++ b/android/app/src/main/kotlin/app/hapi/companion/feature/chat/ChatViewModel.kt @@ -1671,9 +1671,7 @@ class ChatViewModel( active = detail?.active ?: summary?.active ?: false, controlledByUser = terminalControlled(detail), permissionMode = detail?.permissionMode, - permissionModes = PermissionModes.forFlavor(flavor).filter { - detail?.metadata?.capabilities?.concurrentClients != true || it != PermissionMode.SafeYolo - }, + permissionModes = PermissionModes.forFlavor(flavor), model = model, modelOptions = modelOptions, modelOptionsLoading = modelOptionsLoading, diff --git a/android/app/src/main/kotlin/app/hapi/companion/feature/chat/PermissionModeChip.kt b/android/app/src/main/kotlin/app/hapi/companion/feature/chat/PermissionModeChip.kt index 040e3504..e8f8db5c 100644 --- a/android/app/src/main/kotlin/app/hapi/companion/feature/chat/PermissionModeChip.kt +++ b/android/app/src/main/kotlin/app/hapi/companion/feature/chat/PermissionModeChip.kt @@ -25,9 +25,7 @@ import app.hapi.protocol.catalog.PermissionModes * it only when the flavor offers it, and hide `default` everywhere except * copilot (model=auto sessions still surface the bottom-right mode chip). * - * This consults the *unfiltered* flavor catalog: the concurrent-clients - * Safe-Yolo carve-out only narrows the switcher, never the chip. Unknown - * wire ids stay hidden (no raw fallback — same as the web span, which keys + * Unknown wire ids stay hidden (no raw fallback — same as the web span, which keys * off `isPermissionModeAllowedForFlavor`). */ object PermissionModeChipRule { diff --git a/android/app/src/main/kotlin/app/hapi/companion/feature/newsession/NewSessionForm.kt b/android/app/src/main/kotlin/app/hapi/companion/feature/newsession/NewSessionForm.kt index dec06da8..bee1f8c7 100644 --- a/android/app/src/main/kotlin/app/hapi/companion/feature/newsession/NewSessionForm.kt +++ b/android/app/src/main/kotlin/app/hapi/companion/feature/newsession/NewSessionForm.kt @@ -126,7 +126,7 @@ object NewSessionLogic { } val permissionMode = if (usesSharedPermissionModeState(agent)) { stored?.permissionMode - ?.takeIf { mode -> PermissionModes.forLaunch(agent).any { it.wireId == mode } } + ?.takeIf { mode -> PermissionModes.forFlavor(agent).any { it.wireId == mode } } ?: "default" } else { null @@ -352,7 +352,7 @@ object NewSessionLogic { sessionType = draft.sessionType, worktreeName = draft.worktreeName, ) - val allowedModes = PermissionModes.forLaunch(base.agent).map { it.wireId } + val allowedModes = PermissionModes.forFlavor(base.agent).map { it.wireId } val sessionType = if (base.sessionType == SESSION_TYPE_WORKTREE) SESSION_TYPE_WORKTREE else SESSION_TYPE_SIMPLE return base.copy( permissionMode = if (base.permissionMode in allowedModes) base.permissionMode else "default", diff --git a/android/app/src/main/kotlin/app/hapi/companion/feature/newsession/NewSessionViewModel.kt b/android/app/src/main/kotlin/app/hapi/companion/feature/newsession/NewSessionViewModel.kt index bf10b34e..447646bb 100644 --- a/android/app/src/main/kotlin/app/hapi/companion/feature/newsession/NewSessionViewModel.kt +++ b/android/app/src/main/kotlin/app/hapi/companion/feature/newsession/NewSessionViewModel.kt @@ -940,7 +940,7 @@ class NewSessionViewModel( val permission: PermissionUi = when { agent == "pi" || agent == "dsh" -> PermissionUi.Managed usesNativePermissionSelect(agent) -> PermissionUi.NativeSelect( - PermissionModes.forLaunch(agent).map { mode -> + PermissionModes.forFlavor(agent).map { mode -> val unavailable = grokAutoUnavailable && mode == PermissionMode.Auto OptionItem( value = mode.wireId, diff --git a/android/app/src/test/kotlin/app/hapi/companion/feature/chat/PermissionModeChipTest.kt b/android/app/src/test/kotlin/app/hapi/companion/feature/chat/PermissionModeChipTest.kt index c4bba703..58aead43 100644 --- a/android/app/src/test/kotlin/app/hapi/companion/feature/chat/PermissionModeChipTest.kt +++ b/android/app/src/test/kotlin/app/hapi/companion/feature/chat/PermissionModeChipTest.kt @@ -51,11 +51,4 @@ class PermissionModeChipTest { assertNull(PermissionModeChipRule.displayMode(null, "claude")) assertNull(PermissionModeChipRule.displayMode("", "copilot")) } - - @Test fun safeYoloShowsOnTheChipEvenThoughTheSwitcherFiltersIt() { - // The concurrent-clients carve-out (buildConfigUi) narrows the - // switcher only; the chip consults the unfiltered flavor catalog, - // like the web span. - assertEquals(PermissionMode.SafeYolo, PermissionModeChipRule.displayMode("safe-yolo", "codex")) - } } diff --git a/android/app/src/test/kotlin/app/hapi/companion/feature/newsession/NewSessionViewModelTest.kt b/android/app/src/test/kotlin/app/hapi/companion/feature/newsession/NewSessionViewModelTest.kt index 676f68aa..c93b6fa6 100644 --- a/android/app/src/test/kotlin/app/hapi/companion/feature/newsession/NewSessionViewModelTest.kt +++ b/android/app/src/test/kotlin/app/hapi/companion/feature/newsession/NewSessionViewModelTest.kt @@ -415,7 +415,7 @@ class NewSessionLogicTest { NewSessionForm(agent = "codex", permissionMode = "safe-yolo"), ) assertEquals("default", staleMode.permissionMode) - assertEquals(listOf("default", "read-only", "autoReview", "yolo"), app.hapi.protocol.catalog.PermissionModes.forLaunch("codex").map { it.wireId }) + assertEquals(listOf("default", "read-only", "autoReview", "yolo"), app.hapi.protocol.catalog.PermissionModes.forFlavor("codex").map { it.wireId }) assertEquals("safe-yolo", NewSessionLogic.sanitizeDraft(NewSessionForm(agent = "kimi", permissionMode = "safe-yolo")).permissionMode) } diff --git a/android/core/protocol/src/main/kotlin/app/hapi/protocol/catalog/Modes.kt b/android/core/protocol/src/main/kotlin/app/hapi/protocol/catalog/Modes.kt index 3a9e517f..17b42592 100644 --- a/android/core/protocol/src/main/kotlin/app/hapi/protocol/catalog/Modes.kt +++ b/android/core/protocol/src/main/kotlin/app/hapi/protocol/catalog/Modes.kt @@ -57,7 +57,7 @@ object PermissionModes { /** `CODEX_PERMISSION_MODES`. */ val CODEX = listOf( PermissionMode.Default, PermissionMode.ReadOnly, - PermissionMode.SafeYolo, PermissionMode.AutoReview, PermissionMode.Yolo, + PermissionMode.AutoReview, PermissionMode.Yolo, ) /** `GEMINI_PERMISSION_MODES` (kimi/copilot share the same list). */ @@ -103,10 +103,6 @@ object PermissionModes { /** `isPermissionModeAllowedForFlavor`. */ fun isAllowedForFlavor(mode: PermissionMode, flavor: String?): Boolean = mode in forFlavor(flavor) - - /** New Codex sessions use the shared runtime, without HAPI Safe Yolo. */ - fun forLaunch(flavor: String?): List = - forFlavor(flavor).filter { flavor != "codex" || it != PermissionMode.SafeYolo } } /** `CODEX_COLLABORATION_MODES` + labels. */ diff --git a/android/core/protocol/src/test/kotlin/app/hapi/protocol/catalog/CatalogTest.kt b/android/core/protocol/src/test/kotlin/app/hapi/protocol/catalog/CatalogTest.kt index 3934ab0d..7f821435 100644 --- a/android/core/protocol/src/test/kotlin/app/hapi/protocol/catalog/CatalogTest.kt +++ b/android/core/protocol/src/test/kotlin/app/hapi/protocol/catalog/CatalogTest.kt @@ -50,7 +50,7 @@ class CatalogTest { PermissionModes.forFlavor("claude").map { it.wireId } ) assertEquals( - listOf("default", "read-only", "safe-yolo", "autoReview", "yolo"), + listOf("default", "read-only", "autoReview", "yolo"), PermissionModes.forFlavor("codex").map { it.wireId } ) assertEquals( diff --git a/cli/src/codex/codexLocalLauncher.test.ts b/cli/src/codex/codexLocalLauncher.test.ts index 052f4705..1427960c 100644 --- a/cli/src/codex/codexLocalLauncher.test.ts +++ b/cli/src/codex/codexLocalLauncher.test.ts @@ -66,7 +66,7 @@ function createQueueStub() { } function createSessionStub( - permissionMode: 'default' | 'read-only' | 'safe-yolo' | 'yolo', + permissionMode: 'default' | 'read-only' | 'yolo', codexArgs?: string[], path = '/tmp/worktree', initialTranscriptPath: string | null = null, @@ -362,29 +362,6 @@ describe('codexLocalLauncher', () => { ]); }); - it('keeps sandbox escalation available in safe-yolo mode', async () => { - const { session } = createSessionStub('safe-yolo', [ - '--ask-for-approval', - 'never', - '--sandbox', - 'danger-full-access', - '--model', - 'o3' - ]); - - await codexLocalLauncher(session as never); - - expect(harness.launches).toHaveLength(1); - expect(harness.launches[0]?.codexArgs).toEqual([ - '--ask-for-approval', - 'on-request', - '--sandbox', - 'workspace-write', - '--model', - 'o3' - ]); - }); - it('does not emit a session warning while waiting for the first transcript path', async () => { const { session, sessionEvents, getLocalLaunchFailure } = createSessionStub('default', undefined, 'c:\\workspace\\project'); let releaseRunBarrier: (() => void) | undefined; diff --git a/cli/src/codex/codexLocalLauncher.ts b/cli/src/codex/codexLocalLauncher.ts index a70d8664..64a6bc70 100644 --- a/cli/src/codex/codexLocalLauncher.ts +++ b/cli/src/codex/codexLocalLauncher.ts @@ -77,7 +77,7 @@ export async function codexLocalLauncher(session: CodexSession): Promise<'switch const pendingExecWrappers = new Map(); const toolHookBridge = new CodexToolHookBridge(); const permissionMode = session.getPermissionMode(); - const managedPermissionMode = permissionMode === 'read-only' || permissionMode === 'safe-yolo' || permissionMode === 'yolo' + const managedPermissionMode = permissionMode === 'read-only' || permissionMode === 'yolo' ? permissionMode : null; const codexArgs = managedPermissionMode diff --git a/cli/src/codex/codexRemoteLauncher.ts b/cli/src/codex/codexRemoteLauncher.ts index f6ad1b0f..5224953b 100644 --- a/cli/src/codex/codexRemoteLauncher.ts +++ b/cli/src/codex/codexRemoteLauncher.ts @@ -604,7 +604,7 @@ class CodexRemoteLauncher extends RemoteLauncherBase { const getCurrentCodexPermissionMode = () => { const mode = session.getPermissionMode(); - return mode === 'default' || mode === 'read-only' || mode === 'safe-yolo' || mode === 'yolo' + return mode === 'default' || mode === 'read-only' || mode === 'yolo' ? mode : undefined; }; diff --git a/cli/src/codex/shared/launch.test.ts b/cli/src/codex/shared/launch.test.ts index e1bf65c7..c274e27b 100644 --- a/cli/src/codex/shared/launch.test.ts +++ b/cli/src/codex/shared/launch.test.ts @@ -45,7 +45,6 @@ describe('shared launch configuration', () => { it('does not interpret flags after -- as configuration', () => { expect(sharedLaunchConfig({ codexArgs: ['--', '--profile=explain'] }, '/tmp').tuiArgs).toEqual(['--', '--profile=explain']); expect(sharedLaunchConfig({ codexArgs: ['--', '-C', 'literal'] }, '/tmp').tuiArgs).toEqual(['--', '-C', 'literal']); - expect(() => sharedLaunchConfig({ permissionMode: 'safe-yolo' }, '/tmp')).toThrow('not a native shared'); }); it('maps HAPI autoReview to native auto-review in the workspace-write sandbox', () => { const result = sharedLaunchConfig({ permissionMode: 'autoReview', codexArgs: ['--yolo'] }, '/tmp'); diff --git a/cli/src/codex/shared/launch.ts b/cli/src/codex/shared/launch.ts index b76b8c15..74cda49c 100644 --- a/cli/src/codex/shared/launch.ts +++ b/cli/src/codex/shared/launch.ts @@ -10,7 +10,7 @@ import type { CodexAppServerClient } from '../codexAppServerClient'; export const SharedLaunchSchema = z.object({ startedBy: z.enum(['runner', 'terminal']).optional(), codexArgs: z.array(z.string()).optional(), - permissionMode: z.enum(['default', 'read-only', 'safe-yolo', 'autoReview', 'yolo']).optional(), + permissionMode: z.enum(['default', 'read-only', 'autoReview', 'yolo']).optional(), resumeSessionId: z.string().optional(), resumeLast: z.boolean().optional(), resumeAll: z.boolean().optional(), existingSessionId: z.string().optional(), /** Hub-preallocated stub — create/adopt, not reopen. */ @@ -46,7 +46,6 @@ export function resolveSharedCodex(): CodexCommand { export function sharedLaunchConfig(options: SharedLaunchOptions, cwd: string): { cwd: string; serverArgs: string[]; tuiArgs: string[]; threadParams: Record; } { - if (options.permissionMode === 'safe-yolo') throw new Error('safe-yolo is not a native shared permission mode. Choose default, read-only, autoReview, or yolo.'); const args = options.codexArgs ?? []; const serverArgs: string[] = []; let tuiArgs: string[] = []; diff --git a/cli/src/codex/shared/root.ts b/cli/src/codex/shared/root.ts index 4aa8429e..ecd630fd 100644 --- a/cli/src/codex/shared/root.ts +++ b/cli/src/codex/shared/root.ts @@ -32,7 +32,7 @@ export type RootHost = { end(root: SharedCodexRoot, nativeArchive?: boolean): Promise; }; const SettingsSchema = z.object({ - permissionMode: z.enum(['default', 'read-only', 'safe-yolo', 'autoReview', 'yolo']).optional(), + permissionMode: z.enum(['default', 'read-only', 'autoReview', 'yolo']).optional(), model: z.string().min(1).optional(), modelReasoningEffort: z.string().nullable().optional(), collaborationMode: z.enum(['default', 'plan']).optional(), serviceTier: z.enum(['fast', 'standard']).nullable().optional(), @@ -407,7 +407,6 @@ export class SharedCodexRoot { } async applySettings(raw: unknown): Promise<{ applied: RuntimeSettings }> { const config = SettingsSchema.parse(raw); - if (config.permissionMode === 'safe-yolo') throw new Error('safe-yolo is not a native shared permission mode; choose default, read-only, autoReview, or yolo'); if (Object.keys(config).length === 0) return { applied: this.settings }; if (typeof config.modelReasoningEffort === 'string') config.modelReasoningEffort = parseReasoningEffortValue(config.modelReasoningEffort); if (config.modelReasoningEffort === null) { diff --git a/cli/src/codex/utils/appServerConfig.test.ts b/cli/src/codex/utils/appServerConfig.test.ts index d45e677f..2a45177e 100644 --- a/cli/src/codex/utils/appServerConfig.test.ts +++ b/cli/src/codex/utils/appServerConfig.test.ts @@ -156,17 +156,6 @@ describe('appServerConfig', () => { }); }); - it('keeps supported escalation approvals for safe-yolo threads', () => { - const params = buildThreadStartParams({ - cwd: '/workspace/project', - mode: { permissionMode: 'safe-yolo', collaborationMode: 'default' }, - mcpServers - }); - - expect(params.sandbox).toBe('workspace-write'); - expect(params.approvalPolicy).toBe('on-request'); - }); - it('allows MCP elicitation without enabling sandbox prompts for read-only threads', () => { const params = buildThreadStartParams({ cwd: '/workspace/project', @@ -583,7 +572,7 @@ describe('appServerConfig', () => { threadId: 'thread-1', message: 'hello', cwd: '/workspace/project', - mode: { permissionMode: 'safe-yolo', model: 'o3', collaborationMode: 'default' }, + mode: { permissionMode: 'autoReview', model: 'o3', collaborationMode: 'default' }, cliOverrides: { sandbox: 'read-only', approvalPolicy: 'never' } }); diff --git a/cli/src/codex/utils/permissionHandler.test.ts b/cli/src/codex/utils/permissionHandler.test.ts index 8367a5f8..e52c5fc2 100644 --- a/cli/src/codex/utils/permissionHandler.test.ts +++ b/cli/src/codex/utils/permissionHandler.test.ts @@ -8,7 +8,7 @@ type FakeAgentState = { }; function createHarness( - mode: 'default' | 'read-only' | 'safe-yolo' | 'yolo', + mode: 'default' | 'read-only' | 'yolo', collaborationMode?: 'default' | 'plan' ) { let agentState: FakeAgentState = { @@ -91,23 +91,6 @@ describe('CodexPermissionHandler', () => { await expect(resultPromise).resolves.toMatchObject({ decision: 'approved' }); }); - it('auto-approves safe-yolo requests once', async () => { - const { handler, getAgentState } = createHarness('safe-yolo'); - - await expect(handler.handleToolCall('perm-1', 'CodexBash', { command: 'pwd' })).resolves.toEqual({ - decision: 'approved' - }); - - expect(getAgentState().requests).toEqual({}); - expect(getAgentState().completedRequests).toMatchObject({ - 'perm-1': { - tool: 'CodexBash', - status: 'approved', - decision: 'approved' - } - }); - }); - it('keeps default mode requests pending until a permission RPC arrives', async () => { const { handler, rpcHandlers, getAgentState } = createHarness('default'); const resultPromise = handler.handleToolCall('perm-1', 'CodexPatch', { grantRoot: '/tmp' }); diff --git a/cli/src/codex/utils/permissionModeConfig.test.ts b/cli/src/codex/utils/permissionModeConfig.test.ts index 98ec14e7..9cbb453d 100644 --- a/cli/src/codex/utils/permissionModeConfig.test.ts +++ b/cli/src/codex/utils/permissionModeConfig.test.ts @@ -9,12 +9,4 @@ describe('resolveCodexPermissionModeConfig', () => { sandboxPolicy: { type: 'workspaceWrite' } }); }); - - it('keeps safe-yolo escalation available with a supported approval policy', () => { - expect(resolveCodexPermissionModeConfig('safe-yolo')).toEqual({ - approvalPolicy: 'on-request', - sandbox: 'workspace-write', - sandboxPolicy: { type: 'workspaceWrite' } - }); - }); }); diff --git a/cli/src/codex/utils/permissionModeConfig.ts b/cli/src/codex/utils/permissionModeConfig.ts index bb750817..a080ec93 100644 --- a/cli/src/codex/utils/permissionModeConfig.ts +++ b/cli/src/codex/utils/permissionModeConfig.ts @@ -25,15 +25,6 @@ export function resolveCodexPermissionModeConfig(mode: CodexPermissionMode): Cod sandbox: 'read-only', sandboxPolicy: { type: 'readOnly' } }; - case 'safe-yolo': - return { - // Current Codex versions reject the removed `on-failure` policy. Keep - // escalation available through `on-request`; HAPI auto-approves these - // requests in safe-yolo mode. - approvalPolicy: 'on-request', - sandbox: 'workspace-write', - sandboxPolicy: { type: 'workspaceWrite' } - }; case 'yolo': return { approvalPolicy: 'never', diff --git a/cli/src/codex/utils/slashCommands.ts b/cli/src/codex/utils/slashCommands.ts index accd4706..0d03a457 100644 --- a/cli/src/codex/utils/slashCommands.ts +++ b/cli/src/codex/utils/slashCommands.ts @@ -318,7 +318,7 @@ export function resolveCodexSlashCommand( '- `/reasoning [level|default]` — show or set reasoning effort', '- `/personality [friendly|pragmatic|none]` — show or set response style (sticky on thread)', '- `/fast [on|off|status]` — toggle Fast mode (GPT-5.5 / GPT-5.4, ChatGPT login)', - '- `/permissions [default|read-only|safe-yolo|yolo]` — show or set permission mode', + '- `/permissions [default|read-only|autoReview|yolo]` — show or set permission mode', '', 'Custom `/commands` from `.codex/prompts` are expanded before sending.' ].join('\n') diff --git a/cli/src/modules/pingPeer/pingPeer.ts b/cli/src/modules/pingPeer/pingPeer.ts index 878bf14d..fe6b2ea3 100644 --- a/cli/src/modules/pingPeer/pingPeer.ts +++ b/cli/src/modules/pingPeer/pingPeer.ts @@ -65,7 +65,6 @@ export type PingPeerSessionSummary = { lifecycleState?: string | null piSessionId?: string summary?: { text?: string } | null - capabilities?: { concurrentClients?: boolean } | null } | null } @@ -811,9 +810,7 @@ export async function setPeerPermissionMode(options: SetPeerPermissionModeOption const live = await getSession(apiUrl, jwt, matched.id, http) // Hub treats a missing flavor as claude; mirror it so the allowed list matches. const flavor = live.metadata?.flavor ?? matched.metadata?.flavor ?? 'claude' - // Shared (concurrentClients) sessions cannot launch with Safe Yolo; same filter as the web picker. - const shared = Boolean(live.metadata?.capabilities?.concurrentClients) - const allowed: readonly string[] = getPermissionModesForFlavor(flavor).filter((m) => !shared || m !== 'safe-yolo') + const allowed: readonly string[] = getPermissionModesForFlavor(flavor) if (allowed.length === 0) { throw new PingPeerError('bad_args', `${flavor} sessions do not support permission modes`) } diff --git a/cli/src/modules/pingPeer/setPeerPermissionMode.test.ts b/cli/src/modules/pingPeer/setPeerPermissionMode.test.ts index a4a6c308..1242101a 100644 --- a/cli/src/modules/pingPeer/setPeerPermissionMode.test.ts +++ b/cli/src/modules/pingPeer/setPeerPermissionMode.test.ts @@ -60,12 +60,12 @@ describe('setPeerPermissionMode', () => { expect(error).toBeInstanceOf(PingPeerError) expect((error as PingPeerError).code).toBe('bad_args') - expect((error as PingPeerError).message).toContain('allowed: default, read-only, safe-yolo, autoReview, yolo') + expect((error as PingPeerError).message).toContain('allowed: default, read-only, autoReview, yolo') expect(http.post).toHaveBeenCalledTimes(1) }) - it('rejects Safe Yolo for a shared session without calling the hub', async () => { - const http = createHttpMock({ flavor: 'codex', active: false, shared: true }) + it('rejects Safe Yolo for Codex without calling the hub', async () => { + const http = createHttpMock({ flavor: 'codex', active: false }) await expect(setPeerPermissionMode({ ...base, sessionIdPrefix: sessionId, mode: 'safe-yolo', http: http as never })) .rejects.toMatchObject({ code: 'bad_args', message: "invalid mode 'safe-yolo' for codex; allowed: default, read-only, autoReview, yolo" }) diff --git a/docs/api/client-contract/rest.md b/docs/api/client-contract/rest.md index 866ec809..ffbee589 100644 --- a/docs/api/client-contract/rest.md +++ b/docs/api/client-contract/rest.md @@ -124,8 +124,6 @@ Source: `hub/src/web/routes/sessions.ts`; flavor gates in `shared/src/modes.ts` ownership mode. `/switch` returns HTTP 409 with `code: 'control_mode_not_applicable'`; do not offer takeover. The remote-only Codex configuration restrictions above do not apply to these sessions. -Shared Codex rejects `safe-yolo` even though it remains in the historical -Codex permission-mode schema; do not offer it for concurrent sessions. Shared `/clear` has no global `supersededBySessionId` change; clients other than the caller stay on the original thread. Fork may return an already-bound shared diff --git a/docs/guide/codex-shared-sessions.md b/docs/guide/codex-shared-sessions.md index 83a0e021..ddd44958 100644 --- a/docs/guide/codex-shared-sessions.md +++ b/docs/guide/codex-shared-sessions.md @@ -119,9 +119,8 @@ endpoints/tokens never go into HAPI session metadata or SSE. - Cold resume accepts root threads only. To revisit a child agent, resume its parent HAPI session; HAPI does not independently start a child execution. -- HAPI's legacy `safe-yolo` auto-approval policy is not a native Codex mode; - shared sessions reject it instead of silently weakening or misreporting it. - Use `default`, `read-only`, `autoReview`, or `yolo`. `autoReview` keeps the +- Codex permission modes are `default`, `read-only`, `autoReview`, and + `yolo`; HAPI's `safe-yolo` is not a native Codex mode. `autoReview` keeps the workspace-write sandbox and routes escalations to Codex's native auto-review (`approvals_reviewer = "auto_review"`, same as `--approve-for-me`). - Web-created new/fork roots preserve the standard native sandbox restrictions. diff --git a/hub/src/web/routes/sessions.test.ts b/hub/src/web/routes/sessions.test.ts index ed744e56..1c6ca8f4 100644 --- a/hub/src/web/routes/sessions.test.ts +++ b/hub/src/web/routes/sessions.test.ts @@ -1216,10 +1216,10 @@ describe('sessions routes', () => { ]) }) - it('rejects Safe Yolo for shared sessions', async () => { + it('rejects Safe Yolo for Codex', async () => { const session = createSession({ active: false, - metadata: { path: '/tmp/project', host: 'localhost', flavor: 'codex', capabilities: { concurrentClients: true } } + metadata: { path: '/tmp/project', host: 'localhost', flavor: 'codex' } }) const { app, applySessionConfigCalls } = createApp(session) @@ -1230,7 +1230,7 @@ describe('sessions routes', () => { }) expect(response.status).toBe(400) - expect(await response.json()).toEqual({ error: 'Safe Yolo is not available for shared sessions' }) + expect(await response.json()).toEqual({ error: 'Invalid permission mode for session flavor' }) expect(applySessionConfigCalls).toEqual([]) }) diff --git a/hub/src/web/routes/sessions.ts b/hub/src/web/routes/sessions.ts index 448d058c..82ef1761 100644 --- a/hub/src/web/routes/sessions.ts +++ b/hub/src/web/routes/sessions.ts @@ -601,9 +601,6 @@ export function createSessionsRoutes(getSyncEngine: () => SyncEngine | null): Ho if (!isPermissionModeAllowedForFlavor(mode, flavor)) { return c.json({ error: 'Invalid permission mode for session flavor' }, 400) } - if (mode === 'safe-yolo' && sessionResult.session.metadata?.capabilities?.concurrentClients) { - return c.json({ error: 'Safe Yolo is not available for shared sessions' }, 400) - } if (flavor === 'opencode' && mode === 'plan' && sessionResult.session.agentState?.controlledByUser === true && !sessionResult.session.metadata?.capabilities?.concurrentClients) { return c.json({ error: 'OpenCode plan mode is only supported for remote sessions' }, 409) } diff --git a/ios/Hapi/Features/NewSession/NewSessionModel.swift b/ios/Hapi/Features/NewSession/NewSessionModel.swift index 668f879e..8bc6e36c 100644 --- a/ios/Hapi/Features/NewSession/NewSessionModel.swift +++ b/ios/Hapi/Features/NewSession/NewSessionModel.swift @@ -374,7 +374,7 @@ final class NewSessionModel { return .managed } if NewSessionLogic.usesNativePermissionSelect(agent) { - return .nativeSelect(agent.launchPermissionModes.map { PermissionModeOption(mode: $0) }) + return .nativeSelect(agent.permissionModes.map { PermissionModeOption(mode: $0) }) } return .yoloToggle(nativeModeLabel: NewSessionLogic.hapiYoloNativeMode(for: agent)?.label) } diff --git a/ios/HapiTests/SessionConfigTests.swift b/ios/HapiTests/SessionConfigTests.swift index df5665b2..30f10d91 100644 --- a/ios/HapiTests/SessionConfigTests.swift +++ b/ios/HapiTests/SessionConfigTests.swift @@ -225,7 +225,6 @@ final class SessionConfigTests: XCTestCase { // Offline/terminal notices do not introduce a blanket UI authorization gate. harness.store.updateDetailLocal("config") { $0.metadata?.capabilities = nil } XCTAssertTrue(harness.model.config.controlledByUser) - XCTAssertTrue(harness.model.config.permissionModes.contains { $0.mode == .safeYolo }) } } diff --git a/ios/Packages/HapiKit/Sources/HapiClient/Chat/ChatInteractionState.swift b/ios/Packages/HapiKit/Sources/HapiClient/Chat/ChatInteractionState.swift index 90416e87..1b872299 100644 --- a/ios/Packages/HapiKit/Sources/HapiClient/Chat/ChatInteractionState.swift +++ b/ios/Packages/HapiKit/Sources/HapiClient/Chat/ChatInteractionState.swift @@ -300,9 +300,7 @@ public func buildSessionConfigState( active: active, controlledByUser: controlledByUser, permissionMode: detail?.permissionMode, - permissionModes: permissionModeOptions(forFlavor: flavor).filter { - detail?.metadata?.capabilities?.concurrentClients != true || $0.mode.rawValue != "safe-yolo" - }, + permissionModes: permissionModeOptions(forFlavor: flavor), collaborationMode: detail?.collaborationMode, canChangeCollaborationMode: detail != nil && flavor == "codex" && active && !controlledByUser, model: model, diff --git a/ios/Packages/HapiKit/Sources/HapiClient/NewSession/NewSessionForm.swift b/ios/Packages/HapiKit/Sources/HapiClient/NewSession/NewSessionForm.swift index cdffc144..6ac7500a 100644 --- a/ios/Packages/HapiKit/Sources/HapiClient/NewSession/NewSessionForm.swift +++ b/ios/Packages/HapiKit/Sources/HapiClient/NewSession/NewSessionForm.swift @@ -416,7 +416,7 @@ public enum NewSessionLogic { sessionType: draft.sessionType, worktreeName: draft.worktreeName ) - if !base.agent.launchPermissionModes.contains(base.permissionMode) { + if !base.agent.permissionModes.contains(base.permissionMode) { base.permissionMode = .default } return base diff --git a/ios/Packages/HapiKit/Sources/HapiProtocol/Catalog/PermissionModes.swift b/ios/Packages/HapiKit/Sources/HapiProtocol/Catalog/PermissionModes.swift index d3232bd0..667c02a3 100644 --- a/ios/Packages/HapiKit/Sources/HapiProtocol/Catalog/PermissionModes.swift +++ b/ios/Packages/HapiKit/Sources/HapiProtocol/Catalog/PermissionModes.swift @@ -82,11 +82,6 @@ public struct PermissionModeOption: Equatable, Sendable { } extension AgentFlavor { - /// New Codex sessions use the shared runtime, without HAPI Safe Yolo. - public var launchPermissionModes: [PermissionMode] { - permissionModes.filter { self != .codex || $0 != .safeYolo } - } - /// Permission modes offered for this flavor, in picker order. /// /// Ports the per-flavor arrays in `shared/src/modes.ts` @@ -95,7 +90,7 @@ extension AgentFlavor { public var permissionModes: [PermissionMode] { switch self { case .codex: - return [.default, .readOnly, .safeYolo, .autoReview, .yolo] + return [.default, .readOnly, .autoReview, .yolo] case .gemini, .kimi, .copilot: return [.default, .readOnly, .safeYolo, .yolo] case .grok: diff --git a/ios/Packages/HapiKit/Tests/HapiClientTests/NewSessionFormTests.swift b/ios/Packages/HapiKit/Tests/HapiClientTests/NewSessionFormTests.swift index 218971bb..e5844376 100644 --- a/ios/Packages/HapiKit/Tests/HapiClientTests/NewSessionFormTests.swift +++ b/ios/Packages/HapiKit/Tests/HapiClientTests/NewSessionFormTests.swift @@ -279,7 +279,7 @@ struct NewSessionLogicTests { NewSessionForm(agent: .codex, permissionMode: .safeYolo) ) #expect(staleMode.permissionMode == .default) - #expect(AgentFlavor.codex.launchPermissionModes == [.default, .readOnly, .autoReview, .yolo]) + #expect(AgentFlavor.codex.permissionModes == [.default, .readOnly, .autoReview, .yolo]) #expect(NewSessionLogic.sanitizeDraft(NewSessionForm(agent: .kimi, permissionMode: .safeYolo)).permissionMode == .safeYolo) } diff --git a/ios/Packages/HapiKit/Tests/HapiProtocolTests/CatalogTests.swift b/ios/Packages/HapiKit/Tests/HapiProtocolTests/CatalogTests.swift index 4b3890d7..c466dbe0 100644 --- a/ios/Packages/HapiKit/Tests/HapiProtocolTests/CatalogTests.swift +++ b/ios/Packages/HapiKit/Tests/HapiProtocolTests/CatalogTests.swift @@ -78,7 +78,7 @@ struct CatalogTests { @Test func codexFamilyFlavorsShareTheCodexModeSet() { let expected: [PermissionMode] = [.default, .readOnly, .safeYolo, .yolo] - #expect(permissionModes(forFlavor: "codex") == [.default, .readOnly, .safeYolo, .autoReview, .yolo]) + #expect(permissionModes(forFlavor: "codex") == [.default, .readOnly, .autoReview, .yolo]) #expect(permissionModes(forFlavor: "gemini") == expected) #expect(permissionModes(forFlavor: "kimi") == expected) #expect(permissionModes(forFlavor: "copilot") == expected) @@ -144,7 +144,7 @@ struct CatalogTests { @Test func optionListsCarryLabelAndTone() { let options = permissionModeOptions(forFlavor: "codex") - #expect(options.count == 5) + #expect(options.count == 4) #expect(options.first == PermissionModeOption(mode: .default)) #expect(options.first?.label == "Default") #expect(options.first?.tone == .neutral) diff --git a/shared/fixtures/catalogs/modes.json b/shared/fixtures/catalogs/modes.json index bdbfbfb4..41cf0c37 100644 --- a/shared/fixtures/catalogs/modes.json +++ b/shared/fixtures/catalogs/modes.json @@ -60,11 +60,6 @@ "mode": "read-only", "tone": "warning" }, - { - "label": "Safe Yolo", - "mode": "safe-yolo", - "tone": "warning" - }, { "label": "Auto", "mode": "autoReview", diff --git a/shared/src/modes.test.ts b/shared/src/modes.test.ts index e2e13abd..bd11f668 100644 --- a/shared/src/modes.test.ts +++ b/shared/src/modes.test.ts @@ -3,7 +3,6 @@ import { AGENT_FLAVORS, AgentFlavorSchema, CREATABLE_AGENT_FLAVORS, - getLaunchPermissionModesForFlavor, getPermissionModeLabel, getPermissionModeOptionsForFlavor, getPermissionModeTone, @@ -102,7 +101,7 @@ describe('isPermissionModeAllowedForFlavor', () => { }) test("codex offers autoReview at launch and mid-session", () => { - expect(getLaunchPermissionModesForFlavor('codex')).toEqual(['default', 'read-only', 'autoReview', 'yolo']) + expect(getPermissionModesForFlavor('codex')).toEqual(['default', 'read-only', 'autoReview', 'yolo']) expect(isPermissionModeAllowedForFlavor('autoReview', 'codex')).toBe(true) expect(isPermissionModeAllowedForFlavor('autoReview', 'gemini')).toBe(false) }) diff --git a/shared/src/modes.ts b/shared/src/modes.ts index 94d37bf5..8f5c0515 100644 --- a/shared/src/modes.ts +++ b/shared/src/modes.ts @@ -25,7 +25,7 @@ export type AgyPermissionMode = typeof AGY_PERMISSION_MODES[number] export const CLAUDE_PERMISSION_MODES = ['default', 'acceptEdits', 'auto', 'bypassPermissions', 'plan'] as const export type ClaudePermissionMode = typeof CLAUDE_PERMISSION_MODES[number] -export const CODEX_PERMISSION_MODES = ['default', 'read-only', 'safe-yolo', 'autoReview', 'yolo'] as const +export const CODEX_PERMISSION_MODES = ['default', 'read-only', 'autoReview', 'yolo'] as const export type CodexPermissionMode = typeof CODEX_PERMISSION_MODES[number] export const CODEX_COLLABORATION_MODES = ['default', 'plan'] as const @@ -177,11 +177,6 @@ export function isPermissionModeAllowedForFlavor(mode: PermissionMode, flavor?: return getPermissionModesForFlavor(flavor).includes(mode) } -/** New Codex sessions use the native shared runtime, without HAPI Safe Yolo. */ -export function getLaunchPermissionModesForFlavor(flavor?: string | null): readonly PermissionMode[] { - return getPermissionModesForFlavor(flavor).filter(mode => flavor !== 'codex' || mode !== 'safe-yolo') -} - export function getCodexCollaborationModeOptions(): CodexCollaborationModeOption[] { return CODEX_COLLABORATION_MODES.map((mode) => ({ mode, diff --git a/web/src/components/AssistantChat/HappyComposer.tsx b/web/src/components/AssistantChat/HappyComposer.tsx index ac091740..c6b06a45 100644 --- a/web/src/components/AssistantChat/HappyComposer.tsx +++ b/web/src/components/AssistantChat/HappyComposer.tsx @@ -309,7 +309,6 @@ export function HappyComposer(props: { /** Model for the context-window heuristic; see StatusBar.contextModel. */ contextModel?: string | null controlledByUser?: boolean - concurrentClients?: boolean agentFlavor?: string | null availableModelOptions?: Array<{ value: string | null; label: string; group?: string }> /** Full Pi model data with thinkingLevelMap for provider grouping + thinking level filtering */ @@ -418,7 +417,6 @@ export function HappyComposer(props: { contextWindow, contextModel, controlledByUser = false, - concurrentClients = false, agentFlavor, availableModelOptions, piModels, @@ -1020,8 +1018,8 @@ export function HappyComposer(props: { }, [switchDisabled, onSwitchToRemote, haptic]) const permissionModeOptions = useMemo( - () => getPermissionModeOptionsForFlavor(agentFlavor).filter(option => !concurrentClients || option.mode !== 'safe-yolo'), - [agentFlavor, concurrentClients] + () => getPermissionModeOptionsForFlavor(agentFlavor), + [agentFlavor] ) const collaborationModeOptions = useMemo( () => agentFlavor === 'codex' ? getCodexCollaborationModeOptions() : [], diff --git a/web/src/components/NewSession/PermissionField.tsx b/web/src/components/NewSession/PermissionField.tsx index c25d5b0a..e24f9550 100644 --- a/web/src/components/NewSession/PermissionField.tsx +++ b/web/src/components/NewSession/PermissionField.tsx @@ -1,7 +1,7 @@ import { getAgentConfigDescriptor, getPermissionModeLabel, - getLaunchPermissionModesForFlavor, + getPermissionModesForFlavor, resolveHapiYoloPermissionMode, type PermissionMode } from '@hapi/protocol' @@ -61,7 +61,7 @@ export function PermissionField(props: PermissionFieldProps) { } if (field.kind === 'select' && usesNativePermissionSelect(props.agent)) { - const options = getLaunchPermissionModesForFlavor(props.agent) + const options = getPermissionModesForFlavor(props.agent) return (