mirror of
https://github.com/tiennm99/ccs.git
synced 2026-08-07 06:28:20 +00:00
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:
@@ -143,6 +143,11 @@ export class InteractivePrompt {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Get password/secret input (masked)
|
* 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> {
|
static async password(message: string, options: PasswordOptions = {}): Promise<string> {
|
||||||
const { mask = '*' } = options;
|
const { mask = '*' } = options;
|
||||||
@@ -162,6 +167,7 @@ export class InteractivePrompt {
|
|||||||
|
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
let input = '';
|
let input = '';
|
||||||
|
let escapeBuffer = ''; // Buffer for escape sequence detection
|
||||||
|
|
||||||
const cleanup = (): void => {
|
const cleanup = (): void => {
|
||||||
if (process.stdin.setRawMode) {
|
if (process.stdin.setRawMode) {
|
||||||
@@ -177,6 +183,32 @@ export class InteractivePrompt {
|
|||||||
for (const char of str) {
|
for (const char of str) {
|
||||||
const charCode = char.charCodeAt(0);
|
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)
|
// Enter key (CR or LF)
|
||||||
if (charCode === 13 || charCode === 10) {
|
if (charCode === 13 || charCode === 10) {
|
||||||
cleanup();
|
cleanup();
|
||||||
|
|||||||
@@ -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', () => {
|
describe('confirm', () => {
|
||||||
it('returns true when CCS_YES=1', async () => {
|
it('returns true when CCS_YES=1', async () => {
|
||||||
process.env.CCS_YES = '1';
|
process.env.CCS_YES = '1';
|
||||||
|
|||||||
Reference in New Issue
Block a user