/ bridge / bridgeApi.ts
bridgeApi.ts
  1  import axios from 'axios'
  2  
  3  import { debugBody, extractErrorDetail } from './debugUtils.js'
  4  import {
  5    BRIDGE_LOGIN_INSTRUCTION,
  6    type BridgeApiClient,
  7    type BridgeConfig,
  8    type PermissionResponseEvent,
  9    type WorkResponse,
 10  } from './types.js'
 11  
 12  type BridgeApiDeps = {
 13    baseUrl: string
 14    getAccessToken: () => string | undefined
 15    runnerVersion: string
 16    onDebug?: (msg: string) => void
 17    /**
 18     * Called on 401 to attempt OAuth token refresh. Returns true if refreshed,
 19     * in which case the request is retried once. Injected because
 20     * handleOAuth401Error from utils/auth.ts transitively pulls in config.ts →
 21     * file.ts → permissions/filesystem.ts → sessionStorage.ts → commands.ts
 22     * (~1300 modules). Daemon callers using env-var tokens omit this — their
 23     * tokens don't refresh, so 401 goes straight to BridgeFatalError.
 24     */
 25    onAuth401?: (staleAccessToken: string) => Promise<boolean>
 26    /**
 27     * Returns the trusted device token to send as X-Trusted-Device-Token on
 28     * bridge API calls. Bridge sessions have SecurityTier=ELEVATED on the
 29     * server (CCR v2); when the server's enforcement flag is on,
 30     * ConnectBridgeWorker requires a trusted device at JWT-issuance.
 31     * Optional — when absent or returning undefined, the header is omitted
 32     * and the server falls through to its flag-off/no-op path. The CLI-side
 33     * gate is tengu_sessions_elevated_auth_enforcement (see trustedDevice.ts).
 34     */
 35    getTrustedDeviceToken?: () => string | undefined
 36  }
 37  
 38  const BETA_HEADER = 'environments-2025-11-01'
 39  
 40  /** Allowlist pattern for server-provided IDs used in URL path segments. */
 41  const SAFE_ID_PATTERN = /^[a-zA-Z0-9_-]+$/
 42  
 43  /**
 44   * Validate that a server-provided ID is safe to interpolate into a URL path.
 45   * Prevents path traversal (e.g. `../../admin`) and injection via IDs that
 46   * contain slashes, dots, or other special characters.
 47   */
 48  export function validateBridgeId(id: string, label: string): string {
 49    if (!id || !SAFE_ID_PATTERN.test(id)) {
 50      throw new Error(`Invalid ${label}: contains unsafe characters`)
 51    }
 52    return id
 53  }
 54  
 55  /** Fatal bridge errors that should not be retried (e.g. auth failures). */
 56  export class BridgeFatalError extends Error {
 57    readonly status: number
 58    /** Server-provided error type, e.g. "environment_expired". */
 59    readonly errorType: string | undefined
 60    constructor(message: string, status: number, errorType?: string) {
 61      super(message)
 62      this.name = 'BridgeFatalError'
 63      this.status = status
 64      this.errorType = errorType
 65    }
 66  }
 67  
 68  export function createBridgeApiClient(deps: BridgeApiDeps): BridgeApiClient {
 69    function debug(msg: string): void {
 70      deps.onDebug?.(msg)
 71    }
 72  
 73    let consecutiveEmptyPolls = 0
 74    const EMPTY_POLL_LOG_INTERVAL = 100
 75  
 76    function getHeaders(accessToken: string): Record<string, string> {
 77      const headers: Record<string, string> = {
 78        Authorization: `Bearer ${accessToken}`,
 79        'Content-Type': 'application/json',
 80        'anthropic-version': '2023-06-01',
 81        'anthropic-beta': BETA_HEADER,
 82        'x-environment-runner-version': deps.runnerVersion,
 83      }
 84      const deviceToken = deps.getTrustedDeviceToken?.()
 85      if (deviceToken) {
 86        headers['X-Trusted-Device-Token'] = deviceToken
 87      }
 88      return headers
 89    }
 90  
 91    function resolveAuth(): string {
 92      const accessToken = deps.getAccessToken()
 93      if (!accessToken) {
 94        throw new Error(BRIDGE_LOGIN_INSTRUCTION)
 95      }
 96      return accessToken
 97    }
 98  
 99    /**
100     * Execute an OAuth-authenticated request with a single retry on 401.
101     * On 401, attempts token refresh via handleOAuth401Error (same pattern as
102     * withRetry.ts for v1/messages). If refresh succeeds, retries the request
103     * once with the new token. If refresh fails or the retry also returns 401,
104     * the 401 response is returned for handleErrorStatus to throw BridgeFatalError.
105     */
106    async function withOAuthRetry<T>(
107      fn: (accessToken: string) => Promise<{ status: number; data: T }>,
108      context: string,
109    ): Promise<{ status: number; data: T }> {
110      const accessToken = resolveAuth()
111      const response = await fn(accessToken)
112  
113      if (response.status !== 401) {
114        return response
115      }
116  
117      if (!deps.onAuth401) {
118        debug(`[bridge:api] ${context}: 401 received, no refresh handler`)
119        return response
120      }
121  
122      // Attempt token refresh — matches the pattern in withRetry.ts
123      debug(`[bridge:api] ${context}: 401 received, attempting token refresh`)
124      const refreshed = await deps.onAuth401(accessToken)
125      if (refreshed) {
126        debug(`[bridge:api] ${context}: Token refreshed, retrying request`)
127        const newToken = resolveAuth()
128        const retryResponse = await fn(newToken)
129        if (retryResponse.status !== 401) {
130          return retryResponse
131        }
132        debug(`[bridge:api] ${context}: Retry after refresh also got 401`)
133      } else {
134        debug(`[bridge:api] ${context}: Token refresh failed`)
135      }
136  
137      // Refresh failed — return 401 for handleErrorStatus to throw
138      return response
139    }
140  
141    return {
142      async registerBridgeEnvironment(
143        config: BridgeConfig,
144      ): Promise<{ environment_id: string; environment_secret: string }> {
145        debug(
146          `[bridge:api] POST /v1/environments/bridge bridgeId=${config.bridgeId}`,
147        )
148  
149        const response = await withOAuthRetry(
150          (token: string) =>
151            axios.post<{
152              environment_id: string
153              environment_secret: string
154            }>(
155              `${deps.baseUrl}/v1/environments/bridge`,
156              {
157                machine_name: config.machineName,
158                directory: config.dir,
159                branch: config.branch,
160                git_repo_url: config.gitRepoUrl,
161                // Advertise session capacity so claude.ai/code can show
162                // "2/4 sessions" badges and only block the picker when
163                // actually at capacity. Backends that don't yet accept
164                // this field will silently ignore it.
165                max_sessions: config.maxSessions,
166                // worker_type lets claude.ai filter environments by origin
167                // (e.g. assistant picker only shows assistant-mode workers).
168                // Desktop cowork app sends "cowork"; we send a distinct value.
169                metadata: { worker_type: config.workerType },
170                // Idempotent re-registration: if we have a backend-issued
171                // environment_id from a prior session (--session-id resume),
172                // send it back so the backend reattaches instead of creating
173                // a new env. The backend may still hand back a fresh ID if
174                // the old one expired — callers must compare the response.
175                ...(config.reuseEnvironmentId && {
176                  environment_id: config.reuseEnvironmentId,
177                }),
178              },
179              {
180                headers: getHeaders(token),
181                timeout: 15_000,
182                validateStatus: status => status < 500,
183              },
184            ),
185          'Registration',
186        )
187  
188        handleErrorStatus(response.status, response.data, 'Registration')
189        debug(
190          `[bridge:api] POST /v1/environments/bridge -> ${response.status} environment_id=${response.data.environment_id}`,
191        )
192        debug(
193          `[bridge:api] >>> ${debugBody({ machine_name: config.machineName, directory: config.dir, branch: config.branch, git_repo_url: config.gitRepoUrl, max_sessions: config.maxSessions, metadata: { worker_type: config.workerType } })}`,
194        )
195        debug(`[bridge:api] <<< ${debugBody(response.data)}`)
196        return response.data
197      },
198  
199      async pollForWork(
200        environmentId: string,
201        environmentSecret: string,
202        signal?: AbortSignal,
203        reclaimOlderThanMs?: number,
204      ): Promise<WorkResponse | null> {
205        validateBridgeId(environmentId, 'environmentId')
206  
207        // Save and reset so errors break the "consecutive empty" streak.
208        // Restored below when the response is truly empty.
209        const prevEmptyPolls = consecutiveEmptyPolls
210        consecutiveEmptyPolls = 0
211  
212        const response = await axios.get<WorkResponse | null>(
213          `${deps.baseUrl}/v1/environments/${environmentId}/work/poll`,
214          {
215            headers: getHeaders(environmentSecret),
216            params:
217              reclaimOlderThanMs !== undefined
218                ? { reclaim_older_than_ms: reclaimOlderThanMs }
219                : undefined,
220            timeout: 10_000,
221            signal,
222            validateStatus: status => status < 500,
223          },
224        )
225  
226        handleErrorStatus(response.status, response.data, 'Poll')
227  
228        // Empty body or null = no work available
229        if (!response.data) {
230          consecutiveEmptyPolls = prevEmptyPolls + 1
231          if (
232            consecutiveEmptyPolls === 1 ||
233            consecutiveEmptyPolls % EMPTY_POLL_LOG_INTERVAL === 0
234          ) {
235            debug(
236              `[bridge:api] GET .../work/poll -> ${response.status} (no work, ${consecutiveEmptyPolls} consecutive empty polls)`,
237            )
238          }
239          return null
240        }
241  
242        debug(
243          `[bridge:api] GET .../work/poll -> ${response.status} workId=${response.data.id} type=${response.data.data?.type}${response.data.data?.id ? ` sessionId=${response.data.data.id}` : ''}`,
244        )
245        debug(`[bridge:api] <<< ${debugBody(response.data)}`)
246        return response.data
247      },
248  
249      async acknowledgeWork(
250        environmentId: string,
251        workId: string,
252        sessionToken: string,
253      ): Promise<void> {
254        validateBridgeId(environmentId, 'environmentId')
255        validateBridgeId(workId, 'workId')
256  
257        debug(`[bridge:api] POST .../work/${workId}/ack`)
258  
259        const response = await axios.post(
260          `${deps.baseUrl}/v1/environments/${environmentId}/work/${workId}/ack`,
261          {},
262          {
263            headers: getHeaders(sessionToken),
264            timeout: 10_000,
265            validateStatus: s => s < 500,
266          },
267        )
268  
269        handleErrorStatus(response.status, response.data, 'Acknowledge')
270        debug(`[bridge:api] POST .../work/${workId}/ack -> ${response.status}`)
271      },
272  
273      async stopWork(
274        environmentId: string,
275        workId: string,
276        force: boolean,
277      ): Promise<void> {
278        validateBridgeId(environmentId, 'environmentId')
279        validateBridgeId(workId, 'workId')
280  
281        debug(`[bridge:api] POST .../work/${workId}/stop force=${force}`)
282  
283        const response = await withOAuthRetry(
284          (token: string) =>
285            axios.post(
286              `${deps.baseUrl}/v1/environments/${environmentId}/work/${workId}/stop`,
287              { force },
288              {
289                headers: getHeaders(token),
290                timeout: 10_000,
291                validateStatus: s => s < 500,
292              },
293            ),
294          'StopWork',
295        )
296  
297        handleErrorStatus(response.status, response.data, 'StopWork')
298        debug(`[bridge:api] POST .../work/${workId}/stop -> ${response.status}`)
299      },
300  
301      async deregisterEnvironment(environmentId: string): Promise<void> {
302        validateBridgeId(environmentId, 'environmentId')
303  
304        debug(`[bridge:api] DELETE /v1/environments/bridge/${environmentId}`)
305  
306        const response = await withOAuthRetry(
307          (token: string) =>
308            axios.delete(
309              `${deps.baseUrl}/v1/environments/bridge/${environmentId}`,
310              {
311                headers: getHeaders(token),
312                timeout: 10_000,
313                validateStatus: s => s < 500,
314              },
315            ),
316          'Deregister',
317        )
318  
319        handleErrorStatus(response.status, response.data, 'Deregister')
320        debug(
321          `[bridge:api] DELETE /v1/environments/bridge/${environmentId} -> ${response.status}`,
322        )
323      },
324  
325      async archiveSession(sessionId: string): Promise<void> {
326        validateBridgeId(sessionId, 'sessionId')
327  
328        debug(`[bridge:api] POST /v1/sessions/${sessionId}/archive`)
329  
330        const response = await withOAuthRetry(
331          (token: string) =>
332            axios.post(
333              `${deps.baseUrl}/v1/sessions/${sessionId}/archive`,
334              {},
335              {
336                headers: getHeaders(token),
337                timeout: 10_000,
338                validateStatus: s => s < 500,
339              },
340            ),
341          'ArchiveSession',
342        )
343  
344        // 409 = already archived (idempotent, not an error)
345        if (response.status === 409) {
346          debug(
347            `[bridge:api] POST /v1/sessions/${sessionId}/archive -> 409 (already archived)`,
348          )
349          return
350        }
351  
352        handleErrorStatus(response.status, response.data, 'ArchiveSession')
353        debug(
354          `[bridge:api] POST /v1/sessions/${sessionId}/archive -> ${response.status}`,
355        )
356      },
357  
358      async reconnectSession(
359        environmentId: string,
360        sessionId: string,
361      ): Promise<void> {
362        validateBridgeId(environmentId, 'environmentId')
363        validateBridgeId(sessionId, 'sessionId')
364  
365        debug(
366          `[bridge:api] POST /v1/environments/${environmentId}/bridge/reconnect session_id=${sessionId}`,
367        )
368  
369        const response = await withOAuthRetry(
370          (token: string) =>
371            axios.post(
372              `${deps.baseUrl}/v1/environments/${environmentId}/bridge/reconnect`,
373              { session_id: sessionId },
374              {
375                headers: getHeaders(token),
376                timeout: 10_000,
377                validateStatus: s => s < 500,
378              },
379            ),
380          'ReconnectSession',
381        )
382  
383        handleErrorStatus(response.status, response.data, 'ReconnectSession')
384        debug(`[bridge:api] POST .../bridge/reconnect -> ${response.status}`)
385      },
386  
387      async heartbeatWork(
388        environmentId: string,
389        workId: string,
390        sessionToken: string,
391      ): Promise<{ lease_extended: boolean; state: string }> {
392        validateBridgeId(environmentId, 'environmentId')
393        validateBridgeId(workId, 'workId')
394  
395        debug(`[bridge:api] POST .../work/${workId}/heartbeat`)
396  
397        const response = await axios.post<{
398          lease_extended: boolean
399          state: string
400          last_heartbeat: string
401          ttl_seconds: number
402        }>(
403          `${deps.baseUrl}/v1/environments/${environmentId}/work/${workId}/heartbeat`,
404          {},
405          {
406            headers: getHeaders(sessionToken),
407            timeout: 10_000,
408            validateStatus: s => s < 500,
409          },
410        )
411  
412        handleErrorStatus(response.status, response.data, 'Heartbeat')
413        debug(
414          `[bridge:api] POST .../work/${workId}/heartbeat -> ${response.status} lease_extended=${response.data.lease_extended} state=${response.data.state}`,
415        )
416        return response.data
417      },
418  
419      async sendPermissionResponseEvent(
420        sessionId: string,
421        event: PermissionResponseEvent,
422        sessionToken: string,
423      ): Promise<void> {
424        validateBridgeId(sessionId, 'sessionId')
425  
426        debug(
427          `[bridge:api] POST /v1/sessions/${sessionId}/events type=${event.type}`,
428        )
429  
430        const response = await axios.post(
431          `${deps.baseUrl}/v1/sessions/${sessionId}/events`,
432          { events: [event] },
433          {
434            headers: getHeaders(sessionToken),
435            timeout: 10_000,
436            validateStatus: s => s < 500,
437          },
438        )
439  
440        handleErrorStatus(
441          response.status,
442          response.data,
443          'SendPermissionResponseEvent',
444        )
445        debug(
446          `[bridge:api] POST /v1/sessions/${sessionId}/events -> ${response.status}`,
447        )
448        debug(`[bridge:api] >>> ${debugBody({ events: [event] })}`)
449        debug(`[bridge:api] <<< ${debugBody(response.data)}`)
450      },
451    }
452  }
453  
454  function handleErrorStatus(
455    status: number,
456    data: unknown,
457    context: string,
458  ): void {
459    if (status === 200 || status === 204) {
460      return
461    }
462    const detail = extractErrorDetail(data)
463    const errorType = extractErrorTypeFromData(data)
464    switch (status) {
465      case 401:
466        throw new BridgeFatalError(
467          `${context}: Authentication failed (401)${detail ? `: ${detail}` : ''}. ${BRIDGE_LOGIN_INSTRUCTION}`,
468          401,
469          errorType,
470        )
471      case 403:
472        throw new BridgeFatalError(
473          isExpiredErrorType(errorType)
474            ? 'Remote Control session has expired. Please restart with `claude remote-control` or /remote-control.'
475            : `${context}: Access denied (403)${detail ? `: ${detail}` : ''}. Check your organization permissions.`,
476          403,
477          errorType,
478        )
479      case 404:
480        throw new BridgeFatalError(
481          detail ??
482            `${context}: Not found (404). Remote Control may not be available for this organization.`,
483          404,
484          errorType,
485        )
486      case 410:
487        throw new BridgeFatalError(
488          detail ??
489            'Remote Control session has expired. Please restart with `claude remote-control` or /remote-control.',
490          410,
491          errorType ?? 'environment_expired',
492        )
493      case 429:
494        throw new Error(`${context}: Rate limited (429). Polling too frequently.`)
495      default:
496        throw new Error(
497          `${context}: Failed with status ${status}${detail ? `: ${detail}` : ''}`,
498        )
499    }
500  }
501  
502  /** Check whether an error type string indicates a session/environment expiry. */
503  export function isExpiredErrorType(errorType: string | undefined): boolean {
504    if (!errorType) {
505      return false
506    }
507    return errorType.includes('expired') || errorType.includes('lifetime')
508  }
509  
510  /**
511   * Check whether a BridgeFatalError is a suppressible 403 permission error.
512   * These are 403 errors for scopes like 'external_poll_sessions' or operations
513   * like StopWork that fail because the user's role lacks 'environments:manage'.
514   * They don't affect core functionality and shouldn't be shown to users.
515   */
516  export function isSuppressible403(err: BridgeFatalError): boolean {
517    if (err.status !== 403) {
518      return false
519    }
520    return (
521      err.message.includes('external_poll_sessions') ||
522      err.message.includes('environments:manage')
523    )
524  }
525  
526  function extractErrorTypeFromData(data: unknown): string | undefined {
527    if (data && typeof data === 'object') {
528      if (
529        'error' in data &&
530        data.error &&
531        typeof data.error === 'object' &&
532        'type' in data.error &&
533        typeof data.error.type === 'string'
534      ) {
535        return data.error.type
536      }
537    }
538    return undefined
539  }