centaurus-cli 2.9.5 → 2.9.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/dist/cli-adapter.d.ts +27 -2
  2. package/dist/cli-adapter.d.ts.map +1 -1
  3. package/dist/cli-adapter.js +406 -15
  4. package/dist/cli-adapter.js.map +1 -1
  5. package/dist/config/slash-commands.d.ts.map +1 -1
  6. package/dist/config/slash-commands.js +7 -0
  7. package/dist/config/slash-commands.js.map +1 -1
  8. package/dist/context/context-manager.d.ts +4 -0
  9. package/dist/context/context-manager.d.ts.map +1 -1
  10. package/dist/context/context-manager.js +6 -0
  11. package/dist/context/context-manager.js.map +1 -1
  12. package/dist/context/handlers/docker-handler.d.ts +5 -1
  13. package/dist/context/handlers/docker-handler.d.ts.map +1 -1
  14. package/dist/context/handlers/docker-handler.js +27 -10
  15. package/dist/context/handlers/docker-handler.js.map +1 -1
  16. package/dist/context/handlers/ssh-handler.d.ts +47 -1
  17. package/dist/context/handlers/ssh-handler.d.ts.map +1 -1
  18. package/dist/context/handlers/ssh-handler.js +546 -73
  19. package/dist/context/handlers/ssh-handler.js.map +1 -1
  20. package/dist/context/handlers/wsl-handler.d.ts +5 -1
  21. package/dist/context/handlers/wsl-handler.d.ts.map +1 -1
  22. package/dist/context/handlers/wsl-handler.js +24 -6
  23. package/dist/context/handlers/wsl-handler.js.map +1 -1
  24. package/dist/context/subshell-handler.d.ts +8 -2
  25. package/dist/context/subshell-handler.d.ts.map +1 -1
  26. package/dist/index.js +12 -0
  27. package/dist/index.js.map +1 -1
  28. package/dist/services/checkpoint-manager.d.ts +162 -0
  29. package/dist/services/checkpoint-manager.d.ts.map +1 -0
  30. package/dist/services/checkpoint-manager.js +926 -0
  31. package/dist/services/checkpoint-manager.js.map +1 -0
  32. package/dist/tools/background-command.d.ts.map +1 -1
  33. package/dist/tools/background-command.js +132 -24
  34. package/dist/tools/background-command.js.map +1 -1
  35. package/dist/tools/command.d.ts.map +1 -1
  36. package/dist/tools/command.js +14 -4
  37. package/dist/tools/command.js.map +1 -1
  38. package/dist/tools/create-image.d.ts.map +1 -1
  39. package/dist/tools/create-image.js +43 -18
  40. package/dist/tools/create-image.js.map +1 -1
  41. package/dist/tools/file-ops.d.ts.map +1 -1
  42. package/dist/tools/file-ops.js +12 -12
  43. package/dist/tools/file-ops.js.map +1 -1
  44. package/dist/tools/get-diff.d.ts +9 -45
  45. package/dist/tools/get-diff.d.ts.map +1 -1
  46. package/dist/tools/get-diff.js +288 -171
  47. package/dist/tools/get-diff.js.map +1 -1
  48. package/dist/tools/types.d.ts +3 -0
  49. package/dist/tools/types.d.ts.map +1 -1
  50. package/dist/ui/components/App.d.ts +8 -0
  51. package/dist/ui/components/App.d.ts.map +1 -1
  52. package/dist/ui/components/App.js +238 -62
  53. package/dist/ui/components/App.js.map +1 -1
  54. package/dist/ui/components/ConfirmPrompt.d.ts +2 -0
  55. package/dist/ui/components/ConfirmPrompt.d.ts.map +1 -1
  56. package/dist/ui/components/ConfirmPrompt.js +8 -3
  57. package/dist/ui/components/ConfirmPrompt.js.map +1 -1
  58. package/dist/ui/components/InputBox.d.ts +6 -0
  59. package/dist/ui/components/InputBox.d.ts.map +1 -1
  60. package/dist/ui/components/InputBox.js +130 -6
  61. package/dist/ui/components/InputBox.js.map +1 -1
  62. package/dist/ui/components/InteractiveShell.d.ts.map +1 -1
  63. package/dist/ui/components/InteractiveShell.js +47 -12
  64. package/dist/ui/components/InteractiveShell.js.map +1 -1
  65. package/dist/ui/components/ToolExecutionMessage.d.ts.map +1 -1
  66. package/dist/ui/components/ToolExecutionMessage.js +34 -14
  67. package/dist/ui/components/ToolExecutionMessage.js.map +1 -1
  68. package/dist/utils/ansi-encoder.d.ts +5 -0
  69. package/dist/utils/ansi-encoder.d.ts.map +1 -1
  70. package/dist/utils/ansi-encoder.js +5 -5
  71. package/dist/utils/ansi-encoder.js.map +1 -1
  72. package/dist/utils/editor-utils.d.ts +5 -0
  73. package/dist/utils/editor-utils.d.ts.map +1 -1
  74. package/dist/utils/editor-utils.js +67 -0
  75. package/dist/utils/editor-utils.js.map +1 -1
  76. package/dist/utils/input-classifier.d.ts.map +1 -1
  77. package/dist/utils/input-classifier.js +2 -1
  78. package/dist/utils/input-classifier.js.map +1 -1
  79. package/dist/utils/terminal-output.d.ts.map +1 -1
  80. package/dist/utils/terminal-output.js +162 -103
  81. package/dist/utils/terminal-output.js.map +1 -1
  82. package/package.json +3 -1
