mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-17 22:05:50 +00:00
feat: 支持自托管的 remote-control-server (#214)
* feat: 支持自托管的 remote-control-server (#214) --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import axios from 'axios'
|
||||
|
||||
import { debugBody, extractErrorDetail } from './debugUtils.js'
|
||||
import { rcLog } from './rcDebugLog.js'
|
||||
import {
|
||||
BRIDGE_LOGIN_INSTRUCTION,
|
||||
type BridgeApiClient,
|
||||
@@ -224,6 +225,7 @@ export function createBridgeApiClient(deps: BridgeApiDeps): BridgeApiClient {
|
||||
)
|
||||
|
||||
handleErrorStatus(response.status, response.data, 'Poll')
|
||||
rcLog(`poll response: status=${response.status} hasData=${!!response.data} url=${deps.baseUrl}`)
|
||||
|
||||
// Empty body or null = no work available
|
||||
if (!response.data) {
|
||||
|
||||
@@ -14,21 +14,14 @@
|
||||
import { getOauthConfig } from '../constants/oauth.js'
|
||||
import { getClaudeAIOAuthTokens } from '../utils/auth.js'
|
||||
|
||||
/** Ant-only dev override: CLAUDE_BRIDGE_OAUTH_TOKEN, else undefined. */
|
||||
/** Dev override: CLAUDE_BRIDGE_OAUTH_TOKEN, else undefined. */
|
||||
export function getBridgeTokenOverride(): string | undefined {
|
||||
return (
|
||||
(process.env.USER_TYPE === 'ant' &&
|
||||
process.env.CLAUDE_BRIDGE_OAUTH_TOKEN) ||
|
||||
undefined
|
||||
)
|
||||
return process.env.CLAUDE_BRIDGE_OAUTH_TOKEN || undefined
|
||||
}
|
||||
|
||||
/** Ant-only dev override: CLAUDE_BRIDGE_BASE_URL, else undefined. */
|
||||
/** Dev override: CLAUDE_BRIDGE_BASE_URL, else undefined. */
|
||||
export function getBridgeBaseUrlOverride(): string | undefined {
|
||||
return (
|
||||
(process.env.USER_TYPE === 'ant' && process.env.CLAUDE_BRIDGE_BASE_URL) ||
|
||||
undefined
|
||||
)
|
||||
return process.env.CLAUDE_BRIDGE_BASE_URL || undefined
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -46,3 +39,8 @@ export function getBridgeAccessToken(): string | undefined {
|
||||
export function getBridgeBaseUrl(): string {
|
||||
return getBridgeBaseUrlOverride() ?? getOauthConfig().BASE_API_URL
|
||||
}
|
||||
|
||||
/** True when the user has explicitly configured a custom bridge server. */
|
||||
export function isSelfHostedBridge(): boolean {
|
||||
return !!getBridgeBaseUrlOverride()
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
getDynamicConfig_CACHED_MAY_BE_STALE,
|
||||
getFeatureValue_CACHED_MAY_BE_STALE,
|
||||
} from '../services/analytics/growthbook.js'
|
||||
import { isSelfHostedBridge } from './bridgeConfig.js'
|
||||
// Namespace import breaks the bridgeEnabled → auth → config → bridgeEnabled
|
||||
// cycle — authModule.foo is a live binding, so by the time the helpers below
|
||||
// call it, auth.js is fully loaded. Previously used require() for the same
|
||||
@@ -26,6 +27,11 @@ import { lt } from '../utils/semver.js'
|
||||
* is only referenced when bridge mode is enabled at build time.
|
||||
*/
|
||||
export function isBridgeEnabled(): boolean {
|
||||
// Self-hosted bridge: when the user has configured a custom server, bypass
|
||||
// GrowthBook gates entirely.
|
||||
if (feature('BRIDGE_MODE') && isSelfHostedBridge()) {
|
||||
return true
|
||||
}
|
||||
// Positive ternary pattern — see docs/feature-gating.md.
|
||||
// Negative pattern (if (!feature(...)) return) does not eliminate
|
||||
// inline string literals from external builds.
|
||||
@@ -48,6 +54,9 @@ export function isBridgeEnabled(): boolean {
|
||||
* `isBridgeEnabled()` instead.
|
||||
*/
|
||||
export async function isBridgeEnabledBlocking(): Promise<boolean> {
|
||||
if (feature('BRIDGE_MODE') && isSelfHostedBridge()) {
|
||||
return true
|
||||
}
|
||||
return feature('BRIDGE_MODE')
|
||||
? isClaudeAISubscriber() &&
|
||||
(await checkGate_CACHED_OR_BLOCKING('tengu_ccr_bridge'))
|
||||
@@ -69,6 +78,10 @@ export async function isBridgeEnabledBlocking(): Promise<boolean> {
|
||||
*/
|
||||
export async function getBridgeDisabledReason(): Promise<string | null> {
|
||||
if (feature('BRIDGE_MODE')) {
|
||||
// Self-hosted bridge: no subscription/scope/gate checks needed.
|
||||
if (isSelfHostedBridge()) {
|
||||
return null
|
||||
}
|
||||
if (!isClaudeAISubscriber()) {
|
||||
return 'Remote Control requires a claude.ai subscription. Run `claude auth login` to sign in with your claude.ai account.'
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
} from '../services/analytics/index.js'
|
||||
import { isInBundledMode } from '../utils/bundledMode.js'
|
||||
import { logForDebugging } from '../utils/debug.js'
|
||||
import { rcLog } from './rcDebugLog.js'
|
||||
import { logForDiagnosticsNoPII } from '../utils/diagLogs.js'
|
||||
import { isEnvTruthy, isInProtectedNamespace } from '../utils/envUtils.js'
|
||||
import { errorMessage } from '../utils/errors.js'
|
||||
@@ -202,6 +203,7 @@ export async function runBridgeLoop(
|
||||
async function heartbeatActiveWorkItems(): Promise<
|
||||
'ok' | 'auth_failed' | 'fatal' | 'failed'
|
||||
> {
|
||||
rcLog(`heartbeat: checking ${activeSessions.size} active session(s)`)
|
||||
let anySuccess = false
|
||||
let anyFatal = false
|
||||
const authFailedSessions: string[] = []
|
||||
@@ -446,6 +448,9 @@ export async function runBridgeLoop(
|
||||
): (status: SessionDoneStatus) => void {
|
||||
return (rawStatus: SessionDoneStatus): void => {
|
||||
const workId = sessionWorkIds.get(sessionId)
|
||||
rcLog(`session done: sessionId=${sessionId} workId=${workId ?? 'none'} status=${rawStatus}` +
|
||||
` wasTimedOut=${timedOutSessions.has(sessionId)} duration=${Math.round((Date.now() - startTime) / 1000)}s` +
|
||||
` stderr=${handle.lastStderr.length > 0 ? handle.lastStderr.join('\\n').slice(0, 500) : '(none)'}`)
|
||||
activeSessions.delete(sessionId)
|
||||
sessionStartTimes.delete(sessionId)
|
||||
sessionWorkIds.delete(sessionId)
|
||||
@@ -604,6 +609,7 @@ export async function runBridgeLoop(
|
||||
const pollConfig = getPollIntervalConfig()
|
||||
|
||||
try {
|
||||
rcLog(`poll: envId=${environmentId} activeSessions=${activeSessions.size}`)
|
||||
const work = await api.pollForWork(
|
||||
environmentId,
|
||||
environmentSecret,
|
||||
@@ -858,6 +864,7 @@ export async function runBridgeLoop(
|
||||
break
|
||||
case 'session': {
|
||||
const sessionId = work.data.id
|
||||
rcLog(`work received: type=session sessionId=${sessionId} workId=${work.id}`)
|
||||
try {
|
||||
validateBridgeId(sessionId, 'session_id')
|
||||
} catch {
|
||||
@@ -1023,6 +1030,12 @@ export async function runBridgeLoop(
|
||||
// the onFirstUserMessage callback can close over it.
|
||||
const compatSessionId = toCompatSessionId(sessionId)
|
||||
|
||||
rcLog(
|
||||
`spawning session: sessionId=${sessionId} sdkUrl=${sdkUrl}` +
|
||||
` useCcrV2=${useCcrV2} workerEpoch=${workerEpoch}` +
|
||||
` dir=${sessionDir}` +
|
||||
` accessToken=${secret.session_ingress_token ? secret.session_ingress_token.slice(0, 8) + '...' : 'NONE'}`,
|
||||
)
|
||||
const spawnResult = safeSpawn(
|
||||
spawner,
|
||||
{
|
||||
@@ -1266,6 +1279,11 @@ export async function runBridgeLoop(
|
||||
}
|
||||
|
||||
const errMsg = describeAxiosError(err)
|
||||
rcLog(
|
||||
`poll error: ${errMsg}` +
|
||||
` isConn=${isConnectionError(err)} isServer=${isServerError(err)}` +
|
||||
` activeSessions=${activeSessions.size}`,
|
||||
)
|
||||
|
||||
if (isConnectionError(err) || isServerError(err)) {
|
||||
const now = Date.now()
|
||||
@@ -2198,10 +2216,7 @@ export async function bridgeMain(args: string[]): Promise<void> {
|
||||
// contain-provide-api (8211), so CLAUDE_BRIDGE_SESSION_INGRESS_URL must be
|
||||
// set explicitly. Ant-only, matching CLAUDE_BRIDGE_BASE_URL.
|
||||
const sessionIngressUrl =
|
||||
process.env.USER_TYPE === 'ant' &&
|
||||
process.env.CLAUDE_BRIDGE_SESSION_INGRESS_URL
|
||||
? process.env.CLAUDE_BRIDGE_SESSION_INGRESS_URL
|
||||
: baseUrl
|
||||
process.env.CLAUDE_BRIDGE_SESSION_INGRESS_URL || baseUrl
|
||||
|
||||
const { getBranch, getRemoteUrl, findGitRoot } = await import(
|
||||
'../utils/git.js'
|
||||
@@ -2851,10 +2866,7 @@ export async function runBridgeHeadless(
|
||||
)
|
||||
}
|
||||
const sessionIngressUrl =
|
||||
process.env.USER_TYPE === 'ant' &&
|
||||
process.env.CLAUDE_BRIDGE_SESSION_INGRESS_URL
|
||||
? process.env.CLAUDE_BRIDGE_SESSION_INGRESS_URL
|
||||
: baseUrl
|
||||
process.env.CLAUDE_BRIDGE_SESSION_INGRESS_URL || baseUrl
|
||||
|
||||
const { getBranch, getRemoteUrl, findGitRoot } = await import(
|
||||
'../utils/git.js'
|
||||
|
||||
@@ -22,6 +22,7 @@ import { EMPTY_USAGE } from '../services/api/emptyUsage.js'
|
||||
import type { Message } from '../types/message.js'
|
||||
import { normalizeControlMessageKeys } from '../utils/controlMessageCompat.js'
|
||||
import { logForDebugging } from '../utils/debug.js'
|
||||
import { rcLog } from './rcDebugLog.js'
|
||||
import { stripDisplayTagsAllowEmpty } from '../utils/displayTags.js'
|
||||
import { errorMessage } from '../utils/errors.js'
|
||||
import type { PermissionMode } from '../utils/permissions/PermissionMode.js'
|
||||
@@ -386,6 +387,11 @@ export function handleServerControlRequest(
|
||||
|
||||
const event = { ...response, session_id: sessionId }
|
||||
void transport.write(event)
|
||||
rcLog(
|
||||
`control_response: subtype=${req.subtype}` +
|
||||
` request_id=${request.request_id}` +
|
||||
` result=${(response.response as { subtype?: string }).subtype}`,
|
||||
)
|
||||
logForDebugging(
|
||||
`[bridge:repl] Sent control_response for ${req.subtype} request_id=${request.request_id} result=${(response.response as { subtype?: string }).subtype}`,
|
||||
)
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import {
|
||||
getClaudeAiBaseUrl,
|
||||
getRemoteSessionUrl,
|
||||
} from '../constants/product.js'
|
||||
import { getClaudeAiBaseUrl } from '../constants/product.js'
|
||||
import { isSelfHostedBridge, getBridgeBaseUrl } from './bridgeConfig.js'
|
||||
import { stringWidth } from '@anthropic/ink'
|
||||
import { formatDuration, truncateToWidth } from '../utils/format.js'
|
||||
import { getGraphemeSegmenter } from '../utils/intl.js'
|
||||
@@ -40,7 +38,10 @@ export function buildBridgeConnectUrl(
|
||||
environmentId: string,
|
||||
ingressUrl?: string,
|
||||
): string {
|
||||
const baseUrl = getClaudeAiBaseUrl(undefined, ingressUrl)
|
||||
// Self-hosted: use the configured server URL directly
|
||||
const baseUrl = isSelfHostedBridge()
|
||||
? getBridgeBaseUrl()
|
||||
: getClaudeAiBaseUrl(undefined, ingressUrl)
|
||||
return `${baseUrl}/code?bridge=${environmentId}`
|
||||
}
|
||||
|
||||
@@ -54,7 +55,11 @@ export function buildBridgeSessionUrl(
|
||||
environmentId: string,
|
||||
ingressUrl?: string,
|
||||
): string {
|
||||
return `${getRemoteSessionUrl(sessionId, ingressUrl)}?bridge=${environmentId}`
|
||||
// Self-hosted: use the configured server URL directly
|
||||
const baseUrl = isSelfHostedBridge()
|
||||
? getBridgeBaseUrl()
|
||||
: getClaudeAiBaseUrl(undefined, ingressUrl)
|
||||
return `${baseUrl}/code/${sessionId}?bridge=${environmentId}`
|
||||
}
|
||||
|
||||
/** Compute the glimmer index for a reverse-sweep shimmer animation. */
|
||||
|
||||
@@ -60,6 +60,7 @@ export async function createBridgeSession({
|
||||
const { getDefaultBranch } = await import('../utils/git.js')
|
||||
const { getMainLoopModel } = await import('../utils/model/model.js')
|
||||
const { default: axios } = await import('axios')
|
||||
const { isSelfHostedBridge } = await import('./bridgeConfig.js')
|
||||
|
||||
const accessToken =
|
||||
getAccessToken?.() ?? getClaudeAIOAuthTokens()?.accessToken
|
||||
@@ -68,7 +69,11 @@ export async function createBridgeSession({
|
||||
return null
|
||||
}
|
||||
|
||||
const orgUUID = await getOrganizationUUID()
|
||||
// Self-hosted bridges don't require a claude.ai org UUID — the local server
|
||||
// doesn't validate it. Use a placeholder to avoid blocking session creation.
|
||||
const orgUUID = isSelfHostedBridge()
|
||||
? 'self-hosted'
|
||||
: await getOrganizationUUID()
|
||||
if (!orgUUID) {
|
||||
logForDebugging('[bridge] No org UUID for session creation')
|
||||
return null
|
||||
@@ -196,6 +201,7 @@ export async function getBridgeSession(
|
||||
const { getOauthConfig } = await import('../constants/oauth.js')
|
||||
const { getOAuthHeaders } = await import('../utils/teleport/api.js')
|
||||
const { default: axios } = await import('axios')
|
||||
const { isSelfHostedBridge } = await import('./bridgeConfig.js')
|
||||
|
||||
const accessToken =
|
||||
opts?.getAccessToken?.() ?? getClaudeAIOAuthTokens()?.accessToken
|
||||
@@ -204,7 +210,9 @@ export async function getBridgeSession(
|
||||
return null
|
||||
}
|
||||
|
||||
const orgUUID = await getOrganizationUUID()
|
||||
const orgUUID = isSelfHostedBridge()
|
||||
? 'self-hosted'
|
||||
: await getOrganizationUUID()
|
||||
if (!orgUUID) {
|
||||
logForDebugging('[bridge] No org UUID for session fetch')
|
||||
return null
|
||||
@@ -273,6 +281,7 @@ export async function archiveBridgeSession(
|
||||
const { getOauthConfig } = await import('../constants/oauth.js')
|
||||
const { getOAuthHeaders } = await import('../utils/teleport/api.js')
|
||||
const { default: axios } = await import('axios')
|
||||
const { isSelfHostedBridge } = await import('./bridgeConfig.js')
|
||||
|
||||
const accessToken =
|
||||
opts?.getAccessToken?.() ?? getClaudeAIOAuthTokens()?.accessToken
|
||||
@@ -281,7 +290,9 @@ export async function archiveBridgeSession(
|
||||
return
|
||||
}
|
||||
|
||||
const orgUUID = await getOrganizationUUID()
|
||||
const orgUUID = isSelfHostedBridge()
|
||||
? 'self-hosted'
|
||||
: await getOrganizationUUID()
|
||||
if (!orgUUID) {
|
||||
logForDebugging('[bridge] No org UUID for session archive')
|
||||
return
|
||||
@@ -334,6 +345,7 @@ export async function updateBridgeSessionTitle(
|
||||
const { getOauthConfig } = await import('../constants/oauth.js')
|
||||
const { getOAuthHeaders } = await import('../utils/teleport/api.js')
|
||||
const { default: axios } = await import('axios')
|
||||
const { isSelfHostedBridge } = await import('./bridgeConfig.js')
|
||||
|
||||
const accessToken =
|
||||
opts?.getAccessToken?.() ?? getClaudeAIOAuthTokens()?.accessToken
|
||||
@@ -342,7 +354,9 @@ export async function updateBridgeSessionTitle(
|
||||
return
|
||||
}
|
||||
|
||||
const orgUUID = await getOrganizationUUID()
|
||||
const orgUUID = isSelfHostedBridge()
|
||||
? 'self-hosted'
|
||||
: await getOrganizationUUID()
|
||||
if (!orgUUID) {
|
||||
logForDebugging('[bridge] No org UUID for session title update')
|
||||
return
|
||||
|
||||
@@ -52,6 +52,7 @@ import {
|
||||
getBridgeAccessToken,
|
||||
getBridgeBaseUrl,
|
||||
getBridgeTokenOverride,
|
||||
isSelfHostedBridge,
|
||||
} from './bridgeConfig.js'
|
||||
import {
|
||||
checkBridgeMinVersion,
|
||||
@@ -387,7 +388,11 @@ export async function initReplBridge(
|
||||
// environment registration; v2 for archive (which lives at the compat
|
||||
// /v1/sessions/{id}/archive, not /v1/code/sessions). Without it, v2
|
||||
// archive 404s and sessions stay alive in CCR after /exit.
|
||||
const orgUUID = await getOrganizationUUID()
|
||||
// Self-hosted bridges skip this check — the local server doesn't require
|
||||
// org-based auth.
|
||||
const orgUUID = isSelfHostedBridge()
|
||||
? 'self-hosted'
|
||||
: await getOrganizationUUID()
|
||||
if (!orgUUID) {
|
||||
logBridgeSkip('no_org_uuid', '[bridge:repl] Skipping: no org UUID')
|
||||
onStateChange?.('failed', '/login')
|
||||
@@ -465,10 +470,7 @@ export async function initReplBridge(
|
||||
const branch = await getBranch()
|
||||
const gitRepoUrl = await getRemoteUrl()
|
||||
const sessionIngressUrl =
|
||||
process.env.USER_TYPE === 'ant' &&
|
||||
process.env.CLAUDE_BRIDGE_SESSION_INGRESS_URL
|
||||
? process.env.CLAUDE_BRIDGE_SESSION_INGRESS_URL
|
||||
: baseUrl
|
||||
process.env.CLAUDE_BRIDGE_SESSION_INGRESS_URL || baseUrl
|
||||
|
||||
// Assistant-mode sessions advertise a distinct worker_type so the web UI
|
||||
// can filter them into a dedicated picker. KAIROS guard keeps the
|
||||
|
||||
39
src/bridge/rcDebugLog.ts
Normal file
39
src/bridge/rcDebugLog.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* File-based debug logger for Remote Control bridge diagnostics.
|
||||
* Writes [RC-DEBUG] lines to ~/.claude/rc-debug.log so they survive
|
||||
* Ink's stdout capture in the REPL / bridge UI.
|
||||
*/
|
||||
import { appendFileSync, mkdirSync, existsSync } from 'node:fs'
|
||||
import { homedir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
const LOG_PATH = join(homedir(), '.claude', 'rc-debug.log')
|
||||
|
||||
function ensureLogDir() {
|
||||
const dir = join(homedir(), '.claude')
|
||||
if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
|
||||
}
|
||||
|
||||
let headerWritten = false
|
||||
|
||||
export function rcLog(msg: string): void {
|
||||
try {
|
||||
if (!headerWritten) {
|
||||
ensureLogDir()
|
||||
appendFileSync(LOG_PATH, `\n===== RC-DEBUG session ${new Date().toISOString()} =====\n`)
|
||||
headerWritten = true
|
||||
}
|
||||
const ts = new Date().toISOString().slice(11, 23) // HH:mm:ss.SSS
|
||||
appendFileSync(LOG_PATH, `[${ts}] ${msg}\n`)
|
||||
} catch {
|
||||
// best-effort — never crash the bridge
|
||||
}
|
||||
}
|
||||
|
||||
/** Clear the log file at session start. */
|
||||
export function rcLogClear(): void {
|
||||
try {
|
||||
ensureLogDir()
|
||||
appendFileSync(LOG_PATH, '')
|
||||
} catch {}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from './bridgeApi.js'
|
||||
import type { BridgeConfig, BridgeApiClient } from './types.js'
|
||||
import { logForDebugging } from '../utils/debug.js'
|
||||
import { rcLog } from './rcDebugLog.js'
|
||||
import { logForDiagnosticsNoPII } from '../utils/diagLogs.js'
|
||||
import {
|
||||
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
@@ -616,6 +617,12 @@ export async function initBridgeCore(
|
||||
|
||||
async function doReconnect(): Promise<boolean> {
|
||||
environmentRecreations++
|
||||
rcLog(
|
||||
`doReconnect: attempt=${environmentRecreations}/${MAX_ENVIRONMENT_RECREATIONS}` +
|
||||
` envId=${environmentId}` +
|
||||
` sessionId=${currentSessionId}` +
|
||||
` workId=${currentWorkId}`,
|
||||
)
|
||||
// Invalidate any in-flight v2 handshake — the environment is being
|
||||
// recreated, so a stale transport arriving post-reconnect would be
|
||||
// pointed at a dead session.
|
||||
@@ -885,6 +892,11 @@ export async function initBridgeCore(
|
||||
* exhaustion. Transient drops are retried internally by the transport.
|
||||
*/
|
||||
function handleTransportPermanentClose(closeCode: number | undefined): void {
|
||||
rcLog(
|
||||
`handleTransportPermanentClose: code=${closeCode}` +
|
||||
` transport=${transport ? 'exists' : 'null'}` +
|
||||
` pollAborted=${pollController.signal.aborted}`,
|
||||
)
|
||||
logForDebugging(
|
||||
`[bridge:repl] Transport permanently closed: code=${closeCode}`,
|
||||
)
|
||||
@@ -1330,6 +1342,18 @@ export async function initBridgeCore(
|
||||
})
|
||||
|
||||
newTransport.setOnData(data => {
|
||||
try {
|
||||
const parsed = JSON.parse(data)
|
||||
rcLog(
|
||||
`ingress: type=${parsed.type}` +
|
||||
`${parsed.type === 'control_request' ? ` subtype=${(parsed.request as Record<string, unknown>)?.subtype} request_id=${parsed.request_id}` : ''}` +
|
||||
`${parsed.type === 'control_response' ? ` subtype=${(parsed.response as Record<string, unknown>)?.subtype} request_id=${(parsed.response as Record<string, unknown>)?.request_id}` : ''}` +
|
||||
`${parsed.type === 'user' ? ` uuid=${parsed.uuid}` : ''}` +
|
||||
`${parsed.type === 'keep_alive' ? '' : ` len=${data.length}`}`,
|
||||
)
|
||||
} catch {
|
||||
rcLog(`ingress (non-JSON): ${String(data).slice(0, 200)}`)
|
||||
}
|
||||
handleIngressMessage(
|
||||
data,
|
||||
recentPostedUUIDs,
|
||||
@@ -1350,6 +1374,12 @@ export async function initBridgeCore(
|
||||
newTransport.setOnClose(closeCode => {
|
||||
// Guard: if transport was replaced, ignore stale close.
|
||||
if (transport !== newTransport) return
|
||||
rcLog(
|
||||
`transport onClose: code=${closeCode}` +
|
||||
` connected=${newTransport.isConnectedStatus()}` +
|
||||
` state=${newTransport.getStateLabel()}` +
|
||||
` seq=${newTransport.getLastSequenceNum()}`,
|
||||
)
|
||||
handleTransportPermanentClose(closeCode)
|
||||
})
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@ export function decodeWorkSecret(secret: string): WorkSecret {
|
||||
export function buildSdkUrl(apiBaseUrl: string, sessionId: string): string {
|
||||
const isLocalhost =
|
||||
apiBaseUrl.includes('localhost') || apiBaseUrl.includes('127.0.0.1')
|
||||
const protocol = isLocalhost ? 'ws' : 'wss'
|
||||
const protocol = apiBaseUrl.startsWith('https') ? 'wss' : 'ws'
|
||||
const version = isLocalhost ? 'v2' : 'v1'
|
||||
const host = apiBaseUrl.replace(/^https?:\/\//, '').replace(/\/+$/, '')
|
||||
return `${protocol}://${host}/${version}/session_ingress/ws/${sessionId}`
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import axios, { type AxiosError } from 'axios'
|
||||
import type { StdoutMessage } from 'src/entrypoints/sdk/controlTypes.js'
|
||||
import { logForDebugging } from '../../utils/debug.js'
|
||||
import { rcLog } from '../../bridge/rcDebugLog.js'
|
||||
import { logForDiagnosticsNoPII } from '../../utils/diagLogs.js'
|
||||
import { getSessionIngressAuthToken } from '../../utils/sessionIngressAuth.js'
|
||||
import { SerialBatchEventUploader } from './SerialBatchEventUploader.js'
|
||||
@@ -241,6 +242,10 @@ export class HybridTransport extends WebSocketTransport {
|
||||
response.status < 500 &&
|
||||
response.status !== 429
|
||||
) {
|
||||
rcLog(
|
||||
`Hybrid POST ${response.status}: url=${this.postUrl.replace(/token=[^&]+/, 'token=***')}` +
|
||||
` events=${events.length} body=${JSON.stringify(response.data).slice(0, 200)}`,
|
||||
)
|
||||
logForDebugging(
|
||||
`HybridTransport: POST returned ${response.status} (permanent), dropping`,
|
||||
)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import axios, { type AxiosError } from 'axios'
|
||||
import type { StdoutMessage } from 'src/entrypoints/sdk/controlTypes.js'
|
||||
import { logForDebugging } from '../../utils/debug.js'
|
||||
import { rcLog } from '../../bridge/rcDebugLog.js'
|
||||
import { logForDiagnosticsNoPII } from '../../utils/diagLogs.js'
|
||||
import { errorMessage } from '../../utils/errors.js'
|
||||
import { getSessionIngressAuthHeaders } from '../../utils/sessionIngressAuth.js'
|
||||
@@ -468,6 +469,12 @@ export class SSETransport implements Transport {
|
||||
* Handle connection errors with exponential backoff and time budget.
|
||||
*/
|
||||
private handleConnectionError(): void {
|
||||
rcLog(
|
||||
`SSE handleConnectionError: state=${this.state}` +
|
||||
` lastSeqNum=${this.getLastSequenceNum()}` +
|
||||
` reconnectAttempts=${this.reconnectAttempts}` +
|
||||
` msSinceLastActivity=${this.lastActivityTime > 0 ? Date.now() - this.lastActivityTime : -1}`,
|
||||
)
|
||||
this.clearLivenessTimer()
|
||||
|
||||
if (this.state === 'closing' || this.state === 'closed') return
|
||||
@@ -541,6 +548,11 @@ export class SSETransport implements Transport {
|
||||
*/
|
||||
private readonly onLivenessTimeout = (): void => {
|
||||
this.livenessTimer = null
|
||||
rcLog(
|
||||
`SSE liveness timeout (${LIVENESS_TIMEOUT_MS}ms)` +
|
||||
` lastSeqNum=${this.getLastSequenceNum()}` +
|
||||
` state=${this.state}`,
|
||||
)
|
||||
logForDebugging('SSETransport: Liveness timeout, reconnecting', {
|
||||
level: 'error',
|
||||
})
|
||||
|
||||
@@ -3,6 +3,7 @@ import type WsWebSocket from 'ws'
|
||||
import { logEvent } from '../../services/analytics/index.js'
|
||||
import { CircularBuffer } from '../../utils/CircularBuffer.js'
|
||||
import { logForDebugging } from '../../utils/debug.js'
|
||||
import { rcLog } from '../../bridge/rcDebugLog.js'
|
||||
import { logForDiagnosticsNoPII } from '../../utils/diagLogs.js'
|
||||
import { isEnvTruthy } from '../../utils/envUtils.js'
|
||||
import { getWebSocketTLSOptions } from '../../utils/mtls.js'
|
||||
@@ -25,7 +26,7 @@ const DEFAULT_MAX_RECONNECT_DELAY = 30000
|
||||
/** Time budget for reconnection attempts before giving up (10 minutes). */
|
||||
const DEFAULT_RECONNECT_GIVE_UP_MS = 600_000
|
||||
const DEFAULT_PING_INTERVAL = 10000
|
||||
const DEFAULT_KEEPALIVE_INTERVAL = 300_000 // 5 minutes
|
||||
const DEFAULT_KEEPALIVE_INTERVAL = 120_000 // 2 minutes — must be under Bun's 255s idleTimeout
|
||||
|
||||
/**
|
||||
* Threshold for detecting system sleep/wake. If the gap between consecutive
|
||||
@@ -395,6 +396,13 @@ export class WebSocketTransport implements Transport {
|
||||
}
|
||||
|
||||
private handleConnectionError(closeCode?: number): void {
|
||||
rcLog(
|
||||
`WS handleConnectionError: code=${closeCode}` +
|
||||
` state=${this.state}` +
|
||||
` url=${this.url.href.replace(/token=[^&]+/, 'token=***')}` +
|
||||
` msSinceLastActivity=${this.lastActivityTime > 0 ? Date.now() - this.lastActivityTime : -1}` +
|
||||
` reconnectAttempts=${this.reconnectAttempts}`,
|
||||
)
|
||||
logForDebugging(
|
||||
`WebSocketTransport: Disconnected from ${this.url.href}` +
|
||||
(closeCode != null ? ` (code ${closeCode})` : ''),
|
||||
|
||||
@@ -4,6 +4,7 @@ import { StatsProvider, type StatsStore } from '../context/stats.js'
|
||||
import { type AppState, AppStateProvider } from '../state/AppState.js'
|
||||
import { onChangeAppState } from '../state/onChangeAppState.js'
|
||||
import type { FpsMetrics } from '../utils/fpsTracker.js'
|
||||
import { ThemeProvider } from '@anthropic/ink'
|
||||
|
||||
type Props = {
|
||||
getFpsMetrics: () => FpsMetrics | undefined
|
||||
|
||||
@@ -35,12 +35,24 @@ export function isRemoteSessionLocal(
|
||||
|
||||
/**
|
||||
* Get the base URL for Claude AI based on environment.
|
||||
* For localhost, derives the base URL from the ingress URL to preserve the
|
||||
* actual server port instead of using the hardcoded default (4000).
|
||||
*/
|
||||
export function getClaudeAiBaseUrl(
|
||||
sessionId?: string,
|
||||
ingressUrl?: string,
|
||||
): string {
|
||||
if (isRemoteSessionLocal(sessionId, ingressUrl)) {
|
||||
// If an ingress URL is available, extract its origin to keep the correct port.
|
||||
// Self-hosted servers may run on any port (default 3000), not just 4000.
|
||||
if (ingressUrl) {
|
||||
try {
|
||||
const parsed = new URL(ingressUrl)
|
||||
return parsed.origin
|
||||
} catch {
|
||||
// Fall through to default
|
||||
}
|
||||
}
|
||||
return CLAUDE_AI_LOCAL_BASE_URL
|
||||
}
|
||||
if (isRemoteSessionStaging(sessionId, ingressUrl)) {
|
||||
@@ -71,6 +83,12 @@ export function getRemoteSessionUrl(
|
||||
require('../bridge/sessionIdCompat.js') as typeof import('../bridge/sessionIdCompat.js')
|
||||
/* eslint-enable @typescript-eslint/no-require-imports */
|
||||
const compatId = toCompatSessionId(sessionId)
|
||||
// Use CLAUDE_BRIDGE_BASE_URL from env if available, otherwise fall back to default logic
|
||||
const bridgeBaseUrl = process.env.CLAUDE_BRIDGE_BASE_URL
|
||||
if (bridgeBaseUrl) {
|
||||
const base = bridgeBaseUrl.replace(/\/+$/, '')
|
||||
return `${base}/code/${compatId}`
|
||||
}
|
||||
const baseUrl = getClaudeAiBaseUrl(compatId, ingressUrl)
|
||||
return `${baseUrl}/code/${compatId}`
|
||||
}
|
||||
|
||||
@@ -155,7 +155,8 @@ async function main(): Promise<void> {
|
||||
// getBridgeDisabledReason awaits GB init, so the returned value is fresh
|
||||
// (not the stale disk cache), but init still needs auth headers to work.
|
||||
const { getClaudeAIOAuthTokens } = await import('../utils/auth.js')
|
||||
if (!getClaudeAIOAuthTokens()?.accessToken) {
|
||||
const { getBridgeAccessToken } = await import('../bridge/bridgeConfig.js')
|
||||
if (!getClaudeAIOAuthTokens()?.accessToken && !getBridgeAccessToken()) {
|
||||
exitWithError(BRIDGE_LOGIN_ERROR)
|
||||
}
|
||||
const disabledReason = await getBridgeDisabledReason()
|
||||
|
||||
@@ -105,7 +105,7 @@ export class SessionsWebSocket {
|
||||
|
||||
this.state = 'connecting'
|
||||
|
||||
const baseUrl = getOauthConfig().BASE_API_URL.replace('https://', 'wss://')
|
||||
const baseUrl = getOauthConfig().BASE_API_URL.replace('http', 'ws')
|
||||
const url = `${baseUrl}/v1/sessions/ws/${this.sessionId}/subscribe?organization_uuid=${this.orgUuid}`
|
||||
|
||||
logForDebugging(`[SessionsWebSocket] Connecting to ${url}`)
|
||||
|
||||
Reference in New Issue
Block a user