From f69c705166f80f8e231760c4d0a65e2092bfcba5 Mon Sep 17 00:00:00 2001 From: claude-code-best Date: Fri, 19 Jun 2026 10:17:47 +0800 Subject: [PATCH] Fix/bypass root confirm (#1275) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(ink): 主屏幕模式周期性终端重绘, 防止长时间运行 TUI 显示腐蚀 - 添加 lastMainScreenHealTime 字段, 每 5 秒触发一次全量终端重绘 - 使用 wall-clock 时间替代帧计数, 避免 drain frames (250fps) 加速周期 - 添加 isTTY 守卫, 防止非 TTY 环境泄漏 ANSI 转义序列 - 扩展 needsEraseBeforePaint 到主屏幕模式, BSU/ESU 确保原子性无闪烁 - 修复 log-update cursor 漂移和 blit ghosting 导致的文字重叠/残留 Co-Authored-By: deepseek-v4-pro * fix(messages): lookups 缓存感知 progress tick 替换, 修复 Bash 进度时间卡死 REPL.tsx 用原地替换处理 ephemeral progress (Bash/PowerShell/MCP) 以 限制 messages 数组增长, 但 computeMessageStructureKey 只把 parentToolUseID 计入 key, 替换前后 key 完全相同, Messages.tsx 的 lookups 缓存命中, updateMessageLookupsIncremental 长度相同时又直接返回 existing, 导致 progressMessagesByToolUseID 永远停在首条 tick, ShellProgressMessage 的 elapsed time 卡在首次显示值不动. - computeMessageStructureKey: 加入 progress.uuid, tick 替换后 key 必变 - updateMessageLookupsIncremental: 长度相同 + 末尾为 progress 时返回 null 触发 full rebuild, 让新 tick 进入 progressMessagesByToolUseID 补充 4 个测试覆盖 bug 行为与 fast path 保护. Co-Authored-By: glm-5.2 * fix: bypass 模式在 root/sudo 下改为警告 + y 确认而非直接退出 交互式 TTY 下打印风险警告并等待用户输入 y 才进入 bypass 模式; 非 TTY (pipe/ACP/CI) 维持原 exit(1) 行为,因为无法交互确认。 Co-Authored-By: glm-5.2 --------- Co-authored-by: deepseek-v4-pro Co-authored-by: glm-5.2 --- packages/@ant/ink/src/core/ink.tsx | 35 ++++++ src/setup.ts | 37 +++++- src/utils/__tests__/messages.test.ts | 169 +++++++++++++++++++++++++++ src/utils/messages.ts | 20 +++- 4 files changed, 255 insertions(+), 6 deletions(-) diff --git a/packages/@ant/ink/src/core/ink.tsx b/packages/@ant/ink/src/core/ink.tsx index 99a5edb9b..37d3a91c7 100644 --- a/packages/@ant/ink/src/core/ink.tsx +++ b/packages/@ant/ink/src/core/ink.tsx @@ -165,6 +165,12 @@ export default class Ink { private frontFrame: Frame; private backFrame: Frame; private lastPoolResetTime = performance.now(); + /** Timestamp of last periodic full-redraw in main screen mode. Used to + * recover from accumulated cursor drift / blit ghosting. Wall-clock + * based (not frame-count) so drain scroll frames (250fps) don't + * accelerate the cycle. Alt-screen doesn't need this — CSI H resets + * cursor every frame. */ + private lastMainScreenHealTime = performance.now(); private drainTimer: ReturnType | null = null; private lastYogaCounters: { ms: number; @@ -521,7 +527,25 @@ export default class Ink { // an extra React re-render cycle. this.options.onBeforeRender?.(); + // Periodic self-healing: every ~5s in main screen mode, force a full + // terminal redraw to recover from accumulated cursor drift / blit + // ghosting. Alt-screen doesn't need this — CSI H resets cursor to + // (0,0) every frame. Wall-clock based so drain scroll frames (250fps) + // don't accelerate the cycle. Guarded by isTTY so ANSI escape + // sequences are not leaked into pipes / redirected output. const renderStart = performance.now(); + if ( + !this.altScreenActive && + !this.isPaused && + this.options.stdout.isTTY && + renderStart - this.lastMainScreenHealTime > 5000 + ) { + this.lastMainScreenHealTime = renderStart; + this.repaint(); + this.prevFrameContaminated = true; + this.needsEraseBeforePaint = true; + } + const terminalWidth = this.options.stdout.columns || 80; const terminalRows = this.options.stdout.rows || 24; @@ -725,6 +749,10 @@ export default class Ink { const optimized = optimize(diff); const optimizeMs = performance.now() - tOptimize; const hasDiff = optimized.length > 0; + // Periodic self-healing: for main-screen mode, emit ERASE_SCREEN + HOME + // to clear the terminal before the diff. Alt-screen has its own CSI H + // anchor + cursor park below. BSU/ESU wraps erase+paint atomically on + // supported terminals (main-screen always uses sync markers). if (this.altScreenActive && hasDiff) { // Prepend CSI H to anchor the physical cursor to (0,0) so // log-update's relative moves compute from a known spot (self-healing @@ -752,6 +780,13 @@ export default class Ink { optimized.unshift(CURSOR_HOME_PATCH); } optimized.push(this.altScreenParkPatch); + } else if (this.needsEraseBeforePaint && hasDiff) { + // Main-screen periodic self-healing: clear visible terminal before + // painting the diff. Without this, rows past the new frame's height + // would retain stale content from the previous frame. BSU/ESU keeps + // old content visible until the full erase+paint is flushed atomically. + this.needsEraseBeforePaint = false; + optimized.unshift(ERASE_THEN_HOME_PATCH); } // Native cursor positioning: park the terminal cursor at the declared diff --git a/src/setup.ts b/src/setup.ts index 9b299bff0..bf1689794 100644 --- a/src/setup.ts +++ b/src/setup.ts @@ -401,10 +401,39 @@ export async function setup( process.env.IS_SANDBOX !== '1' && !isEnvTruthy(process.env.CLAUDE_CODE_BUBBLEWRAP) ) { - console.error( - `--dangerously-skip-permissions cannot be used with root/sudo privileges for security reasons`, - ) - process.exit(1) + // Root + bypass = every tool call executes without review at uid 0. + // Interactive TTY: warn and require explicit "y" to proceed. + // Non-interactive (pipe, ACP, CI, no TTY): cannot prompt, must abort. + if (process.stdin.isTTY) { + console.error( + chalk.bold.red( + 'WARNING: Running as root/sudo with bypass permissions mode is dangerous.', + ), + ) + console.error( + chalk.yellow( + 'Bypass mode skips ALL permission checks. Combined with root, any command (rm -rf /, chmod, dd) executes without review.', + ), + ) + const readline = await import('readline') + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }) + const answer = await new Promise(resolve => { + rl.question('\nI understand the risks. Continue? [y/N] ', resolve) + }) + rl.close() + if (answer.trim().toLowerCase() !== 'y') { + console.error('Aborted.') + process.exit(1) + } + } else { + console.error( + `--dangerously-skip-permissions cannot be used with root/sudo privileges for security reasons`, + ) + process.exit(1) + } } if ( diff --git a/src/utils/__tests__/messages.test.ts b/src/utils/__tests__/messages.test.ts index d143df681..8a769fd43 100644 --- a/src/utils/__tests__/messages.test.ts +++ b/src/utils/__tests__/messages.test.ts @@ -16,6 +16,7 @@ import { createUserInterruptionMessage, prepareUserContent, createToolResultStopMessage, + createProgressMessage, extractTag, isNotEmptyMessage, deriveUUID, @@ -28,6 +29,9 @@ import { DONT_ASK_REJECT_MESSAGE, SYNTHETIC_MODEL, ensureToolResultPairing, + buildMessageLookups, + updateMessageLookupsIncremental, + computeMessageStructureKey, } from '../messages' import type { Message, @@ -786,3 +790,168 @@ describe('normalizeMessagesForAPI – thinking + tool_use same turn (CC-1215)', } }) }) + +// ─── Progress tick replace (Bash/PowerShell elapsed-time freeze) ────────── + +describe('computeMessageStructureKey + updateMessageLookupsIncremental: progress replace', () => { + // REPL.tsx replaces ephemeral progress ticks (Bash/PowerShell/MCP) in-place + // to bound the messages array. The lookups cache must invalidate when the + // trailing progress tick changes, or ShellProgressMessage's elapsed time + // freezes at the first tick forever. + + type BashProgress = { + type: 'bash_progress' + elapsedTimeSeconds: number + output: string + fullOutput: string + } + + function makeAssistantWithToolUse(toolUseID: string): Message { + return createAssistantMessage({ + content: [ + { + type: 'tool_use', + id: toolUseID, + name: 'Bash', + input: { command: 'sleep 10' }, + } as any, + ], + }) + } + + function makeProgress( + parentToolUseID: string, + uuid: `${string}-${string}-${string}-${string}-${string}`, + elapsedTimeSeconds: number, + ) { + const msg = createProgressMessage({ + toolUseID: `bash-progress-${elapsedTimeSeconds}`, + parentToolUseID, + data: { + type: 'bash_progress', + elapsedTimeSeconds, + output: '', + fullOutput: '', + }, + }) + // Override uuid so the test is deterministic (createProgressMessage + // generates a random uuid). + return { ...msg, uuid } + } + + test('computeMessageStructureKey distinguishes progress ticks by uuid', () => { + const assistant = makeAssistantWithToolUse('bash-1') + const normalized = normalizeMessages([assistant]) + + const progress1 = makeProgress( + 'bash-1', + '00000000-0000-0000-0000-000000000001', + 3, + ) + const progress2 = makeProgress( + 'bash-1', + '00000000-0000-0000-0000-000000000002', + 4, + ) + + const keyBefore = computeMessageStructureKey( + [...normalized, progress1 as any], + [...normalized, progress1 as any] as any, + ) + const keyAfter = computeMessageStructureKey( + [...normalized, progress2 as any], + [...normalized, progress2 as any] as any, + ) + + // Same parentToolUseID, same length, but different uuid (tick replace). + // Without uuid in the key, these would be identical and the lookups cache + // would freeze on the first tick. + expect(keyBefore).not.toEqual(keyAfter) + }) + + test('updateMessageLookupsIncremental returns null when trailing progress was replaced (same length)', () => { + const assistant = makeAssistantWithToolUse('bash-1') + const normalized = normalizeMessages([assistant]) + + const progress1 = makeProgress( + 'bash-1', + '00000000-0000-0000-0000-000000000001', + 3, + ) + const progress2 = makeProgress( + 'bash-1', + '00000000-0000-0000-0000-000000000002', + 4, + ) + + const withProgress1 = [...normalized, progress1 as any] + const withProgress2 = [...normalized, progress2 as any] + + const existing = buildMessageLookups( + withProgress1 as any, + withProgress1 as any, + ) + + // Same length, but the trailing progress is a fresh tick. Returning + // `existing` here would leave progressMessagesByToolUseID stuck on u1. + const result = updateMessageLookupsIncremental( + existing, + withProgress1.length, + withProgress1.length, + withProgress2 as any, + withProgress2 as any, + ) + + expect(result).toBeNull() + }) + + test('updateMessageLookupsIncremental still returns existing when length same and trailing is NOT progress', () => { + // Protect the original streaming-delta fast path: content-only changes + // on a non-progress trailing message should not trigger a full rebuild. + const assistant = makeAssistantWithToolUse('bash-1') + const normalized = normalizeMessages([assistant]) + + const existing = buildMessageLookups(normalized as any, normalized as any) + + const result = updateMessageLookupsIncremental( + existing, + normalized.length, + normalized.length, + normalized as any, + normalized as any, + ) + + expect(result).toBe(existing) + }) + + test('full rebuild after progress replace yields the new tick in progressMessagesByToolUseID', () => { + // End-to-end: buildMessageLookups after a tick replace must reflect the + // fresh progress, not the stale one. This is what Messages.tsx falls back + // to when updateMessageLookupsIncremental returns null. + const assistant = makeAssistantWithToolUse('bash-1') + const normalized = normalizeMessages([assistant]) + + const progress1 = makeProgress( + 'bash-1', + '00000000-0000-0000-0000-000000000001', + 3, + ) + const progress2 = makeProgress( + 'bash-1', + '00000000-0000-0000-0000-000000000002', + 4, + ) + + const withProgress2 = [...normalized, progress2 as any] + const rebuilt = buildMessageLookups( + withProgress2 as any, + withProgress2 as any, + ) + + const arr = rebuilt.progressMessagesByToolUseID.get('bash-1') + expect(arr).toBeDefined() + expect(arr).toHaveLength(1) + expect(arr![0].uuid).toBe('00000000-0000-0000-0000-000000000002') + expect((arr![0].data as BashProgress).elapsedTimeSeconds).toBe(4) + }) +}) diff --git a/src/utils/messages.ts b/src/utils/messages.ts index 8b1bec0a2..cc624abb2 100644 --- a/src/utils/messages.ts +++ b/src/utils/messages.ts @@ -1417,11 +1417,21 @@ export function updateMessageLookupsIncremental( return null } - // No new messages — nothing to do + // No new messages — nothing to do, UNLESS the trailing message is a + // progress tick. REPL.tsx replaces ephemeral progress (Bash/PowerShell/MCP) + // in-place to bound the messages array — same length, but the trailing + // progress is a fresh tick. Returning `existing` here would leave + // progressMessagesByToolUseID stuck on the first tick and elapsed-time + // displays (ShellProgressMessage) would freeze. Force a full rebuild so + // the fresh tick propagates. if ( normalizedMessages.length === previousNormalizedCount && messages.length === previousMessageCount ) { + const lastNormalized = normalizedMessages[normalizedMessages.length - 1] + if (lastNormalized && lastNormalized.type === 'progress') { + return null + } return existing } @@ -1605,7 +1615,13 @@ export function computeMessageStructureKey( } for (const msg of normalizedMessages) { if (msg.type === 'progress') { - parts.push('p', (msg as ProgressMessage).parentToolUseID as string) + const pMsg = msg as ProgressMessage + // Include uuid so ephemeral progress tick replacements + // (Bash/PowerShell/MCP) invalidate the lookups cache. Without this, + // REPL.tsx's in-place tick replacement (same parentToolUseID, same + // length) yields an identical key, lookups cache the first tick + // forever, and ShellProgressMessage's elapsed time freezes. + parts.push('p', pMsg.parentToolUseID as string, pMsg.uuid) } } return parts.join(',')