@agent-relay/daemon 0.1.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 (109) hide show
  1. package/dist/agent-manager.d.ts +134 -0
  2. package/dist/agent-manager.d.ts.map +1 -0
  3. package/dist/agent-manager.js +578 -0
  4. package/dist/agent-manager.js.map +1 -0
  5. package/dist/agent-registry.d.ts +99 -0
  6. package/dist/agent-registry.d.ts.map +1 -0
  7. package/dist/agent-registry.js +213 -0
  8. package/dist/agent-registry.js.map +1 -0
  9. package/dist/agent-signing.d.ts +158 -0
  10. package/dist/agent-signing.d.ts.map +1 -0
  11. package/dist/agent-signing.js +523 -0
  12. package/dist/agent-signing.js.map +1 -0
  13. package/dist/api.d.ts +106 -0
  14. package/dist/api.d.ts.map +1 -0
  15. package/dist/api.js +876 -0
  16. package/dist/api.js.map +1 -0
  17. package/dist/auth.d.ts +94 -0
  18. package/dist/auth.d.ts.map +1 -0
  19. package/dist/auth.js +197 -0
  20. package/dist/auth.js.map +1 -0
  21. package/dist/channel-membership-store.d.ts +55 -0
  22. package/dist/channel-membership-store.d.ts.map +1 -0
  23. package/dist/channel-membership-store.js +176 -0
  24. package/dist/channel-membership-store.js.map +1 -0
  25. package/dist/cli-auth.d.ts +89 -0
  26. package/dist/cli-auth.d.ts.map +1 -0
  27. package/dist/cli-auth.js +792 -0
  28. package/dist/cli-auth.js.map +1 -0
  29. package/dist/cloud-sync.d.ts +150 -0
  30. package/dist/cloud-sync.d.ts.map +1 -0
  31. package/dist/cloud-sync.js +446 -0
  32. package/dist/cloud-sync.js.map +1 -0
  33. package/dist/connection.d.ts +130 -0
  34. package/dist/connection.d.ts.map +1 -0
  35. package/dist/connection.js +438 -0
  36. package/dist/connection.js.map +1 -0
  37. package/dist/consensus-integration.d.ts +167 -0
  38. package/dist/consensus-integration.d.ts.map +1 -0
  39. package/dist/consensus-integration.js +371 -0
  40. package/dist/consensus-integration.js.map +1 -0
  41. package/dist/consensus.d.ts +271 -0
  42. package/dist/consensus.d.ts.map +1 -0
  43. package/dist/consensus.js +632 -0
  44. package/dist/consensus.js.map +1 -0
  45. package/dist/delivery-tracker.d.ts +34 -0
  46. package/dist/delivery-tracker.d.ts.map +1 -0
  47. package/dist/delivery-tracker.js +104 -0
  48. package/dist/delivery-tracker.js.map +1 -0
  49. package/dist/enhanced-features.d.ts +118 -0
  50. package/dist/enhanced-features.d.ts.map +1 -0
  51. package/dist/enhanced-features.js +176 -0
  52. package/dist/enhanced-features.js.map +1 -0
  53. package/dist/index.d.ts +31 -0
  54. package/dist/index.d.ts.map +1 -0
  55. package/dist/index.js +37 -0
  56. package/dist/index.js.map +1 -0
  57. package/dist/migrations/index.d.ts +73 -0
  58. package/dist/migrations/index.d.ts.map +1 -0
  59. package/dist/migrations/index.js +241 -0
  60. package/dist/migrations/index.js.map +1 -0
  61. package/dist/orchestrator.d.ts +217 -0
  62. package/dist/orchestrator.d.ts.map +1 -0
  63. package/dist/orchestrator.js +1143 -0
  64. package/dist/orchestrator.js.map +1 -0
  65. package/dist/rate-limiter.d.ts +68 -0
  66. package/dist/rate-limiter.d.ts.map +1 -0
  67. package/dist/rate-limiter.js +130 -0
  68. package/dist/rate-limiter.js.map +1 -0
  69. package/dist/registry.d.ts +9 -0
  70. package/dist/registry.d.ts.map +1 -0
  71. package/dist/registry.js +9 -0
  72. package/dist/registry.js.map +1 -0
  73. package/dist/relay-ledger.d.ts +261 -0
  74. package/dist/relay-ledger.d.ts.map +1 -0
  75. package/dist/relay-ledger.js +532 -0
  76. package/dist/relay-ledger.js.map +1 -0
  77. package/dist/relay-watchdog.d.ts +125 -0
  78. package/dist/relay-watchdog.d.ts.map +1 -0
  79. package/dist/relay-watchdog.js +611 -0
  80. package/dist/relay-watchdog.js.map +1 -0
  81. package/dist/repo-manager.d.ts +116 -0
  82. package/dist/repo-manager.d.ts.map +1 -0
  83. package/dist/repo-manager.js +384 -0
  84. package/dist/repo-manager.js.map +1 -0
  85. package/dist/router.d.ts +370 -0
  86. package/dist/router.d.ts.map +1 -0
  87. package/dist/router.js +1437 -0
  88. package/dist/router.js.map +1 -0
  89. package/dist/server.d.ts +174 -0
  90. package/dist/server.d.ts.map +1 -0
  91. package/dist/server.js +1001 -0
  92. package/dist/server.js.map +1 -0
  93. package/dist/spawn-manager.d.ts +78 -0
  94. package/dist/spawn-manager.d.ts.map +1 -0
  95. package/dist/spawn-manager.js +165 -0
  96. package/dist/spawn-manager.js.map +1 -0
  97. package/dist/sync-queue.d.ts +116 -0
  98. package/dist/sync-queue.d.ts.map +1 -0
  99. package/dist/sync-queue.js +361 -0
  100. package/dist/sync-queue.js.map +1 -0
  101. package/dist/types.d.ts +133 -0
  102. package/dist/types.d.ts.map +1 -0
  103. package/dist/types.js +6 -0
  104. package/dist/types.js.map +1 -0
  105. package/dist/workspace-manager.d.ts +80 -0
  106. package/dist/workspace-manager.d.ts.map +1 -0
  107. package/dist/workspace-manager.js +314 -0
  108. package/dist/workspace-manager.js.map +1 -0
  109. package/package.json +52 -0
