import axios from 'axios' import { debugBody, extractErrorDetail } from './debugUtils.js' import { BRIDGE_LOGIN_INSTRUCTION, type BridgeApiClient, type BridgeConfig, type PermissionResponseEvent, type WorkResponse, } from './types.js' type BridgeApiDeps = { baseUrl: string getAccessToken: () => string | undefined runnerVersion: string onDebug?: (msg: string) => void /** * Called on 401 to attempt OAuth token refresh. Returns true if refreshed, * in which case the request is retried once. Injected because * handleOAuth401Error from utils/auth.ts transitively pulls in config.ts → * file.ts → permissions/filesystem.ts → sessionStorage.ts → commands.ts * (~1300 modules). Daemon callers using env-var tokens omit this — their * tokens don't refresh, so 401 goes straight to BridgeFatalError. */ onAuth401?: (staleAccessToken: string) => Promise /** * Returns the trusted device token to send as X-Trusted-Device-Token on * bridge API calls. Bridge sessions have SecurityTier=ELEVATED on the * server (CCR v2); when the server's enforcement flag is on, * ConnectBridgeWorker requires a trusted device at JWT-issuance. * Optional — when absent or returning undefined, the header is omitted * and the server falls through to its flag-off/no-op path. The CLI-side * gate is tengu_sessions_elevated_auth_enforcement (see trustedDevice.ts). */ getTrustedDeviceToken?: () => string | undefined } const BETA_HEADER = 'environments-2025-11-01' /** Allowlist pattern for server-provided IDs used in URL path segments. */ const SAFE_ID_PATTERN = /^[a-zA-Z0-9_-]+$/ /** * Validate that a server-provided ID is safe to interpolate into a URL path. * Prevents path traversal (e.g. `../../admin`) and injection via IDs that * contain slashes, dots, or other special characters. */ export function validateBridgeId(id: string, label: string): string { if (!id || !SAFE_ID_PATTERN.test(id)) { throw new Error(`Invalid ${label}: contains unsafe characters`) } return id } /** Fatal bridge errors that should not be retried (e.g. auth failures). */ export class BridgeFatalError extends Error { readonly status: number /** Server-provided error type, e.g. "environment_expired". */ readonly errorType: string | undefined constructor(message: string, status: number, errorType?: string) { super(message) this.name = 'BridgeFatalError' this.status = status this.errorType = errorType } } export function createBridgeApiClient(deps: BridgeApiDeps): BridgeApiClient { function debug(msg: string): void { deps.onDebug?.(msg) } let consecutiveEmptyPolls = 0 const EMPTY_POLL_LOG_INTERVAL = 100 function getHeaders(accessToken: string): Record { const headers: Record = { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json', 'anthropic-version': '2023-06-01', 'anthropic-beta': BETA_HEADER, 'x-environment-runner-version': deps.runnerVersion, } const deviceToken = deps.getTrustedDeviceToken?.() if (deviceToken) { headers['X-Trusted-Device-Token'] = deviceToken } return headers } function resolveAuth(): string { const accessToken = deps.getAccessToken() if (!accessToken) { throw new Error(BRIDGE_LOGIN_INSTRUCTION) } return accessToken } /** * Execute an OAuth-authenticated request with a single retry on 401. * On 401, attempts token refresh via handleOAuth401Error (same pattern as * withRetry.ts for v1/messages). If refresh succeeds, retries the request * once with the new token. If refresh fails or the retry also returns 401, * the 401 response is returned for handleErrorStatus to throw BridgeFatalError. */ async function withOAuthRetry( fn: (accessToken: string) => Promise<{ status: number; data: T }>, context: string, ): Promise<{ status: number; data: T }> { const accessToken = resolveAuth() const response = await fn(accessToken) if (response.status !== 401) { return response } if (!deps.onAuth401) { debug(`[bridge:api] ${context}: 401 received, no refresh handler`) return response } // Attempt token refresh — matches the pattern in withRetry.ts debug(`[bridge:api] ${context}: 401 received, attempting token refresh`) const refreshed = await deps.onAuth401(accessToken) if (refreshed) { debug(`[bridge:api] ${context}: Token refreshed, retrying request`) const newToken = resolveAuth() const retryResponse = await fn(newToken) if (retryResponse.status !== 401) { return retryResponse } debug(`[bridge:api] ${context}: Retry after refresh also got 401`) } else { debug(`[bridge:api] ${context}: Token refresh failed`) } // Refresh failed — return 401 for handleErrorStatus to throw return response } return { async registerBridgeEnvironment( config: BridgeConfig, ): Promise<{ environment_id: string; environment_secret: string }> { debug( `[bridge:api] POST /v1/environments/bridge bridgeId=${config.bridgeId}`, ) const response = await withOAuthRetry( (token: string) => axios.post<{ environment_id: string environment_secret: string }>( `${deps.baseUrl}/v1/environments/bridge`, { machine_name: config.machineName, directory: config.dir, branch: config.branch, git_repo_url: config.gitRepoUrl, // Advertise session capacity so claude.ai/code can show // "2/4 sessions" badges and only block the picker when // actually at capacity. Backends that don't yet accept // this field will silently ignore it. max_sessions: config.maxSessions, // worker_type lets claude.ai filter environments by origin // (e.g. assistant picker only shows assistant-mode workers). // Desktop cowork app sends "cowork"; we send a distinct value. metadata: { worker_type: config.workerType }, // Idempotent re-registration: if we have a backend-issued // environment_id from a prior session (--session-id resume), // send it back so the backend reattaches instead of creating // a new env. The backend may still hand back a fresh ID if // the old one expired — callers must compare the response. ...(config.reuseEnvironmentId && { environment_id: config.reuseEnvironmentId, }), }, { headers: getHeaders(token), timeout: 15_000, validateStatus: status => status < 500, }, ), 'Registration', ) handleErrorStatus(response.status, response.data, 'Registration') debug( `[bridge:api] POST /v1/environments/bridge -> ${response.status} environment_id=${response.data.environment_id}`, ) debug( `[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 } })}`, ) debug(`[bridge:api] <<< ${debugBody(response.data)}`) return response.data }, async pollForWork( environmentId: string, environmentSecret: string, signal?: AbortSignal, reclaimOlderThanMs?: number, ): Promise { validateBridgeId(environmentId, 'environmentId') // Save and reset so errors break the "consecutive empty" streak. // Restored below when the response is truly empty. const prevEmptyPolls = consecutiveEmptyPolls consecutiveEmptyPolls = 0 const response = await axios.get( `${deps.baseUrl}/v1/environments/${environmentId}/work/poll`, { headers: getHeaders(environmentSecret), params: reclaimOlderThanMs !== undefined ? { reclaim_older_than_ms: reclaimOlderThanMs } : undefined, timeout: 10_000, signal, validateStatus: status => status < 500, }, ) handleErrorStatus(response.status, response.data, 'Poll') // Empty body or null = no work available if (!response.data) { consecutiveEmptyPolls = prevEmptyPolls + 1 if ( consecutiveEmptyPolls === 1 || consecutiveEmptyPolls % EMPTY_POLL_LOG_INTERVAL === 0 ) { debug( `[bridge:api] GET .../work/poll -> ${response.status} (no work, ${consecutiveEmptyPolls} consecutive empty polls)`, ) } return null } debug( `[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}` : ''}`, ) debug(`[bridge:api] <<< ${debugBody(response.data)}`) return response.data }, async acknowledgeWork( environmentId: string, workId: string, sessionToken: string, ): Promise { validateBridgeId(environmentId, 'environmentId') validateBridgeId(workId, 'workId') debug(`[bridge:api] POST .../work/${workId}/ack`) const response = await axios.post( `${deps.baseUrl}/v1/environments/${environmentId}/work/${workId}/ack`, {}, { headers: getHeaders(sessionToken), timeout: 10_000, validateStatus: s => s < 500, }, ) handleErrorStatus(response.status, response.data, 'Acknowledge') debug(`[bridge:api] POST .../work/${workId}/ack -> ${response.status}`) }, async stopWork( environmentId: string, workId: string, force: boolean, ): Promise { validateBridgeId(environmentId, 'environmentId') validateBridgeId(workId, 'workId') debug(`[bridge:api] POST .../work/${workId}/stop force=${force}`) const response = await withOAuthRetry( (token: string) => axios.post( `${deps.baseUrl}/v1/environments/${environmentId}/work/${workId}/stop`, { force }, { headers: getHeaders(token), timeout: 10_000, validateStatus: s => s < 500, }, ), 'StopWork', ) handleErrorStatus(response.status, response.data, 'StopWork') debug(`[bridge:api] POST .../work/${workId}/stop -> ${response.status}`) }, async deregisterEnvironment(environmentId: string): Promise { validateBridgeId(environmentId, 'environmentId') debug(`[bridge:api] DELETE /v1/environments/bridge/${environmentId}`) const response = await withOAuthRetry( (token: string) => axios.delete( `${deps.baseUrl}/v1/environments/bridge/${environmentId}`, { headers: getHeaders(token), timeout: 10_000, validateStatus: s => s < 500, }, ), 'Deregister', ) handleErrorStatus(response.status, response.data, 'Deregister') debug( `[bridge:api] DELETE /v1/environments/bridge/${environmentId} -> ${response.status}`, ) }, async archiveSession(sessionId: string): Promise { validateBridgeId(sessionId, 'sessionId') debug(`[bridge:api] POST /v1/sessions/${sessionId}/archive`) const response = await withOAuthRetry( (token: string) => axios.post( `${deps.baseUrl}/v1/sessions/${sessionId}/archive`, {}, { headers: getHeaders(token), timeout: 10_000, validateStatus: s => s < 500, }, ), 'ArchiveSession', ) // 409 = already archived (idempotent, not an error) if (response.status === 409) { debug( `[bridge:api] POST /v1/sessions/${sessionId}/archive -> 409 (already archived)`, ) return } handleErrorStatus(response.status, response.data, 'ArchiveSession') debug( `[bridge:api] POST /v1/sessions/${sessionId}/archive -> ${response.status}`, ) }, async reconnectSession( environmentId: string, sessionId: string, ): Promise { validateBridgeId(environmentId, 'environmentId') validateBridgeId(sessionId, 'sessionId') debug( `[bridge:api] POST /v1/environments/${environmentId}/bridge/reconnect session_id=${sessionId}`, ) const response = await withOAuthRetry( (token: string) => axios.post( `${deps.baseUrl}/v1/environments/${environmentId}/bridge/reconnect`, { session_id: sessionId }, { headers: getHeaders(token), timeout: 10_000, validateStatus: s => s < 500, }, ), 'ReconnectSession', ) handleErrorStatus(response.status, response.data, 'ReconnectSession') debug(`[bridge:api] POST .../bridge/reconnect -> ${response.status}`) }, async heartbeatWork( environmentId: string, workId: string, sessionToken: string, ): Promise<{ lease_extended: boolean; state: string }> { validateBridgeId(environmentId, 'environmentId') validateBridgeId(workId, 'workId') debug(`[bridge:api] POST .../work/${workId}/heartbeat`) const response = await axios.post<{ lease_extended: boolean state: string last_heartbeat: string ttl_seconds: number }>( `${deps.baseUrl}/v1/environments/${environmentId}/work/${workId}/heartbeat`, {}, { headers: getHeaders(sessionToken), timeout: 10_000, validateStatus: s => s < 500, }, ) handleErrorStatus(response.status, response.data, 'Heartbeat') debug( `[bridge:api] POST .../work/${workId}/heartbeat -> ${response.status} lease_extended=${response.data.lease_extended} state=${response.data.state}`, ) return response.data }, async sendPermissionResponseEvent( sessionId: string, event: PermissionResponseEvent, sessionToken: string, ): Promise { validateBridgeId(sessionId, 'sessionId') debug( `[bridge:api] POST /v1/sessions/${sessionId}/events type=${event.type}`, ) const response = await axios.post( `${deps.baseUrl}/v1/sessions/${sessionId}/events`, { events: [event] }, { headers: getHeaders(sessionToken), timeout: 10_000, validateStatus: s => s < 500, }, ) handleErrorStatus( response.status, response.data, 'SendPermissionResponseEvent', ) debug( `[bridge:api] POST /v1/sessions/${sessionId}/events -> ${response.status}`, ) debug(`[bridge:api] >>> ${debugBody({ events: [event] })}`) debug(`[bridge:api] <<< ${debugBody(response.data)}`) }, } } function handleErrorStatus( status: number, data: unknown, context: string, ): void { if (status === 200 || status === 204) { return } const detail = extractErrorDetail(data) const errorType = extractErrorTypeFromData(data) switch (status) { case 401: throw new BridgeFatalError( `${context}: Authentication failed (401)${detail ? `: ${detail}` : ''}. ${BRIDGE_LOGIN_INSTRUCTION}`, 401, errorType, ) case 403: throw new BridgeFatalError( isExpiredErrorType(errorType) ? 'Remote Control session has expired. Please restart with `claude remote-control` or /remote-control.' : `${context}: Access denied (403)${detail ? `: ${detail}` : ''}. Check your organization permissions.`, 403, errorType, ) case 404: throw new BridgeFatalError( detail ?? `${context}: Not found (404). Remote Control may not be available for this organization.`, 404, errorType, ) case 410: throw new BridgeFatalError( detail ?? 'Remote Control session has expired. Please restart with `claude remote-control` or /remote-control.', 410, errorType ?? 'environment_expired', ) case 429: throw new Error(`${context}: Rate limited (429). Polling too frequently.`) default: throw new Error( `${context}: Failed with status ${status}${detail ? `: ${detail}` : ''}`, ) } } /** Check whether an error type string indicates a session/environment expiry. */ export function isExpiredErrorType(errorType: string | undefined): boolean { if (!errorType) { return false } return errorType.includes('expired') || errorType.includes('lifetime') } /** * Check whether a BridgeFatalError is a suppressible 403 permission error. * These are 403 errors for scopes like 'external_poll_sessions' or operations * like StopWork that fail because the user's role lacks 'environments:manage'. * They don't affect core functionality and shouldn't be shown to users. */ export function isSuppressible403(err: BridgeFatalError): boolean { if (err.status !== 403) { return false } return ( err.message.includes('external_poll_sessions') || err.message.includes('environments:manage') ) } function extractErrorTypeFromData(data: unknown): string | undefined { if (data && typeof data === 'object') { if ( 'error' in data && data.error && typeof data.error === 'object' && 'type' in data.error && typeof data.error.type === 'string' ) { return data.error.type } } return undefined }