agent-relay 2.1.26 → 2.1.27-beta.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 (34) hide show
  1. package/dist/index.cjs +2 -2
  2. package/dist/src/cli/index.d.ts.map +1 -1
  3. package/dist/src/cli/index.js +530 -18
  4. package/dist/src/cli/index.js.map +1 -1
  5. package/package.json +18 -18
  6. package/packages/acp-bridge/package.json +2 -2
  7. package/packages/api-types/package.json +1 -1
  8. package/packages/benchmark/package.json +5 -5
  9. package/packages/bridge/package.json +7 -7
  10. package/packages/cli-tester/package.json +1 -1
  11. package/packages/config/package.json +2 -2
  12. package/packages/continuity/package.json +2 -2
  13. package/packages/daemon/package.json +12 -12
  14. package/packages/hooks/package.json +4 -4
  15. package/packages/mcp/package.json +5 -5
  16. package/packages/memory/package.json +2 -2
  17. package/packages/policy/package.json +2 -2
  18. package/packages/protocol/package.json +1 -1
  19. package/packages/resiliency/package.json +1 -1
  20. package/packages/sdk/dist/client.d.ts +1 -1
  21. package/packages/sdk/dist/client.js +2 -2
  22. package/packages/sdk/package.json +3 -3
  23. package/packages/sdk/src/client.ts +2 -2
  24. package/packages/spawner/package.json +1 -1
  25. package/packages/state/package.json +1 -1
  26. package/packages/storage/package.json +2 -2
  27. package/packages/telemetry/package.json +1 -1
  28. package/packages/trajectory/package.json +2 -2
  29. package/packages/user-directory/package.json +2 -2
  30. package/packages/utils/package.json +3 -3
  31. package/packages/wrapper/dist/client.js +1 -1
  32. package/packages/wrapper/package.json +6 -6
  33. package/packages/wrapper/src/client.test.ts +1 -1
  34. package/packages/wrapper/src/client.ts +1 -1
