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.
@@ -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 sshClient = new Client();
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
- const execInteractive = async (cmd, timeoutMs) => {
3850
- return await new Promise((resolve, reject) => {
3851
- const cols = process.stdout.columns || 80;
3852
- const rows = process.stdout.rows || 24;
3853
- const term = process.env.TERM || 'xterm-256color';
3854
- sshClient.exec(cmd, { pty: { term, cols, rows } }, (err, stream) => {
3855
- if (err)
3856
- return reject(err);
3857
- let exitCode = null;
3858
- let exitSignal = null;
3859
- let authDetected = false;
3860
- let outputBuffer = ''; // Rolling buffer for pattern matching
3861
- const stdin = process.stdin;
3862
- const stdout = process.stdout;
3863
- const stderr = process.stderr;
3864
- const wasRaw = stdin.isRaw ?? false;
3865
- try {
3866
- stdin.setRawMode?.(true);
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
- return;
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
- stream.write(data);
3886
- };
3887
- stdin.on('data', onStdinData);
3888
- const cleanup = () => {
3889
- stdin.off('data', onStdinData);
3890
- stdout.off('resize', onResize);
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?.(wasRaw);
3911
+ stdin.setRawMode?.(true);
3893
3912
  }
3894
3913
  catch {
3895
3914
  // ignore
3896
3915
  }
3897
- stdin.pause();
3898
- };
3899
- // Notify user when auth success is detected
3900
- const closeOnAuthSuccess = () => {
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 { /* ignore */ }
3940
- reject(new Error(matched.message + (matched.hint ? ` ${matched.hint}` : '')));
3925
+ catch {
3926
+ // ignore
3927
+ }
3928
+ return;
3941
3929
  }
3942
- }
3943
- });
3944
- stream.stderr.on('data', (data) => {
3945
- stderr.write(data);
3946
- });
3947
- const onResize = () => {
3948
- try {
3949
- stream.setWindow(stdout.rows || 24, stdout.columns || 80, 0, 0);
3950
- }
3951
- catch {
3952
- // ignore
3953
- }
3954
- };
3955
- stdout.on('resize', onResize);
3956
- const timer = setTimeout(() => {
3957
- cleanup();
3958
- try {
3959
- stream.close();
3960
- }
3961
- catch {
3962
- // ignore
3963
- }
3964
- reject(new Error(`Authentication timed out after ${Math.floor(timeoutMs / 1000)}s`));
3965
- }, timeoutMs);
3966
- stream.on('exit', (code, signal) => {
3967
- if (typeof code === 'number')
3968
- exitCode = code;
3969
- if (typeof signal === 'string')
3970
- exitSignal = signal;
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
- stream.on('close', () => {
3973
- clearTimeout(timer);
3974
- cleanup();
3975
- resolve({ exitCode, exitSignal, authDetected });
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
- stream.on('error', (e) => {
3978
- clearTimeout(timer);
3979
- cleanup();
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
- let execResult = null;
3986
- let execError = null;
3987
- try {
3988
- console.log(yellow('Starting interactive authentication...'));
3989
- console.log(dim('Follow the prompts below. The session will close automatically when auth completes.'));
3990
- console.log('');
3991
- execResult = await execInteractive(remoteCommand, TIMEOUT_MS);
3992
- }
3993
- catch (err) {
3994
- execError = err instanceof Error ? err : new Error(String(err));
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 using ssh2 library (no external tools needed)
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 { Client } = await import('ssh2');
4187
- const net = await import('node:net');
4188
- const sshClient = new Client();
4189
- // Use object to hold server reference (avoids TypeScript narrowing issues)
4190
- const tunnel = { server: null };
4191
- let tunnelReady = false;
4192
- let tunnelError = null;
4193
- // Create a promise that resolves when tunnel is ready or rejects on error
4194
- const tunnelPromise = new Promise((resolve, reject) => {
4195
- sshClient.on('ready', () => {
4196
- // Create local server that forwards connections through SSH
4197
- tunnel.server = net.createServer((localSocket) => {
4198
- sshClient.forwardOut('127.0.0.1', TUNNEL_PORT, 'localhost', tunnelInfo.tunnelPort, (err, stream) => {
4199
- if (err) {
4200
- localSocket.end();
4201
- return;
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
- localSocket.pipe(stream).pipe(localSocket);
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
- tunnel.server.on('error', (err) => {
4207
- if (err.code === 'EADDRINUSE') {
4208
- tunnelError = `Port ${TUNNEL_PORT} is already in use. Close any other applications using this port.`;
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
- tunnel.server.listen(TUNNEL_PORT, '127.0.0.1', () => {
4216
- tunnelReady = true;
4217
- resolve();
4218
- });
4219
- });
4220
- sshClient.on('error', (err) => {
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
- reject(new Error(tunnelError));
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
- // Connect to SSH server
4248
- sshClient.connect({
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
- password: tunnelInfo.password,
4253
- readyTimeout: 10000,
4254
- // Disable host key checking for simplicity (workspace containers)
4255
- hostVerifier: () => true,
4376
+ localPort: TUNNEL_PORT,
4377
+ remotePort: tunnelInfo.tunnelPort,
4256
4378
  });
4257
- });
4258
- // Wait for tunnel to establish
4259
- try {
4260
- await Promise.race([
4261
- tunnelPromise,
4262
- new Promise((_, reject) => setTimeout(() => reject(new Error('SSH connection timeout')), 15000)),
4263
- ]);
4264
- }
4265
- catch (err) {
4266
- console.log(red(`Failed to establish tunnel: ${err instanceof Error ? err.message : String(err)}`));
4267
- sshClient.end();
4268
- process.exit(1);
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
- if (tunnel.server) {
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
- if (tunnel.server) {
4331
- tunnel.server.close();
4332
- }
4333
- sshClient.end();
4485
+ sshCleanup();
4334
4486
  if (authenticated) {
4335
4487
  console.log('');
4336
4488
  console.log(green('═══════════════════════════════════════════════════'));