From 2d4b94ab8e091ba30d415d71e563ba2f97c2eed0 Mon Sep 17 00:00:00 2001 From: claude-code-best Date: Fri, 22 May 2026 22:25:51 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E9=80=80=E5=87=BA=E5=90=AF=E5=8A=A8?= =?UTF-8?q?=E5=AF=B9=E8=AF=9D=E6=A1=86=E6=97=B6=E7=BB=88=E7=AB=AF=E6=AE=8B?= =?UTF-8?q?=E7=95=99=E4=B8=80=E8=A1=8C=E5=86=85=E5=AE=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit gracefulShutdownSync 启动异步 shutdown 后同步返回,React 立即 重新渲染组件,与 cleanupTerminalModes() 中的 Ink unmount 产生 竞态条件,导致退出后终端残留对话框内容。 修复方案:引入 pendingExitCode state,退出路径先清空画面 (渲染 null),在 useEffect 中延迟到下一个 tick 再调用 gracefulShutdownSync,确保 Ink 在终端清理前已完成空帧刷新。 影响三个启动对话框:TrustDialog、BypassPermissionsModeDialog、 DevChannelsDialog。 Co-Authored-By: glm-5.1 --- .../BypassPermissionsModeDialog.tsx | 77 ++++---- src/components/DevChannelsDialog.tsx | 68 ++++--- src/components/TrustDialog/TrustDialog.tsx | 184 +++++++++--------- 3 files changed, 169 insertions(+), 160 deletions(-) diff --git a/src/components/BypassPermissionsModeDialog.tsx b/src/components/BypassPermissionsModeDialog.tsx index 8e8e40d88..87bc15dd4 100644 --- a/src/components/BypassPermissionsModeDialog.tsx +++ b/src/components/BypassPermissionsModeDialog.tsx @@ -1,61 +1,70 @@ -import React, { useCallback } from 'react' -import { logEvent } from 'src/services/analytics/index.js' -import { Box, Link, Newline, Text } from '@anthropic/ink' -import { gracefulShutdownSync } from '../utils/gracefulShutdown.js' -import { updateSettingsForSource } from '../utils/settings/settings.js' -import { Select } from './CustomSelect/index.js' -import { Dialog } from '@anthropic/ink' +import React, { useCallback } from 'react'; +import { logEvent } from 'src/services/analytics/index.js'; +import { Box, Link, Newline, Text } from '@anthropic/ink'; +import { gracefulShutdownSync } from '../utils/gracefulShutdown.js'; +import { updateSettingsForSource } from '../utils/settings/settings.js'; +import { Select } from './CustomSelect/index.js'; +import { Dialog } from '@anthropic/ink'; type Props = { - onAccept(): void -} + onAccept(): void; +}; -export function BypassPermissionsModeDialog({ - onAccept, -}: Props): React.ReactNode { +export function BypassPermissionsModeDialog({ onAccept }: Props): React.ReactNode { + const [pendingExitCode, setPendingExitCode] = React.useState(null); + + // Clear screen before shutdown so residual dialog content doesn't leak + // to the terminal. Deferred to next tick so Ink flushes the null render. React.useEffect(() => { - logEvent('tengu_bypass_permissions_mode_dialog_shown', {}) - }, []) + if (pendingExitCode !== null) { + const code = pendingExitCode; + const timer = setTimeout(() => gracefulShutdownSync(code)); + return () => clearTimeout(timer); + } + }, [pendingExitCode]); + + React.useEffect(() => { + logEvent('tengu_bypass_permissions_mode_dialog_shown', {}); + }, []); function onChange(value: 'accept' | 'decline') { switch (value) { case 'accept': { - logEvent('tengu_bypass_permissions_mode_dialog_accept', {}) + logEvent('tengu_bypass_permissions_mode_dialog_accept', {}); updateSettingsForSource('userSettings', { skipDangerousModePermissionPrompt: true, - }) - onAccept() - break + }); + onAccept(); + break; } case 'decline': { - gracefulShutdownSync(1) - break + setPendingExitCode(1); + break; } } } const handleEscape = useCallback(() => { - gracefulShutdownSync(0) - }, []) + setPendingExitCode(0); + }, []); + + if (pendingExitCode !== null) { + return null; + } return ( - + - In Bypass Permissions mode, Claude Code will not ask for your approval - before running potentially dangerous commands. + In Bypass Permissions mode, Claude Code will not ask for your approval before running potentially dangerous + commands. - This mode should only be used in a sandboxed container/VM that has - restricted internet access and can easily be restored if damaged. + This mode should only be used in a sandboxed container/VM that has restricted internet access and can easily + be restored if damaged. - By proceeding, you accept all responsibility for actions taken while - running in Bypass Permissions mode. + By proceeding, you accept all responsibility for actions taken while running in Bypass Permissions mode. @@ -69,5 +78,5 @@ export function BypassPermissionsModeDialog({ onChange={value => onChange(value as 'accept' | 'decline')} /> - ) + ); } diff --git a/src/components/DevChannelsDialog.tsx b/src/components/DevChannelsDialog.tsx index f055e4ae6..b0aada801 100644 --- a/src/components/DevChannelsDialog.tsx +++ b/src/components/DevChannelsDialog.tsx @@ -1,54 +1,58 @@ -import React, { useCallback } from 'react' -import type { ChannelEntry } from '../bootstrap/state.js' -import { Box, Text, Dialog } from '@anthropic/ink' -import { gracefulShutdownSync } from '../utils/gracefulShutdown.js' -import { Select } from './CustomSelect/index.js' +import React, { useCallback } from 'react'; +import type { ChannelEntry } from '../bootstrap/state.js'; +import { Box, Text, Dialog } from '@anthropic/ink'; +import { gracefulShutdownSync } from '../utils/gracefulShutdown.js'; +import { Select } from './CustomSelect/index.js'; type Props = { - channels: ChannelEntry[] - onAccept(): void -} + channels: ChannelEntry[]; + onAccept(): void; +}; + +export function DevChannelsDialog({ channels, onAccept }: Props): React.ReactNode { + const [pendingExitCode, setPendingExitCode] = React.useState(null); + + // Clear screen before shutdown so residual dialog content doesn't leak + // to the terminal. Deferred to next tick so Ink flushes the null render. + React.useEffect(() => { + if (pendingExitCode !== null) { + const code = pendingExitCode; + const timer = setTimeout(() => gracefulShutdownSync(code)); + return () => clearTimeout(timer); + } + }, [pendingExitCode]); -export function DevChannelsDialog({ - channels, - onAccept, -}: Props): React.ReactNode { function onChange(value: 'accept' | 'exit') { switch (value) { case 'accept': - onAccept() - break + onAccept(); + break; case 'exit': - gracefulShutdownSync(1) - break + setPendingExitCode(1); + break; } } const handleEscape = useCallback(() => { - gracefulShutdownSync(0) - }, []) + setPendingExitCode(0); + }, []); + + if (pendingExitCode !== null) { + return null; + } return ( - + - --dangerously-load-development-channels is for local channel - development only. Do not use this option to run channels you have - downloaded off the internet. + --dangerously-load-development-channels is for local channel development only. Do not use this option to run + channels you have downloaded off the internet. Please use --channels to run a list of approved channels. Channels:{' '} {channels - .map(c => - c.kind === 'plugin' - ? `plugin:${c.name}@${c.marketplace}` - : `server:${c.name}`, - ) + .map(c => (c.kind === 'plugin' ? `plugin:${c.name}@${c.marketplace}` : `server:${c.name}`)) .join(', ')} @@ -61,5 +65,5 @@ export function DevChannelsDialog({ onChange={value => onChange(value as 'accept' | 'exit')} /> - ) + ); } diff --git a/src/components/TrustDialog/TrustDialog.tsx b/src/components/TrustDialog/TrustDialog.tsx index 88a0c897f..91283c156 100644 --- a/src/components/TrustDialog/TrustDialog.tsx +++ b/src/components/TrustDialog/TrustDialog.tsx @@ -1,22 +1,19 @@ -import { homedir } from 'os' -import React from 'react' -import { logEvent } from 'src/services/analytics/index.js' -import { setSessionTrustAccepted } from '../../bootstrap/state.js' -import type { Command } from '../../commands.js' -import { useExitOnCtrlCDWithKeybindings } from '../../hooks/useExitOnCtrlCDWithKeybindings.js' -import { Box, Link, Text } from '@anthropic/ink' -import { useKeybinding } from '../../keybindings/useKeybinding.js' -import { getMcpConfigsByScope } from '../../services/mcp/config.js' -import { BASH_TOOL_NAME } from '../../tools/BashTool/toolName.js' -import { - checkHasTrustDialogAccepted, - saveCurrentProjectConfig, -} from '../../utils/config.js' -import { getCwd } from '../../utils/cwd.js' -import { getFsImplementation } from '../../utils/fsOperations.js' -import { gracefulShutdownSync } from '../../utils/gracefulShutdown.js' -import { Select } from '../CustomSelect/index.js' -import { PermissionDialog } from '../permissions/PermissionDialog.js' +import { homedir } from 'os'; +import React from 'react'; +import { logEvent } from 'src/services/analytics/index.js'; +import { setSessionTrustAccepted } from '../../bootstrap/state.js'; +import type { Command } from '../../commands.js'; +import { useExitOnCtrlCDWithKeybindings } from '../../hooks/useExitOnCtrlCDWithKeybindings.js'; +import { Box, Link, Text } from '@anthropic/ink'; +import { useKeybinding } from '../../keybindings/useKeybinding.js'; +import { getMcpConfigsByScope } from '../../services/mcp/config.js'; +import { BASH_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/BashTool/toolName.js'; +import { checkHasTrustDialogAccepted, saveCurrentProjectConfig } from '../../utils/config.js'; +import { getCwd } from '../../utils/cwd.js'; +import { getFsImplementation } from '../../utils/fsOperations.js'; +import { gracefulShutdownSync } from '../../utils/gracefulShutdown.js'; +import { Select } from '../CustomSelect/index.js'; +import { PermissionDialog } from '../permissions/PermissionDialog.js'; import { getApiKeyHelperSources, getAwsCommandsSources, @@ -25,77 +22,82 @@ import { getGcpCommandsSources, getHooksSources, getOtelHeadersHelperSources, -} from './utils.js' +} from './utils.js'; type Props = { - onDone(): void - commands?: Command[] -} + onDone(): void; + commands?: Command[]; +}; export function TrustDialog({ onDone, commands }: Props): React.ReactNode { - const { servers: projectServers } = getMcpConfigsByScope('project') + const { servers: projectServers } = getMcpConfigsByScope('project'); // In all cases, we generally check only the project-level and // project-local-level settings, which we assume that users do not configure // directly compared to user-level settings. // Check for MCPs - const hasMcpServers = Object.keys(projectServers).length > 0 + const hasMcpServers = Object.keys(projectServers).length > 0; // Check for hooks - const hooksSettingSources = getHooksSources() - const hasHooks = hooksSettingSources.length > 0 + const hooksSettingSources = getHooksSources(); + const hasHooks = hooksSettingSources.length > 0; // Check whether code execution is allowed in permissions and slash commands - const bashSettingSources = getBashPermissionSources() + const bashSettingSources = getBashPermissionSources(); // Check for apiKeyHelper which executes arbitrary commands - const apiKeyHelperSources = getApiKeyHelperSources() - const hasApiKeyHelper = apiKeyHelperSources.length > 0 + const apiKeyHelperSources = getApiKeyHelperSources(); + const hasApiKeyHelper = apiKeyHelperSources.length > 0; // Check for AWS commands which execute arbitrary commands - const awsCommandsSources = getAwsCommandsSources() - const hasAwsCommands = awsCommandsSources.length > 0 + const awsCommandsSources = getAwsCommandsSources(); + const hasAwsCommands = awsCommandsSources.length > 0; // Check for GCP commands which execute arbitrary commands - const gcpCommandsSources = getGcpCommandsSources() - const hasGcpCommands = gcpCommandsSources.length > 0 + const gcpCommandsSources = getGcpCommandsSources(); + const hasGcpCommands = gcpCommandsSources.length > 0; // Check for otelHeadersHelper which executes arbitrary commands - const otelHeadersHelperSources = getOtelHeadersHelperSources() - const hasOtelHeadersHelper = otelHeadersHelperSources.length > 0 + const otelHeadersHelperSources = getOtelHeadersHelperSources(); + const hasOtelHeadersHelper = otelHeadersHelperSources.length > 0; // Check for dangerous environment variables (not in SAFE_ENV_VARS) - const dangerousEnvVarsSources = getDangerousEnvVarsSources() - const hasDangerousEnvVars = dangerousEnvVarsSources.length > 0 + const dangerousEnvVarsSources = getDangerousEnvVarsSources(); + const hasDangerousEnvVars = dangerousEnvVarsSources.length > 0; const hasSlashCommandBash = commands?.some( command => command.type === 'prompt' && command.loadedFrom === 'commands_DEPRECATED' && - (command.source === 'projectSettings' || - command.source === 'localSettings') && - command.allowedTools?.some( - (tool: string) => - tool === BASH_TOOL_NAME || tool.startsWith(BASH_TOOL_NAME + '('), - ), - ) ?? false + (command.source === 'projectSettings' || command.source === 'localSettings') && + command.allowedTools?.some((tool: string) => tool === BASH_TOOL_NAME || tool.startsWith(BASH_TOOL_NAME + '(')), + ) ?? false; const hasSkillsBash = commands?.some( command => command.type === 'prompt' && (command.loadedFrom === 'skills' || command.loadedFrom === 'plugin') && - (command.source === 'projectSettings' || - command.source === 'localSettings' || - command.source === 'plugin') && - command.allowedTools?.some( - (tool: string) => - tool === BASH_TOOL_NAME || tool.startsWith(BASH_TOOL_NAME + '('), - ), - ) ?? false + (command.source === 'projectSettings' || command.source === 'localSettings' || command.source === 'plugin') && + command.allowedTools?.some((tool: string) => tool === BASH_TOOL_NAME || tool.startsWith(BASH_TOOL_NAME + '(')), + ) ?? false; - const hasAnyBashExecution = - bashSettingSources.length > 0 || hasSlashCommandBash || hasSkillsBash + const hasAnyBashExecution = bashSettingSources.length > 0 || hasSlashCommandBash || hasSkillsBash; - const hasTrustDialogAccepted = checkHasTrustDialogAccepted() + const hasTrustDialogAccepted = checkHasTrustDialogAccepted(); + const [pendingExitCode, setPendingExitCode] = React.useState(null); + + // When a non-null exit code is set, render null (clear screen) first, + // then trigger shutdown in the next tick so Ink has time to flush + // the empty frame before cleanupTerminalModes() unmounts and exits + // the alt screen. Without this deferral, gracefulShutdownSync starts + // async cleanup immediately after React commit, racing the reconciler + // and leaving residual TrustDialog output on the terminal. + React.useEffect(() => { + if (pendingExitCode !== null) { + const code = pendingExitCode; + const timer = setTimeout(() => gracefulShutdownSync(code)); + return () => clearTimeout(timer); + } + }, [pendingExitCode]); React.useEffect(() => { - const isHomeDir = homedir() === getCwd() + const isHomeDir = homedir() === getCwd(); logEvent('tengu_trust_dialog_shown', { isHomeDir, hasMcpServers, @@ -106,7 +108,7 @@ export function TrustDialog({ onDone, commands }: Props): React.ReactNode { hasGcpCommands, hasOtelHeadersHelper, hasDangerousEnvVars, - }) + }); }, [ hasMcpServers, hasHooks, @@ -116,15 +118,20 @@ export function TrustDialog({ onDone, commands }: Props): React.ReactNode { hasGcpCommands, hasOtelHeadersHelper, hasDangerousEnvVars, - ]) + ]); function onChange(value: 'enable_all' | 'exit') { if (value === 'exit') { - gracefulShutdownSync(1) - return + // Set pendingExitCode to clear the screen before triggering shutdown. + // The useEffect above defers gracefulShutdownSync to the next tick + // so Ink can flush the empty frame first — otherwise + // cleanupTerminalModes races React's re-render and leaves + // residual TrustDialog content on the terminal. + setPendingExitCode(1); + return; } - const isHomeDir = homedir() === getCwd() + const isHomeDir = homedir() === getCwd(); logEvent('tengu_trust_dialog_accept', { isHomeDir, @@ -136,18 +143,18 @@ export function TrustDialog({ onDone, commands }: Props): React.ReactNode { hasGcpCommands, hasOtelHeadersHelper, hasDangerousEnvVars, - }) + }); if (isHomeDir) { // For home directory, store trust in session memory only (not persisted to disk) // This allows hooks and other trust-requiring features to work during this session // while preserving the security intent of not permanently trusting home dir - setSessionTrustAccepted(true) + setSessionTrustAccepted(true); } else { saveCurrentProjectConfig(current => ({ ...current, hasTrustDialogAccepted: true, - })) + })); } // Do NOT write MCP server settings here. handleMcpjsonServerApprovals in @@ -155,7 +162,7 @@ export function TrustDialog({ onDone, commands }: Props): React.ReactNode { // UI. Writing enabledMcpjsonServers/enableAllProjectMcpServers here would // mark every server 'approved' and silently skip that dialog. See #15558. - onDone() + onDone(); } // Default onExit is useApp().exit() → Ink.unmount(), which tears down the @@ -164,48 +171,41 @@ export function TrustDialog({ onDone, commands }: Props): React.ReactNode { // so the default would hang the await forever. With keybinding // customization enabled, the chokidar watcher (persistent: true) keeps the // event loop alive and the process freezes. Explicitly exit 1 like "No". - const exitState = useExitOnCtrlCDWithKeybindings(() => - gracefulShutdownSync(1), - ) + const exitState = useExitOnCtrlCDWithKeybindings(() => setPendingExitCode(1)); // Use configurable keybinding for ESC to cancel/exit useKeybinding( 'confirm:no', () => { - gracefulShutdownSync(0) + setPendingExitCode(0); }, { context: 'Confirmation' }, - ) + ); + + // When pendingExitCode is set, render nothing so the screen is cleared + // before shutdown cleans up the alt screen. See the useEffect above. + if (pendingExitCode !== null) { + return null; + } // Automatically resolve the trust dialog if there is nothing to be shown. if (hasTrustDialogAccepted) { - setTimeout(onDone) - return null + setTimeout(onDone); + return null; } return ( - + {getFsImplementation().cwd()} - Quick safety check: Is this a project you created or one you trust? - (Like your own code, a well-known open source project, or work from - your team). If not, take a moment to review what{"'"}s in this folder - first. - - - Claude Code{"'"}ll be able to read, edit, and execute files here. + Is this a project you trust? (Your own code, a well-known open source project, or work from your team). + Once trusted, Claude Code can read, edit, and run commands in this folder. - - Security guide - + Security guide