package/dist/index.cjs CHANGED
@@ -33354,8 +33354,8 @@ var RelayClient = class _RelayClient {
33354
33354
  const result = await this.requestResponse(
33355
33355
  "SPAWN",
33356
33356
  payload,
33357
- 3e4
33358
- // 30 second timeout for spawn
33357
+ 6e4
33358
+ // 60 second timeout for spawn
33359
33359
  );
33360
33360
  return toSpawnResult(result);
33361
33361
  } catch (e) {
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/cli/index.ts"],"names":[],"mappings":";AACA;;;;;;;;;;;;;;;;GAgBG;AA8VH;;;;GAIG;AACH,wBAAsB,oBAAoB,CAAC,OAAO,CAAC,EAAE;IAAE,MAAM,CAAC,EAAE,OAAO,CAAA;CAAE,GAAG,OAAO,CAAC;IAAE,OAAO,EAAE,OAAO,CAAC;IAAC,SAAS,EAAE,MAAM,EAAE,CAAA;CAAE,CAAC,CAqC7H"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/cli/index.ts"],"names":[],"mappings":";AACA;;;;;;;;;;;;;;;;GAgBG;AA+VH;;;;GAIG;AACH,wBAAsB,oBAAoB,CAAC,OAAO,CAAC,EAAE;IAAE,MAAM,CAAC,EAAE,OAAO,CAAA;CAAE,GAAG,OAAO,CAAC;IAAE,OAAO,EAAE,OAAO,CAAC;IAAC,SAAS,EAAE,MAAM,EAAE,CAAA;CAAE,CAAC,CAqC7H"}
@@ -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 } 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,468 @@ 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
+ const remoteCommandFallback = [providerConfig.command, ...providerConfig.args].map(shellEscape).join(' ');
3652
+ // When --token is provided (from dashboard CLI command), skip cloud config requirement.
3653
+ // The token authenticates directly with the /start endpoint.
3654
+ const cliToken = options.token;
3655
+ let cloudConfig = {};
3656
+ if (!cliToken) {
3657
+ const dataDir = process.env.AGENT_RELAY_DATA_DIR || path.join(homedir(), '.local', 'share', 'agent-relay');
3658
+ const configPath = path.join(dataDir, 'cloud-config.json');
3659
+ if (!fs.existsSync(configPath)) {
3660
+ console.log(red('Cloud config not found.'));
3661
+ console.log(dim(`Expected: ${configPath}`));
3662
+ console.log('');
3663
+ console.log(`Run ${cyan('agent-relay cloud link')} first to link this machine to Agent Relay Cloud.`);
3664
+ process.exit(1);
3665
+ }
3666
+ try {
3667
+ cloudConfig = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
3668
+ }
3669
+ catch (err) {
3670
+ console.log(red(`Failed to read cloud config: ${err instanceof Error ? err.message : String(err)}`));
3671
+ process.exit(1);
3672
+ }
3673
+ if (!cloudConfig.apiKey) {
3674
+ console.log(red('Cloud config is missing apiKey.'));
3675
+ console.log(dim(`Config path: ${configPath}`));
3676
+ console.log(`Re-link with ${cyan('agent-relay cloud link')}.`);
3677
+ process.exit(1);
3678
+ }
3679
+ }
3680
+ const CLOUD_URL = (options.cloudUrl || process.env.AGENT_RELAY_CLOUD_URL || cloudConfig.cloudUrl || 'https://agent-relay.com')
3681
+ .replace(/\/$/, '');
3682
+ const requestedWorkspaceId = options.workspace || process.env.WORKSPACE_ID;
3683
+ console.log('');
3684
+ console.log(cyan('═══════════════════════════════════════════════════'));
3685
+ console.log(cyan(' Provider Authentication (SSH)'));
3686
+ console.log(cyan('═══════════════════════════════════════════════════'));
3687
+ console.log('');
3688
+ console.log(`Provider: ${providerConfig.displayName} (${provider})`);
3689
+ console.log(`Workspace: ${requestedWorkspaceId ? `${requestedWorkspaceId.slice(0, 8)}...` : '(default)'}`);
3690
+ console.log(dim(`Cloud: ${CLOUD_URL}`));
3691
+ console.log('');
3692
+ // Step 1: Request SSH session info from cloud.
3693
+ console.log('Requesting SSH session from cloud...');
3694
+ let start;
3695
+ try {
3696
+ const headers = { 'Content-Type': 'application/json' };
3697
+ if (!cliToken && cloudConfig.apiKey) {
3698
+ headers['Authorization'] = `Bearer ${cloudConfig.apiKey}`;
3699
+ }
3700
+ const response = await fetch(`${CLOUD_URL}/api/auth/ssh/start`, {
3701
+ method: 'POST',
3702
+ headers,
3703
+ body: JSON.stringify({
3704
+ provider,
3705
+ workspaceId: requestedWorkspaceId,
3706
+ ...(cliToken && { token: cliToken }),
3707
+ }),
3708
+ });
3709
+ if (!response.ok) {
3710
+ let details = response.statusText;
3711
+ try {
3712
+ const json = await response.json();
3713
+ details = json.error || json.message || details;
3714
+ }
3715
+ catch {
3716
+ try {
3717
+ details = await response.text();
3718
+ }
3719
+ catch {
3720
+ // ignore
3721
+ }
3722
+ }
3723
+ console.log(red(`Failed to start SSH auth session: ${details || response.statusText}`));
3724
+ process.exit(1);
3725
+ }
3726
+ start = await response.json();
3727
+ }
3728
+ catch (err) {
3729
+ console.log(red(`Failed to connect to cloud API: ${err instanceof Error ? err.message : String(err)}`));
3730
+ process.exit(1);
3731
+ }
3732
+ const sshPort = typeof start.ssh?.port === 'string' ? parseInt(start.ssh.port, 10) : start.ssh?.port;
3733
+ if (!start.sessionId || !start.workspaceId || !start.ssh?.host || !sshPort || !start.ssh.user || !start.ssh.password) {
3734
+ console.log(red('Cloud returned invalid SSH session details.'));
3735
+ process.exit(1);
3736
+ }
3737
+ const remoteCommand = (typeof start.command === 'string' && start.command.trim().length > 0)
3738
+ ? start.command.trim()
3739
+ : remoteCommandFallback;
3740
+ console.log(green('✓ SSH session created'));
3741
+ if (start.workspaceName) {
3742
+ console.log(`Workspace: ${cyan(start.workspaceName)} (${start.workspaceId.slice(0, 8)}...)`);
3743
+ }
3744
+ else {
3745
+ console.log(`Workspace: ${start.workspaceId.slice(0, 8)}...`);
3746
+ }
3747
+ console.log(dim(` SSH: ${start.ssh.user}@${start.ssh.host}:${sshPort}`));
3748
+ console.log(dim(` Command: ${remoteCommand}`));
3749
+ console.log('');
3750
+ const { Client } = await import('ssh2');
3751
+ const net = await import('node:net');
3752
+ const TUNNEL_PORT = 1455;
3753
+ const sshClient = new Client();
3754
+ let sshReady = false;
3755
+ const tunnel = { server: null };
3756
+ const sshReadyPromise = new Promise((resolve, reject) => {
3757
+ sshClient.on('ready', () => {
3758
+ sshReady = true;
3759
+ // Set up port forwarding for OAuth callbacks: localhost:1455 -> workspace:1455
3760
+ tunnel.server = net.createServer((localSocket) => {
3761
+ sshClient.forwardOut('127.0.0.1', TUNNEL_PORT, 'localhost', TUNNEL_PORT, (err, stream) => {
3762
+ if (err) {
3763
+ localSocket.end();
3764
+ return;
3765
+ }
3766
+ localSocket.pipe(stream).pipe(localSocket);
3767
+ });
3768
+ });
3769
+ tunnel.server.on('error', (err) => {
3770
+ if (err.code === 'EADDRINUSE') {
3771
+ console.log(dim(`Note: Port ${TUNNEL_PORT} in use, OAuth callbacks may not work.`));
3772
+ }
3773
+ // Non-fatal: resolve anyway so the auth command still runs.
3774
+ // Device-flow providers don't need port forwarding.
3775
+ resolve();
3776
+ });
3777
+ tunnel.server.listen(TUNNEL_PORT, '127.0.0.1', () => {
3778
+ resolve();
3779
+ });
3780
+ });
3781
+ sshClient.on('error', (err) => {
3782
+ let msg;
3783
+ if (err.message.includes('Authentication')) {
3784
+ msg = 'SSH authentication failed.';
3785
+ }
3786
+ else if (err.message.includes('ECONNREFUSED')) {
3787
+ msg = `Cannot connect to SSH server at ${start.ssh.host}:${sshPort}. Is the workspace running and SSH enabled?`;
3788
+ }
3789
+ else if (err.message.includes('ENOTFOUND') || err.message.includes('getaddrinfo')) {
3790
+ msg = `Cannot resolve hostname: ${start.ssh.host}. Check network connectivity.`;
3791
+ }
3792
+ else if (err.message.includes('ETIMEDOUT')) {
3793
+ msg = `Connection timed out to ${start.ssh.host}:${sshPort}. Is the workspace running?`;
3794
+ }
3795
+ else {
3796
+ msg = `SSH error: ${err.message}`;
3797
+ }
3798
+ reject(new Error(msg));
3799
+ });
3800
+ sshClient.on('close', () => {
3801
+ if (!sshReady) {
3802
+ reject(new Error(`SSH connection to ${start.ssh.host}:${sshPort} closed unexpectedly.`));
3803
+ }
3804
+ });
3805
+ });
3806
+ console.log(yellow('Connecting via SSH...'));
3807
+ console.log(dim(` Tunnel: localhost:${TUNNEL_PORT} → workspace:${TUNNEL_PORT}`));
3808
+ console.log(dim(` Running: ${remoteCommand}`));
3809
+ console.log('');
3810
+ try {
3811
+ sshClient.connect({
3812
+ host: start.ssh.host,
3813
+ port: sshPort,
3814
+ username: start.ssh.user,
3815
+ password: start.ssh.password,
3816
+ readyTimeout: 10000,
3817
+ // Workspace containers use ephemeral SSH hosts; skip host key checking.
3818
+ hostVerifier: () => true,
3819
+ });
3820
+ await Promise.race([
3821
+ sshReadyPromise,
3822
+ new Promise((_, reject) => setTimeout(() => reject(new Error('SSH connection timeout')), 15000)),
3823
+ ]);
3824
+ }
3825
+ catch (err) {
3826
+ console.log(red(`Failed to connect via SSH: ${err instanceof Error ? err.message : String(err)}`));
3827
+ if (tunnel.server)
3828
+ tunnel.server.close();
3829
+ sshClient.end();
3830
+ process.exit(1);
3831
+ }
3832
+ // Get success/error patterns from CLI_AUTH_CONFIG for auto-detection
3833
+ const successPatterns = providerConfig.successPatterns || [];
3834
+ const errorPatterns = providerConfig.errorPatterns || [];
3835
+ const execInteractive = async (cmd, timeoutMs) => {
3836
+ return await new Promise((resolve, reject) => {
3837
+ const cols = process.stdout.columns || 80;
3838
+ const rows = process.stdout.rows || 24;
3839
+ const term = process.env.TERM || 'xterm-256color';
3840
+ sshClient.exec(cmd, { pty: { term, cols, rows } }, (err, stream) => {
3841
+ if (err)
3842
+ return reject(err);
3843
+ let exitCode = null;
3844
+ let exitSignal = null;
3845
+ let authDetected = false;
3846
+ let outputBuffer = ''; // Rolling buffer for pattern matching
3847
+ const stdin = process.stdin;
3848
+ const stdout = process.stdout;
3849
+ const stderr = process.stderr;
3850
+ const wasRaw = stdin.isRaw ?? false;
3851
+ try {
3852
+ stdin.setRawMode?.(true);
3853
+ }
3854
+ catch {
3855
+ // ignore
3856
+ }
3857
+ stdin.resume();
3858
+ const onStdinData = (data) => {
3859
+ stream.write(data);
3860
+ };
3861
+ stdin.on('data', onStdinData);
3862
+ const cleanup = () => {
3863
+ stdin.off('data', onStdinData);
3864
+ stdout.off('resize', onResize);
3865
+ try {
3866
+ stdin.setRawMode?.(wasRaw);
3867
+ }
3868
+ catch {
3869
+ // ignore
3870
+ }
3871
+ stdin.pause();
3872
+ };
3873
+ // Auto-close the session when auth success is detected
3874
+ const closeOnAuthSuccess = () => {
3875
+ authDetected = true;
3876
+ // Brief delay so the user sees the success message
3877
+ setTimeout(() => {
3878
+ cleanup();
3879
+ clearTimeout(timer);
3880
+ try {
3881
+ stream.close();
3882
+ }
3883
+ catch {
3884
+ // ignore
3885
+ }
3886
+ }, 1500);
3887
+ };
3888
+ stream.on('data', (data) => {
3889
+ stdout.write(data);
3890
+ // Accumulate output for pattern matching (keep last 2KB to avoid memory growth)
3891
+ const text = data.toString();
3892
+ outputBuffer += text;
3893
+ if (outputBuffer.length > 2048) {
3894
+ outputBuffer = outputBuffer.slice(-2048);
3895
+ }
3896
+ // Check for auth success patterns
3897
+ if (!authDetected && successPatterns.length > 0) {
3898
+ const clean = stripAnsiCodes(outputBuffer);
3899
+ for (const pattern of successPatterns) {
3900
+ if (pattern.test(clean)) {
3901
+ closeOnAuthSuccess();
3902
+ break;
3903
+ }
3904
+ }
3905
+ }
3906
+ });
3907
+ stream.stderr.on('data', (data) => {
3908
+ stderr.write(data);
3909
+ });
3910
+ const onResize = () => {
3911
+ try {
3912
+ stream.setWindow(stdout.rows || 24, stdout.columns || 80, 0, 0);
3913
+ }
3914
+ catch {
3915
+ // ignore
3916
+ }
3917
+ };
3918
+ stdout.on('resize', onResize);
3919
+ const timer = setTimeout(() => {
3920
+ cleanup();
3921
+ try {
3922
+ stream.close();
3923
+ }
3924
+ catch {
3925
+ // ignore
3926
+ }
3927
+ reject(new Error(`Authentication timed out after ${Math.floor(timeoutMs / 1000)}s`));
3928
+ }, timeoutMs);
3929
+ stream.on('exit', (code, signal) => {
3930
+ if (typeof code === 'number')
3931
+ exitCode = code;
3932
+ if (typeof signal === 'string')
3933
+ exitSignal = signal;
3934
+ });
3935
+ stream.on('close', () => {
3936
+ clearTimeout(timer);
3937
+ cleanup();
3938
+ resolve({ exitCode, exitSignal, authDetected });
3939
+ });
3940
+ stream.on('error', (e) => {
3941
+ clearTimeout(timer);
3942
+ cleanup();
3943
+ reject(e instanceof Error ? e : new Error(String(e)));
3944
+ });
3945
+ });
3946
+ });
3947
+ };
3948
+ let execResult = null;
3949
+ let execError = null;
3950
+ try {
3951
+ console.log(yellow('Starting interactive authentication...'));
3952
+ console.log(dim('Follow the prompts below. The session will close automatically when auth completes.'));
3953
+ console.log('');
3954
+ execResult = await execInteractive(remoteCommand, TIMEOUT_MS);
3955
+ }
3956
+ catch (err) {
3957
+ execError = err instanceof Error ? err : new Error(String(err));
3958
+ console.log('');
3959
+ console.log(red(`Remote auth command failed: ${execError.message}`));
3960
+ }
3961
+ finally {
3962
+ if (tunnel.server)
3963
+ tunnel.server.close();
3964
+ sshClient.end();
3965
+ }
3966
+ // Step 2: Notify cloud completion (cloud will verify and persist credentials).
3967
+ console.log('');
3968
+ console.log('Finalizing authentication with cloud...');
3969
+ // Auth is successful if: success patterns were detected, OR the process exited cleanly (code 0)
3970
+ const success = execError === null && (execResult?.authDetected === true || execResult?.exitCode === 0);
3971
+ const providerForComplete = (typeof start.provider === 'string' && start.provider.trim().length > 0)
3972
+ ? start.provider.trim()
3973
+ : provider;
3974
+ try {
3975
+ const response = await fetch(`${CLOUD_URL}/api/auth/ssh/complete`, {
3976
+ method: 'POST',
3977
+ headers: {
3978
+ 'Authorization': `Bearer ${cloudConfig.apiKey}`,
3979
+ 'Content-Type': 'application/json',
3980
+ },
3981
+ body: JSON.stringify({ sessionId: start.sessionId, workspaceId: start.workspaceId, provider: providerForComplete, success }),
3982
+ });
3983
+ if (!response.ok) {
3984
+ let details = response.statusText;
3985
+ try {
3986
+ const json = await response.json();
3987
+ details = json.error || json.message || details;
3988
+ }
3989
+ catch {
3990
+ try {
3991
+ details = await response.text();
3992
+ }
3993
+ catch {
3994
+ // ignore
3995
+ }
3996
+ }
3997
+ console.log(red(`Failed to complete auth session: ${details || response.statusText}`));
3998
+ process.exit(1);
3999
+ }
4000
+ }
4001
+ catch (err) {
4002
+ console.log(red(`Failed to complete auth session: ${err instanceof Error ? err.message : String(err)}`));
4003
+ process.exit(1);
4004
+ }
4005
+ if (!success) {
4006
+ const exitCode = execResult?.exitCode;
4007
+ if (typeof exitCode === 'number' && exitCode !== 0) {
4008
+ console.log('');
4009
+ console.log(red(`Remote auth command exited with code ${exitCode}.`));
4010
+ }
4011
+ process.exit(1);
4012
+ }
4013
+ console.log('');
4014
+ console.log(green('═══════════════════════════════════════════════════'));
4015
+ console.log(green(' Authentication Complete!'));
4016
+ console.log(green('═══════════════════════════════════════════════════'));
4017
+ console.log('');
4018
+ console.log(`${providerConfig.displayName} is now connected to workspace ${start.workspaceId.slice(0, 8)}...`);
4019
+ console.log('');
4020
+ });
4021
+ // ============================================================================
4022
+ // cli-auth - SSH tunnel helper for provider authentication (Claude, Codex, Cursor, etc.)
4023
+ // ============================================================================
4024
+ // Provider display names for CLI output
4025
+ const CLI_AUTH_DISPLAY_NAMES = {
4026
+ anthropic: 'Claude',
4027
+ openai: 'Codex',
4028
+ google: 'Gemini',
4029
+ cursor: 'Cursor',
4030
+ copilot: 'GitHub Copilot',
4031
+ opencode: 'OpenCode',
4032
+ droid: 'Droid',
4033
+ };
4034
+ // CLI command names per provider (used in help text)
4035
+ const CLI_AUTH_COMMAND_NAMES = {
4036
+ anthropic: 'claude',
4037
+ openai: 'codex',
4038
+ google: 'gemini',
4039
+ cursor: 'cursor',
4040
+ copilot: 'copilot',
4041
+ opencode: 'opencode',
4042
+ droid: 'droid',
4043
+ };
4044
+ // Provider alias mapping (CLI name → config key)
4045
+ const CLI_AUTH_PROVIDER_MAP = {
4046
+ claude: 'anthropic',
4047
+ codex: 'openai',
4048
+ gemini: 'google',
4049
+ };
4050
+ /**
4051
+ * Shared action handler for cli-auth and its provider-specific aliases.
4052
+ */
4053
+ async function runCliAuth(providerArg, options) {
4054
+ // Resolve provider alias
4055
+ const provider = CLI_AUTH_PROVIDER_MAP[providerArg.toLowerCase()] || providerArg.toLowerCase();
4056
+ const displayName = CLI_AUTH_DISPLAY_NAMES[provider] || provider;
4057
+ const cliName = CLI_AUTH_COMMAND_NAMES[provider] || provider;
3606
4058
  const TIMEOUT_MS = parseInt(options.timeout, 10) * 1000;
3607
4059
  const CLOUD_URL = options.cloudUrl.replace(/\/$/, '');
3608
4060
  const TUNNEL_PORT = 1455;
@@ -3614,25 +4066,26 @@ program
3614
4066
  const dim = (s) => `\x1b[2m${s}\x1b[0m`;
3615
4067
  console.log('');
3616
4068
  console.log(cyan('═══════════════════════════════════════════════════'));
3617
- console.log(cyan(' Codex Authentication Helper'));
4069
+ console.log(cyan(` ${displayName} Authentication Helper`));
3618
4070
  console.log(cyan('═══════════════════════════════════════════════════'));
3619
4071
  console.log('');
3620
4072
  if (!options.workspace) {
3621
4073
  console.log(red('Missing --workspace parameter.'));
3622
4074
  console.log('');
3623
- console.log('To connect Codex, follow these steps:');
4075
+ console.log(`To connect ${displayName}, follow these steps:`);
3624
4076
  console.log('');
3625
4077
  console.log(' 1. Go to the Agent Relay dashboard');
3626
- console.log(' 2. Click "Connect with Codex" (Settings → AI Providers)');
4078
+ console.log(` 2. Click "Connect with ${displayName}" (Settings → AI Providers)`);
3627
4079
  console.log(' 3. Copy the command shown (it includes the workspace ID and token)');
3628
4080
  console.log(' 4. Run the command in your terminal');
3629
4081
  console.log('');
3630
4082
  console.log('The command will look like:');
3631
- console.log(cyan(' npx agent-relay codex-auth --workspace=<ID> --token=<TOKEN>'));
4083
+ console.log(cyan(` npx agent-relay cli-auth ${cliName} --workspace=<ID> --token=<TOKEN>`));
3632
4084
  console.log('');
3633
4085
  process.exit(1);
3634
4086
  }
3635
4087
  const workspaceId = options.workspace;
4088
+ console.log(`Provider: ${displayName}`);
3636
4089
  console.log(`Workspace: ${workspaceId.slice(0, 8)}...`);
3637
4090
  // Get tunnel info from cloud API
3638
4091
  console.log('Getting workspace connection info...');
@@ -3644,18 +4097,19 @@ program
3644
4097
  if (!options.token && !options.sessionCookie) {
3645
4098
  console.log(red('Missing --token parameter.'));
3646
4099
  console.log('');
3647
- console.log('The token is provided by the dashboard when you click "Connect with Codex".');
4100
+ console.log(`The token is provided by the dashboard when you click "Connect with ${displayName}".`);
3648
4101
  console.log('Copy the complete command from the dashboard and paste it here.');
3649
4102
  console.log('');
3650
4103
  process.exit(1);
3651
4104
  }
3652
4105
  let tunnelInfo;
3653
4106
  try {
3654
- // Build URL with token query parameter
4107
+ // Build URL with token and provider query parameters
3655
4108
  const tunnelInfoUrl = new URL(`${CLOUD_URL}/api/auth/codex-helper/tunnel-info/${workspaceId}`);
3656
4109
  if (options.token) {
3657
4110
  tunnelInfoUrl.searchParams.set('token', options.token);
3658
4111
  }
4112
+ tunnelInfoUrl.searchParams.set('provider', provider);
3659
4113
  const response = await fetch(tunnelInfoUrl.toString(), {
3660
4114
  method: 'GET',
3661
4115
  headers,
@@ -3784,8 +4238,8 @@ program
3784
4238
  console.log('');
3785
4239
  console.log(cyan(tunnelInfo.authUrl));
3786
4240
  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.'));
4241
+ console.log(dim(`The browser will redirect to localhost:${TUNNEL_PORT}, which tunnels to the workspace.`));
4242
+ console.log(dim(`The ${displayName} CLI in the workspace will receive the callback and complete auth.`));
3789
4243
  console.log('');
3790
4244
  }
3791
4245
  else {
@@ -3800,11 +4254,12 @@ program
3800
4254
  while (!authenticated && (Date.now() - startTime) < TIMEOUT_MS) {
3801
4255
  await new Promise(resolve => setTimeout(resolve, 3000));
3802
4256
  try {
3803
- // Build URL with token for authentication
4257
+ // Build URL with token and provider for authentication
3804
4258
  const authStatusUrl = new URL(`${CLOUD_URL}/api/auth/codex-helper/auth-status/${workspaceId}`);
3805
4259
  if (options.token) {
3806
4260
  authStatusUrl.searchParams.set('token', options.token);
3807
4261
  }
4262
+ authStatusUrl.searchParams.set('provider', provider);
3808
4263
  const statusResponse = await fetch(authStatusUrl.toString(), { method: 'GET', headers, credentials: 'include' });
3809
4264
  if (statusResponse.ok) {
3810
4265
  const statusData = await statusResponse.json();
@@ -3832,7 +4287,7 @@ program
3832
4287
  console.log(green(' Authentication Complete!'));
3833
4288
  console.log(green('═══════════════════════════════════════════════════'));
3834
4289
  console.log('');
3835
- console.log('Your Codex account is now connected to the workspace.');
4290
+ console.log(`Your ${displayName} account is now connected to the workspace.`);
3836
4291
  console.log('You can close this terminal and return to the dashboard.');
3837
4292
  console.log('');
3838
4293
  }
@@ -3844,6 +4299,63 @@ program
3844
4299
  console.log('the callback. Check if the SSH tunnel was working correctly.');
3845
4300
  process.exit(1);
3846
4301
  }
4302
+ }
4303
+ // Shared options for all cli-auth commands
4304
+ const cliAuthOptions = {
4305
+ workspace: '--workspace <id>',
4306
+ cloudUrl: '--cloud-url <url>',
4307
+ token: '--token <token>',
4308
+ sessionCookie: '--session-cookie <cookie>',
4309
+ timeout: '--timeout <seconds>',
4310
+ };
4311
+ const defaultCloudUrl = process.env.AGENT_RELAY_CLOUD_URL || 'https://agent-relay.com';
4312
+ // cli-auth <provider> - Generic command
4313
+ program
4314
+ .command('cli-auth <provider>')
4315
+ .description('Connect a provider via SSH tunnel to workspace (Claude, Codex, Cursor, etc.)')
4316
+ .option(cliAuthOptions.workspace, 'Workspace ID to connect to')
4317
+ .option(cliAuthOptions.cloudUrl, 'Cloud API URL', defaultCloudUrl)
4318
+ .option(cliAuthOptions.token, 'CLI authentication token (from dashboard)')
4319
+ .option(cliAuthOptions.sessionCookie, 'Session cookie for authentication (deprecated, use --token)')
4320
+ .option(cliAuthOptions.timeout, 'Timeout in seconds (default: 300)', '300')
4321
+ .action(async (providerArg, options) => {
4322
+ await runCliAuth(providerArg, options);
4323
+ });
4324
+ // codex-auth - Backward-compatible alias
4325
+ program
4326
+ .command('codex-auth')
4327
+ .description('Connect Codex via SSH tunnel to workspace (alias for cli-auth codex)')
4328
+ .option(cliAuthOptions.workspace, 'Workspace ID to connect to')
4329
+ .option(cliAuthOptions.cloudUrl, 'Cloud API URL', defaultCloudUrl)
4330
+ .option(cliAuthOptions.token, 'CLI authentication token (from dashboard)')
4331
+ .option(cliAuthOptions.sessionCookie, 'Session cookie for authentication (deprecated, use --token)')
4332
+ .option(cliAuthOptions.timeout, 'Timeout in seconds (default: 300)', '300')
4333
+ .action(async (options) => {
4334
+ await runCliAuth('codex', options);
4335
+ });
4336
+ // claude-auth - Alias for Claude/Anthropic
4337
+ program
4338
+ .command('claude-auth')
4339
+ .description('Connect Claude via SSH tunnel to workspace (alias for cli-auth claude)')
4340
+ .option(cliAuthOptions.workspace, 'Workspace ID to connect to')
4341
+ .option(cliAuthOptions.cloudUrl, 'Cloud API URL', defaultCloudUrl)
4342
+ .option(cliAuthOptions.token, 'CLI authentication token (from dashboard)')
4343
+ .option(cliAuthOptions.sessionCookie, 'Session cookie for authentication (deprecated, use --token)')
4344
+ .option(cliAuthOptions.timeout, 'Timeout in seconds (default: 300)', '300')
4345
+ .action(async (options) => {
4346
+ await runCliAuth('claude', options);
4347
+ });
4348
+ // cursor-auth - Alias for Cursor
4349
+ program
4350
+ .command('cursor-auth')
4351
+ .description('Connect Cursor via SSH tunnel to workspace (alias for cli-auth cursor)')
4352
+ .option(cliAuthOptions.workspace, 'Workspace ID to connect to')
4353
+ .option(cliAuthOptions.cloudUrl, 'Cloud API URL', defaultCloudUrl)
4354
+ .option(cliAuthOptions.token, 'CLI authentication token (from dashboard)')
4355
+ .option(cliAuthOptions.sessionCookie, 'Session cookie for authentication (deprecated, use --token)')
4356
+ .option(cliAuthOptions.timeout, 'Timeout in seconds (default: 300)', '300')
4357
+ .action(async (options) => {
4358
+ await runCliAuth('cursor', options);
3847
4359
  });
3848
4360
  // init - First-time setup wizard for Agent Relay
3849
4361
  async function runInit(options) {