Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 83 additions & 0 deletions apps/server/src/terminal/Layers/Manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -469,6 +469,89 @@ describe("TerminalManager", () => {
manager.dispose();
});

it("strips replay-unsafe terminal query and reply sequences from persisted history", async () => {
const { manager, ptyAdapter } = makeManager();
await manager.open(openInput());
const process = ptyAdapter.processes[0];
expect(process).toBeDefined();
if (!process) return;

process.emitData("prompt ");
process.emitData("\u001b[32mok\u001b[0m ");
process.emitData("\u001b]11;rgb:ffff/ffff/ffff\u0007");
process.emitData("\u001b[1;1R");
process.emitData("done\n");

await manager.close({ threadId: "thread-1" });

const reopened = await manager.open(openInput());
expect(reopened.history).toBe("prompt \u001b[32mok\u001b[0m done\n");

manager.dispose();
});

it("preserves clear and style control sequences while dropping chunk-split query traffic", async () => {
const { manager, ptyAdapter } = makeManager();
await manager.open(openInput());
const process = ptyAdapter.processes[0];
expect(process).toBeDefined();
if (!process) return;

process.emitData("before clear\n");
process.emitData("\u001b[H\u001b[2J");
process.emitData("prompt ");
process.emitData("\u001b]11;");
process.emitData("rgb:ffff/ffff/ffff\u0007\u001b[1;1");
process.emitData("R\u001b[36mdone\u001b[0m\n");

await manager.close({ threadId: "thread-1" });

const reopened = await manager.open(openInput());
expect(reopened.history).toBe(
"before clear\n\u001b[H\u001b[2Jprompt \u001b[36mdone\u001b[0m\n",
);

manager.dispose();
});

it("does not leak final bytes from ESC sequences with intermediate bytes", async () => {
const { manager, ptyAdapter } = makeManager();
await manager.open(openInput());
const process = ptyAdapter.processes[0];
expect(process).toBeDefined();
if (!process) return;

process.emitData("before ");
process.emitData("\u001b(B");
process.emitData("after\n");

await manager.close({ threadId: "thread-1" });

const reopened = await manager.open(openInput());
expect(reopened.history).toBe("before \u001b(Bafter\n");

manager.dispose();
});

it("preserves chunk-split ESC sequences with intermediate bytes without leaking final bytes", async () => {
const { manager, ptyAdapter } = makeManager();
await manager.open(openInput());
const process = ptyAdapter.processes[0];
expect(process).toBeDefined();
if (!process) return;

process.emitData("before ");
process.emitData("\u001b(");
process.emitData("Bafter\n");

await manager.close({ threadId: "thread-1" });

const reopened = await manager.open(openInput());
expect(reopened.history).toBe("before \u001b(Bafter\n");

manager.dispose();
});

