/ utils / teleport / api.ts
api.ts
  1  import axios, { type AxiosRequestConfig, type AxiosResponse } from 'axios'
  2  import { randomUUID } from 'crypto'
  3  import { getOauthConfig } from 'src/constants/oauth.js'
  4  import { getOrganizationUUID } from 'src/services/oauth/client.js'
  5  import z from 'zod/v4'
  6  import { getClaudeAIOAuthTokens } from '../auth.js'
  7  import { logForDebugging } from '../debug.js'
  8  import { parseGitHubRepository } from '../detectRepository.js'
  9  import { errorMessage, toError } from '../errors.js'
 10  import { lazySchema } from '../lazySchema.js'
 11  import { logError } from '../log.js'
 12  import { sleep } from '../sleep.js'
 13  import { jsonStringify } from '../slowOperations.js'
 14  
 15  // Retry configuration for teleport API requests
 16  const TELEPORT_RETRY_DELAYS = [2000, 4000, 8000, 16000] // 4 retries with exponential backoff
 17  const MAX_TELEPORT_RETRIES = TELEPORT_RETRY_DELAYS.length
 18  
 19  export const CCR_BYOC_BETA = 'ccr-byoc-2025-07-29'
 20  
 21  /**
 22   * Checks if an axios error is a transient network error that should be retried
 23   */
 24  export function isTransientNetworkError(error: unknown): boolean {
 25    if (!axios.isAxiosError(error)) {
 26      return false
 27    }
 28  
 29    // Retry on network errors (no response received)
 30    if (!error.response) {
 31      return true
 32    }
 33  
 34    // Retry on server errors (5xx)
 35    if (error.response.status >= 500) {
 36      return true
 37    }
 38  
 39    // Don't retry on client errors (4xx) - they're not transient
 40    return false
 41  }
 42  
 43  /**
 44   * Makes an axios GET request with automatic retry for transient network errors
 45   * Uses exponential backoff: 2s, 4s, 8s, 16s (4 retries = 5 total attempts)
 46   */
 47  export async function axiosGetWithRetry<T>(
 48    url: string,
 49    config?: AxiosRequestConfig,
 50  ): Promise<AxiosResponse<T>> {
 51    let lastError: unknown
 52  
 53    for (let attempt = 0; attempt <= MAX_TELEPORT_RETRIES; attempt++) {
 54      try {
 55        return await axios.get<T>(url, config)
 56      } catch (error) {
 57        lastError = error
 58  
 59        // Don't retry if this isn't a transient error
 60        if (!isTransientNetworkError(error)) {
 61          throw error
 62        }
 63  
 64        // Don't retry if we've exhausted all retries
 65        if (attempt >= MAX_TELEPORT_RETRIES) {
 66          logForDebugging(
 67            `Teleport request failed after ${attempt + 1} attempts: ${errorMessage(error)}`,
 68          )
 69          throw error
 70        }
 71  
 72        const delay = TELEPORT_RETRY_DELAYS[attempt] ?? 2000
 73        logForDebugging(
 74          `Teleport request failed (attempt ${attempt + 1}/${MAX_TELEPORT_RETRIES + 1}), retrying in ${delay}ms: ${errorMessage(error)}`,
 75        )
 76        await sleep(delay)
 77      }
 78    }
 79  
 80    throw lastError
 81  }
 82  
 83  // Types matching the actual Sessions API response from api/schemas/sessions/sessions.py
 84  export type SessionStatus = 'requires_action' | 'running' | 'idle' | 'archived'
 85  
 86  export type GitSource = {
 87    type: 'git_repository'
 88    url: string
 89    revision?: string | null
 90    allow_unrestricted_git_push?: boolean
 91  }
 92  
 93  export type KnowledgeBaseSource = {
 94    type: 'knowledge_base'
 95    knowledge_base_id: string
 96  }
 97  
 98  export type SessionContextSource = GitSource | KnowledgeBaseSource
 99  
