Skip to content
Source
Sign inSign up

[Bug]: UTF-8 U+009C inside OSC exposes its payload in replayed history #36012

Send to my agent
Closedcoygeek(GitHub profile, opens in a new tab) opened this issue 0 comments

Summary

The history scanner treats a Unicode character inside an operating system command as its terminator, while the bundled terminal keeps it inside the command. Filtering removes only the prefix, so replay prints part of the command payload as normal text. The mismatch was reproduced with the production scanner and bundled terminal engine.

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

For A ESC ] 11 ; ? U+009C HIDDEN ESC \ B, the actual UTF-8 bytes include c2 9c, not a standalone 9c byte. The scanner keeps AHIDDEN ESC \ B; Ghostty renders the original bytes as AB and the kept history as AHIDDENB. Plain A U+009C B remains unchanged, so the already-fixed C1-introducer problem is not the case being reported.

Reproduction

  1. Use a checkout of the baseline below. Save the script as repro-osc-c1.mjs outside the checkout and run node repro-osc-c1.mjs /path/to/checkout.
  2. The script runs one UTF-8 OSC fixture, an ESC-ST control, and plain U+009C text 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 Ghostty input, including the two bytes of U+009C, produced the same result. 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-osc-c1.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\x1b]11;?\x1b\\B');
assert(control.kept==='AB'&&control.live.text==='AB','real ST');
const plain=allSplits('A\x9cB');
assert(plain.kept==='A\x9cB'&&plain.live.text==='A\x9cB','U+009C ground text');
const input='A\x1b]11;?\x9cHIDDEN\x1b\\B',result=allSplits(input);
assert(result.live.text==='AB'&&!result.pending,'OSC continues through UTF-8 U+009C');
console.log(JSON.stringify({input,utf8Hex:Buffer.from(input).toString('hex'),expectedHistory:'AB',expectedReplayText:'AB',...result}));
if(result.replay.text!=='AB')defects.push('OSC U+009C');

 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

The scanner should follow the bundled engine’s OSC boundary: UTF-8-encoded U+009C is not ST in this OSC, and HIDDEN remains payload until the real ESC \. Filtering this OSC should therefore retain AB. The XTerm control-byte explanation distinguishes raw C1 bytes from decoded Unicode. The decisive compatibility check here is the pinned WASM engine’s observed behavior, including a split between UTF-8 bytes c2 and 9c.

Impact and scope

Impact: P2 - Replayed terminal history gains text absent from live output. This requires U+009C inside an ESC-started OSC selected by the color-query filter; ground-state C1 text remains correct. No user workload frequency or native-client UI behavior was measured.

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>]11;?<U+009C>HIDDEN<ESC>\\B","utf8Hex":"411b5d31313b3fc29c48494444454e1b5c42","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: the OSC fixture replays as AB, genuine ST continues to terminate and filter OSC, and plain U+009C text remains intact.

Additional context

References

  • https://github.com/pingdotgg/t3code/pull/29649 — fixed C1 code points as sequence introducers and explicitly said the U+009C terminator inside ESC-started strings was not changed.
  • https://github.com/pingdotgg/t3code/pull/29604 — fixed ESC abort handling; this complete OSC instead exercises a UTF-8 code point before its actual ST.

AI-assisted report; verified with the script and tools listed above. No source changes, fixes, or PRs are included.

  1. tanstack-source added labelsbug

  2. github-actions[bot](GitHub profile, opens in a new tab) added labelsvouch:denounced

  3. juliusmarminge closed this

Sign in to join this conversation.