320 lines
10 KiB
TypeScript
320 lines
10 KiB
TypeScript
import type { SDKMessage } from '../entrypoints/agentSdkTypes.ts'
|
|
import { logForDebugging } from '../utils/debug.js'
|
|
import { errorMessage } from '../utils/errors.js'
|
|
import { extractErrorDetail } from './debugUtils.js'
|
|
import { toCompatSessionId } from './sessionIdCompat.js'
|
|
|
|
// Events must be wrapped in { type: 'event', data: <sdk_message> } for the
|
|
// POST /v1/sessions endpoint (discriminated union format).
|
|
type SessionEvent = {
|
|
type: 'event'
|
|
data: SDKMessage
|
|
}
|
|
|
|
/**
|
|
* Create a session on a bridge environment via POST /v1/sessions.
|
|
*
|
|
* Used by both `claude remote-control` (empty session so the user has somewhere to
|
|
* type immediately) and `/remote-control` (session pre-populated with conversation
|
|
* history).
|
|
*
|
|
* Returns the session ID on success, or null if creation fails (non-fatal).
|
|
*/
|
|
export async function createBridgeSession({
|
|
environmentId,
|
|
title,
|
|
events,
|
|
signal,
|
|
baseUrl: baseUrlOverride,
|
|
getAccessToken,
|
|
permissionMode,
|
|
}: {
|
|
environmentId: string
|
|
title?: string
|
|
events: SessionEvent[]
|
|
signal: AbortSignal
|
|
baseUrl?: string
|
|
getAccessToken?: () => string | undefined
|
|
permissionMode?: string
|
|
}): Promise<string | null> {
|
|
const { getClaudeAIOAuthTokens } = await import('../utils/auth.js')
|
|
const { getOrganizationUUID } = await import('../services/oauth/client.js')
|
|
const { getOauthConfig } = await import('../constants/oauth.js')
|
|
const { getOAuthHeaders } = await import('../utils/teleport/api.js')
|
|
const { getMainLoopModel } = await import('../utils/model/model.js')
|
|
const { default: axios } = await import('axios')
|
|
|
|
const accessToken =
|
|
getAccessToken?.() ?? getClaudeAIOAuthTokens()?.accessToken
|
|
if (!accessToken) {
|
|
logForDebugging('[bridge] No access token for session creation')
|
|
return null
|
|
}
|
|
|
|
const orgUUID = await getOrganizationUUID()
|
|
if (!orgUUID) {
|
|
logForDebugging('[bridge] No org UUID for session creation')
|
|
return null
|
|
}
|
|
|
|
const requestBody = {
|
|
...(title !== undefined && { title }),
|
|
events,
|
|
session_context: {
|
|
sources: [],
|
|
outcomes: [],
|
|
model: getMainLoopModel(),
|
|
},
|
|
environment_id: environmentId,
|
|
source: 'remote-control',
|
|
...(permissionMode && { permission_mode: permissionMode }),
|
|
}
|
|
|
|
const headers = {
|
|
...getOAuthHeaders(accessToken),
|
|
'anthropic-beta': 'ccr-byoc-2025-07-29',
|
|
'x-organization-uuid': orgUUID,
|
|
}
|
|
|
|
const url = `${baseUrlOverride ?? getOauthConfig().BASE_API_URL}/v1/sessions`
|
|
let response
|
|
try {
|
|
response = await axios.post(url, requestBody, {
|
|
headers,
|
|
signal,
|
|
validateStatus: s => s < 500,
|
|
})
|
|
} catch (err: unknown) {
|
|
logForDebugging(
|
|
`[bridge] Session creation request failed: ${errorMessage(err)}`,
|
|
)
|
|
return null
|
|
}
|
|
const isSuccess = response.status === 200 || response.status === 201
|
|
|
|
if (!isSuccess) {
|
|
const detail = extractErrorDetail(response.data)
|
|
logForDebugging(
|
|
`[bridge] Session creation failed with status ${response.status}${detail ? `: ${detail}` : ''}`,
|
|
)
|
|
return null
|
|
}
|
|
|
|
const sessionData: unknown = response.data
|
|
if (
|
|
!sessionData ||
|
|
typeof sessionData !== 'object' ||
|
|
!('id' in sessionData) ||
|
|
typeof sessionData.id !== 'string'
|
|
) {
|
|
logForDebugging('[bridge] No session ID in response')
|
|
return null
|
|
}
|
|
|
|
return sessionData.id
|
|
}
|
|
|
|
/**
|
|
* Fetch a bridge session via GET /v1/sessions/{id}.
|
|
*
|
|
* Returns the session's environment_id (for `--session-id` resume) and title.
|
|
* Uses the same org-scoped headers as create/archive — the environments-level
|
|
* client in bridgeApi.ts uses a different beta header and no org UUID, which
|
|
* makes the Sessions API return 404.
|
|
*/
|
|
export async function getBridgeSession(
|
|
sessionId: string,
|
|
opts?: { baseUrl?: string; getAccessToken?: () => string | undefined },
|
|
): Promise<{ environment_id?: string; title?: string } | null> {
|
|
const { getClaudeAIOAuthTokens } = await import('../utils/auth.js')
|
|
const { getOrganizationUUID } = await import('../services/oauth/client.js')
|
|
const { getOauthConfig } = await import('../constants/oauth.js')
|
|
const { getOAuthHeaders } = await import('../utils/teleport/api.js')
|
|
const { default: axios } = await import('axios')
|
|
|
|
const accessToken =
|
|
opts?.getAccessToken?.() ?? getClaudeAIOAuthTokens()?.accessToken
|
|
if (!accessToken) {
|
|
logForDebugging('[bridge] No access token for session fetch')
|
|
return null
|
|
}
|
|
|
|
const orgUUID = await getOrganizationUUID()
|
|
if (!orgUUID) {
|
|
logForDebugging('[bridge] No org UUID for session fetch')
|
|
return null
|
|
}
|
|
|
|
const headers = {
|
|
...getOAuthHeaders(accessToken),
|
|
'anthropic-beta': 'ccr-byoc-2025-07-29',
|
|
'x-organization-uuid': orgUUID,
|
|
}
|
|
|
|
const url = `${opts?.baseUrl ?? getOauthConfig().BASE_API_URL}/v1/sessions/${sessionId}`
|
|
logForDebugging(`[bridge] Fetching session ${sessionId}`)
|
|
|
|
let response
|
|
try {
|
|
response = await axios.get<{ environment_id?: string; title?: string }>(
|
|
url,
|
|
{ headers, timeout: 10_000, validateStatus: s => s < 500 },
|
|
)
|
|
} catch (err: unknown) {
|
|
logForDebugging(
|
|
`[bridge] Session fetch request failed: ${errorMessage(err)}`,
|
|
)
|
|
return null
|
|
}
|
|
|
|
if (response.status !== 200) {
|
|
const detail = extractErrorDetail(response.data)
|
|
logForDebugging(
|
|
`[bridge] Session fetch failed with status ${response.status}${detail ? `: ${detail}` : ''}`,
|
|
)
|
|
return null
|
|
}
|
|
|
|
return response.data
|
|
}
|
|
|
|
/**
|
|
* Archive a bridge session via POST /v1/sessions/{id}/archive.
|
|
*
|
|
* The CCR server never auto-archives sessions — archival is always an
|
|
* explicit client action. Both `claude remote-control` (standalone bridge) and the
|
|
* always-on `/remote-control` REPL bridge call this during shutdown to archive any
|
|
* sessions that are still alive.
|
|
*
|
|
* The archive endpoint accepts sessions in any status (running, idle,
|
|
* requires_action, pending) and returns 409 if already archived, making
|
|
* it safe to call even if the server-side runner already archived the
|
|
* session.
|
|
*
|
|
* Callers must handle errors — this function has no try/catch; 5xx,
|
|
* timeouts, and network errors throw. Archival is best-effort during
|
|
* cleanup; call sites wrap with .catch().
|
|
*/
|
|
export async function archiveBridgeSession(
|
|
sessionId: string,
|
|
opts?: {
|
|
baseUrl?: string
|
|
getAccessToken?: () => string | undefined
|
|
timeoutMs?: number
|
|
},
|
|
): Promise<void> {
|
|
const { getClaudeAIOAuthTokens } = await import('../utils/auth.js')
|
|
const { getOrganizationUUID } = await import('../services/oauth/client.js')
|
|
const { getOauthConfig } = await import('../constants/oauth.js')
|
|
const { getOAuthHeaders } = await import('../utils/teleport/api.js')
|
|
const { default: axios } = await import('axios')
|
|
|
|
const accessToken =
|
|
opts?.getAccessToken?.() ?? getClaudeAIOAuthTokens()?.accessToken
|
|
if (!accessToken) {
|
|
logForDebugging('[bridge] No access token for session archive')
|
|
return
|
|
}
|
|
|
|
const orgUUID = await getOrganizationUUID()
|
|
if (!orgUUID) {
|
|
logForDebugging('[bridge] No org UUID for session archive')
|
|
return
|
|
}
|
|
|
|
const headers = {
|
|
...getOAuthHeaders(accessToken),
|
|
'anthropic-beta': 'ccr-byoc-2025-07-29',
|
|
'x-organization-uuid': orgUUID,
|
|
}
|
|
|
|
const url = `${opts?.baseUrl ?? getOauthConfig().BASE_API_URL}/v1/sessions/${sessionId}/archive`
|
|
logForDebugging(`[bridge] Archiving session ${sessionId}`)
|
|
|
|
const response = await axios.post(
|
|
url,
|
|
{},
|
|
{
|
|
headers,
|
|
timeout: opts?.timeoutMs ?? 10_000,
|
|
validateStatus: s => s < 500,
|
|
},
|
|
)
|
|
|
|
if (response.status === 200) {
|
|
logForDebugging(`[bridge] Session ${sessionId} archived successfully`)
|
|
} else {
|
|
const detail = extractErrorDetail(response.data)
|
|
logForDebugging(
|
|
`[bridge] Session archive failed with status ${response.status}${detail ? `: ${detail}` : ''}`,
|
|
)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Update the title of a bridge session via PATCH /v1/sessions/{id}.
|
|
*
|
|
* Called when the user renames a session via /rename while a bridge
|
|
* connection is active, so the title stays in sync on claude.ai/code.
|
|
*
|
|
* Errors are swallowed — title sync is best-effort.
|
|
*/
|
|
export async function updateBridgeSessionTitle(
|
|
sessionId: string,
|
|
title: string,
|
|
opts?: { baseUrl?: string; getAccessToken?: () => string | undefined },
|
|
): Promise<void> {
|
|
const { getClaudeAIOAuthTokens } = await import('../utils/auth.js')
|
|
const { getOrganizationUUID } = await import('../services/oauth/client.js')
|
|
const { getOauthConfig } = await import('../constants/oauth.js')
|
|
const { getOAuthHeaders } = await import('../utils/teleport/api.js')
|
|
const { default: axios } = await import('axios')
|
|
|
|
const accessToken =
|
|
opts?.getAccessToken?.() ?? getClaudeAIOAuthTokens()?.accessToken
|
|
if (!accessToken) {
|
|
logForDebugging('[bridge] No access token for session title update')
|
|
return
|
|
}
|
|
|
|
const orgUUID = await getOrganizationUUID()
|
|
if (!orgUUID) {
|
|
logForDebugging('[bridge] No org UUID for session title update')
|
|
return
|
|
}
|
|
|
|
const headers = {
|
|
...getOAuthHeaders(accessToken),
|
|
'anthropic-beta': 'ccr-byoc-2025-07-29',
|
|
'x-organization-uuid': orgUUID,
|
|
}
|
|
|
|
// Compat gateway only accepts session_* (compat/convert.go:27). v2 callers
|
|
// pass raw cse_*; retag here so all callers can pass whatever they hold.
|
|
// Idempotent for v1's session_* and bridgeMain's pre-converted compatSessionId.
|
|
const compatId = toCompatSessionId(sessionId)
|
|
const url = `${opts?.baseUrl ?? getOauthConfig().BASE_API_URL}/v1/sessions/${compatId}`
|
|
logForDebugging(`[bridge] Updating session title: ${compatId} → ${title}`)
|
|
|
|
try {
|
|
const response = await axios.patch(
|
|
url,
|
|
{ title },
|
|
{ headers, timeout: 10_000, validateStatus: s => s < 500 },
|
|
)
|
|
|
|
if (response.status === 200) {
|
|
logForDebugging(`[bridge] Session title updated successfully`)
|
|
} else {
|
|
const detail = extractErrorDetail(response.data)
|
|
logForDebugging(
|
|
`[bridge] Session title update failed with status ${response.status}${detail ? `: ${detail}` : ''}`,
|
|
)
|
|
}
|
|
} catch (err: unknown) {
|
|
logForDebugging(
|
|
`[bridge] Session title update request failed: ${errorMessage(err)}`,
|
|
)
|
|
}
|
|
}
|