540 lines
18 KiB
TypeScript
540 lines
18 KiB
TypeScript
import axios from 'axios'
|
|
|
|
import { debugBody, extractErrorDetail } from './debugUtils.js'
|
|
import {
|
|
BRIDGE_LOGIN_INSTRUCTION,
|
|
type BridgeApiClient,
|
|
type BridgeConfig,
|
|
type PermissionResponseEvent,
|
|
type WorkResponse,
|
|
} from './types.js'
|
|
|
|
type BridgeApiDeps = {
|
|
baseUrl: string
|
|
getAccessToken: () => string | undefined
|
|
runnerVersion: string
|
|
onDebug?: (msg: string) => void
|
|
/**
|
|
* Called on 401 to attempt OAuth token refresh. Returns true if refreshed,
|
|
* in which case the request is retried once. Injected because
|
|
* handleOAuth401Error from utils/auth.ts transitively pulls in config.ts →
|
|
* file.ts → permissions/filesystem.ts → sessionStorage.ts → commands.ts
|
|
* (~1300 modules). Daemon callers using env-var tokens omit this — their
|
|
* tokens don't refresh, so 401 goes straight to BridgeFatalError.
|
|
*/
|
|
onAuth401?: (staleAccessToken: string) => Promise<boolean>
|
|
/**
|
|
* Returns the trusted device token to send as X-Trusted-Device-Token on
|
|
* bridge API calls. Bridge sessions have SecurityTier=ELEVATED on the
|
|
* server (CCR v2); when the server's enforcement flag is on,
|
|
* ConnectBridgeWorker requires a trusted device at JWT-issuance.
|
|
* Optional — when absent or returning undefined, the header is omitted
|
|
* and the server falls through to its flag-off/no-op path. The CLI-side
|
|
* gate is tengu_sessions_elevated_auth_enforcement (see trustedDevice.ts).
|
|
*/
|
|
getTrustedDeviceToken?: () => string | undefined
|
|
}
|
|
|
|
const BETA_HEADER = 'environments-2025-11-01'
|
|
|
|
/** Allowlist pattern for server-provided IDs used in URL path segments. */
|
|
const SAFE_ID_PATTERN = /^[a-zA-Z0-9_-]+$/
|
|
|
|
/**
|
|
* Validate that a server-provided ID is safe to interpolate into a URL path.
|
|
* Prevents path traversal (e.g. `../../admin`) and injection via IDs that
|
|
* contain slashes, dots, or other special characters.
|
|
*/
|
|
export function validateBridgeId(id: string, label: string): string {
|
|
if (!id || !SAFE_ID_PATTERN.test(id)) {
|
|
throw new Error(`Invalid ${label}: contains unsafe characters`)
|
|
}
|
|
return id
|
|
}
|
|
|
|
/** Fatal bridge errors that should not be retried (e.g. auth failures). */
|
|
export class BridgeFatalError extends Error {
|
|
readonly status: number
|
|
/** Server-provided error type, e.g. "environment_expired". */
|
|
readonly errorType: string | undefined
|
|
constructor(message: string, status: number, errorType?: string) {
|
|
super(message)
|
|
this.name = 'BridgeFatalError'
|
|
this.status = status
|
|
this.errorType = errorType
|
|
}
|
|
}
|
|
|
|
export function createBridgeApiClient(deps: BridgeApiDeps): BridgeApiClient {
|
|
function debug(msg: string): void {
|
|
deps.onDebug?.(msg)
|
|
}
|
|
|
|
let consecutiveEmptyPolls = 0
|
|
const EMPTY_POLL_LOG_INTERVAL = 100
|
|
|
|
function getHeaders(accessToken: string): Record<string, string> {
|
|
const headers: Record<string, string> = {
|
|
Authorization: `Bearer ${accessToken}`,
|
|
'Content-Type': 'application/json',
|
|
'anthropic-version': '2023-06-01',
|
|
'anthropic-beta': BETA_HEADER,
|
|
'x-environment-runner-version': deps.runnerVersion,
|
|
}
|
|
const deviceToken = deps.getTrustedDeviceToken?.()
|
|
if (deviceToken) {
|
|
headers['X-Trusted-Device-Token'] = deviceToken
|
|
}
|
|
return headers
|
|
}
|
|
|
|
function resolveAuth(): string {
|
|
const accessToken = deps.getAccessToken()
|
|
if (!accessToken) {
|
|
throw new Error(BRIDGE_LOGIN_INSTRUCTION)
|
|
}
|
|
return accessToken
|
|
}
|
|
|
|
/**
|
|
* Execute an OAuth-authenticated request with a single retry on 401.
|
|
* On 401, attempts token refresh via handleOAuth401Error (same pattern as
|
|
* withRetry.ts for v1/messages). If refresh succeeds, retries the request
|
|
* once with the new token. If refresh fails or the retry also returns 401,
|
|
* the 401 response is returned for handleErrorStatus to throw BridgeFatalError.
|
|
*/
|
|
async function withOAuthRetry<T>(
|
|
fn: (accessToken: string) => Promise<{ status: number; data: T }>,
|
|
context: string,
|
|
): Promise<{ status: number; data: T }> {
|
|
const accessToken = resolveAuth()
|
|
const response = await fn(accessToken)
|
|
|
|
if (response.status !== 401) {
|
|
return response
|
|
}
|
|
|
|
if (!deps.onAuth401) {
|
|
debug(`[bridge:api] ${context}: 401 received, no refresh handler`)
|
|
return response
|
|
}
|
|
|
|
// Attempt token refresh — matches the pattern in withRetry.ts
|
|
debug(`[bridge:api] ${context}: 401 received, attempting token refresh`)
|
|
const refreshed = await deps.onAuth401(accessToken)
|
|
if (refreshed) {
|
|
debug(`[bridge:api] ${context}: Token refreshed, retrying request`)
|
|
const newToken = resolveAuth()
|
|
const retryResponse = await fn(newToken)
|
|
if (retryResponse.status !== 401) {
|
|
return retryResponse
|
|
}
|
|
debug(`[bridge:api] ${context}: Retry after refresh also got 401`)
|
|
} else {
|
|
debug(`[bridge:api] ${context}: Token refresh failed`)
|
|
}
|
|
|
|
// Refresh failed — return 401 for handleErrorStatus to throw
|
|
return response
|
|
}
|
|
|
|
return {
|
|
async registerBridgeEnvironment(
|
|
config: BridgeConfig,
|
|
): Promise<{ environment_id: string; environment_secret: string }> {
|
|
debug(
|
|
`[bridge:api] POST /v1/environments/bridge bridgeId=${config.bridgeId}`,
|
|
)
|
|
|
|
const response = await withOAuthRetry(
|
|
(token: string) =>
|
|
axios.post<{
|
|
environment_id: string
|
|
environment_secret: string
|
|
}>(
|
|
`${deps.baseUrl}/v1/environments/bridge`,
|
|
{
|
|
machine_name: config.machineName,
|
|
directory: config.dir,
|
|
branch: config.branch,
|
|
git_repo_url: config.gitRepoUrl,
|
|
// Advertise session capacity so claude.ai/code can show
|
|
// "2/4 sessions" badges and only block the picker when
|
|
// actually at capacity. Backends that don't yet accept
|
|
// this field will silently ignore it.
|
|
max_sessions: config.maxSessions,
|
|
// worker_type lets claude.ai filter environments by origin
|
|
// (e.g. assistant picker only shows assistant-mode workers).
|
|
// Desktop cowork app sends "cowork"; we send a distinct value.
|
|
metadata: { worker_type: config.workerType },
|
|
// Idempotent re-registration: if we have a backend-issued
|
|
// environment_id from a prior session (--session-id resume),
|
|
// send it back so the backend reattaches instead of creating
|
|
// a new env. The backend may still hand back a fresh ID if
|
|
// the old one expired — callers must compare the response.
|
|
...(config.reuseEnvironmentId && {
|
|
environment_id: config.reuseEnvironmentId,
|
|
}),
|
|
},
|
|
{
|
|
headers: getHeaders(token),
|
|
timeout: 15_000,
|
|
validateStatus: status => status < 500,
|
|
},
|
|
),
|
|
'Registration',
|
|
)
|
|
|
|
handleErrorStatus(response.status, response.data, 'Registration')
|
|
debug(
|
|
`[bridge:api] POST /v1/environments/bridge -> ${response.status} environment_id=${response.data.environment_id}`,
|
|
)
|
|
debug(
|
|
`[bridge:api] >>> ${debugBody({ machine_name: config.machineName, directory: config.dir, branch: config.branch, git_repo_url: config.gitRepoUrl, max_sessions: config.maxSessions, metadata: { worker_type: config.workerType } })}`,
|
|
)
|
|
debug(`[bridge:api] <<< ${debugBody(response.data)}`)
|
|
return response.data
|
|
},
|
|
|
|
async pollForWork(
|
|
environmentId: string,
|
|
environmentSecret: string,
|
|
signal?: AbortSignal,
|
|
reclaimOlderThanMs?: number,
|
|
): Promise<WorkResponse | null> {
|
|
validateBridgeId(environmentId, 'environmentId')
|
|
|
|
// Save and reset so errors break the "consecutive empty" streak.
|
|
// Restored below when the response is truly empty.
|
|
const prevEmptyPolls = consecutiveEmptyPolls
|
|
consecutiveEmptyPolls = 0
|
|
|
|
const response = await axios.get<WorkResponse | null>(
|
|
`${deps.baseUrl}/v1/environments/${environmentId}/work/poll`,
|
|
{
|
|
headers: getHeaders(environmentSecret),
|
|
params:
|
|
reclaimOlderThanMs !== undefined
|
|
? { reclaim_older_than_ms: reclaimOlderThanMs }
|
|
: undefined,
|
|
timeout: 10_000,
|
|
signal,
|
|
validateStatus: status => status < 500,
|
|
},
|
|
)
|
|
|
|
handleErrorStatus(response.status, response.data, 'Poll')
|
|
|
|
// Empty body or null = no work available
|
|
if (!response.data) {
|
|
consecutiveEmptyPolls = prevEmptyPolls + 1
|
|
if (
|
|
consecutiveEmptyPolls === 1 ||
|
|
consecutiveEmptyPolls % EMPTY_POLL_LOG_INTERVAL === 0
|
|
) {
|
|
debug(
|
|
`[bridge:api] GET .../work/poll -> ${response.status} (no work, ${consecutiveEmptyPolls} consecutive empty polls)`,
|
|
)
|
|
}
|
|
return null
|
|
}
|
|
|
|
debug(
|
|
`[bridge:api] GET .../work/poll -> ${response.status} workId=${response.data.id} type=${response.data.data?.type}${response.data.data?.id ? ` sessionId=${response.data.data.id}` : ''}`,
|
|
)
|
|
debug(`[bridge:api] <<< ${debugBody(response.data)}`)
|
|
return response.data
|
|
},
|
|
|
|
async acknowledgeWork(
|
|
environmentId: string,
|
|
workId: string,
|
|
sessionToken: string,
|
|
): Promise<void> {
|
|
validateBridgeId(environmentId, 'environmentId')
|
|
validateBridgeId(workId, 'workId')
|
|
|
|
debug(`[bridge:api] POST .../work/${workId}/ack`)
|
|
|
|
const response = await axios.post(
|
|
`${deps.baseUrl}/v1/environments/${environmentId}/work/${workId}/ack`,
|
|
{},
|
|
{
|
|
headers: getHeaders(sessionToken),
|
|
timeout: 10_000,
|
|
validateStatus: s => s < 500,
|
|
},
|
|
)
|
|
|
|
handleErrorStatus(response.status, response.data, 'Acknowledge')
|
|
debug(`[bridge:api] POST .../work/${workId}/ack -> ${response.status}`)
|
|
},
|
|
|
|
async stopWork(
|
|
environmentId: string,
|
|
workId: string,
|
|
force: boolean,
|
|
): Promise<void> {
|
|
validateBridgeId(environmentId, 'environmentId')
|
|
validateBridgeId(workId, 'workId')
|
|
|
|
debug(`[bridge:api] POST .../work/${workId}/stop force=${force}`)
|
|
|
|
const response = await withOAuthRetry(
|
|
(token: string) =>
|
|
axios.post(
|
|
`${deps.baseUrl}/v1/environments/${environmentId}/work/${workId}/stop`,
|
|
{ force },
|
|
{
|
|
headers: getHeaders(token),
|
|
timeout: 10_000,
|
|
validateStatus: s => s < 500,
|
|
},
|
|
),
|
|
'StopWork',
|
|
)
|
|
|
|
handleErrorStatus(response.status, response.data, 'StopWork')
|
|
debug(`[bridge:api] POST .../work/${workId}/stop -> ${response.status}`)
|
|
},
|
|
|
|
async deregisterEnvironment(environmentId: string): Promise<void> {
|
|
validateBridgeId(environmentId, 'environmentId')
|
|
|
|
debug(`[bridge:api] DELETE /v1/environments/bridge/${environmentId}`)
|
|
|
|
const response = await withOAuthRetry(
|
|
(token: string) =>
|
|
axios.delete(
|
|
`${deps.baseUrl}/v1/environments/bridge/${environmentId}`,
|
|
{
|
|
headers: getHeaders(token),
|
|
timeout: 10_000,
|
|
validateStatus: s => s < 500,
|
|
},
|
|
),
|
|
'Deregister',
|
|
)
|
|
|
|
handleErrorStatus(response.status, response.data, 'Deregister')
|
|
debug(
|
|
`[bridge:api] DELETE /v1/environments/bridge/${environmentId} -> ${response.status}`,
|
|
)
|
|
},
|
|
|
|
async archiveSession(sessionId: string): Promise<void> {
|
|
validateBridgeId(sessionId, 'sessionId')
|
|
|
|
debug(`[bridge:api] POST /v1/sessions/${sessionId}/archive`)
|
|
|
|
const response = await withOAuthRetry(
|
|
(token: string) =>
|
|
axios.post(
|
|
`${deps.baseUrl}/v1/sessions/${sessionId}/archive`,
|
|
{},
|
|
{
|
|
headers: getHeaders(token),
|
|
timeout: 10_000,
|
|
validateStatus: s => s < 500,
|
|
},
|
|
),
|
|
'ArchiveSession',
|
|
)
|
|
|
|
// 409 = already archived (idempotent, not an error)
|
|
if (response.status === 409) {
|
|
debug(
|
|
`[bridge:api] POST /v1/sessions/${sessionId}/archive -> 409 (already archived)`,
|
|
)
|
|
return
|
|
}
|
|
|
|
handleErrorStatus(response.status, response.data, 'ArchiveSession')
|
|
debug(
|
|
`[bridge:api] POST /v1/sessions/${sessionId}/archive -> ${response.status}`,
|
|
)
|
|
},
|
|
|
|
async reconnectSession(
|
|
environmentId: string,
|
|
sessionId: string,
|
|
): Promise<void> {
|
|
validateBridgeId(environmentId, 'environmentId')
|
|
validateBridgeId(sessionId, 'sessionId')
|
|
|
|
debug(
|
|
`[bridge:api] POST /v1/environments/${environmentId}/bridge/reconnect session_id=${sessionId}`,
|
|
)
|
|
|
|
const response = await withOAuthRetry(
|
|
(token: string) =>
|
|
axios.post(
|
|
`${deps.baseUrl}/v1/environments/${environmentId}/bridge/reconnect`,
|
|
{ session_id: sessionId },
|
|
{
|
|
headers: getHeaders(token),
|
|
timeout: 10_000,
|
|
validateStatus: s => s < 500,
|
|
},
|
|
),
|
|
'ReconnectSession',
|
|
)
|
|
|
|
handleErrorStatus(response.status, response.data, 'ReconnectSession')
|
|
debug(`[bridge:api] POST .../bridge/reconnect -> ${response.status}`)
|
|
},
|
|
|
|
async heartbeatWork(
|
|
environmentId: string,
|
|
workId: string,
|
|
sessionToken: string,
|
|
): Promise<{ lease_extended: boolean; state: string }> {
|
|
validateBridgeId(environmentId, 'environmentId')
|
|
validateBridgeId(workId, 'workId')
|
|
|
|
debug(`[bridge:api] POST .../work/${workId}/heartbeat`)
|
|
|
|
const response = await axios.post<{
|
|
lease_extended: boolean
|
|
state: string
|
|
last_heartbeat: string
|
|
ttl_seconds: number
|
|
}>(
|
|
`${deps.baseUrl}/v1/environments/${environmentId}/work/${workId}/heartbeat`,
|
|
{},
|
|
{
|
|
headers: getHeaders(sessionToken),
|
|
timeout: 10_000,
|
|
validateStatus: s => s < 500,
|
|
},
|
|
)
|
|
|
|
handleErrorStatus(response.status, response.data, 'Heartbeat')
|
|
debug(
|
|
`[bridge:api] POST .../work/${workId}/heartbeat -> ${response.status} lease_extended=${response.data.lease_extended} state=${response.data.state}`,
|
|
)
|
|
return response.data
|
|
},
|
|
|
|
async sendPermissionResponseEvent(
|
|
sessionId: string,
|
|
event: PermissionResponseEvent,
|
|
sessionToken: string,
|
|
): Promise<void> {
|
|
validateBridgeId(sessionId, 'sessionId')
|
|
|
|
debug(
|
|
`[bridge:api] POST /v1/sessions/${sessionId}/events type=${event.type}`,
|
|
)
|
|
|
|
const response = await axios.post(
|
|
`${deps.baseUrl}/v1/sessions/${sessionId}/events`,
|
|
{ events: [event] },
|
|
{
|
|
headers: getHeaders(sessionToken),
|
|
timeout: 10_000,
|
|
validateStatus: s => s < 500,
|
|
},
|
|
)
|
|
|
|
handleErrorStatus(
|
|
response.status,
|
|
response.data,
|
|
'SendPermissionResponseEvent',
|
|
)
|
|
debug(
|
|
`[bridge:api] POST /v1/sessions/${sessionId}/events -> ${response.status}`,
|
|
)
|
|
debug(`[bridge:api] >>> ${debugBody({ events: [event] })}`)
|
|
debug(`[bridge:api] <<< ${debugBody(response.data)}`)
|
|
},
|
|
}
|
|
}
|
|
|
|
function handleErrorStatus(
|
|
status: number,
|
|
data: unknown,
|
|
context: string,
|
|
): void {
|
|
if (status === 200 || status === 204) {
|
|
return
|
|
}
|
|
const detail = extractErrorDetail(data)
|
|
const errorType = extractErrorTypeFromData(data)
|
|
switch (status) {
|
|
case 401:
|
|
throw new BridgeFatalError(
|
|
`${context}: Authentication failed (401)${detail ? `: ${detail}` : ''}. ${BRIDGE_LOGIN_INSTRUCTION}`,
|
|
401,
|
|
errorType,
|
|
)
|
|
case 403:
|
|
throw new BridgeFatalError(
|
|
isExpiredErrorType(errorType)
|
|
? 'Remote Control session has expired. Please restart with `claude remote-control` or /remote-control.'
|
|
: `${context}: Access denied (403)${detail ? `: ${detail}` : ''}. Check your organization permissions.`,
|
|
403,
|
|
errorType,
|
|
)
|
|
case 404:
|
|
throw new BridgeFatalError(
|
|
detail ??
|
|
`${context}: Not found (404). Remote Control may not be available for this organization.`,
|
|
404,
|
|
errorType,
|
|
)
|
|
case 410:
|
|
throw new BridgeFatalError(
|
|
detail ??
|
|
'Remote Control session has expired. Please restart with `claude remote-control` or /remote-control.',
|
|
410,
|
|
errorType ?? 'environment_expired',
|
|
)
|
|
case 429:
|
|
throw new Error(`${context}: Rate limited (429). Polling too frequently.`)
|
|
default:
|
|
throw new Error(
|
|
`${context}: Failed with status ${status}${detail ? `: ${detail}` : ''}`,
|
|
)
|
|
}
|
|
}
|
|
|
|
/** Check whether an error type string indicates a session/environment expiry. */
|
|
export function isExpiredErrorType(errorType: string | undefined): boolean {
|
|
if (!errorType) {
|
|
return false
|
|
}
|
|
return errorType.includes('expired') || errorType.includes('lifetime')
|
|
}
|
|
|
|
/**
|
|
* Check whether a BridgeFatalError is a suppressible 403 permission error.
|
|
* These are 403 errors for scopes like 'external_poll_sessions' or operations
|
|
* like StopWork that fail because the user's role lacks 'environments:manage'.
|
|
* They don't affect core functionality and shouldn't be shown to users.
|
|
*/
|
|
export function isSuppressible403(err: BridgeFatalError): boolean {
|
|
if (err.status !== 403) {
|
|
return false
|
|
}
|
|
return (
|
|
err.message.includes('external_poll_sessions') ||
|
|
err.message.includes('environments:manage')
|
|
)
|
|
}
|
|
|
|
function extractErrorTypeFromData(data: unknown): string | undefined {
|
|
if (data && typeof data === 'object') {
|
|
if (
|
|
'error' in data &&
|
|
data.error &&
|
|
typeof data.error === 'object' &&
|
|
'type' in data.error &&
|
|
typeof data.error.type === 'string'
|
|
) {
|
|
return data.error.type
|
|
}
|
|
}
|
|
return undefined
|
|
}
|