100  // Outcome types from api/schemas/sandbox.py
101  export type OutcomeGitInfo = {
102    type: 'github'
103    repo: string
104    branches: string[]
105  }
106  
107  export type GitRepositoryOutcome = {
108    type: 'git_repository'
109    git_info: OutcomeGitInfo
110  }
111  
112  export type Outcome = GitRepositoryOutcome
113  
114  export type SessionContext = {
115    sources: SessionContextSource[]
116    cwd: string
117    outcomes: Outcome[] | null
118    custom_system_prompt: string | null
119    append_system_prompt: string | null
120    model: string | null
121    // Seed filesystem with a git bundle on Files API
122    seed_bundle_file_id?: string
123    github_pr?: { owner: string; repo: string; number: number }
124    reuse_outcome_branches?: boolean
125  }
126  
127  export type SessionResource = {
128    type: 'session'
129    id: string
130    title: string | null
131    session_status: SessionStatus
132    environment_id: string
133    created_at: string
134    updated_at: string
135    session_context: SessionContext
136  }
137  
138  export type ListSessionsResponse = {
139    data: SessionResource[]
140    has_more: boolean
141    first_id: string | null
142    last_id: string | null
143  }
144  
145  export const CodeSessionSchema = lazySchema(() =>
146    z.object({
147      id: z.string(),
148      title: z.string(),
149      description: z.string(),
150      status: z.enum([
151        'idle',
152        'working',
153        'waiting',
154        'completed',
155        'archived',
156        'cancelled',
157        'rejected',
158      ]),
159      repo: z
160        .object({
161          name: z.string(),
162          owner: z.object({
163            login: z.string(),
164          }),
165          default_branch: z.string().optional(),
166        })
167        .nullable(),
168      turns: z.array(z.string()),
169      created_at: z.string(),
170      updated_at: z.string(),
171    }),
172  )
173  
174  // Export the inferred type from the Zod schema
175  export type CodeSession = z.infer<ReturnType<typeof CodeSessionSchema>>
176  
177  /**
178   * Validates and prepares for API requests
179   * @returns Object containing access token and organization UUID
180   */
181  export async function prepareApiRequest(): Promise<{
182    accessToken: string
183    orgUUID: string
184  }> {
185    const accessToken = getClaudeAIOAuthTokens()?.accessToken
186    if (accessToken === undefined) {
187      throw new Error(
188        'Claude Code web sessions require authentication with a Claude.ai account. API key authentication is not sufficient. Please run /login to authenticate, or check your authentication status with /status.',
189      )
190    }
191  
192    const orgUUID = await getOrganizationUUID()
193    if (!orgUUID) {
194      throw new Error('Unable to get organization UUID')
195    }
196  
197    return { accessToken, orgUUID }
198  }
199  
200  /**
201   * Fetches code sessions from the new Sessions API (/v1/sessions)
202   * @returns Array of code sessions
203   */
204  export async function fetchCodeSessionsFromSessionsAPI(): Promise<
205    CodeSession[]
206  > {
207    const { accessToken, orgUUID } = await prepareApiRequest()
208  
209    const url = `${getOauthConfig().BASE_API_URL}/v1/sessions`
210  
211    try {
212      const headers = {
213        ...getOAuthHeaders(accessToken),
214        'anthropic-beta': 'ccr-byoc-2025-07-29',
215        'x-organization-uuid': orgUUID,
216      }
217  
218      const response = await axiosGetWithRetry<ListSessionsResponse>(url, {
219        headers,
220      })
221  
222      if (response.status !== 200) {
223        throw new Error(`Failed to fetch code sessions: ${response.statusText}`)
224      }
225  
226      // Transform SessionResource[] to CodeSession[] format
227      const sessions: CodeSession[] = response.data.data.map(session => {
228        // Extract repository info from git sources
229        const gitSource = session.session_context.sources.find(
230          (source): source is GitSource => source.type === 'git_repository',
231        )
232  
233        let repo: CodeSession['repo'] = null
234        if (gitSource?.url) {
235          // Parse GitHub URL using the existing utility function
236          const repoPath = parseGitHubRepository(gitSource.url)
237          if (repoPath) {
238            const [owner, name] = repoPath.split('/')
239            if (owner && name) {
240              repo = {
241                name,
242                owner: {
243                  login: owner,
244                },
245                default_branch: gitSource.revision || undefined,
246              }
247            }
248          }
249        }
250  
251        return {
252          id: session.id,
253          title: session.title || 'Untitled',
254          description: '', // SessionResource doesn't have description field
255          status: session.session_status as CodeSession['status'], // Map session_status to status
256          repo,
257          turns: [], // SessionResource doesn't have turns field
258          created_at: session.created_at,
259          updated_at: session.updated_at,
260        }
261      })
262  
263      return sessions
264    } catch (error) {
265      const err = toError(error)
266      logError(err)
267      throw error
268    }
269  }
270  
271  /**
272   * Creates OAuth headers for API requests
273   * @param accessToken The OAuth access token
274   * @returns Headers object with Authorization, Content-Type, and anthropic-version
275   */
276  export function getOAuthHeaders(accessToken: string): Record<string, string> {
277    return {
278      Authorization: `Bearer ${accessToken}`,
279      'Content-Type': 'application/json',
280      'anthropic-version': '2023-06-01',
281    }
282  }
283  
284  /**
285   * Fetches a single session by ID from the Sessions API
286   * @param sessionId The session ID to fetch
287   * @returns The session resource
288   */
289  export async function fetchSession(
290    sessionId: string,
291  ): Promise<SessionResource> {
292    const { accessToken, orgUUID } = await prepareApiRequest()
293  
294    const url = `${getOauthConfig().BASE_API_URL}/v1/sessions/${sessionId}`
295    const headers = {
296      ...getOAuthHeaders(accessToken),
297      'anthropic-beta': 'ccr-byoc-2025-07-29',
298      'x-organization-uuid': orgUUID,
299    }
300  
301    const response = await axios.get<SessionResource>(url, {
302      headers,
303      timeout: 15000,
304      validateStatus: status => status < 500,
305    })
306  
307    if (response.status !== 200) {
308      // Extract error message from response if available
309      const errorData = response.data as { error?: { message?: string } }
310      const apiMessage = errorData?.error?.message
311  
312      if (response.status === 404) {
313        throw new Error(`Session not found: ${sessionId}`)
314      }
315  
316      if (response.status === 401) {
317        throw new Error('Session expired. Please run /login to sign in again.')
318      }
319  
320      throw new Error(
321        apiMessage ||
322          `Failed to fetch session: ${response.status} ${response.statusText}`,
323      )
324    }
325  
326    return response.data
327  }
328  
329  /**
330   * Extracts the first branch name from a session's git repository outcomes
331   * @param session The session resource to extract from
332   * @returns The first branch name, or undefined if none found
333   */
334  export function getBranchFromSession(
335    session: SessionResource,
336  ): string | undefined {
337    const gitOutcome = session.session_context.outcomes?.find(
338      (outcome): outcome is GitRepositoryOutcome =>
339        outcome.type === 'git_repository',
340    )
341    return gitOutcome?.git_info?.branches[0]
342  }
343  
344  /**
345   * Content for a remote session message.
346   * Accepts a plain string or an array of content blocks (text, image, etc.)
347   * following the Anthropic API messages spec.
348   */
349  export type RemoteMessageContent =
350    | string
351    | Array<{ type: string; [key: string]: unknown }>
352  
353  /**
354   * Sends a user message event to an existing remote session via the Sessions API
355   * @param sessionId The session ID to send the event to
356   * @param messageContent The user message content (string or content blocks)
357   * @param opts.uuid Optional UUID for the event — callers that added a local
358   *   UserMessage first should pass its UUID so echo filtering can dedup
359   * @returns Promise<boolean> True if successful, false otherwise
360   */
361  export async function sendEventToRemoteSession(
362    sessionId: string,
363    messageContent: RemoteMessageContent,
364    opts?: { uuid?: string },
365  ): Promise<boolean> {
366    try {
367      const { accessToken, orgUUID } = await prepareApiRequest()
368  
369      const url = `${getOauthConfig().BASE_API_URL}/v1/sessions/${sessionId}/events`
370      const headers = {
371        ...getOAuthHeaders(accessToken),
372        'anthropic-beta': 'ccr-byoc-2025-07-29',
373        'x-organization-uuid': orgUUID,
374      }
375  
376      const userEvent = {
377        uuid: opts?.uuid ?? randomUUID(),
378        session_id: sessionId,
379        type: 'user',
380        parent_tool_use_id: null,
381        message: {
382          role: 'user',
383          content: messageContent,
384        },
385      }
386  
387      const requestBody = {
388        events: [userEvent],
389      }
390  
391      logForDebugging(
392        `[sendEventToRemoteSession] Sending event to session ${sessionId}`,
393      )
394      // The endpoint may block until the CCR worker is ready. Observed ~2.6s
395      // in normal cases; allow a generous margin for cold-start containers.
396      const response = await axios.post(url, requestBody, {
397        headers,
398        validateStatus: status => status < 500,
399        timeout: 30000,
400      })
401  
402      if (response.status === 200 || response.status === 201) {
403        logForDebugging(
404          `[sendEventToRemoteSession] Successfully sent event to session ${sessionId}`,
405        )
406        return true
407      }
408  
409      logForDebugging(
410        `[sendEventToRemoteSession] Failed with status ${response.status}: ${jsonStringify(response.data)}`,
411      )
412      return false
413    } catch (error) {
414      logForDebugging(`[sendEventToRemoteSession] Error: ${errorMessage(error)}`)
415      return false
416    }
417  }
418  
419  /**
420   * Updates the title of an existing remote session via the Sessions API
421   * @param sessionId The session ID to update
422   * @param title The new title for the session
423   * @returns Promise<boolean> True if successful, false otherwise
424   */
425  export async function updateSessionTitle(
426    sessionId: string,
427    title: string,
428  ): Promise<boolean> {
429    try {
430      const { accessToken, orgUUID } = await prepareApiRequest()
431  
432      const url = `${getOauthConfig().BASE_API_URL}/v1/sessions/${sessionId}`
433      const headers = {
434        ...getOAuthHeaders(accessToken),
435        'anthropic-beta': 'ccr-byoc-2025-07-29',
436        'x-organization-uuid': orgUUID,
437      }
438  
439      logForDebugging(
440        `[updateSessionTitle] Updating title for session ${sessionId}: "${title}"`,
441      )
442      const response = await axios.patch(
443        url,
444        { title },
445        {
446          headers,
447          validateStatus: status => status < 500,
448        },
449      )
450  
451      if (response.status === 200) {
452        logForDebugging(
453          `[updateSessionTitle] Successfully updated title for session ${sessionId}`,
454        )
455        return true
456      }
457  
458      logForDebugging(
459        `[updateSessionTitle] Failed with status ${response.status}: ${jsonStringify(response.data)}`,
460      )
461      return false
462    } catch (error) {
463      logForDebugging(`[updateSessionTitle] Error: ${errorMessage(error)}`)
464      return false
465    }
466  }