workSecret.ts 4.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127
  1. import axios from 'axios'
  2. import { jsonParse, jsonStringify } from '../utils/slowOperations.js'
  3. import type { WorkSecret } from './types.js'
  4. /** Decode a base64url-encoded work secret and validate its version. */
  5. export function decodeWorkSecret(secret: string): WorkSecret {
  6. const json = Buffer.from(secret, 'base64url').toString('utf-8')
  7. const parsed: unknown = jsonParse(json)
  8. if (
  9. !parsed ||
  10. typeof parsed !== 'object' ||
  11. !('version' in parsed) ||
  12. parsed.version !== 1
  13. ) {
  14. throw new Error(
  15. `Unsupported work secret version: ${parsed && typeof parsed === 'object' && 'version' in parsed ? parsed.version : 'unknown'}`,
  16. )
  17. }
  18. const obj = parsed as Record<string, unknown>
  19. if (
  20. typeof obj.session_ingress_token !== 'string' ||
  21. obj.session_ingress_token.length === 0
  22. ) {
  23. throw new Error(
  24. 'Invalid work secret: missing or empty session_ingress_token',
  25. )
  26. }
  27. if (typeof obj.api_base_url !== 'string') {
  28. throw new Error('Invalid work secret: missing api_base_url')
  29. }
  30. return parsed as WorkSecret
  31. }
  32. /**
  33. * Build a WebSocket SDK URL from the API base URL and session ID.
  34. * Strips the HTTP(S) protocol and constructs a ws(s):// ingress URL.
  35. *
  36. * Uses /v2/ for localhost (direct to session-ingress, no Envoy rewrite)
  37. * and /v1/ for production (Envoy rewrites /v1/ → /v2/).
  38. */
  39. export function buildSdkUrl(apiBaseUrl: string, sessionId: string): string {
  40. const isLocalhost =
  41. apiBaseUrl.includes('localhost') || apiBaseUrl.includes('127.0.0.1')
  42. const protocol = isLocalhost ? 'ws' : 'wss'
  43. const version = isLocalhost ? 'v2' : 'v1'
  44. const host = apiBaseUrl.replace(/^https?:\/\//, '').replace(/\/+$/, '')
  45. return `${protocol}://${host}/${version}/session_ingress/ws/${sessionId}`
  46. }
  47. /**
  48. * Compare two session IDs regardless of their tagged-ID prefix.
  49. *
  50. * Tagged IDs have the form {tag}_{body} or {tag}_staging_{body}, where the
  51. * body encodes a UUID. CCR v2's compat layer returns `session_*` to v1 API
  52. * clients (compat/convert.go:41) but the infrastructure layer (sandbox-gateway
  53. * work queue, work poll response) uses `cse_*` (compat/CLAUDE.md:13). Both
  54. * have the same underlying UUID.
  55. *
  56. * Without this, replBridge rejects its own session as "foreign" at the
  57. * work-received check when the ccr_v2_compat_enabled gate is on.
  58. */
  59. export function sameSessionId(a: string, b: string): boolean {
  60. if (a === b) return true
  61. // The body is everything after the last underscore — this handles both
  62. // `{tag}_{body}` and `{tag}_staging_{body}`.
  63. const aBody = a.slice(a.lastIndexOf('_') + 1)
  64. const bBody = b.slice(b.lastIndexOf('_') + 1)
  65. // Guard against IDs with no underscore (bare UUIDs): lastIndexOf returns -1,
  66. // slice(0) returns the whole string, and we already checked a === b above.
  67. // Require a minimum length to avoid accidental matches on short suffixes
  68. // (e.g. single-char tag remnants from malformed IDs).
  69. return aBody.length >= 4 && aBody === bBody
  70. }
  71. /**
  72. * Build a CCR v2 session URL from the API base URL and session ID.
  73. * Unlike buildSdkUrl, this returns an HTTP(S) URL (not ws://) and points at
  74. * /v1/code/sessions/{id} — the child CC will derive the SSE stream path
  75. * and worker endpoints from this base.
  76. */
  77. export function buildCCRv2SdkUrl(
  78. apiBaseUrl: string,
  79. sessionId: string,
  80. ): string {
  81. const base = apiBaseUrl.replace(/\/+$/, '')
  82. return `${base}/v1/code/sessions/${sessionId}`
  83. }
  84. /**
  85. * Register this bridge as the worker for a CCR v2 session.
  86. * Returns the worker_epoch, which must be passed to the child CC process
  87. * so its CCRClient can include it in every heartbeat/state/event request.
  88. *
  89. * Mirrors what environment-manager does in the container path
  90. * (api-go/environment-manager/cmd/cmd_task_run.go RegisterWorker).
  91. */
  92. export async function registerWorker(
  93. sessionUrl: string,
  94. accessToken: string,
  95. ): Promise<number> {
  96. const response = await axios.post(
  97. `${sessionUrl}/worker/register`,
  98. {},
  99. {
  100. headers: {
  101. Authorization: `Bearer ${accessToken}`,
  102. 'Content-Type': 'application/json',
  103. 'anthropic-version': '2023-06-01',
  104. },
  105. timeout: 10_000,
  106. },
  107. )
  108. // protojson serializes int64 as a string to avoid JS number precision loss;
  109. // the Go side may also return a number depending on encoder settings.
  110. const raw = response.data?.worker_epoch
  111. const epoch = typeof raw === 'string' ? Number(raw) : raw
  112. if (
  113. typeof epoch !== 'number' ||
  114. !Number.isFinite(epoch) ||
  115. !Number.isSafeInteger(epoch)
  116. ) {
  117. throw new Error(
  118. `registerWorker: invalid worker_epoch in response: ${jsonStringify(response.data)}`,
  119. )
  120. }
  121. return epoch
  122. }