| ... | @@ -254,6 +254,180 @@ function capHistory(history: string, maxLines: number): string { | ... | @@ -254,6 +254,180 @@ function capHistory(history: string, maxLines: number): string { |
| 254 | return hasTrailingNewline ? `${capped}\n` : capped; | 254 | return hasTrailingNewline ? `${capped}\n` : capped; |
| 255 | } | 255 | } |
| 256 | | 256 | |
| | 257 | function isCsiFinalByte(codePoint: number): boolean { |
| | 258 | return codePoint >= 0x40 && codePoint <= 0x7e; |
| | 259 | } |
| | 260 | |
| | 261 | function shouldStripCsiSequence(body: string, finalByte: string): boolean { |
| | 262 | if (finalByte === "n") { |
| | 263 | return true; |
| | 264 | } |
| | 265 | if (finalByte === "R" && /^[0-9;?]*$/.test(body)) { |
| | 266 | return true; |
| | 267 | } |
| | 268 | if (finalByte === "c" && /^[>0-9;?]*$/.test(body)) { |
| | 269 | return true; |
| | 270 | } |
| | 271 | return false; |
| | 272 | } |
| | 273 | |
| | 274 | function shouldStripOscSequence(content: string): boolean { |
| | 275 | return /^(10|11|12);(?:\?|rgb:)/.test(content); |
| | 276 | } |
| | 277 | |
| | 278 | function stripStringTerminator(value: string): string { |
| | 279 | if (value.endsWith("\u001b\\")) { |
| | 280 | return value.slice(0, -2); |
| | 281 | } |
| | 282 | const lastCharacter = value.at(-1); |
| | 283 | if (lastCharacter === "\u0007" || lastCharacter === "\u009c") { |
| | 284 | return value.slice(0, -1); |
| | 285 | } |
| | 286 | return value; |
| | 287 | } |
| | 288 | |
| | 289 | function findStringTerminatorIndex(input: string, start: number): number | null { |
| | 290 | for (let index = start; index < input.length; index += 1) { |
| | 291 | const codePoint = input.charCodeAt(index); |
| | 292 | if (codePoint === 0x07 || codePoint === 0x9c) { |
| | 293 | return index + 1; |
| | 294 | } |
| | 295 | if (codePoint === 0x1b && input.charCodeAt(index + 1) === 0x5c) { |
| | 296 | return index + 2; |
| | 297 | } |
| | 298 | } |
| | 299 | return null; |
| | 300 | } |
| | 301 | |
| | 302 | function isEscapeIntermediateByte(codePoint: number): boolean { |
| | 303 | return codePoint >= 0x20 && codePoint <= 0x2f; |
| | 304 | } |
| | 305 | |
| | 306 | function isEscapeFinalByte(codePoint: number): boolean { |
| | 307 | return codePoint >= 0x30 && codePoint <= 0x7e; |
| | 308 | } |
| | 309 | |
| | 310 | function findEscapeSequenceEndIndex(input: string, start: number): number | null { |
| | 311 | let cursor = start; |
| | 312 | while (cursor < input.length && isEscapeIntermediateByte(input.charCodeAt(cursor))) { |
| | 313 | cursor += 1; |
| | 314 | } |
| | 315 | if (cursor >= input.length) { |
| | 316 | return null; |
| | 317 | } |
| | 318 | return isEscapeFinalByte(input.charCodeAt(cursor)) ? cursor + 1 : start + 1; |
| | 319 | } |
| | 320 | |
| | 321 | function sanitizeTerminalHistoryChunk( |
| | 322 | pendingControlSequence: string, |
| | 323 | data: string, |
| | 324 | ): { visibleText: string; pendingControlSequence: string } { |
| | 325 | const input = `${pendingControlSequence}${data}`; |
| | 326 | let visibleText = ""; |
| | 327 | let index = 0; |
| | 328 | |
| | 329 | const append = (value: string) => { |
| | 330 | visibleText += value; |
| | 331 | }; |
| | 332 | |
| | 333 | while (index < input.length) { |
| | 334 | const codePoint = input.charCodeAt(index); |
| | 335 | |
| | 336 | if (codePoint === 0x1b) { |
| | 337 | const nextCodePoint = input.charCodeAt(index + 1); |
| | 338 | if (Number.isNaN(nextCodePoint)) { |
| | 339 | return { visibleText, pendingControlSequence: input.slice(index) }; |
| | 340 | } |
| | 341 | |
| | 342 | if (nextCodePoint === 0x5b) { |
| | 343 | let cursor = index + 2; |
| | 344 | while (cursor < input.length) { |
| | 345 | if (isCsiFinalByte(input.charCodeAt(cursor))) { |
| | 346 | const sequence = input.slice(index, cursor + 1); |
| | 347 | const body = input.slice(index + 2, cursor); |
| | 348 | if (!shouldStripCsiSequence(body, input[cursor] ?? "")) { |
| | 349 | append(sequence); |
| | 350 | } |
| | 351 | index = cursor + 1; |
| | 352 | break; |
| | 353 | } |
| | 354 | cursor += 1; |
| | 355 | } |
| | 356 | if (cursor >= input.length) { |
| | 357 | return { visibleText, pendingControlSequence: input.slice(index) }; |
| | 358 | } |
| | 359 | continue; |
| | 360 | } |
| | 361 | |
| | 362 | if ( |
| | 363 | nextCodePoint === 0x5d || |
| | 364 | nextCodePoint === 0x50 || |
| | 365 | nextCodePoint === 0x5e || |
| | 366 | nextCodePoint === 0x5f |
| | 367 | ) { |
| | 368 | const terminatorIndex = findStringTerminatorIndex(input, index + 2); |
| | 369 | if (terminatorIndex === null) { |
| | 370 | return { visibleText, pendingControlSequence: input.slice(index) }; |
| | 371 | } |
| | 372 | const sequence = input.slice(index, terminatorIndex); |
| | 373 | const content = stripStringTerminator(input.slice(index + 2, terminatorIndex)); |
| | 374 | if (nextCodePoint !== 0x5d || !shouldStripOscSequence(content)) { |
| | 375 | append(sequence); |
| | 376 | } |
| | 377 | index = terminatorIndex; |
| | 378 | continue; |
| | 379 | } |
| | 380 | |
| | 381 | const escapeSequenceEndIndex = findEscapeSequenceEndIndex(input, index + 1); |
| | 382 | if (escapeSequenceEndIndex === null) { |
| | 383 | return { visibleText, pendingControlSequence: input.slice(index) }; |
| | 384 | } |
| | 385 | append(input.slice(index, escapeSequenceEndIndex)); |
| | 386 | index = escapeSequenceEndIndex; |
| | 387 | continue; |
| | 388 | } |
| | 389 | |
| | 390 | if (codePoint === 0x9b) { |
| | 391 | let cursor = index + 1; |
| | 392 | while (cursor < input.length) { |
| | 393 | if (isCsiFinalByte(input.charCodeAt(cursor))) { |
| | 394 | const sequence = input.slice(index, cursor + 1); |
| | 395 | const body = input.slice(index + 1, cursor); |
| | 396 | if (!shouldStripCsiSequence(body, input[cursor] ?? "")) { |
| | 397 | append(sequence); |
| | 398 | } |
| | 399 | index = cursor + 1; |
| | 400 | break; |
| | 401 | } |
| | 402 | cursor += 1; |
| | 403 | } |
| | 404 | if (cursor >= input.length) { |
| | 405 | return { visibleText, pendingControlSequence: input.slice(index) }; |
| | 406 | } |
| | 407 | continue; |
| | 408 | } |
| | 409 | |
| | 410 | if (codePoint === 0x9d || codePoint === 0x90 || codePoint === 0x9e || codePoint === 0x9f) { |
| | 411 | const terminatorIndex = findStringTerminatorIndex(input, index + 1); |
| | 412 | if (terminatorIndex === null) { |
| | 413 | return { visibleText, pendingControlSequence: input.slice(index) }; |
| | 414 | } |
| | 415 | const sequence = input.slice(index, terminatorIndex); |
| | 416 | const content = stripStringTerminator(input.slice(index + 1, terminatorIndex)); |
| | 417 | if (codePoint !== 0x9d || !shouldStripOscSequence(content)) { |
| | 418 | append(sequence); |
| | 419 | } |
| | 420 | index = terminatorIndex; |
| | 421 | continue; |
| | 422 | } |
| | 423 | |
| | 424 | append(input[index] ?? ""); |
| | 425 | index += 1; |
| | 426 | } |
| | 427 | |
| | 428 | return { visibleText, pendingControlSequence: "" }; |
| | 429 | } |
| | 430 | |
| 257 | function legacySafeThreadId(threadId: string): string { | 431 | function legacySafeThreadId(threadId: string): string { |
| 258 | return threadId.replace(/[^a-zA-Z0-9._-]/g, "_"); | 432 | return threadId.replace(/[^a-zA-Z0-9._-]/g, "_"); |
| 259 | } | 433 | } |
| ... | @@ -378,6 +552,7 @@ export class TerminalManagerRuntime extends EventEmitter<TerminalManagerEvents> | ... | @@ -378,6 +552,7 @@ export class TerminalManagerRuntime extends EventEmitter<TerminalManagerEvents> |
| 378 | status: "starting", | 552 | status: "starting", |
| 379 | pid: null, | 553 | pid: null, |
| 380 | history, | 554 | history, |
| | 555 | pendingHistoryControlSequence: "", |
| 381 | exitCode: null, | 556 | exitCode: null, |
| 382 | exitSignal: null, | 557 | exitSignal: null, |
| 383 | updatedAt: new Date().toISOString(), | 558 | updatedAt: new Date().toISOString(), |
| ... | @@ -407,10 +582,12 @@ export class TerminalManagerRuntime extends EventEmitter<TerminalManagerEvents> | ... | @@ -407,10 +582,12 @@ export class TerminalManagerRuntime extends EventEmitter<TerminalManagerEvents> |
| 407 | existing.cwd = input.cwd; | 582 | existing.cwd = input.cwd; |
| 408 | existing.runtimeEnv = nextRuntimeEnv; | 583 | existing.runtimeEnv = nextRuntimeEnv; |
| 409 | existing.history = ""; | 584 | existing.history = ""; |
| | 585 | existing.pendingHistoryControlSequence = ""; |
| 410 | await this.persistHistory(existing.threadId, existing.terminalId, existing.history); | 586 | await this.persistHistory(existing.threadId, existing.terminalId, existing.history); |
| 411 | } else if (existing.status === "exited" || existing.status === "error") { | 587 | } else if (existing.status === "exited" || existing.status === "error") { |
| 412 | existing.runtimeEnv = nextRuntimeEnv; | 588 | existing.runtimeEnv = nextRuntimeEnv; |
| 413 | existing.history = ""; | 589 | existing.history = ""; |
| | 590 | existing.pendingHistoryControlSequence = ""; |
| 414 | await this.persistHistory(existing.threadId, existing.terminalId, existing.history); | 591 | await this.persistHistory(existing.threadId, existing.terminalId, existing.history); |
| 415 | } else if (currentRuntimeEnv !== nextRuntimeEnv) { | 592 | } else if (currentRuntimeEnv !== nextRuntimeEnv) { |
| 416 | existing.runtimeEnv = nextRuntimeEnv; | 593 | existing.runtimeEnv = nextRuntimeEnv; |
| ... | @@ -469,6 +646,7 @@ export class TerminalManagerRuntime extends EventEmitter<TerminalManagerEvents> | ... | @@ -469,6 +646,7 @@ export class TerminalManagerRuntime extends EventEmitter<TerminalManagerEvents> |
| 469 | await this.runWithThreadLock(input.threadId, async () => { | 646 | await this.runWithThreadLock(input.threadId, async () => { |
| 470 | const session = this.requireSession(input.threadId, input.terminalId); | 647 | const session = this.requireSession(input.threadId, input.terminalId); |
| 471 | session.history = ""; | 648 | session.history = ""; |
| | 649 | session.pendingHistoryControlSequence = ""; |
| 472 | session.updatedAt = new Date().toISOString(); | 650 | session.updatedAt = new Date().toISOString(); |
| 473 | await this.persistHistory(input.threadId, input.terminalId, session.history); | 651 | await this.persistHistory(input.threadId, input.terminalId, session.history); |
| 474 | this.emitEvent({ | 652 | this.emitEvent({ |
| ... | @@ -497,6 +675,7 @@ export class TerminalManagerRuntime extends EventEmitter<TerminalManagerEvents> | ... | @@ -497,6 +675,7 @@ export class TerminalManagerRuntime extends EventEmitter<TerminalManagerEvents> |
| 497 | status: "starting", | 675 | status: "starting", |
| 498 | pid: null, | 676 | pid: null, |
| 499 | history: "", | 677 | history: "", |
| | 678 | pendingHistoryControlSequence: "", |
| 500 | exitCode: null, | 679 | exitCode: null, |
| 501 | exitSignal: null, | 680 | exitSignal: null, |
| 502 | updatedAt: new Date().toISOString(), | 681 | updatedAt: new Date().toISOString(), |
| ... | @@ -520,6 +699,7 @@ export class TerminalManagerRuntime extends EventEmitter<TerminalManagerEvents> | ... | @@ -520,6 +699,7 @@ export class TerminalManagerRuntime extends EventEmitter<TerminalManagerEvents> |
| 520 | const rows = input.rows ?? session.rows; | 699 | const rows = input.rows ?? session.rows; |
| 521 | | 700 | |
| 522 | session.history = ""; | 701 | session.history = ""; |
| | 702 | session.pendingHistoryControlSequence = ""; |
| 523 | await this.persistHistory(input.threadId, input.terminalId, session.history); | 703 | await this.persistHistory(input.threadId, input.terminalId, session.history); |
| 524 | await this.startSession(session, { ...input, cols, rows }, "restarted"); | 704 | await this.startSession(session, { ...input, cols, rows }, "restarted"); |
| 525 | return this.snapshot(session); | 705 | return this.snapshot(session); |
| ... | @@ -694,9 +874,16 @@ export class TerminalManagerRuntime extends EventEmitter<TerminalManagerEvents> | ... | @@ -694,9 +874,16 @@ export class TerminalManagerRuntime extends EventEmitter<TerminalManagerEvents> |
| 694 | } | 874 | } |
| 695 | | 875 | |
| 696 | private onProcessData(session: TerminalSessionState, data: string): void { | 876 | private onProcessData(session: TerminalSessionState, data: string): void { |
| 697 | session.history = capHistory(`${session.history}${data}`, this.historyLineLimit); | 877 | const sanitized = sanitizeTerminalHistoryChunk(session.pendingHistoryControlSequence, data); |
| | 878 | session.pendingHistoryControlSequence = sanitized.pendingControlSequence; |
| | 879 | if (sanitized.visibleText.length > 0) { |
| | 880 | session.history = capHistory( |
| | 881 | `${session.history}${sanitized.visibleText}`, |
| | 882 | this.historyLineLimit, |
| | 883 | ); |
| | 884 | this.queuePersist(session.threadId, session.terminalId, session.history); |
| | 885 | } |
| 698 | session.updatedAt = new Date().toISOString(); | 886 | session.updatedAt = new Date().toISOString(); |
| 699 | this.queuePersist(session.threadId, session.terminalId, session.history); | | |
| 700 | this.emitEvent({ | 887 | this.emitEvent({ |
| 701 | type: "output", | 888 | type: "output", |
| 702 | threadId: session.threadId, | 889 | threadId: session.threadId, |
| ... | @@ -713,6 +900,7 @@ export class TerminalManagerRuntime extends EventEmitter<TerminalManagerEvents> | ... | @@ -713,6 +900,7 @@ export class TerminalManagerRuntime extends EventEmitter<TerminalManagerEvents> |
| 713 | session.pid = null; | 900 | session.pid = null; |
| 714 | session.hasRunningSubprocess = false; | 901 | session.hasRunningSubprocess = false; |
| 715 | session.status = "exited"; | 902 | session.status = "exited"; |
| | 903 | session.pendingHistoryControlSequence = ""; |
| 716 | session.exitCode = Number.isInteger(event.exitCode) ? event.exitCode : null; | 904 | session.exitCode = Number.isInteger(event.exitCode) ? event.exitCode : null; |
| 717 | session.exitSignal = Number.isInteger(event.signal) ? event.signal : null; | 905 | session.exitSignal = Number.isInteger(event.signal) ? event.signal : null; |
| 718 | session.updatedAt = new Date().toISOString(); | 906 | session.updatedAt = new Date().toISOString(); |
| ... | @@ -736,6 +924,7 @@ export class TerminalManagerRuntime extends EventEmitter<TerminalManagerEvents> | ... | @@ -736,6 +924,7 @@ export class TerminalManagerRuntime extends EventEmitter<TerminalManagerEvents> |
| 736 | session.pid = null; | 924 | session.pid = null; |
| 737 | session.hasRunningSubprocess = false; | 925 | session.hasRunningSubprocess = false; |
| 738 | session.status = "exited"; | 926 | session.status = "exited"; |
| | 927 | session.pendingHistoryControlSequence = ""; |
| 739 | session.updatedAt = new Date().toISOString(); | 928 | session.updatedAt = new Date().toISOString(); |
| 740 | this.killProcessWithEscalation(process, session.threadId, session.terminalId); | 929 | this.killProcessWithEscalation(process, session.threadId, session.terminalId); |
| 741 | this.evictInactiveSessionsIfNeeded(); | 930 | this.evictInactiveSessionsIfNeeded(); |