[Bug]: BEL inside a DCS makes hidden payload text appear in replayed history #36011
Send to my agentSummary#
A bell inside a device control string ends the history scanner’s string early even though the live terminal keeps reading the payload until the string terminator. The saved history then exposes payload text as ordinary output when replayed. Deterministic probes reproduced the mismatch with both status-string and termcap-query prefixes.
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
Observed behavior#
Input A ESC P $ q m BEL HIDDEN ESC \ B is saved as A HIDDEN ESC \ B, with no spaces between those bytes. Live Ghostty output is AB; feeding the kept bytes into a fresh Ghostty terminal produces AHIDDENB. The same occurs with +q544e in place of $qm.
Reproduction#
- Use a checkout of the baseline below. Save the script as
repro-dcs-bel.mjsoutside the checkout and runnode repro-dcs-bel.mjs /path/to/checkout. - The script runs two complete DCS strings, a complete DCS control, and a BEL-terminated OSC control 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. Every two-chunk scanner split and every raw-byte split of the live Ghostty stream produced the same result. The probe does not route replies into a shell. 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-dcs-bel.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])};
}
const control=allSplits('A\x1bP$qm\x1b\\B');
assert(control.kept==='AB'&&control.live.text==='AB'&&control.replay.text==='AB','ST-terminated DCS');
const oscControl=allSplits('A\x1b]11;?\x07B');
assert(oscControl.kept==='AB'&&oscControl.live.text==='AB','BEL terminates OSC');
for(const prefix of ['$qm','+q544e']){
const input='A\x1bP'+prefix+'\x07HIDDEN\x1b\\B',result=allSplits(input);
assert(result.live.text==='AB'&&!result.pending,'BEL stays inside DCS');
console.log(JSON.stringify({input,expectedHistory:'AB',expectedReplayText:'AB',...result}));
if(result.replay.text!=='AB')defects.push(prefix);
}
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#
BEL must not end a DCS. For these filtered DCS prefixes, the whole string through ESC \ should be omitted, leaving AB; replay must not print HIDDEN. ECMA-48 sections 5.5 and 8.3.27 delimit control strings with ST. The pinned Ghostty DCS passthrough table treats BEL as DCS payload. The passing OSC control demonstrates that BEL remains a valid OSC terminator.
Impact and scope#
Impact: P2 - Reopening or reconnecting can show text that was never visible in the live terminal. This is bounded to a BEL inside a DCS whose prefix is filtered. The probe checks retained bytes and rendered text; it does not assume support for answering either optional DCS query.
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.
Earliest failure stage#
Input parsing or validation.
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
{"input":"A<ESC>P$qm<BEL>HIDDEN<ESC>\\B","expectedHistory":"AB","expectedReplayText":"AB","kept":"AHIDDEN<ESC>\\B","pending":"","live":{"reply":"","text":"AB","title":"","x":2,"y":0},"replay":{"reply":"","text":"AHIDDENB","title":"","x":8,"y":0}}
{"input":"A<ESC>P+q544e<BEL>HIDDEN<ESC>\\B","expectedHistory":"AB","expectedReplayText":"AB","kept":"AHIDDEN<ESC>\\B","pending":"","live":{"reply":"","text":"AB","title":"","x":2,"y":0},"replay":{"reply":"","text":"AHIDDENB","title":"","x":8,"y":0}}
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#L1144-L1328 contains the scanner exercised by the probe. apps/server/src/terminal/Manager.ts#L2321-L2331 passes each PTY output chunk to it and appends its kept bytes to session history.
Restoration check#
The script should exit 0: both BEL-containing DCS fixtures replay as AB, plain ST-terminated DCS still filters, and BEL-terminated OSC still completes.
Additional context#
References#
- https://github.com/pingdotgg/t3code/pull/29604 — fixed ESC abort boundaries for unterminated strings; this input has a complete ST and exercises BEL inside DCS.
- https://github.com/pingdotgg/t3code/pull/23819 — changed incremental terminator scanning performance; the mismatch occurs even with a single short chunk.
AI-assisted report; verified with the script and tools listed above. No source changes, fixes, or PRs are included.
tanstack-source added labelsbug
coygeek(GitHub profile, opens in a new tab) changed the title
github-actions[bot](GitHub profile, opens in a new tab) added labelsvouch:denounced
juliusmarminge closed this
Sign in to join this conversation.