@@ -4,6 +4,10 @@
4
4
  import { Client } from 'ssh2';
5
5
  import { SubshellConnectionError, SubshellExecutionError } from '../types.js';
6
6
  import { randomBytes } from 'crypto';
7
+ import * as fs from 'fs';
8
+ import * as os from 'os';
9
+ import * as path from 'path';
10
+ import { quickLog } from '../../utils/conversation-logger.js';
7
11
  /**
8
12
  * SSH Handler implementation
9
13
  */
@@ -101,16 +105,6 @@ export class SSHHandler {
101
105
  try {
102
106
  // Parse SSH command
103
107
  this.config = this.parseSSHCommand(command);
104
- // Prompt for password if no other auth method is available
105
- if (!this.config.password && !this.config.privateKey && this.onPasswordRequest) {
106
- try {
107
- const password = await this.onPasswordRequest(`Password for ${this.config.username}@${this.config.host}:`);
108
- this.config.password = password;
109
- }
110
- catch (error) {
111
- throw new Error('Password input cancelled');
112
- }
113
- }
114
108
  // Establish SSH connection
115
109
  await this.establishConnection(this.config);
116
110
  // Initialize SFTP (non-fatal)
@@ -155,31 +149,19 @@ export class SSHHandler {
155
149
  try {
156
150
  // Parse SSH command
157
151
  this.config = this.parseSSHCommand(command);
158
- // Prompt for password if no other auth method is available
159
- if (!this.config.password && !this.config.privateKey && this.onPasswordRequest) {
160
- try {
161
- const password = await this.onPasswordRequest(`Password for ${this.config.username}@${this.config.host}:`);
162
- this.config.password = password;
163
- }
164
- catch (error) {
165
- throw new Error('Password input cancelled');
166
- }
167
- }
152
+ // Mark as nested session to disable implicit local authentication
153
+ this.config.isNested = true;
154
+ // Check if parent supports streaming
168
155
  // Check if parent supports streaming
169
- let stream;
156
+ let streamFactory;
170
157
  if (parentContext.handler && typeof parentContext.handler.createStream === 'function') {
171
- try {
172
- stream = await parentContext.handler.createStream(this.config.host, this.config.port);
173
- }
174
- catch (err) {
175
- throw new SubshellConnectionError('ssh', `Values to create tunnel: ${err.message}`, false);
176
- }
158
+ streamFactory = () => parentContext.handler.createStream(this.config.host, this.config.port);
177
159
  }
178
160
  else {
179
161
  throw new SubshellConnectionError('ssh', `Parent context (${parentContext.type}) does not support nested connections via tunneling.`, false);
180
162
  }
181
- // Establish SSH connection using the tunnel stream
182
- await this.establishConnection(this.config, stream);
163
+ // Establish SSH connection using the tunnel stream factory
164
+ await this.establishConnection(this.config, undefined, streamFactory);
183
165
  // Initialize SFTP (non-fatal)
184
166
  try {
185
167
  await this.initializeSFTP();
@@ -255,11 +237,12 @@ export class SSHHandler {
255
237
  if (!this._client || !this._isConnected) {
256
238
  throw new SubshellExecutionError(command, 'Not connected to SSH server');
257
239
  }
240
+ const pwdTag = `__CENTAURUS_PWD_${this.sessionId}__`;
258
241
  return new Promise((resolve, reject) => {
259
242
  const timeout = setTimeout(() => {
260
243
  reject(new SubshellExecutionError(command, 'Command timed out after 30 seconds'));
261
244
  }, 30000);
262
- this._client.exec(`cd "${this.currentWorkingDirectory}" && ${command}; echo "__CENTAURUS_PWD__:$(pwd)"`, (err, stream) => {
245
+ this._client.exec(`cd "${this.currentWorkingDirectory}" && ${command}; echo "${pwdTag}:$(pwd)"`, (err, stream) => {
263
246
  if (err) {
264
247
  clearTimeout(timeout);
265
248
  reject(new SubshellExecutionError(command, err.message));
@@ -269,11 +252,15 @@ export class SSHHandler {
269
252
  let stderr = '';
270
253
  stream.on('close', (code) => {
271
254
  clearTimeout(timeout);
272
- // Extract working directory from output
273
- const pwdMatch = stdout.match(/__CENTAURUS_PWD__:(.+)$/m);
255
+ // Extract working directory from output using unique tag
256
+ // We use a constructed regex to match the specific tag for this session
257
+ const pwdRegex = new RegExp(`${pwdTag}:(.+)$`, 'm');
258
+ const pwdMatch = stdout.match(pwdRegex);
274
259
  if (pwdMatch) {
275
260
  this.currentWorkingDirectory = pwdMatch[1].trim();
276
- stdout = stdout.replace(/__CENTAURUS_PWD__:.+$/m, '').trim();
261
+ // Remove the tag line from stdout
262
+ const removeRegex = new RegExp(`${pwdTag}:.+$`, 'm');
263
+ stdout = stdout.replace(removeRegex, '').trim();
277
264
  }
278
265
  resolve({
279
266
  stdout,
@@ -326,7 +313,10 @@ export class SSHHandler {
326
313
  // Prefer SFTP when available
327
314
  if (this.sftpClient) {
328
315
  return new Promise((resolve, reject) => {
329
- this.sftpClient.writeFile(absolutePath, content, 'utf8', (err) => {
316
+ // ssh2 sftp writeFile supports Buffer or string
317
+ // If buffer, encoding is ignored/not needed
318
+ const options = Buffer.isBuffer(content) ? undefined : 'utf8';
319
+ this.sftpClient.writeFile(absolutePath, content, options, (err) => {
330
320
  if (err) {
331
321
  reject(new Error(`Failed to write file ${path}: ${err.message}`));
332
322
  }
@@ -337,7 +327,8 @@ export class SSHHandler {
337
327
  });
338
328
  }
339
329
  // Fallback: base64 via exec (robust against special chars), chunked
340
- const base64Content = Buffer.from(content, 'utf8').toString('base64');
330
+ const inputBuffer = Buffer.isBuffer(content) ? content : Buffer.from(content, 'utf8');
331
+ const base64Content = inputBuffer.toString('base64');
341
332
  const CHUNK_SIZE = 32000;
342
333
  // Truncate file first
343
334
  let result = await this.executeCommand(`: > "${absolutePath}"`);
@@ -447,26 +438,81 @@ export class SSHHandler {
447
438
  * Parse SSH command to extract connection details
448
439
  */
449
440
  parseSSHCommand(command) {
450
- const parts = command.trim().split(/\s+/);
441
+ const parts = this.tokenizeSSHCommand(command);
451
442
  let host = '';
452
443
  let port = 22;
453
444
  let username = '';
445
+ let identityFilePath;
446
+ // SSH options that consume the next token as a value.
447
+ // We skip those values so they are not mistaken as the host.
448
+ const optionsWithValue = new Set([
449
+ '-b', '-c', '-D', '-E', '-F', '-I', '-J', '-L', '-l', '-m', '-O', '-o', '-p', '-Q', '-R', '-S', '-W', '-w'
450
+ ]);
454
451
  // Parse command line arguments
455
452
  for (let i = 1; i < parts.length; i++) {
456
453
  const part = parts[i];
454
+ if (part === '--') {
455
+ // End of options; next token is the host
456
+ if (!host && i + 1 < parts.length) {
457
+ host = parts[i + 1];
458
+ }
459
+ break;
460
+ }
457
461
  if (part === '-p' && i + 1 < parts.length) {
458
- port = parseInt(parts[i + 1], 10);
462
+ port = this.parsePort(parts[i + 1], port);
463
+ i++;
464
+ }
465
+ else if (part.startsWith('-p') && part.length > 2) {
466
+ port = this.parsePort(part.slice(2), port);
467
+ }
468
+ else if (part === '-l' && i + 1 < parts.length) {
469
+ username = parts[i + 1];
470
+ i++;
471
+ }
472
+ else if (part.startsWith('-l') && part.length > 2) {
473
+ username = part.slice(2);
474
+ }
475
+ else if (part === '-i' && i + 1 < parts.length) {
476
+ identityFilePath = parts[i + 1];
459
477
  i++;
460
478
  }
479
+ else if (part.startsWith('-i') && part.length > 2) {
480
+ identityFilePath = part.slice(2);
481
+ }
482
+ else if (part === '-o' && i + 1 < parts.length) {
483
+ // Support both "-o Key=Value" and "-o Key Value" styles.
484
+ const optionToken = parts[i + 1];
485
+ if (optionToken.includes('=')) {
486
+ this.applySSHOption(optionToken, (value) => { port = value; }, (value) => { username = value; }, (value) => { identityFilePath = value; }, port);
487
+ i++;
488
+ }
489
+ else if (i + 2 < parts.length) {
490
+ this.applySSHOption(`${optionToken}=${parts[i + 2]}`, (value) => { port = value; }, (value) => { username = value; }, (value) => { identityFilePath = value; }, port);
491
+ i += 2;
492
+ }
493
+ else {
494
+ i++;
495
+ }
496
+ }
497
+ else if (part.startsWith('-o') && part.length > 2) {
498
+ this.applySSHOption(part.slice(2), (value) => { port = value; }, (value) => { username = value; }, (value) => { identityFilePath = value; }, port);
499
+ }
461
500
  else if (part.startsWith('-') && part !== '-p') {
462
- // Skip other flags
501
+ // Skip flags and their value, if any
502
+ if (optionsWithValue.has(part) && i + 1 < parts.length) {
503
+ i++;
504
+ }
463
505
  continue;
464
506
  }
465
507
  else if (!host) {
466
508
  // This should be the host (possibly with username)
467
509
  if (part.includes('@')) {
468
- const [user, hostname] = part.split('@');
469
- username = user;
510
+ const atIndex = part.indexOf('@');
511
+ const user = part.slice(0, atIndex);
512
+ const hostname = part.slice(atIndex + 1);
513
+ if (user) {
514
+ username = user;
515
+ }
470
516
  host = hostname;
471
517
  }
472
518
  else {
@@ -481,50 +527,170 @@ export class SSHHandler {
481
527
  if (!host) {
482
528
  throw new Error('Could not parse SSH host from command');
483
529
  }
484
- return {
530
+ const parsedConfig = {
485
531
  host,
486
532
  port,
487
533
  username,
488
534
  };
535
+ if (identityFilePath) {
536
+ parsedConfig.privateKey = this.loadIdentityFile(identityFilePath);
537
+ }
538
+ return parsedConfig;
489
539
  }
490
540
  /**
491
541
  * Establish SSH connection
492
542
  */
493
- async establishConnection(config, stream) {
494
- return new Promise((resolve, reject) => {
495
- this._client = new Client();
496
- const connectConfig = {
497
- host: config.host,
498
- port: config.port,
499
- username: config.username,
500
- };
501
- if (stream) {
502
- connectConfig.sock = stream;
503
- }
504
- // Add authentication method
505
- if (config.password) {
506
- connectConfig.password = config.password;
543
+ /**
544
+ * Establish SSH connection.
545
+ * Handles authentication retries (agent -> keys -> password).
546
+ * For nested connections, uses streamFactory to get a fresh stream for each attempt.
547
+ */
548
+ async establishConnection(config, stream, streamFactory) {
549
+ let promptedForPassword = false;
550
+ let firstError;
551
+ quickLog(`[SSH Auth] Starting auth for ${config.username}@${config.host}:${config.port}\n`);
552
+ quickLog(`[SSH Auth] Has privateKey: ${!!config.privateKey}, Has password: ${!!config.password}\n`);
553
+ try {
554
+ quickLog(`[SSH Auth] Attempt 1: agent/none (tryKeyboard=false)\n`);
555
+ // Get fresh stream if factory provided
556
+ const currentStream = streamFactory ? await streamFactory() : stream;
557
+ await this.establishConnectionOnce(config, currentStream, false, () => {
558
+ promptedForPassword = true;
559
+ });
560
+ quickLog(`[SSH Auth] Attempt 1 succeeded (agent or none auth)\n`);
561
+ return;
562
+ }
563
+ catch (error) {
564
+ const errMsg = error instanceof Error ? error.message : String(error);
565
+ quickLog(`[SSH Auth] Attempt 1 failed: ${errMsg}, promptedForPassword=${promptedForPassword}\n`);
566
+ firstError = error;
567
+ // Stop early for non-auth failures or if user was already prompted.
568
+ if (!this.isAuthenticationFailure(error) || promptedForPassword) {
569
+ quickLog(`[SSH Auth] Stopping early: isAuthFailure=${this.isAuthenticationFailure(error)}, prompted=${promptedForPassword}\n`);
570
+ throw error;
507
571
  }
508
- else if (config.privateKey) {
509
- connectConfig.privateKey = config.privateKey;
510
- if (config.passphrase) {
511
- connectConfig.passphrase = config.passphrase;
572
+ }
573
+ // If no explicit key was provided, try default identity files before prompting for password.
574
+ // Skip this for nested sessions to avoid using local keys where remote ones are expected.
575
+ if (!config.privateKey && !config.isNested) {
576
+ const defaultKeys = this.loadDefaultIdentityFiles(config.host);
577
+ quickLog(`[SSH Auth] Found ${defaultKeys.length} default identity file(s)\n`);
578
+ for (let ki = 0; ki < defaultKeys.length; ki++) {
579
+ const key = defaultKeys[ki];
580
+ try {
581
+ quickLog(`[SSH Auth] Trying key file ${ki + 1}/${defaultKeys.length} (${key.length} bytes)\n`);
582
+ // Get fresh stream if factory provided
583
+ const keyStream = streamFactory ? await streamFactory() : stream;
584
+ const keyConfig = {
585
+ ...config,
586
+ privateKey: key,
587
+ password: undefined,
588
+ };
589
+ await this.establishConnectionOnce(keyConfig, keyStream, false, () => {
590
+ promptedForPassword = true;
591
+ });
592
+ quickLog(`[SSH Auth] Key file ${ki + 1} succeeded!\n`);
593
+ config.privateKey = key;
594
+ return;
595
+ }
596
+ catch (keyError) {
597
+ const keyErrMsg = keyError instanceof Error ? keyError.message : String(keyError);
598
+ quickLog(`[SSH Auth] Key file ${ki + 1} failed: ${keyErrMsg}\n`);
599
+ if (promptedForPassword) {
600
+ // User already provided a secret once for this connection attempt.
601
+ throw keyError;
602
+ }
603
+ // Continue trying other keys for expected auth/key-format failures.
604
+ if (this.isAuthenticationFailure(keyError) || this.isKeyParseFailure(keyError)) {
605
+ continue;
606
+ }
607
+ throw keyError;
512
608
  }
513
609
  }
514
- else {
515
- // Try SSH agent or default keys
516
- connectConfig.tryKeyboard = true;
517
- }
518
- this._client.on('ready', () => {
610
+ }
611
+ else {
612
+ quickLog(`[SSH Auth] Explicit key provided, skipping default key search\n`);
613
+ }
614
+ // Password retry (only if callback exists).
615
+ quickLog(`[SSH Auth] All key-based auth methods exhausted, falling back to password\n`);
616
+ if (!this.onPasswordRequest) {
617
+ throw (firstError instanceof Error ? firstError : new Error('SSH authentication failed'));
618
+ }
619
+ const password = await this.requestPassword(config);
620
+ config.password = password;
621
+ config.privateKey = undefined;
622
+ // Get fresh stream for password attempt
623
+ const passwordStream = streamFactory ? await streamFactory() : stream;
624
+ await this.establishConnectionOnce(config, passwordStream, true, () => {
625
+ promptedForPassword = true;
626
+ });
627
+ }
628
+ /**
629
+ * Single SSH connection attempt.
630
+ * Password retry logic is handled by establishConnection().
631
+ * @param enableKeyboard - Whether to enable keyboard-interactive auth.
632
+ * Set to false for key-based attempts so the server doesn't prompt for password prematurely.
633
+ * Set to true only for the final password-retry attempt.
634
+ */
635
+ async establishConnectionOnce(config, stream, enableKeyboard, onPasswordPrompt) {
636
+ return new Promise((resolve, reject) => {
637
+ const client = new Client();
638
+ this._client = client;
639
+ let settled = false;
640
+ const settleResolve = () => {
641
+ if (settled) {
642
+ return;
643
+ }
644
+ settled = true;
519
645
  this._isConnected = true;
520
646
  resolve();
521
- });
522
- this._client.on('error', (err) => {
647
+ };
648
+ const settleReject = (error) => {
649
+ if (settled) {
650
+ return;
651
+ }
652
+ settled = true;
523
653
  this._isConnected = false;
524
- reject(new Error(`ssh connection failed: ${err.message}`));
654
+ this._client = null;
655
+ try {
656
+ client.end();
657
+ }
658
+ catch {
659
+ // Ignore cleanup errors
660
+ }
661
+ reject(error);
662
+ };
663
+ client.on('ready', () => {
664
+ settleResolve();
665
+ });
666
+ client.on('error', (err) => {
667
+ settleReject(new Error(`ssh connection failed: ${err.message}`));
668
+ });
669
+ client.on('keyboard-interactive', async (_name, _instructions, _lang, prompts, finish) => {
670
+ if (!prompts || prompts.length === 0 || !this.onPasswordRequest) {
671
+ finish([]);
672
+ return;
673
+ }
674
+ try {
675
+ const responses = [];
676
+ for (const prompt of prompts) {
677
+ onPasswordPrompt();
678
+ const message = this.buildPasswordPromptMessage(config, prompt.prompt);
679
+ const response = await this.requestPassword(config, message);
680
+ responses.push(response);
681
+ if (!prompt.echo) {
682
+ config.password = response;
683
+ }
684
+ }
685
+ finish(responses);
686
+ }
687
+ catch (error) {
688
+ finish([]);
689
+ settleReject(error instanceof Error ? error : new Error('Password input cancelled'));
690
+ }
525
691
  });
526
692
  // Listen for unexpected disconnections
527
- this._client.on('close', () => {
693
+ client.on('close', () => {
528
694
  if (this._isConnected) {
529
695
  // Unexpected disconnect (not initiated by us calling disconnect())
530
696
  this._isConnected = false;
@@ -534,7 +700,7 @@ export class SSHHandler {
534
700
  }
535
701
  }
536
702
  });
537
- this._client.on('end', () => {
703
+ client.on('end', () => {
538
704
  if (this._isConnected) {
539
705
  // Unexpected disconnect
540
706
  this._isConnected = false;
@@ -544,9 +710,303 @@ export class SSHHandler {
544
710
  }
545
711
  }
546
712
  });
547
- this._client.connect(connectConfig);
713
+ client.connect(this.buildConnectConfig(config, stream, enableKeyboard));
548
714
  });
549
715
  }
716
+ /**
717
+ * Build ssh2 connection config.
718
+ * @param enableKeyboard - When true, enables keyboard-interactive auth.
719
+ * This should only be true for the final password-retry attempt;
720
+ * otherwise the server's keyboard-interactive challenge fires before
721
+ * key-based auth methods (agent, key files) have been tried.
722
+ */
723
+ buildConnectConfig(config, stream, enableKeyboard = false) {
724
+ const connectConfig = {
725
+ host: config.host,
726
+ port: config.port,
727
+ username: config.username,
728
+ tryKeyboard: enableKeyboard,
729
+ };
730
+ if (stream) {
731
+ connectConfig.sock = stream;
732
+ }
733
+ // Skip local SSH agent for nested sessions
734
+ if (!config.isNested) {
735
+ if (process.env.SSH_AUTH_SOCK) {
736
+ connectConfig.agent = process.env.SSH_AUTH_SOCK;
737
+ quickLog(`[SSH Auth] Using SSH_AUTH_SOCK agent: ${process.env.SSH_AUTH_SOCK}\n`);
738
+ }
739
+ else if (os.platform() === 'win32') {
740
+ // Windows OpenSSH agent uses a named pipe, not SSH_AUTH_SOCK.
741
+ // The ssh2 library supports this via the agent option.
742
+ // Pipe path: \\.\pipe\openssh-ssh-agent
743
+ const winPipe = ['', '', '.', 'pipe', 'openssh-ssh-agent'].join('\\');
744
+ quickLog(`[SSH Auth] Windows detected, checking agent pipe: ${winPipe}\n`);
745
+ try {
746
+ // Did not stat the pipe as it returns EBUSY on Windows sometimes
747
+ // Just try to use it
748
+ connectConfig.agent = winPipe;
749
+ quickLog(`[SSH Auth] Using Windows agent pipe (blind trust)\n`);
750
+ }
751
+ catch (e) {
752
+ // Named pipe not available (agent service not running).
753
+ // Fall through to key file / password auth.
754
+ quickLog(`[SSH Auth] Windows agent pipe error: ${e.message}\n`);
755
+ }
756
+ }
757
+ else {
758
+ quickLog(`[SSH Auth] No SSH agent available (no SSH_AUTH_SOCK, not Windows)\n`);
759
+ }
760
+ }
761
+ if (config.password) {
762
+ connectConfig.password = config.password;
763
+ }
764
+ if (config.privateKey) {
765
+ connectConfig.privateKey = config.privateKey;
766
+ if (config.passphrase) {
767
+ connectConfig.passphrase = config.passphrase;
768
+ }
769
+ }
770
+ return connectConfig;
771
+ }
772
+ async requestPassword(config, promptMessage) {
773
+ if (!this.onPasswordRequest) {
774
+ throw new Error('Password authentication required but no password prompt handler is configured');
775
+ }
776
+ try {
777
+ return await this.onPasswordRequest(promptMessage || `Password for ${config.username}@${config.host}:`);
778
+ }
779
+ catch {
780
+ throw new Error('Password input cancelled');
781
+ }
782
+ }
783
+ buildPasswordPromptMessage(config, rawPrompt) {
784
+ const trimmedPrompt = rawPrompt?.trim();
785
+ if (trimmedPrompt) {
786
+ return trimmedPrompt;
787
+ }
788
+ return `Password for ${config.username}@${config.host}:`;
789
+ }
790
+ isAuthenticationFailure(error) {
791
+ const message = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase();
792
+ return (message.includes('all configured authentication methods failed') ||
793
+ message.includes('authentication failed') ||
794
+ message.includes('permission denied'));
795
+ }
796
+ isKeyParseFailure(error) {
797
+ const message = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase();
798
+ return (message.includes('cannot parse privatekey') ||
799
+ message.includes('bad passphrase') ||
800
+ message.includes('no passphrase given') ||
801
+ message.includes('invalid private key'));
802
+ }
803
+ parsePort(value, fallbackPort) {
804
+ const parsed = Number.parseInt(value, 10);
805
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : fallbackPort;
806
+ }
807
+ applySSHOption(option, setPort, setUsername, setIdentityFile, fallbackPort) {
808
+ const trimmed = option.trim();
809
+ if (!trimmed) {
810
+ return;
811
+ }
812
+ const equalsIndex = trimmed.indexOf('=');
813
+ const key = (equalsIndex >= 0 ? trimmed.slice(0, equalsIndex) : trimmed).trim().toLowerCase();
814
+ const value = (equalsIndex >= 0 ? trimmed.slice(equalsIndex + 1) : '').trim();
815
+ if (!value) {
816
+ return;
817
+ }
818
+ if (key === 'identityfile') {
819
+ setIdentityFile(value);
820
+ return;
821
+ }
822
+ if (key === 'user') {
823
+ setUsername(value);
824
+ return;
825
+ }
826
+ if (key === 'port') {
827
+ setPort(this.parsePort(value, fallbackPort));
828
+ }
829
+ }
830
+ tokenizeSSHCommand(command) {
831
+ const tokens = [];
832
+ let current = '';
833
+ let quote = null;
834
+ let escaped = false;
835
+ for (const char of command.trim()) {
836
+ if (escaped) {
837
+ current += char;
838
+ escaped = false;
839
+ continue;
840
+ }
841
+ if (char === '\\' && quote !== '\'') {
842
+ escaped = true;
843
+ continue;
844
+ }
845
+ if (quote) {
846
+ if (char === quote) {
847
+ quote = null;
848
+ }
849
+ else {
850
+ current += char;
851
+ }
852
+ continue;
853
+ }
854
+ if (char === '"' || char === '\'') {
855
+ quote = char;
856
+ continue;
857
+ }
858
+ if (/\s/.test(char)) {
859
+ if (current) {
860
+ tokens.push(current);
861
+ current = '';
862
+ }
863
+ continue;
864
+ }
865
+ current += char;
866
+ }
867
+ if (current) {
868
+ tokens.push(current);
869
+ }
870
+ return tokens;
871
+ }
872
+ loadIdentityFile(identityPath) {
873
+ const resolvedPath = this.resolveIdentityPath(identityPath);
874
+ try {
875
+ return fs.readFileSync(resolvedPath);
876
+ }
877
+ catch (error) {
878
+ if (error?.code === 'ENOENT') {
879
+ throw new Error(`SSH identity file not found: ${resolvedPath}`);
880
+ }
881
+ throw new Error(`Failed to read SSH identity file "${resolvedPath}": ${error?.message || 'Unknown error'}`);
882
+ }
883
+ }
884
+ resolveIdentityPath(identityPath) {
885
+ const trimmed = identityPath.trim();
886
+ if (!trimmed) {
887
+ return trimmed;
888
+ }
889
+ if (trimmed.startsWith('~')) {
890
+ const homeDir = os.homedir();
891
+ return path.resolve(homeDir, trimmed.slice(1));
892
+ }
893
+ return path.isAbsolute(trimmed) ? trimmed : path.resolve(process.cwd(), trimmed);
894
+ }
895
+ loadDefaultIdentityFiles(host) {
896
+ const homeDir = os.homedir();
897
+ const candidatePaths = [
898
+ path.join(homeDir, '.ssh', 'id_ed25519'),
899
+ path.join(homeDir, '.ssh', 'id_rsa'),
900
+ path.join(homeDir, '.ssh', 'id_ecdsa'),
901
+ path.join(homeDir, '.ssh', 'id_dsa'),
902
+ ];
903
+ // Add keys from ~/.ssh/config if host is provided
904
+ if (host) {
905
+ const configKeys = this.getIdentityFilesFromConfig(host);
906
+ quickLog(`[SSH Config] Found ${configKeys.length} keys in config for host ${host}\n`);
907
+ for (const keyPath of configKeys) {
908
+ // Resolving relative paths in config (relative to ~/.ssh)
909
+ let resolvedKeyPath = keyPath;
910
+ if (!path.isAbsolute(keyPath)) {
911
+ if (keyPath.startsWith('~')) {
912
+ resolvedKeyPath = keyPath.replace(/^~/, homeDir);
913
+ }
914
+ else {
915
+ resolvedKeyPath = path.join(homeDir, '.ssh', keyPath);
916
+ }
917
+ }
918
+ // Avoid duplicates if user config points to standard keys
919
+ if (!candidatePaths.includes(resolvedKeyPath)) {
920
+ candidatePaths.unshift(resolvedKeyPath); // Priority to config keys
921
+ }
922
+ }
923
+ }
924
+ const keys = [];
925
+ for (const candidate of candidatePaths) {
926
+ try {
927
+ if (fs.existsSync(candidate)) {
928
+ quickLog(`[SSH Auth] Loading key from: ${candidate}\n`);
929
+ keys.push(fs.readFileSync(candidate));
930
+ }
931
+ }
932
+ catch {
933
+ // Ignore unreadable key files and keep trying.
934
+ }
935
+ }
936
+ return keys;
937
+ }
938
+ /**
939
+ * Parse ~/.ssh/config to find IdentityFile entries for the given host.
940
+ * Supports standard SSH config patterns including wildcards (*, ?) and quoted paths.
941
+ */
942
+ getIdentityFilesFromConfig(targetHost) {
943
+ const homeDir = os.homedir();
944
+ const configPath = path.join(homeDir, '.ssh', 'config');
945
+ const identityFiles = [];
946
+ if (!fs.existsSync(configPath)) {
947
+ return identityFiles;
948
+ }
949
+ try {
950
+ const content = fs.readFileSync(configPath, 'utf8');
951
+ const lines = content.split('\n');
952
+ let inMatchingHost = false;
953
+ for (const line of lines) {
954
+ const trimmed = line.trim();
955
+ if (!trimmed || trimmed.startsWith('#'))
956
+ continue;
957
+ // Split by whitespace to get key and arguments
958
+ const parts = trimmed.split(/\s+/);
959
+ const key = parts[0].toLowerCase();
960
+ if (key === 'host') {
961
+ // Check if this Host block matches our target
962
+ // Patterns can be separated by whitespace
963
+ const patterns = parts.slice(1);
964
+ inMatchingHost = patterns.some(pattern => {
965
+ // Convert SSH glob pattern to regex
966
+ try {
967
+ const regex = this.convertGlobToRegex(pattern);
968
+ return regex.test(targetHost);
969
+ }
970
+ catch {
971
+ return false;
972
+ }
973
+ });
974
+ }
975
+ else if (inMatchingHost && key === 'identityfile') {
976
+ // Found an IdentityFile for a matching host.
977
+ // Extract the value robustly (handling potential quotes and spaces)
978
+ // Find where the key ends and value begins in the original line
979
+ const keyMatch = trimmed.match(/^\S+/);
980
+ if (keyMatch) {
981
+ let fileValue = trimmed.substring(keyMatch[0].length).trim();
982
+ // Remove surrounding quotes if present
983
+ if ((fileValue.startsWith('"') && fileValue.endsWith('"')) ||
984
+ (fileValue.startsWith("'") && fileValue.endsWith("'"))) {
985
+ fileValue = fileValue.slice(1, -1);
986
+ }
987
+ if (fileValue) {
988
+ identityFiles.push(fileValue);
989
+ }
990
+ }
991
+ }
992
+ }
993
+ }
994
+ catch (e) {
995
+ quickLog(`[SSH Config] Failed to parse config file: ${e}\n`);
996
+ }
997
+ return identityFiles;
998
+ }
999
+ /**
1000
+ * Convert standard SSH config glob pattern to RegExp.
1001
+ * Supports * (wildcard) and ? (single char).
1002
+ */
1003
+ convertGlobToRegex(pattern) {
1004
+ // Escape special regex characters
1005
+ const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&');
1006
+ // Convert * to .* and ? to .
1007
+ const regexString = '^' + escaped.replace(/\*/g, '.*').replace(/\?/g, '.') + '$';
1008
+ return new RegExp(regexString);
1009
+ }
550
1010
  /**
551
1011
  * Initialize SFTP session
552
1012
  */
@@ -625,7 +1085,7 @@ export class SSHHandler {
625
1085
  export CENTAURUS_SUBSHELL=1
626
1086
  export CENTAURUS_SESSION_ID="${sessionId}"
627
1087
  _centaurus_pwd_hook() {
628
- echo "__CENTAURUS_PWD__:$(pwd)"
1088
+ echo "__CENTAURUS_PWD_${sessionId}__:$(pwd)"
629
1089
  }
630
1090
  export PROMPT_COMMAND="_centaurus_pwd_hook; \${PROMPT_COMMAND}"
631
1091
  `.trim();
@@ -635,7 +1095,7 @@ export PROMPT_COMMAND="_centaurus_pwd_hook; \${PROMPT_COMMAND}"
635
1095
  export CENTAURUS_SUBSHELL=1
636
1096
  export CENTAURUS_SESSION_ID="${sessionId}"
637
1097
  _centaurus_pwd_hook() {
638
- echo "__CENTAURUS_PWD__:$(pwd)"
1098
+ echo "__CENTAURUS_PWD_${sessionId}__:$(pwd)"
639
1099
  }
640
1100
  precmd_functions+=(_centaurus_pwd_hook)
641
1101
  `.trim();
@@ -645,7 +1105,7 @@ precmd_functions+=(_centaurus_pwd_hook)
645
1105
  set -x CENTAURUS_SUBSHELL 1
646
1106
  set -x CENTAURUS_SESSION_ID "${sessionId}"
647
1107
  function _centaurus_pwd_hook --on-event fish_prompt
648
- echo "__CENTAURUS_PWD__:"(pwd)
1108
+ echo "__CENTAURUS_PWD_${sessionId}__:"(pwd)
649
1109
  end
650
1110
  `.trim();
651
1111
  }
@@ -692,5 +1152,18 @@ export CENTAURUS_SESSION_ID="${sessionId}"
692
1152
  }
693
1153
  return entries;
694
1154
  }
1155
+ /**
1156
+ * Create a new instance of this handler
1157
+ */
1158
+ createNew() {
1159
+ const newHandler = new SSHHandler();
1160
+ if (this.onPasswordRequest) {
1161
+ newHandler.setPasswordRequestCallback(this.onPasswordRequest);
1162
+ }
1163
+ if (this.onDisconnectCallback) {
1164
+ newHandler.setDisconnectCallback(this.onDisconnectCallback);
1165
+ }
1166
+ return newHandler;
1167
+ }
695
1168
  }
696
1169
  //# sourceMappingURL=ssh-handler.js.map