From 5b621a5400f6f71e48cb7577c5857e8f41ee91dc Mon Sep 17 00:00:00 2001 From: Mikey Date: Thu, 3 Sep 2026 13:42:01 -0700 Subject: [PATCH 1/2] Strip all ANSI control sequences in bounded terminal output buffer - Switch BoundedOutputBuffer.append from stripColors to stripAnsi in sdk/src/tools/run-terminal-command.ts. - Update INCOMPLETE_ESCAPE_SEQUENCE_REGEX to correctly match split ECMA-48 CSI and OSC sequences across chunk boundaries. - Rename internal tracker pendingColorSequence to pendingEscapeSequence. - Add unit tests in sdk/src/__tests__/run-terminal-command.test.ts verifying stripping of line erases, cursor movement, cursor visibility, and chunk-split sequences. --- .../__tests__/run-terminal-command.test.ts | 21 +++++++++++++++ sdk/src/tools/run-terminal-command.ts | 26 +++++++++---------- 2 files changed, 34 insertions(+), 13 deletions(-) diff --git a/sdk/src/__tests__/run-terminal-command.test.ts b/sdk/src/__tests__/run-terminal-command.test.ts index ab455db040..6e51eeedf7 100644 --- a/sdk/src/__tests__/run-terminal-command.test.ts +++ b/sdk/src/__tests__/run-terminal-command.test.ts @@ -109,6 +109,27 @@ describe('BoundedOutputBuffer', () => { expect(output.format()).toStartWith('chunk-0000') expect(output.format()).toEndWith('chunk-0999') }) + + test('strips non-color ANSI control sequences such as line clears and cursor controls', () => { + const output = new BoundedOutputBuffer(100) + output.append('building...\u001b[2K\r') + output.append('\u001b[?25lprogress\u001b[?25h') + output.append('\u001b[1A\u001b[2Jdone') + + expect(output.format()).toBe('building...\rprogressdone') + expect(output.format()).not.toContain('\u001b[') + }) + + test('buffers and strips split ANSI control sequences across chunk boundaries', () => { + const output = new BoundedOutputBuffer(100) + output.append('step 1\u001b[2') + output.append('K-cleared') + output.append(' \u001b[?25') + output.append('h-visible') + + expect(output.format()).toBe('step 1-cleared -visible') + expect(output.format()).not.toContain('\u001b[') + }) }) describe('terminal command process diagnostics', () => { diff --git a/sdk/src/tools/run-terminal-command.ts b/sdk/src/tools/run-terminal-command.ts index 6450b2bace..cfa658cf21 100644 --- a/sdk/src/tools/run-terminal-command.ts +++ b/sdk/src/tools/run-terminal-command.ts @@ -8,7 +8,7 @@ import type { } from 'child_process' import type { Readable } from 'stream' -import { stripColors } from '../../../common/src/util/string' +import { stripAnsi } from '../../../common/src/util/string' import { getSystemProcessEnv } from '../env' import { createWindowsBashNotFoundError, @@ -19,8 +19,8 @@ import type { CodebuffToolOutput } from '../../../common/src/tools/list' const COMMAND_OUTPUT_LIMIT = 50_000 const TRUNCATION_MARKER = '\n[...TRUNCATED DUE TO LENGTH...]\n' -const MAX_PENDING_COLOR_SEQUENCE_LENGTH = 32 -const INCOMPLETE_COLOR_SEQUENCE_REGEX = /\x1B\[[0-9;]*$/ +const MAX_PENDING_ESCAPE_SEQUENCE_LENGTH = 32 +const INCOMPLETE_ESCAPE_SEQUENCE_REGEX = /\x1B(?:\[[0-?]*[ -/]*|\][^\x1B]*)?$/ // Grace period between SIGTERM and SIGKILL for commands that trap or ignore // SIGTERM. const KILL_ESCALATION_MS = 1500 @@ -64,7 +64,7 @@ export class BoundedOutputBuffer { private head = '' private tail = '' private truncated = false - private pendingColorSequence = '' + private pendingEscapeSequence = '' private readonly headLimit: number private readonly tailLimit: number @@ -80,19 +80,19 @@ export class BoundedOutputBuffer { append(value: string): void { if (!value) return - let normalized = this.pendingColorSequence + value - this.pendingColorSequence = '' - const incompleteColorSequence = normalized.match( - INCOMPLETE_COLOR_SEQUENCE_REGEX, + let normalized = this.pendingEscapeSequence + value + this.pendingEscapeSequence = '' + const incompleteEscapeSequence = normalized.match( + INCOMPLETE_ESCAPE_SEQUENCE_REGEX, )?.[0] if ( - incompleteColorSequence && - incompleteColorSequence.length <= MAX_PENDING_COLOR_SEQUENCE_LENGTH + incompleteEscapeSequence && + incompleteEscapeSequence.length <= MAX_PENDING_ESCAPE_SEQUENCE_LENGTH ) { - this.pendingColorSequence = incompleteColorSequence - normalized = normalized.slice(0, -incompleteColorSequence.length) + this.pendingEscapeSequence = incompleteEscapeSequence + normalized = normalized.slice(0, -incompleteEscapeSequence.length) } - normalized = stripColors(normalized) + normalized = stripAnsi(normalized) if (!normalized) return if (!this.truncated) { From b56c4460d5083d3cda243d99ec8ab33549829742 Mon Sep 17 00:00:00 2001 From: Mikey Date: Fri, 4 Sep 2026 09:52:23 -0700 Subject: [PATCH 2/2] Exclude terminated OSC sequences from incomplete escape buffering - Tighten INCOMPLETE_ESCAPE_SEQUENCE_REGEX in sdk/src/tools/run-terminal-command.ts to require incomplete OSC sequences not contain BEL (\x07) or ST (\x1B\), preventing fully-terminated OSC codes from being buffered as incomplete and dropping trailing text. - Reorder ansiRegex in common/src/util/string.ts so CSI and OSC take precedence over generic 2-character Fe escapes, and support both BEL and ST terminators. - Add unit tests in sdk/src/__tests__/run-terminal-command.test.ts verifying fully-terminated OSC stripping, preservation of trailing text in the same chunk and final chunk, and chunk-split OSC. - Add unit tests in common/src/util/__tests__/string.test.ts verifying stripAnsi on colors, CSI controls, OSC BEL, OSC ST, Fe escapes, and plain text. --- common/src/util/__tests__/string.test.ts | 62 ++++++++++++++++--- common/src/util/string.ts | 3 +- .../__tests__/run-terminal-command.test.ts | 28 ++++++++- sdk/src/tools/run-terminal-command.ts | 8 +-- 4 files changed, 86 insertions(+), 15 deletions(-) diff --git a/common/src/util/__tests__/string.test.ts b/common/src/util/__tests__/string.test.ts index 3a141ca6b6..33026468b3 100644 --- a/common/src/util/__tests__/string.test.ts +++ b/common/src/util/__tests__/string.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'bun:test' -import { pluralize } from '../string' +import { pluralize, stripAnsi, stripColors } from '../string' describe('pluralize', () => { it('should handle singular and plural cases correctly', () => { @@ -53,13 +53,13 @@ describe('pluralize', () => { expect(pluralize(2, 'person')).toBe('2 people') expect(pluralize(2, 'child')).toBe('2 children') expect(pluralize(2, 'mouse')).toBe('2 mice') - + // -ex/-ix → -ices (no reliable rule, must be hardcoded) expect(pluralize(2, 'index')).toBe('2 indices') expect(pluralize(2, 'vertex')).toBe('2 vertices') expect(pluralize(2, 'matrix')).toBe('2 matrices') expect(pluralize(2, 'appendix')).toBe('2 appendices') - + // Latin -um → -a expect(pluralize(2, 'datum')).toBe('2 data') expect(pluralize(2, 'medium')).toBe('2 media') @@ -123,7 +123,7 @@ describe('pluralize', () => { expect(pluralize(2, 'data')).toBe('2 data') expect(pluralize(2, 'metadata')).toBe('2 metadata') expect(pluralize(2, 'feedback')).toBe('2 feedback') - + // Other words ending in -s that don't change expect(pluralize(2, 'series')).toBe('2 series') expect(pluralize(2, 'chassis')).toBe('2 chassis') @@ -135,7 +135,7 @@ describe('pluralize', () => { expect(pluralize(2, 'hero')).toBe('2 heroes') expect(pluralize(2, 'echo')).toBe('2 echoes') expect(pluralize(2, 'veto')).toBe('2 vetoes') - + // Tech terms that just add -s expect(pluralize(2, 'photo')).toBe('2 photos') expect(pluralize(2, 'video')).toBe('2 videos') @@ -160,11 +160,11 @@ describe('pluralize', () => { expect(pluralize(2, 'shelf')).toBe('2 shelves') expect(pluralize(2, 'self')).toBe('2 selves') expect(pluralize(2, 'leaf')).toBe('2 leaves') - + // -fe to -ves expect(pluralize(2, 'knife')).toBe('2 knives') expect(pluralize(2, 'life')).toBe('2 lives') - + // Tech/design terms that just add -s expect(pluralize(2, 'proof')).toBe('2 proofs') // mathematical proofs expect(pluralize(2, 'brief')).toBe('2 briefs') // design briefs @@ -237,3 +237,51 @@ describe('pluralize', () => { }) }) +describe('stripAnsi', () => { + it('strips ANSI color escape sequences', () => { + expect(stripAnsi('\u001b[31;1mred text\u001b[0m')).toBe('red text') + expect(stripAnsi('\u001b[38;5;196mcolored\u001b[0m normal')).toBe( + 'colored normal', + ) + }) + + it('strips CSI control sequences (line clears, cursor movement, visibility)', () => { + expect(stripAnsi('building...\u001b[2K\r')).toBe('building...\r') + expect(stripAnsi('\u001b[?25lhidden\u001b[?25h')).toBe('hidden') + expect(stripAnsi('\u001b[1A\u001b[2Jcleared')).toBe('cleared') + }) + + it('strips OSC sequences terminated by BEL (\\u0007)', () => { + expect(stripAnsi('\u001b]0;my window title\u0007window')).toBe('window') + expect(stripAnsi('prefix\u001b]2;tab title\u0007suffix')).toBe( + 'prefixsuffix', + ) + }) + + it('strips OSC sequences terminated by String Terminator (ST, ESC \\)', () => { + expect(stripAnsi('\u001b]0;my window title\u001b\\window')).toBe('window') + expect(stripAnsi('prefix\u001b]2;tab title\u001b\\suffix')).toBe( + 'prefixsuffix', + ) + }) + + it('strips 2-character Fe escape sequences', () => { + expect(stripAnsi('\u001bMreverse index')).toBe('reverse index') + expect(stripAnsi('\u001bNsingle shift 2')).toBe('single shift 2') + }) + + it('preserves plain text without escape sequences', () => { + expect(stripAnsi('hello world 123 !@#$%^&*()')).toBe( + 'hello world 123 !@#$%^&*()', + ) + }) +}) + +describe('stripColors', () => { + it('strips only ANSI color codes while leaving other sequences alone', () => { + expect(stripColors('\u001b[31mred\u001b[0m')).toBe('red') + expect(stripColors('status: \u001b[2K\rline')).toBe( + 'status: \u001b[2K\rline', + ) + }) +}) diff --git a/common/src/util/string.ts b/common/src/util/string.ts index 506de962fd..1ab3458be0 100644 --- a/common/src/util/string.ts +++ b/common/src/util/string.ts @@ -378,7 +378,8 @@ export function stripColors(str: string): string { return str.replace(ansiColorsRegex, '') } -const ansiRegex = /\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~]|\][^\x1B]*\x1B\\?)/g +const ansiRegex = + /\x1B(?:\[[0-?]*[ -/]*[@-~]|\][^\x07\x1B]*(?:\x07|\x1B\\)|[@-Z\\-_])/g export function stripAnsi(str: string): string { return str.replace(ansiRegex, '') } diff --git a/sdk/src/__tests__/run-terminal-command.test.ts b/sdk/src/__tests__/run-terminal-command.test.ts index 6e51eeedf7..6cf480c2c3 100644 --- a/sdk/src/__tests__/run-terminal-command.test.ts +++ b/sdk/src/__tests__/run-terminal-command.test.ts @@ -120,15 +120,39 @@ describe('BoundedOutputBuffer', () => { expect(output.format()).not.toContain('\u001b[') }) + test('strips fully-terminated OSC sequences and preserves trailing text', () => { + const output = new BoundedOutputBuffer(100) + // OSC sequence terminated by BEL with text after it in same chunk + output.append('building...\u001b]0;my window title\u0007done') + // OSC sequence terminated by String Terminator (ST, ESC \) + output.append(' \u001b]2;another title\u001b\\completed') + // OSC sequence terminated by BEL as the final chunk + output.append('\u001b]0;final title\u0007') + + expect(output.format()).toBe('building...done completed') + expect(output.format()).not.toContain('\u001b]') + expect(output.format()).not.toContain('my window title') + expect(output.format()).not.toContain('another title') + expect(output.format()).not.toContain('final title') + }) + test('buffers and strips split ANSI control sequences across chunk boundaries', () => { const output = new BoundedOutputBuffer(100) + // Split CSI output.append('step 1\u001b[2') output.append('K-cleared') output.append(' \u001b[?25') output.append('h-visible') - - expect(output.format()).toBe('step 1-cleared -visible') + // Split OSC across chunks terminated by BEL + output.append(' \u001b]0;tit') + output.append('le\u0007-osc-bel') + // Split OSC across chunks terminated by ST split between ESC and backslash + output.append(' \u001b]0;title2\u001b') + output.append('\\-osc-st') + + expect(output.format()).toBe('step 1-cleared -visible -osc-bel -osc-st') expect(output.format()).not.toContain('\u001b[') + expect(output.format()).not.toContain('\u001b]') }) }) diff --git a/sdk/src/tools/run-terminal-command.ts b/sdk/src/tools/run-terminal-command.ts index cfa658cf21..30434f172f 100644 --- a/sdk/src/tools/run-terminal-command.ts +++ b/sdk/src/tools/run-terminal-command.ts @@ -10,17 +10,15 @@ import type { Readable } from 'stream' import { stripAnsi } from '../../../common/src/util/string' import { getSystemProcessEnv } from '../env' -import { - createWindowsBashNotFoundError, - findWindowsBash, -} from './windows-bash' +import { createWindowsBashNotFoundError, findWindowsBash } from './windows-bash' import type { CodebuffToolOutput } from '../../../common/src/tools/list' const COMMAND_OUTPUT_LIMIT = 50_000 const TRUNCATION_MARKER = '\n[...TRUNCATED DUE TO LENGTH...]\n' const MAX_PENDING_ESCAPE_SEQUENCE_LENGTH = 32 -const INCOMPLETE_ESCAPE_SEQUENCE_REGEX = /\x1B(?:\[[0-?]*[ -/]*|\][^\x1B]*)?$/ +const INCOMPLETE_ESCAPE_SEQUENCE_REGEX = + /\x1B(?:\[[0-?]*[ -/]*|\][^\x07\x1B]*(?:\x1B)?)?$/ // Grace period between SIGTERM and SIGKILL for commands that trap or ignore // SIGTERM. const KILL_ESCALATION_MS = 1500