mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-22 16:25:51 +00:00
feat: 开放 auto mode 和 bypass mode 给所有用户
通过 Shift+Tab 统一循环:default → acceptEdits → plan → auto → bypassPermissions → default - 移除 USER_TYPE 分支判断,所有用户使用同一循环路径 - isBypassPermissionsModeAvailable 始终为 true - isAutoModeAvailable 初始化直接为 true - 移除 AutoModeOptInDialog 确认流程 - 简化 isAutoModeGateEnabled 仅保留快模式熔断器 - 简化 verifyAutoModeGateAccess 仅检查快模式 - 移除 GrowthBook/Statsig 远程门控 - bypass permissions killswitch 改为 no-op - 新增 24 个测试覆盖循环逻辑和门控不变量 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -146,7 +146,7 @@ export const getEmptyToolPermissionContext: () => ToolPermissionContext =
|
|||||||
alwaysAllowRules: {},
|
alwaysAllowRules: {},
|
||||||
alwaysDenyRules: {},
|
alwaysDenyRules: {},
|
||||||
alwaysAskRules: {},
|
alwaysAskRules: {},
|
||||||
isBypassPermissionsModeAvailable: false,
|
isBypassPermissionsModeAvailable: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
export type CompactProgressEvent =
|
export type CompactProgressEvent =
|
||||||
|
|||||||
@@ -166,9 +166,9 @@ describe('getEmptyToolPermissionContext', () => {
|
|||||||
expect(ctx.alwaysAskRules).toEqual({})
|
expect(ctx.alwaysAskRules).toEqual({})
|
||||||
})
|
})
|
||||||
|
|
||||||
test('returns isBypassPermissionsModeAvailable as false', () => {
|
test('returns isBypassPermissionsModeAvailable as true', () => {
|
||||||
const ctx = getEmptyToolPermissionContext()
|
const ctx = getEmptyToolPermissionContext()
|
||||||
expect(ctx.isBypassPermissionsModeAvailable).toBe(false)
|
expect(ctx.isBypassPermissionsModeAvailable).toBe(true)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -18,9 +18,7 @@ import type { LocalJSXCommandOnDone } from '../../types/command.js'
|
|||||||
import { stripSignatureBlocks } from '../../utils/messages.js'
|
import { stripSignatureBlocks } from '../../utils/messages.js'
|
||||||
import {
|
import {
|
||||||
checkAndDisableAutoModeIfNeeded,
|
checkAndDisableAutoModeIfNeeded,
|
||||||
checkAndDisableBypassPermissionsIfNeeded,
|
|
||||||
resetAutoModeGateCheck,
|
resetAutoModeGateCheck,
|
||||||
resetBypassPermissionsCheck,
|
|
||||||
} from '../../utils/permissions/bypassPermissionsKillswitch.js'
|
} from '../../utils/permissions/bypassPermissionsKillswitch.js'
|
||||||
import { resetUserCache } from '../../utils/user.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)
|
// Enroll as a trusted device for Remote Control (10-min fresh-session window)
|
||||||
void enrollTrustedDevice()
|
void enrollTrustedDevice()
|
||||||
// Reset killswitch gate checks and re-run with new org
|
// Reset killswitch gate checks and re-run with new org
|
||||||
resetBypassPermissionsCheck()
|
resetAutoModeGateCheck()
|
||||||
const appState = context.getAppState()
|
const appState = context.getAppState()
|
||||||
void checkAndDisableBypassPermissionsIfNeeded(
|
void checkAndDisableAutoModeIfNeeded(
|
||||||
appState.toolPermissionContext,
|
appState.toolPermissionContext,
|
||||||
context.setAppState,
|
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)
|
// Increment authVersion to trigger re-fetching of auth-dependent data in hooks (e.g., MCP servers)
|
||||||
context.setAppState(prev => ({
|
context.setAppState(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
|
|||||||
@@ -151,16 +151,14 @@ import {
|
|||||||
isOpus1mMergeEnabled,
|
isOpus1mMergeEnabled,
|
||||||
modelDisplayString,
|
modelDisplayString,
|
||||||
} from '../../utils/model/model.js'
|
} from '../../utils/model/model.js'
|
||||||
import { setAutoModeActive } from '../../utils/permissions/autoModeState.js'
|
|
||||||
import {
|
import {
|
||||||
cyclePermissionMode,
|
cyclePermissionMode,
|
||||||
getNextPermissionMode,
|
getNextPermissionMode,
|
||||||
} from '../../utils/permissions/getNextPermissionMode.js'
|
} from '../../utils/permissions/getNextPermissionMode.js'
|
||||||
import { transitionPermissionMode } from '../../utils/permissions/permissionSetup.js'
|
|
||||||
import { getPlatform } from '../../utils/platform.js'
|
import { getPlatform } from '../../utils/platform.js'
|
||||||
import type { ProcessUserInputContext } from '../../utils/processUserInput/processUserInput.js'
|
import type { ProcessUserInputContext } from '../../utils/processUserInput/processUserInput.js'
|
||||||
import { editPromptInEditor } from '../../utils/promptEditor.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 { findBtwTriggerPositions } from '../../utils/sideQuestion.js'
|
||||||
import { findSlashCommandPositions } from '../../utils/suggestions/commandSuggestions.js'
|
import { findSlashCommandPositions } from '../../utils/suggestions/commandSuggestions.js'
|
||||||
import {
|
import {
|
||||||
@@ -187,7 +185,7 @@ import {
|
|||||||
findUltraplanTriggerPositions,
|
findUltraplanTriggerPositions,
|
||||||
findUltrareviewTriggerPositions,
|
findUltrareviewTriggerPositions,
|
||||||
} from '../../utils/ultraplan/keyword.js'
|
} 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 { BridgeDialog } from '../BridgeDialog.js'
|
||||||
import { ConfigurableShortcutHint } from '../ConfigurableShortcutHint.js'
|
import { ConfigurableShortcutHint } from '../ConfigurableShortcutHint.js'
|
||||||
import {
|
import {
|
||||||
@@ -571,10 +569,6 @@ function PromptInput({
|
|||||||
const [showHistoryPicker, setShowHistoryPicker] = useState(false)
|
const [showHistoryPicker, setShowHistoryPicker] = useState(false)
|
||||||
const [showFastModePicker, setShowFastModePicker] = useState(false)
|
const [showFastModePicker, setShowFastModePicker] = useState(false)
|
||||||
const [showThinkingToggle, setShowThinkingToggle] = useState(false)
|
const [showThinkingToggle, setShowThinkingToggle] = useState(false)
|
||||||
const [showAutoModeOptIn, setShowAutoModeOptIn] = useState(false)
|
|
||||||
const [previousModeBeforeAuto, setPreviousModeBeforeAuto] =
|
|
||||||
useState<PermissionMode | null>(null)
|
|
||||||
const autoModeOptInTimeoutRef = useRef<NodeJS.Timeout | null>(null)
|
|
||||||
|
|
||||||
// Check if cursor is on the first line of input
|
// Check if cursor is on the first line of input
|
||||||
const isCursorOnFirstLine = useMemo(() => {
|
const isCursorOnFirstLine = useMemo(() => {
|
||||||
@@ -1883,86 +1877,11 @@ function PromptInput({
|
|||||||
|
|
||||||
// Compute the next mode without triggering side effects first
|
// Compute the next mode without triggering side effects first
|
||||||
logForDebugging(
|
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)
|
const nextMode = getNextPermissionMode(toolPermissionContext, teamContext)
|
||||||
|
|
||||||
// Check if user is entering auto mode for the first time. Gated on the
|
// Call cyclePermissionMode to apply side effects (e.g. strip
|
||||||
// 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
|
|
||||||
// dangerous permissions, activate classifier)
|
// dangerous permissions, activate classifier)
|
||||||
const { context: preparedContext } = cyclePermissionMode(
|
const { context: preparedContext } = cyclePermissionMode(
|
||||||
toolPermissionContext,
|
toolPermissionContext,
|
||||||
@@ -2007,91 +1926,10 @@ function PromptInput({
|
|||||||
}, [
|
}, [
|
||||||
toolPermissionContext,
|
toolPermissionContext,
|
||||||
teamContext,
|
teamContext,
|
||||||
viewingAgentTaskId,
|
|
||||||
viewedTeammate,
|
viewedTeammate,
|
||||||
setAppState,
|
setAppState,
|
||||||
setToolPermissionContext,
|
setToolPermissionContext,
|
||||||
helpOpen,
|
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
|
// 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
|
// Portal dialog to DialogOverlay in fullscreen so it escapes the bottom
|
||||||
// slot's overflowY:hidden clip (same pattern as SuggestionsOverlay).
|
// slot's overflowY:hidden clip (same pattern as SuggestionsOverlay).
|
||||||
// Must be called before early returns below to satisfy rules-of-hooks.
|
// Must be called before early returns below to satisfy rules-of-hooks.
|
||||||
// Memoized so the portal useEffect doesn't churn on every PromptInput render.
|
useSetPromptOverlayDialog(null)
|
||||||
const autoModeOptInDialog = useMemo(
|
|
||||||
() =>
|
|
||||||
feature('TRANSCRIPT_CLASSIFIER') && showAutoModeOptIn ? (
|
|
||||||
<AutoModeOptInDialog
|
|
||||||
onAccept={handleAutoModeOptInAccept}
|
|
||||||
onDecline={handleAutoModeOptInDecline}
|
|
||||||
/>
|
|
||||||
) : null,
|
|
||||||
[showAutoModeOptIn, handleAutoModeOptInAccept, handleAutoModeOptInDecline],
|
|
||||||
)
|
|
||||||
useSetPromptOverlayDialog(
|
|
||||||
isFullscreenEnvEnabled() ? autoModeOptInDialog : null,
|
|
||||||
)
|
|
||||||
|
|
||||||
if (showBashesDialog) {
|
if (showBashesDialog) {
|
||||||
return (
|
return (
|
||||||
@@ -3077,7 +2902,6 @@ function PromptInput({
|
|||||||
isFullscreenEnvEnabled() ? handleOpenTasksDialog : undefined
|
isFullscreenEnvEnabled() ? handleOpenTasksDialog : undefined
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
{isFullscreenEnvEnabled() ? null : autoModeOptInDialog}
|
|
||||||
{isFullscreenEnvEnabled() ? (
|
{isFullscreenEnvEnabled() ? (
|
||||||
// position=absolute takes zero layout height so the spinner
|
// position=absolute takes zero layout height so the spinner
|
||||||
// doesn't shift when a notification appears/disappears. Yoga
|
// doesn't shift when a notification appears/disappears. Yoga
|
||||||
@@ -3098,7 +2922,7 @@ function PromptInput({
|
|||||||
<Box
|
<Box
|
||||||
position="absolute"
|
position="absolute"
|
||||||
marginTop={briefOwnsGap ? -2 : -1}
|
marginTop={briefOwnsGap ? -2 : -1}
|
||||||
height={suggestions.length === 0 && !showAutoModeOptIn ? 1 : 0}
|
height={suggestions.length === 0 ? 1 : 0}
|
||||||
width="100%"
|
width="100%"
|
||||||
paddingLeft={2}
|
paddingLeft={2}
|
||||||
paddingRight={1}
|
paddingRight={1}
|
||||||
|
|||||||
@@ -52,7 +52,6 @@ import type { PermissionMode } from './utils/permissions/PermissionMode.js'
|
|||||||
import { getBaseRenderOptions } from './utils/renderOptions.js'
|
import { getBaseRenderOptions } from './utils/renderOptions.js'
|
||||||
import { getSettingsWithAllErrors } from './utils/settings/allErrors.js'
|
import { getSettingsWithAllErrors } from './utils/settings/allErrors.js'
|
||||||
import {
|
import {
|
||||||
hasAutoModeOptIn,
|
|
||||||
hasSkipDangerousModePermissionPrompt,
|
hasSkipDangerousModePermissionPrompt,
|
||||||
} from './utils/settings/settings.js'
|
} from './utils/settings/settings.js'
|
||||||
|
|
||||||
@@ -309,25 +308,6 @@ export async function showSetupScreens(
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
if (feature('TRANSCRIPT_CLASSIFIER')) {
|
|
||||||
// Only show the opt-in dialog if auto mode actually resolved — if the
|
|
||||||
// gate denied it (org not allowlisted, settings disabled), showing
|
|
||||||
// consent for an unavailable feature is pointless. The
|
|
||||||
// verifyAutoModeGateAccess notification will explain why instead.
|
|
||||||
if (permissionMode === 'auto' && !hasAutoModeOptIn()) {
|
|
||||||
const { AutoModeOptInDialog } = await import(
|
|
||||||
'./components/AutoModeOptInDialog.js'
|
|
||||||
)
|
|
||||||
await showSetupDialog(root, done => (
|
|
||||||
<AutoModeOptInDialog
|
|
||||||
onAccept={done}
|
|
||||||
onDecline={() => gracefulShutdownSync(1)}
|
|
||||||
declineExits
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --dangerously-load-development-channels confirmation. On accept, append
|
// --dangerously-load-development-channels confirmation. On accept, append
|
||||||
// dev channels to any --channels list already set in main.tsx. Org policy
|
// dev channels to any --channels list already set in main.tsx. Org policy
|
||||||
// is NOT bypassed — gateChannelServer() still runs; this flag only exists
|
// is NOT bypassed — gateChannelServer() still runs; this flag only exists
|
||||||
|
|||||||
13
src/main.tsx
13
src/main.tsx
@@ -242,7 +242,6 @@ import {
|
|||||||
import { ensureModelStringsInitialized } from "./utils/model/modelStrings.js";
|
import { ensureModelStringsInitialized } from "./utils/model/modelStrings.js";
|
||||||
import { PERMISSION_MODES } from "./utils/permissions/PermissionMode.js";
|
import { PERMISSION_MODES } from "./utils/permissions/PermissionMode.js";
|
||||||
import {
|
import {
|
||||||
checkAndDisableBypassPermissions,
|
|
||||||
getAutoModeEnabledStateIfCached,
|
getAutoModeEnabledStateIfCached,
|
||||||
initializeToolPermissionContext,
|
initializeToolPermissionContext,
|
||||||
initialPermissionModeFromCLI,
|
initialPermissionModeFromCLI,
|
||||||
@@ -3910,19 +3909,7 @@ async function run(): Promise<CommanderCommand> {
|
|||||||
onChangeAppState,
|
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.
|
// 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")) {
|
if (feature("TRANSCRIPT_CLASSIFIER")) {
|
||||||
void verifyAutoModeGateAccess(
|
void verifyAutoModeGateAccess(
|
||||||
toolPermissionContext,
|
toolPermissionContext,
|
||||||
|
|||||||
@@ -422,9 +422,7 @@ import { usePromptsFromClaudeInChrome } from 'src/hooks/usePromptsFromClaudeInCh
|
|||||||
import { getTipToShowOnSpinner, recordShownTip } from 'src/services/tips/tipScheduler.js';
|
import { getTipToShowOnSpinner, recordShownTip } from 'src/services/tips/tipScheduler.js';
|
||||||
import type { Theme } from 'src/utils/theme.js';
|
import type { Theme } from 'src/utils/theme.js';
|
||||||
import {
|
import {
|
||||||
checkAndDisableBypassPermissionsIfNeeded,
|
|
||||||
checkAndDisableAutoModeIfNeeded,
|
checkAndDisableAutoModeIfNeeded,
|
||||||
useKickOffCheckAndDisableBypassPermissionsIfNeeded,
|
|
||||||
useKickOffCheckAndDisableAutoModeIfNeeded,
|
useKickOffCheckAndDisableAutoModeIfNeeded,
|
||||||
} from 'src/utils/permissions/bypassPermissionsKillswitch.js';
|
} from 'src/utils/permissions/bypassPermissionsKillswitch.js';
|
||||||
import { SandboxManager } from 'src/utils/sandbox/sandbox-adapter.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 { SandboxViolationExpandedView } from 'src/components/SandboxViolationExpandedView.js';
|
||||||
import { useSettingsErrors } from 'src/hooks/notifs/useSettingsErrors.js';
|
import { useSettingsErrors } from 'src/hooks/notifs/useSettingsErrors.js';
|
||||||
import { useMcpConnectivityStatus } from 'src/hooks/notifs/useMcpConnectivityStatus.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 { AUTO_MODE_DESCRIPTION } from 'src/components/AutoModeOptInDialog.js';
|
||||||
import { useLspInitializationNotification } from 'src/hooks/notifs/useLspInitializationNotification.js';
|
import { useLspInitializationNotification } from 'src/hooks/notifs/useLspInitializationNotification.js';
|
||||||
import { useLspPluginRecommendation } from 'src/hooks/useLspPluginRecommendation.js';
|
import { useLspPluginRecommendation } from 'src/hooks/useLspPluginRecommendation.js';
|
||||||
@@ -948,7 +945,6 @@ export function REPL({
|
|||||||
[toolPermissionContext, proactiveActive, isBriefOnly],
|
[toolPermissionContext, proactiveActive, isBriefOnly],
|
||||||
);
|
);
|
||||||
|
|
||||||
useKickOffCheckAndDisableBypassPermissionsIfNeeded();
|
|
||||||
useKickOffCheckAndDisableAutoModeIfNeeded();
|
useKickOffCheckAndDisableAutoModeIfNeeded();
|
||||||
|
|
||||||
const [dynamicMcpConfig, setDynamicMcpConfig] = useState<Record<string, ScopedMcpServerConfig> | undefined>(
|
const [dynamicMcpConfig, setDynamicMcpConfig] = useState<Record<string, ScopedMcpServerConfig> | undefined>(
|
||||||
@@ -1006,7 +1002,6 @@ export function REPL({
|
|||||||
useCanSwitchToExistingSubscription();
|
useCanSwitchToExistingSubscription();
|
||||||
useIDEStatusIndicator({ ideSelection, mcpClients, ideInstallationStatus });
|
useIDEStatusIndicator({ ideSelection, mcpClients, ideInstallationStatus });
|
||||||
useMcpConnectivityStatus({ mcpClients });
|
useMcpConnectivityStatus({ mcpClients });
|
||||||
useAutoModeUnavailableNotification();
|
|
||||||
usePluginInstallationStatus();
|
usePluginInstallationStatus();
|
||||||
usePluginAutoupdateNotification();
|
usePluginAutoupdateNotification();
|
||||||
useSettingsErrors();
|
useSettingsErrors();
|
||||||
@@ -3314,8 +3309,8 @@ export function REPL({
|
|||||||
queryCheckpoint('query_context_loading_start');
|
queryCheckpoint('query_context_loading_start');
|
||||||
const [, , defaultSystemPrompt, baseUserContext, systemContext] = await Promise.all([
|
const [, , defaultSystemPrompt, baseUserContext, systemContext] = await Promise.all([
|
||||||
// IMPORTANT: do this after setMessages() above, to avoid UI jank
|
// IMPORTANT: do this after setMessages() above, to avoid UI jank
|
||||||
checkAndDisableBypassPermissionsIfNeeded(toolPermissionContext, setAppState),
|
undefined,
|
||||||
// Gated on TRANSCRIPT_CLASSIFIER so GrowthBook kill switch runs wherever auto mode is built in
|
// Fast-mode circuit breaker check
|
||||||
feature('TRANSCRIPT_CLASSIFIER')
|
feature('TRANSCRIPT_CLASSIFIER')
|
||||||
? checkAndDisableAutoModeIfNeeded(toolPermissionContext, setAppState, store.getState().fastMode)
|
? checkAndDisableAutoModeIfNeeded(toolPermissionContext, setAppState, store.getState().fastMode)
|
||||||
: undefined,
|
: undefined,
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ const mockGetDefaultAppState = mock(() => ({
|
|||||||
alwaysAllowRules: { user: [], project: [], local: [] },
|
alwaysAllowRules: { user: [], project: [], local: [] },
|
||||||
alwaysDenyRules: { user: [], project: [], local: [] },
|
alwaysDenyRules: { user: [], project: [], local: [] },
|
||||||
alwaysAskRules: { user: [], project: [], local: [] },
|
alwaysAskRules: { user: [], project: [], local: [] },
|
||||||
isBypassPermissionsModeAvailable: false,
|
isBypassPermissionsModeAvailable: true,
|
||||||
},
|
},
|
||||||
fastMode: false,
|
fastMode: false,
|
||||||
settings: {},
|
settings: {},
|
||||||
|
|||||||
@@ -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.`,
|
`Use Plan Mode to prepare for a complex request before making changes. Press ${getShortcutDisplay('chat:cycleMode', 'Chat', 'shift+tab')} twice to enable.`,
|
||||||
cooldownSessions: 5,
|
cooldownSessions: 5,
|
||||||
isRelevant: async () => {
|
isRelevant: async () => {
|
||||||
if (process.env.USER_TYPE === 'ant') return false
|
|
||||||
const config = getGlobalConfig()
|
const config = getGlobalConfig()
|
||||||
// Show to users who haven't used plan mode recently (7+ days)
|
// Show to users who haven't used plan mode recently (7+ days)
|
||||||
const daysSinceLastUse = config.lastPlanModeUse
|
const daysSinceLastUse = config.lastPlanModeUse
|
||||||
@@ -401,9 +400,7 @@ const externalTips: Tip[] = [
|
|||||||
{
|
{
|
||||||
id: 'shift-tab',
|
id: 'shift-tab',
|
||||||
content: async () =>
|
content: async () =>
|
||||||
process.env.USER_TYPE === 'ant'
|
`Hit ${getShortcutDisplay('chat:cycleMode', 'Chat', 'shift+tab')} to cycle between default, accept edits, plan, auto, and bypass modes`,
|
||||||
? `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`,
|
|
||||||
cooldownSessions: 10,
|
cooldownSessions: 10,
|
||||||
isRelevant: async () => true,
|
isRelevant: async () => true,
|
||||||
},
|
},
|
||||||
|
|||||||
204
src/utils/permissions/__tests__/getNextPermissionMode.test.ts
Normal file
204
src/utils/permissions/__tests__/getNextPermissionMode.test.ts
Normal file
@@ -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> = {},
|
||||||
|
): 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<string>([
|
||||||
|
'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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
148
src/utils/permissions/__tests__/permissionSetup.test.ts
Normal file
148
src/utils/permissions/__tests__/permissionSetup.test.ts
Normal file
@@ -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> = {},
|
||||||
|
): 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,35 +1,13 @@
|
|||||||
import { feature } from 'bun:bundle'
|
|
||||||
import type { ToolPermissionContext } from '../../Tool.js'
|
import type { ToolPermissionContext } from '../../Tool.js'
|
||||||
import { logForDebugging } from '../debug.js'
|
import { logForDebugging } from '../debug.js'
|
||||||
import type { PermissionMode } from './PermissionMode.js'
|
import type { PermissionMode } from './PermissionMode.js'
|
||||||
import {
|
import { transitionPermissionMode } from './permissionSetup.js'
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Determines the next permission mode when cycling through modes with Shift+Tab.
|
* 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(
|
export function getNextPermissionMode(
|
||||||
toolPermissionContext: ToolPermissionContext,
|
toolPermissionContext: ToolPermissionContext,
|
||||||
@@ -37,43 +15,29 @@ export function getNextPermissionMode(
|
|||||||
): PermissionMode {
|
): PermissionMode {
|
||||||
switch (toolPermissionContext.mode) {
|
switch (toolPermissionContext.mode) {
|
||||||
case 'default':
|
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'
|
return 'acceptEdits'
|
||||||
|
|
||||||
case 'acceptEdits':
|
case 'acceptEdits':
|
||||||
return 'plan'
|
return 'plan'
|
||||||
|
|
||||||
case 'plan':
|
case 'plan':
|
||||||
|
return 'auto'
|
||||||
|
|
||||||
|
case 'auto':
|
||||||
if (toolPermissionContext.isBypassPermissionsModeAvailable) {
|
if (toolPermissionContext.isBypassPermissionsModeAvailable) {
|
||||||
return 'bypassPermissions'
|
return 'bypassPermissions'
|
||||||
}
|
}
|
||||||
if (canCycleToAuto(toolPermissionContext)) {
|
|
||||||
return 'auto'
|
|
||||||
}
|
|
||||||
return 'default'
|
return 'default'
|
||||||
|
|
||||||
case 'bypassPermissions':
|
case 'bypassPermissions':
|
||||||
if (canCycleToAuto(toolPermissionContext)) {
|
|
||||||
return 'auto'
|
|
||||||
}
|
|
||||||
return 'default'
|
return 'default'
|
||||||
|
|
||||||
case 'dontAsk':
|
case 'dontAsk':
|
||||||
// Not exposed in UI cycle yet, but return default if somehow reached
|
// Not exposed in UI cycle yet, but return default if somehow reached
|
||||||
return 'default'
|
return 'default'
|
||||||
|
|
||||||
|
|
||||||
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'
|
return 'default'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -894,20 +894,8 @@ export function hasSkipDangerousModePermissionPrompt(): boolean {
|
|||||||
* a malicious project could otherwise auto-bypass the dialog (RCE risk).
|
* a malicious project could otherwise auto-bypass the dialog (RCE risk).
|
||||||
*/
|
*/
|
||||||
export function hasAutoModeOptIn(): boolean {
|
export function hasAutoModeOptIn(): boolean {
|
||||||
if (feature('TRANSCRIPT_CLASSIFIER')) {
|
// Auto mode is available to all users — no opt-in needed
|
||||||
const user = getSettingsForSource('userSettings')?.skipAutoPermissionPrompt
|
return true
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user