agent-relay 2.2.0 → 2.2.23
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/src/cli/index.js +445 -293
- package/dist/src/cli/index.js.map +1 -1
- package/package.json +18 -18
- package/packages/acp-bridge/package.json +2 -2
- package/packages/api-types/package.json +1 -1
- package/packages/benchmark/package.json +5 -5
- package/packages/bridge/package.json +7 -7
- package/packages/cli-tester/package.json +1 -1
- package/packages/config/package.json +2 -2
- package/packages/continuity/package.json +2 -2
- package/packages/daemon/package.json +12 -12
- package/packages/hooks/package.json +4 -4
- package/packages/mcp/package.json +5 -5
- package/packages/memory/package.json +2 -2
- package/packages/policy/package.json +2 -2
- package/packages/protocol/package.json +1 -1
- package/packages/resiliency/package.json +1 -1
- package/packages/sdk/package.json +3 -3
- package/packages/spawner/package.json +1 -1
- package/packages/state/package.json +1 -1
- package/packages/storage/package.json +2 -2
- package/packages/telemetry/package.json +1 -1
- package/packages/trajectory/package.json +2 -2
- package/packages/user-directory/package.json +2 -2
- package/packages/utils/package.json +3 -3
- package/packages/wrapper/package.json +6 -6
package/dist/src/cli/index.js
CHANGED
|
@@ -30,7 +30,7 @@ import { initTelemetry, track, enableTelemetry, disableTelemetry, getStatus, isD
|
|
|
30
30
|
import { installMcpConfig } from '@agent-relay/mcp';
|
|
31
31
|
import fs from 'node:fs';
|
|
32
32
|
import path from 'node:path';
|
|
33
|
-
import { homedir } from 'node:os';
|
|
33
|
+
import { homedir, tmpdir } from 'node:os';
|
|
34
34
|
import readline from 'node:readline';
|
|
35
35
|
import { promisify } from 'node:util';
|
|
36
36
|
import { exec, execSync, spawn as spawnProcess } from 'node:child_process';
|
|
@@ -3593,6 +3593,46 @@ program
|
|
|
3593
3593
|
console.log(`Profiling ${agentName}... Press Ctrl+C to stop.`);
|
|
3594
3594
|
});
|
|
3595
3595
|
// ============================================================================
|
|
3596
|
+
// SSH helpers - try ssh2 library, fall back to system ssh command
|
|
3597
|
+
// ============================================================================
|
|
3598
|
+
/**
|
|
3599
|
+
* Try to load the ssh2 library. Returns null if unavailable (e.g. compiled binary).
|
|
3600
|
+
*/
|
|
3601
|
+
async function loadSSH2() {
|
|
3602
|
+
try {
|
|
3603
|
+
return await import('ssh2');
|
|
3604
|
+
}
|
|
3605
|
+
catch {
|
|
3606
|
+
return null;
|
|
3607
|
+
}
|
|
3608
|
+
}
|
|
3609
|
+
/**
|
|
3610
|
+
* Create a temporary SSH_ASKPASS helper script that echoes the given password.
|
|
3611
|
+
* Returns the script path. Caller must clean up.
|
|
3612
|
+
*/
|
|
3613
|
+
function createAskpassScript(password) {
|
|
3614
|
+
const askpassPath = path.join(tmpdir(), `ar-askpass-${process.pid}-${Date.now()}`);
|
|
3615
|
+
// Escape single quotes for the shell
|
|
3616
|
+
const escaped = password.replace(/'/g, "'\"'\"'");
|
|
3617
|
+
fs.writeFileSync(askpassPath, `#!/bin/sh\nprintf '%s\\n' '${escaped}'\n`, { mode: 0o700 });
|
|
3618
|
+
return askpassPath;
|
|
3619
|
+
}
|
|
3620
|
+
/**
|
|
3621
|
+
* Build SSH args common to both auth and connect commands.
|
|
3622
|
+
*/
|
|
3623
|
+
function buildSystemSshArgs(options) {
|
|
3624
|
+
const args = [
|
|
3625
|
+
'-o', 'StrictHostKeyChecking=no',
|
|
3626
|
+
'-o', 'UserKnownHostsFile=/dev/null',
|
|
3627
|
+
'-o', 'LogLevel=ERROR',
|
|
3628
|
+
'-p', String(options.port),
|
|
3629
|
+
];
|
|
3630
|
+
if (options.localPort && options.remotePort) {
|
|
3631
|
+
args.push('-L', `${options.localPort}:localhost:${options.remotePort}`);
|
|
3632
|
+
}
|
|
3633
|
+
return args;
|
|
3634
|
+
}
|
|
3635
|
+
// ============================================================================
|
|
3596
3636
|
// auth - SSH-based interactive provider authentication (cloud workspaces)
|
|
3597
3637
|
// ============================================================================
|
|
3598
3638
|
program
|
|
@@ -3761,244 +3801,300 @@ program
|
|
|
3761
3801
|
console.log(dim(` SSH: ${start.ssh.user}@${start.ssh.host}:${sshPort}`));
|
|
3762
3802
|
console.log(dim(` Command: ${remoteCommand}`));
|
|
3763
3803
|
console.log('');
|
|
3764
|
-
const { Client } = await import('ssh2');
|
|
3765
|
-
const net = await import('node:net');
|
|
3766
3804
|
const TUNNEL_PORT = 1455;
|
|
3767
|
-
const
|
|
3768
|
-
let sshReady = false;
|
|
3769
|
-
const tunnel = { server: null };
|
|
3770
|
-
const sshReadyPromise = new Promise((resolve, reject) => {
|
|
3771
|
-
sshClient.on('ready', () => {
|
|
3772
|
-
sshReady = true;
|
|
3773
|
-
// Set up port forwarding for OAuth callbacks: localhost:1455 -> workspace:1455
|
|
3774
|
-
tunnel.server = net.createServer((localSocket) => {
|
|
3775
|
-
sshClient.forwardOut('127.0.0.1', TUNNEL_PORT, 'localhost', TUNNEL_PORT, (err, stream) => {
|
|
3776
|
-
if (err) {
|
|
3777
|
-
localSocket.end();
|
|
3778
|
-
return;
|
|
3779
|
-
}
|
|
3780
|
-
localSocket.pipe(stream).pipe(localSocket);
|
|
3781
|
-
});
|
|
3782
|
-
});
|
|
3783
|
-
tunnel.server.on('error', (err) => {
|
|
3784
|
-
if (err.code === 'EADDRINUSE') {
|
|
3785
|
-
console.log(dim(`Note: Port ${TUNNEL_PORT} in use, OAuth callbacks may not work.`));
|
|
3786
|
-
}
|
|
3787
|
-
// Non-fatal: resolve anyway so the auth command still runs.
|
|
3788
|
-
// Device-flow providers don't need port forwarding.
|
|
3789
|
-
resolve();
|
|
3790
|
-
});
|
|
3791
|
-
tunnel.server.listen(TUNNEL_PORT, '127.0.0.1', () => {
|
|
3792
|
-
resolve();
|
|
3793
|
-
});
|
|
3794
|
-
});
|
|
3795
|
-
sshClient.on('error', (err) => {
|
|
3796
|
-
let msg;
|
|
3797
|
-
if (err.message.includes('Authentication')) {
|
|
3798
|
-
msg = 'SSH authentication failed.';
|
|
3799
|
-
}
|
|
3800
|
-
else if (err.message.includes('ECONNREFUSED')) {
|
|
3801
|
-
msg = `Cannot connect to SSH server at ${start.ssh.host}:${sshPort}. Is the workspace running and SSH enabled?`;
|
|
3802
|
-
}
|
|
3803
|
-
else if (err.message.includes('ENOTFOUND') || err.message.includes('getaddrinfo')) {
|
|
3804
|
-
msg = `Cannot resolve hostname: ${start.ssh.host}. Check network connectivity.`;
|
|
3805
|
-
}
|
|
3806
|
-
else if (err.message.includes('ETIMEDOUT')) {
|
|
3807
|
-
msg = `Connection timed out to ${start.ssh.host}:${sshPort}. Is the workspace running?`;
|
|
3808
|
-
}
|
|
3809
|
-
else {
|
|
3810
|
-
msg = `SSH error: ${err.message}`;
|
|
3811
|
-
}
|
|
3812
|
-
reject(new Error(msg));
|
|
3813
|
-
});
|
|
3814
|
-
sshClient.on('close', () => {
|
|
3815
|
-
if (!sshReady) {
|
|
3816
|
-
reject(new Error(`SSH connection to ${start.ssh.host}:${sshPort} closed unexpectedly.`));
|
|
3817
|
-
}
|
|
3818
|
-
});
|
|
3819
|
-
});
|
|
3805
|
+
const ssh2 = await loadSSH2();
|
|
3820
3806
|
console.log(yellow('Connecting via SSH...'));
|
|
3821
3807
|
console.log(dim(` Tunnel: localhost:${TUNNEL_PORT} → workspace:${TUNNEL_PORT}`));
|
|
3822
3808
|
console.log(dim(` Running: ${remoteCommand}`));
|
|
3823
3809
|
console.log('');
|
|
3824
|
-
try {
|
|
3825
|
-
sshClient.connect({
|
|
3826
|
-
host: start.ssh.host,
|
|
3827
|
-
port: sshPort,
|
|
3828
|
-
username: start.ssh.user,
|
|
3829
|
-
password: start.ssh.password,
|
|
3830
|
-
readyTimeout: 10000,
|
|
3831
|
-
// Workspace containers use ephemeral SSH hosts; skip host key checking.
|
|
3832
|
-
hostVerifier: () => true,
|
|
3833
|
-
});
|
|
3834
|
-
await Promise.race([
|
|
3835
|
-
sshReadyPromise,
|
|
3836
|
-
new Promise((_, reject) => setTimeout(() => reject(new Error('SSH connection timeout')), 15000)),
|
|
3837
|
-
]);
|
|
3838
|
-
}
|
|
3839
|
-
catch (err) {
|
|
3840
|
-
console.log(red(`Failed to connect via SSH: ${err instanceof Error ? err.message : String(err)}`));
|
|
3841
|
-
if (tunnel.server)
|
|
3842
|
-
tunnel.server.close();
|
|
3843
|
-
sshClient.end();
|
|
3844
|
-
process.exit(1);
|
|
3845
|
-
}
|
|
3846
3810
|
// Get success/error patterns from CLI_AUTH_CONFIG for auto-detection
|
|
3847
3811
|
const successPatterns = providerConfig.successPatterns || [];
|
|
3848
3812
|
const errorPatterns = providerConfig.errorPatterns || [];
|
|
3849
|
-
|
|
3850
|
-
|
|
3851
|
-
|
|
3852
|
-
|
|
3853
|
-
|
|
3854
|
-
|
|
3855
|
-
|
|
3856
|
-
|
|
3857
|
-
|
|
3858
|
-
|
|
3859
|
-
|
|
3860
|
-
|
|
3861
|
-
|
|
3862
|
-
|
|
3863
|
-
|
|
3864
|
-
|
|
3865
|
-
|
|
3866
|
-
|
|
3867
|
-
}
|
|
3868
|
-
catch {
|
|
3869
|
-
// ignore
|
|
3870
|
-
}
|
|
3871
|
-
stdin.resume();
|
|
3872
|
-
const onStdinData = (data) => {
|
|
3873
|
-
// Escape (0x1b) or Ctrl+C (0x03) after auth success → close session
|
|
3874
|
-
if (authDetected && (data[0] === 0x1b || data[0] === 0x03)) {
|
|
3875
|
-
cleanup();
|
|
3876
|
-
clearTimeout(timer);
|
|
3877
|
-
try {
|
|
3878
|
-
stream.close();
|
|
3879
|
-
}
|
|
3880
|
-
catch {
|
|
3881
|
-
// ignore
|
|
3813
|
+
let execResult = null;
|
|
3814
|
+
let execError = null;
|
|
3815
|
+
if (ssh2) {
|
|
3816
|
+
// Use ssh2 library (available in dev / npm installs)
|
|
3817
|
+
const { Client } = ssh2;
|
|
3818
|
+
const net = await import('node:net');
|
|
3819
|
+
const sshClient = new Client();
|
|
3820
|
+
let sshReady = false;
|
|
3821
|
+
const tunnel = { server: null };
|
|
3822
|
+
const sshReadyPromise = new Promise((resolve, reject) => {
|
|
3823
|
+
sshClient.on('ready', () => {
|
|
3824
|
+
sshReady = true;
|
|
3825
|
+
// Set up port forwarding for OAuth callbacks: localhost:1455 -> workspace:1455
|
|
3826
|
+
tunnel.server = net.createServer((localSocket) => {
|
|
3827
|
+
sshClient.forwardOut('127.0.0.1', TUNNEL_PORT, 'localhost', TUNNEL_PORT, (err, stream) => {
|
|
3828
|
+
if (err) {
|
|
3829
|
+
localSocket.end();
|
|
3830
|
+
return;
|
|
3882
3831
|
}
|
|
3883
|
-
|
|
3832
|
+
localSocket.pipe(stream).pipe(localSocket);
|
|
3833
|
+
});
|
|
3834
|
+
});
|
|
3835
|
+
tunnel.server.on('error', (err) => {
|
|
3836
|
+
if (err.code === 'EADDRINUSE') {
|
|
3837
|
+
console.log(dim(`Note: Port ${TUNNEL_PORT} in use, OAuth callbacks may not work.`));
|
|
3884
3838
|
}
|
|
3885
|
-
|
|
3886
|
-
|
|
3887
|
-
|
|
3888
|
-
|
|
3889
|
-
|
|
3890
|
-
|
|
3839
|
+
// Non-fatal: resolve anyway so the auth command still runs.
|
|
3840
|
+
// Device-flow providers don't need port forwarding.
|
|
3841
|
+
resolve();
|
|
3842
|
+
});
|
|
3843
|
+
tunnel.server.listen(TUNNEL_PORT, '127.0.0.1', () => {
|
|
3844
|
+
resolve();
|
|
3845
|
+
});
|
|
3846
|
+
});
|
|
3847
|
+
sshClient.on('error', (err) => {
|
|
3848
|
+
let msg;
|
|
3849
|
+
if (err.message.includes('Authentication')) {
|
|
3850
|
+
msg = 'SSH authentication failed.';
|
|
3851
|
+
}
|
|
3852
|
+
else if (err.message.includes('ECONNREFUSED')) {
|
|
3853
|
+
msg = `Cannot connect to SSH server at ${start.ssh.host}:${sshPort}. Is the workspace running and SSH enabled?`;
|
|
3854
|
+
}
|
|
3855
|
+
else if (err.message.includes('ENOTFOUND') || err.message.includes('getaddrinfo')) {
|
|
3856
|
+
msg = `Cannot resolve hostname: ${start.ssh.host}. Check network connectivity.`;
|
|
3857
|
+
}
|
|
3858
|
+
else if (err.message.includes('ETIMEDOUT')) {
|
|
3859
|
+
msg = `Connection timed out to ${start.ssh.host}:${sshPort}. Is the workspace running?`;
|
|
3860
|
+
}
|
|
3861
|
+
else {
|
|
3862
|
+
msg = `SSH error: ${err.message}`;
|
|
3863
|
+
}
|
|
3864
|
+
reject(new Error(msg));
|
|
3865
|
+
});
|
|
3866
|
+
sshClient.on('close', () => {
|
|
3867
|
+
if (!sshReady) {
|
|
3868
|
+
reject(new Error(`SSH connection to ${start.ssh.host}:${sshPort} closed unexpectedly.`));
|
|
3869
|
+
}
|
|
3870
|
+
});
|
|
3871
|
+
});
|
|
3872
|
+
try {
|
|
3873
|
+
sshClient.connect({
|
|
3874
|
+
host: start.ssh.host,
|
|
3875
|
+
port: sshPort,
|
|
3876
|
+
username: start.ssh.user,
|
|
3877
|
+
password: start.ssh.password,
|
|
3878
|
+
readyTimeout: 10000,
|
|
3879
|
+
// Workspace containers use ephemeral SSH hosts; skip host key checking.
|
|
3880
|
+
hostVerifier: () => true,
|
|
3881
|
+
});
|
|
3882
|
+
await Promise.race([
|
|
3883
|
+
sshReadyPromise,
|
|
3884
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error('SSH connection timeout')), 15000)),
|
|
3885
|
+
]);
|
|
3886
|
+
}
|
|
3887
|
+
catch (err) {
|
|
3888
|
+
console.log(red(`Failed to connect via SSH: ${err instanceof Error ? err.message : String(err)}`));
|
|
3889
|
+
if (tunnel.server)
|
|
3890
|
+
tunnel.server.close();
|
|
3891
|
+
sshClient.end();
|
|
3892
|
+
process.exit(1);
|
|
3893
|
+
}
|
|
3894
|
+
const execInteractive = async (cmd, timeoutMs) => {
|
|
3895
|
+
return await new Promise((resolve, reject) => {
|
|
3896
|
+
const cols = process.stdout.columns || 80;
|
|
3897
|
+
const rows = process.stdout.rows || 24;
|
|
3898
|
+
const term = process.env.TERM || 'xterm-256color';
|
|
3899
|
+
sshClient.exec(cmd, { pty: { term, cols, rows } }, (err, stream) => {
|
|
3900
|
+
if (err)
|
|
3901
|
+
return reject(err);
|
|
3902
|
+
let exitCode = null;
|
|
3903
|
+
let exitSignal = null;
|
|
3904
|
+
let authDetected = false;
|
|
3905
|
+
let outputBuffer = ''; // Rolling buffer for pattern matching
|
|
3906
|
+
const stdin = process.stdin;
|
|
3907
|
+
const stdout = process.stdout;
|
|
3908
|
+
const stderr = process.stderr;
|
|
3909
|
+
const wasRaw = stdin.isRaw ?? false;
|
|
3891
3910
|
try {
|
|
3892
|
-
stdin.setRawMode?.(
|
|
3911
|
+
stdin.setRawMode?.(true);
|
|
3893
3912
|
}
|
|
3894
3913
|
catch {
|
|
3895
3914
|
// ignore
|
|
3896
3915
|
}
|
|
3897
|
-
stdin.
|
|
3898
|
-
|
|
3899
|
-
|
|
3900
|
-
|
|
3901
|
-
authDetected = true;
|
|
3902
|
-
// Don't try to auto-navigate post-login prompts (trust directory,
|
|
3903
|
-
// bypass permissions, etc.) — they vary by CLI version and are fragile
|
|
3904
|
-
// to automate. Just tell the user they're done.
|
|
3905
|
-
stdout.write('\n');
|
|
3906
|
-
stdout.write(green(' ✓ Authentication successful!') + '\n');
|
|
3907
|
-
stdout.write(dim(' Press Escape or Ctrl+C to exit.') + '\n');
|
|
3908
|
-
stdout.write('\n');
|
|
3909
|
-
};
|
|
3910
|
-
stream.on('data', (data) => {
|
|
3911
|
-
stdout.write(data);
|
|
3912
|
-
// Accumulate output for pattern matching (keep last 8KB to avoid memory growth)
|
|
3913
|
-
// Ink-based CLIs use heavy ANSI escape codes, so raw output is much
|
|
3914
|
-
// larger than visible text. 8KB ensures success patterns aren't truncated.
|
|
3915
|
-
const text = data.toString();
|
|
3916
|
-
outputBuffer += text;
|
|
3917
|
-
if (outputBuffer.length > 8192) {
|
|
3918
|
-
outputBuffer = outputBuffer.slice(-8192);
|
|
3919
|
-
}
|
|
3920
|
-
// Check for auth success patterns
|
|
3921
|
-
if (!authDetected && successPatterns.length > 0) {
|
|
3922
|
-
const clean = stripAnsiCodes(outputBuffer);
|
|
3923
|
-
for (const pattern of successPatterns) {
|
|
3924
|
-
if (pattern.test(clean)) {
|
|
3925
|
-
closeOnAuthSuccess();
|
|
3926
|
-
break;
|
|
3927
|
-
}
|
|
3928
|
-
}
|
|
3929
|
-
}
|
|
3930
|
-
// Check for auth error patterns (early exit instead of waiting for timeout)
|
|
3931
|
-
if (!authDetected && errorPatterns.length > 0) {
|
|
3932
|
-
const matched = findMatchingError(outputBuffer, errorPatterns);
|
|
3933
|
-
if (matched) {
|
|
3934
|
-
clearTimeout(timer);
|
|
3916
|
+
stdin.resume();
|
|
3917
|
+
const onStdinData = (data) => {
|
|
3918
|
+
// Escape (0x1b) or Ctrl+C (0x03) after auth success → close session
|
|
3919
|
+
if (authDetected && (data[0] === 0x1b || data[0] === 0x03)) {
|
|
3935
3920
|
cleanup();
|
|
3921
|
+
clearTimeout(timer);
|
|
3936
3922
|
try {
|
|
3937
3923
|
stream.close();
|
|
3938
3924
|
}
|
|
3939
|
-
catch {
|
|
3940
|
-
|
|
3925
|
+
catch {
|
|
3926
|
+
// ignore
|
|
3927
|
+
}
|
|
3928
|
+
return;
|
|
3941
3929
|
}
|
|
3942
|
-
|
|
3943
|
-
|
|
3944
|
-
|
|
3945
|
-
|
|
3946
|
-
|
|
3947
|
-
|
|
3948
|
-
|
|
3949
|
-
|
|
3950
|
-
|
|
3951
|
-
|
|
3952
|
-
|
|
3953
|
-
|
|
3954
|
-
|
|
3955
|
-
|
|
3956
|
-
|
|
3957
|
-
|
|
3958
|
-
|
|
3959
|
-
|
|
3960
|
-
|
|
3961
|
-
|
|
3962
|
-
|
|
3963
|
-
|
|
3964
|
-
|
|
3965
|
-
|
|
3966
|
-
|
|
3967
|
-
|
|
3968
|
-
|
|
3969
|
-
|
|
3970
|
-
|
|
3930
|
+
stream.write(data);
|
|
3931
|
+
};
|
|
3932
|
+
stdin.on('data', onStdinData);
|
|
3933
|
+
const cleanup = () => {
|
|
3934
|
+
stdin.off('data', onStdinData);
|
|
3935
|
+
stdout.off('resize', onResize);
|
|
3936
|
+
try {
|
|
3937
|
+
stdin.setRawMode?.(wasRaw);
|
|
3938
|
+
}
|
|
3939
|
+
catch {
|
|
3940
|
+
// ignore
|
|
3941
|
+
}
|
|
3942
|
+
stdin.pause();
|
|
3943
|
+
};
|
|
3944
|
+
// Notify user when auth success is detected
|
|
3945
|
+
const closeOnAuthSuccess = () => {
|
|
3946
|
+
authDetected = true;
|
|
3947
|
+
// Don't try to auto-navigate post-login prompts (trust directory,
|
|
3948
|
+
// bypass permissions, etc.) — they vary by CLI version and are fragile
|
|
3949
|
+
// to automate. Just tell the user they're done.
|
|
3950
|
+
stdout.write('\n');
|
|
3951
|
+
stdout.write(green(' ✓ Authentication successful!') + '\n');
|
|
3952
|
+
stdout.write(dim(' Press Escape or Ctrl+C to exit.') + '\n');
|
|
3953
|
+
stdout.write('\n');
|
|
3954
|
+
};
|
|
3955
|
+
stream.on('data', (data) => {
|
|
3956
|
+
stdout.write(data);
|
|
3957
|
+
// Accumulate output for pattern matching (keep last 8KB to avoid memory growth)
|
|
3958
|
+
// Ink-based CLIs use heavy ANSI escape codes, so raw output is much
|
|
3959
|
+
// larger than visible text. 8KB ensures success patterns aren't truncated.
|
|
3960
|
+
const text = data.toString();
|
|
3961
|
+
outputBuffer += text;
|
|
3962
|
+
if (outputBuffer.length > 8192) {
|
|
3963
|
+
outputBuffer = outputBuffer.slice(-8192);
|
|
3964
|
+
}
|
|
3965
|
+
// Check for auth success patterns
|
|
3966
|
+
if (!authDetected && successPatterns.length > 0) {
|
|
3967
|
+
const clean = stripAnsiCodes(outputBuffer);
|
|
3968
|
+
for (const pattern of successPatterns) {
|
|
3969
|
+
if (pattern.test(clean)) {
|
|
3970
|
+
closeOnAuthSuccess();
|
|
3971
|
+
break;
|
|
3972
|
+
}
|
|
3973
|
+
}
|
|
3974
|
+
}
|
|
3975
|
+
// Check for auth error patterns (early exit instead of waiting for timeout)
|
|
3976
|
+
if (!authDetected && errorPatterns.length > 0) {
|
|
3977
|
+
const matched = findMatchingError(outputBuffer, errorPatterns);
|
|
3978
|
+
if (matched) {
|
|
3979
|
+
clearTimeout(timer);
|
|
3980
|
+
cleanup();
|
|
3981
|
+
try {
|
|
3982
|
+
stream.close();
|
|
3983
|
+
}
|
|
3984
|
+
catch { /* ignore */ }
|
|
3985
|
+
reject(new Error(matched.message + (matched.hint ? ` ${matched.hint}` : '')));
|
|
3986
|
+
}
|
|
3987
|
+
}
|
|
3988
|
+
});
|
|
3989
|
+
stream.stderr.on('data', (data) => {
|
|
3990
|
+
stderr.write(data);
|
|
3991
|
+
});
|
|
3992
|
+
const onResize = () => {
|
|
3993
|
+
try {
|
|
3994
|
+
stream.setWindow(stdout.rows || 24, stdout.columns || 80, 0, 0);
|
|
3995
|
+
}
|
|
3996
|
+
catch {
|
|
3997
|
+
// ignore
|
|
3998
|
+
}
|
|
3999
|
+
};
|
|
4000
|
+
stdout.on('resize', onResize);
|
|
4001
|
+
const timer = setTimeout(() => {
|
|
4002
|
+
cleanup();
|
|
4003
|
+
try {
|
|
4004
|
+
stream.close();
|
|
4005
|
+
}
|
|
4006
|
+
catch {
|
|
4007
|
+
// ignore
|
|
4008
|
+
}
|
|
4009
|
+
reject(new Error(`Authentication timed out after ${Math.floor(timeoutMs / 1000)}s`));
|
|
4010
|
+
}, timeoutMs);
|
|
4011
|
+
stream.on('exit', (code, signal) => {
|
|
4012
|
+
if (typeof code === 'number')
|
|
4013
|
+
exitCode = code;
|
|
4014
|
+
if (typeof signal === 'string')
|
|
4015
|
+
exitSignal = signal;
|
|
4016
|
+
});
|
|
4017
|
+
stream.on('close', () => {
|
|
4018
|
+
clearTimeout(timer);
|
|
4019
|
+
cleanup();
|
|
4020
|
+
resolve({ exitCode, exitSignal, authDetected });
|
|
4021
|
+
});
|
|
4022
|
+
stream.on('error', (e) => {
|
|
4023
|
+
clearTimeout(timer);
|
|
4024
|
+
cleanup();
|
|
4025
|
+
reject(e instanceof Error ? e : new Error(String(e)));
|
|
4026
|
+
});
|
|
3971
4027
|
});
|
|
3972
|
-
|
|
3973
|
-
|
|
3974
|
-
|
|
3975
|
-
|
|
4028
|
+
});
|
|
4029
|
+
};
|
|
4030
|
+
try {
|
|
4031
|
+
console.log(yellow('Starting interactive authentication...'));
|
|
4032
|
+
console.log(dim('Follow the prompts below. The session will close automatically when auth completes.'));
|
|
4033
|
+
console.log('');
|
|
4034
|
+
execResult = await execInteractive(remoteCommand, TIMEOUT_MS);
|
|
4035
|
+
}
|
|
4036
|
+
catch (err) {
|
|
4037
|
+
execError = err instanceof Error ? err : new Error(String(err));
|
|
4038
|
+
console.log('');
|
|
4039
|
+
console.log(red(`Remote auth command failed: ${execError.message}`));
|
|
4040
|
+
}
|
|
4041
|
+
finally {
|
|
4042
|
+
if (tunnel.server)
|
|
4043
|
+
tunnel.server.close();
|
|
4044
|
+
sshClient.end();
|
|
4045
|
+
}
|
|
4046
|
+
}
|
|
4047
|
+
else {
|
|
4048
|
+
// System ssh fallback for compiled binaries where ssh2 is unavailable
|
|
4049
|
+
const askpassPath = createAskpassScript(start.ssh.password);
|
|
4050
|
+
try {
|
|
4051
|
+
const sshArgs = buildSystemSshArgs({
|
|
4052
|
+
host: start.ssh.host,
|
|
4053
|
+
port: sshPort,
|
|
4054
|
+
username: start.ssh.user,
|
|
4055
|
+
localPort: TUNNEL_PORT,
|
|
4056
|
+
remotePort: TUNNEL_PORT,
|
|
4057
|
+
});
|
|
4058
|
+
sshArgs.push('-tt'); // Force PTY for interactive session
|
|
4059
|
+
sshArgs.push(`${start.ssh.user}@${start.ssh.host}`);
|
|
4060
|
+
sshArgs.push(remoteCommand);
|
|
4061
|
+
console.log(yellow('Starting interactive authentication...'));
|
|
4062
|
+
console.log(dim('Follow the prompts below.'));
|
|
4063
|
+
console.log('');
|
|
4064
|
+
const child = spawnProcess('ssh', sshArgs, {
|
|
4065
|
+
stdio: 'inherit',
|
|
4066
|
+
env: {
|
|
4067
|
+
...process.env,
|
|
4068
|
+
SSH_ASKPASS: askpassPath,
|
|
4069
|
+
SSH_ASKPASS_REQUIRE: 'force',
|
|
4070
|
+
DISPLAY: process.env.DISPLAY || ':0',
|
|
4071
|
+
},
|
|
4072
|
+
});
|
|
4073
|
+
execResult = await new Promise((resolve) => {
|
|
4074
|
+
child.on('exit', (code, signal) => {
|
|
4075
|
+
resolve({
|
|
4076
|
+
exitCode: code,
|
|
4077
|
+
exitSignal: signal ? String(signal) : null,
|
|
4078
|
+
authDetected: code === 0,
|
|
4079
|
+
});
|
|
3976
4080
|
});
|
|
3977
|
-
|
|
3978
|
-
|
|
3979
|
-
|
|
3980
|
-
reject(e instanceof Error ? e : new Error(String(e)));
|
|
4081
|
+
child.on('error', (err) => {
|
|
4082
|
+
console.log(red(`Failed to launch ssh: ${err.message}`));
|
|
4083
|
+
resolve({ exitCode: 1, exitSignal: null, authDetected: false });
|
|
3981
4084
|
});
|
|
3982
4085
|
});
|
|
3983
|
-
}
|
|
3984
|
-
|
|
3985
|
-
|
|
3986
|
-
|
|
3987
|
-
|
|
3988
|
-
|
|
3989
|
-
|
|
3990
|
-
|
|
3991
|
-
|
|
3992
|
-
|
|
3993
|
-
|
|
3994
|
-
|
|
3995
|
-
console.log('');
|
|
3996
|
-
console.log(red(`Remote auth command failed: ${execError.message}`));
|
|
3997
|
-
}
|
|
3998
|
-
finally {
|
|
3999
|
-
if (tunnel.server)
|
|
4000
|
-
tunnel.server.close();
|
|
4001
|
-
sshClient.end();
|
|
4086
|
+
}
|
|
4087
|
+
catch (err) {
|
|
4088
|
+
execError = err instanceof Error ? err : new Error(String(err));
|
|
4089
|
+
console.log('');
|
|
4090
|
+
console.log(red(`SSH error: ${execError.message}`));
|
|
4091
|
+
}
|
|
4092
|
+
finally {
|
|
4093
|
+
try {
|
|
4094
|
+
fs.unlinkSync(askpassPath);
|
|
4095
|
+
}
|
|
4096
|
+
catch { /* ignore */ }
|
|
4097
|
+
}
|
|
4002
4098
|
}
|
|
4003
4099
|
// Step 2: Notify cloud completion (cloud will verify and persist credentials).
|
|
4004
4100
|
console.log('');
|
|
@@ -4178,94 +4274,156 @@ async function runCliAuth(providerArg, options) {
|
|
|
4178
4274
|
}
|
|
4179
4275
|
console.log(`Workspace: ${cyan(tunnelInfo.workspaceName)}`);
|
|
4180
4276
|
console.log('');
|
|
4181
|
-
// Establish SSH tunnel
|
|
4277
|
+
// Establish SSH tunnel
|
|
4182
4278
|
console.log(yellow('Establishing SSH tunnel...'));
|
|
4183
4279
|
console.log(dim(` SSH: ${tunnelInfo.host}:${tunnelInfo.port}`));
|
|
4184
4280
|
console.log(dim(` Tunnel: localhost:${TUNNEL_PORT} → workspace:${tunnelInfo.tunnelPort}`));
|
|
4185
4281
|
console.log('');
|
|
4186
|
-
const
|
|
4187
|
-
|
|
4188
|
-
|
|
4189
|
-
|
|
4190
|
-
|
|
4191
|
-
|
|
4192
|
-
|
|
4193
|
-
|
|
4194
|
-
|
|
4195
|
-
|
|
4196
|
-
|
|
4197
|
-
|
|
4198
|
-
|
|
4199
|
-
|
|
4200
|
-
|
|
4201
|
-
|
|
4282
|
+
const ssh2 = await loadSSH2();
|
|
4283
|
+
let sshCleanup;
|
|
4284
|
+
if (ssh2) {
|
|
4285
|
+
// Use ssh2 library (available in dev / npm installs)
|
|
4286
|
+
const { Client } = ssh2;
|
|
4287
|
+
const net = await import('node:net');
|
|
4288
|
+
const sshClient = new Client();
|
|
4289
|
+
const tunnel = { server: null };
|
|
4290
|
+
let tunnelReady = false;
|
|
4291
|
+
let tunnelError = null;
|
|
4292
|
+
const tunnelPromise = new Promise((resolve, reject) => {
|
|
4293
|
+
sshClient.on('ready', () => {
|
|
4294
|
+
tunnel.server = net.createServer((localSocket) => {
|
|
4295
|
+
sshClient.forwardOut('127.0.0.1', TUNNEL_PORT, 'localhost', tunnelInfo.tunnelPort, (err, stream) => {
|
|
4296
|
+
if (err) {
|
|
4297
|
+
localSocket.end();
|
|
4298
|
+
return;
|
|
4299
|
+
}
|
|
4300
|
+
localSocket.pipe(stream).pipe(localSocket);
|
|
4301
|
+
});
|
|
4302
|
+
});
|
|
4303
|
+
tunnel.server.on('error', (err) => {
|
|
4304
|
+
if (err.code === 'EADDRINUSE') {
|
|
4305
|
+
tunnelError = `Port ${TUNNEL_PORT} is already in use. Close any other applications using this port.`;
|
|
4306
|
+
}
|
|
4307
|
+
else {
|
|
4308
|
+
tunnelError = err.message;
|
|
4202
4309
|
}
|
|
4203
|
-
|
|
4310
|
+
reject(new Error(tunnelError));
|
|
4311
|
+
});
|
|
4312
|
+
tunnel.server.listen(TUNNEL_PORT, '127.0.0.1', () => {
|
|
4313
|
+
tunnelReady = true;
|
|
4314
|
+
resolve();
|
|
4204
4315
|
});
|
|
4205
4316
|
});
|
|
4206
|
-
|
|
4207
|
-
if (err.
|
|
4208
|
-
tunnelError =
|
|
4317
|
+
sshClient.on('error', (err) => {
|
|
4318
|
+
if (err.message.includes('Authentication')) {
|
|
4319
|
+
tunnelError = 'SSH authentication failed. Check the password.';
|
|
4320
|
+
}
|
|
4321
|
+
else if (err.message.includes('ECONNREFUSED')) {
|
|
4322
|
+
tunnelError = `Cannot connect to SSH server at ${tunnelInfo.host}:${tunnelInfo.port}. Is the workspace running and SSH enabled?`;
|
|
4323
|
+
}
|
|
4324
|
+
else if (err.message.includes('ENOTFOUND') || err.message.includes('getaddrinfo')) {
|
|
4325
|
+
tunnelError = `Cannot resolve hostname: ${tunnelInfo.host}. Check network connectivity.`;
|
|
4326
|
+
}
|
|
4327
|
+
else if (err.message.includes('ETIMEDOUT')) {
|
|
4328
|
+
tunnelError = `Connection timed out to ${tunnelInfo.host}:${tunnelInfo.port}. Is the workspace running?`;
|
|
4209
4329
|
}
|
|
4210
4330
|
else {
|
|
4211
|
-
tunnelError = err.message
|
|
4331
|
+
tunnelError = `SSH error: ${err.message}`;
|
|
4212
4332
|
}
|
|
4213
4333
|
reject(new Error(tunnelError));
|
|
4214
4334
|
});
|
|
4215
|
-
|
|
4216
|
-
tunnelReady
|
|
4217
|
-
|
|
4218
|
-
|
|
4219
|
-
|
|
4220
|
-
|
|
4221
|
-
if (err.message.includes('Authentication')) {
|
|
4222
|
-
tunnelError = 'SSH authentication failed. Check the password.';
|
|
4223
|
-
}
|
|
4224
|
-
else if (err.message.includes('ECONNREFUSED')) {
|
|
4225
|
-
tunnelError = `Cannot connect to SSH server at ${tunnelInfo.host}:${tunnelInfo.port}. Is the workspace running and SSH enabled?`;
|
|
4226
|
-
}
|
|
4227
|
-
else if (err.message.includes('ENOTFOUND') || err.message.includes('getaddrinfo')) {
|
|
4228
|
-
tunnelError = `Cannot resolve hostname: ${tunnelInfo.host}. Check network connectivity.`;
|
|
4229
|
-
}
|
|
4230
|
-
else if (err.message.includes('ETIMEDOUT')) {
|
|
4231
|
-
tunnelError = `Connection timed out to ${tunnelInfo.host}:${tunnelInfo.port}. Is the workspace running?`;
|
|
4232
|
-
}
|
|
4233
|
-
else {
|
|
4234
|
-
tunnelError = `SSH error: ${err.message}`;
|
|
4235
|
-
}
|
|
4236
|
-
reject(new Error(tunnelError));
|
|
4237
|
-
});
|
|
4238
|
-
sshClient.on('close', () => {
|
|
4239
|
-
if (!tunnelReady) {
|
|
4240
|
-
// Only set error if not already set by error handler
|
|
4241
|
-
if (!tunnelError) {
|
|
4242
|
-
tunnelError = `SSH connection to ${tunnelInfo.host}:${tunnelInfo.port} closed unexpectedly. The workspace may not have SSH enabled or the port may be blocked.`;
|
|
4335
|
+
sshClient.on('close', () => {
|
|
4336
|
+
if (!tunnelReady) {
|
|
4337
|
+
if (!tunnelError) {
|
|
4338
|
+
tunnelError = `SSH connection to ${tunnelInfo.host}:${tunnelInfo.port} closed unexpectedly. The workspace may not have SSH enabled or the port may be blocked.`;
|
|
4339
|
+
}
|
|
4340
|
+
reject(new Error(tunnelError));
|
|
4243
4341
|
}
|
|
4244
|
-
|
|
4245
|
-
|
|
4342
|
+
});
|
|
4343
|
+
sshClient.connect({
|
|
4344
|
+
host: tunnelInfo.host,
|
|
4345
|
+
port: tunnelInfo.port,
|
|
4346
|
+
username: tunnelInfo.user,
|
|
4347
|
+
password: tunnelInfo.password,
|
|
4348
|
+
readyTimeout: 10000,
|
|
4349
|
+
hostVerifier: () => true,
|
|
4350
|
+
});
|
|
4246
4351
|
});
|
|
4247
|
-
|
|
4248
|
-
|
|
4352
|
+
try {
|
|
4353
|
+
await Promise.race([
|
|
4354
|
+
tunnelPromise,
|
|
4355
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error('SSH connection timeout')), 15000)),
|
|
4356
|
+
]);
|
|
4357
|
+
}
|
|
4358
|
+
catch (err) {
|
|
4359
|
+
console.log(red(`Failed to establish tunnel: ${err instanceof Error ? err.message : String(err)}`));
|
|
4360
|
+
sshClient.end();
|
|
4361
|
+
process.exit(1);
|
|
4362
|
+
}
|
|
4363
|
+
sshCleanup = () => {
|
|
4364
|
+
if (tunnel.server)
|
|
4365
|
+
tunnel.server.close();
|
|
4366
|
+
sshClient.end();
|
|
4367
|
+
};
|
|
4368
|
+
}
|
|
4369
|
+
else {
|
|
4370
|
+
// System ssh fallback for compiled binaries where ssh2 is unavailable
|
|
4371
|
+
const askpassPath = createAskpassScript(tunnelInfo.password);
|
|
4372
|
+
const sshArgs = buildSystemSshArgs({
|
|
4249
4373
|
host: tunnelInfo.host,
|
|
4250
4374
|
port: tunnelInfo.port,
|
|
4251
4375
|
username: tunnelInfo.user,
|
|
4252
|
-
|
|
4253
|
-
|
|
4254
|
-
// Disable host key checking for simplicity (workspace containers)
|
|
4255
|
-
hostVerifier: () => true,
|
|
4376
|
+
localPort: TUNNEL_PORT,
|
|
4377
|
+
remotePort: tunnelInfo.tunnelPort,
|
|
4256
4378
|
});
|
|
4257
|
-
|
|
4258
|
-
|
|
4259
|
-
|
|
4260
|
-
|
|
4261
|
-
|
|
4262
|
-
|
|
4263
|
-
|
|
4264
|
-
|
|
4265
|
-
|
|
4266
|
-
|
|
4267
|
-
|
|
4268
|
-
|
|
4379
|
+
sshArgs.push('-N'); // No command, just port forwarding
|
|
4380
|
+
sshArgs.push(`${tunnelInfo.user}@${tunnelInfo.host}`);
|
|
4381
|
+
const child = spawnProcess('ssh', sshArgs, {
|
|
4382
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
4383
|
+
env: {
|
|
4384
|
+
...process.env,
|
|
4385
|
+
SSH_ASKPASS: askpassPath,
|
|
4386
|
+
SSH_ASKPASS_REQUIRE: 'force',
|
|
4387
|
+
DISPLAY: process.env.DISPLAY || ':0',
|
|
4388
|
+
},
|
|
4389
|
+
});
|
|
4390
|
+
// Wait for ssh to connect (or fail)
|
|
4391
|
+
const connected = await new Promise((resolve) => {
|
|
4392
|
+
const timeout = setTimeout(() => resolve(true), 3000); // Assume connected after 3s
|
|
4393
|
+
child.on('error', (err) => {
|
|
4394
|
+
clearTimeout(timeout);
|
|
4395
|
+
console.log(red(`Failed to launch ssh: ${err.message}`));
|
|
4396
|
+
resolve(false);
|
|
4397
|
+
});
|
|
4398
|
+
child.on('exit', (code) => {
|
|
4399
|
+
clearTimeout(timeout);
|
|
4400
|
+
if (code !== null && code !== 0) {
|
|
4401
|
+
console.log(red(`SSH exited with code ${code}. Check credentials and connectivity.`));
|
|
4402
|
+
}
|
|
4403
|
+
// Any exit during the connection window means tunnel failed
|
|
4404
|
+
resolve(false);
|
|
4405
|
+
});
|
|
4406
|
+
child.stderr?.on('data', (data) => {
|
|
4407
|
+
const msg = data.toString().trim();
|
|
4408
|
+
if (msg && !msg.includes('Warning:')) {
|
|
4409
|
+
console.log(dim(` ssh: ${msg}`));
|
|
4410
|
+
}
|
|
4411
|
+
});
|
|
4412
|
+
});
|
|
4413
|
+
if (!connected) {
|
|
4414
|
+
try {
|
|
4415
|
+
fs.unlinkSync(askpassPath);
|
|
4416
|
+
}
|
|
4417
|
+
catch { /* ignore */ }
|
|
4418
|
+
process.exit(1);
|
|
4419
|
+
}
|
|
4420
|
+
sshCleanup = () => {
|
|
4421
|
+
child.kill();
|
|
4422
|
+
try {
|
|
4423
|
+
fs.unlinkSync(askpassPath);
|
|
4424
|
+
}
|
|
4425
|
+
catch { /* ignore */ }
|
|
4426
|
+
};
|
|
4269
4427
|
}
|
|
4270
4428
|
console.log(green('✓ SSH tunnel established!'));
|
|
4271
4429
|
console.log('');
|
|
@@ -4273,10 +4431,7 @@ async function runCliAuth(providerArg, options) {
|
|
|
4273
4431
|
const cleanup = () => {
|
|
4274
4432
|
console.log('');
|
|
4275
4433
|
console.log(dim('Shutting down...'));
|
|
4276
|
-
|
|
4277
|
-
tunnel.server.close();
|
|
4278
|
-
}
|
|
4279
|
-
sshClient.end();
|
|
4434
|
+
sshCleanup();
|
|
4280
4435
|
process.exit(0);
|
|
4281
4436
|
};
|
|
4282
4437
|
process.on('SIGINT', cleanup);
|
|
@@ -4327,10 +4482,7 @@ async function runCliAuth(providerArg, options) {
|
|
|
4327
4482
|
}
|
|
4328
4483
|
}
|
|
4329
4484
|
// Cleanup SSH tunnel
|
|
4330
|
-
|
|
4331
|
-
tunnel.server.close();
|
|
4332
|
-
}
|
|
4333
|
-
sshClient.end();
|
|
4485
|
+
sshCleanup();
|
|
4334
4486
|
if (authenticated) {
|
|
4335
4487
|
console.log('');
|
|
4336
4488
|
console.log(green('═══════════════════════════════════════════════════'));
|