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.
- package/bin/relay-pty-darwin-arm64 +0 -0
- package/bin/relay-pty-darwin-x64 +0 -0
- package/bin/relay-pty-linux-arm64 +0 -0
- package/bin/relay-pty-linux-x64 +0 -0
- package/dist/index.cjs +2 -0
- package/dist/src/cli/index.d.ts.map +1 -1
- package/dist/src/cli/index.js +580 -18
- package/dist/src/cli/index.js.map +1 -1
- package/package.json +18 -18
- package/packages/acp-bridge/package.json +2 -2
- package/packages/api-types/package.json +1 -1
- package/packages/benchmark/package.json +5 -5
- package/packages/bridge/dist/spawner.d.ts.map +1 -1
- package/packages/bridge/dist/spawner.js +2 -0
- package/packages/bridge/dist/spawner.js.map +1 -1
- package/packages/bridge/package.json +7 -7
- package/packages/bridge/src/spawner.ts +2 -0
- package/packages/cli-tester/package.json +1 -1
- package/packages/config/package.json +2 -2
- package/packages/continuity/package.json +2 -2
- package/packages/daemon/dist/api.d.ts.map +1 -1
- package/packages/daemon/dist/api.js +22 -3
- package/packages/daemon/dist/api.js.map +1 -1
- package/packages/daemon/dist/cli-auth.d.ts +8 -0
- package/packages/daemon/dist/cli-auth.d.ts.map +1 -1
- package/packages/daemon/dist/cli-auth.js +33 -0
- package/packages/daemon/dist/cli-auth.js.map +1 -1
- package/packages/daemon/package.json +12 -12
- package/packages/daemon/src/api.ts +25 -3
- package/packages/daemon/src/cli-auth.ts +39 -0
- package/packages/hooks/package.json +4 -4
- package/packages/mcp/package.json +5 -5
- package/packages/memory/package.json +2 -2
- package/packages/policy/package.json +2 -2
- package/packages/protocol/package.json +1 -1
- package/packages/resiliency/package.json +1 -1
- package/packages/sdk/package.json +3 -3
- package/packages/spawner/package.json +1 -1
- package/packages/state/package.json +1 -1
- package/packages/storage/package.json +2 -2
- package/packages/telemetry/package.json +1 -1
- package/packages/trajectory/package.json +2 -2
- package/packages/user-directory/package.json +2 -2
- package/packages/utils/package.json +3 -3
- package/packages/wrapper/package.json +6 -6
package/dist/src/cli/index.js
CHANGED
|
@@ -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
|
-
//
|
|
3596
|
+
// auth - SSH-based interactive provider authentication (cloud workspaces)
|
|
3596
3597
|
// ============================================================================
|
|
3597
3598
|
program
|
|
3598
|
-
.command('
|
|
3599
|
-
.description('
|
|
3600
|
-
.option('--workspace <id>', 'Workspace ID to
|
|
3601
|
-
.option('--
|
|
3602
|
-
.option('--
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
|
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(
|
|
3788
|
-
console.log(dim(
|
|
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(
|
|
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) {
|