agent-relay 2.1.28-beta.0 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/bin/relay-pty-darwin-arm64 +0 -0
  2. package/bin/relay-pty-darwin-x64 +0 -0
  3. package/bin/relay-pty-linux-arm64 +0 -0
  4. package/bin/relay-pty-linux-x64 +0 -0
  5. package/dist/index.cjs +2 -0
  6. package/dist/src/cli/index.d.ts.map +1 -1
  7. package/dist/src/cli/index.js +580 -18
  8. package/dist/src/cli/index.js.map +1 -1
  9. package/package.json +18 -18
  10. package/packages/acp-bridge/package.json +2 -2
  11. package/packages/api-types/package.json +1 -1
  12. package/packages/benchmark/package.json +5 -5
  13. package/packages/bridge/dist/spawner.d.ts.map +1 -1
  14. package/packages/bridge/dist/spawner.js +2 -0
  15. package/packages/bridge/dist/spawner.js.map +1 -1
  16. package/packages/bridge/package.json +7 -7
  17. package/packages/bridge/src/spawner.ts +2 -0
  18. package/packages/cli-tester/package.json +1 -1
  19. package/packages/config/package.json +2 -2
  20. package/packages/continuity/package.json +2 -2
  21. package/packages/daemon/dist/api.d.ts.map +1 -1
  22. package/packages/daemon/dist/api.js +22 -3
  23. package/packages/daemon/dist/api.js.map +1 -1
  24. package/packages/daemon/dist/cli-auth.d.ts +8 -0
  25. package/packages/daemon/dist/cli-auth.d.ts.map +1 -1
  26. package/packages/daemon/dist/cli-auth.js +33 -0
  27. package/packages/daemon/dist/cli-auth.js.map +1 -1
  28. package/packages/daemon/package.json +12 -12
  29. package/packages/daemon/src/api.ts +25 -3
  30. package/packages/daemon/src/cli-auth.ts +39 -0
  31. package/packages/hooks/package.json +4 -4
  32. package/packages/mcp/package.json +5 -5
  33. package/packages/memory/package.json +2 -2
  34. package/packages/policy/package.json +2 -2
  35. package/packages/protocol/package.json +1 -1
  36. package/packages/resiliency/package.json +1 -1
  37. package/packages/sdk/package.json +3 -3
  38. package/packages/spawner/package.json +1 -1
  39. package/packages/state/package.json +1 -1
  40. package/packages/storage/package.json +2 -2
  41. package/packages/telemetry/package.json +1 -1
  42. package/packages/trajectory/package.json +2 -2
  43. package/packages/user-directory/package.json +2 -2
  44. package/packages/utils/package.json +3 -3
  45. package/packages/wrapper/package.json +6 -6
@@ -24,6 +24,7 @@ import { RelayPtyOrchestrator, getTmuxPath } from '@agent-relay/wrapper';
24
24
  import { AgentSpawner, readWorkersMetadata, getWorkerLogsDir, selectShadowCli, ensureMcpPermissions } from '@agent-relay/bridge';
25
25
  import { generateAgentName, checkForUpdatesInBackground, checkForUpdates } from '@agent-relay/utils';
26
26
  import { getShadowForAgent, getProjectPaths, loadRuntimeConfig } from '@agent-relay/config';
27
+ import { CLI_AUTH_CONFIG, stripAnsiCodes, findMatchingError } from '@agent-relay/config/cli-auth-config';
27
28
  import { createStorageAdapter } from '@agent-relay/storage/adapter';
28
29
  import { initTelemetry, track, enableTelemetry, disableTelemetry, getStatus, isDisabledByEnv, } from '@agent-relay/telemetry';
29
30
  import { installMcpConfig } from '@agent-relay/mcp';
@@ -3592,17 +3593,518 @@ program
3592
3593
  console.log(`Profiling ${agentName}... Press Ctrl+C to stop.`);
3593
3594
  });
