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