@@ -0,0 +1,792 @@
1
+ /**
2
+ * CLI Auth Handler for Workspace Daemon
3
+ *
4
+ * Handles CLI-based authentication (claude, codex, etc.) via PTY.
5
+ * Runs inside the workspace container where CLI tools are installed.
6
+ *
7
+ * Uses relay-pty binary for PTY emulation, providing better Node.js
8
+ * version compatibility by avoiding native module compilation.
9
+ */
10
+ import { spawn } from 'node:child_process';
11
+ import { existsSync } from 'node:fs';
12
+ import * as fs from 'fs/promises';
13
+ import * as os from 'os';
14
+ import { join, dirname } from 'node:path';
15
+ import { fileURLToPath } from 'node:url';
16
+ import * as crypto from 'crypto';
17
+ import { createLogger } from '@agent-relay/resiliency';
18
+ import { CLI_AUTH_CONFIG, stripAnsiCodes, matchesSuccessPattern, findMatchingPrompt, findMatchingError, getSupportedProviders, } from '@agent-relay/config/cli-auth-config';
19
+ import { getUserDirectoryService } from '@agent-relay/user-directory';
20
+ // Get the directory where this module is located
21
+ const __filename = fileURLToPath(import.meta.url);
22
+ const __dirname = dirname(__filename);
23
+ const logger = createLogger('cli-auth');
24
+ // Re-export for consumers
25
+ export { CLI_AUTH_CONFIG, getSupportedProviders };
26
+ /**
27
+ * Find the relay-pty binary path.
28
+ * Returns null if not found.
29
+ */
30
+ function findRelayPtyBinary() {
31
+ // Get the project root (four levels up from packages/daemon/dist/)
32
+ // packages/daemon/dist/ -> packages/daemon -> packages -> project root
33
+ const projectRoot = join(__dirname, '..', '..', '..', '..');
34
+ const candidates = [
35
+ // Primary: installed by postinstall from platform-specific binary
36
+ join(projectRoot, 'bin', 'relay-pty'),
37
+ // Development: local Rust build
38
+ join(projectRoot, 'relay-pty', 'target', 'release', 'relay-pty'),
39
+ join(projectRoot, 'relay-pty', 'target', 'debug', 'relay-pty'),
40
+ // Local build in cwd (for development)
41
+ join(process.cwd(), 'relay-pty', 'target', 'release', 'relay-pty'),
42
+ // Installed globally
43
+ '/usr/local/bin/relay-pty',
44
+ // In node_modules (when installed as dependency)
45
+ join(process.cwd(), 'node_modules', 'agent-relay', 'bin', 'relay-pty'),
46
+ ];
47
+ for (const candidate of candidates) {
48
+ if (existsSync(candidate)) {
49
+ return candidate;
50
+ }
51
+ }
52
+ return null;
53
+ }
54
+ // Active sessions
55
+ const sessions = new Map();
56
+ // Clean up old sessions periodically
57
+ // Use .unref() so this timer doesn't prevent the process from exiting
58
+ setInterval(() => {
59
+ const now = Date.now();
60
+ for (const [id, session] of sessions) {
61
+ if (now - session.createdAt.getTime() > 10 * 60 * 1000) {
62
+ if (session.process) {
63
+ try {
64
+ session.process.kill();
65
+ }
66
+ catch {
67
+ // Process may already be dead
68
+ }
69
+ }
70
+ sessions.delete(id);
71
+ }
72
+ }
73
+ }, 60000).unref();
74
+ /**
75
+ * Start CLI auth flow
76
+ *
77
+ * This function waits for the auth URL to be captured before returning,
78
+ * ensuring the caller can immediately open the OAuth popup.
79
+ */
80
+ export async function startCLIAuth(provider, options = {}) {
81
+ const config = CLI_AUTH_CONFIG[provider];
82
+ if (!config) {
83
+ throw new Error(`Unknown provider: ${provider}`);
84
+ }
85
+ const sessionId = crypto.randomUUID();
86
+ const session = {
87
+ id: sessionId,
88
+ provider,
89
+ userId: options.userId,
90
+ status: 'starting',
91
+ output: '',
92
+ promptsHandled: [],
93
+ createdAt: new Date(),
94
+ };
95
+ sessions.set(sessionId, session);
96
+ logger.info('CLI auth session created', {
97
+ sessionId,
98
+ provider,
99
+ totalActiveSessions: sessions.size,
100
+ allSessionIds: Array.from(sessions.keys()),
101
+ });
102
+ // Check if already authenticated (credentials exist)
103
+ try {
104
+ const existingCreds = await extractCredentials(provider, config, options.userId);
105
+ if (existingCreds?.token) {
106
+ logger.info('Already authenticated - existing credentials found', { provider, sessionId });
107
+ session.status = 'success';
108
+ session.token = existingCreds.token;
109
+ session.refreshToken = existingCreds.refreshToken;
110
+ session.tokenExpiresAt = existingCreds.expiresAt;
111
+ return session;
112
+ }
113
+ }
114
+ catch {
115
+ // No existing credentials, proceed with auth flow
116
+ }
117
+ // Find relay-pty binary
118
+ const relayPtyPath = findRelayPtyBinary();
119
+ if (!relayPtyPath) {
120
+ session.status = 'error';
121
+ session.error = 'relay-pty binary not found. Build with: cd relay-pty && cargo build --release';
122
+ return session;
123
+ }
124
+ // Use device flow args if requested and supported
125
+ const args = options.useDeviceFlow && config.deviceFlowArgs
126
+ ? config.deviceFlowArgs
127
+ : config.args;
128
+ logger.info('Starting CLI auth', {
129
+ provider,
130
+ sessionId,
131
+ useDeviceFlow: options.useDeviceFlow,
132
+ args,
133
+ });
134
+ const respondedPrompts = new Set();
135
+ // Create a promise that resolves when authUrl is captured or timeout
136
+ let resolveAuthUrl;
137
+ const authUrlPromise = new Promise((resolve) => {
138
+ resolveAuthUrl = resolve;
139
+ });
140
+ // Timeout for waiting for auth URL (shorter than the full OAuth timeout)
141
+ const AUTH_URL_WAIT_TIMEOUT = 15000; // 15 seconds to capture auth URL
142
+ const authUrlTimeout = setTimeout(() => {
143
+ logger.warn('Auth URL wait timeout, returning session without URL', { provider, sessionId });
144
+ resolveAuthUrl();
145
+ }, AUTH_URL_WAIT_TIMEOUT);
146
+ try {
147
+ // Get per-user environment if userId provided (for multi-user workspaces)
148
+ // This sets HOME to /data/users/{userId} so CLI stores credentials per-user
149
+ let userEnv = {};
150
+ if (options.userId) {
151
+ try {
152
+ const userDirService = getUserDirectoryService();
153
+ userEnv = userDirService.getUserEnvironment(options.userId);
154
+ logger.info('Using per-user environment for CLI auth', {
155
+ provider,
156
+ userId: options.userId,
157
+ home: userEnv.HOME,
158
+ });
159
+ }
160
+ catch (err) {
161
+ logger.warn('Failed to get user environment, using default', {
162
+ provider,
163
+ userId: options.userId,
164
+ error: err instanceof Error ? err.message : String(err),
165
+ });
166
+ }
167
+ }
168
+ // Build relay-pty arguments
169
+ const relayArgs = [
170
+ '--name', `auth-${sessionId.substring(0, 8)}`,
171
+ '--rows', '30',
172
+ '--cols', '120',
173
+ '--log-level', 'error', // Suppress relay-pty logs
174
+ '--', config.command,
175
+ ...args,
176
+ ];
177
+ const proc = spawn(relayPtyPath, relayArgs, {
178
+ cwd: process.cwd(),
179
+ env: {
180
+ ...process.env,
181
+ ...userEnv, // Override HOME for per-user credential storage
182
+ NO_COLOR: '1',
183
+ TERM: 'xterm-256color',
184
+ // Don't set BROWSER - let CLI fail to open browser and fall back to manual paste mode
185
+ // Setting BROWSER: 'echo' caused CLI to think browser opened and wait for callback that never came
186
+ DISPLAY: '',
187
+ },
188
+ stdio: ['pipe', 'pipe', 'pipe'],
189
+ });
190
+ // Create wrapper for session management
191
+ const processWrapper = {
192
+ write: (data) => proc.stdin?.write(data),
193
+ kill: () => proc.kill(),
194
+ };
195
+ session.process = processWrapper;
196
+ // Timeout handler - give user plenty of time to complete OAuth flow
197
+ // 5 minutes should be enough for even slow OAuth flows
198
+ const OAUTH_COMPLETION_TIMEOUT = 5 * 60 * 1000; // 5 minutes
199
+ const timeout = setTimeout(() => {
200
+ if (session.status === 'starting' || session.status === 'waiting_auth') {
201
+ logger.warn('CLI auth timed out', { provider, sessionId, status: session.status });
202
+ proc.kill();
203
+ session.status = 'error';
204
+ session.error = 'Timeout waiting for auth completion (5 minutes). Please try again.';
205
+ }
206
+ }, config.waitTimeout + OAUTH_COMPLETION_TIMEOUT);
207
+ // Handle data from PTY output
208
+ const handleData = (data) => {
209
+ session.output += data;
210
+ const cleanText = stripAnsiCodes(data);
211
+ // Check for error patterns FIRST - if error detected, don't auto-respond to prompts
212
+ // This prevents us from auto-responding to "Press Enter to retry" in error messages
213
+ const matchedError = findMatchingError(data, config.errorPatterns);
214
+ if (matchedError && session.status !== 'error') {
215
+ logger.warn('Auth error detected', {
216
+ provider,
217
+ sessionId,
218
+ errorMessage: matchedError.message,
219
+ recoverable: matchedError.recoverable,
220
+ });
221
+ session.status = 'error';
222
+ session.error = matchedError.message;
223
+ session.errorHint = matchedError.hint;
224
+ session.recoverable = matchedError.recoverable;
225
+ }
226
+ // Don't auto-respond to prompts if we're in error state
227
+ // This prevents responding to "Press Enter to retry" after an error
228
+ if (session.status !== 'error') {
229
+ const matchingPrompt = findMatchingPrompt(data, config.prompts, respondedPrompts);
230
+ if (matchingPrompt) {
231
+ respondedPrompts.add(matchingPrompt.description);
232
+ session.promptsHandled.push(matchingPrompt.description);
233
+ logger.info('Auto-responding to prompt', { description: matchingPrompt.description });
234
+ const delay = matchingPrompt.delay ?? 100;
235
+ setTimeout(() => {
236
+ try {
237
+ proc.stdin?.write(matchingPrompt.response);
238
+ }
239
+ catch {
240
+ // Process may have exited
241
+ }
242
+ }, delay);
243
+ }
244
+ }
245
+ // Extract auth URL (only if not in error state and don't have URL yet)
246
+ const match = cleanText.match(config.urlPattern);
247
+ if (match && match[1] && !session.authUrl && session.status !== 'error') {
248
+ session.authUrl = match[1];
249
+ session.status = 'waiting_auth';
250
+ logger.info('Auth URL captured', { provider, url: session.authUrl });
251
+ // Signal that we have the auth URL
252
+ clearTimeout(authUrlTimeout);
253
+ resolveAuthUrl();
254
+ }
255
+ // Log all output after auth URL is captured (for debugging)
256
+ if (session.authUrl) {
257
+ const trimmedData = cleanText.trim();
258
+ if (trimmedData.length > 0) {
259
+ logger.info('PTY output after auth URL', {
260
+ provider,
261
+ sessionId,
262
+ output: trimmedData.substring(0, 500),
263
+ });
264
+ }
265
+ }
266
+ // Check for success and try to extract credentials
267
+ // Don't override error status - if there was an error, keep it
268
+ if (session.status !== 'error' && matchesSuccessPattern(data, config.successPatterns)) {
269
+ session.status = 'success';
270
+ logger.info('Success pattern detected, attempting credential extraction', { provider });
271
+ // Try to extract credentials immediately (CLI may not exit after success)
272
+ // Use a small delay to let the CLI finish writing the file
273
+ setTimeout(async () => {
274
+ // Don't extract if status changed to error (e.g., error detected after success pattern)
275
+ if (session.status === 'error') {
276
+ logger.info('Skipping credential extraction - session is in error state', { provider });
277
+ return;
278
+ }
279
+ try {
280
+ const creds = await extractCredentials(provider, config, session.userId);
281
+ if (creds) {
282
+ session.token = creds.token;
283
+ session.refreshToken = creds.refreshToken;
284
+ session.tokenExpiresAt = creds.expiresAt;
285
+ logger.info('Credentials extracted successfully', { provider, hasRefreshToken: !!creds.refreshToken });
286
+ }
287
+ }
288
+ catch (err) {
289
+ logger.error('Failed to extract credentials on success', { error: String(err) });
290
+ }
291
+ }, 500);
292
+ }
293
+ };
294
+ // Handle stdout (main PTY output)
295
+ proc.stdout?.on('data', (data) => {
296
+ handleData(data.toString());
297
+ });
298
+ // Handle stderr (relay-pty logs and some CLI output)
299
+ proc.stderr?.on('data', (data) => {
300
+ handleData(data.toString());
301
+ });
302
+ proc.on('exit', async (exitCode) => {
303
+ clearTimeout(timeout);
304
+ clearTimeout(authUrlTimeout);
305
+ // Clear process reference so submitAuthCode knows PTY is gone
306
+ session.process = undefined;
307
+ // Log full output for debugging PTY exit issues
308
+ const cleanOutput = stripAnsiCodes(session.output);
309
+ logger.info('CLI process exited', {
310
+ provider,
311
+ exitCode,
312
+ outputLength: session.output.length,
313
+ hasAuthUrl: !!session.authUrl,
314
+ sessionStatus: session.status,
315
+ promptsHandled: session.promptsHandled,
316
+ // Last 500 chars of output for debugging
317
+ outputTail: cleanOutput.slice(-500),
318
+ });
319
+ // Try to extract credentials (but don't override error status)
320
+ // CLI might exit cleanly (code 0) even after an OAuth error
321
+ if ((session.authUrl || exitCode === 0) && session.status !== 'error') {
322
+ try {
323
+ const creds = await extractCredentials(provider, config, session.userId);
324
+ if (creds) {
325
+ session.token = creds.token;
326
+ session.refreshToken = creds.refreshToken;
327
+ session.tokenExpiresAt = creds.expiresAt;
328
+ session.status = 'success';
329
+ }
330
+ }
331
+ catch (err) {
332
+ logger.error('Failed to extract credentials', { error: String(err) });
333
+ }
334
+ }
335
+ if (!session.authUrl && !session.token && session.status !== 'error') {
336
+ session.status = 'error';
337
+ session.error = 'CLI exited without auth URL or credentials';
338
+ }
339
+ // Resolve in case we're still waiting
340
+ resolveAuthUrl();
341
+ });
342
+ proc.on('error', (err) => {
343
+ clearTimeout(timeout);
344
+ clearTimeout(authUrlTimeout);
345
+ session.status = 'error';
346
+ session.error = err.message;
347
+ logger.error('CLI process error', { error: err.message });
348
+ resolveAuthUrl();
349
+ });
350
+ }
351
+ catch (err) {
352
+ session.status = 'error';
353
+ session.error = err instanceof Error ? err.message : 'Failed to spawn CLI';
354
+ logger.error('Failed to start CLI auth', { error: session.error });
355
+ clearTimeout(authUrlTimeout);
356
+ resolveAuthUrl();
357
+ }
358
+ // Wait for auth URL to be captured (or timeout)
359
+ await authUrlPromise;
360
+ return session;
361
+ }
362
+ /**
363
+ * Get auth session status
364
+ */
365
+ export function getAuthSession(sessionId) {
366
+ return sessions.get(sessionId) || null;
367
+ }
368
+ /**
369
+ * Submit auth code to a waiting session
370
+ * This writes the code to the PTY process stdin
371
+ *
372
+ * @param sessionId - The auth session ID
373
+ * @param code - The OAuth authorization code
374
+ * @param state - Optional OAuth state parameter for CSRF validation (used by Codex)
375
+ * @returns Object with success status and optional error message
376
+ */
377
+ export async function submitAuthCode(sessionId, code, state) {
378
+ // Log all active sessions for debugging
379
+ const activeSessionIds = Array.from(sessions.keys());
380
+ logger.info('submitAuthCode called', {
381
+ sessionId,
382
+ codeLength: code.length,
383
+ activeSessionCount: activeSessionIds.length,
384
+ activeSessionIds,
385
+ });
386
+ const session = sessions.get(sessionId);
387
+ if (!session) {
388
+ logger.warn('Auth code submission failed: session not found', {
389
+ sessionId,
390
+ activeSessionIds,
391
+ hint: 'Session may have been cleaned up or never created',
392
+ });
393
+ return { success: false, error: 'Session not found or expired', needsRestart: true };
394
+ }
395
+ logger.info('Session found for code submission', {
396
+ sessionId,
397
+ provider: session.provider,
398
+ status: session.status,
399
+ hasProcess: !!session.process,
400
+ hasAuthUrl: !!session.authUrl,
401
+ hasToken: !!session.token,
402
+ promptsHandled: session.promptsHandled,
403
+ createdAt: session.createdAt.toISOString(),
404
+ ageSeconds: Math.round((Date.now() - session.createdAt.getTime()) / 1000),
405
+ });
406
+ if (!session.process) {
407
+ logger.warn('Auth code submission failed: no PTY process', {
408
+ sessionId,
409
+ sessionStatus: session.status,
410
+ provider: session.provider,
411
+ outputLength: session.output?.length || 0,
412
+ outputTail: session.output ? stripAnsiCodes(session.output).slice(-500) : 'no output',
413
+ });
414
+ // Try to extract credentials as a fallback - maybe auth completed in browser
415
+ // But don't override error status
416
+ const config = CLI_AUTH_CONFIG[session.provider];
417
+ if (config && session.status !== 'error') {
418
+ try {
419
+ const creds = await extractCredentials(session.provider, config, session.userId);
420
+ // Re-check status after async operation (race condition protection)
421
+ // Use type assertion because TypeScript narrowing doesn't account for async race conditions
422
+ if (creds && session.status !== 'error') {
423
+ session.token = creds.token;
424
+ session.refreshToken = creds.refreshToken;
425
+ session.tokenExpiresAt = creds.expiresAt;
426
+ session.status = 'success';
427
+ logger.info('Credentials found despite PTY exit', { provider: session.provider });
428
+ return { success: true };
429
+ }
430
+ }
431
+ catch {
432
+ // No credentials found
433
+ }
434
+ }
435
+ // For providers like Claude that need the code pasted into CLI,
436
+ // if the PTY is gone, user needs to restart the auth flow
437
+ return {
438
+ success: false,
439
+ error: 'The authentication session has ended. The CLI process exited before the code could be entered. Please click "Try Again" to restart.',
440
+ needsRestart: true,
441
+ };
442
+ }
443
+ try {
444
+ // Clean the code - trim whitespace and strip state parameter if present
445
+ // Claude OAuth codes come as "CODE#STATE" - we only need the code part
446
+ let cleanCode = code.trim();
447
+ if (cleanCode.includes('#')) {
448
+ const originalCode = cleanCode;
449
+ cleanCode = cleanCode.split('#')[0];
450
+ logger.info('Stripped state parameter from auth code', {
451
+ sessionId,
452
+ originalLength: originalCode.length,
453
+ cleanLength: cleanCode.length,
454
+ });
455
+ }
456
+ // For Codex (openai), forward the callback to the CLI's localhost server
457
+ // instead of writing to PTY stdin. The CLI spawns a localhost server
458
+ // waiting for the OAuth callback.
459
+ if (session.provider === 'openai' && session.authUrl) {
460
+ // Extract the redirect port from the auth URL (usually 1455)
461
+ const redirectMatch = session.authUrl.match(/redirect_uri=http%3A%2F%2Flocalhost%3A(\d+)/);
462
+ const port = redirectMatch ? redirectMatch[1] : '1455';
463
+ logger.info('Forwarding OAuth callback to Codex CLI localhost server', {
464
+ sessionId,
465
+ port,
466
+ codeLength: cleanCode.length,
467
+ hasState: !!state,
468
+ });
469
+ try {
470
+ // Forward the callback to the CLI's localhost server
471
+ // Include state parameter for CSRF validation if provided
472
+ let callbackUrl = `http://localhost:${port}/auth/callback?code=${encodeURIComponent(cleanCode)}`;
473
+ if (state) {
474
+ callbackUrl += `&state=${encodeURIComponent(state)}`;
475
+ }
476
+ const response = await fetch(callbackUrl, {
477
+ method: 'GET',
478
+ signal: AbortSignal.timeout(5000),
479
+ });
480
+ if (response.ok) {
481
+ logger.info('OAuth callback forwarded successfully to Codex CLI', { sessionId, status: response.status });
482
+ // Start polling for credentials
483
+ const config = CLI_AUTH_CONFIG[session.provider];
484
+ if (config) {
485
+ pollForCredentials(session, config);
486
+ }
487
+ return { success: true };
488
+ }
489
+ else {
490
+ // Try to get error details from response body
491
+ let errorBody = '';
492
+ try {
493
+ errorBody = await response.text();
494
+ }
495
+ catch {
496
+ // Ignore
497
+ }
498
+ logger.warn('Codex CLI localhost server returned error', {
499
+ sessionId,
500
+ status: response.status,
501
+ statusText: response.statusText,
502
+ errorBody: errorBody.substring(0, 500), // Limit log size
503
+ callbackUrl: callbackUrl.replace(/code=[^&]+/, 'code=***'), // Redact code
504
+ });
505
+ // Fall through to PTY write as fallback
506
+ }
507
+ }
508
+ catch (err) {
509
+ logger.warn('Failed to forward callback to Codex CLI localhost server', {
510
+ sessionId,
511
+ error: String(err),
512
+ });
513
+ // Fall through to PTY write as fallback
514
+ }
515
+ }
516
+ logger.info('Writing auth code to PTY', {
517
+ sessionId,
518
+ originalLength: code.length,
519
+ cleanLength: cleanCode.length,
520
+ codePreview: cleanCode.substring(0, 20) + '...',
521
+ });
522
+ // Write the auth code WITHOUT Enter first
523
+ // Claude CLI's Ink text input needs time to process the input
524
+ // before receiving Enter (tested: immediate Enter fails, delayed Enter works)
525
+ session.process.write(cleanCode);
526
+ logger.info('Auth code written, waiting before sending Enter...', { sessionId });
527
+ // Wait 1 second for CLI to process the typed input
528
+ await new Promise(resolve => setTimeout(resolve, 1000));
529
+ // Now send Enter to submit
530
+ session.process.write('\r');
531
+ logger.info('Enter key sent', { sessionId });
532
+ // Start polling for credentials after code submission
533
+ // The CLI should write credentials shortly after receiving the code
534
+ const config = CLI_AUTH_CONFIG[session.provider];
535
+ if (config) {
536
+ pollForCredentials(session, config);
537
+ }
538
+ return { success: true };
539
+ }
540
+ catch (err) {
541
+ logger.error('Failed to submit auth code', { sessionId, error: String(err) });
542
+ return {
543
+ success: false,
544
+ error: 'Failed to write to CLI process. The process may have exited. Please try again.',
545
+ needsRestart: true,
546
+ };
547
+ }
548
+ }
549
+ /**
550
+ * Poll for credentials file after auth code submission
551
+ * Some CLIs don't output success patterns, so we check the file directly
552
+ */
553
+ async function pollForCredentials(session, config) {
554
+ const maxAttempts = 10;
555
+ const pollInterval = 1000; // 1 second
556
+ for (let i = 0; i < maxAttempts; i++) {
557
+ await new Promise(resolve => setTimeout(resolve, pollInterval));
558
+ // Skip if session already has credentials or errored
559
+ if (session.token || session.status === 'error') {
560
+ return;
561
+ }
562
+ try {
563
+ const creds = await extractCredentials(session.provider, config, session.userId);
564
+ if (creds) {
565
+ // Double-check we're not in error state (race condition protection)
566
+ // Use type assertion because TypeScript narrowing doesn't account for async race conditions
567
+ if (session.status === 'error') {
568
+ logger.info('Credentials found but session is in error state, not overriding', {
569
+ provider: session.provider,
570
+ });
571
+ return;
572
+ }
573
+ session.token = creds.token;
574
+ session.refreshToken = creds.refreshToken;
575
+ session.tokenExpiresAt = creds.expiresAt;
576
+ session.status = 'success';
577
+ logger.info('Credentials found via polling', {
578
+ provider: session.provider,
579
+ attempt: i + 1,
580
+ hasRefreshToken: !!creds.refreshToken,
581
+ });
582
+ return;
583
+ }
584
+ }
585
+ catch {
586
+ // File doesn't exist yet, continue polling
587
+ }
588
+ }
589
+ logger.warn('Credential polling completed without finding credentials', {
590
+ provider: session.provider,
591
+ sessionId: session.id,
592
+ });
593
+ }
594
+ /**
595
+ * Complete auth session by polling for credentials
596
+ * Called when user indicates they've completed auth in browser
597
+ */
598
+ export async function completeAuthSession(sessionId) {
599
+ const session = sessions.get(sessionId);
600
+ if (!session) {
601
+ return { success: false, error: 'Session not found or expired' };
602
+ }
603
+ // Already have credentials
604
+ if (session.token) {
605
+ return { success: true, token: session.token };
606
+ }
607
+ const config = CLI_AUTH_CONFIG[session.provider];
608
+ if (!config) {
609
+ return { success: false, error: 'Unknown provider' };
610
+ }
611
+ // Poll for credentials (user just completed auth in browser)
612
+ const maxAttempts = 15;
613
+ const pollInterval = 1000;
614
+ for (let i = 0; i < maxAttempts; i++) {
615
+ // Check if session went into error state
616
+ if (session.status === 'error') {
617
+ return { success: false, error: session.error || 'Authentication failed' };
618
+ }
619
+ try {
620
+ const creds = await extractCredentials(session.provider, config, session.userId);
621
+ if (creds) {
622
+ // Double-check we're not in error state (race condition protection)
623
+ // Use type assertion because TypeScript narrowing doesn't account for async race conditions
624
+ if (session.status === 'error') {
625
+ return { success: false, error: session.error || 'Authentication failed' };
626
+ }
627
+ session.token = creds.token;
628
+ session.refreshToken = creds.refreshToken;
629
+ session.tokenExpiresAt = creds.expiresAt;
630
+ session.status = 'success';
631
+ logger.info('Credentials found via complete polling', {
632
+ provider: session.provider,
633
+ attempt: i + 1,
634
+ });
635
+ return { success: true, token: creds.token };
636
+ }
637
+ }
638
+ catch {
639
+ // File doesn't exist yet
640
+ }
641
+ await new Promise(resolve => setTimeout(resolve, pollInterval));
642
+ }
643
+ return {
644
+ success: false,
645
+ error: 'Credentials not found. Please ensure you completed authentication in the browser.',
646
+ };
647
+ }
648
+ /**
649
+ * Cancel auth session
650
+ */
651
+ export function cancelAuthSession(sessionId) {
652
+ const session = sessions.get(sessionId);
653
+ if (!session)
654
+ return false;
655
+ if (session.process) {
656
+ try {
657
+ session.process.kill();
658
+ }
659
+ catch {
660
+ // Already dead
661
+ }
662
+ }
663
+ sessions.delete(sessionId);
664
+ return true;
665
+ }
666
+ function resolveCredentialPath(provider, config, userId) {
667
+ if (!config.credentialPath)
668
+ return null;
669
+ if (!userId) {
670
+ return config.credentialPath.replace('~', os.homedir());
671
+ }
672
+ try {
673
+ const userDirService = getUserDirectoryService();
674
+ const userHome = userDirService.getUserHome(userId);
675
+ return config.credentialPath.replace('~', userHome);
676
+ }
677
+ catch (err) {
678
+ logger.warn('Failed to resolve per-user credential path, using default', {
679
+ provider,
680
+ userId,
681
+ error: err instanceof Error ? err.message : String(err),
682
+ });
683
+ return config.credentialPath.replace('~', os.homedir());
684
+ }
685
+ }
686
+ /**
687
+ * Extract credentials from CLI credential file
688
+ */
689
+ async function extractCredentials(provider, config, userId) {
690
+ const credPath = resolveCredentialPath(provider, config, userId);
691
+ if (!credPath)
692
+ return null;
693
+ try {
694
+ const content = await fs.readFile(credPath, 'utf8');
695
+ const creds = JSON.parse(content);
696
+ // Extract token based on provider
697
+ if (provider === 'anthropic') {
698
+ // Claude stores OAuth in: { claudeAiOauth: { accessToken: "...", refreshToken: "...", expiresAt: ... } }
699
+ if (creds.claudeAiOauth?.accessToken) {
700
+ return {
701
+ token: creds.claudeAiOauth.accessToken,
702
+ refreshToken: creds.claudeAiOauth.refreshToken,
703
+ expiresAt: creds.claudeAiOauth.expiresAt ? new Date(creds.claudeAiOauth.expiresAt) : undefined,
704
+ };
705
+ }
706
+ // Fallback to legacy formats
707
+ const token = creds.oauth_token || creds.access_token || creds.api_key;
708
+ return token ? { token } : null;
709
+ }
710
+ else if (provider === 'openai') {
711
+ // Codex stores OAuth in: { tokens: { access_token: "...", refresh_token: "...", ... } }
712
+ if (creds.tokens?.access_token) {
713
+ return {
714
+ token: creds.tokens.access_token,
715
+ refreshToken: creds.tokens.refresh_token,
716
+ };
717
+ }
718
+ // Fallback: API key or legacy formats
719
+ const token = creds.OPENAI_API_KEY || creds.token || creds.access_token || creds.api_key;
720
+ return token ? { token } : null;
721
+ }
722
+ else if (provider === 'opencode') {
723
+ // OpenCode stores multiple providers: { opencode: {...}, anthropic: {...}, openai: {...}, google: {...} }
724
+ // Check for any valid credential - prefer OpenCode Zen, then Anthropic
725
+ if (creds.opencode?.key) {
726
+ return { token: creds.opencode.key };
727
+ }
728
+ if (creds.anthropic?.access) {
729
+ return {
730
+ token: creds.anthropic.access,
731
+ refreshToken: creds.anthropic.refresh,
732
+ expiresAt: creds.anthropic.expires ? new Date(creds.anthropic.expires) : undefined,
733
+ };
734
+ }
735
+ if (creds.openai?.access) {
736
+ return {
737
+ token: creds.openai.access,
738
+ refreshToken: creds.openai.refresh,
739
+ expiresAt: creds.openai.expires ? new Date(creds.openai.expires) : undefined,
740
+ };
741
+ }
742
+ if (creds.google?.key) {
743
+ return { token: creds.google.key };
744
+ }
745
+ return null;
746
+ }
747
+ else if (provider === 'cursor') {
748
+ // Cursor stores credentials in various formats - try common patterns
749
+ // { accessToken: "...", refreshToken: "..." } or { token: "..." } or nested
750
+ if (creds.accessToken) {
751
+ return {
752
+ token: creds.accessToken,
753
+ refreshToken: creds.refreshToken,
754
+ expiresAt: creds.expiresAt ? new Date(creds.expiresAt) : undefined,
755
+ };
756
+ }
757
+ if (creds.auth?.accessToken) {
758
+ return {
759
+ token: creds.auth.accessToken,
760
+ refreshToken: creds.auth.refreshToken,
761
+ expiresAt: creds.auth.expiresAt ? new Date(creds.auth.expiresAt) : undefined,
762
+ };
763
+ }
764
+ // Fallback to generic token fields
765
+ const token = creds.token || creds.access_token || creds.api_key;
766
+ return token ? { token } : null;
767
+ }
768
+ const token = creds.token || creds.access_token || creds.api_key;
769
+ return token ? { token } : null;
770
+ }
771
+ catch {
772
+ return null;
773
+ }
774
+ }
775
+ /**
776
+ * Check if a provider is authenticated (credentials exist)
777
+ * Used by the auth check endpoint for SSH tunnel flow
778
+ */
779
+ export async function checkProviderAuth(provider, userId) {
780
+ const config = CLI_AUTH_CONFIG[provider];
781
+ if (!config) {
782
+ return false;
783
+ }
784
+ try {
785
+ const creds = await extractCredentials(provider, config, userId);
786
+ return !!creds?.token;
787
+ }
788
+ catch {
789
+ return false;
790
+ }
791
+ }
792
+ //# sourceMappingURL=cli-auth.js.map