3594
3595
  // ============================================================================
3595
- // codex-auth - SSH tunnel helper for Codex/OpenAI authentication
3596
+ // auth - SSH-based interactive provider authentication (cloud workspaces)
3596
3597
  // ============================================================================
3597
3598
  program
3598
- .command('codex-auth')
3599
- .description('Connect Codex via SSH tunnel to workspace (run this when connecting Codex in Agent Relay)')
3600
- .option('--workspace <id>', 'Workspace ID to connect to')
3601
- .option('--cloud-url <url>', 'Cloud API URL', process.env.AGENT_RELAY_CLOUD_URL || 'https://agent-relay.com')
3602
- .option('--token <token>', 'CLI authentication token (from dashboard)')
3603
- .option('--session-cookie <cookie>', 'Session cookie for authentication (deprecated, use --token)')
3599
+ .command('auth <provider>')
3600
+ .description('Authenticate a provider CLI in a cloud workspace over SSH (interactive)')
3601
+ .option('--workspace <id>', 'Workspace ID to authenticate in')
3602
+ .option('--token <token>', 'One-time CLI token from dashboard (skips cloud config requirement)')
3603
+ .option('--cloud-url <url>', 'Cloud API URL (overrides linked config and AGENT_RELAY_CLOUD_URL)')
3604
3604
  .option('--timeout <seconds>', 'Timeout in seconds (default: 300)', '300')
