centaurus-cli 2.9.4 → 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.
- package/dist/cli-adapter.d.ts +29 -4
- package/dist/cli-adapter.d.ts.map +1 -1
- package/dist/cli-adapter.js +700 -121
- package/dist/cli-adapter.js.map +1 -1
- package/dist/config/slash-commands.d.ts.map +1 -1
- package/dist/config/slash-commands.js +7 -0
- package/dist/config/slash-commands.js.map +1 -1
- package/dist/context/context-manager.d.ts +10 -0
- package/dist/context/context-manager.d.ts.map +1 -1
- package/dist/context/context-manager.js +17 -0
- package/dist/context/context-manager.js.map +1 -1
- package/dist/context/handlers/docker-handler.d.ts +7 -1
- package/dist/context/handlers/docker-handler.d.ts.map +1 -1
- package/dist/context/handlers/docker-handler.js +89 -16
- package/dist/context/handlers/docker-handler.js.map +1 -1
- package/dist/context/handlers/ssh-handler.d.ts +47 -1
- package/dist/context/handlers/ssh-handler.d.ts.map +1 -1
- package/dist/context/handlers/ssh-handler.js +546 -73
- package/dist/context/handlers/ssh-handler.js.map +1 -1
- package/dist/context/handlers/wsl-handler.d.ts +5 -1
- package/dist/context/handlers/wsl-handler.d.ts.map +1 -1
- package/dist/context/handlers/wsl-handler.js +24 -6
- package/dist/context/handlers/wsl-handler.js.map +1 -1
- package/dist/context/subshell-handler.d.ts +8 -2
- package/dist/context/subshell-handler.d.ts.map +1 -1
- package/dist/index.js +12 -0
- package/dist/index.js.map +1 -1
- package/dist/services/checkpoint-manager.d.ts +162 -0
- package/dist/services/checkpoint-manager.d.ts.map +1 -0
- package/dist/services/checkpoint-manager.js +926 -0
- package/dist/services/checkpoint-manager.js.map +1 -0
- package/dist/services/local-chat-storage.d.ts +3 -1
- package/dist/services/local-chat-storage.d.ts.map +1 -1
- package/dist/services/local-chat-storage.js +8 -3
- package/dist/services/local-chat-storage.js.map +1 -1
- package/dist/tools/background-command.d.ts.map +1 -1
- package/dist/tools/background-command.js +132 -24
- package/dist/tools/background-command.js.map +1 -1
- package/dist/tools/command.d.ts.map +1 -1
- package/dist/tools/command.js +106 -42
- package/dist/tools/command.js.map +1 -1
- package/dist/tools/create-image.d.ts.map +1 -1
- package/dist/tools/create-image.js +43 -18
- package/dist/tools/create-image.js.map +1 -1
- package/dist/tools/file-ops.d.ts.map +1 -1
- package/dist/tools/file-ops.js +12 -12
- package/dist/tools/file-ops.js.map +1 -1
- package/dist/tools/get-diff.d.ts +9 -45
- package/dist/tools/get-diff.d.ts.map +1 -1
- package/dist/tools/get-diff.js +288 -171
- package/dist/tools/get-diff.js.map +1 -1
- package/dist/tools/grep-search.d.ts +1 -1
- package/dist/tools/grep-search.d.ts.map +1 -1
- package/dist/tools/grep-search.js +80 -1
- package/dist/tools/grep-search.js.map +1 -1
- package/dist/tools/types.d.ts +3 -0
- package/dist/tools/types.d.ts.map +1 -1
- package/dist/ui/components/App.d.ts +8 -0
- package/dist/ui/components/App.d.ts.map +1 -1
- package/dist/ui/components/App.js +256 -66
- package/dist/ui/components/App.js.map +1 -1
- package/dist/ui/components/Breadcrumbs.d.ts.map +1 -1
- package/dist/ui/components/Breadcrumbs.js +22 -2
- package/dist/ui/components/Breadcrumbs.js.map +1 -1
- package/dist/ui/components/ConfirmPrompt.d.ts +2 -0
- package/dist/ui/components/ConfirmPrompt.d.ts.map +1 -1
- package/dist/ui/components/ConfirmPrompt.js +8 -3
- package/dist/ui/components/ConfirmPrompt.js.map +1 -1
- package/dist/ui/components/InputBox.d.ts +6 -0
- package/dist/ui/components/InputBox.d.ts.map +1 -1
- package/dist/ui/components/InputBox.js +188 -23
- package/dist/ui/components/InputBox.js.map +1 -1
- package/dist/ui/components/InteractiveShell.d.ts +2 -0
- package/dist/ui/components/InteractiveShell.d.ts.map +1 -1
- package/dist/ui/components/InteractiveShell.js +88 -26
- package/dist/ui/components/InteractiveShell.js.map +1 -1
- package/dist/ui/components/KeyboardHelp.d.ts.map +1 -1
- package/dist/ui/components/KeyboardHelp.js +14 -6
- package/dist/ui/components/KeyboardHelp.js.map +1 -1
- package/dist/ui/components/ToolExecutionMessage.d.ts.map +1 -1
- package/dist/ui/components/ToolExecutionMessage.js +35 -16
- package/dist/ui/components/ToolExecutionMessage.js.map +1 -1
- package/dist/utils/ansi-encoder.d.ts +5 -0
- package/dist/utils/ansi-encoder.d.ts.map +1 -1
- package/dist/utils/ansi-encoder.js +12 -5
- package/dist/utils/ansi-encoder.js.map +1 -1
- package/dist/utils/editor-utils.d.ts +14 -0
- package/dist/utils/editor-utils.d.ts.map +1 -1
- package/dist/utils/editor-utils.js +172 -0
- package/dist/utils/editor-utils.js.map +1 -1
- package/dist/utils/input-classifier.d.ts.map +1 -1
- package/dist/utils/input-classifier.js +2 -1
- package/dist/utils/input-classifier.js.map +1 -1
- package/dist/utils/terminal-output.d.ts +3 -1
- package/dist/utils/terminal-output.d.ts.map +1 -1
- package/dist/utils/terminal-output.js +235 -195
- package/dist/utils/terminal-output.js.map +1 -1
- 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
|
-
//
|
|
159
|
-
|
|
160
|
-
|
|
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
|
|
156
|
+
let streamFactory;
|
|
170
157
|
if (parentContext.handler && typeof parentContext.handler.createStream === 'function') {
|
|
171
|
-
|
|
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,
|
|
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 "
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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 =
|
|
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
|
|
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
|
|
469
|
-
|
|
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
|
-
|
|
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
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
if
|
|
506
|
-
|
|
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
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
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
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
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
|
-
|
|
647
|
+
};
|
|
648
|
+
const settleReject = (error) => {
|
|
649
|
+
if (settled) {
|
|
650
|
+
return;
|
|
651
|
+
}
|
|
652
|
+
settled = true;
|
|
523
653
|
this._isConnected = false;
|
|
524
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 "
|
|
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 "
|
|
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 "
|
|
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
|