[Bug]: Terminal history byte trimming can expose the middle of an escape sequence
Maintainer thường phản hồi trong vòng 1 ngày
Chưa có ai nhận issue này.
Đánh giá
- Độ khó
- 4/5
- Thời gian dự kiến
- 3-5 ngày
- Mức phù hợp với người mới
- 35/100
- Loại issue
- Lỗi
- Độ rõ ràng
- Khá rõ ràng
- Mức độ hoạt động
- Sôi nổi
- Công nghệ
- typescript, wasm
Hướng nghiên cứu
Start with BoundedTerminalHistory and sanitizeTerminalHistoryChunk in apps/server/src/terminal/Manager.ts, plus retainedTerminalHistory in packages/contracts/src/terminal.ts and appendOutput in packages/client-runtime/src/state/terminalOutput.ts. The repro script in the issue shows the fixtures: a six-byte CSI case and a ten-byte OSC case. Done when trimmed tails never begin mid-escape and replay yields only OK for those fixtures.
Do mô hình lập chỉ mục viết ra từ nội dung của issue.
Mô tả
Summary
Terminal history trimming preserves Unicode characters but can cut away the beginning of a terminal control sequence. Replaying the retained tail then prints control syntax or hidden payload as ordinary output. Deterministic probes reproduced this in server history and client retention, including both default byte limits.
Before submitting
- Related issues: searched open and closed issues and PRs; related history is listed below, with no exact current duplicate found.
- Removed credentials, tokens, private paths, and sensitive data.
Area
apps/server; also packages/contracts and packages/client-runtime
Observed behavior
At a six-byte cap, prefix ESC [ 31 m OK becomes [31mOK, and replay prints the style command fragment. At a ten-byte cap, prefix ESC ] 0 ; HIDDEN BEL OK becomes ;HIDDEN BEL OK, and replay prints ;HIDDENOK. BoundedTerminalHistory, retainedTerminalHistory, and the client runtime’s appendOutput retain those same broken tails. A long OSC title also reproduces at the real 512 KiB client and 8 MiB server budgets: live output is OK, while the retained tail begins in the title payload and replay prints that payload before OK.
Reproduction
- Use a checkout of the baseline below. Save the script as
repro-vt-trimming.mjsoutside the checkout and runnode repro-vt-trimming.mjs /path/to/checkout. - The script runs small configurable byte-cap fixtures plus OSC titles exceeding each default budget through the unchanged production definitions extracted into an in-memory module, then compares live bytes and replayed history in the bundled Ghostty WASM. It needs Node with
node:module.stripTypeScriptTypes, Git, and the checked-in WASM artifacts; installed application dependencies are unnecessary.
The result is deterministic. The small-cap fixtures use real exported retention functions; the long-title fixtures exercise the default byte budgets. No application state or checkout files are modified. The script creates a disposable directory, points HOME, TMPDIR, and T3CODE_HOME into it, removes it on exit, and leaves the checkout untouched. Exit 1 means the defect was reproduced, exit 0 means expected behavior, and exit 2 means setup or a control failed. The recorded run exited 1 after all controls passed. This is a unit-style scanner/engine reproduction, not a running application or native-device test.
Reproduction script: repro-vt-trimming.mjs
import fs from 'node:fs';
import os from 'node:os';
import {execFileSync} from 'node:child_process';
import path from 'node:path';
import { stripTypeScriptTypes } from 'node:module';
const scratch=fs.mkdtempSync(path.join(os.tmpdir(),'terminal-history-repro-'));
let status=2;
try {
for(const name of ['HOME','TMPDIR','T3CODE_HOME']){const dir=path.join(scratch,name.toLowerCase());fs.mkdirSync(dir);process.env[name]=dir;}
if(!process.argv[2])throw Error('Pass the checkout path as the only argument');
const root = path.resolve(process.argv[2]);
console.log('Git', execFileSync('git',['--version'],{encoding:'utf8'}).trim());
console.log('T3 commit',execFileSync('git',['-C',root,'rev-parse','HEAD'],{encoding:'utf8'}).trim());
console.log('Node',process.version);
const source=fs.readFileSync(path.join(root,'apps/server/src/terminal/Manager.ts'),'utf8');
const scanSource=source.slice(source.indexOf('function isCsiFinalByte('),source.indexOf('\nfunction ',source.indexOf('function sanitizeTerminalHistoryChunk(')+10));
const boundedSource=source.slice(source.indexOf('export class BoundedTerminalHistory'),source.indexOf('// Pop beyond Ghostty'));
const contract=fs.readFileSync(path.join(root,'packages/contracts/src/terminal.ts'),'utf8');
const tailSource=contract.slice(contract.indexOf('const historyEncoder'),contract.indexOf('const TrimmedNonEmptyStringSchema'));
const clientSource=fs.readFileSync(path.join(root,'packages/client-runtime/src/state/terminalOutput.ts'),'utf8').replace('import { retainedTerminalHistory } from "@t3tools/contracts";','');
const extracted=stripTypeScriptTypes('const DEFAULT_HISTORY_BYTE_LIMIT=8*1024*1024; const MAX_HISTORY_CHUNK_LENGTH=16*1024;\n'+boundedSource+tailSource+scanSource+'\n'+clientSource+'\nexport {sanitizeTerminalHistoryChunk};',{mode:'strip'});
const api=await import('data:text/javascript;base64,'+Buffer.from(extracted).toString('base64'));
const scan=(chunks)=>{let kept='',pending=''; for(const s of chunks){const r=api.sanitizeTerminalHistoryChunk(pending,s);kept+=r.visibleText;pending=r.pendingControlSequence;}return {kept,pending};};
const wasm=await WebAssembly.instantiate(fs.readFileSync(path.join(root,'apps/web/src/terminal/ghostty/vendor/ghostty-vt.wasm')),{env:{log:()=>{}}});
const e=wasm.instance.exports;
const call=(n,...a)=>e[n](...a);
const mem=e.memory;
const alloc=(n)=>call('ghostty_wasm_alloc_u8_array',n);
const free=(p,n)=>call('ghostty_wasm_free_u8_array',p,n);
const view=()=>new DataView(mem.buffer);
const bytes=(p,n)=>new Uint8Array(mem.buffer,p,n);
const decode=(p,n)=>new TextDecoder().decode(bytes(p,n));
const check=(r)=>{if(r!==0)throw Error('Ghostty result '+r);};
let replies='';
const trampoline=await WebAssembly.instantiate(fs.readFileSync(path.join(root,'apps/web/src/terminal/ghostty/vendor/ghostty-write-pty.wasm')),{env:{t3_write_pty:(_t,_u,p,n)=>{replies+=decode(p,n);}}});
const table=e.__indirect_function_table, callback=table.length;
table.grow(1,trampoline.instance.exports.ghostty_write_pty);
const revSlot=alloc(8);check(call('ghostty_build_info',10,revSlot));console.log('Ghostty',decode(view().getUint32(revSlot,true),view().getUint32(revSlot+4,true)));free(revSlot,8);
function terminal(chunks){
const opts=alloc(8),slot=alloc(4);bytes(opts,8).fill(0);view().setUint16(opts,80,true);view().setUint16(opts+2,24,true);check(call('ghostty_terminal_new',0,slot,opts));const t=view().getUint32(slot,true);free(opts,8);free(slot,4);
call('ghostty_terminal_set',t,0,1);call('ghostty_terminal_set',t,1,callback);replies='';
for(const s of chunks){const b=typeof s==='string'?Buffer.from(s):s;if(!b.length)continue;const p=alloc(b.length);bytes(p,b.length).set(b);call('ghostty_terminal_vt_write',t,p,b.length);free(p,b.length);}
const pos=alloc(8);check(call('ghostty_terminal_get',t,3,pos));const x=view().getUint16(pos,true);check(call('ghostty_terminal_get',t,4,pos));const y=view().getUint16(pos,true);check(call('ghostty_terminal_get',t,12,pos));const title=decode(view().getUint32(pos,true),view().getUint32(pos+4,true));free(pos,8);
// WASM C ABI passes the formatter options struct by reference.
const fopts=alloc(24),fslot=alloc(4),output=alloc(8192),written=alloc(4);bytes(fopts,24).fill(0);view().setUint32(fopts,24,true);bytes(fopts+8,2).fill(1);check(call('ghostty_formatter_terminal_new',0,fslot,t,fopts));const f=view().getUint32(fslot,true);check(call('ghostty_formatter_format_buf',f,output,8192,written));const text=decode(output,view().getUint32(written,true));call('ghostty_formatter_free',f);free(fopts,24);free(fslot,4);free(output,8192);free(written,4);
const result={reply:replies,text,title,x,y};call('ghostty_terminal_free',t);return result;
}
const assert=(condition,message)=>{if(!condition)throw Error('Setup/control failed: '+message);};
const defects=[];
function allSplits(input){
const expected=scan([input]),baseline=terminal([input]);
for(let i=0;i<=input.length;i++)assert(JSON.stringify(scan([input.slice(0,i),input.slice(i)]))===JSON.stringify(expected),'scanner split '+i);
const raw=Buffer.from(input);
for(let i=0;i<=raw.length;i++)assert(JSON.stringify(terminal([raw.subarray(0,i),raw.subarray(i)]))===JSON.stringify(baseline),'Ghostty byte split '+i);
return {kept:expected.kept,pending:expected.pending,live:baseline,replay:terminal([expected.kept])};
}
for(const [input,budget] of [['a🙂界éB',7],['a🙂B',3],['a\ufeffB',4]]){
const server=new api.BoundedTerminalHistory(5000,input,budget).value();
const client=api.retainedTerminalHistory(input,budget);
assert(server===client&&!server.includes('\ufffd')&&Buffer.byteLength(server)<=budget,'whole-code-point control');
}
for(const [name,input,budget] of [['CSI','prefix\x1b[31mOK',6],['OSC','prefix\x1b]0;HIDDEN\x07OK',10]]){
const server=new api.BoundedTerminalHistory(5000,input,budget).value();
const client=api.retainedTerminalHistory(input,budget);
const state=api.appendOutput(api.EMPTY_TERMINAL_OUTPUT_STATE,input,budget);
const appended=api.terminalOutputText(state);
const live=terminal([input]),serverReplay=terminal([server]),clientReplay=terminal([client]);
console.log(JSON.stringify({name,input,budget,server,client,appended,live,serverReplay,clientReplay}));
assert(server===client&&client===appended&&Buffer.byteLength(server)<=budget,'matching bounded tails');
if(serverReplay.text!=='OK'||clientReplay.text!=='OK')defects.push(name);
}
for(const budget of [512*1024,8*1024*1024]){
const input='\x1b]0;'+ 'h'.repeat(budget)+'\x07OK';
const retained=budget===512*1024?api.retainedTerminalHistory(input,budget):new api.BoundedTerminalHistory(5000,input,budget).value();
const live=terminal([input]),replay=terminal([retained]);
console.log(JSON.stringify({budget,inputBytes:Buffer.byteLength(input),retainedBytes:Buffer.byteLength(retained),retainedPrefix:retained.slice(0,16),liveText:live.text,replaySuffix:replay.text.slice(-20)}));
assert(live.text==='OK'&&Buffer.byteLength(retained)<=budget,'default-cap fixture');
if(replay.text!=='OK')defects.push('default budget '+budget);
}
status=defects.length?1:0;
}catch(error){console.log('Setup error:',error.message.replaceAll(scratch,'<scratch>'));status=2;}
finally{fs.rmSync(scratch,{recursive:true,force:true});}
console.log(status===0?'RESULT: expected behavior':status===1?'RESULT: defect reproduced':'RESULT: inconclusive');
process.exit(status);
Expected behavior
Evicting old output should not turn part of a control sequence into printable retained output. These fixtures should replay only OK; retaining fewer bytes than the budget is acceptable. The history intent is documented in docs/internals/terminal-runtime.md#L9-L28, while ECMA-48 sections 5.4–5.5 explain why dropping an introducer changes the meaning of the remaining bytes. This report concerns complete control-sequence boundaries, not restoration of every old terminal mode or color.
Impact and scope
Impact: P2 - Terminal history can gain bogus command syntax or hidden title payload after retention or reattachment. The failure is bounded to cuts inside control sequences, but the default limits reproduce it. Ordinary multi-byte characters and a BOM remain intact in the controls; no Unicode-splitting defect is claimed.
Form impact: Minor bug or occasional failure. Workaround: NOT_ENOUGH_INFO — no general end-user workaround was demonstrated for arbitrary process output.
Version
T3 Code commit 59e64eef881632a23f89c4138bcf37f23d92bf93.
Runtime and platform
Node v26.12.1; Git 2.56.0; macOS arm64 (Darwin 27.2.0). Vendored libghostty-vt WASM build info reports 9f62873bf195e4d8a762d768a1405a5f2f7b1697, matching native/libghostty-vt/VERSION. No browser, application server, real PTY, or native mobile device was started.
Interface
Library: server terminal-history scanner and bundled Ghostty C ABI; shared client retention functions.
Earliest failure stage
State or storage.
Relevant output or evidence
Actual behavior
The following display uses named markers for ESC, NUL, BEL, DEL, and U+009C; reproduction inputs in the script above retain executable escape notation.
Git git version 2.56.0
T3 commit 59e64eef881632a23f89c4138bcf37f23d92bf93
Node v26.12.1
Ghostty 9f62873bf195e4d8a762d768a1405a5f2f7b1697
{"name":"CSI","input":"prefix<ESC>[31mOK","budget":6,"server":"[31mOK","client":"[31mOK","appended":"[31mOK","live":{"reply":"","text":"prefixOK","title":"","x":8,"y":0},"serverReplay":{"reply":"","text":"[31mOK","title":"","x":6,"y":0},"clientReplay":{"reply":"","text":"[31mOK","title":"","x":6,"y":0}}
{"name":"OSC","input":"prefix<ESC>]0;HIDDEN<BEL>OK","budget":10,"server":";HIDDEN<BEL>OK","client":";HIDDEN<BEL>OK","appended":";HIDDEN<BEL>OK","live":{"reply":"","text":"prefixOK","title":"HIDDEN","x":8,"y":0},"serverReplay":{"reply":"","text":";HIDDENOK","title":"","x":9,"y":0},"clientReplay":{"reply":"","text":";HIDDENOK","title":"","x":9,"y":0}}
{"budget":524288,"inputBytes":524295,"retainedBytes":524288,"retainedPrefix":"hhhhhhhhhhhhhhhh","liveText":"OK","replaySuffix":"hhhhhhhhhhhhhhhhhhOK"}
{"budget":8388608,"inputBytes":8388615,"retainedBytes":8388608,"retainedPrefix":"hhhhhhhhhhhhhhhh","liveText":"OK","replaySuffix":"hhhhhhhhhhhhhhhhhhOK"}
RESULT: defect reproduced
Evidence
- Expected source: the documented restoration/retention contract and pinned parser behavior described above.
- Failure source: the script output above, running the unchanged production definitions and checked-in WASM.
- Evidence provenance: observed.
- Local verification: reproduced.
- Reproduction completeness: complete for the scanner/retention and engine boundary; no full-client behavior is claimed.
apps/server/src/terminal/Manager.ts#L1006-L1058 trims server history; packages/contracts/src/terminal.ts#L12-L33 returns the bounded UTF-8 tail; packages/client-runtime/src/state/terminalOutput.ts#L143-L224 trims appended client output. The script extracts and runs these production definitions without patching the checkout.
Restoration check
The script should exit 0: all retained tails remain within their byte budgets and replay only OK, including the default-budget long OSC fixtures, while the existing whole-code-point and BOM controls continue to pass.
Additional context
References
- https://github.com/pingdotgg/t3code/pull/9748 — introduced the bounded server history and verified Unicode cutoffs; the distinct requirement here is preserving VT sequence boundaries.
- https://github.com/pingdotgg/t3code/pull/33942 — reuses the Unicode-safe tail in bounded attach snapshots; this report tests whether that tail is also a valid replay boundary.
AI-assisted report; verified with the script and tools listed above. No source changes, fixes, or PRs are included.
- Ngôn ngữ chính
- TypeScript
- Star
- 24.8k
- Fork
- 6.4k
- Merge trung bình
- 59 phút
- Pull request đã merge (30 ngày)
- 418
Chuẩn bị môi trường
Khởi chạy dev container của dự án ngay trên trình duyệt, bằng tài khoản GitHub của bạn.
- Không có Dockerfile hay tệp Docker Compose
- Có mẫu pull request
- Đọc hướng dẫn đóng góp
Bắt đầu từ đâu
- Đọc hết issue, rồi đọc hướng dẫn đóng góp của dự án.
- Bình luận trên issue rằng bạn sẽ nhận — tránh hai người làm cùng một việc.
- Fork repository và làm thay đổi trên một nhánh.
- Mở pull request có tham chiếu số hiệu của issue.
Issue khác của pingdotgg/t3code
-
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 66/100
Maintainer thường phản hồi trong vòng 1 ngày
-
via-triage
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 82/100
Maintainer thường phản hồi trong vòng 1 ngày
-
[Bug]: MCP App inline frames never go below 80px, so compact apps get blank space under themĐang mở
Độ khó 2/5 Nửa ngày Mức phù hợp với người mới 68/100
Maintainer thường phản hồi trong vòng 1 ngày
-
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 76/100
Maintainer thường phản hồi trong vòng 1 ngày
-
[Bug]: Settings inheritance popover shows raw slugs for branch naming, default permissions and response streamingCó thể đã có người làm @juliusmarminge đã nhận 1 ngày trước. Đang mởvouch:denounced
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 84/100
Maintainer thường phản hồi trong vòng 1 ngày
Tất cả issue của pingdotgg/t3code
Issue tương tự
-
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 72/100
nikoheikkila/nikoheikkila.fi#1131 ·
Maintainer thường phản hồi trong vòng 1 ngày
-
Resources: Update work of urumqiCó thể đã có người làm Có pull request liên kết đang mở hoặc đã được merge. Đang mở
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 65/100
railmapgen/rmp-gallery#4176 ·
Maintainer thường phản hồi trong vòng 2 ngày
-
GraphQL custom headers duplicate differently cased default headersCó thể đã có người làm @yxr-2025 đã nhận hôm nay. Đang mở
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 70/100
elie222/rakazo#4625 · 1 bình luận ·
Maintainer thường phản hồi trong vòng 1 ngày
-
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 76/100
Maintainer thường phản hồi trong vòng 1 ngày
-
[New Project]: PacaĐang mở
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 65/100
github/forgoodfirstissue#574 ·