3605
- .action(async (options) => {
3605
+ .action(async (providerArg, options) => {
3606
+ const cyan = (s) => `\x1b[36m${s}\x1b[0m`;
3607
+ const green = (s) => `\x1b[32m${s}\x1b[0m`;
3608
+ const yellow = (s) => `\x1b[33m${s}\x1b[0m`;
3609
+ const red = (s) => `\x1b[31m${s}\x1b[0m`;
3610
+ const dim = (s) => `\x1b[2m${s}\x1b[0m`;
3611
+ const timeoutSeconds = parseInt(options.timeout, 10);
3612
+ if (!Number.isFinite(timeoutSeconds) || timeoutSeconds <= 0) {
3613
+ console.log(red(`Invalid --timeout value: ${options.timeout}`));
3614
+ process.exit(1);
3615
+ }
3616
+ const TIMEOUT_MS = timeoutSeconds * 1000;
3617
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
3618
+ console.log(red('This command requires an interactive terminal (TTY).'));
3619
+ console.log(dim('Run it directly in your terminal (not piped/redirected).'));
3620
+ process.exit(1);
3621
+ }
3622
+ const providerInput = providerArg.toLowerCase().trim();
3623
+ const providerMap = {
3624
+ claude: 'anthropic',
3625
+ codex: 'openai',
3626
+ gemini: 'google',
3627
+ };
3628
+ const provider = providerMap[providerInput] || providerInput;
3629
+ const providerConfig = CLI_AUTH_CONFIG[provider];
3630
+ if (!providerConfig) {
3631
+ const known = Object.keys(CLI_AUTH_CONFIG).sort();
3632
+ console.log(red(`Unknown provider: ${providerArg}`));
3633
+ console.log('');
3634
+ console.log('Examples:');
3635
+ console.log(` ${cyan('npx agent-relay auth claude --workspace=<ID>')}`);
3636
+ console.log(` ${cyan('npx agent-relay auth codex --workspace=<ID>')}`);
3637
+ console.log(` ${cyan('npx agent-relay auth gemini --workspace=<ID>')}`);
3638
+ console.log('');
3639
+ console.log('Supported provider ids:');
3640
+ console.log(` ${known.join(', ')}`);
3641
+ process.exit(1);
3642
+ }
3643
+ const shellEscape = (s) => {
3644
+ // Basic POSIX shell escaping for args (remote exec uses a shell).
3645
+ if (s.length === 0)
3646
+ return "''";
3647
+ if (/^[a-zA-Z0-9_/\\.=:-]+$/.test(s))
3648
+ return s;
3649
+ return `'${s.replace(/'/g, `'\"'\"'`)}'`;
3650
+ };
3651
+ // Build the fallback command to run on the remote workspace.
3652
+ // For Cursor, the installer creates both "agent" (primary) and "cursor-agent" (legacy)
3653
+ // symlinks. Try the primary first, fall back to legacy if not found.
3654
+ const primaryCmd = [providerConfig.command, ...providerConfig.args].map(shellEscape).join(' ');
3655
+ const fallbackCmd = provider === 'cursor'
3656
+ ? `command -v agent >/dev/null 2>&1 && ${primaryCmd} || cursor-agent ${providerConfig.args.map(shellEscape).join(' ')}`
3657
+ : primaryCmd;
3658
+ const remoteCommandFallback = fallbackCmd;
3659
+ // When --token is provided (from dashboard CLI command), skip cloud config requirement.
3660
+ // The token authenticates directly with the /start endpoint.
3661
+ const cliToken = options.token;
3662
+ let cloudConfig = {};
3663
+ if (!cliToken) {
3664
+ const dataDir = process.env.AGENT_RELAY_DATA_DIR || path.join(homedir(), '.local', 'share', 'agent-relay');
3665
+ const configPath = path.join(dataDir, 'cloud-config.json');
3666
+ if (!fs.existsSync(configPath)) {
3667
+ console.log(red('Cloud config not found.'));
3668
+ console.log(dim(`Expected: ${configPath}`));
3669
+ console.log('');
3670
+ console.log(`Run ${cyan('agent-relay cloud link')} first to link this machine to Agent Relay Cloud.`);
3671
+ process.exit(1);
3672
+ }
3673
+ try {
3674
+ cloudConfig = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
3675
+ }
3676
+ catch (err) {
3677
+ console.log(red(`Failed to read cloud config: ${err instanceof Error ? err.message : String(err)}`));
3678
+ process.exit(1);
3679
+ }
3680
+ if (!cloudConfig.apiKey) {
3681
+ console.log(red('Cloud config is missing apiKey.'));
3682
+ console.log(dim(`Config path: ${configPath}`));
3683
+ console.log(`Re-link with ${cyan('agent-relay cloud link')}.`);
3684
+ process.exit(1);
3685
+ }
3686
+ }
3687
+ const CLOUD_URL = (options.cloudUrl || process.env.AGENT_RELAY_CLOUD_URL || cloudConfig.cloudUrl || 'https://agent-relay.com')
3688
+ .replace(/\/$/, '');
3689
+ const requestedWorkspaceId = options.workspace || process.env.WORKSPACE_ID;
3690
+ console.log('');
3691
+ console.log(cyan('═══════════════════════════════════════════════════'));
3692
+ console.log(cyan(' Provider Authentication (SSH)'));
3693
+ console.log(cyan('═══════════════════════════════════════════════════'));
3694
+ console.log('');
3695
+ console.log(`Provider: ${providerConfig.displayName} (${provider})`);
3696
+ console.log(`Workspace: ${requestedWorkspaceId ? `${requestedWorkspaceId.slice(0, 8)}...` : '(default)'}`);
3697
+ console.log(dim(`Cloud: ${CLOUD_URL}`));
3698
+ console.log('');
3699
+ // Step 1: Request SSH session info from cloud.
3700
+ console.log('Requesting SSH session from cloud...');
3701
+ let start;
3702
+ try {
3703
+ const headers = { 'Content-Type': 'application/json' };
3704
+ if (!cliToken && cloudConfig.apiKey) {
3705
+ headers['Authorization'] = `Bearer ${cloudConfig.apiKey}`;
3706
+ }
3707
+ const response = await fetch(`${CLOUD_URL}/api/auth/ssh/start`, {
3708
+ method: 'POST',
3709
+ headers,
3710
+ body: JSON.stringify({
3711
+ provider,
3712
+ workspaceId: requestedWorkspaceId,
3713
+ ...(cliToken && { token: cliToken }),
3714
+ }),
3715
+ });
3716
+ if (!response.ok) {
3717
+ let details = response.statusText;
3718
+ try {
3719
+ const json = await response.json();
3720
+ details = json.error || json.message || details;
3721
+ }
3722
+ catch {
3723
+ try {
3724
+ details = await response.text();
3725
+ }
3726
+ catch {
3727
+ // ignore
3728
+ }
3729
+ }
3730
+ console.log(red(`Failed to start SSH auth session: ${details || response.statusText}`));
3731
+ process.exit(1);
3732
+ }
3733
+ start = await response.json();
3734
+ }
3735
+ catch (err) {
3736
+ console.log(red(`Failed to connect to cloud API: ${err instanceof Error ? err.message : String(err)}`));
3737
+ process.exit(1);
3738
+ }
3739
+ const sshPort = typeof start.ssh?.port === 'string' ? parseInt(start.ssh.port, 10) : start.ssh?.port;
3740
+ if (!start.sessionId || !start.workspaceId || !start.ssh?.host || !sshPort || !start.ssh.user || !start.ssh.password) {
3741
+ console.log(red('Cloud returned invalid SSH session details.'));
3742
+ process.exit(1);
3743
+ }
3744
+ const baseCommand = (typeof start.command === 'string' && start.command.trim().length > 0)
3745
+ ? start.command.trim()
3746
+ : remoteCommandFallback;
3747
+ // Set per-user HOME so credentials persist to the authenticated user's directory.
3748
+ // Multi-user workspaces use /data/users/{userId} as per-user HOME (see entrypoint.sh).
3749
+ // Include /home/workspace/.local/bin in PATH since SSH direct commands don't source
3750
+ // /etc/profile.d/ scripts where the workspace PATH is normally configured.
3751
+ const remoteCommand = start.userId
3752
+ ? `mkdir -p /data/users/${shellEscape(start.userId)} && HOME=/data/users/${shellEscape(start.userId)} PATH=/home/workspace/.local/bin:$PATH ${baseCommand}`
3753
+ : `PATH=/home/workspace/.local/bin:$PATH ${baseCommand}`;
3754
+ console.log(green('✓ SSH session created'));
3755
+ if (start.workspaceName) {
3756
+ console.log(`Workspace: ${cyan(start.workspaceName)} (${start.workspaceId.slice(0, 8)}...)`);
3757
+ }
3758
+ else {
3759
+ console.log(`Workspace: ${start.workspaceId.slice(0, 8)}...`);
3760
+ }
3761
+ console.log(dim(` SSH: ${start.ssh.user}@${start.ssh.host}:${sshPort}`));
3762
+ console.log(dim(` Command: ${remoteCommand}`));
3763
+ console.log('');
3764
+ const { Client } = await import('ssh2');
3765
+ const net = await import('node:net');
3766
+ 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
+ });
3820
+ console.log(yellow('Connecting via SSH...'));
3821
+ console.log(dim(` Tunnel: localhost:${TUNNEL_PORT} → workspace:${TUNNEL_PORT}`));
3822
+ console.log(dim(` Running: ${remoteCommand}`));
3823
+ 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
+ // Get success/error patterns from CLI_AUTH_CONFIG for auto-detection
3847
+ const successPatterns = providerConfig.successPatterns || [];
3848
+ 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
3882
+ }
3883
+ return;
3884
+ }
3885
+ stream.write(data);
3886
+ };
3887
+ stdin.on('data', onStdinData);
3888
+ const cleanup = () => {
3889
+ stdin.off('data', onStdinData);
3890
+ stdout.off('resize', onResize);
3891
+ try {
3892
+ stdin.setRawMode?.(wasRaw);
3893
+ }
3894
+ catch {
3895
+ // ignore
3896
+ }
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);
3935
+ cleanup();
3936
+ try {
3937
+ stream.close();
3938
+ }
3939
+ catch { /* ignore */ }
3940
+ reject(new Error(matched.message + (matched.hint ? ` ${matched.hint}` : '')));
3941
+ }
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;
3971
+ });
3972
+ stream.on('close', () => {
3973
+ clearTimeout(timer);
3974
+ cleanup();
3975
+ resolve({ exitCode, exitSignal, authDetected });
3976
+ });
3977
+ stream.on('error', (e) => {
3978
+ clearTimeout(timer);
3979
+ cleanup();
3980
+ reject(e instanceof Error ? e : new Error(String(e)));
3981
+ });
3982
+ });
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();
4002
+ }
4003
+ // Step 2: Notify cloud completion (cloud will verify and persist credentials).
4004
+ console.log('');
4005
+ console.log('Finalizing authentication with cloud...');
4006
+ // Auth is successful if: success patterns were detected, OR the process exited cleanly (code 0)
4007
+ const success = execError === null && (execResult?.authDetected === true || execResult?.exitCode === 0);
4008
+ const providerForComplete = (typeof start.provider === 'string' && start.provider.trim().length > 0)
4009
+ ? start.provider.trim()
4010
+ : provider;
4011
+ try {
4012
+ const completeHeaders = { 'Content-Type': 'application/json' };
4013
+ if (!cliToken && cloudConfig.apiKey) {
4014
+ completeHeaders['Authorization'] = `Bearer ${cloudConfig.apiKey}`;
4015
+ }
4016
+ const response = await fetch(`${CLOUD_URL}/api/auth/ssh/complete`, {
4017
+ method: 'POST',
4018
+ headers: completeHeaders,
4019
+ body: JSON.stringify({
4020
+ sessionId: start.sessionId,
4021
+ workspaceId: start.workspaceId,
4022
+ provider: providerForComplete,
4023
+ success,
4024
+ ...(cliToken && { token: cliToken }),
4025
+ }),
4026
+ });
4027
+ if (!response.ok) {
4028
+ let details = response.statusText;
4029
+ try {
4030
+ const json = await response.json();
4031
+ details = json.error || json.message || details;
4032
+ }
4033
+ catch {
4034
+ try {
4035
+ details = await response.text();
4036
+ }
4037
+ catch {
4038
+ // ignore
4039
+ }
4040
+ }
4041
+ console.log(red(`Failed to complete auth session: ${details || response.statusText}`));
4042
+ process.exit(1);
4043
+ }
4044
+ }
4045
+ catch (err) {
4046
+ console.log(red(`Failed to complete auth session: ${err instanceof Error ? err.message : String(err)}`));
4047
+ process.exit(1);
4048
+ }
4049
+ if (!success) {
4050
+ const exitCode = execResult?.exitCode;
4051
+ if (typeof exitCode === 'number' && exitCode !== 0) {
4052
+ console.log('');
4053
+ console.log(red(`Remote auth command exited with code ${exitCode}.`));
4054
+ }
4055
+ // Exit code 127 = command not found
4056
+ if (execResult?.exitCode === 127) {
4057
+ console.log('');
4058
+ console.log(yellow(`The ${providerConfig.displayName} CLI ("${providerConfig.command}") is not installed on this workspace.`));
4059
+ console.log(dim('Ask your workspace administrator to install it, or check the workspace Dockerfile.'));
4060
+ }
4061
+ process.exit(1);
4062
+ }
4063
+ console.log('');
4064
+ console.log(green('═══════════════════════════════════════════════════'));
4065
+ console.log(green(' Authentication Complete!'));
4066
+ console.log(green('═══════════════════════════════════════════════════'));
4067
+ console.log('');
4068
+ console.log(`${providerConfig.displayName} is now connected to workspace ${start.workspaceId.slice(0, 8)}...`);
4069
+ console.log('');
4070
+ });
4071
+ // ============================================================================
4072
+ // cli-auth - SSH tunnel helper for provider authentication (Claude, Codex, Cursor, etc.)
4073
+ // ============================================================================
4074
+ // Provider display names for CLI output
4075
+ const CLI_AUTH_DISPLAY_NAMES = {
4076
+ anthropic: 'Claude',
4077
+ openai: 'Codex',
4078
+ google: 'Gemini',
4079
+ cursor: 'Cursor',
4080
+ copilot: 'GitHub Copilot',
4081
+ opencode: 'OpenCode',
4082
+ droid: 'Droid',
4083
+ };
4084
+ // CLI command names per provider (used in help text)
4085
+ const CLI_AUTH_COMMAND_NAMES = {
4086
+ anthropic: 'claude',
4087
+ openai: 'codex',
4088
+ google: 'gemini',
4089
+ cursor: 'cursor',
4090
+ copilot: 'copilot',
4091
+ opencode: 'opencode',
4092
+ droid: 'droid',
4093
+ };
4094
+ // Provider alias mapping (CLI name → config key)
4095
+ const CLI_AUTH_PROVIDER_MAP = {
4096
+ claude: 'anthropic',
4097
+ codex: 'openai',
4098
+ gemini: 'google',
4099
+ };
4100
+ /**
4101
+ * Shared action handler for cli-auth and its provider-specific aliases.
4102
+ */
4103
+ async function runCliAuth(providerArg, options) {
4104
+ // Resolve provider alias
4105
+ const provider = CLI_AUTH_PROVIDER_MAP[providerArg.toLowerCase()] || providerArg.toLowerCase();
4106
+ const displayName = CLI_AUTH_DISPLAY_NAMES[provider] || provider;
4107
+ const cliName = CLI_AUTH_COMMAND_NAMES[provider] || provider;
3606
4108
  const TIMEOUT_MS = parseInt(options.timeout, 10) * 1000;
3607
4109
  const CLOUD_URL = options.cloudUrl.replace(/\/$/, '');
3608
4110
  const TUNNEL_PORT = 1455;
@@ -3614,25 +4116,26 @@ program
3614
4116
  const dim = (s) => `\x1b[2m${s}\x1b[0m`;
3615
4117
  console.log('');
3616
4118
  console.log(cyan('═══════════════════════════════════════════════════'));
3617
- console.log(cyan(' Codex Authentication Helper'));
4119
+ console.log(cyan(` ${displayName} Authentication Helper`));
3618
4120
  console.log(cyan('═══════════════════════════════════════════════════'));
3619
4121
  console.log('');
3620
4122
  if (!options.workspace) {
3621
4123
  console.log(red('Missing --workspace parameter.'));
3622
4124
  console.log('');
3623
- console.log('To connect Codex, follow these steps:');
4125
+ console.log(`To connect ${displayName}, follow these steps:`);
3624
4126
  console.log('');
3625
4127
  console.log(' 1. Go to the Agent Relay dashboard');
3626
- console.log(' 2. Click "Connect with Codex" (Settings → AI Providers)');
4128
+ console.log(` 2. Click "Connect with ${displayName}" (Settings → AI Providers)`);
3627
4129
  console.log(' 3. Copy the command shown (it includes the workspace ID and token)');
3628
4130
  console.log(' 4. Run the command in your terminal');
3629
4131
  console.log('');
3630
4132
  console.log('The command will look like:');
3631
- console.log(cyan(' npx agent-relay codex-auth --workspace=<ID> --token=<TOKEN>'));
4133
+ console.log(cyan(` npx agent-relay cli-auth ${cliName} --workspace=<ID> --token=<TOKEN>`));
3632
4134
  console.log('');
3633
4135
  process.exit(1);
3634
4136
  }
3635
4137
  const workspaceId = options.workspace;
4138
+ console.log(`Provider: ${displayName}`);
3636
4139
  console.log(`Workspace: ${workspaceId.slice(0, 8)}...`);
3637
4140
  // Get tunnel info from cloud API
3638
4141
  console.log('Getting workspace connection info...');
@@ -3644,18 +4147,19 @@ program
3644
4147
  if (!options.token && !options.sessionCookie) {
3645
4148
  console.log(red('Missing --token parameter.'));
3646
4149
  console.log('');
3647
- console.log('The token is provided by the dashboard when you click "Connect with Codex".');
4150
+ console.log(`The token is provided by the dashboard when you click "Connect with ${displayName}".`);
3648
4151
  console.log('Copy the complete command from the dashboard and paste it here.');
3649
4152
  console.log('');
3650
4153
  process.exit(1);
3651
4154
  }
3652
4155
  let tunnelInfo;
3653
4156
  try {
3654
- // Build URL with token query parameter
4157
+ // Build URL with token and provider query parameters
3655
4158
  const tunnelInfoUrl = new URL(`${CLOUD_URL}/api/auth/codex-helper/tunnel-info/${workspaceId}`);
3656
4159
  if (options.token) {
3657
4160
  tunnelInfoUrl.searchParams.set('token', options.token);
3658
4161
  }
4162
+ tunnelInfoUrl.searchParams.set('provider', provider);
3659
4163
  const response = await fetch(tunnelInfoUrl.toString(), {
3660
4164
  method: 'GET',
3661
4165
  headers,
@@ -3784,8 +4288,8 @@ program
3784
4288
  console.log('');
3785
4289
  console.log(cyan(tunnelInfo.authUrl));
3786
4290
  console.log('');
3787
- console.log(dim('The browser will redirect to localhost:1455, which tunnels to the workspace.'));
3788
- console.log(dim('The Codex CLI in the workspace will receive the callback and complete auth.'));
4291
+ console.log(dim(`The browser will redirect to localhost:${TUNNEL_PORT}, which tunnels to the workspace.`));
4292
+ console.log(dim(`The ${displayName} CLI in the workspace will receive the callback and complete auth.`));
3789
4293
  console.log('');
3790
4294
  }
3791
4295
  else {
@@ -3800,11 +4304,12 @@ program
3800
4304
  while (!authenticated && (Date.now() - startTime) < TIMEOUT_MS) {
3801
4305
  await new Promise(resolve => setTimeout(resolve, 3000));
3802
4306
  try {
3803
- // Build URL with token for authentication
4307
+ // Build URL with token and provider for authentication
3804
4308
  const authStatusUrl = new URL(`${CLOUD_URL}/api/auth/codex-helper/auth-status/${workspaceId}`);
3805
4309
  if (options.token) {
3806
4310
  authStatusUrl.searchParams.set('token', options.token);
3807
4311
  }
4312
+ authStatusUrl.searchParams.set('provider', provider);
3808
4313
  const statusResponse = await fetch(authStatusUrl.toString(), { method: 'GET', headers, credentials: 'include' });
3809
4314
  if (statusResponse.ok) {
3810
4315
  const statusData = await statusResponse.json();
@@ -3832,7 +4337,7 @@ program
3832
4337
  console.log(green(' Authentication Complete!'));
3833
4338
  console.log(green('═══════════════════════════════════════════════════'));
3834
4339
  console.log('');
3835
- console.log('Your Codex account is now connected to the workspace.');
4340
+ console.log(`Your ${displayName} account is now connected to the workspace.`);
3836
4341
  console.log('You can close this terminal and return to the dashboard.');
3837
4342
  console.log('');
3838
4343
  }
@@ -3844,6 +4349,63 @@ program
3844
4349
  console.log('the callback. Check if the SSH tunnel was working correctly.');
3845
4350
  process.exit(1);
3846
4351
  }
4352
+ }
4353
+ // Shared options for all cli-auth commands
4354
+ const cliAuthOptions = {
4355
+ workspace: '--workspace <id>',
4356
+ cloudUrl: '--cloud-url <url>',
4357
+ token: '--token <token>',
4358
+ sessionCookie: '--session-cookie <cookie>',
4359
+ timeout: '--timeout <seconds>',
4360
+ };
4361
+ const defaultCloudUrl = process.env.AGENT_RELAY_CLOUD_URL || 'https://agent-relay.com';
4362
+ // cli-auth <provider> - Generic command
4363
+ program
4364
+ .command('cli-auth <provider>')
4365
+ .description('Connect a provider via SSH tunnel to workspace (Claude, Codex, Cursor, etc.)')
4366
+ .option(cliAuthOptions.workspace, 'Workspace ID to connect to')
4367
+ .option(cliAuthOptions.cloudUrl, 'Cloud API URL', defaultCloudUrl)
4368
+ .option(cliAuthOptions.token, 'CLI authentication token (from dashboard)')
4369
+ .option(cliAuthOptions.sessionCookie, 'Session cookie for authentication (deprecated, use --token)')
4370
+ .option(cliAuthOptions.timeout, 'Timeout in seconds (default: 300)', '300')
4371
+ .action(async (providerArg, options) => {
4372
+ await runCliAuth(providerArg, options);
4373
+ });
4374
+ // codex-auth - Backward-compatible alias
4375
+ program
4376
+ .command('codex-auth')
4377
+ .description('Connect Codex via SSH tunnel to workspace (alias for cli-auth codex)')
4378
+ .option(cliAuthOptions.workspace, 'Workspace ID to connect to')
4379
+ .option(cliAuthOptions.cloudUrl, 'Cloud API URL', defaultCloudUrl)
4380
+ .option(cliAuthOptions.token, 'CLI authentication token (from dashboard)')
4381
+ .option(cliAuthOptions.sessionCookie, 'Session cookie for authentication (deprecated, use --token)')
4382
+ .option(cliAuthOptions.timeout, 'Timeout in seconds (default: 300)', '300')
4383
+ .action(async (options) => {
4384
+ await runCliAuth('codex', options);
4385
+ });
4386
+ // claude-auth - Alias for Claude/Anthropic
4387
+ program
4388
+ .command('claude-auth')
4389
+ .description('Connect Claude via SSH tunnel to workspace (alias for cli-auth claude)')
4390
+ .option(cliAuthOptions.workspace, 'Workspace ID to connect to')
4391
+ .option(cliAuthOptions.cloudUrl, 'Cloud API URL', defaultCloudUrl)
4392
+ .option(cliAuthOptions.token, 'CLI authentication token (from dashboard)')
4393
+ .option(cliAuthOptions.sessionCookie, 'Session cookie for authentication (deprecated, use --token)')
4394
+ .option(cliAuthOptions.timeout, 'Timeout in seconds (default: 300)', '300')
4395
+ .action(async (options) => {
4396
+ await runCliAuth('claude', options);
4397
+ });
4398
+ // cursor-auth - Alias for Cursor
4399
+ program
4400
+ .command('cursor-auth')
4401
+ .description('Connect Cursor via SSH tunnel to workspace (alias for cli-auth cursor)')
4402
+ .option(cliAuthOptions.workspace, 'Workspace ID to connect to')
4403
+ .option(cliAuthOptions.cloudUrl, 'Cloud API URL', defaultCloudUrl)
4404
+ .option(cliAuthOptions.token, 'CLI authentication token (from dashboard)')
4405
+ .option(cliAuthOptions.sessionCookie, 'Session cookie for authentication (deprecated, use --token)')
4406
+ .option(cliAuthOptions.timeout, 'Timeout in seconds (default: 300)', '300')
4407
+ .action(async (options) => {
4408
+ await runCliAuth('cursor', options);
3847
4409
  });
3848
4410
  // init - First-time setup wizard for Agent Relay
3849
4411
  async function runInit(options) {