| ... | ... | @@ -254,6 +254,180 @@ function capHistory(history: string, maxLines: number): string { |
| 254 | 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 | 431 | function legacySafeThreadId(threadId: string): string { |
| 258 | 432 | return threadId.replace(/[^a-zA-Z0-9._-]/g, "_"); |
| 259 | 433 | } |
| ... | ... | @@ -378,6 +552,7 @@ export class TerminalManagerRuntime extends EventEmitter<TerminalManagerEvents> |
| 378 | 552 | status: "starting", |
| 379 | 553 | pid: null, |
| 380 | 554 | history, |
| 555 | pendingHistoryControlSequence: "", |
| 381 | 556 | exitCode: null, |
| 382 | 557 | exitSignal: null, |
| 383 | 558 | updatedAt: new Date().toISOString(), |
| ... | ... | @@ -407,10 +582,12 @@ export class TerminalManagerRuntime extends EventEmitter<TerminalManagerEvents> |
| 407 | 582 | existing.cwd = input.cwd; |
| 408 | 583 | existing.runtimeEnv = nextRuntimeEnv; |
| 409 | 584 | existing.history = ""; |
| 585 | existing.pendingHistoryControlSequence = ""; |
| 410 | 586 | await this.persistHistory(existing.threadId, existing.terminalId, existing.history); |
| 411 | 587 | } else if (existing.status === "exited" || existing.status === "error") { |
| 412 | 588 | existing.runtimeEnv = nextRuntimeEnv; |
| 413 | 589 | existing.history = ""; |
| 590 | existing.pendingHistoryControlSequence = ""; |
| 414 | 591 | await this.persistHistory(existing.threadId, existing.terminalId, existing.history); |
| 415 | 592 | } else if (currentRuntimeEnv !== nextRuntimeEnv) { |
| 416 | 593 | existing.runtimeEnv = nextRuntimeEnv; |
| ... | ... | @@ -469,6 +646,7 @@ export class TerminalManagerRuntime extends EventEmitter<TerminalManagerEvents> |
| 469 | 646 | await this.runWithThreadLock(input.threadId, async () => { |
| 470 | 647 | const session = this.requireSession(input.threadId, input.terminalId); |
| 471 | 648 | session.history = ""; |
| 649 | session.pendingHistoryControlSequence = ""; |
| 472 | 650 | session.updatedAt = new Date().toISOString(); |
| 473 | 651 | await this.persistHistory(input.threadId, input.terminalId, session.history); |
| 474 | 652 | this.emitEvent({ |
| ... | ... | @@ -497,6 +675,7 @@ export class TerminalManagerRuntime extends EventEmitter<TerminalManagerEvents> |
| 497 | 675 | status: "starting", |
| 498 | 676 | pid: null, |
| 499 | 677 | history: "", |
| 678 | pendingHistoryControlSequence: "", |
| 500 | 679 | exitCode: null, |
| 501 | 680 | exitSignal: null, |
| 502 | 681 | updatedAt: new Date().toISOString(), |
| ... | ... | @@ -520,6 +699,7 @@ export class TerminalManagerRuntime extends EventEmitter<TerminalManagerEvents> |
| 520 | 699 | const rows = input.rows ?? session.rows; |
| 521 | 700 | |
| 522 | 701 | session.history = ""; |
| 702 | session.pendingHistoryControlSequence = ""; |
| 523 | 703 | await this.persistHistory(input.threadId, input.terminalId, session.history); |
| 524 | 704 | await this.startSession(session, { ...input, cols, rows }, "restarted"); |
| 525 | 705 | return this.snapshot(session); |
| ... | ... | @@ -694,9 +874,16 @@ export class TerminalManagerRuntime extends EventEmitter<TerminalManagerEvents> |
| 694 | 874 | } |
| 695 | 875 | |
| 696 | 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 | 886 | session.updatedAt = new Date().toISOString(); |
| 699 | | this.queuePersist(session.threadId, session.terminalId, session.history); |
| 700 | 887 | this.emitEvent({ |
| 701 | 888 | type: "output", |
| 702 | 889 | threadId: session.threadId, |
| ... | ... | @@ -713,6 +900,7 @@ export class TerminalManagerRuntime extends EventEmitter<TerminalManagerEvents> |
| 713 | 900 | session.pid = null; |
| 714 | 901 | session.hasRunningSubprocess = false; |
| 715 | 902 | session.status = "exited"; |
| 903 | session.pendingHistoryControlSequence = ""; |
| 716 | 904 | session.exitCode = Number.isInteger(event.exitCode) ? event.exitCode : null; |
| 717 | 905 | session.exitSignal = Number.isInteger(event.signal) ? event.signal : null; |
| 718 | 906 | session.updatedAt = new Date().toISOString(); |
| ... | ... | @@ -736,6 +924,7 @@ export class TerminalManagerRuntime extends EventEmitter<TerminalManagerEvents> |
| 736 | 924 | session.pid = null; |
| 737 | 925 | session.hasRunningSubprocess = false; |
| 738 | 926 | session.status = "exited"; |
| 927 | session.pendingHistoryControlSequence = ""; |
| 739 | 928 | session.updatedAt = new Date().toISOString(); |
| 740 | 929 | this.killProcessWithEscalation(process, session.threadId, session.terminalId); |
| 741 | 930 | this.evictInactiveSessionsIfNeeded(); |