openclaude-improved 多模型 Claude Code 兼容层
openclaude-improved 把 Claude Code 风格的编码代理运行时扩展到 OpenAI、Gemini、Ollama、Codex、GitHub Copilot 等 provider,同时保留 bridge、权限、安装、会话和 SDK 兼容路径。
它解决什么,凭什么值得拆
这个仓库不是简单换壳 CLI。src/QueryEngine.ts:187 把单轮对话生命周期抽成可复用类,src/bridge/replBridge.ts:92 又把远端会话的初始化参数显式化,因此它的核心价值是把交互式编码代理拆成 SDK、bridge、provider 和 installer 几条可替换链路。
它值得源码级阅读,是因为安全边界写在实现里而不是只写在 README:src/bridge/sessionRunner.ts:24 用环境变量 allowlist 阻断父进程 secret 继承,src/bridge/workSecret.ts:64 拒绝非本地明文 HTTP,src/bridge/sessionRunner.test.ts:34 又用测试锁住任意 secret 不外泄。
▸核心数据
Bridge 如何把本地 CLI 接到远端会话
▸子进程环境 allowlist
Bridge 层的设计重点是“只传该传的”。src/bridge/sessionRunner.ts:56 从父环境构造子进程环境,src/bridge/sessionRunner.ts:72 单独注入 session access token,避免把用户 shell 里的 API key、数据库密码或代理 secret 直接交给子进程。
24const CHILD_ENV_ALLOWLIST = new Set([ 25 // System / shell 26 'PATH', 'HOME', 'USERPROFILE', 'HOMEPATH', 'HOMEDRIVE', 27 'USERNAME', 'USER', 'LOGNAME', 28 'TEMP', 'TMP', 'TMPDIR', 29 'SYSTEMROOT', 'SYSTEMDRIVE', 'COMSPEC', 'WINDIR', 30 'LANG', 'LC_ALL', 'LC_CTYPE', 31 // Node.js runtime 32 'NODE_OPTIONS', 'NODE_PATH', 'NODE_ENV', 33 // OpenClaude session / bridge (non-secret) 34 'CLAUDE_CODE_ENVIRONMENT_KIND', 35 'CLAUDE_CODE_FORCE_SANDBOX', 36 'CLAUDE_CODE_BUBBLEWRAP', 37 'CLAUDE_CODE_ENTRYPOINT', 38 'CLAUDE_CODE_COORDINATOR_MODE', 39 'CLAUDE_CODE_PERMISSIONS_VERSION', 40 'CLAUDE_CODE_PERMISSIONS_SETTING', 41 // Display / terminal 42 'TERM', 'COLORTERM', 'FORCE_COLOR', 'NO_COLOR', 43]) 44 45type BuildChildEnvOpts = { 46 accessToken: string 47 useCcrV2: boolean 48 workerEpoch?: number 49 sandbox?: boolean 50} 51 52/** 53 * Build the environment for the child CC process from an explicit allowlist. 54 * This prevents the parent's API keys and credentials from leaking to the child. 55 */ 56export function buildChildEnv( 57 parentEnv: NodeJS.ProcessEnv, 58 opts: BuildChildEnvOpts, 59): NodeJS.ProcessEnv { 60 // Start from allowlisted parent vars only 61 const env: NodeJS.ProcessEnv = {} 62 for (const key of Object.keys(parentEnv)) { 63 if (CHILD_ENV_ALLOWLIST.has(key)) { 64 env[key] = parentEnv[key] 65 } 66 } 67 68 // Bridge-required overrides 69 env.CLAUDE_CODE_OAUTH_TOKEN = undefined // explicitly strip 70 env.CLAUDE_CODE_ENVIRONMENT_KIND = 'bridge' 71 if (opts.sandbox) env.CLAUDE_CODE_FORCE_SANDBOX = '1' 72 env.CLAUDE_CODE_SESSION_ACCESS_TOKEN = opts.accessToken 73 env.CLAUDE_CODE_POST_FOR_SESSION_INGRESS_V2 = '1' 74 if (opts.useCcrV2) { 75 env.CLAUDE_CODE_USE_CCR_V2 = '1' 76 env.CLAUDE_CODE_WORKER_EPOCH = String(opts.workerEpoch) 77 } 78 79 return env 80}
▸会话 ID 与权限请求
会话 runner 还处理文件名安全与权限请求结构。src/bridge/sessionRunner.ts:87 会清理 session id,src/bridge/sessionRunner.ts:96 定义 control_request 结构,src/bridge/sessionRunner.ts:132 把工具名转成人类可读的活动摘要。
87export function safeFilenameId(id: string): string { 88 return id.replace(/[^a-zA-Z0-9_-]/g, '_') 89} 90 91/** 92 * A control_request emitted by the child CLI when it needs permission to 93 * execute a **specific** tool invocation (not a general capability check). 94 * The bridge forwards this to the server so the user can approve/deny. 95 */ 96export type PermissionRequest = { 97 type: 'control_request' 98 request_id: string 99 request: { 100 /** Per-invocation permission check — "may I run this tool with these inputs?" */ 101 subtype: 'can_use_tool' 102 tool_name: string 103 input: Record<string, unknown> 104 tool_use_id: string 105 } 106} 107 108type SessionSpawnerDeps = { 109 execPath: string 110 /** 111 * Arguments that must precede the CLI flags when spawning. Empty for 112 * compiled binaries (where execPath is the claude binary itself); contains 113 * the script path (process.argv[1]) for npm installs where execPath is the 114 * node runtime. Without this, node sees --sdk-url as a node option and 115 * exits with "bad option: --sdk-url" (see anthropics/claude-code#28334). 116 */ 117 scriptArgs: string[] 118 env: NodeJS.ProcessEnv 119 verbose: boolean 120 sandbox: boolean 121 debugFile?: string 122 permissionMode?: string 123 onDebug: (msg: string) => void 124 onActivity?: (sessionId: string, activity: SessionActivity) => void 125 onPermissionRequest?: ( 126 sessionId: string, 127 request: PermissionRequest, 128 accessToken: string, 129 ) => void 130} 131 132/** Map tool names to human-readable verbs for the status display. */ 133const TOOL_VERBS: Record<string, string> = { 134 Read: 'Reading', 135 Write: 'Writing', 136 Edit: 'Editing', 137 MultiEdit: 'Editing', 138 Bash: 'Running', 139 Glob: 'Searching', 140 Grep: 'Searching', 141 WebFetch: 'Fetching', 142 WebSearch: 'Searching', 143 Task: 'Running task', 144 FileReadTool: 'Reading', 145 FileWriteTool: 'Writing', 146 FileEditTool: 'Editing', 147 GlobTool: 'Searching', 148 GrepTool: 'Searching', 149 BashTool: 'Running', 150 NotebookEditTool: 'Editing notebook', 151 LSP: 'LSP', 152} 153 154function toolSummary(name: string, input: Record<string, unknown>): string { 155 const verb = TOOL_VERBS[name] ?? name 156 const target = 157 (input.file_path as string) ?? 158 (input.filePath as string) ?? 159 (input.pattern as string) ?? 160 (input.command as string | undefined)?.slice(0, 60) ?? 161 (input.url as string) ?? 162 (input.query as string) ?? 163 '' 164 if (target) { 165 return `${verb} ${target}` 166 } 167 return verb
▸深度阅读入口
深度阅读 · Bridge session isolationQueryEngine 如何承载无界多轮会话
▸QueryEngine 配置面
QueryEngine 是 headless/SDK 路径的主循环。src/QueryEngine.ts:132 把工具、命令、MCP clients、agents、budget 和 schema 都放进配置对象,src/QueryEngine.ts:213 让每次 submitMessage 作为异步生成器产出 SDKMessage。
132export type QueryEngineConfig = { 133 cwd: string 134 tools: Tools 135 commands: Command[] 136 mcpClients: MCPServerConnection[] 137 agents: AgentDefinition[] 138 canUseTool: CanUseToolFn 139 getAppState: () => AppState 140 setAppState: (f: (prev: AppState) => AppState) => void 141 initialMessages?: Message[] 142 readFileCache: FileStateCache 143 customSystemPrompt?: string 144 appendSystemPrompt?: string 145 userSpecifiedModel?: string 146 fallbackModel?: string 147 thinkingConfig?: ThinkingConfig 148 maxTurns?: number 149 maxBudgetUsd?: number 150 taskBudget?: { total: number } 151 jsonSchema?: Record<string, unknown> 152 verbose?: boolean 153 replayUserMessages?: boolean 154 /** Handler for URL elicitations triggered by MCP tool -32042 errors. */ 155 handleElicitation?: ToolUseContext['handleElicitation'] 156 includePartialMessages?: boolean 157 setSDKStatus?: (status: SDKStatus) => void 158 abortController?: AbortController 159 orphanedPermission?: OrphanedPermission 160 /** 161 * Snip-boundary handler: receives each yielded system message plus the 162 * current mutableMessages store. Returns undefined if the message is not a 163 * snip boundary; otherwise returns the replayed snip result. Injected by 164 * ask() when HISTORY_SNIP is enabled so feature-gated strings stay inside 165 * the gated module (keeps QueryEngine free of excluded strings and testable 166 * despite feature() returning false under bun test). SDK-only: the REPL 167 * keeps full history for UI scrollback and projects on demand via 168 * projectSnippedView; QueryEngine truncates here to bound memory in long 169 * headless sessions (no UI to preserve). 170 */ 171 snipReplay?: ( 172 yieldedSystemMsg: Message, 173 store: Message[], 174 ) => { messages: Message[]; executed: boolean } | undefined 175 query?: typeof defaultQuery 176} 177 178/** 179 * QueryEngine owns the query lifecycle and session state for a conversation. 180 * It extracts the core logic from ask() into a standalone class that can be 181 * used by both the headless/SDK path and (in a future phase) the REPL. 182 * 183 * One QueryEngine per conversation. Each submitMessage() call starts a new 184 * turn within the same conversation. State (messages, file cache, usage, etc.) 185 * persists across turns. 186 */ 187export class QueryEngine { 188 private config: QueryEngineConfig 189 private mutableMessages: Message[] 190 private abortController: AbortController 191 private permissionDenials: SDKPermissionDenial[] 192 private totalUsage: NonNullableUsage 193 private hasHandledOrphanedPermission = false 194 private readFileState: FileStateCache 195 private autoCompactTracking: AutoCompactTrackingState | undefined 196 // Turn-scoped skill discovery tracking (feeds was_discovered on 197 // tengu_skill_tool_invocation). Must persist across the two 198 // processUserInputContext rebuilds inside submitMessage, but is cleared 199 // at the start of each submitMessage to avoid unbounded growth across 200 // many turns in SDK mode. 201 private discoveredSkillNames = new Set<string>() 202 private loadedNestedMemoryPaths = new Set<string>() 203 204 constructor(config: QueryEngineConfig) { 205 this.config = config 206 this.mutableMessages = config.initialMessages ?? [] 207 this.abortController = config.abortController ?? createAbortController() 208 this.permissionDenials = [] 209 this.readFileState = config.readFileCache 210 this.totalUsage = EMPTY_USAGE 211 }
▸输入处理上下文
每一轮开始时,src/QueryEngine.ts:242 清空本轮 skill discovery,src/QueryEngine.ts:248 包装 canUseTool 记录拒绝,src/QueryEngine.ts:339 再构造 processUserInputContext,把 slash command、file history、nested memory 和动态 skill 发现放在同一个可变上下文里。
213 async *submitMessage( 214 prompt: string | ContentBlockParam[], 215 options?: { uuid?: string; isMeta?: boolean }, 216 ): AsyncGenerator<SDKMessage, void, unknown> { 217 const { 218 cwd, 219 commands, 220 tools, 221 mcpClients, 222 verbose = false, 223 thinkingConfig, 224 maxTurns, 225 maxBudgetUsd, 226 taskBudget, 227 canUseTool, 228 customSystemPrompt, 229 appendSystemPrompt, 230 userSpecifiedModel, 231 fallbackModel, 232 jsonSchema, 233 getAppState, 234 setAppState, 235 replayUserMessages = false, 236 includePartialMessages = false, 237 agents = [], 238 setSDKStatus, 239 orphanedPermission, 240 } = this.config 241 242 this.discoveredSkillNames.clear() 243 setCwd(cwd) 244 const persistSession = !isSessionPersistenceDisabled() 245 const startTime = Date.now() 246 247 // Wrap canUseTool to track permission denials 248 const wrappedCanUseTool: CanUseToolFn = async ( 249 tool, 250 input, 251 toolUseContext, 252 assistantMessage, 253 toolUseID, 254 forceDecision, 255 ) => { 256 const result = await canUseTool( 257 tool, 258 input, 259 toolUseContext, 260 assistantMessage, 261 toolUseID, 262 forceDecision, 263 ) 264 265 // Track denials for SDK reporting 266 if (result.behavior !== 'allow') { 267 this.permissionDenials.push({ 268 tool_name: sdkCompatToolName(tool.name), 269 tool_use_id: toolUseID, 270 tool_input: input, 271 }) 272 } 273 274 return result 275 } 276 277 const initialAppState = getAppState() 278 const initialMainLoopModel = userSpecifiedModel 279 ? parseUserSpecifiedModel(userSpecifiedModel) 280 : getMainLoopModel() 281 282 const initialThinkingConfig: ThinkingConfig = thinkingConfig 283 ? thinkingConfig 284 : shouldEnableThinkingByDefault() !== false 285 ? { type: 'adaptive' } 286 : { type: 'disabled' } 287 288 headlessProfilerCheckpoint('before_getSystemPrompt') 289 // Narrow once so TS tracks the type through the conditionals below. 290 const customPrompt = 291 typeof customSystemPrompt === 'string' ? customSystemPrompt : undefined 292 const { 293 defaultSystemPrompt, 294 userContext: baseUserContext, 295 systemContext, 296 } = await fetchSystemPromptParts({ 297 tools, 298 mainLoopModel: initialMainLoopModel, 299 additionalWorkingDirectories: Array.from( 300 initialAppState.toolPermissionContext.additionalWorkingDirectories.keys(), 301 ), 302 mcpClients, 303 customSystemPrompt: customPrompt, 304 }) 305 headlessProfilerCheckpoint('after_getSystemPrompt') 306 const userContext = { 307 ...baseUserContext, 308 ...getCoordinatorUserContext( 309 mcpClients, 310 isScratchpadEnabled() ? getScratchpadDir() : undefined, 311 ), 312 } 313 314 // When an SDK caller provides a custom system prompt AND has set 315 // CLAUDE_COWORK_MEMORY_PATH_OVERRIDE, inject the memory-mechanics prompt. 316 // The env var is an explicit opt-in signal — the caller has wired up 317 // a memory directory and needs Claude to know how to use it (which 318 // Write/Edit tools to call, MEMORY.md filename, loading semantics). 319 // The caller can layer their own policy text via appendSystemPrompt. 320 const memoryMechanicsPrompt = 321 customPrompt !== undefined && hasAutoMemPathOverride() 322 ? await loadMemoryPrompt() 323 : null 324 325 const systemPrompt = asSystemPrompt([ 326 ...(customPrompt !== undefined ? [customPrompt] : defaultSystemPrompt), 327 ...(memoryMechanicsPrompt ? [memoryMechanicsPrompt] : []), 328 ...(appendSystemPrompt ? [appendSystemPrompt] : []), 329 ]) 330 331 // Register function hook for structured output enforcement 332 const hasStructuredOutputTool = tools.some(t => 333 toolMatchesName(t, SYNTHETIC_OUTPUT_TOOL_NAME), 334 ) 335 if (jsonSchema && hasStructuredOutputTool) { 336 registerStructuredOutputEnforcement(setAppState, getSessionId()) 337 } 338 339 let processUserInputContext: ProcessUserInputContext = { 340 messages: this.mutableMessages, 341 // Slash commands that mutate the message array (e.g. /clear) 342 // call setMessages(fn). In interactive mode this writes back to 343 // AppState; in print mode we write back to mutableMessages so the 344 // rest of the query loop (push at :389, snapshot at :392) sees 345 // the result. The second processUserInputContext below (after 346 // slash-command processing) keeps the no-op — nothing else calls 347 // setMessages past that point. 348 setMessages: fn => { 349 this.mutableMessages = fn(this.mutableMessages) 350 }, 351 onChangeAPIKey: () => {}, 352 handleElicitation: this.config.handleElicitation, 353 options: { 354 commands, 355 debug: false, // we use stdout, so don't want to clobber it 356 tools, 357 verbose, 358 mainLoopModel: initialMainLoopModel, 359 thinkingConfig: initialThinkingConfig, 360 mcpClients, 361 mcpResources: {}, 362 ideInstallationStatus: null, 363 isNonInteractiveSession: true, 364 customSystemPrompt, 365 appendSystemPrompt, 366 agentDefinitions: { activeAgents: agents, allAgents: agents }, 367 theme: resolveThemeSetting(getGlobalConfig().theme), 368 maxBudgetUsd, 369 }, 370 getAppState, 371 setAppState, 372 abortController: this.abortController, 373 readFileState: this.readFileState, 374 nestedMemoryAttachmentTriggers: new Set<string>(), 375 loadedNestedMemoryPaths: this.loadedNestedMemoryPaths, 376 dynamicSkillDirTriggers: new Set<string>(), 377 discoveredSkillNames: this.discoveredSkillNames, 378 setInProgressToolUseIDs: () => {}, 379 setResponseLength: () => {}, 380 updateFileHistoryState: ( 381 updater: (prev: FileHistoryState) => FileHistoryState, 382 ) => { 383 setAppState(prev => { 384 const updated = updater(prev.fileHistory) 385 if (updated === prev.fileHistory) return prev 386 return { ...prev, fileHistory: updated } 387 }) 388 }, 389 updateAttributionState: ( 390 updater: (prev: AttributionState) => AttributionState, 391 ) => { 392 setAppState(prev => { 393 const updated = updater(prev.attribution) 394 if (updated === prev.attribution) return prev 395 return { ...prev, attribution: updated } 396 }) 397 }, 398 setSDKStatus, 399 } 400 401 // Handle orphaned permission (only once per engine lifetime) 402 if (orphanedPermission && !this.hasHandledOrphanedPermission) { 403 this.hasHandledOrphanedPermission = true 404 for await (const message of handleOrphanedPermission( 405 orphanedPermission, 406 tools, 407 this.mutableMessages, 408 processUserInputContext, 409 )) { 410 yield message 411 } 412 } 413 414 const { 415 messages: messagesFromUserInput, 416 shouldQuery, 417 allowedTools, 418 model: modelFromUserInput, 419 resultText, 420 } = await processUserInput({ 421 input: prompt, 422 mode: 'prompt', 423 setToolJSX: () => {}, 424 context: { 425 ...processUserInputContext, 426 messages: this.mutableMessages, 427 }, 428 messages: this.mutableMessages, 429 uuid: options?.uuid, 430 isMeta: options?.isMeta,
▸深度阅读入口
深度阅读 · Query lifecycleProvider 与安装链路的现实边界
▸Provider profile
Provider 配置不是一个字符串枚举那么简单。src/utils/providerProfile.ts:58 收拢大量环境变量,src/utils/providerProfile.ts:124 明确列出 anthropic、openai、gemini、codex、ollama 等兼容 profile,src/utils/providerProfile.ts:215 又把 secret source 设计成开放类型,避免新增 provider 时无法统一脱敏。
49export const PROFILE_FILE_NAME = '.openclaude-profile.json' 50export const DEFAULT_GEMINI_BASE_URL = 51 'https://generativelanguage.googleapis.com/v1beta/openai' 52export const DEFAULT_GEMINI_MODEL = 'gemini-3-flash-preview' 53export const DEFAULT_MISTRAL_BASE_URL = 'https://api.mistral.ai/v1' 54export const DEFAULT_MISTRAL_MODEL = 'mistral-vibe-cli-latest' 55export const DEFAULT_STARTUP_PROVIDER_ENV_VAR = 56 'CLAUDE_CODE_DEFAULT_STARTUP_PROVIDER' 57 58const PROFILE_ENV_KEYS = [ 59 'CLAUDE_CODE_USE_OPENAI', 60 'CLAUDE_CODE_USE_GITHUB', 61 'CLAUDE_CODE_USE_GEMINI', 62 'CLAUDE_CODE_USE_MISTRAL', 63 'CLAUDE_CODE_USE_BEDROCK', 64 'CLAUDE_CODE_USE_VERTEX', 65 'CLAUDE_CODE_USE_FOUNDRY', 66 'ANTHROPIC_BASE_URL', 67 'ANTHROPIC_MODEL', 68 'ANTHROPIC_API_KEY', 69 'ANTHROPIC_AUTH_TOKEN', 70 'ANTHROPIC_CUSTOM_HEADERS', 71 'ANTHROPIC_BEDROCK_BASE_URL', 72 'ANTHROPIC_VERTEX_BASE_URL', 73 'OPENAI_BASE_URL', 74 'OPENAI_API_BASE', 75 'OPENAI_MODEL', 76 'OPENAI_API_FORMAT', 77 'OPENAI_AZURE_STYLE', 78 'OPENAI_AUTH_HEADER', 79 'OPENAI_AUTH_SCHEME', 80 'OPENAI_AUTH_HEADER_VALUE', 81 'OPENAI_API_KEYS', 82 'OPENAI_API_KEY', 83 'GITHUB_COPILOT_KEY', 84 'GITHUB_ENTERPRISE_URL', 85 'CLAUDE_CODE_OPENAI_CONTEXT_WINDOWS', 86 'CODEX_API_KEY', 87 'CODEX_CREDENTIAL_SOURCE', 88 'CHATGPT_ACCOUNT_ID', 89 'CODEX_ACCOUNT_ID', 90 'GEMINI_API_KEY', 91 'GEMINI_AUTH_MODE', 92 'GEMINI_ACCESS_TOKEN', 93 'GEMINI_MODEL', 94 'GEMINI_BASE_URL', 95 'GOOGLE_API_KEY', 96 'NVIDIA_NIM', 97 'NVIDIA_API_KEY', 98 'NVIDIA_MODEL', 99 'MINIMAX_API_KEY', 100 'MINIMAX_BASE_URL', 101 'MINIMAX_MODEL', 102 'MISTRAL_BASE_URL', 103 'MISTRAL_API_KEY', 104 'MISTRAL_MODEL', 105 'BANKR_BASE_URL', 106 'BNKR_API_KEY', 107 'BANKR_MODEL', 108 'XAI_API_KEY', 109 'XAI_CREDENTIAL_SOURCE', 110 'AIMLAPI_API_KEY', 111 'VENICE_API_KEY', 112 'MIMO_API_KEY', 113 'ATLAS_CLOUD_API_KEY', 114 'NEARAI_API_KEY', 115 'FIREWORKS_API_KEY', 116 'LONGCAT_API_KEY', 117 'CLINE_API_KEY', 118 'OPENCODE_API_KEY', 119 'CLAUDE_CODE_PROVIDER_ROUTE_ID', 120 'CLOUDFLARE_API_TOKEN', 121 DEFAULT_STARTUP_PROVIDER_ENV_VAR, 122] as const
▸安装与修复流程
安装命令也体现了“兼容但不越界”。src/commands/install.tsx:107 检测没有 native distribution 的 npm build,src/commands/install.tsx:131 调用 installLatest,src/commands/install.tsx:149 清理旧 npm 安装,src/commands/install.tsx:177 再验证 shell launcher。
91export function Install({ 92 onDone, 93 force, 94 target 95}: InstallProps): React.ReactNode { 96 const [state, setState] = useState<InstallState>({ 97 type: 'checking' 98 }); 99 useEffect(() => { 100 async function run() { 101 try { 102 logForDebugging(`Install: Starting installation process (force=${force}, target=${target})`); 103 104 // npm-only builds have no native binary to install. Downloading here 105 // would fetch the first-party Claude Code binary and replace the npm 106 // install the user is running, so just confirm the current install. 107 if (!hasNativeDistribution()) { 108 logForDebugging('Install: build has no native distribution; reporting npm installation instead'); 109 logEvent('tengu_claude_install_command', { 110 has_version: 1, 111 forced: force ? 1 : 0 112 }); 113 setState({ 114 type: 'success', 115 version: MACRO.DISPLAY_VERSION, 116 location: process.argv[1] || 'npm global installation', 117 setupMessages: [`This build is distributed via npm (${MACRO.PACKAGE_URL}) and has no native binary, so nothing was downloaded.`, `To update, run: npm install -g ${MACRO.PACKAGE_URL}@latest`] 118 }); 119 return; 120 } 121 122 // Install native build first 123 const channelOrVersion = target || getInitialSettings()?.autoUpdatesChannel || 'latest'; 124 setState({ 125 type: 'installing', 126 version: channelOrVersion 127 }); 128 129 // Pass force flag to trigger reinstall even if up to date 130 logForDebugging(`Install: Calling installLatest(channelOrVersion=${channelOrVersion}, forceReinstall=${force})`); 131 const result = await installLatest(channelOrVersion, force); 132 logForDebugging(`Install: installLatest returned version=${result.latestVersion}, wasUpdated=${result.wasUpdated}, lockFailed=${result.lockFailed}`); 133 134 // Check specifically for lock failure 135 if (result.lockFailed) { 136 throw new Error('Could not install - another process is currently installing Claude. Please try again in a moment.'); 137 } 138 139 // If we couldn't get the version, there might be an issue 140 if (!result.latestVersion) { 141 logForDebugging('Install: Failed to retrieve version information during install', { 142 level: 'error' 143 }); 144 } 145 if (!result.wasUpdated) { 146 logForDebugging('Install: Already up to date'); 147 } 148 149 // Now that native installation succeeded, clean up old npm installations. 150 // npm uninstall owns its bin entries and can remove ~/.local/bin/openclaude 151 // when the npm prefix overlaps the native launcher directory, so repair the 152 // native launcher after cleanup before checking the final install state. 153 logForDebugging('Install: Cleaning up npm installations after successful install'); 154 const { 155 removed, 156 errors, 157 warnings 158 } = await cleanupNpmInstallations(); 159 if (removed > 0) { 160 logForDebugging(`Cleaned up ${removed} npm installation(s)`); 161 } 162 if (errors.length > 0) { 163 logForDebugging(`Cleanup errors: ${errors.join(', ')}`); 164 // Continue despite cleanup errors - native install already succeeded 165 } 166 167 setState({ 168 type: 'setting-up' 169 }); 170 if (!result.latestVersion) { 171 throw new Error('Could not repair native launcher - installed version is unknown.'); 172 } 173 logForDebugging(`Install: Repairing native launcher after npm cleanup`); 174 await repairNativeLauncher(result.latestVersion); 175 176 // Set up launcher and shell integration against the final post-cleanup state 177 const setupMessages = await checkInstall(true); 178 logForDebugging(`Install: Setup launcher completed with ${setupMessages.length} messages`); 179 if (setupMessages.length > 0) { 180 setupMessages.forEach(msg => logForDebugging(`Install: Setup message: ${msg.message}`));
▸系统流程图
测试把安全假设固定下来
▸Secret 不外泄
最重要的测试不是 UI 快照,而是安全不变量。src/bridge/sessionRunner.test.ts:13 验证 ANTHROPIC_API_KEY 不传给 child,src/bridge/sessionRunner.test.ts:24 覆盖 OPENAI_API_KEY,src/bridge/sessionRunner.test.ts:34 覆盖 DB、AWS、GitHub token。
13test('buildChildEnv does not leak ANTHROPIC_API_KEY to child', () => { 14 const parentEnv = { 15 PATH: '/usr/bin', 16 HOME: '/home/user', 17 ANTHROPIC_API_KEY: 'sk-ant-secret-key', 18 CLAUDE_CODE_SESSION_ACCESS_TOKEN: 'will-be-overwritten', 19 } 20 const env = buildChildEnv(parentEnv, baseOpts) 21 expect(env.ANTHROPIC_API_KEY).toBeUndefined() 22}) 23 24test('buildChildEnv does not leak OPENAI_API_KEY to child', () => { 25 const parentEnv = { 26 PATH: '/usr/bin', 27 HOME: '/home/user', 28 OPENAI_API_KEY: 'sk-openai-secret', 29 } 30 const env = buildChildEnv(parentEnv, baseOpts) 31 expect(env.OPENAI_API_KEY).toBeUndefined() 32}) 33 34test('buildChildEnv does not leak arbitrary secrets to child', () => { 35 const parentEnv = { 36 PATH: '/usr/bin', 37 HOME: '/home/user', 38 DB_PASSWORD: 'super-secret', 39 AWS_SECRET_ACCESS_KEY: 'aws-secret', 40 GITHUB_TOKEN: 'ghp_token', 41 } 42 const env = buildChildEnv(parentEnv, baseOpts) 43 expect(env.DB_PASSWORD).toBeUndefined() 44 expect(env.AWS_SECRET_ACCESS_KEY).toBeUndefined() 45 expect(env.GITHUB_TOKEN).toBeUndefined() 46}) 47 48test('buildChildEnv includes PATH and HOME from parent', () => { 49 const parentEnv = { 50 PATH: '/usr/bin:/usr/local/bin', 51 HOME: '/home/user', 52 ANTHROPIC_API_KEY: 'sk-secret', 53 } 54 const env = buildChildEnv(parentEnv, baseOpts) 55 expect(env.PATH).toBe('/usr/bin:/usr/local/bin') 56 expect(env.HOME).toBe('/home/user') 57}) 58 59test('buildChildEnv sets CLAUDE_CODE_SESSION_ACCESS_TOKEN from opts', () => { 60 const env = buildChildEnv({ PATH: '/usr/bin' }, { ...baseOpts, accessToken: 'my-token' }) 61 expect(env.CLAUDE_CODE_SESSION_ACCESS_TOKEN).toBe('my-token') 62}) 63 64test('buildChildEnv sets CLAUDE_CODE_ENVIRONMENT_KIND to bridge', () => { 65 const env = buildChildEnv({ PATH: '/usr/bin' }, baseOpts) 66 expect(env.CLAUDE_CODE_ENVIRONMENT_KIND).toBe('bridge') 67}) 68 69test('buildChildEnv does not pass CLAUDE_CODE_OAUTH_TOKEN to child', () => { 70 const parentEnv = { 71 PATH: '/usr/bin', 72 CLAUDE_CODE_OAUTH_TOKEN: 'oauth-token-to-strip', 73 } 74 const env = buildChildEnv(parentEnv, baseOpts) 75 expect(env.CLAUDE_CODE_OAUTH_TOKEN).toBeUndefined() 76}) 77 78test('buildChildEnv sets CCR v2 vars when useCcrV2 is true', () => { 79 const env = buildChildEnv( 80 { PATH: '/usr/bin' }, 81 { accessToken: 'tok', useCcrV2: true, workerEpoch: 42 }, 82 ) 83 expect(env.CLAUDE_CODE_USE_CCR_V2).toBe('1') 84 expect(env.CLAUDE_CODE_WORKER_EPOCH).toBe('42') 85})
▸消息路由测试面
实现本身提供了足够可测接口:src/bridge/bridgeMessaging.ts:36 是 SDKMessage type guard,src/bridge/bridgeMessaging.ts:77 过滤 eligible message,src/bridge/bridgeMessaging.ts:132 则把 ingress parsing 和去重逻辑集中起来。
31// ─── Type guards ───────────────────────────────────────────────────────────── 32 33/** Type predicate for parsed WebSocket messages. SDKMessage is a 34 * discriminated union on `type` — validating the discriminant is 35 * sufficient for the predicate; callers narrow further via the union. */ 36export function isSDKMessage(value: unknown): value is SDKMessage { 37 return ( 38 value !== null && 39 typeof value === 'object' && 40 'type' in value && 41 typeof value.type === 'string' 42 ) 43} 44 45/** Type predicate for control_response messages from the server. */ 46export function isSDKControlResponse( 47 value: unknown, 48): value is SDKControlResponse { 49 return ( 50 value !== null && 51 typeof value === 'object' && 52 'type' in value && 53 value.type === 'control_response' && 54 'response' in value 55 ) 56} 57 58/** Type predicate for control_request messages from the server. */ 59export function isSDKControlRequest( 60 value: unknown, 61): value is SDKControlRequest { 62 return ( 63 value !== null && 64 typeof value === 'object' && 65 'type' in value && 66 value.type === 'control_request' && 67 'request_id' in value && 68 'request' in value 69 ) 70} 71 72/** 73 * True for message types that should be forwarded to the bridge transport. 74 * The server only wants user/assistant turns and slash-command system events; 75 * everything else (tool_result, progress, etc.) is internal REPL chatter. 76 */ 77export function isEligibleBridgeMessage(m: Message): boolean { 78 // Virtual messages (REPL inner calls) are display-only — bridge/SDK 79 // consumers see the REPL tool_use/result which summarizes the work. 80 if ((m.type === 'user' || m.type === 'assistant') && m.isVirtual) { 81 return false 82 } 83 return ( 84 m.type === 'user' || 85 m.type === 'assistant' || 86 (m.type === 'system' && m.subtype === 'local_command') 87 ) 88} 89 90/** 91 * Extract title-worthy text from a Message for onUserMessage. Returns 92 * undefined for messages that shouldn't title the session: non-user, meta 93 * (nudges), tool results, compact summaries, non-human origins (task 94 * notifications, channel messages), or pure display-tag content 95 * (<ide_opened_file>, <session-start-hook>, etc.). 96 * 97 * Synthetic interrupts ([Request interrupted by user]) are NOT filtered here — 98 * isSyntheticMessage lives in messages.ts (heavy import, pulls command 99 * registry). The initialMessages path in initReplBridge checks it; the 100 * writeMessages path reaching an interrupt as the *first* message is 101 * implausible (an interrupt implies a prior prompt already flowed through). 102 */ 103export function extractTitleText(m: Message): string | undefined { 104 if (m.type !== 'user' || m.isMeta || m.toolUseResult || m.isCompactSummary) 105 return undefined 106 if (m.origin && m.origin.kind !== 'human') return undefined 107 const content = m.message.content 108 let raw: string | undefined 109 if (typeof content === 'string') { 110 raw = content 111 } else { 112 for (const block of content) { 113 if (block.type === 'text') { 114 raw = block.text 115 break 116 } 117 } 118 } 119 if (!raw) return undefined 120 const clean = stripDisplayTagsAllowEmpty(raw) 121 return clean || undefined 122}
技术亮点之外,别忽略这些
▸源码证据地图
主页面源码分析 的证据点 src/bridge/sessionRunner.ts:24 不是孤立行号:这一处把状态、权限或传输边界固定在源码里,和相邻模块形成可测试契约;阅读时应把它当成调用链的一段,而不是 README 里的营销描述。
主页面源码分析 的证据点 src/bridge/sessionRunner.ts:56 不是孤立行号:这一处把状态、权限或传输边界固定在源码里,和相邻模块形成可测试契约;阅读时应把它当成调用链的一段,而不是 README 里的营销描述。
主页面源码分析 的证据点 src/bridge/sessionRunner.ts:72 不是孤立行号:这一处把状态、权限或传输边界固定在源码里,和相邻模块形成可测试契约;阅读时应把它当成调用链的一段,而不是 README 里的营销描述。
主页面源码分析 的证据点 src/bridge/sessionRunner.ts:87 不是孤立行号:这一处把状态、权限或传输边界固定在源码里,和相邻模块形成可测试契约;阅读时应把它当成调用链的一段,而不是 README 里的营销描述。
主页面源码分析 的证据点 src/bridge/sessionRunner.ts:96 不是孤立行号:这一处把状态、权限或传输边界固定在源码里,和相邻模块形成可测试契约;阅读时应把它当成调用链的一段,而不是 README 里的营销描述。
主页面源码分析 的证据点 src/bridge/sessionRunner.ts:132 不是孤立行号:这一处把状态、权限或传输边界固定在源码里,和相邻模块形成可测试契约;阅读时应把它当成调用链的一段,而不是 README 里的营销描述。
主页面源码分析 的证据点 src/bridge/sessionRunner.ts:170 不是孤立行号:这一处把状态、权限或传输边界固定在源码里,和相邻模块形成可测试契约;阅读时应把它当成调用链的一段,而不是 README 里的营销描述。
主页面源码分析 的证据点 src/bridge/sessionRunner.ts:201 不是孤立行号:这一处把状态、权限或传输边界固定在源码里,和相邻模块形成可测试契约;阅读时应把它当成调用链的一段,而不是 README 里的营销描述。
主页面源码分析 的证据点 src/bridge/sessionRunner.ts:247 不是孤立行号:这一处把状态、权限或传输边界固定在源码里,和相邻模块形成可测试契约;阅读时应把它当成调用链的一段,而不是 README 里的营销描述。
主页面源码分析 的证据点 src/bridge/sessionRunner.ts:310 不是孤立行号:这一处把状态、权限或传输边界固定在源码里,和相邻模块形成可测试契约;阅读时应把它当成调用链的一段,而不是 README 里的营销描述。
主页面源码分析 的证据点 src/bridge/replBridge.ts:92 不是孤立行号:这一处把状态、权限或传输边界固定在源码里,和相邻模块形成可测试契约;阅读时应把它当成调用链的一段,而不是 README 里的营销描述。
主页面源码分析 的证据点 src/bridge/replBridge.ts:120 不是孤立行号:这一处把状态、权限或传输边界固定在源码里,和相邻模块形成可测试契约;阅读时应把它当成调用链的一段,而不是 README 里的营销描述。
主页面源码分析 的证据点 src/bridge/replBridge.ts:151 不是孤立行号:这一处把状态、权限或传输边界固定在源码里,和相邻模块形成可测试契约;阅读时应把它当成调用链的一段,而不是 README 里的营销描述。
主页面源码分析 的证据点 src/bridge/replBridge.ts:166 不是孤立行号:这一处把状态、权限或传输边界固定在源码里,和相邻模块形成可测试契约;阅读时应把它当成调用链的一段,而不是 README 里的营销描述。
主页面源码分析 的证据点 src/bridge/replBridge.ts:183 不是孤立行号:这一处把状态、权限或传输边界固定在源码里,和相邻模块形成可测试契约;阅读时应把它当成调用链的一段,而不是 README 里的营销描述。
主页面源码分析 的证据点 src/bridge/replBridge.ts:193 不是孤立行号:这一处把状态、权限或传输边界固定在源码里,和相邻模块形成可测试契约;阅读时应把它当成调用链的一段,而不是 README 里的营销描述。
主页面源码分析 的证据点 src/bridge/replBridge.ts:214 不是孤立行号:这一处把状态、权限或传输边界固定在源码里,和相邻模块形成可测试契约;阅读时应把它当成调用链的一段,而不是 README 里的营销描述。
主页面源码分析 的证据点 src/QueryEngine.ts:132 不是孤立行号:这一处把状态、权限或传输边界固定在源码里,和相邻模块形成可测试契约;阅读时应把它当成调用链的一段,而不是 README 里的营销描述。
主页面源码分析 的证据点 src/QueryEngine.ts:187 不是孤立行号:这一处把状态、权限或传输边界固定在源码里,和相邻模块形成可测试契约;阅读时应把它当成调用链的一段,而不是 README 里的营销描述。
主页面源码分析 的证据点 src/QueryEngine.ts:213 不是孤立行号:这一处把状态、权限或传输边界固定在源码里,和相邻模块形成可测试契约;阅读时应把它当成调用链的一段,而不是 README 里的营销描述。
主页面源码分析 的证据点 src/QueryEngine.ts:242 不是孤立行号:这一处把状态、权限或传输边界固定在源码里,和相邻模块形成可测试契约;阅读时应把它当成调用链的一段,而不是 README 里的营销描述。
主页面源码分析 的证据点 src/QueryEngine.ts:248 不是孤立行号:这一处把状态、权限或传输边界固定在源码里,和相邻模块形成可测试契约;阅读时应把它当成调用链的一段,而不是 README 里的营销描述。
主页面源码分析 的证据点 src/QueryEngine.ts:292 不是孤立行号:这一处把状态、权限或传输边界固定在源码里,和相邻模块形成可测试契约;阅读时应把它当成调用链的一段,而不是 README 里的营销描述。
主页面源码分析 的证据点 src/QueryEngine.ts:320 不是孤立行号:这一处把状态、权限或传输边界固定在源码里,和相邻模块形成可测试契约;阅读时应把它当成调用链的一段,而不是 README 里的营销描述。
主页面源码分析 的证据点 src/QueryEngine.ts:339 不是孤立行号:这一处把状态、权限或传输边界固定在源码里,和相邻模块形成可测试契约;阅读时应把它当成调用链的一段,而不是 README 里的营销描述。
主页面源码分析 的证据点 src/QueryEngine.ts:401 不是孤立行号:这一处把状态、权限或传输边界固定在源码里,和相邻模块形成可测试契约;阅读时应把它当成调用链的一段,而不是 README 里的营销描述。
主页面源码分析 的证据点 src/QueryEngine.ts:414 不是孤立行号:这一处把状态、权限或传输边界固定在源码里,和相邻模块形成可测试契约;阅读时应把它当成调用链的一段,而不是 README 里的营销描述。
主页面源码分析 的证据点 src/bridge/bridgeMessaging.ts:36 不是孤立行号:这一处把状态、权限或传输边界固定在源码里,和相邻模块形成可测试契约;阅读时应把它当成调用链的一段,而不是 README 里的营销描述。
主页面源码分析 的证据点 src/bridge/bridgeMessaging.ts:77 不是孤立行号:这一处把状态、权限或传输边界固定在源码里,和相邻模块形成可测试契约;阅读时应把它当成调用链的一段,而不是 README 里的营销描述。
主页面源码分析 的证据点 src/bridge/bridgeMessaging.ts:103 不是孤立行号:这一处把状态、权限或传输边界固定在源码里,和相邻模块形成可测试契约;阅读时应把它当成调用链的一段,而不是 README 里的营销描述。
主页面源码分析 的证据点 src/bridge/bridgeMessaging.ts:132 不是孤立行号:这一处把状态、权限或传输边界固定在源码里,和相邻模块形成可测试契约;阅读时应把它当成调用链的一段,而不是 README 里的营销描述。
主页面源码分析 的证据点 src/bridge/bridgeMessaging.ts:143 不是孤立行号:这一处把状态、权限或传输边界固定在源码里,和相邻模块形成可测试契约;阅读时应把它当成调用链的一段,而不是 README 里的营销描述。
主页面源码分析 的证据点 src/bridge/bridgeMessaging.ts:151 不是孤立行号:这一处把状态、权限或传输边界固定在源码里,和相邻模块形成可测试契约;阅读时应把它当成调用链的一段,而不是 README 里的营销描述。
主页面源码分析 的证据点 src/bridge/bridgeMessaging.ts:171 不是孤立行号:这一处把状态、权限或传输边界固定在源码里,和相邻模块形成可测试契约;阅读时应把它当成调用链的一段,而不是 README 里的营销描述。
主页面源码分析 的证据点 src/bridge/bridgeMessaging.ts:183 不是孤立行号:这一处把状态、权限或传输边界固定在源码里,和相邻模块形成可测试契约;阅读时应把它当成调用链的一段,而不是 README 里的营销描述。
主页面源码分析 的证据点 src/bridge/bridgeMessaging.ts:194 不是孤立行号:这一处把状态、权限或传输边界固定在源码里,和相邻模块形成可测试契约;阅读时应把它当成调用链的一段,而不是 README 里的营销描述。
主页面源码分析 的证据点 src/bridge/bridgeMessaging.ts:206 不是孤立行号:这一处把状态、权限或传输边界固定在源码里,和相邻模块形成可测试契约;阅读时应把它当成调用链的一段,而不是 README 里的营销描述。
主页面源码分析 的证据点 src/bridge/workSecret.ts:5 不是孤立行号:这一处把状态、权限或传输边界固定在源码里,和相邻模块形成可测试契约;阅读时应把它当成调用链的一段,而不是 README 里的营销描述。
主页面源码分析 的证据点 src/bridge/workSecret.ts:44 不是孤立行号:这一处把状态、权限或传输边界固定在源码里,和相邻模块形成可测试契约;阅读时应把它当成调用链的一段,而不是 README 里的营销描述。
主页面源码分析 的证据点 src/bridge/workSecret.ts:64 不是孤立行号:这一处把状态、权限或传输边界固定在源码里,和相邻模块形成可测试契约;阅读时应把它当成调用链的一段,而不是 README 里的营销描述。
主页面源码分析 的证据点 src/bridge/workSecret.ts:81 不是孤立行号:这一处把状态、权限或传输边界固定在源码里,和相邻模块形成可测试契约;阅读时应把它当成调用链的一段,而不是 README 里的营销描述。
主页面源码分析 的证据点 src/bridge/workSecret.ts:101 不是孤立行号:这一处把状态、权限或传输边界固定在源码里,和相邻模块形成可测试契约;阅读时应把它当成调用链的一段,而不是 README 里的营销描述。
主页面源码分析 的证据点 src/bridge/workSecret.ts:136 不是孤立行号:这一处把状态、权限或传输边界固定在源码里,和相邻模块形成可测试契约;阅读时应把它当成调用链的一段,而不是 README 里的营销描述。
主页面源码分析 的证据点 src/bridge/sessionRunner.test.ts:13 不是孤立行号:这一处把状态、权限或传输边界固定在源码里,和相邻模块形成可测试契约;阅读时应把它当成调用链的一段,而不是 README 里的营销描述。
主页面源码分析 的证据点 src/bridge/sessionRunner.test.ts:34 不是孤立行号:这一处把状态、权限或传输边界固定在源码里,和相邻模块形成可测试契约;阅读时应把它当成调用链的一段,而不是 README 里的营销描述。
主页面源码分析 的证据点 src/bridge/sessionRunner.test.ts:78 不是孤立行号:这一处把状态、权限或传输边界固定在源码里,和相邻模块形成可测试契约;阅读时应把它当成调用链的一段,而不是 README 里的营销描述。
主页面源码分析 的证据点 src/utils/providerProfile.ts:49 不是孤立行号:这一处把状态、权限或传输边界固定在源码里,和相邻模块形成可测试契约;阅读时应把它当成调用链的一段,而不是 README 里的营销描述。
主页面源码分析 的证据点 src/utils/providerProfile.ts:58 不是孤立行号:这一处把状态、权限或传输边界固定在源码里,和相邻模块形成可测试契约;阅读时应把它当成调用链的一段,而不是 README 里的营销描述。
主页面源码分析 的证据点 src/utils/providerProfile.ts:124 不是孤立行号:这一处把状态、权限或传输边界固定在源码里,和相邻模块形成可测试契约;阅读时应把它当成调用链的一段,而不是 README 里的营销描述。
主页面源码分析 的证据点 src/utils/providerProfile.ts:209 不是孤立行号:这一处把状态、权限或传输边界固定在源码里,和相邻模块形成可测试契约;阅读时应把它当成调用链的一段,而不是 README 里的营销描述。
主页面源码分析 的证据点 src/utils/providerProfile.ts:215 不是孤立行号:这一处把状态、权限或传输边界固定在源码里,和相邻模块形成可测试契约;阅读时应把它当成调用链的一段,而不是 README 里的营销描述。
主页面源码分析 的证据点 src/commands/install.tsx:91 不是孤立行号:这一处把状态、权限或传输边界固定在源码里,和相邻模块形成可测试契约;阅读时应把它当成调用链的一段,而不是 README 里的营销描述。
主页面源码分析 的证据点 src/commands/install.tsx:107 不是孤立行号:这一处把状态、权限或传输边界固定在源码里,和相邻模块形成可测试契约;阅读时应把它当成调用链的一段,而不是 README 里的营销描述。
主页面源码分析 的证据点 src/commands/install.tsx:131 不是孤立行号:这一处把状态、权限或传输边界固定在源码里,和相邻模块形成可测试契约;阅读时应把它当成调用链的一段,而不是 README 里的营销描述。
主页面源码分析 的证据点 src/commands/install.tsx:149 不是孤立行号:这一处把状态、权限或传输边界固定在源码里,和相邻模块形成可测试契约;阅读时应把它当成调用链的一段,而不是 README 里的营销描述。
主页面源码分析 的证据点 src/commands/install.tsx:177 不是孤立行号:这一处把状态、权限或传输边界固定在源码里,和相邻模块形成可测试契约;阅读时应把它当成调用链的一段,而不是 README 里的营销描述。