Skip to content

Commit f69c705

Browse files
claude-code-bestdeepseek-v4-proglm-5.2
authored
Fix/bypass root confirm (#1275)
* 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 <deepseek-ai@claude-code-best.win> * 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 <zai-org@claude-code-best.win> * fix: bypass 模式在 root/sudo 下改为警告 + y 确认而非直接退出 交互式 TTY 下打印风险警告并等待用户输入 y 才进入 bypass 模式; 非 TTY (pipe/ACP/CI) 维持原 exit(1) 行为,因为无法交互确认。 Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win> --------- Co-authored-by: deepseek-v4-pro <deepseek-ai@claude-code-best.win> Co-authored-by: glm-5.2 <zai-org@claude-code-best.win>
1 parent bca2758 commit f69c705

4 files changed

Lines changed: 255 additions & 6 deletions

File tree

packages/@ant/ink/src/core/ink.tsx

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,12 @@ export default class Ink {
165165
private frontFrame: Frame;
166166
private backFrame: Frame;
167167
private lastPoolResetTime = performance.now();
168+
/** Timestamp of last periodic full-redraw in main screen mode. Used to
169+
* recover from accumulated cursor drift / blit ghosting. Wall-clock
170+
* based (not frame-count) so drain scroll frames (250fps) don't
171+
* accelerate the cycle. Alt-screen doesn't need this — CSI H resets
172+
* cursor every frame. */
173+
private lastMainScreenHealTime = performance.now();
168174
private drainTimer: ReturnType<typeof setTimeout> | null = null;
169175
private lastYogaCounters: {
170176
ms: number;
@@ -521,7 +527,25 @@ export default class Ink {
521527
// an extra React re-render cycle.
522528
this.options.onBeforeRender?.();
523529

530+
// Periodic self-healing: every ~5s in main screen mode, force a full
531+
// terminal redraw to recover from accumulated cursor drift / blit
532+
// ghosting. Alt-screen doesn't need this — CSI H resets cursor to
533+
// (0,0) every frame. Wall-clock based so drain scroll frames (250fps)
534+
// don't accelerate the cycle. Guarded by isTTY so ANSI escape
535+
// sequences are not leaked into pipes / redirected output.
524536
const renderStart = performance.now();
537+
if (
538+
!this.altScreenActive &&
539+
!this.isPaused &&
540+
this.options.stdout.isTTY &&
541+
renderStart - this.lastMainScreenHealTime > 5000
542+
) {
543+
this.lastMainScreenHealTime = renderStart;
544+
this.repaint();
545+
this.prevFrameContaminated = true;
546+
this.needsEraseBeforePaint = true;
547+
}
548+
525549
const terminalWidth = this.options.stdout.columns || 80;
526550
const terminalRows = this.options.stdout.rows || 24;
527551

@@ -725,6 +749,10 @@ export default class Ink {
725749
const optimized = optimize(diff);
726750
const optimizeMs = performance.now() - tOptimize;
727751
const hasDiff = optimized.length > 0;
752+
// Periodic self-healing: for main-screen mode, emit ERASE_SCREEN + HOME
753+
// to clear the terminal before the diff. Alt-screen has its own CSI H
754+
// anchor + cursor park below. BSU/ESU wraps erase+paint atomically on
755+
// supported terminals (main-screen always uses sync markers).
728756
if (this.altScreenActive && hasDiff) {
729757
// Prepend CSI H to anchor the physical cursor to (0,0) so
730758
// log-update's relative moves compute from a known spot (self-healing
@@ -752,6 +780,13 @@ export default class Ink {
752780
optimized.unshift(CURSOR_HOME_PATCH);
753781
}
754782
optimized.push(this.altScreenParkPatch);
783+
} else if (this.needsEraseBeforePaint && hasDiff) {
784+
// Main-screen periodic self-healing: clear visible terminal before
785+
// painting the diff. Without this, rows past the new frame's height
786+
// would retain stale content from the previous frame. BSU/ESU keeps
787+
// old content visible until the full erase+paint is flushed atomically.
788+
this.needsEraseBeforePaint = false;
789+
optimized.unshift(ERASE_THEN_HOME_PATCH);
755790
}
756791

757792
// Native cursor positioning: park the terminal cursor at the declared

src/setup.ts

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -401,10 +401,39 @@ export async function setup(
401401
process.env.IS_SANDBOX !== '1' &&
402402
!isEnvTruthy(process.env.CLAUDE_CODE_BUBBLEWRAP)
403403
) {
404-
console.error(
405-
`--dangerously-skip-permissions cannot be used with root/sudo privileges for security reasons`,
406-
)
407-
process.exit(1)
404+
// Root + bypass = every tool call executes without review at uid 0.
405+
// Interactive TTY: warn and require explicit "y" to proceed.
406+
// Non-interactive (pipe, ACP, CI, no TTY): cannot prompt, must abort.
407+
if (process.stdin.isTTY) {
408+
console.error(
409+
chalk.bold.red(
410+
'WARNING: Running as root/sudo with bypass permissions mode is dangerous.',
411+
),
412+
)
413+
console.error(
414+
chalk.yellow(
415+
'Bypass mode skips ALL permission checks. Combined with root, any command (rm -rf /, chmod, dd) executes without review.',
416+
),
417+
)
418+
const readline = await import('readline')
419+
const rl = readline.createInterface({
420+
input: process.stdin,
421+
output: process.stdout,
422+
})
423+
const answer = await new Promise<string>(resolve => {
424+
rl.question('\nI understand the risks. Continue? [y/N] ', resolve)
425+
})
426+
rl.close()
427+
if (answer.trim().toLowerCase() !== 'y') {
428+
console.error('Aborted.')
429+
process.exit(1)
430+
}
431+
} else {
432+
console.error(
433+
`--dangerously-skip-permissions cannot be used with root/sudo privileges for security reasons`,
434+
)
435+
process.exit(1)
436+
}
408437
}
409438

410439
if (

src/utils/__tests__/messages.test.ts

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
createUserInterruptionMessage,
1717
prepareUserContent,
1818
createToolResultStopMessage,
19+
createProgressMessage,
1920
extractTag,
2021
isNotEmptyMessage,
2122
deriveUUID,
@@ -28,6 +29,9 @@ import {
2829
DONT_ASK_REJECT_MESSAGE,
2930
SYNTHETIC_MODEL,
3031
ensureToolResultPairing,
32+
buildMessageLookups,
33+
updateMessageLookupsIncremental,
34+
computeMessageStructureKey,
3135
} from '../messages'
3236
import type {
3337
Message,
@@ -786,3 +790,168 @@ describe('normalizeMessagesForAPI – thinking + tool_use same turn (CC-1215)',
786790
}
787791
})
788792
})
793+
794+
// ─── Progress tick replace (Bash/PowerShell elapsed-time freeze) ──────────
795+
796+
describe('computeMessageStructureKey + updateMessageLookupsIncremental: progress replace', () => {
797+
// REPL.tsx replaces ephemeral progress ticks (Bash/PowerShell/MCP) in-place
798+
// to bound the messages array. The lookups cache must invalidate when the
799+
// trailing progress tick changes, or ShellProgressMessage's elapsed time
800+
// freezes at the first tick forever.
801+
802+
type BashProgress = {
803+
type: 'bash_progress'
804+
elapsedTimeSeconds: number
805+
output: string
806+
fullOutput: string
807+
}
808+
809+
function makeAssistantWithToolUse(toolUseID: string): Message {
810+
return createAssistantMessage({
811+
content: [
812+
{
813+
type: 'tool_use',
814+
id: toolUseID,
815+
name: 'Bash',
816+
input: { command: 'sleep 10' },
817+
} as any,
818+
],
819+
})
820+
}
821+
822+
function makeProgress(
823+
parentToolUseID: string,
824+
uuid: `${string}-${string}-${string}-${string}-${string}`,
825+
elapsedTimeSeconds: number,
826+
) {
827+
const msg = createProgressMessage<BashProgress>({
828+
toolUseID: `bash-progress-${elapsedTimeSeconds}`,
829+
parentToolUseID,
830+
data: {
831+
type: 'bash_progress',
832+
elapsedTimeSeconds,
833+
output: '',
834+
fullOutput: '',
835+
},
836+
})
837+
// Override uuid so the test is deterministic (createProgressMessage
838+
// generates a random uuid).
839+
return { ...msg, uuid }
840+
}
841+
842+
test('computeMessageStructureKey distinguishes progress ticks by uuid', () => {
843+
const assistant = makeAssistantWithToolUse('bash-1')
844+
const normalized = normalizeMessages([assistant])
845+
846+
const progress1 = makeProgress(
847+
'bash-1',
848+
'00000000-0000-0000-0000-000000000001',
849+
3,
850+
)
851+
const progress2 = makeProgress(
852+
'bash-1',
853+
'00000000-0000-0000-0000-000000000002',
854+
4,
855+
)
856+
857+
const keyBefore = computeMessageStructureKey(
858+
[...normalized, progress1 as any],
859+
[...normalized, progress1 as any] as any,
860+
)
861+
const keyAfter = computeMessageStructureKey(
862+
[...normalized, progress2 as any],
863+
[...normalized, progress2 as any] as any,
864+
)
865+
866+
// Same parentToolUseID, same length, but different uuid (tick replace).
867+
// Without uuid in the key, these would be identical and the lookups cache
868+
// would freeze on the first tick.
869+
expect(keyBefore).not.toEqual(keyAfter)
870+
})
871+
872+
test('updateMessageLookupsIncremental returns null when trailing progress was replaced (same length)', () => {
873+
const assistant = makeAssistantWithToolUse('bash-1')
874+
const normalized = normalizeMessages([assistant])
875+
876+
const progress1 = makeProgress(
877+
'bash-1',
878+
'00000000-0000-0000-0000-000000000001',
879+
3,
880+
)
881+
const progress2 = makeProgress(
882+
'bash-1',
883+
'00000000-0000-0000-0000-000000000002',
884+
4,
885+
)
886+
887+
const withProgress1 = [...normalized, progress1 as any]
888+
const withProgress2 = [...normalized, progress2 as any]
889+
890+
const existing = buildMessageLookups(
891+
withProgress1 as any,
892+
withProgress1 as any,
893+
)
894+
895+
// Same length, but the trailing progress is a fresh tick. Returning
896+
// `existing` here would leave progressMessagesByToolUseID stuck on u1.
897+
const result = updateMessageLookupsIncremental(
898+
existing,
899+
withProgress1.length,
900+
withProgress1.length,
901+
withProgress2 as any,
902+
withProgress2 as any,
903+
)
904+
905+
expect(result).toBeNull()
906+
})
907+
908+
test('updateMessageLookupsIncremental still returns existing when length same and trailing is NOT progress', () => {
909+
// Protect the original streaming-delta fast path: content-only changes
910+
// on a non-progress trailing message should not trigger a full rebuild.
911+
const assistant = makeAssistantWithToolUse('bash-1')
912+
const normalized = normalizeMessages([assistant])
913+
914+
const existing = buildMessageLookups(normalized as any, normalized as any)
915+
916+
const result = updateMessageLookupsIncremental(
917+
existing,
918+
normalized.length,
919+
normalized.length,
920+
normalized as any,
921+
normalized as any,
922+
)
923+
924+
expect(result).toBe(existing)
925+
})
926+
927+
test('full rebuild after progress replace yields the new tick in progressMessagesByToolUseID', () => {
928+
// End-to-end: buildMessageLookups after a tick replace must reflect the
929+
// fresh progress, not the stale one. This is what Messages.tsx falls back
930+
// to when updateMessageLookupsIncremental returns null.
931+
const assistant = makeAssistantWithToolUse('bash-1')
932+
const normalized = normalizeMessages([assistant])
933+
934+
const progress1 = makeProgress(
935+
'bash-1',
936+
'00000000-0000-0000-0000-000000000001',
937+
3,
938+
)
939+
const progress2 = makeProgress(
940+
'bash-1',
941+
'00000000-0000-0000-0000-000000000002',
942+
4,
943+
)
944+
945+
const withProgress2 = [...normalized, progress2 as any]
946+
const rebuilt = buildMessageLookups(
947+
withProgress2 as any,
948+
withProgress2 as any,
949+
)
950+
951+
const arr = rebuilt.progressMessagesByToolUseID.get('bash-1')
952+
expect(arr).toBeDefined()
953+
expect(arr).toHaveLength(1)
954+
expect(arr![0].uuid).toBe('00000000-0000-0000-0000-000000000002')
955+
expect((arr![0].data as BashProgress).elapsedTimeSeconds).toBe(4)
956+
})
957+
})

src/utils/messages.ts

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1417,11 +1417,21 @@ export function updateMessageLookupsIncremental(
14171417
return null
14181418
}
14191419

1420-
// No new messages — nothing to do
1420+
// No new messages — nothing to do, UNLESS the trailing message is a
1421+
// progress tick. REPL.tsx replaces ephemeral progress (Bash/PowerShell/MCP)
1422+
// in-place to bound the messages array — same length, but the trailing
1423+
// progress is a fresh tick. Returning `existing` here would leave
1424+
// progressMessagesByToolUseID stuck on the first tick and elapsed-time
1425+
// displays (ShellProgressMessage) would freeze. Force a full rebuild so
1426+
// the fresh tick propagates.
14211427
if (
14221428
normalizedMessages.length === previousNormalizedCount &&
14231429
messages.length === previousMessageCount
14241430
) {
1431+
const lastNormalized = normalizedMessages[normalizedMessages.length - 1]
1432+
if (lastNormalized && lastNormalized.type === 'progress') {
1433+
return null
1434+
}
14251435
return existing
14261436
}
14271437

@@ -1605,7 +1615,13 @@ export function computeMessageStructureKey(
16051615
}
16061616
for (const msg of normalizedMessages) {
16071617
if (msg.type === 'progress') {
1608-
parts.push('p', (msg as ProgressMessage).parentToolUseID as string)
1618+
const pMsg = msg as ProgressMessage
1619+
// Include uuid so ephemeral progress tick replacements
1620+
// (Bash/PowerShell/MCP) invalidate the lookups cache. Without this,
1621+
// REPL.tsx's in-place tick replacement (same parentToolUseID, same
1622+
// length) yields an identical key, lookups cache the first tick
1623+
// forever, and ShellProgressMessage's elapsed time freezes.
1624+
parts.push('p', pMsg.parentToolUseID as string, pMsg.uuid)
16091625
}
16101626
}
16111627
return parts.join(',')

0 commit comments

Comments
 (0)