Compare commits

...
8 changed files with 479 additions and 26 deletions

No files matched your search

@@ -185,21 +185,31 @@ test("pending cursor-home lookahead controls are bounded before clear", () => {
assert.equal(log.includes("\x1b[31m"), false);
});
test("alternate-screen entry protects preserved replay history", () => {
test("alternate-screen entry drops TUI frames from connection logs", () => {
const log = createReplaySafeTerminalLog(
"before\n\x1b[?1049h\x1b[Hvim screen\n\x1b[?1049lafter\n"
+ "\x1b[?47h\x1b[10;5Htop screen\n\x1b[?47l"
+ "\x1b[?25lcursor hidden\n",
);
assert.equal(log, "before\n\r\nvim screen\nafter\n\r\ntop screen\n\x1b[?25lcursor hidden\n");
assert.equal(log, "before\nafter\n\x1b[?25lcursor hidden\n");
assert.equal(log.includes("\x1b[?1049h"), false);
assert.equal(log.includes("\x1b[?1049l"), false);
assert.equal(log.includes("\x1b[?47h"), false);
assert.equal(log.includes("\x1b[?47l"), false);
assert.equal(log.includes("\x1b[H"), false);
assert.equal(log.includes("\x1b[10;5H"), false);
assert.equal(log.includes("\x1b[?25l"), true);
assert.equal(log.includes("vim screen"), false);
assert.equal(log.includes("top screen"), false);
});
test("alternate-screen content stays dropped across split chunks", () => {
const sanitizer = createReplaySafeTerminalLogSanitizer();
const log =
sanitizer.append("shell\n\x1b[?1049h~\r\n")
+ sanitizer.append("~\r\nbuffer\r\n\x1b[?1049l")
+ sanitizer.append("after\n")
+ sanitizer.finish();
assert.equal(log, "shell\nafter\n");
});
test("dec cursor save mode is not treated as alternate screen", () => {
+62 -17
View File
@@ -175,6 +175,7 @@ class ReplaySafeTerminalLogSanitizerImpl implements ReplaySafeTerminalLogSanitiz
private discardingCsi = false;
private inClearCluster = false;
private protectingClearedHistory = false;
private alternateScreenActive = false;
private hasOutput = false;
private lastOutputChar = "";
@@ -190,10 +191,18 @@ class ReplaySafeTerminalLogSanitizerImpl implements ReplaySafeTerminalLogSanitiz
this.lastOutputChar = next[next.length - 1];
};
const clearPendingCursorHome = () => {
this.pendingCursorHome = "";
this.pendingAfterCursorHome = "";
this.replaySafePendingAfterCursorHome = "";
this.pendingAfterCursorHomeOverflowed = false;
};
if (
!this.pendingCursorHome
&& !this.discardingCsi
&& !this.controlStringMode
&& !this.alternateScreenActive
&& !hasReplayControlCandidate(data)
) {
appendOutput(data);
@@ -203,6 +212,19 @@ class ReplaySafeTerminalLogSanitizerImpl implements ReplaySafeTerminalLogSanitiz
return output;
}
// Fullscreen TUI frames are connection-log chrome (vim "~" lines, etc.).
// Drop them here so later txt/html export still works after enter/leave
// sequences are stripped for replay safety.
if (
this.alternateScreenActive
&& !this.pendingCursorHome
&& !this.discardingCsi
&& !this.controlStringMode
&& !hasReplayControlCandidate(data)
) {
return "";
}
const flushPendingCursorHome = () => {
if (!this.pendingCursorHome) return;
if (this.protectingClearedHistory) {
@@ -211,18 +233,12 @@ class ReplaySafeTerminalLogSanitizerImpl implements ReplaySafeTerminalLogSanitiz
appendOutput(this.pendingCursorHome);
appendOutput(this.pendingAfterCursorHome);
}
this.pendingCursorHome = "";
this.pendingAfterCursorHome = "";
this.replaySafePendingAfterCursorHome = "";
this.pendingAfterCursorHomeOverflowed = false;
clearPendingCursorHome();
};
const emitClearSeparator = (preservePendingControls: boolean) => {
const preservedControls = preservePendingControls ? this.replaySafePendingAfterCursorHome : "";
this.pendingCursorHome = "";
this.pendingAfterCursorHome = "";
this.replaySafePendingAfterCursorHome = "";
this.pendingAfterCursorHomeOverflowed = false;
clearPendingCursorHome();
if (!this.inClearCluster && this.hasOutput) {
appendOutput(/[\r\n]$/.test(this.lastOutputChar) ? "\r\n" : "\r\n\r\n");
}
@@ -261,12 +277,20 @@ class ReplaySafeTerminalLogSanitizerImpl implements ReplaySafeTerminalLogSanitiz
const alternateScreenMode = getAlternateScreenMode(sequence);
if (alternateScreenMode) {
if (alternateScreenMode === "enter") {
emitClearSeparator(false);
this.alternateScreenActive = true;
clearPendingCursorHome();
} else {
this.alternateScreenActive = false;
}
i = sequence.end;
continue;
}
if (this.alternateScreenActive) {
i = sequence.end;
continue;
}
if (isCursorHomeSequence(sequence)) {
if (!this.inClearCluster) {
if (!this.pendingCursorHome) {
@@ -314,6 +338,11 @@ class ReplaySafeTerminalLogSanitizerImpl implements ReplaySafeTerminalLogSanitiz
continue;
}
if (this.alternateScreenActive && isC1SingleCharCursorControl(data[i])) {
i += 1;
continue;
}
if (this.protectingClearedHistory && isC1SingleCharCursorControl(data[i])) {
i += 1;
continue;
@@ -326,24 +355,32 @@ class ReplaySafeTerminalLogSanitizerImpl implements ReplaySafeTerminalLogSanitiz
}
if (data[i + 1] === "c") {
this.alternateScreenActive = false;
emitClearSeparator(false);
i += 2;
continue;
}
if (this.protectingClearedHistory && (data[i + 1] === "7" || data[i + 1] === "8")) {
i += 2;
continue;
}
if (this.protectingClearedHistory && isEscSingleCharCursorControl(data[i + 1])) {
i += 2;
continue;
if (!this.alternateScreenActive) {
if (this.protectingClearedHistory && (data[i + 1] === "7" || data[i + 1] === "8")) {
i += 2;
continue;
}
if (this.protectingClearedHistory && isEscSingleCharCursorControl(data[i + 1])) {
i += 2;
continue;
}
}
}
// Plain span: no branch above can trigger until the next ESC/C1
// control. Hop there with a native scan and append it in one slice.
if (this.alternateScreenActive) {
const nextControl = nextReplayControlCandidate(data, i + 1);
i = nextControl === -1 ? data.length : nextControl;
continue;
}
flushPendingCursorHome();
const nextControl = nextReplayControlCandidate(data, i + 1);
const end = nextControl === -1 ? data.length : nextControl;
@@ -361,6 +398,14 @@ class ReplaySafeTerminalLogSanitizerImpl implements ReplaySafeTerminalLogSanitiz
this.controlStringEscPending = false;
this.discardingCsi = false;
if (this.alternateScreenActive) {
this.pendingCursorHome = "";
this.pendingAfterCursorHome = "";
this.replaySafePendingAfterCursorHome = "";
this.pendingAfterCursorHomeOverflowed = false;
return "";
}
let output = "";
if (this.pendingCursorHome) {
output = this.pendingCursorHome + this.pendingAfterCursorHome;
@@ -0,0 +1,219 @@
/**
* Session-log alternate-screen filter.
*
* Fullscreen TUIs (vim/vi, less, htop, ...) switch to the terminal alternate
* buffer via DEC private modes 47 / 1047 / 1049. That buffer is UI chrome for
* the interactive session -- including vim's "~" empty-line markers -- and
* should not land in rendered (txt/html) session logs. Raw logs keep the
* original byte stream and must not use this filter. Mirror the trigger-filter
* approach: drop enter/leave sequences and everything between them, keeping
* main-screen shell output intact.
*/
const ESC = "\x1b";
/** 8-bit C1 CSI (0x9b); xterm accepts this as equivalent to ESC [. */
const C1_CSI = "\x9b";
/** 8-bit C1 ST (0x9c); valid OSC/DCS string terminator alongside BEL and ESC \\. */
const C1_ST = "\x9c";
const ALT_SCREEN_MODES = new Set([47, 1047, 1049]);
/**
* Incomplete CSI/OSC tails are normally tiny. Cap retention so an unterminated
* control cannot grow pendingEscape without bound or rescan ever-larger prefixes.
* Matches components/terminal/runtime/terminalControlSequenceLimits.ts.
*/
const MAX_PENDING_ESCAPE_CHARS = 64 * 1024;
function readCsiSequence(input, startIndex, scanFrom = 0) {
let bodyStart = -1;
if (input[startIndex] === ESC && input[startIndex + 1] === "[") {
bodyStart = startIndex + 2;
} else if (input[startIndex] === C1_CSI) {
bodyStart = startIndex + 1;
} else {
return null;
}
for (let index = Math.max(bodyStart, scanFrom); index < input.length; index += 1) {
const code = input.charCodeAt(index);
if (code >= 0x40 && code <= 0x7e) {
return {
sequence: input.slice(startIndex, index + 1),
end: index + 1,
};
}
}
return null;
}
function readOscSequence(input, startIndex, scanFrom = 0) {
if (input[startIndex] !== ESC || input[startIndex + 1] !== "]") return null;
for (let index = Math.max(startIndex + 2, scanFrom); index < input.length; index += 1) {
if (input[index] === "\x07" || input[index] === C1_ST) {
return {
sequence: input.slice(startIndex, index + 1),
end: index + 1,
};
}
if (input[index] === ESC && input[index + 1] === "\\") {
return {
sequence: input.slice(startIndex, index + 2),
end: index + 2,
};
}
}
return null;
}
function readEscapeSequence(input, startIndex, scanFrom = 0) {
if (input[startIndex] === C1_CSI) {
return readCsiSequence(input, startIndex, scanFrom);
}
if (input[startIndex] !== ESC) return null;
if (startIndex + 1 >= input.length) return null;
const next = input[startIndex + 1];
if (next === "[") return readCsiSequence(input, startIndex, scanFrom);
if (next === "]") return readOscSequence(input, startIndex, scanFrom);
// ECMA-48 escape sequences: ESC Intermediate* Final
// Intermediate 0x20-0x2F (e.g. "(" in ESC ( B); Final 0x30-0x7E
// (covers DECSC ESC 7, charset ESC ( B, RIS ESC c, ...).
for (let index = Math.max(startIndex + 1, scanFrom); index < input.length; index += 1) {
const code = input.charCodeAt(index);
if (code >= 0x20 && code <= 0x2f) {
continue;
}
if (code >= 0x30 && code <= 0x7e) {
return {
sequence: input.slice(startIndex, index + 1),
end: index + 1,
};
}
// Malformed escape: consume ESC alone so pendingEscape never stalls.
return {
sequence: input.slice(startIndex, startIndex + 1),
end: startIndex + 1,
};
}
return null;
}
/**
* Resume offset for the next chunk. OSC may end with a lone ESC awaiting `\`,
* so re-check that final ESC when more bytes arrive.
*/
function nextPendingScanFrom(remainder) {
if (
remainder.length >= 2 &&
remainder[0] === ESC &&
remainder[1] === "]" &&
remainder.endsWith(ESC)
) {
return remainder.length - 1;
}
return remainder.length;
}
function getAlternateScreenAction(sequence) {
// RIS (ESC c) restores the normal screen without a DECSET leave.
if (sequence === `${ESC}c`) return "leave";
let params = null;
if (sequence.startsWith(`${ESC}[`) && sequence.length >= 3) {
params = sequence.slice(2, -1);
} else if (sequence.startsWith(C1_CSI) && sequence.length >= 2) {
params = sequence.slice(1, -1);
} else {
return null;
}
const final = sequence.at(-1);
if (final !== "h" && final !== "l") return null;
if (!params.startsWith("?")) return null;
const modes = params
.slice(1)
.split(";")
.map((part) => Number.parseInt(part, 10))
.filter(Number.isFinite);
if (!modes.some((mode) => ALT_SCREEN_MODES.has(mode))) {
return null;
}
return final === "h" ? "enter" : "leave";
}
function createSessionLogAlternateScreenFilter() {
let alternateScreenActive = false;
let pendingEscape = "";
let pendingScanFrom = 0;
return {
append(chunk) {
if (!chunk) return "";
let input;
let resumeScanFrom = 0;
if (pendingEscape) {
input = `${pendingEscape}${chunk}`;
resumeScanFrom = pendingScanFrom;
pendingEscape = "";
pendingScanFrom = 0;
} else {
input = chunk;
}
let output = "";
for (let index = 0; index < input.length; index += 1) {
const ch = input[index];
if (ch !== ESC && ch !== C1_CSI) {
if (!alternateScreenActive) {
output += ch;
}
continue;
}
const sequence = readEscapeSequence(input, index, resumeScanFrom);
resumeScanFrom = 0;
if (!sequence) {
const remainder = input.slice(index);
if (remainder.length <= MAX_PENDING_ESCAPE_CHARS) {
pendingEscape = remainder;
pendingScanFrom = nextPendingScanFrom(remainder);
break;
}
// Incomplete control exceeded the retention cap: drop the introducer
// and resume so later bytes are not withheld forever.
continue;
}
const action = getAlternateScreenAction(sequence.sequence);
if (action === "enter") {
alternateScreenActive = true;
} else if (action === "leave") {
alternateScreenActive = false;
} else if (!alternateScreenActive) {
output += sequence.sequence;
}
index = sequence.end - 1;
}
return output;
},
finish() {
// Incomplete escape tails are controls, never printable shell text.
pendingEscape = "";
pendingScanFrom = 0;
return "";
},
};
}
module.exports = {
createSessionLogAlternateScreenFilter,
getAlternateScreenAction,
MAX_PENDING_ESCAPE_CHARS,
};
@@ -0,0 +1,123 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const {
createSessionLogAlternateScreenFilter,
MAX_PENDING_ESCAPE_CHARS,
} = require("./sessionLogAlternateScreenFilter.cjs");
test("drops vim alternate-screen content including empty-line markers", () => {
const filter = createSessionLogAlternateScreenFilter();
const output =
filter.append("before vim\r\n") +
filter.append("\x1b[?1049h~\r\n~\r\nfile content\r\n\x1b[?1049l") +
filter.append("after vim\r\n") +
filter.finish();
assert.equal(output, "before vim\r\nafter vim\r\n");
});
test("supports DECSET 47 / 1047 and split enter/leave sequences", () => {
const filter = createSessionLogAlternateScreenFilter();
let output = filter.append("shell\n\x1b[?47");
assert.equal(output, "shell\n");
output += filter.append("h~empty\n");
assert.equal(output, "shell\n");
output += filter.append("\x1b[?1;10");
assert.equal(output, "shell\n");
output += filter.append("47lback\n");
output += filter.finish();
assert.equal(output, "shell\nback\n");
});
test("keeps normal CSI sequences outside alternate screen", () => {
const filter = createSessionLogAlternateScreenFilter();
const output =
filter.append("red \x1b[31mtext\x1b[0m\n") + filter.finish();
assert.equal(output, "red \x1b[31mtext\x1b[0m\n");
});
test("consumes non-CSI ESC forms such as DECSC and charset designation", () => {
const filter = createSessionLogAlternateScreenFilter();
const output =
filter.append("save\x1b7 then \x1b(Bcharset\n") + filter.finish();
assert.equal(output, "save\x1b7 then \x1b(Bcharset\n");
});
test("buffers incomplete ESC intermediate sequences across chunks", () => {
const filter = createSessionLogAlternateScreenFilter();
let output = filter.append("pre\x1b(");
assert.equal(output, "pre");
output += filter.append("Bpost\n");
output += filter.finish();
assert.equal(output, "pre\x1b(Bpost\n");
});
test("recovers when an unterminated OSC exceeds the pending-control cap", () => {
const filter = createSessionLogAlternateScreenFilter();
const oversized = `\x1b]${"x".repeat(MAX_PENDING_ESCAPE_CHARS)}recovered\n`;
const output = filter.append(`pre${oversized}`) + filter.finish();
assert.equal(output, `pre]${"x".repeat(MAX_PENDING_ESCAPE_CHARS)}recovered\n`);
});
test("unterminated OSC grown one byte at a time recovers at the pending-control cap", () => {
const filter = createSessionLogAlternateScreenFilter();
let output = filter.append("pre\x1b]");
// Grow to the retention cap without rescanning the whole prefix each chunk.
for (let i = 0; i < MAX_PENDING_ESCAPE_CHARS - 2; i += 1) {
output += filter.append("x");
}
assert.equal(output, "pre");
// One more byte pushes past the cap: drop the ESC introducer and emit the rest.
output += filter.append("x");
output += filter.append("recovered\n");
output += filter.finish();
assert.equal(
output,
`pre]${"x".repeat(MAX_PENDING_ESCAPE_CHARS - 1)}recovered\n`,
);
});
test("OSC ST split across chunks after a trailing ESC completes without rescan stalls", () => {
const filter = createSessionLogAlternateScreenFilter();
let output = filter.append("pre\x1b]0;title\x1b");
assert.equal(output, "pre");
output += filter.append("\\after\n");
output += filter.finish();
assert.equal(output, "pre\x1b]0;title\x1b\\after\n");
});
test("RIS clears alternate screen so logging resumes", () => {
const filter = createSessionLogAlternateScreenFilter();
const output =
filter.append("before\n\x1b[?1049h~tui\n\x1bc") +
filter.append("after reset\n") +
filter.finish();
assert.equal(output, "before\nafter reset\n");
});
test("recognizes 8-bit C1 CSI alternate-screen enter/leave", () => {
const filter = createSessionLogAlternateScreenFilter();
const output =
filter.append("before\n\x9b?1049h~tui\n\x9b?1049l") +
filter.append("after\n") +
filter.finish();
assert.equal(output, "before\nafter\n");
});
test("recognizes C1 ST as an OSC string terminator", () => {
const filter = createSessionLogAlternateScreenFilter();
const output =
filter.append("pre\x1b]0;title\x9cafter\n") + filter.finish();
assert.equal(output, "pre\x1b]0;title\x9cafter\n");
});
test("C1-ST-terminated OSC does not truncate later shell output", () => {
const filter = createSessionLogAlternateScreenFilter();
const output =
filter.append("shell\n\x1b]0;vim\x9c\x1b[?1049h~\n\x1b[?1049lback\n") +
filter.finish();
assert.equal(output, "shell\n\x1b]0;vim\x9cback\n");
});
+9 -2
View File
@@ -13,6 +13,7 @@ const {
} = require("./sessionLogsBridge.cjs");
const { createTerminalTextRenderer } = require("./terminalLogSanitizer.cjs");
const { createProgrammaticCommandLogRewriter } = require("./programmaticCommandLog.cjs");
const { createSessionLogAlternateScreenFilter } = require("./sessionLogAlternateScreenFilter.cjs");
// Active log streams keyed by sessionId
const activeStreams = new Map();
@@ -221,6 +222,8 @@ function createStreamEntry(sessionId, opts) {
hostLabel,
startTime,
buffer: "",
// Raw logs keep the original byte stream; filter only rendered txt/html.
alternateScreenFilter: isRaw ? null : createSessionLogAlternateScreenFilter(),
programmaticCommandLogRewriter: createProgrammaticCommandLogRewriter(),
sudoAutofillRewrites: [],
sudoAutofillPending: "",
@@ -392,9 +395,12 @@ function appendData(sessionId, dataChunk) {
}
function appendBufferedData(entry, dataChunk) {
const readableData = entry.programmaticCommandLogRewriter
? entry.programmaticCommandLogRewriter.append(dataChunk)
const withoutAlternateScreen = entry.alternateScreenFilter
? entry.alternateScreenFilter.append(dataChunk)
: dataChunk;
const readableData = entry.programmaticCommandLogRewriter
? entry.programmaticCommandLogRewriter.append(withoutAlternateScreen)
: withoutAlternateScreen;
entry.buffer += sanitizeSudoAutofillLogData(entry, readableData);
// Immediate flush if buffer is large
@@ -445,6 +451,7 @@ async function stopStream(sessionId, expectedToken) {
entry.pendingInitialLineLeadingCarriageReturn = false;
appendBufferedData(entry, "\n\r");
}
entry.alternateScreenFilter?.finish();
const readablePending = entry.programmaticCommandLogRewriter?.finish();
if (readablePending) {
entry.buffer += sanitizeSudoAutofillLogData(entry, readablePending);
@@ -853,6 +853,41 @@ test("raw stream keeps original bytes when timestamps are enabled", async () =>
}
});
test("txt streams omit vim alternate-screen content; raw keeps the byte stream (issue #2871)", async () => {
const directory = path.join(TEMP_ROOT, `stream-alt-screen-${Date.now()}-${Math.random().toString(16).slice(2)}`);
const rawSessionId = `raw-${Date.now()}-${Math.random().toString(16).slice(2)}`;
const txtSessionId = `txt-${Date.now()}-${Math.random().toString(16).slice(2)}`;
const vimChunk = "before\r\n\x1b[?1049h~\r\n~\r\nbuffer line\r\n\x1b[?1049lafter\r\n";
try {
startStream(rawSessionId, {
hostLabel: "raw-host",
hostname: "raw.example",
directory,
format: "raw",
startTime: Date.UTC(2026, 0, 2, 3, 4, 5),
});
appendData(rawSessionId, vimChunk);
const rawPath = await stopStream(rawSessionId);
assert.equal(fs.readFileSync(rawPath, "utf8"), vimChunk);
startStream(txtSessionId, {
hostLabel: "txt-host",
hostname: "txt.example",
directory,
format: "txt",
startTime: Date.UTC(2026, 0, 2, 3, 4, 6),
});
appendData(txtSessionId, vimChunk);
const txtPath = await stopStream(txtSessionId);
assert.equal(fs.readFileSync(txtPath, "utf8"), "before\nafter");
} finally {
await stopStream(rawSessionId);
await stopStream(txtSessionId);
fs.rmSync(directory, { recursive: true, force: true });
}
});
async function waitForFileContent(directory, expectedContent) {
const deadline = Date.now() + 3000;
let lastContent = "";
+9 -2
View File
@@ -7,6 +7,8 @@
* line-editing output actually leaves on screen.
*/
const { createSessionLogAlternateScreenFilter } = require("./sessionLogAlternateScreenFilter.cjs");
const CSI_FINAL_RE = /[@-~]/;
const DEFAULT_FOREGROUND = "#d4d4d4";
const DEFAULT_BACKGROUND = "#1e1e1e";
@@ -425,15 +427,20 @@ class TerminalTextRenderer {
}
}
function filterAlternateScreenForLog(terminalData) {
const filter = createSessionLogAlternateScreenFilter();
return filter.append(terminalData || "") + filter.finish();
}
function terminalDataToPlainText(terminalData) {
const renderer = new TerminalTextRenderer();
renderer.feed(terminalData || "");
renderer.feed(filterAlternateScreenForLog(terminalData));
return renderer.finish();
}
function terminalDataToHtmlContent(terminalData) {
const renderer = new TerminalTextRenderer();
renderer.feed(terminalData || "");
renderer.feed(filterAlternateScreenForLog(terminalData));
renderer.finish();
return renderer.toHtmlContent();
}
@@ -151,3 +151,10 @@ test("standalone ED3 preserves current visible screen", () => {
"before\n\nscreen\nafter",
);
});
test("plain text rendering drops vim alternate-screen empty-line markers", () => {
assert.equal(
terminalDataToPlainText("shell\n\x1b[?1049h~\r\n~\r\nbuffer\r\n\x1b[?1049lafter\n"),
"shell\nafter",
);
});