diff --git a/src/Tool.ts b/src/Tool.ts index dd9966983..30e6509da 100644 --- a/src/Tool.ts +++ b/src/Tool.ts @@ -146,7 +146,7 @@ export const getEmptyToolPermissionContext: () => ToolPermissionContext = alwaysAllowRules: {}, alwaysDenyRules: {}, alwaysAskRules: {}, - isBypassPermissionsModeAvailable: false, + isBypassPermissionsModeAvailable: true, }) export type CompactProgressEvent = diff --git a/src/__tests__/Tool.test.ts b/src/__tests__/Tool.test.ts index 569cd2d6c..9292459c3 100644 --- a/src/__tests__/Tool.test.ts +++ b/src/__tests__/Tool.test.ts @@ -166,9 +166,9 @@ describe('getEmptyToolPermissionContext', () => { expect(ctx.alwaysAskRules).toEqual({}) }) - test('returns isBypassPermissionsModeAvailable as false', () => { + test('returns isBypassPermissionsModeAvailable as true', () => { const ctx = getEmptyToolPermissionContext() - expect(ctx.isBypassPermissionsModeAvailable).toBe(false) + expect(ctx.isBypassPermissionsModeAvailable).toBe(true) }) }) diff --git a/src/commands/login/login.tsx b/src/commands/login/login.tsx index b4329fe62..1b4c2f588 100644 --- a/src/commands/login/login.tsx +++ b/src/commands/login/login.tsx @@ -18,9 +18,7 @@ import type { LocalJSXCommandOnDone } from '../../types/command.js' import { stripSignatureBlocks } from '../../utils/messages.js' import { checkAndDisableAutoModeIfNeeded, - checkAndDisableBypassPermissionsIfNeeded, resetAutoModeGateCheck, - resetBypassPermissionsCheck, } from '../../utils/permissions/bypassPermissionsKillswitch.js' import { resetUserCache } from '../../utils/user.js' @@ -54,20 +52,13 @@ export async function call( // Enroll as a trusted device for Remote Control (10-min fresh-session window) void enrollTrustedDevice() // Reset killswitch gate checks and re-run with new org - resetBypassPermissionsCheck() + resetAutoModeGateCheck() const appState = context.getAppState() - void checkAndDisableBypassPermissionsIfNeeded( + void checkAndDisableAutoModeIfNeeded( appState.toolPermissionContext, context.setAppState, + appState.fastMode, ) - if (feature('TRANSCRIPT_CLASSIFIER')) { - resetAutoModeGateCheck() - void checkAndDisableAutoModeIfNeeded( - appState.toolPermissionContext, - context.setAppState, - appState.fastMode, - ) - } // Increment authVersion to trigger re-fetching of auth-dependent data in hooks (e.g., MCP servers) context.setAppState(prev => ({ ...prev, diff --git a/src/components/PromptInput/PromptInput.tsx b/src/components/PromptInput/PromptInput.tsx index 3d43a1fae..03b627602 100644 --- a/src/components/PromptInput/PromptInput.tsx +++ b/src/components/PromptInput/PromptInput.tsx @@ -151,16 +151,14 @@ import { isOpus1mMergeEnabled, modelDisplayString, } from '../../utils/model/model.js' -import { setAutoModeActive } from '../../utils/permissions/autoModeState.js' import { cyclePermissionMode, getNextPermissionMode, } from '../../utils/permissions/getNextPermissionMode.js' -import { transitionPermissionMode } from '../../utils/permissions/permissionSetup.js' import { getPlatform } from '../../utils/platform.js' import type { ProcessUserInputContext } from '../../utils/processUserInput/processUserInput.js' import { editPromptInEditor } from '../../utils/promptEditor.js' -import { hasAutoModeOptIn } from '../../utils/settings/settings.js' +// hasAutoModeOptIn removed — auto mode is available to all users import { findBtwTriggerPositions } from '../../utils/sideQuestion.js' import { findSlashCommandPositions } from '../../utils/suggestions/commandSuggestions.js' import { @@ -187,7 +185,7 @@ import { findUltraplanTriggerPositions, findUltrareviewTriggerPositions, } from '../../utils/ultraplan/keyword.js' -import { AutoModeOptInDialog } from '../AutoModeOptInDialog.js' +// AutoModeOptInDialog removed — auto mode is available to all users import { BridgeDialog } from '../BridgeDialog.js' import { ConfigurableShortcutHint } from '../ConfigurableShortcutHint.js' import { @@ -571,10 +569,6 @@ function PromptInput({ const [showHistoryPicker, setShowHistoryPicker] = useState(false) const [showFastModePicker, setShowFastModePicker] = useState(false) const [showThinkingToggle, setShowThinkingToggle] = useState(false) - const [showAutoModeOptIn, setShowAutoModeOptIn] = useState(false) - const [previousModeBeforeAuto, setPreviousModeBeforeAuto] = - useState(null) - const autoModeOptInTimeoutRef = useRef(null) // Check if cursor is on the first line of input const isCursorOnFirstLine = useMemo(() => { @@ -1883,86 +1877,11 @@ function PromptInput({ // Compute the next mode without triggering side effects first logForDebugging( - `[auto-mode] handleCycleMode: currentMode=${toolPermissionContext.mode} isAutoModeAvailable=${toolPermissionContext.isAutoModeAvailable} showAutoModeOptIn=${showAutoModeOptIn} timeoutPending=${!!autoModeOptInTimeoutRef.current}`, + `[auto-mode] handleCycleMode: currentMode=${toolPermissionContext.mode}`, ) const nextMode = getNextPermissionMode(toolPermissionContext, teamContext) - // Check if user is entering auto mode for the first time. Gated on the - // persistent settings flag (hasAutoModeOptIn) rather than the broader - // hasAutoModeOptInAnySource so that --enable-auto-mode users still see - // the warning dialog once — the CLI flag should grant carousel access, - // not bypass the safety text. - let isEnteringAutoModeFirstTime = false - if (feature('TRANSCRIPT_CLASSIFIER')) { - isEnteringAutoModeFirstTime = - nextMode === 'auto' && - toolPermissionContext.mode !== 'auto' && - !hasAutoModeOptIn() && - !viewingAgentTaskId // Only show for primary agent, not subagents - } - - if (feature('TRANSCRIPT_CLASSIFIER')) { - if (isEnteringAutoModeFirstTime) { - // Store previous mode so we can revert if user declines - setPreviousModeBeforeAuto(toolPermissionContext.mode) - - // Only update the UI mode label — do NOT call transitionPermissionMode - // or cyclePermissionMode yet; we haven't confirmed with the user. - setAppState(prev => ({ - ...prev, - toolPermissionContext: { - ...prev.toolPermissionContext, - mode: 'auto', - }, - })) - setToolPermissionContext({ - ...toolPermissionContext, - mode: 'auto', - }) - - // Show opt-in dialog after 400ms debounce - if (autoModeOptInTimeoutRef.current) { - clearTimeout(autoModeOptInTimeoutRef.current) - } - autoModeOptInTimeoutRef.current = setTimeout( - (setShowAutoModeOptIn, autoModeOptInTimeoutRef) => { - setShowAutoModeOptIn(true) - autoModeOptInTimeoutRef.current = null - }, - 400, - setShowAutoModeOptIn, - autoModeOptInTimeoutRef, - ) - - if (helpOpen) { - setHelpOpen(false) - } - return - } - } - - // Dismiss auto mode opt-in dialog if showing or pending (user is cycling away). - // Do NOT revert to previousModeBeforeAuto here — shift+tab means "advance the - // carousel", not "decline". Reverting causes a ping-pong loop: auto reverts to - // the prior mode, whose next mode is auto again, forever. - // The dialog's own decline button (handleAutoModeOptInDecline) handles revert. - if (feature('TRANSCRIPT_CLASSIFIER')) { - if (showAutoModeOptIn || autoModeOptInTimeoutRef.current) { - if (showAutoModeOptIn) { - logEvent('tengu_auto_mode_opt_in_dialog_decline', {}) - } - setShowAutoModeOptIn(false) - if (autoModeOptInTimeoutRef.current) { - clearTimeout(autoModeOptInTimeoutRef.current) - autoModeOptInTimeoutRef.current = null - } - setPreviousModeBeforeAuto(null) - // Fall through — mode is 'auto', cyclePermissionMode below goes to 'default'. - } - } - - // Now that we know this is NOT the first-time auto mode path, - // call cyclePermissionMode to apply side effects (e.g. strip + // Call cyclePermissionMode to apply side effects (e.g. strip // dangerous permissions, activate classifier) const { context: preparedContext } = cyclePermissionMode( toolPermissionContext, @@ -2007,91 +1926,10 @@ function PromptInput({ }, [ toolPermissionContext, teamContext, - viewingAgentTaskId, viewedTeammate, setAppState, setToolPermissionContext, helpOpen, - showAutoModeOptIn, - ]) - - // Handler for auto mode opt-in dialog acceptance - const handleAutoModeOptInAccept = useCallback(() => { - if (feature('TRANSCRIPT_CLASSIFIER')) { - setShowAutoModeOptIn(false) - setPreviousModeBeforeAuto(null) - - // Now that the user accepted, apply the full transition: activate the - // auto mode backend (classifier, beta headers) and strip dangerous - // permissions (e.g. Bash(*) always-allow rules). - const strippedContext = transitionPermissionMode( - previousModeBeforeAuto ?? toolPermissionContext.mode, - 'auto', - toolPermissionContext, - ) - setAppState(prev => ({ - ...prev, - toolPermissionContext: { - ...strippedContext, - mode: 'auto', - }, - })) - setToolPermissionContext({ - ...strippedContext, - mode: 'auto', - }) - - // Close help tips if they're open when auto mode is enabled - if (helpOpen) { - setHelpOpen(false) - } - } - }, [ - helpOpen, - setHelpOpen, - previousModeBeforeAuto, - toolPermissionContext, - setAppState, - setToolPermissionContext, - ]) - - // Handler for auto mode opt-in dialog decline - const handleAutoModeOptInDecline = useCallback(() => { - if (feature('TRANSCRIPT_CLASSIFIER')) { - logForDebugging( - `[auto-mode] handleAutoModeOptInDecline: reverting to ${previousModeBeforeAuto}, setting isAutoModeAvailable=false`, - ) - setShowAutoModeOptIn(false) - if (autoModeOptInTimeoutRef.current) { - clearTimeout(autoModeOptInTimeoutRef.current) - autoModeOptInTimeoutRef.current = null - } - - // Revert to previous mode and remove auto from the carousel - // for the rest of this session - if (previousModeBeforeAuto) { - setAutoModeActive(false) - setAppState(prev => ({ - ...prev, - toolPermissionContext: { - ...prev.toolPermissionContext, - mode: previousModeBeforeAuto, - isAutoModeAvailable: false, - }, - })) - setToolPermissionContext({ - ...toolPermissionContext, - mode: previousModeBeforeAuto, - isAutoModeAvailable: false, - }) - setPreviousModeBeforeAuto(null) - } - } - }, [ - previousModeBeforeAuto, - toolPermissionContext, - setAppState, - setToolPermissionContext, ]) // Handler for chat:imagePaste - paste image from clipboard @@ -2758,20 +2596,7 @@ function PromptInput({ // Portal dialog to DialogOverlay in fullscreen so it escapes the bottom // slot's overflowY:hidden clip (same pattern as SuggestionsOverlay). // Must be called before early returns below to satisfy rules-of-hooks. - // Memoized so the portal useEffect doesn't churn on every PromptInput render. - const autoModeOptInDialog = useMemo( - () => - feature('TRANSCRIPT_CLASSIFIER') && showAutoModeOptIn ? ( - - ) : null, - [showAutoModeOptIn, handleAutoModeOptInAccept, handleAutoModeOptInDecline], - ) - useSetPromptOverlayDialog( - isFullscreenEnvEnabled() ? autoModeOptInDialog : null, - ) + useSetPromptOverlayDialog(null) if (showBashesDialog) { return ( @@ -3077,7 +2902,6 @@ function PromptInput({ isFullscreenEnvEnabled() ? handleOpenTasksDialog : undefined } /> - {isFullscreenEnvEnabled() ? null : autoModeOptInDialog} {isFullscreenEnvEnabled() ? ( // position=absolute takes zero layout height so the spinner // doesn't shift when a notification appears/disappears. Yoga @@ -3098,7 +2922,7 @@ function PromptInput({ ( - gracefulShutdownSync(1)} - declineExits - /> - )) - } - } - // --dangerously-load-development-channels confirmation. On accept, append // dev channels to any --channels list already set in main.tsx. Org policy // is NOT bypassed — gateChannelServer() still runs; this flag only exists diff --git a/src/main.tsx b/src/main.tsx index 20c8289c4..9d9cf56de 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -242,7 +242,6 @@ import { import { ensureModelStringsInitialized } from "./utils/model/modelStrings.js"; import { PERMISSION_MODES } from "./utils/permissions/PermissionMode.js"; import { - checkAndDisableBypassPermissions, getAutoModeEnabledStateIfCached, initializeToolPermissionContext, initialPermissionModeFromCLI, @@ -3910,19 +3909,7 @@ async function run(): Promise { onChangeAppState, ); - // Check if bypassPermissions should be disabled based on Statsig gate - // This runs in parallel to the code below, to avoid blocking the main loop. - if ( - toolPermissionContext.mode === "bypassPermissions" || - allowDangerouslySkipPermissions - ) { - void checkAndDisableBypassPermissions( - toolPermissionContext, - ); - } - // Async check of auto mode gate — corrects state and disables auto if needed. - // Gated on TRANSCRIPT_CLASSIFIER (not USER_TYPE) so GrowthBook kill switch runs for external builds too. if (feature("TRANSCRIPT_CLASSIFIER")) { void verifyAutoModeGateAccess( toolPermissionContext, diff --git a/src/screens/REPL.tsx b/src/screens/REPL.tsx index d47a4507e..3547c4aed 100644 --- a/src/screens/REPL.tsx +++ b/src/screens/REPL.tsx @@ -422,9 +422,7 @@ import { usePromptsFromClaudeInChrome } from 'src/hooks/usePromptsFromClaudeInCh import { getTipToShowOnSpinner, recordShownTip } from 'src/services/tips/tipScheduler.js'; import type { Theme } from 'src/utils/theme.js'; import { - checkAndDisableBypassPermissionsIfNeeded, checkAndDisableAutoModeIfNeeded, - useKickOffCheckAndDisableBypassPermissionsIfNeeded, useKickOffCheckAndDisableAutoModeIfNeeded, } from 'src/utils/permissions/bypassPermissionsKillswitch.js'; import { SandboxManager } from 'src/utils/sandbox/sandbox-adapter.js'; @@ -434,7 +432,6 @@ import { SandboxPermissionRequest } from 'src/components/permissions/SandboxPerm import { SandboxViolationExpandedView } from 'src/components/SandboxViolationExpandedView.js'; import { useSettingsErrors } from 'src/hooks/notifs/useSettingsErrors.js'; import { useMcpConnectivityStatus } from 'src/hooks/notifs/useMcpConnectivityStatus.js'; -import { useAutoModeUnavailableNotification } from 'src/hooks/notifs/useAutoModeUnavailableNotification.js'; import { AUTO_MODE_DESCRIPTION } from 'src/components/AutoModeOptInDialog.js'; import { useLspInitializationNotification } from 'src/hooks/notifs/useLspInitializationNotification.js'; import { useLspPluginRecommendation } from 'src/hooks/useLspPluginRecommendation.js'; @@ -948,7 +945,6 @@ export function REPL({ [toolPermissionContext, proactiveActive, isBriefOnly], ); - useKickOffCheckAndDisableBypassPermissionsIfNeeded(); useKickOffCheckAndDisableAutoModeIfNeeded(); const [dynamicMcpConfig, setDynamicMcpConfig] = useState | undefined>( @@ -1006,7 +1002,6 @@ export function REPL({ useCanSwitchToExistingSubscription(); useIDEStatusIndicator({ ideSelection, mcpClients, ideInstallationStatus }); useMcpConnectivityStatus({ mcpClients }); - useAutoModeUnavailableNotification(); usePluginInstallationStatus(); usePluginAutoupdateNotification(); useSettingsErrors(); @@ -3314,8 +3309,8 @@ export function REPL({ queryCheckpoint('query_context_loading_start'); const [, , defaultSystemPrompt, baseUserContext, systemContext] = await Promise.all([ // IMPORTANT: do this after setMessages() above, to avoid UI jank - checkAndDisableBypassPermissionsIfNeeded(toolPermissionContext, setAppState), - // Gated on TRANSCRIPT_CLASSIFIER so GrowthBook kill switch runs wherever auto mode is built in + undefined, + // Fast-mode circuit breaker check feature('TRANSCRIPT_CLASSIFIER') ? checkAndDisableAutoModeIfNeeded(toolPermissionContext, setAppState, store.getState().fastMode) : undefined, diff --git a/src/services/acp/__tests__/agent.test.ts b/src/services/acp/__tests__/agent.test.ts index 353791886..459cff09f 100644 --- a/src/services/acp/__tests__/agent.test.ts +++ b/src/services/acp/__tests__/agent.test.ts @@ -42,7 +42,7 @@ const mockGetDefaultAppState = mock(() => ({ alwaysAllowRules: { user: [], project: [], local: [] }, alwaysDenyRules: { user: [], project: [], local: [] }, alwaysAskRules: { user: [], project: [], local: [] }, - isBypassPermissionsModeAvailable: false, + isBypassPermissionsModeAvailable: true, }, fastMode: false, settings: {}, diff --git a/src/services/tips/tipRegistry.ts b/src/services/tips/tipRegistry.ts index 37bf27cad..35fb2bea9 100644 --- a/src/services/tips/tipRegistry.ts +++ b/src/services/tips/tipRegistry.ts @@ -109,7 +109,6 @@ const externalTips: Tip[] = [ `Use Plan Mode to prepare for a complex request before making changes. Press ${getShortcutDisplay('chat:cycleMode', 'Chat', 'shift+tab')} twice to enable.`, cooldownSessions: 5, isRelevant: async () => { - if (process.env.USER_TYPE === 'ant') return false const config = getGlobalConfig() // Show to users who haven't used plan mode recently (7+ days) const daysSinceLastUse = config.lastPlanModeUse @@ -401,9 +400,7 @@ const externalTips: Tip[] = [ { id: 'shift-tab', content: async () => - process.env.USER_TYPE === 'ant' - ? `Hit ${getShortcutDisplay('chat:cycleMode', 'Chat', 'shift+tab')} to cycle between default mode and auto mode` - : `Hit ${getShortcutDisplay('chat:cycleMode', 'Chat', 'shift+tab')} to cycle between default mode, auto-accept edit mode, and plan mode`, + `Hit ${getShortcutDisplay('chat:cycleMode', 'Chat', 'shift+tab')} to cycle between default, accept edits, plan, auto, and bypass modes`, cooldownSessions: 10, isRelevant: async () => true, }, diff --git a/src/utils/permissions/__tests__/getNextPermissionMode.test.ts b/src/utils/permissions/__tests__/getNextPermissionMode.test.ts new file mode 100644 index 000000000..de3b70959 --- /dev/null +++ b/src/utils/permissions/__tests__/getNextPermissionMode.test.ts @@ -0,0 +1,204 @@ +/** + * Tests for src/utils/permissions/getNextPermissionMode.ts + * + * Covers the unified permission mode cycling logic: + * default → acceptEdits → plan → auto → bypassPermissions → default + * + * After the "open auto/bypass to all users" change, there is no USER_TYPE + * distinction — all users share the same cycle order. + */ +import { describe, expect, test } from 'bun:test' +import type { ToolPermissionContext } from '../../Tool.js' +import type { PermissionMode } from '../PermissionMode.js' + +// Inline getNextPermissionMode to avoid importing the heavy permissionSetup +// dependency chain (growthbook, settings, etc.). +// The function under test is small and pure enough to copy for testing. +import { getNextPermissionMode } from '../getNextPermissionMode.js' + +// ─── helpers ────────────────────────────────────────────────────────────────── + +function makeContext( + mode: PermissionMode, + overrides: Partial = {}, +): ToolPermissionContext { + return { + mode, + additionalWorkingDirectories: new Map(), + alwaysAllowRules: {}, + alwaysDenyRules: {}, + alwaysAskRules: {}, + isBypassPermissionsModeAvailable: true, + ...overrides, + } +} + +// ─── tests ──────────────────────────────────────────────────────────────────── + +describe('getNextPermissionMode', () => { + // ── Full cycle ────────────────────────────────────────────────────────── + + describe('unified cycle order', () => { + test('default → acceptEdits', () => { + expect(getNextPermissionMode(makeContext('default'))).toBe('acceptEdits') + }) + + test('acceptEdits → plan', () => { + expect(getNextPermissionMode(makeContext('acceptEdits'))).toBe('plan') + }) + + test('plan → auto', () => { + expect(getNextPermissionMode(makeContext('plan'))).toBe('auto') + }) + + test('auto → bypassPermissions (when bypass available)', () => { + expect(getNextPermissionMode(makeContext('auto'))).toBe('bypassPermissions') + }) + + test('bypassPermissions → default', () => { + expect(getNextPermissionMode(makeContext('bypassPermissions'))).toBe('default') + }) + + test('full cycle completes back to default', () => { + const cycle: PermissionMode[] = [] + let ctx = makeContext('default') + for (let i = 0; i < 5; i++) { + const next = getNextPermissionMode(ctx) + cycle.push(next) + ctx = makeContext(next) + } + expect(cycle).toEqual([ + 'acceptEdits', + 'plan', + 'auto', + 'bypassPermissions', + 'default', + ]) + }) + }) + + // ── auto → default when bypass unavailable ───────────────────────────── + + describe('auto mode with bypass unavailable', () => { + test('auto → default when isBypassPermissionsModeAvailable is false', () => { + const ctx = makeContext('auto', { + isBypassPermissionsModeAvailable: false, + }) + expect(getNextPermissionMode(ctx)).toBe('default') + }) + }) + + // ── dontAsk mode ──────────────────────────────────────────────────────── + + describe('dontAsk mode', () => { + test('dontAsk → default', () => { + expect(getNextPermissionMode(makeContext('dontAsk'))).toBe('default') + }) + }) + + // ── USER_TYPE independence ────────────────────────────────────────────── + + describe('no USER_TYPE distinction', () => { + test('cycle order is the same regardless of USER_TYPE', () => { + // Save original + const originalUserType = process.env.USER_TYPE + + // Test with no USER_TYPE + delete process.env.USER_TYPE + const cycleNoType: PermissionMode[] = [] + let ctx = makeContext('default') + for (let i = 0; i < 5; i++) { + const next = getNextPermissionMode(ctx) + cycleNoType.push(next) + ctx = makeContext(next) + } + + // Test with USER_TYPE=ant + process.env.USER_TYPE = 'ant' + const cycleAnt: PermissionMode[] = [] + ctx = makeContext('default') + for (let i = 0; i < 5; i++) { + const next = getNextPermissionMode(ctx) + cycleAnt.push(next) + ctx = makeContext(next) + } + + // Restore + if (originalUserType !== undefined) { + process.env.USER_TYPE = originalUserType + } else { + delete process.env.USER_TYPE + } + + // Both should produce the same cycle + expect(cycleNoType).toEqual(cycleAnt) + expect(cycleNoType).toEqual([ + 'acceptEdits', + 'plan', + 'auto', + 'bypassPermissions', + 'default', + ]) + }) + }) + + // ── teamContext parameter ─────────────────────────────────────────────── + + describe('teamContext parameter', () => { + test('does not affect cycle when provided', () => { + const ctx = makeContext('default') + const teamCtx = { leadAgentId: 'agent-123' } + expect(getNextPermissionMode(ctx, teamCtx)).toBe('acceptEdits') + }) + + test('does not affect cycle for plan mode', () => { + const ctx = makeContext('plan') + const teamCtx = { leadAgentId: 'agent-456' } + expect(getNextPermissionMode(ctx, teamCtx)).toBe('auto') + }) + }) + + // ── cycle stability (no infinite loops) ───────────────────────────────── + + describe('cycle stability', () => { + test('all modes return to default within 6 steps', () => { + const modes: PermissionMode[] = [ + 'default', + 'acceptEdits', + 'plan', + 'auto', + 'bypassPermissions', + 'dontAsk', + ] + for (const startMode of modes) { + let current = startMode + let returnedToDefault = false + for (let i = 0; i < 6; i++) { + current = getNextPermissionMode(makeContext(current)) + if (current === 'default') { + returnedToDefault = true + break + } + } + expect(returnedToDefault).toBe(true) + } + }) + + test('cycling 100 times never produces an invalid mode', () => { + const validModes = new Set([ + 'default', + 'acceptEdits', + 'plan', + 'auto', + 'bypassPermissions', + 'dontAsk', + ]) + let ctx = makeContext('default') + for (let i = 0; i < 100; i++) { + const next = getNextPermissionMode(ctx) + expect(validModes.has(next)).toBe(true) + ctx = makeContext(next) + } + }) + }) +}) diff --git a/src/utils/permissions/__tests__/permissionSetup.test.ts b/src/utils/permissions/__tests__/permissionSetup.test.ts new file mode 100644 index 000000000..ce073ef8f --- /dev/null +++ b/src/utils/permissions/__tests__/permissionSetup.test.ts @@ -0,0 +1,148 @@ +/** + * Tests for the simplified permission gate functions. + * + * After the "open auto/bypass to all users" change, the key guarantees are: + * - shouldDisableBypassPermissions() always returns false + * - isBypassPermissionsModeDisabled() always returns false + * - hasAutoModeOptInAnySource() always returns true + * - isAutoModeGateEnabled() returns true unless fast-mode circuit breaker fires + * - getAutoModeUnavailableReason() returns null when no breaker fires + * + * These functions are tested through the getNextPermissionMode cycle + * and through direct unit tests of the gate functions. + */ +import { describe, expect, test } from 'bun:test' +import type { ToolPermissionContext } from '../../Tool.js' +import type { PermissionMode } from '../PermissionMode.js' +import { getNextPermissionMode } from '../getNextPermissionMode.js' + +// ─── helpers ────────────────────────────────────────────────────────────────── + +function makeContext( + mode: PermissionMode, + overrides: Partial = {}, +): ToolPermissionContext { + return { + mode, + additionalWorkingDirectories: new Map(), + alwaysAllowRules: {}, + alwaysDenyRules: {}, + alwaysAskRules: {}, + isBypassPermissionsModeAvailable: true, + ...overrides, + } +} + +// ─── tests ──────────────────────────────────────────────────────────────────── + +describe('permission gate invariants (after opening auto/bypass)', () => { + // ── Bypass permissions is always available ────────────────────────────── + + describe('bypass mode always reachable in cycle', () => { + test('auto → bypassPermissions when isBypassPermissionsModeAvailable is true', () => { + const ctx = makeContext('auto', { isBypassPermissionsModeAvailable: true }) + expect(getNextPermissionMode(ctx)).toBe('bypassPermissions') + }) + + test('isBypassPermissionsModeAvailable true is the default from getEmptyToolPermissionContext', () => { + // This test verifies the Tool.ts default is true + // (imported indirectly through the cycle behavior) + const ctx = makeContext('auto') + expect(ctx.isBypassPermissionsModeAvailable).toBe(true) + expect(getNextPermissionMode(ctx)).toBe('bypassPermissions') + }) + }) + + // ── Auto mode is always available in cycle ────────────────────────────── + + describe('auto mode always reachable in cycle', () => { + test('plan → auto (always, no gate check)', () => { + expect(getNextPermissionMode(makeContext('plan'))).toBe('auto') + }) + + test('plan → auto even when isBypassPermissionsModeAvailable is false', () => { + const ctx = makeContext('plan', { isBypassPermissionsModeAvailable: false }) + expect(getNextPermissionMode(ctx)).toBe('auto') + }) + + test('bypassPermissions → default (then default → acceptEdits → plan → auto)', () => { + // Verify that after bypass, you can reach auto by cycling through + const fromBypass = getNextPermissionMode(makeContext('bypassPermissions')) + expect(fromBypass).toBe('default') + + const fromDefault = getNextPermissionMode(makeContext('default')) + expect(fromDefault).toBe('acceptEdits') + + const fromAcceptEdits = getNextPermissionMode(makeContext('acceptEdits')) + expect(fromAcceptEdits).toBe('plan') + + const fromPlan = getNextPermissionMode(makeContext('plan')) + expect(fromPlan).toBe('auto') + }) + }) + + // ── No opt-in gate between modes ──────────────────────────────────────── + + describe('no opt-in gate between modes', () => { + test('cycling from default to auto completes in 3 steps without any opt-in check', () => { + let mode: PermissionMode = 'default' + const steps: PermissionMode[] = [] + + // default → acceptEdits → plan → auto + for (let i = 0; i < 3; i++) { + mode = getNextPermissionMode(makeContext(mode)) + steps.push(mode) + } + + expect(steps).toEqual(['acceptEdits', 'plan', 'auto']) + }) + + test('cycling from default to bypassPermissions completes in 4 steps', () => { + let mode: PermissionMode = 'default' + const steps: PermissionMode[] = [] + + for (let i = 0; i < 4; i++) { + mode = getNextPermissionMode(makeContext(mode)) + steps.push(mode) + } + + expect(steps).toEqual(['acceptEdits', 'plan', 'auto', 'bypassPermissions']) + }) + }) + + // ── Mode ordering safety (most dangerous modes last) ──────────────────── + + describe('safety ordering', () => { + test('auto comes before bypassPermissions in the cycle', () => { + // Starting from plan, user must press Shift+Tab twice to reach bypass + // (plan → auto → bypassPermissions) + const fromPlan = getNextPermissionMode(makeContext('plan')) + expect(fromPlan).toBe('auto') + + const fromAuto = getNextPermissionMode(makeContext('auto')) + expect(fromAuto).toBe('bypassPermissions') + }) + + test('default comes before any dangerous mode', () => { + // default → acceptEdits (safe, just auto-accept edits) + const fromDefault = getNextPermissionMode(makeContext('default')) + expect(fromDefault).toBe('acceptEdits') + // acceptEdits is the least dangerous mode + }) + }) +}) + +describe('Tool.ts default context', () => { + test('getEmptyToolPermissionContext has isBypassPermissionsModeAvailable = true', async () => { + const { getEmptyToolPermissionContext } = await import('../../../Tool.js') + const ctx = getEmptyToolPermissionContext() + expect(ctx.isBypassPermissionsModeAvailable).toBe(true) + }) +}) + +describe('settings hasAutoModeOptIn', () => { + test('always returns true after change', async () => { + const { hasAutoModeOptIn } = await import('../../settings/settings.js') + expect(hasAutoModeOptIn()).toBe(true) + }) +}) diff --git a/src/utils/permissions/getNextPermissionMode.ts b/src/utils/permissions/getNextPermissionMode.ts index cbe958e1e..67a077d8a 100644 --- a/src/utils/permissions/getNextPermissionMode.ts +++ b/src/utils/permissions/getNextPermissionMode.ts @@ -1,35 +1,13 @@ -import { feature } from 'bun:bundle' import type { ToolPermissionContext } from '../../Tool.js' import { logForDebugging } from '../debug.js' import type { PermissionMode } from './PermissionMode.js' -import { - getAutoModeUnavailableReason, - isAutoModeGateEnabled, - transitionPermissionMode, -} from './permissionSetup.js' - -// Checks both the cached isAutoModeAvailable (set at startup by -// verifyAutoModeGateAccess) and the live isAutoModeGateEnabled() — these can -// diverge if the circuit breaker or settings change mid-session. The -// live check prevents transitionPermissionMode from throwing -// (permissionSetup.ts:~559), which would silently crash the shift+tab handler -// and leave the user stuck at the current mode. -function canCycleToAuto(ctx: ToolPermissionContext): boolean { - if (feature('TRANSCRIPT_CLASSIFIER')) { - const gateEnabled = isAutoModeGateEnabled() - const can = !!ctx.isAutoModeAvailable && gateEnabled - if (!can) { - logForDebugging( - `[auto-mode] canCycleToAuto=false: ctx.isAutoModeAvailable=${ctx.isAutoModeAvailable} isAutoModeGateEnabled=${gateEnabled} reason=${getAutoModeUnavailableReason()}`, - ) - } - return can - } - return false -} +import { transitionPermissionMode } from './permissionSetup.js' /** * Determines the next permission mode when cycling through modes with Shift+Tab. + * + * Unified cycle for all users (no USER_TYPE distinction): + * default → acceptEdits → plan → auto → bypassPermissions → default */ export function getNextPermissionMode( toolPermissionContext: ToolPermissionContext, @@ -37,43 +15,29 @@ export function getNextPermissionMode( ): PermissionMode { switch (toolPermissionContext.mode) { case 'default': - // Ants skip acceptEdits and plan — auto mode replaces them - if (process.env.USER_TYPE === 'ant') { - if (toolPermissionContext.isBypassPermissionsModeAvailable) { - return 'bypassPermissions' - } - if (canCycleToAuto(toolPermissionContext)) { - return 'auto' - } - return 'default' - } return 'acceptEdits' case 'acceptEdits': return 'plan' case 'plan': + return 'auto' + + case 'auto': if (toolPermissionContext.isBypassPermissionsModeAvailable) { return 'bypassPermissions' } - if (canCycleToAuto(toolPermissionContext)) { - return 'auto' - } return 'default' case 'bypassPermissions': - if (canCycleToAuto(toolPermissionContext)) { - return 'auto' - } return 'default' case 'dontAsk': // Not exposed in UI cycle yet, but return default if somehow reached return 'default' - default: - // Covers auto (when TRANSCRIPT_CLASSIFIER is enabled) and any future modes — always fall back to default + // Covers any future modes — always fall back to default return 'default' } } diff --git a/src/utils/settings/settings.ts b/src/utils/settings/settings.ts index 3bea04af2..f656e6a6a 100644 --- a/src/utils/settings/settings.ts +++ b/src/utils/settings/settings.ts @@ -894,20 +894,8 @@ export function hasSkipDangerousModePermissionPrompt(): boolean { * a malicious project could otherwise auto-bypass the dialog (RCE risk). */ export function hasAutoModeOptIn(): boolean { - if (feature('TRANSCRIPT_CLASSIFIER')) { - const user = getSettingsForSource('userSettings')?.skipAutoPermissionPrompt - const local = - getSettingsForSource('localSettings')?.skipAutoPermissionPrompt - const flag = getSettingsForSource('flagSettings')?.skipAutoPermissionPrompt - const policy = - getSettingsForSource('policySettings')?.skipAutoPermissionPrompt - const result = !!(user || local || flag || policy) - logForDebugging( - `[auto-mode] hasAutoModeOptIn=${result} skipAutoPermissionPrompt: user=${user} local=${local} flag=${flag} policy=${policy}`, - ) - return result - } - return false + // Auto mode is available to all users — no opt-in needed + return true } /**