/ bridge / codeSessionApi.ts
codeSessionApi.ts
  1  /**
  2   * Thin HTTP wrappers for the CCR v2 code-session API.
  3   *
  4   * Separate file from remoteBridgeCore.ts so the SDK /bridge subpath can
  5   * export createCodeSession + fetchRemoteCredentials without bundling the
  6   * heavy CLI tree (analytics, transport, etc.). Callers supply explicit
  7   * accessToken + baseUrl — no implicit auth or config reads.
  8   */
  9  
 10  import axios from 'axios'
 11  import { logForDebugging } from '../utils/debug.js'
 12  import { errorMessage } from '../utils/errors.js'
 13  import { jsonStringify } from '../utils/slowOperations.js'
 14  import { extractErrorDetail } from './debugUtils.js'
 15  
 16  const ANTHROPIC_VERSION = '2023-06-01'
 17  
 18  function oauthHeaders(accessToken: string): Record<string, string> {
 19    return {
 20      Authorization: `Bearer ${accessToken}`,
 21      'Content-Type': 'application/json',
 22      'anthropic-version': ANTHROPIC_VERSION,
 23    }
 24  }
 25  
 26  export async function createCodeSession(
 27    baseUrl: string,
 28    accessToken: string,
 29    title: string,
 30    timeoutMs: number,
 31    tags?: string[],
 32  ): Promise<string | null> {
 33    const url = `${baseUrl}/v1/code/sessions`
 34    let response
 35    try {
 36      response = await axios.post(
 37        url,
 38        // bridge: {} is the positive signal for the oneof runner — omitting it
 39        // (or sending environment_id: "") now 400s. BridgeRunner is an empty
 40        // message today; it's a placeholder for future bridge-specific options.
 41        { title, bridge: {}, ...(tags?.length ? { tags } : {}) },
 42        {
 43          headers: oauthHeaders(accessToken),
 44          timeout: timeoutMs,
 45          validateStatus: s => s < 500,
 46        },
 47      )
 48    } catch (err: unknown) {
 49      logForDebugging(
 50        `[code-session] Session create request failed: ${errorMessage(err)}`,
 51      )
 52      return null
 53    }
 54  
 55    if (response.status !== 200 && response.status !== 201) {
 56      const detail = extractErrorDetail(response.data)
 57      logForDebugging(
 58        `[code-session] Session create failed ${response.status}${detail ? `: ${detail}` : ''}`,
 59      )
 60      return null
 61    }
 62  
 63    const data: unknown = response.data
 64    if (
 65      !data ||
 66      typeof data !== 'object' ||
 67      !('session' in data) ||
 68      !data.session ||
 69      typeof data.session !== 'object' ||
 70      !('id' in data.session) ||
 71      typeof data.session.id !== 'string' ||
 72      !data.session.id.startsWith('cse_')
 73    ) {
 74      logForDebugging(
 75        `[code-session] No session.id (cse_*) in response: ${jsonStringify(data).slice(0, 200)}`,
 76      )
 77      return null
 78    }
 79    return data.session.id
 80  }
 81  
 82  /**
 83   * Credentials from POST /bridge. JWT is opaque — do not decode.
 84   * Each /bridge call bumps worker_epoch server-side (it IS the register).
 85   */
 86  export type RemoteCredentials = {
 87    worker_jwt: string
 88    api_base_url: string
 89    expires_in: number
 90    worker_epoch: number
 91  }
 92  
 93  export async function fetchRemoteCredentials(
 94    sessionId: string,
 95    baseUrl: string,
 96    accessToken: string,
 97    timeoutMs: number,
 98    trustedDeviceToken?: string,
 99  ): Promise<RemoteCredentials | null> {
100    const url = `${baseUrl}/v1/code/sessions/${sessionId}/bridge`
101    const headers = oauthHeaders(accessToken)
102    if (trustedDeviceToken) {
103      headers['X-Trusted-Device-Token'] = trustedDeviceToken
104    }
105    let response
106    try {
107      response = await axios.post(
108        url,
109        {},
110        {
111          headers,
112          timeout: timeoutMs,
113          validateStatus: s => s < 500,
114        },
115      )
116    } catch (err: unknown) {
117      logForDebugging(
118        `[code-session] /bridge request failed: ${errorMessage(err)}`,
119      )
120      return null
121    }
122  
123    if (response.status !== 200) {
124      const detail = extractErrorDetail(response.data)
125      logForDebugging(
126        `[code-session] /bridge failed ${response.status}${detail ? `: ${detail}` : ''}`,
127      )
128      return null
129    }
130  
131    const data: unknown = response.data
132    if (
133      data === null ||
134      typeof data !== 'object' ||
135      !('worker_jwt' in data) ||
136      typeof data.worker_jwt !== 'string' ||
137      !('expires_in' in data) ||
138      typeof data.expires_in !== 'number' ||
139      !('api_base_url' in data) ||
140      typeof data.api_base_url !== 'string' ||
141      !('worker_epoch' in data)
142    ) {
143      logForDebugging(
144        `[code-session] /bridge response malformed (need worker_jwt, expires_in, api_base_url, worker_epoch): ${jsonStringify(data).slice(0, 200)}`,
145      )
146      return null
147    }
148    // protojson serializes int64 as a string to avoid JS precision loss;
149    // Go may also return a number depending on encoder settings.
150    const rawEpoch = data.worker_epoch
151    const epoch = typeof rawEpoch === 'string' ? Number(rawEpoch) : rawEpoch
152    if (
153      typeof epoch !== 'number' ||
154      !Number.isFinite(epoch) ||
155      !Number.isSafeInteger(epoch)
156    ) {
157      logForDebugging(
158        `[code-session] /bridge worker_epoch invalid: ${jsonStringify(rawEpoch)}`,
159      )
160      return null
161    }
162    return {
163      worker_jwt: data.worker_jwt,
164      api_base_url: data.api_base_url,
165      expires_in: data.expires_in,
166      worker_epoch: epoch,
167    }
168  }