it("deletes history file when close(deleteHistory=true)", async () => {
const { manager, ptyAdapter, logsDir } = makeManager();
await manager.open(openInput());
Expand Down
193 changes: 191 additions & 2 deletions apps/server/src/terminal/Layers/Manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,180 @@ function capHistory(history: string, maxLines: number): string {
return hasTrailingNewline ? `${capped}\n` : capped;
}

function isCsiFinalByte(codePoint: number): boolean {
return codePoint >= 0x40 && codePoint <= 0x7e;
}

function shouldStripCsiSequence(body: string, finalByte: string): boolean {
if (finalByte === "n") {
return true;
}
if (finalByte === "R" && /^[0-9;?]*$/.test(body)) {
return true;
}
if (finalByte === "c" && /^[>0-9;?]*$/.test(body)) {
return true;
}
return false;
}

function shouldStripOscSequence(content: string): boolean {
return /^(10|11|12);(?:\?|rgb:)/.test(content);
}

function stripStringTerminator(value: string): string {
if (value.endsWith("\u001b\\")) {
return value.slice(0, -2);
}
const lastCharacter = value.at(-1);
if (lastCharacter === "\u0007" || lastCharacter === "\u009c") {
return value.slice(0, -1);
}
return value;
}

function findStringTerminatorIndex(input: string, start: number): number | null {
for (let index = start; index < input.length; index += 1) {
const codePoint = input.charCodeAt(index);
if (codePoint === 0x07 || codePoint === 0x9c) {
return index + 1;
}
if (codePoint === 0x1b && input.charCodeAt(index + 1) === 0x5c) {
return index + 2;
}
}
return null;
}

function isEscapeIntermediateByte(codePoint: number): boolean {
return codePoint >= 0x20 && codePoint <= 0x2f;
}

function isEscapeFinalByte(codePoint: number): boolean {
return codePoint >= 0x30 && codePoint <= 0x7e;
}

function findEscapeSequenceEndIndex(input: string, start: number): number | null {
let cursor = start;
while (cursor < input.length && isEscapeIntermediateByte(input.charCodeAt(cursor))) {
cursor += 1;
}
if (cursor >= input.length) {
return null;
}
return isEscapeFinalByte(input.charCodeAt(cursor)) ? cursor + 1 : start + 1;
}

function sanitizeTerminalHistoryChunk(
pendingControlSequence: string,
data: string,
): { visibleText: string; pendingControlSequence: string } {
const input = `${pendingControlSequence}${data}`;
let visibleText = "";
let index = 0;

const append = (value: string) => {
visibleText += value;
};

while (index < input.length) {
const codePoint = input.charCodeAt(index);

if (codePoint === 0x1b) {
const nextCodePoint = input.charCodeAt(index + 1);
if (Number.isNaN(nextCodePoint)) {
return { visibleText, pendingControlSequence: input.slice(index) };
}

if (nextCodePoint === 0x5b) {
let cursor = index + 2;
while (cursor < input.length) {
if (isCsiFinalByte(input.charCodeAt(cursor))) {
const sequence = input.slice(index, cursor + 1);
const body = input.slice(index + 2, cursor);
if (!shouldStripCsiSequence(body, input[cursor] ?? "")) {
append(sequence);
}
index = cursor + 1;
break;
}
cursor += 1;
}
if (cursor >= input.length) {
return { visibleText, pendingControlSequence: input.slice(index) };
}
continue;
}

if (
nextCodePoint === 0x5d ||
nextCodePoint === 0x50 ||
nextCodePoint === 0x5e ||
nextCodePoint === 0x5f
) {
const terminatorIndex = findStringTerminatorIndex(input, index + 2);
if (terminatorIndex === null) {
return { visibleText, pendingControlSequence: input.slice(index) };
}
const sequence = input.slice(index, terminatorIndex);
const content = stripStringTerminator(input.slice(index + 2, terminatorIndex));
if (nextCodePoint !== 0x5d || !shouldStripOscSequence(content)) {
append(sequence);
}
index = terminatorIndex;
continue;
}

const escapeSequenceEndIndex = findEscapeSequenceEndIndex(input, index + 1);
if (escapeSequenceEndIndex === null) {
return { visibleText, pendingControlSequence: input.slice(index) };
}
append(input.slice(index, escapeSequenceEndIndex));
index = escapeSequenceEndIndex;
continue;
}

if (codePoint === 0x9b) {
let cursor = index + 1;
while (cursor < input.length) {
if (isCsiFinalByte(input.charCodeAt(cursor))) {
const sequence = input.slice(index, cursor + 1);
const body = input.slice(index + 1, cursor);
if (!shouldStripCsiSequence(body, input[cursor] ?? "")) {
append(sequence);
}
index = cursor + 1;
break;
}
cursor += 1;
}
if (cursor >= input.length) {
return { visibleText, pendingControlSequence: input.slice(index) };
}
continue;
}

if (codePoint === 0x9d || codePoint === 0x90 || codePoint === 0x9e || codePoint === 0x9f) {
const terminatorIndex = findStringTerminatorIndex(input, index + 1);
if (terminatorIndex === null) {
return { visibleText, pendingControlSequence: input.slice(index) };
}
const sequence = input.slice(index, terminatorIndex);
const content = stripStringTerminator(input.slice(index + 1, terminatorIndex));
if (codePoint !== 0x9d || !shouldStripOscSequence(content)) {
append(sequence);
}
index = terminatorIndex;
continue;
}

append(input[index] ?? "");
index += 1;
}

return { visibleText, pendingControlSequence: "" };
}

function legacySafeThreadId(threadId: string): string {
return threadId.replace(/[^a-zA-Z0-9._-]/g, "_");
}
Expand Down Expand Up @@ -378,6 +552,7 @@ export class TerminalManagerRuntime extends EventEmitter<TerminalManagerEvents>
status: "starting",
pid: null,
history,
pendingHistoryControlSequence: "",
exitCode: null,
exitSignal: null,
updatedAt: new Date().toISOString(),
Expand Down Expand Up @@ -407,10 +582,12 @@ export class TerminalManagerRuntime extends EventEmitter<TerminalManagerEvents>
existing.cwd = input.cwd;
existing.runtimeEnv = nextRuntimeEnv;
existing.history = "";
existing.pendingHistoryControlSequence = "";
await this.persistHistory(existing.threadId, existing.terminalId, existing.history);
} else if (existing.status === "exited" || existing.status === "error") {
existing.runtimeEnv = nextRuntimeEnv;
existing.history = "";
existing.pendingHistoryControlSequence = "";
await this.persistHistory(existing.threadId, existing.terminalId, existing.history);
} else if (currentRuntimeEnv !== nextRuntimeEnv) {
existing.runtimeEnv = nextRuntimeEnv;
Expand Down Expand Up @@ -469,6 +646,7 @@ export class TerminalManagerRuntime extends EventEmitter<TerminalManagerEvents>
await this.runWithThreadLock(input.threadId, async () => {
const session = this.requireSession(input.threadId, input.terminalId);
session.history = "";
session.pendingHistoryControlSequence = "";
session.updatedAt = new Date().toISOString();
await this.persistHistory(input.threadId, input.terminalId, session.history);
this.emitEvent({
Expand Down Expand Up @@ -497,6 +675,7 @@ export class TerminalManagerRuntime extends EventEmitter<TerminalManagerEvents>
status: "starting",
pid: null,
history: "",
pendingHistoryControlSequence: "",
exitCode: null,
exitSignal: null,
updatedAt: new Date().toISOString(),
Expand All @@ -520,6 +699,7 @@ export class TerminalManagerRuntime extends EventEmitter<TerminalManagerEvents>
const rows = input.rows ?? session.rows;

session.history = "";
session.pendingHistoryControlSequence = "";
await this.persistHistory(input.threadId, input.terminalId, session.history);
await this.startSession(session, { ...input, cols, rows }, "restarted");
return this.snapshot(session);
Expand Down Expand Up @@ -694,9 +874,16 @@ export class TerminalManagerRuntime extends EventEmitter<TerminalManagerEvents>
}

private onProcessData(session: TerminalSessionState, data: string): void {
session.history = capHistory(`${session.history}${data}`, this.historyLineLimit);
const sanitized = sanitizeTerminalHistoryChunk(session.pendingHistoryControlSequence, data);
session.pendingHistoryControlSequence = sanitized.pendingControlSequence;
if (sanitized.visibleText.length > 0) {
session.history = capHistory(
`${session.history}${sanitized.visibleText}`,
this.historyLineLimit,
);
this.queuePersist(session.threadId, session.terminalId, session.history);
}
session.updatedAt = new Date().toISOString();
this.queuePersist(session.threadId, session.terminalId, session.history);
this.emitEvent({
type: "output",
threadId: session.threadId,
Expand All @@ -713,6 +900,7 @@ export class TerminalManagerRuntime extends EventEmitter<TerminalManagerEvents>
session.pid = null;
session.hasRunningSubprocess = false;
session.status = "exited";
session.pendingHistoryControlSequence = "";
session.exitCode = Number.isInteger(event.exitCode) ? event.exitCode : null;
session.exitSignal = Number.isInteger(event.signal) ? event.signal : null;
session.updatedAt = new Date().toISOString();
Expand All @@ -736,6 +924,7 @@ export class TerminalManagerRuntime extends EventEmitter<TerminalManagerEvents>
session.pid = null;
session.hasRunningSubprocess = false;
session.status = "exited";
session.pendingHistoryControlSequence = "";
session.updatedAt = new Date().toISOString();
this.killProcessWithEscalation(process, session.threadId, session.terminalId);
this.evictInactiveSessionsIfNeeded();
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/terminal/Services/Manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export interface TerminalSessionState {
status: TerminalSessionStatus;
pid: number | null;
history: string;
pendingHistoryControlSequence: string;
exitCode: number | null;
exitSignal: number | null;
updatedAt: string;
Expand Down