Skip to content
Source
Sign inSign up

[Bug]: ESC Z identity query survives terminal history filtering and replies again on replay #36013

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

Summary

The terminal’s legacy identity query is kept in saved history. Replaying that history into a fresh terminal with a writer attached sends the identity reply again, although history restoration is supposed to generate no replies. A deterministic probe reproduced the duplicate callback with the bundled engine, while the equivalent modern query was correctly filtered.

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

The scanner retains all bytes of A ESC Z B. The bundled Ghostty engine answers the original input with ESC[?62;22c and answers the kept history with the same bytes again. Its displayed text stays AB. The equivalent A CSI c B control saves AB and produces no replay reply.

Reproduction

  1. Use a checkout of the baseline below. Save the script as repro-decid.mjs outside the checkout and run node repro-decid.mjs /path/to/checkout.
  2. The script runs the complete legacy DECID query with printable sentinels and an equivalent CSI DA 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 engine input 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-decid.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[cB');
assert(control.kept==='AB'&&control.live.reply==='\x1b[?62;22c'&&control.replay.reply==='','canonical DA');
const input='A\x1bZB',result=allSplits(input);
assert(result.live.text==='AB'&&result.live.reply==='\x1b[?62;22c'&&!result.pending,'DECID recognized');
console.log(JSON.stringify({input,expectedHistory:'AB',expectedReplayReply:'',...result}));
if(result.replay.reply!==''||result.kept!=='AB')defects.push('DECID');

 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

History must omit the answer-generating ESC Z query, leaving AB, just as it omits the equivalent CSI DA query. XTerm’s DECID entry documents ESC Z as the obsolete DA form; the pinned engine demonstrably supports it. T3’s docs/internals/terminal-runtime.md#L39-L44 states that restoring scrollback must not send terminal replies to the current shell.

Impact and scope

Impact: P2 - Clients replaying with a PTY writer attached can deliver an extra primary-DA reply to the current process. This is bounded to streams using DECID. The measured result is the generated callback, not shell display or a physical Android client. Web’s writer-detaching replay path is an existing mitigation there.

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>ZB","expectedHistory":"AB","expectedReplayReply":"","kept":"A<ESC>ZB","pending":"","live":{"reply":"<ESC>[?62;22c","text":"AB","title":"","x":2,"y":0},"replay":{"reply":"<ESC>[?62;22c","text":"AB","title":"","x":2,"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 DECID fixture retains AB and produces no replay callback, while the ordinary CSI DA control remains correctly filtered.

Additional context

References

  • https://github.com/pingdotgg/t3code/issues/35511 — reported tertiary CSI DA replay; that different query is already fixed, while this report uses the ESC-form DECID query.
  • https://github.com/pingdotgg/t3code/pull/35537 — fixed only the CSI tertiary prefix, not the generic ESC sequence path.

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.