Skip to content

Commit cf44cc3

Browse files
Merge branch 'pr/amDosion/60'
2 parents 9dd180d + 67caa5d commit cf44cc3

6 files changed

Lines changed: 209 additions & 22 deletions

File tree

DEV-LOG.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,39 @@
11
# DEV-LOG
22

3+
## Enable Remote Control / BRIDGE_MODE (2026-04-03)
4+
5+
**PR**: [claude-code-best/claude-code#60](https://github.com/claude-code-best/claude-code/pull/60)
6+
7+
Remote Control 功能将本地 CLI 注册为 bridge 环境,生成可分享的 URL(`https://claude.ai/code/session_xxx`),允许从浏览器、手机或其他设备远程查看输出、发送消息、审批工具调用。
8+
9+
**改动文件:**
10+
11+
| 文件 | 变更 |
12+
|------|------|
13+
| `scripts/dev.ts` | `DEFAULT_FEATURES` 加入 `"BRIDGE_MODE"`,dev 模式默认启用 |
14+
| `src/bridge/peerSessions.ts` | stub → 完整实现:通过 bridge API 发送跨会话消息,含三层安全防护(trim + validateBridgeId 白名单 + encodeURIComponent) |
15+
| `src/bridge/webhookSanitizer.ts` | stub → 完整实现:正则 redact 8 类 secret(GitHub/Anthropic/AWS/npm/Slack token),先 redact 再截断,失败返回安全占位符 |
16+
| `src/entrypoints/sdk/controlTypes.ts` | 12 个 `any` stub → `z.infer<ReturnType<typeof XxxSchema>>` 从现有 Zod schema 推导类型 |
17+
| `src/hooks/useReplBridge.tsx` | `tengu_bridge_system_init` 默认值 `false``true`,使 app 端显示 "active" 而非卡在 "connecting" |
18+
19+
**关键设计决策:**
20+
21+
1. **不改现有代码逻辑** — 只补全 stub、修正默认值、开启编译开关
22+
2. **`tengu_bridge_system_init`** — Anthropic 通过 GrowthBook 给订阅用户推送 `true`,但我们的 build 收不到推送;改默认值是唯一不侵入其他代码的方案
23+
3. **`peerSessions.ts` 认证** — 使用 `getBridgeAccessToken()` 获取 OAuth Bearer token,与 `bridgeApi.ts`/`codeSessionApi.ts` 认证模式一致
24+
4. **`webhookSanitizer.ts` 安全** — fail-closed(出错返回 `[webhook content redacted due to sanitization error]`),不泄露原始内容
25+
26+
**验证结果:**
27+
28+
- `/remote-control` 命令可见且可用
29+
- CLI 连接 Anthropic CCR,生成可分享 URL
30+
- App 端(claude.ai/code)显示 "Remote Control active"
31+
- 手机端(Claude iOS app)通过 URL 连接,双向消息正常
32+
33+
![Remote Control on Mobile](docs/images/remote-control-mobile.png)
34+
35+
---
36+
337
## GrowthBook 自定义服务器适配器 (2026-04-03)
438

539
GrowthBook 功能开关系统原为 Anthropic 内部构建设计,硬编码 SDK key 和 API 地址,外部构建因 `is1PEventLoggingEnabled()` 门控始终禁用。新增适配器模式,通过环境变量连接自定义 GrowthBook 服务器,无配置时所有 feature 读取返回代码默认值。

scripts/dev.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ const defineArgs = Object.entries(defines).flatMap(([k, v]) => [
1515

1616
// Bun --feature flags: enable feature() gates at runtime.
1717
// Default features enabled in dev mode.
18-
const DEFAULT_FEATURES = ["BUDDY", "TRANSCRIPT_CLASSIFIER"];
18+
const DEFAULT_FEATURES = ["BUDDY", "TRANSCRIPT_CLASSIFIER", "BRIDGE_MODE"];
1919

2020
// Any env var matching FEATURE_<NAME>=1 will also enable that feature.
2121
// e.g. FEATURE_PROACTIVE=1 bun run dev

src/bridge/peerSessions.ts

Lines changed: 84 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,84 @@
1-
// Auto-generated stub — replace with real implementation
2-
export {};
3-
export const postInterClaudeMessage: (target: string, message: string) => Promise<{ ok: boolean; error?: string }> = () => Promise.resolve({ ok: false });
1+
import axios from 'axios'
2+
import { logForDebugging } from '../utils/debug.js'
3+
import { errorMessage } from '../utils/errors.js'
4+
import { validateBridgeId } from './bridgeApi.js'
5+
import { getBridgeAccessToken } from './bridgeConfig.js'
6+
import { getReplBridgeHandle } from './replBridgeHandle.js'
7+
import { toCompatSessionId } from './sessionIdCompat.js'
8+
9+
/**
10+
* Send a plain-text message to another Claude session via the bridge API.
11+
*
12+
* Called by SendMessageTool when the target address scheme is "bridge:".
13+
* Uses the current ReplBridgeHandle to derive the sender identity and
14+
* the session ingress URL for the POST request.
15+
*
16+
* @param target - Target session ID (from the "bridge:<sessionId>" address)
17+
* @param message - Plain text message content (structured messages are rejected upstream)
18+
* @returns { ok: true } on success, { ok: false, error } on failure. Never throws.
19+
*/
20+
export async function postInterClaudeMessage(
21+
target: string,
22+
message: string,
23+
): Promise<{ ok: true } | { ok: false; error: string }> {
24+
try {
25+
const handle = getReplBridgeHandle()
26+
if (!handle) {
27+
return { ok: false, error: 'Bridge not connected' }
28+
}
29+
30+
const normalizedTarget = target.trim()
31+
if (!normalizedTarget) {
32+
return { ok: false, error: 'No target session specified' }
33+
}
34+
35+
const accessToken = getBridgeAccessToken()
36+
if (!accessToken) {
37+
return { ok: false, error: 'No access token available' }
38+
}
39+
40+
const compatTarget = toCompatSessionId(normalizedTarget)
41+
// Validate against path traversal — same allowlist as bridgeApi.ts
42+
validateBridgeId(compatTarget, 'target sessionId')
43+
const from = toCompatSessionId(handle.bridgeSessionId)
44+
const baseUrl = handle.sessionIngressUrl
45+
46+
const url = `${baseUrl}/v1/sessions/${encodeURIComponent(compatTarget)}/messages`
47+
48+
const response = await axios.post(
49+
url,
50+
{
51+
type: 'peer_message',
52+
from,
53+
content: message,
54+
},
55+
{
56+
headers: {
57+
Authorization: `Bearer ${accessToken}`,
58+
'Content-Type': 'application/json',
59+
'anthropic-version': '2023-06-01',
60+
},
61+
timeout: 10_000,
62+
validateStatus: (s: number) => s < 500,
63+
},
64+
)
65+
66+
if (response.status === 200 || response.status === 204) {
67+
logForDebugging(
68+
`[bridge:peer] Message sent to ${compatTarget} (${response.status})`,
69+
)
70+
return { ok: true }
71+
}
72+
73+
const detail =
74+
typeof response.data === 'object' && response.data?.error?.message
75+
? response.data.error.message
76+
: `HTTP ${response.status}`
77+
logForDebugging(`[bridge:peer] Send failed: ${detail}`)
78+
return { ok: false, error: detail }
79+
} catch (err: unknown) {
80+
const msg = errorMessage(err)
81+
logForDebugging(`[bridge:peer] postInterClaudeMessage error: ${msg}`)
82+
return { ok: false, error: msg }
83+
}
84+
}

src/bridge/webhookSanitizer.ts

Lines changed: 57 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,57 @@
1-
// Auto-generated stub — replace with real implementation
2-
export {};
3-
export const sanitizeInboundWebhookContent: (content: string) => string = (content) => content;
1+
/**
2+
* Sanitize inbound GitHub webhook payload content before it enters the session.
3+
*
4+
* Called from useReplBridge.tsx when feature('KAIROS_GITHUB_WEBHOOKS') is enabled.
5+
* Strips known secret patterns (tokens, API keys, credentials) while preserving
6+
* the meaningful content (PR titles, descriptions, commit messages, etc.).
7+
*
8+
* Must be synchronous and never throw — on error, returns a safe placeholder.
9+
*/
10+
11+
/** Patterns that match known secret/token formats. */
12+
const SECRET_PATTERNS: Array<{ pattern: RegExp; replacement: string }> = [
13+
// GitHub tokens (PAT, OAuth, App, Server-to-server)
14+
{ pattern: /\b(ghp|gho|ghs|ghu|github_pat)_[A-Za-z0-9_]{10,}\b/g, replacement: '[REDACTED_GITHUB_TOKEN]' },
15+
// Anthropic API keys
16+
{ pattern: /\bsk-ant-[A-Za-z0-9_-]{10,}\b/g, replacement: '[REDACTED_ANTHROPIC_KEY]' },
17+
// Generic Bearer tokens in headers
18+
{ pattern: /(Bearer\s+)[A-Za-z0-9._\-/+=]{20,}/gi, replacement: '$1[REDACTED_TOKEN]' },
19+
// AWS access keys
20+
{ pattern: /\b(AKIA|ASIA)[A-Z0-9]{16}\b/g, replacement: '[REDACTED_AWS_KEY]' },
21+
// AWS secret keys (40-char base64-like strings after common labels)
22+
{ pattern: /(aws_secret_access_key|secret_key|SecretAccessKey)['":\s=]+[A-Za-z0-9/+=]{30,}/gi, replacement: '$1=[REDACTED_AWS_SECRET]' },
23+
// Generic API key patterns (key=value or "key": "value")
24+
{ pattern: /(api[_-]?key|apikey|secret|password|token|credential)['":\s=]+["']?[A-Za-z0-9._\-/+=]{16,}["']?/gi, replacement: '$1=[REDACTED]' },
25+
// npm tokens
26+
{ pattern: /\bnpm_[A-Za-z0-9]{36}\b/g, replacement: '[REDACTED_NPM_TOKEN]' },
27+
// Slack tokens
28+
{ pattern: /\bxox[bporas]-[A-Za-z0-9-]{10,}\b/g, replacement: '[REDACTED_SLACK_TOKEN]' },
29+
]
30+
31+
/** Maximum content length before truncation (100KB). */
32+
const MAX_CONTENT_LENGTH = 100_000
33+
34+
export function sanitizeInboundWebhookContent(content: string): string {
35+
try {
36+
if (!content) return content
37+
38+
let sanitized = content
39+
40+
// Redact known secret patterns first (before truncation to avoid
41+
// splitting a secret across the truncation boundary)
42+
for (const { pattern, replacement } of SECRET_PATTERNS) {
43+
pattern.lastIndex = 0
44+
sanitized = sanitized.replace(pattern, replacement)
45+
}
46+
47+
// Truncate excessively large payloads after redaction
48+
if (sanitized.length > MAX_CONTENT_LENGTH) {
49+
sanitized = sanitized.slice(0, MAX_CONTENT_LENGTH) + '\n... [truncated]'
50+
}
51+
52+
return sanitized
53+
} catch {
54+
// Never throw, never return raw content — return a safe placeholder
55+
return '[webhook content redacted due to sanitization error]'
56+
}
57+
}
Lines changed: 32 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,34 @@
11
/**
2-
* Stub: SDK Control Types (not yet published in open-source).
3-
* Used by bridge/transport layer for the control protocol.
2+
* SDK Control Types — inferred from Zod schemas in controlSchemas.ts / coreSchemas.ts.
3+
*
4+
* These types define the control protocol between the CLI bridge and the server.
5+
* Used by bridge/transport layer, remote session manager, and CLI print/IO paths.
46
*/
5-
export type SDKControlRequest = { type: string; [key: string]: unknown }
6-
export type SDKControlResponse = { type: string; [key: string]: unknown }
7-
export type StdoutMessage = any;
8-
export type SDKControlInitializeRequest = any;
9-
export type SDKControlInitializeResponse = any;
10-
export type SDKControlMcpSetServersResponse = any;
11-
export type SDKControlReloadPluginsResponse = any;
12-
export type StdinMessage = any;
13-
export type SDKPartialAssistantMessage = any;
14-
export type SDKControlPermissionRequest = any;
15-
export type SDKControlCancelRequest = any;
16-
export type SDKControlRequestInner = any;
7+
import type { z } from 'zod'
8+
import type {
9+
SDKControlRequestSchema,
10+
SDKControlResponseSchema,
11+
SDKControlInitializeRequestSchema,
12+
SDKControlInitializeResponseSchema,
13+
SDKControlMcpSetServersResponseSchema,
14+
SDKControlReloadPluginsResponseSchema,
15+
SDKControlPermissionRequestSchema,
16+
SDKControlCancelRequestSchema,
17+
SDKControlRequestInnerSchema,
18+
StdoutMessageSchema,
19+
StdinMessageSchema,
20+
} from './controlSchemas.js'
21+
import type { SDKPartialAssistantMessageSchema } from './coreSchemas.js'
22+
23+
export type SDKControlRequest = z.infer<ReturnType<typeof SDKControlRequestSchema>>
24+
export type SDKControlResponse = z.infer<ReturnType<typeof SDKControlResponseSchema>>
25+
export type StdoutMessage = z.infer<ReturnType<typeof StdoutMessageSchema>>
26+
export type SDKControlInitializeRequest = z.infer<ReturnType<typeof SDKControlInitializeRequestSchema>>
27+
export type SDKControlInitializeResponse = z.infer<ReturnType<typeof SDKControlInitializeResponseSchema>>
28+
export type SDKControlMcpSetServersResponse = z.infer<ReturnType<typeof SDKControlMcpSetServersResponseSchema>>
29+
export type SDKControlReloadPluginsResponse = z.infer<ReturnType<typeof SDKControlReloadPluginsResponseSchema>>
30+
export type StdinMessage = z.infer<ReturnType<typeof StdinMessageSchema>>
31+
export type SDKPartialAssistantMessage = z.infer<ReturnType<typeof SDKPartialAssistantMessageSchema>>
32+
export type SDKControlPermissionRequest = z.infer<ReturnType<typeof SDKControlPermissionRequestSchema>>
33+
export type SDKControlCancelRequest = z.infer<ReturnType<typeof SDKControlCancelRequestSchema>>
34+
export type SDKControlRequestInner = z.infer<ReturnType<typeof SDKControlRequestInnerSchema>>

src/hooks/useReplBridge.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -290,7 +290,7 @@ export function useReplBridge(messages: Message[], setMessages: (action: React.S
290290
// to put system/init on the REPL-bridge wire. Skills load is
291291
// async (memoized, cheap after REPL startup); fire-and-forget
292292
// so the connected-state transition isn't blocked.
293-
if (getFeatureValue_CACHED_MAY_BE_STALE('tengu_bridge_system_init', false)) {
293+
if (getFeatureValue_CACHED_MAY_BE_STALE('tengu_bridge_system_init', true)) {
294294
void (async () => {
295295
try {
296296
const skills = await getSlashCommandToolSkills(getCwd());

0 commit comments

Comments
 (0)