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..78baf8199 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: {}, @@ -627,6 +627,23 @@ describe('AcpAgent', () => { agent.setSessionMode({ sessionId: 'ghost', modeId: 'auto' } as any), ).rejects.toThrow('Session not found') }) + + test('availableModes includes bypassPermissions when not root', async () => { + const agent = new AcpAgent(makeConn()) + const { sessionId } = await agent.newSession({ cwd: '/tmp' } as any) + const session = agent.sessions.get(sessionId) + const modeIds = session?.modes.availableModes.map((m: any) => m.id) + expect(modeIds).toContain('bypassPermissions') + }) + + test('can switch to bypassPermissions mode', async () => { + const agent = new AcpAgent(makeConn()) + const { sessionId } = await agent.newSession({ cwd: '/tmp' } as any) + await agent.setSessionMode({ sessionId, modeId: 'bypassPermissions' } as any) + const session = agent.sessions.get(sessionId) + expect(session?.modes.currentModeId).toBe('bypassPermissions') + expect(session?.appState.toolPermissionContext.mode).toBe('bypassPermissions') + }) }) describe('setSessionConfigOption', () => { diff --git a/src/services/acp/agent.ts b/src/services/acp/agent.ts index b367d2c4a..d96960bd4 100644 --- a/src/services/acp/agent.ts +++ b/src/services/acp/agent.ts @@ -519,12 +519,15 @@ export class AcpAgent implements Agent { const queryEngine = new QueryEngine(engineConfig) - // Build modes + // Build modes — bypassPermissions only available when not running as root (or in sandbox) const availableModes = [ - { id: 'auto', name: 'Auto', description: 'Use a model classifier to approve/deny permission prompts.' }, { id: 'default', name: 'Default', description: 'Standard behavior, prompts for dangerous operations' }, { id: 'acceptEdits', name: 'Accept Edits', description: 'Auto-accept file edit operations' }, { id: 'plan', name: 'Plan Mode', description: 'Planning mode, no actual tool execution' }, + { id: 'auto', name: 'Auto', description: 'Use a model classifier to approve/deny permission prompts.' }, + ...(isBypassAvailable + ? [{ id: 'bypassPermissions' as const, name: 'Bypass Permissions', description: 'Skip all permission checks' }] + : []), { id: 'dontAsk', name: "Don't Ask", description: "Don't prompt for permissions, deny if not pre-approved" }, ] 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/state/onChangeAppState.ts b/src/state/onChangeAppState.ts index a84d98da0..2d94830b6 100644 --- a/src/state/onChangeAppState.ts +++ b/src/state/onChangeAppState.ts @@ -17,7 +17,6 @@ import { notifySessionMetadataChanged, type SessionExternalMetadata, } from '../utils/sessionState.js' -import { updateSettingsForSource } from '../utils/settings/settings.js' import type { AppState } from './AppStateStore.js' // Inverse of the push below — restore on worker restart. @@ -91,23 +90,11 @@ export function onChangeAppState({ notifyPermissionModeChanged(newMode) } - // mainLoopModel: remove it from settings? - if ( - newState.mainLoopModel !== oldState.mainLoopModel && - newState.mainLoopModel === null - ) { - // Remove from settings - updateSettingsForSource('userSettings', { model: undefined }) - setMainLoopModelOverride(null) - } - - // mainLoopModel: add it to settings? - if ( - newState.mainLoopModel !== oldState.mainLoopModel && - newState.mainLoopModel !== null - ) { - // Save to settings - updateSettingsForSource('userSettings', { model: newState.mainLoopModel }) + // mainLoopModel: session-scoped only (do NOT persist to userSettings). + // Writing to settings.json would leak model changes into other running + // sessions (anthropics/claude-code#37596). Each process keeps its own + // model override in memory via setMainLoopModelOverride. + if (newState.mainLoopModel !== oldState.mainLoopModel) { setMainLoopModelOverride(newState.mainLoopModel) } diff --git a/src/utils/permissions/__tests__/getNextPermissionMode.test.ts b/src/utils/permissions/__tests__/getNextPermissionMode.test.ts new file mode 100644 index 000000000..9b16609a0 --- /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..c4d0cafb0 --- /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/bypassPermissionsKillswitch.ts b/src/utils/permissions/bypassPermissionsKillswitch.ts index f03d57278..543c66b68 100644 --- a/src/utils/permissions/bypassPermissionsKillswitch.ts +++ b/src/utils/permissions/bypassPermissionsKillswitch.ts @@ -1,79 +1,44 @@ import { feature } from 'bun:bundle' import { useEffect, useRef } from 'react' -import { - type AppState, - useAppState, - useAppStateStore, - useSetAppState, -} from 'src/state/AppState.js' -import type { ToolPermissionContext } from 'src/Tool.js' +import { useNotifications } from 'src/context/notifications.js' +import { toError } from '../../utils/errors.js' +import { logError } from '../../utils/log.js' import { getIsRemoteMode } from '../../bootstrap/state.js' +import { useAppState, useAppStateStore, useSetAppState } from '../../state/AppState.js' +import type { ToolPermissionContext } from '../../Tool.js' import { - createDisabledBypassPermissionsContext, - shouldDisableBypassPermissions, verifyAutoModeGateAccess, } from './permissionSetup.js' -let bypassPermissionsCheckRan = false - +/** + * No-op — bypass permissions is always available. + */ export async function checkAndDisableBypassPermissionsIfNeeded( - toolPermissionContext: ToolPermissionContext, - setAppState: (f: (prev: AppState) => AppState) => void, + _toolPermissionContext: ToolPermissionContext, + _setAppState: (f: (prev: import('../../state/AppState.js').AppState) => import('../../state/AppState.js').AppState) => void, ): Promise { - // Check if bypassPermissions should be disabled based on Statsig gate - // Do this only once, before the first query, to ensure we have the latest gate value - if (bypassPermissionsCheckRan) { - return - } - bypassPermissionsCheckRan = true - - if (!toolPermissionContext.isBypassPermissionsModeAvailable) { - return - } - - const shouldDisable = await shouldDisableBypassPermissions() - if (!shouldDisable) { - return - } - - setAppState(prev => { - return { - ...prev, - toolPermissionContext: createDisabledBypassPermissionsContext( - prev.toolPermissionContext, - ), - } - }) + // Bypass permissions is always available — no gate check needed } /** - * Reset the run-once flag for checkAndDisableBypassPermissionsIfNeeded. - * Call this after /login so the gate check re-runs with the new org. + * Reset stub — kept for interface compatibility. */ export function resetBypassPermissionsCheck(): void { - bypassPermissionsCheckRan = false + // No-op } +/** + * No-op hook — bypass permissions is always available. + */ export function useKickOffCheckAndDisableBypassPermissionsIfNeeded(): void { - const toolPermissionContext = useAppState(s => s.toolPermissionContext) - const setAppState = useSetAppState() - - // Run once, when the component mounts - useEffect(() => { - if (getIsRemoteMode()) return - void checkAndDisableBypassPermissionsIfNeeded( - toolPermissionContext, - setAppState, - ) - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []) + // No-op } let autoModeCheckRan = false export async function checkAndDisableAutoModeIfNeeded( toolPermissionContext: ToolPermissionContext, - setAppState: (f: (prev: AppState) => AppState) => void, + setAppState: (f: (prev: import('../../state/AppState.js').AppState) => import('../../state/AppState.js').AppState) => void, fastMode?: boolean, ): Promise { if (feature('TRANSCRIPT_CLASSIFIER')) { @@ -87,10 +52,6 @@ export async function checkAndDisableAutoModeIfNeeded( fastMode, ) setAppState(prev => { - // Apply the transform to CURRENT context, not the stale snapshot we - // passed to verifyAutoModeGateAccess. The async GrowthBook await inside - // can be outrun by a mid-turn shift-tab; spreading a stale context here - // would revert the user's mode change. const nextCtx = updateContext(prev.toolPermissionContext) const newState = nextCtx === prev.toolPermissionContext @@ -133,11 +94,6 @@ export function useKickOffCheckAndDisableAutoModeIfNeeded(): void { const isFirstRunRef = useRef(true) // Runs on mount (startup check) AND whenever the model or fast mode changes - // (kick-out / carousel-restore). Watching both model fields covers /model, - // Cmd+P picker, /config, and bridge onSetModel paths; fastMode covers - // /fast on|off for the tengu_auto_mode_config.disableFastMode circuit - // breaker. The print.ts headless paths are covered by the sync - // isAutoModeGateEnabled() check. useEffect(() => { if (getIsRemoteMode()) return if (isFirstRunRef.current) { @@ -149,7 +105,9 @@ export function useKickOffCheckAndDisableAutoModeIfNeeded(): void { store.getState().toolPermissionContext, setAppState, fastMode, - ) + ).catch(error => { + logError(new Error('Auto mode gate check failed', { cause: toError(error) })) + }) // eslint-disable-next-line react-hooks/exhaustive-deps }, [mainLoopModel, mainLoopModelForSession, fastMode]) } 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/permissions/permissionSetup.ts b/src/utils/permissions/permissionSetup.ts index 7275307f9..986bb7b98 100644 --- a/src/utils/permissions/permissionSetup.ts +++ b/src/utils/permissions/permissionSetup.ts @@ -799,10 +799,6 @@ export function initialPermissionModeFromCLI({ result = { mode: 'default', notification } } - if (!result) { - result = { mode: 'default', notification } - } - if (feature('TRANSCRIPT_CLASSIFIER') && result.mode === 'auto') { autoModeStateModule?.setAutoModeActive(true) } @@ -927,20 +923,9 @@ export async function initializeToolPermissionContext({ }) } - // Check if bypassPermissions mode is available (not disabled by Statsig gate or settings) - // Use cached values to avoid blocking on startup - const growthBookDisableBypassPermissionsMode = - checkStatsigFeatureGate_CACHED_MAY_BE_STALE( - 'tengu_disable_bypass_permissions_mode', - ) + // Bypass permissions mode is available to all users + const isBypassPermissionsModeAvailable = true const settings = getSettings_DEPRECATED() || {} - const settingsDisableBypassPermissionsMode = - settings.permissions?.disableBypassPermissionsMode === 'disable' - const isBypassPermissionsModeAvailable = - (permissionMode === 'bypassPermissions' || - allowDangerouslySkipPermissions) && - !growthBookDisableBypassPermissionsMode && - !settingsDisableBypassPermissionsMode // Load all permission rules from disk const rulesFromDisk = loadAllPermissionRulesFromDisk() @@ -984,7 +969,7 @@ export async function initializeToolPermissionContext({ alwaysAskRules: {}, isBypassPermissionsModeAvailable, ...(feature('TRANSCRIPT_CLASSIFIER') - ? { isAutoModeAvailable: isAutoModeGateEnabled() } + ? { isAutoModeAvailable: true } : {}), }, rulesFromDisk, @@ -1076,131 +1061,54 @@ export function getAutoModeUnavailableNotification( * kicking the user out of a mode they've already left during the await. */ export async function verifyAutoModeGateAccess( - currentContext: ToolPermissionContext, + _currentContext: ToolPermissionContext, // Runtime AppState.fastMode — passed from callers with AppState access so // the disableFastMode circuit breaker reads current state, not stale // settings.fastMode (which is intentionally sticky across /model auto- // downgrades). Optional for callers without AppState (e.g. SDK init paths). fastMode?: boolean, ): Promise { - // Auto-mode config — runs in ALL builds (circuit breaker, carousel, kick-out) - // Fresh read of tengu_auto_mode_config.enabled — this async check runs once - // after GrowthBook initialization and is the authoritative source for - // isAutoModeAvailable. The sync startup path uses stale cache; this - // corrects it. Circuit breaker (enabled==='disabled') takes effect here. + // Only fast-mode circuit breaker remains. All other gates (GrowthBook, + // settings, model support, opt-in) have been removed. const autoModeConfig = await getDynamicConfig_BLOCKS_ON_INIT<{ enabled?: AutoModeEnabledState disableFastMode?: boolean }>('tengu_auto_mode_config', {}) - const enabledState = parseAutoModeEnabledState(autoModeConfig?.enabled) - const disabledBySettings = isAutoModeDisabledBySettings() - // Treat settings-disable the same as GrowthBook 'disabled' for circuit-breaker - // semantics — blocks SDK/explicit re-entry via isAutoModeGateEnabled(). - autoModeStateModule?.setAutoModeCircuitBroken( - enabledState === 'disabled' || disabledBySettings, - ) - // Carousel availability: not circuit-broken, not disabled-by-settings, - // model supports it, disableFastMode breaker not firing, and (enabled or opted-in) const mainModel = getMainLoopModel() - // Temp circuit breaker: tengu_auto_mode_config.disableFastMode blocks auto - // mode when fast mode is on. Checks runtime AppState.fastMode (if provided) - // and, for ants, model name '-fast' substring (ant-internal fast models - // like capybara-v2-fast[1m] encode speed in the model ID itself). - // Remove once auto+fast mode interaction is validated. const disableFastModeBreakerFires = !!autoModeConfig?.disableFastMode && (!!fastMode || (process.env.USER_TYPE === 'ant' && mainModel.toLowerCase().includes('-fast'))) - const modelSupported = - modelSupportsAutoMode(mainModel) && !disableFastModeBreakerFires - let carouselAvailable = false - if (enabledState !== 'disabled' && !disabledBySettings && modelSupported) { - carouselAvailable = - enabledState === 'enabled' || hasAutoModeOptInAnySource() - } - // canEnterAuto gates explicit entry (--permission-mode auto, defaultMode: auto) - // — explicit entry IS an opt-in, so we only block on circuit breaker + settings + model - const canEnterAuto = - enabledState !== 'disabled' && !disabledBySettings && modelSupported + + // If fast-mode breaker fires, circuit-break auto mode + autoModeStateModule?.setAutoModeCircuitBroken(disableFastModeBreakerFires) + logForDebugging( - `[auto-mode] verifyAutoModeGateAccess: enabledState=${enabledState} disabledBySettings=${disabledBySettings} model=${mainModel} modelSupported=${modelSupported} disableFastModeBreakerFires=${disableFastModeBreakerFires} carouselAvailable=${carouselAvailable} canEnterAuto=${canEnterAuto}`, + `[auto-mode] verifyAutoModeGateAccess: disableFastModeBreakerFires=${disableFastModeBreakerFires}`, ) - // Capture CLI-flag intent now (doesn't depend on context). - const autoModeFlagCli = autoModeStateModule?.getAutoModeFlagCli() ?? false - - // Return a transform function that re-evaluates context-dependent conditions - // against the CURRENT context at setAppState time. The async GrowthBook - // results above (canEnterAuto, carouselAvailable, enabledState, reason) are - // closure-captured — those don't depend on context. But mode, prePlanMode, - // and isAutoModeAvailable checks MUST use the fresh ctx or a mid-await - // shift-tab gets reverted (or worse, the user stays in auto despite the - // circuit breaker if they entered auto DURING the await — which is possible - // because setAutoModeCircuitBroken above runs AFTER the await). - const setAvailable = ( - ctx: ToolPermissionContext, - available: boolean, - ): ToolPermissionContext => { - if (ctx.isAutoModeAvailable !== available) { - logForDebugging( - `[auto-mode] verifyAutoModeGateAccess setAvailable: ${ctx.isAutoModeAvailable} -> ${available}`, - ) - } - return ctx.isAutoModeAvailable === available - ? ctx - : { ...ctx, isAutoModeAvailable: available } + if (!disableFastModeBreakerFires) { + // Auto mode available — no kick-out needed + return { updateContext: ctx => ctx } } - if (canEnterAuto) { - return { updateContext: ctx => setAvailable(ctx, carouselAvailable) } - } + // Fast-mode breaker fired — kick out of auto if currently in it + const notification = getAutoModeUnavailableNotification('circuit-breaker') - // Gate is off or circuit-broken — determine reason (context-independent). - let reason: AutoModeUnavailableReason - if (disabledBySettings) { - reason = 'settings' - logForDebugging('auto mode disabled: disableAutoMode in settings', { - level: 'warn', - }) - } else if (enabledState === 'disabled') { - reason = 'circuit-breaker' - logForDebugging( - 'auto mode disabled: tengu_auto_mode_config.enabled === "disabled" (circuit breaker)', - { level: 'warn' }, - ) - } else { - reason = 'model' - logForDebugging( - `auto mode disabled: model ${getMainLoopModel()} does not support auto mode`, - { level: 'warn' }, - ) - } - const notification = getAutoModeUnavailableNotification(reason) - - // Unified kick-out transform. Re-checks the FRESH ctx and only fires - // side effects (setAutoModeActive(false), setNeedsAutoModeExitAttachment) - // when the kick-out actually applies. This keeps autoModeActive in sync - // with toolPermissionContext.mode even if the user changed modes during - // the await: if they already left auto on their own, handleCycleMode - // already deactivated the classifier and we don't fire again; if they - // ENTERED auto during the await (possible before setAutoModeCircuitBroken - // landed), we kick them out here. const kickOutOfAutoIfNeeded = ( ctx: ToolPermissionContext, ): ToolPermissionContext => { const inAuto = ctx.mode === 'auto' logForDebugging( - `[auto-mode] kickOutOfAutoIfNeeded applying: ctx.mode=${ctx.mode} ctx.prePlanMode=${ctx.prePlanMode} reason=${reason}`, + `[auto-mode] kickOutOfAutoIfNeeded (fast-mode): ctx.mode=${ctx.mode}`, ) - // Plan mode with auto active: either from prePlanMode='auto' (entered - // from auto) or from opt-in (strippedDangerousRules present). const inPlanWithAutoActive = ctx.mode === 'plan' && (ctx.prePlanMode === 'auto' || !!ctx.strippedDangerousRules) if (!inAuto && !inPlanWithAutoActive) { - return setAvailable(ctx, false) + return { ...ctx, isAutoModeAvailable: false } } if (inAuto) { autoModeStateModule?.setAutoModeActive(false) @@ -1214,8 +1122,6 @@ export async function verifyAutoModeGateAccess( isAutoModeAvailable: false, } } - // Plan with auto active: deactivate auto, restore permissions, defuse - // prePlanMode so ExitPlanMode goes to default. autoModeStateModule?.setAutoModeActive(false) setNeedsAutoModeExitAttachment(true) return { @@ -1225,65 +1131,23 @@ export async function verifyAutoModeGateAccess( } } - // Notification decisions use the stale context — that's OK: we're deciding - // WHETHER to notify based on what the user WAS doing when this check started. - // (Side effects and mode mutation are decided inside the transform above, - // against the fresh ctx.) - const wasInAuto = currentContext.mode === 'auto' - // Auto was used during plan: entered from auto or opt-in auto active - const autoActiveDuringPlan = - currentContext.mode === 'plan' && - (currentContext.prePlanMode === 'auto' || - !!currentContext.strippedDangerousRules) - const wantedAuto = wasInAuto || autoActiveDuringPlan || autoModeFlagCli - - if (!wantedAuto) { - // User didn't want auto at call time — no notification. But still apply - // the full kick-out transform: if they shift-tabbed INTO auto during the - // await (before setAutoModeCircuitBroken landed), we need to evict them. - return { updateContext: kickOutOfAutoIfNeeded } - } - - if (wasInAuto || autoActiveDuringPlan) { - // User was in auto or had auto active during plan — kick out + notify. - return { updateContext: kickOutOfAutoIfNeeded, notification } - } - - // autoModeFlagCli only: defaultMode was auto but sync check rejected it. - // Suppress notification if isAutoModeAvailable is already false (already - // notified on a prior check; prevents repeat notifications on successive - // unsupported-model switches). - return { - updateContext: kickOutOfAutoIfNeeded, - notification: currentContext.isAutoModeAvailable ? notification : undefined, - } + return { updateContext: kickOutOfAutoIfNeeded, notification } } /** - * Core logic to check if bypassPermissions should be disabled based on Statsig gate + * Bypass permissions is always available — no remote gate check needed. */ export function shouldDisableBypassPermissions(): Promise { - return checkSecurityRestrictionGate('tengu_disable_bypass_permissions_mode') -} - -function isAutoModeDisabledBySettings(): boolean { - const settings = getSettings_DEPRECATED() || {} - return ( - (settings as { disableAutoMode?: 'disable' }).disableAutoMode === - 'disable' || - (settings.permissions as { disableAutoMode?: 'disable' } | undefined) - ?.disableAutoMode === 'disable' - ) + return Promise.resolve(false) } /** - * Checks if auto mode can be entered: circuit breaker is not active and settings - * have not disabled it. Synchronous. + * Checks if auto mode can be entered: only fast-mode circuit breaker remains. + * Synchronous. */ export function isAutoModeGateEnabled(): boolean { + // Auto mode is available to all users — only fast-mode circuit breaker remains if (autoModeStateModule?.isAutoModeCircuitBroken() ?? false) return false - if (isAutoModeDisabledBySettings()) return false - if (!modelSupportsAutoMode(getMainLoopModel())) return false return true } @@ -1292,11 +1156,9 @@ export function isAutoModeGateEnabled(): boolean { * Synchronous — uses state populated by verifyAutoModeGateAccess. */ export function getAutoModeUnavailableReason(): AutoModeUnavailableReason | null { - if (isAutoModeDisabledBySettings()) return 'settings' if (autoModeStateModule?.isAutoModeCircuitBroken() ?? false) { return 'circuit-breaker' } - if (!modelSupportsAutoMode(getMainLoopModel())) return 'model' return null } @@ -1310,8 +1172,7 @@ export function getAutoModeUnavailableReason(): AutoModeUnavailableReason | null */ export type AutoModeEnabledState = 'enabled' | 'disabled' | 'opt-in' -const AUTO_MODE_ENABLED_DEFAULT: AutoModeEnabledState = - feature('TRANSCRIPT_CLASSIFIER') ? 'enabled' : 'disabled' +const AUTO_MODE_ENABLED_DEFAULT: AutoModeEnabledState = 'enabled' function parseAutoModeEnabledState(value: unknown): AutoModeEnabledState { if (value === 'enabled' || value === 'disabled' || value === 'opt-in') { @@ -1361,27 +1222,15 @@ export function getAutoModeEnabledStateIfCached(): * dialog or by IDE/Desktop settings toggle) */ export function hasAutoModeOptInAnySource(): boolean { - if (autoModeStateModule?.getAutoModeFlagCli() ?? false) return true - return hasAutoModeOptIn() + return true } /** * Checks if bypassPermissions mode is currently disabled by Statsig gate or settings. - * This is a synchronous version that uses cached Statsig values. + * Always returns false — bypass is available to all users. */ export function isBypassPermissionsModeDisabled(): boolean { - const growthBookDisableBypassPermissionsMode = - checkStatsigFeatureGate_CACHED_MAY_BE_STALE( - 'tengu_disable_bypass_permissions_mode', - ) - const settings = getSettings_DEPRECATED() || {} - const settingsDisableBypassPermissionsMode = - settings.permissions?.disableBypassPermissionsMode === 'disable' - - return ( - growthBookDisableBypassPermissionsMode || - settingsDisableBypassPermissionsMode - ) + return false } /** @@ -1406,29 +1255,12 @@ export function createDisabledBypassPermissionsContext( } /** - * Asynchronously checks if the bypassPermissions mode should be disabled based on Statsig gate - * and returns an updated toolPermissionContext if needed + * No-op — bypass permissions is always available, no remote gate check needed. */ export async function checkAndDisableBypassPermissions( - currentContext: ToolPermissionContext, + _currentContext: ToolPermissionContext, ): Promise { - // Only proceed if bypassPermissions mode is available - if (!currentContext.isBypassPermissionsModeAvailable) { - return - } - - const shouldDisable = await shouldDisableBypassPermissions() - if (!shouldDisable) { - return - } - - // Gate is enabled, need to disable bypassPermissions mode - logForDebugging( - 'bypassPermissions mode is being disabled by Statsig gate (async check)', - { level: 'warn' }, - ) - - void gracefulShutdown(1, 'bypass_permissions_disabled') + // Bypass permissions is always available — no gate check needed } export function isDefaultPermissionModeAuto(): boolean { @@ -1446,11 +1278,7 @@ export function isDefaultPermissionModeAuto(): boolean { */ export function shouldPlanUseAutoMode(): boolean { if (feature('TRANSCRIPT_CLASSIFIER')) { - return ( - hasAutoModeOptIn() && - isAutoModeGateEnabled() && - getUseAutoModeDuringPlan() - ) + return isAutoModeGateEnabled() && getUseAutoModeDuringPlan() } return false } 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 } /** diff --git a/src/utils/sideQuery.ts b/src/utils/sideQuery.ts index 4e6d4d731..9455a83e2 100644 --- a/src/utils/sideQuery.ts +++ b/src/utils/sideQuery.ts @@ -2,6 +2,7 @@ import type Anthropic from '@anthropic-ai/sdk' import type { BetaToolUnion } from '@anthropic-ai/sdk/resources/beta/messages.js' import { getLastApiCompletionTimestamp, + getSessionId, setLastApiCompletionTimestamp, } from '../bootstrap/state.js' import { STRUCTURED_OUTPUTS_BETA_HEADER } from '../constants/betas.js' @@ -14,8 +15,10 @@ import { logEvent } from '../services/analytics/index.js' import type { AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS } from '../services/analytics/metadata.js' import { getAPIMetadata } from '../services/api/claude.js' import { getAnthropicClient } from '../services/api/client.js' +import { createTrace, endTrace, recordLLMObservation } from '../services/langfuse/index.js' import { getModelBetas, modelSupportsStructuredOutputs } from './betas.js' import { computeFingerprint } from './fingerprint.js' +import { getAPIProvider } from './model/providers.js' import { normalizeModelStringForAPI } from './model/model.js' type MessageParam = Anthropic.MessageParam @@ -177,25 +180,39 @@ export async function sideQuery(opts: SideQueryOptions): Promise { } const normalizedModel = normalizeModelStringForAPI(model) + const provider = getAPIProvider() const start = Date.now() - // biome-ignore lint/plugin: this IS the wrapper that handles OAuth attribution - const response = await client.beta.messages.create( - { - model: normalizedModel, - max_tokens, - system: systemBlocks, - messages, - ...(tools && { tools }), - ...(tool_choice && { tool_choice }), - ...(output_format && { output_config: { format: output_format } }), - ...(temperature !== undefined && { temperature }), - ...(stop_sequences && { stop_sequences }), - ...(thinkingConfig && { thinking: thinkingConfig }), - ...(betas.length > 0 && { betas }), - metadata: getAPIMetadata(), - }, - { signal }, - ) + const langfuseTrace = createTrace({ + sessionId: getSessionId(), + model: normalizedModel, + provider, + name: `side-query:${opts.querySource}`, + querySource: opts.querySource, + }) + + let response: BetaMessage + try { + response = await client.beta.messages.create( + { + model: normalizedModel, + max_tokens, + system: systemBlocks, + messages, + ...(tools && { tools }), + ...(tool_choice && { tool_choice }), + ...(output_format && { output_config: { format: output_format } }), + ...(temperature !== undefined && { temperature }), + ...(stop_sequences && { stop_sequences }), + ...(thinkingConfig && { thinking: thinkingConfig }), + ...(betas.length > 0 && { betas }), + metadata: getAPIMetadata(), + }, + { signal }, + ) + } catch (error) { + endTrace(langfuseTrace, undefined, 'error') + throw error + } const requestId = (response as { _request_id?: string | null })._request_id ?? undefined @@ -218,5 +235,22 @@ export async function sideQuery(opts: SideQueryOptions): Promise { }) setLastApiCompletionTimestamp(now) + // Record LLM observation in Langfuse (no-op if not configured) + recordLLMObservation(langfuseTrace, { + model: normalizedModel, + provider, + input: messages, + output: response.content, + usage: { + input_tokens: response.usage.input_tokens, + output_tokens: response.usage.output_tokens, + cache_creation_input_tokens: response.usage.cache_creation_input_tokens ?? undefined, + cache_read_input_tokens: response.usage.cache_read_input_tokens ?? undefined, + }, + startTime: new Date(start), + endTime: new Date(), + }) + endTrace(langfuseTrace) + return response }