Bridge session isolation 深度解析
这一页聚焦 bridge/session 层如何隔离环境变量、处理 ingress 消息、构造 websocket URL 和注册 worker。
导入链与契约
▸环境隔离
Bridge session 的安全边界从 src/bridge/sessionRunner.ts:24 开始,allowlist 明确哪些系统变量可以进入 child process;再通过 src/bridge/workSecret.ts:81 和 src/bridge/workSecret.ts:120 构造 v1/v2 session URL。
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}
▸测试锁住 secret 边界
环境隔离的关键不是删除某一个 token,而是默认拒绝。src/bridge/sessionRunner.ts:56 从空对象开始,src/bridge/sessionRunner.ts:62 只复制 allowlist 里的 key,src/bridge/sessionRunner.ts:69 还显式去掉 OAuth 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})
▸URL 与 worker 注册
workSecret 层处理网络边界:src/bridge/workSecret.ts:44 精确识别 localhost,src/bridge/workSecret.ts:64 拒绝非本地 HTTP,src/bridge/workSecret.ts:136 注册 worker 并校验 worker_epoch。
44export function isLocalhostBaseUrl(baseUrl: string): boolean { 45 let hostname: string 46 try { 47 hostname = new URL(baseUrl).hostname 48 } catch { 49 return false 50 } 51 return hostname === 'localhost' || hostname === '127.0.0.1' 52} 53 54/** 55 * Whether a base URL would send traffic to a non-localhost host over plaintext 56 * HTTP, which must be rejected to keep credentials off the wire. 57 * 58 * The scheme is read from the parsed URL (`protocol === 'http:'`) rather than a 59 * `startsWith('http://')` test so that mixed-case schemes like `HTTP://` — which 60 * the URL parser normalizes to `http:` and would otherwise carry credentials in 61 * the clear — are still caught. A malformed URL is treated as not-insecure (the 62 * safe default: the callers fall through to their normal handling). 63 */ 64export function isInsecureHttpBaseUrl(baseUrl: string): boolean { 65 let protocol: string 66 try { 67 protocol = new URL(baseUrl).protocol 68 } catch { 69 return false 70 } 71 return protocol === 'http:' && !isLocalhostBaseUrl(baseUrl) 72} 73 74/** 75 * Build a WebSocket SDK URL from the API base URL and session ID. 76 * Strips the HTTP(S) protocol and constructs a ws(s):// ingress URL. 77 * 78 * Uses /v2/ for localhost (direct to session-ingress, no Envoy rewrite) 79 * and /v1/ for production (Envoy rewrites /v1/ → /v2/). 80 */ 81export function buildSdkUrl(apiBaseUrl: string, sessionId: string): string { 82 const isLocalhost = isLocalhostBaseUrl(apiBaseUrl) 83 const protocol = isLocalhost ? 'ws' : 'wss' 84 const version = isLocalhost ? 'v2' : 'v1' 85 const host = apiBaseUrl.replace(/^https?:\/\//, '').replace(/\/+$/, '') 86 return `${protocol}://${host}/${version}/session_ingress/ws/${sessionId}` 87} 88 89/** 90 * Compare two session IDs regardless of their tagged-ID prefix. 91 * 92 * Tagged IDs have the form {tag}_{body} or {tag}_staging_{body}, where the 93 * body encodes a UUID. CCR v2's compat layer returns `session_*` to v1 API 94 * clients (compat/convert.go:41) but the infrastructure layer (sandbox-gateway 95 * work queue, work poll response) uses `cse_*` (compat/CLAUDE.md:13). Both 96 * have the same underlying UUID. 97 * 98 * Without this, replBridge rejects its own session as "foreign" at the 99 * work-received check when the ccr_v2_compat_enabled gate is on. 100 */ 101export function sameSessionId(a: string, b: string): boolean { 102 if (a === b) return true 103 // The body is everything after the last underscore — this handles both 104 // `{tag}_{body}` and `{tag}_staging_{body}`. 105 const aBody = a.slice(a.lastIndexOf('_') + 1) 106 const bBody = b.slice(b.lastIndexOf('_') + 1) 107 // Guard against IDs with no underscore (bare UUIDs): lastIndexOf returns -1, 108 // slice(0) returns the whole string, and we already checked a === b above. 109 // Require a minimum length to avoid accidental matches on short suffixes 110 // (e.g. single-char tag remnants from malformed IDs). 111 return aBody.length >= 4 && aBody === bBody 112} 113 114/** 115 * Build a CCR v2 session URL from the API base URL and session ID. 116 * Unlike buildSdkUrl, this returns an HTTP(S) URL (not ws://) and points at 117 * /v1/code/sessions/{id} — the child CC will derive the SSE stream path 118 * and worker endpoints from this base. 119 */ 120export function buildCCRv2SdkUrl( 121 apiBaseUrl: string, 122 sessionId: string, 123): string { 124 const base = apiBaseUrl.replace(/\/+$/, '') 125 return `${base}/v1/code/sessions/${sessionId}` 126} 127 128/** 129 * Register this bridge as the worker for a CCR v2 session. 130 * Returns the worker_epoch, which must be passed to the child CC process 131 * so its CCRClient can include it in every heartbeat/state/event request. 132 * 133 * Mirrors what environment-manager does in the container path 134 * (api-go/environment-manager/cmd/cmd_task_run.go RegisterWorker). 135 */ 136export async function registerWorker( 137 sessionUrl: string, 138 accessToken: string, 139): Promise<number> { 140 const response = await axios.post( 141 `${sessionUrl}/worker/register`, 142 {}, 143 { 144 headers: { 145 Authorization: `Bearer ${accessToken}`, 146 'Content-Type': 'application/json', 147 'anthropic-version': '2023-06-01', 148 }, 149 timeout: 10_000, 150 }, 151 ) 152 // protojson serializes int64 as a string to avoid JS number precision loss; 153 // the Go side may also return a number depending on encoder settings. 154 const raw = response.data?.worker_epoch 155 const epoch = typeof raw === 'string' ? Number(raw) : raw 156 if ( 157 typeof epoch !== 'number' || 158 !Number.isFinite(epoch) || 159 !Number.isSafeInteger(epoch) 160 ) { 161 throw new Error( 162 `registerWorker: invalid worker_epoch in response: ${jsonStringify(response.data)}`, 163 ) 164 } 165 return epoch
▸消息去重与控制请求
Ingress 处理把 control_response、control_request 和 SDKMessage 分开。src/bridge/bridgeMessaging.ts:143 先处理 control_response,src/bridge/bridgeMessaging.ts:151 再处理 control_request,src/bridge/bridgeMessaging.ts:171 和 src/bridge/bridgeMessaging.ts:183 分别处理 echo 与重放。
124// ─── Ingress routing ───────────────────────────────────────────────────────── 125 126/** 127 * Parse an ingress WebSocket message and route it to the appropriate handler. 128 * Ignores messages whose UUID is in recentPostedUUIDs (echoes of what we sent) 129 * or in recentInboundUUIDs (re-deliveries we've already forwarded — e.g. 130 * server replayed history after a transport swap lost the seq-num cursor). 131 */ 132export function handleIngressMessage( 133 data: string, 134 recentPostedUUIDs: BoundedUUIDSet, 135 recentInboundUUIDs: BoundedUUIDSet, 136 onInboundMessage: ((msg: SDKMessage) => void | Promise<void>) | undefined, 137 onPermissionResponse?: ((response: SDKControlResponse) => void) | undefined, 138 onControlRequest?: ((request: SDKControlRequest) => void) | undefined, 139): void { 140 try { 141 const parsed: unknown = normalizeControlMessageKeys(jsonParse(data)) 142 143 // control_response is not an SDKMessage — check before the type guard 144 if (isSDKControlResponse(parsed)) { 145 const controlResponse = parsed as SDKControlResponse 146 logForDebugging('[bridge:repl] Ingress message type=control_response') 147 onPermissionResponse?.(controlResponse) 148 return 149 } 150 151 // control_request from the server (initialize, set_model, can_use_tool). 152 // Must respond promptly or the server kills the WS (~10-14s timeout). 153 if (isSDKControlRequest(parsed)) { 154 const controlRequest = parsed as SDKControlRequest 155 logForDebugging( 156 `[bridge:repl] Inbound control_request subtype=${controlRequest.request.subtype}`, 157 ) 158 onControlRequest?.(controlRequest) 159 return 160 } 161 162 if (!isSDKMessage(parsed)) return 163 const sdkMessage = parsed as SDKMessage 164 165 // Check for UUID to detect echoes of our own messages 166 const uuid = 167 'uuid' in sdkMessage && typeof sdkMessage.uuid === 'string' 168 ? sdkMessage.uuid 169 : undefined 170 171 if (uuid && recentPostedUUIDs.has(uuid)) { 172 logForDebugging( 173 `[bridge:repl] Ignoring echo: type=${sdkMessage.type} uuid=${uuid}`, 174 ) 175 return 176 } 177 178 // Defensive dedup: drop inbound prompts we've already forwarded. The 179 // SSE seq-num carryover (lastTransportSequenceNum) is the primary fix 180 // for history-replay; this catches edge cases where that negotiation 181 // fails (server ignores from_sequence_num, transport died before 182 // receiving any frames, etc). 183 if (uuid && recentInboundUUIDs.has(uuid)) { 184 logForDebugging( 185 `[bridge:repl] Ignoring re-delivered inbound: type=${sdkMessage.type} uuid=${uuid}`, 186 ) 187 return 188 } 189 190 logForDebugging( 191 `[bridge:repl] Ingress message type=${sdkMessage.type}${uuid ? ` uuid=${uuid}` : ''}`, 192 ) 193 194 if (sdkMessage.type === 'user') { 195 if (uuid) recentInboundUUIDs.add(uuid) 196 logEvent('tengu_bridge_message_received', { 197 is_repl: true, 198 }) 199 // Fire-and-forget — handler may be async (attachment resolution). 200 void onInboundMessage?.(sdkMessage) 201 } else { 202 logForDebugging( 203 `[bridge:repl] Ignoring non-user inbound message: type=${sdkMessage.type}`, 204 ) 205 } 206 } catch (err) { 207 logForDebugging( 208 `[bridge:repl] Failed to parse ingress message: ${errorMessage(err)}`, 209 ) 210 } 211}
▸逐行证据
Bridge 子页面 的证据点 src/bridge/sessionRunner.ts:24 不是孤立行号:这一处把状态、权限或传输边界固定在源码里,和相邻模块形成可测试契约;阅读时应把它当成调用链的一段,而不是 README 里的营销描述。
Bridge 子页面 的证据点 src/bridge/sessionRunner.ts:56 不是孤立行号:这一处把状态、权限或传输边界固定在源码里,和相邻模块形成可测试契约;阅读时应把它当成调用链的一段,而不是 README 里的营销描述。
Bridge 子页面 的证据点 src/bridge/sessionRunner.ts:72 不是孤立行号:这一处把状态、权限或传输边界固定在源码里,和相邻模块形成可测试契约;阅读时应把它当成调用链的一段,而不是 README 里的营销描述。
Bridge 子页面 的证据点 src/bridge/sessionRunner.ts:87 不是孤立行号:这一处把状态、权限或传输边界固定在源码里,和相邻模块形成可测试契约;阅读时应把它当成调用链的一段,而不是 README 里的营销描述。
Bridge 子页面 的证据点 src/bridge/sessionRunner.ts:96 不是孤立行号:这一处把状态、权限或传输边界固定在源码里,和相邻模块形成可测试契约;阅读时应把它当成调用链的一段,而不是 README 里的营销描述。
Bridge 子页面 的证据点 src/bridge/sessionRunner.ts:132 不是孤立行号:这一处把状态、权限或传输边界固定在源码里,和相邻模块形成可测试契约;阅读时应把它当成调用链的一段,而不是 README 里的营销描述。
Bridge 子页面 的证据点 src/bridge/sessionRunner.ts:170 不是孤立行号:这一处把状态、权限或传输边界固定在源码里,和相邻模块形成可测试契约;阅读时应把它当成调用链的一段,而不是 README 里的营销描述。
Bridge 子页面 的证据点 src/bridge/sessionRunner.ts:201 不是孤立行号:这一处把状态、权限或传输边界固定在源码里,和相邻模块形成可测试契约;阅读时应把它当成调用链的一段,而不是 README 里的营销描述。
Bridge 子页面 的证据点 src/bridge/sessionRunner.ts:247 不是孤立行号:这一处把状态、权限或传输边界固定在源码里,和相邻模块形成可测试契约;阅读时应把它当成调用链的一段,而不是 README 里的营销描述。
Bridge 子页面 的证据点 src/bridge/sessionRunner.ts:310 不是孤立行号:这一处把状态、权限或传输边界固定在源码里,和相邻模块形成可测试契约;阅读时应把它当成调用链的一段,而不是 README 里的营销描述。
Bridge 子页面 的证据点 src/bridge/replBridge.ts:92 不是孤立行号:这一处把状态、权限或传输边界固定在源码里,和相邻模块形成可测试契约;阅读时应把它当成调用链的一段,而不是 README 里的营销描述。
Bridge 子页面 的证据点 src/bridge/replBridge.ts:120 不是孤立行号:这一处把状态、权限或传输边界固定在源码里,和相邻模块形成可测试契约;阅读时应把它当成调用链的一段,而不是 README 里的营销描述。
Bridge 子页面 的证据点 src/bridge/replBridge.ts:151 不是孤立行号:这一处把状态、权限或传输边界固定在源码里,和相邻模块形成可测试契约;阅读时应把它当成调用链的一段,而不是 README 里的营销描述。
Bridge 子页面 的证据点 src/bridge/replBridge.ts:166 不是孤立行号:这一处把状态、权限或传输边界固定在源码里,和相邻模块形成可测试契约;阅读时应把它当成调用链的一段,而不是 README 里的营销描述。
Bridge 子页面 的证据点 src/bridge/replBridge.ts:183 不是孤立行号:这一处把状态、权限或传输边界固定在源码里,和相邻模块形成可测试契约;阅读时应把它当成调用链的一段,而不是 README 里的营销描述。
Bridge 子页面 的证据点 src/bridge/replBridge.ts:193 不是孤立行号:这一处把状态、权限或传输边界固定在源码里,和相邻模块形成可测试契约;阅读时应把它当成调用链的一段,而不是 README 里的营销描述。
Bridge 子页面 的证据点 src/bridge/replBridge.ts:214 不是孤立行号:这一处把状态、权限或传输边界固定在源码里,和相邻模块形成可测试契约;阅读时应把它当成调用链的一段,而不是 README 里的营销描述。
Bridge 子页面 的证据点 src/bridge/bridgeMessaging.ts:36 不是孤立行号:这一处把状态、权限或传输边界固定在源码里,和相邻模块形成可测试契约;阅读时应把它当成调用链的一段,而不是 README 里的营销描述。
Bridge 子页面 的证据点 src/bridge/bridgeMessaging.ts:77 不是孤立行号:这一处把状态、权限或传输边界固定在源码里,和相邻模块形成可测试契约;阅读时应把它当成调用链的一段,而不是 README 里的营销描述。
Bridge 子页面 的证据点 src/bridge/bridgeMessaging.ts:103 不是孤立行号:这一处把状态、权限或传输边界固定在源码里,和相邻模块形成可测试契约;阅读时应把它当成调用链的一段,而不是 README 里的营销描述。
Bridge 子页面 的证据点 src/bridge/bridgeMessaging.ts:132 不是孤立行号:这一处把状态、权限或传输边界固定在源码里,和相邻模块形成可测试契约;阅读时应把它当成调用链的一段,而不是 README 里的营销描述。
Bridge 子页面 的证据点 src/bridge/bridgeMessaging.ts:143 不是孤立行号:这一处把状态、权限或传输边界固定在源码里,和相邻模块形成可测试契约;阅读时应把它当成调用链的一段,而不是 README 里的营销描述。
Bridge 子页面 的证据点 src/bridge/bridgeMessaging.ts:151 不是孤立行号:这一处把状态、权限或传输边界固定在源码里,和相邻模块形成可测试契约;阅读时应把它当成调用链的一段,而不是 README 里的营销描述。
Bridge 子页面 的证据点 src/bridge/bridgeMessaging.ts:171 不是孤立行号:这一处把状态、权限或传输边界固定在源码里,和相邻模块形成可测试契约;阅读时应把它当成调用链的一段,而不是 README 里的营销描述。
Bridge 子页面 的证据点 src/bridge/bridgeMessaging.ts:183 不是孤立行号:这一处把状态、权限或传输边界固定在源码里,和相邻模块形成可测试契约;阅读时应把它当成调用链的一段,而不是 README 里的营销描述。
Bridge 子页面 的证据点 src/bridge/bridgeMessaging.ts:194 不是孤立行号:这一处把状态、权限或传输边界固定在源码里,和相邻模块形成可测试契约;阅读时应把它当成调用链的一段,而不是 README 里的营销描述。
Bridge 子页面 的证据点 src/bridge/bridgeMessaging.ts:206 不是孤立行号:这一处把状态、权限或传输边界固定在源码里,和相邻模块形成可测试契约;阅读时应把它当成调用链的一段,而不是 README 里的营销描述。
Bridge 子页面 的证据点 src/bridge/workSecret.ts:5 不是孤立行号:这一处把状态、权限或传输边界固定在源码里,和相邻模块形成可测试契约;阅读时应把它当成调用链的一段,而不是 README 里的营销描述。
Bridge 子页面 的证据点 src/bridge/workSecret.ts:44 不是孤立行号:这一处把状态、权限或传输边界固定在源码里,和相邻模块形成可测试契约;阅读时应把它当成调用链的一段,而不是 README 里的营销描述。
Bridge 子页面 的证据点 src/bridge/workSecret.ts:64 不是孤立行号:这一处把状态、权限或传输边界固定在源码里,和相邻模块形成可测试契约;阅读时应把它当成调用链的一段,而不是 README 里的营销描述。
Bridge 子页面 的证据点 src/bridge/workSecret.ts:81 不是孤立行号:这一处把状态、权限或传输边界固定在源码里,和相邻模块形成可测试契约;阅读时应把它当成调用链的一段,而不是 README 里的营销描述。
Bridge 子页面 的证据点 src/bridge/workSecret.ts:101 不是孤立行号:这一处把状态、权限或传输边界固定在源码里,和相邻模块形成可测试契约;阅读时应把它当成调用链的一段,而不是 README 里的营销描述。
Bridge 子页面 的证据点 src/bridge/workSecret.ts:136 不是孤立行号:这一处把状态、权限或传输边界固定在源码里,和相邻模块形成可测试契约;阅读时应把它当成调用链的一段,而不是 README 里的营销描述。
Bridge 子页面 的证据点 src/bridge/sessionRunner.test.ts:13 不是孤立行号:这一处把状态、权限或传输边界固定在源码里,和相邻模块形成可测试契约;阅读时应把它当成调用链的一段,而不是 README 里的营销描述。
Bridge 子页面 的证据点 src/bridge/sessionRunner.test.ts:34 不是孤立行号:这一处把状态、权限或传输边界固定在源码里,和相邻模块形成可测试契约;阅读时应把它当成调用链的一段,而不是 README 里的营销描述。
Bridge 子页面 的证据点 src/bridge/sessionRunner.test.ts:78 不是孤立行号:这一处把状态、权限或传输边界固定在源码里,和相邻模块形成可测试契约;阅读时应把它当成调用链的一段,而不是 README 里的营销描述。