codeSessionApi.ts 4.7 KB

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