fix(prompt): strip bracketed paste escape sequences from password input

Terminals using bracketed paste mode wrap pasted content with ESC[200~
(start) and ESC[201~ (end) sequences. These were incorrectly passed
through to API keys, causing "[200~API_KEY[201~" instead of "API_KEY".

Now buffers ESC sequences and discards recognized paste markers.
This commit is contained in:
kaitranntt
2025-12-04 05:05:45 -05:00
parent 0d4d2daf06
commit df31ffcee7
2 changed files with 119 additions and 0 deletions
+32
View File
@@ -143,6 +143,11 @@ export class InteractivePrompt {
/**
* Get password/secret input (masked)
*
* Handles bracketed paste mode escape sequences that terminals send:
* - Start paste: ESC[200~
* - End paste: ESC[201~
* These are stripped automatically so pasted API keys work correctly.
*/
static async password(message: string, options: PasswordOptions = {}): Promise<string> {
const { mask = '*' } = options;
@@ -162,6 +167,7 @@ export class InteractivePrompt {
return new Promise((resolve) => {
let input = '';
let escapeBuffer = ''; // Buffer for escape sequence detection
const cleanup = (): void => {
if (process.stdin.setRawMode) {
@@ -177,6 +183,32 @@ export class InteractivePrompt {
for (const char of str) {
const charCode = char.charCodeAt(0);
// ESC character (start of escape sequence)
if (charCode === 27) {
escapeBuffer = '\x1b';
continue;
}
// If we're in an escape sequence, buffer chars until we detect the pattern
if (escapeBuffer) {
escapeBuffer += char;
// Check for bracketed paste sequences: ESC[200~ (start) or ESC[201~ (end)
if (escapeBuffer === '\x1b[200~' || escapeBuffer === '\x1b[201~') {
// Discard bracketed paste markers
escapeBuffer = '';
continue;
}
// If buffer is getting too long without match, it's not a paste sequence
// Flush buffer as regular input (shouldn't happen with API keys)
if (escapeBuffer.length > 6) {
// Not a recognized sequence - skip it entirely (likely other escape seq)
escapeBuffer = '';
}
continue;
}
// Enter key (CR or LF)
if (charCode === 13 || charCode === 10) {
cleanup();
+87
View File
@@ -152,6 +152,93 @@ describe('InteractivePrompt', () => {
});
});
describe('password - bracketed paste handling', () => {
/**
* Test helper: Simulates the escape sequence filtering logic from password()
* This mirrors the implementation to verify bracketed paste sequences are stripped
*/
function stripBracketedPaste(input) {
let result = '';
let escapeBuffer = '';
for (const char of input) {
const charCode = char.charCodeAt(0);
// ESC character (start of escape sequence)
if (charCode === 27) {
escapeBuffer = '\x1b';
continue;
}
// If we're in an escape sequence, buffer chars until we detect the pattern
if (escapeBuffer) {
escapeBuffer += char;
// Check for bracketed paste sequences: ESC[200~ (start) or ESC[201~ (end)
if (escapeBuffer === '\x1b[200~' || escapeBuffer === '\x1b[201~') {
escapeBuffer = '';
continue;
}
// If buffer is getting too long without match, it's not a paste sequence
if (escapeBuffer.length > 6) {
escapeBuffer = '';
}
continue;
}
// Regular printable character
if (charCode >= 32) {
result += char;
}
}
return result;
}
it('strips ESC[200~ (start paste) sequence', () => {
const input = '\x1b[200~sk-ant-api-key\x1b[201~';
const result = stripBracketedPaste(input);
assert.strictEqual(result, 'sk-ant-api-key');
});
it('handles API key pasted with bracketed paste mode', () => {
const pastedKey = '\x1b[200~sk-ant-api03-abcdefghijklmnop\x1b[201~';
const result = stripBracketedPaste(pastedKey);
assert.strictEqual(result, 'sk-ant-api03-abcdefghijklmnop');
});
it('passes through normal typed input without escape sequences', () => {
const typedKey = 'sk-ant-api03-normal-typing';
const result = stripBracketedPaste(typedKey);
assert.strictEqual(result, 'sk-ant-api03-normal-typing');
});
it('handles only start paste sequence', () => {
const input = '\x1b[200~my-api-key';
const result = stripBracketedPaste(input);
assert.strictEqual(result, 'my-api-key');
});
it('handles only end paste sequence', () => {
const input = 'my-api-key\x1b[201~';
const result = stripBracketedPaste(input);
assert.strictEqual(result, 'my-api-key');
});
it('handles multiple paste sequences', () => {
const input = '\x1b[200~first\x1b[201~\x1b[200~second\x1b[201~';
const result = stripBracketedPaste(input);
assert.strictEqual(result, 'firstsecond');
});
it('handles empty paste', () => {
const input = '\x1b[200~\x1b[201~';
const result = stripBracketedPaste(input);
assert.strictEqual(result, '');
});
});
describe('confirm', () => {
it('returns true when CCS_YES=1', async () => {
process.env.CCS_YES = '1';