Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions packages/@ant/ink/src/core/ink.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof setTimeout> | null = null;
private lastYogaCounters: {
ms: number;
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
37 changes: 33 additions & 4 deletions src/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>(resolve => {
rl.question('\nI understand the risks. Continue? [y/N] ', resolve)
Comment on lines +407 to +424

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Use the shared non-interactive gate before prompting.

Line 407 only checks stdin. If stdout is redirected, this still enters the prompt path and readline writes the prompt to stdout, which can block or contaminate scripted output instead of taking the non-interactive abort path. This also diverges from the shared non-interactive detection in src/utils/env.ts.

Suggested fix
-      if (process.stdin.isTTY) {
+      if (
+        !getIsNonInteractiveSession() &&
+        process.stdin.isTTY &&
+        process.stderr.isTTY
+      ) {
         console.error(
           chalk.bold.red(
             'WARNING: Running as root/sudo with bypass permissions mode is dangerous.',
@@
         const rl = readline.createInterface({
           input: process.stdin,
-          output: process.stdout,
+          output: process.stderr,
         })
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/setup.ts` around lines 407 - 424, The current check at line 407 only
verifies process.stdin.isTTY, but does not check if stdout is also a TTY. When
stdout is redirected in a non-interactive environment, readline will still
attempt to write the prompt to stdout, which can block or contaminate scripted
output. Replace the process.stdin.isTTY check with the shared non-interactive
gate utility from src/utils/env.ts that properly validates both stdin and stdout
are TTYs before entering the prompt path, ensuring consistency with the
non-interactive detection logic used elsewhere in the codebase.

})
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 (
Expand Down
169 changes: 169 additions & 0 deletions src/utils/__tests__/messages.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
createUserInterruptionMessage,
prepareUserContent,
createToolResultStopMessage,
createProgressMessage,
extractTag,
isNotEmptyMessage,
deriveUUID,
Expand All @@ -28,6 +29,9 @@ import {
DONT_ASK_REJECT_MESSAGE,
SYNTHETIC_MODEL,
ensureToolResultPairing,
buildMessageLookups,
updateMessageLookupsIncremental,
computeMessageStructureKey,
} from '../messages'
import type {
Message,
Expand Down Expand Up @@ -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<BashProgress>({
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)
})
})
20 changes: 18 additions & 2 deletions src/utils/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down Expand Up @@ -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(',')
Expand Down