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:
claude-code-best
2026-04-09 17:40:50 +08:00
committed by GitHub
parent f17b7c7163
commit 2da6514095
81 changed files with 9875 additions and 40 deletions

View File

@@ -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) {

View File

@@ -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()
}

View File

@@ -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.'
}

View File

@@ -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'

View File

@@ -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}`,
)

View File

@@ -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. */

View File

@@ -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

View File

@@ -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
View 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 {}
}

View File

@@ -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)
})

View File

@@ -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}`