Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions DEV-LOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,39 @@
# DEV-LOG

## Enable Remote Control / BRIDGE_MODE (2026-04-03)

**PR**: [claude-code-best/claude-code#60](https://github.com/claude-code-best/claude-code/pull/60)

Remote Control 功能将本地 CLI 注册为 bridge 环境,生成可分享的 URL(`https://claude.ai/code/session_xxx`),允许从浏览器、手机或其他设备远程查看输出、发送消息、审批工具调用。

**改动文件:**

| 文件 | 变更 |
|------|------|
| `scripts/dev.ts` | `DEFAULT_FEATURES` 加入 `"BRIDGE_MODE"`,dev 模式默认启用 |
| `src/bridge/peerSessions.ts` | stub → 完整实现:通过 bridge API 发送跨会话消息,含三层安全防护(trim + validateBridgeId 白名单 + encodeURIComponent) |
| `src/bridge/webhookSanitizer.ts` | stub → 完整实现:正则 redact 8 类 secret(GitHub/Anthropic/AWS/npm/Slack token),先 redact 再截断,失败返回安全占位符 |
| `src/entrypoints/sdk/controlTypes.ts` | 12 个 `any` stub → `z.infer<ReturnType<typeof XxxSchema>>` 从现有 Zod schema 推导类型 |
| `src/hooks/useReplBridge.tsx` | `tengu_bridge_system_init` 默认值 `false` → `true`,使 app 端显示 "active" 而非卡在 "connecting" |

**关键设计决策:**

1. **不改现有代码逻辑** — 只补全 stub、修正默认值、开启编译开关
2. **`tengu_bridge_system_init`** — Anthropic 通过 GrowthBook 给订阅用户推送 `true`,但我们的 build 收不到推送;改默认值是唯一不侵入其他代码的方案
3. **`peerSessions.ts` 认证** — 使用 `getBridgeAccessToken()` 获取 OAuth Bearer token,与 `bridgeApi.ts`/`codeSessionApi.ts` 认证模式一致
4. **`webhookSanitizer.ts` 安全** — fail-closed(出错返回 `[webhook content redacted due to sanitization error]`),不泄露原始内容

**验证结果:**

- `/remote-control` 命令可见且可用
- CLI 连接 Anthropic CCR,生成可分享 URL
- App 端(claude.ai/code)显示 "Remote Control active"
- 手机端(Claude iOS app)通过 URL 连接,双向消息正常

![Remote Control on Mobile](docs/images/remote-control-mobile.png)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Verify the referenced image exists and check for related test screenshots

# Check if the referenced image exists
fd -t f "remote-control-mobile.png" docs/images/

# List all images in docs/images to see what test screenshots are available
echo "=== All images in docs/images/ ==="
fd -t f . docs/images/ --exec ls -lh {}

Repository: claude-code-best/claude-code

Length of output: 839


Add the missing screenshot or remove the broken image reference.

The documentation at line 33 references docs/images/remote-control-mobile.png, but this file does not exist in the repository. Either add the screenshot to docs/images/ as requested in the original review ("测试的截图"), or remove the broken image reference from the documentation.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@DEV-LOG.md` at line 33, The markdown references a missing image
"docs/images/remote-control-mobile.png" in DEV-LOG.md; fix by either adding the
requested screenshot file named remote-control-mobile.png into the docs/images/
directory (use the original "测试的截图" image) or remove/replace the markdown image
line `![Remote Control on Mobile](docs/images/remote-control-mobile.png)` from
DEV-LOG.md so the broken reference is eliminated.


---

## WebSearch Bing 适配器补全 (2026-04-03)

原始 `WebSearchTool` 仅支持 Anthropic API 服务端搜索(`web_search_20250305` server tool),在非官方 API 端点(第三方代理)下搜索功能不可用。本次改动引入适配器架构,新增 Bing 搜索页面解析作为 fallback。
Expand Down
2 changes: 1 addition & 1 deletion scripts/dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ const defineArgs = Object.entries(defines).flatMap(([k, v]) => [

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

// Any env var matching FEATURE_<NAME>=1 will also enable that feature.
// e.g. FEATURE_PROACTIVE=1 bun run dev
Expand Down
87 changes: 84 additions & 3 deletions src/bridge/peerSessions.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,84 @@
// Auto-generated stub — replace with real implementation
export {};
export const postInterClaudeMessage: (target: string, message: string) => Promise<{ ok: boolean; error?: string }> = () => Promise.resolve({ ok: false });
import axios from 'axios'
import { logForDebugging } from '../utils/debug.js'
import { errorMessage } from '../utils/errors.js'
import { validateBridgeId } from './bridgeApi.js'
import { getBridgeAccessToken } from './bridgeConfig.js'
import { getReplBridgeHandle } from './replBridgeHandle.js'
import { toCompatSessionId } from './sessionIdCompat.js'

/**
* Send a plain-text message to another Claude session via the bridge API.
*
* Called by SendMessageTool when the target address scheme is "bridge:".
* Uses the current ReplBridgeHandle to derive the sender identity and
* the session ingress URL for the POST request.
*
* @param target - Target session ID (from the "bridge:<sessionId>" address)
* @param message - Plain text message content (structured messages are rejected upstream)
* @returns { ok: true } on success, { ok: false, error } on failure. Never throws.
*/
export async function postInterClaudeMessage(
target: string,
message: string,
): Promise<{ ok: true } | { ok: false; error: string }> {
try {
const handle = getReplBridgeHandle()
if (!handle) {
return { ok: false, error: 'Bridge not connected' }
}

const normalizedTarget = target.trim()
if (!normalizedTarget) {
return { ok: false, error: 'No target session specified' }
}

const accessToken = getBridgeAccessToken()
if (!accessToken) {
return { ok: false, error: 'No access token available' }
}

const compatTarget = toCompatSessionId(normalizedTarget)
// Validate against path traversal — same allowlist as bridgeApi.ts
validateBridgeId(compatTarget, 'target sessionId')
const from = toCompatSessionId(handle.bridgeSessionId)
const baseUrl = handle.sessionIngressUrl

const url = `${baseUrl}/v1/sessions/${encodeURIComponent(compatTarget)}/messages`

const response = await axios.post(
url,
{
type: 'peer_message',
from,
content: message,
},
{
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
'anthropic-version': '2023-06-01',
},
timeout: 10_000,
validateStatus: (s: number) => s < 500,
},
)

if (response.status === 200 || response.status === 204) {
logForDebugging(
`[bridge:peer] Message sent to ${compatTarget} (${response.status})`,
)
return { ok: true }
}

const detail =
typeof response.data === 'object' && response.data?.error?.message
? response.data.error.message
: `HTTP ${response.status}`
logForDebugging(`[bridge:peer] Send failed: ${detail}`)
return { ok: false, error: detail }
} catch (err: unknown) {
const msg = errorMessage(err)
logForDebugging(`[bridge:peer] postInterClaudeMessage error: ${msg}`)
return { ok: false, error: msg }
}
}
60 changes: 57 additions & 3 deletions src/bridge/webhookSanitizer.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,57 @@
// Auto-generated stub — replace with real implementation
export {};
export const sanitizeInboundWebhookContent: (content: string) => string = (content) => content;
/**
* Sanitize inbound GitHub webhook payload content before it enters the session.
*
* Called from useReplBridge.tsx when feature('KAIROS_GITHUB_WEBHOOKS') is enabled.
* Strips known secret patterns (tokens, API keys, credentials) while preserving
* the meaningful content (PR titles, descriptions, commit messages, etc.).
*
* Must be synchronous and never throw — on error, returns a safe placeholder.
*/

/** Patterns that match known secret/token formats. */
const SECRET_PATTERNS: Array<{ pattern: RegExp; replacement: string }> = [
// GitHub tokens (PAT, OAuth, App, Server-to-server)
{ pattern: /\b(ghp|gho|ghs|ghu|github_pat)_[A-Za-z0-9_]{10,}\b/g, replacement: '[REDACTED_GITHUB_TOKEN]' },
// Anthropic API keys
{ pattern: /\bsk-ant-[A-Za-z0-9_-]{10,}\b/g, replacement: '[REDACTED_ANTHROPIC_KEY]' },
// Generic Bearer tokens in headers
{ pattern: /(Bearer\s+)[A-Za-z0-9._\-/+=]{20,}/gi, replacement: '$1[REDACTED_TOKEN]' },
// AWS access keys
{ pattern: /\b(AKIA|ASIA)[A-Z0-9]{16}\b/g, replacement: '[REDACTED_AWS_KEY]' },
// AWS secret keys (40-char base64-like strings after common labels)
{ pattern: /(aws_secret_access_key|secret_key|SecretAccessKey)['":\s=]+[A-Za-z0-9/+=]{30,}/gi, replacement: '$1=[REDACTED_AWS_SECRET]' },
// Generic API key patterns (key=value or "key": "value")
{ pattern: /(api[_-]?key|apikey|secret|password|token|credential)['":\s=]+["']?[A-Za-z0-9._\-/+=]{16,}["']?/gi, replacement: '$1=[REDACTED]' },
// npm tokens
{ pattern: /\bnpm_[A-Za-z0-9]{36}\b/g, replacement: '[REDACTED_NPM_TOKEN]' },
// Slack tokens
{ pattern: /\bxox[bporas]-[A-Za-z0-9-]{10,}\b/g, replacement: '[REDACTED_SLACK_TOKEN]' },
]

/** Maximum content length before truncation (100KB). */
const MAX_CONTENT_LENGTH = 100_000

export function sanitizeInboundWebhookContent(content: string): string {
try {
if (!content) return content

let sanitized = content

// Redact known secret patterns first (before truncation to avoid
// splitting a secret across the truncation boundary)
for (const { pattern, replacement } of SECRET_PATTERNS) {
pattern.lastIndex = 0
sanitized = sanitized.replace(pattern, replacement)
}

// Truncate excessively large payloads after redaction
if (sanitized.length > MAX_CONTENT_LENGTH) {
sanitized = sanitized.slice(0, MAX_CONTENT_LENGTH) + '\n... [truncated]'
}

return sanitized
} catch {
// Never throw, never return raw content — return a safe placeholder
return '[webhook content redacted due to sanitization error]'
}
}
46 changes: 32 additions & 14 deletions src/entrypoints/sdk/controlTypes.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,34 @@
/**
* Stub: SDK Control Types (not yet published in open-source).
* Used by bridge/transport layer for the control protocol.
* SDK Control Types — inferred from Zod schemas in controlSchemas.ts / coreSchemas.ts.
*
* These types define the control protocol between the CLI bridge and the server.
* Used by bridge/transport layer, remote session manager, and CLI print/IO paths.
*/
export type SDKControlRequest = { type: string; [key: string]: unknown }
export type SDKControlResponse = { type: string; [key: string]: unknown }
export type StdoutMessage = any;
export type SDKControlInitializeRequest = any;
export type SDKControlInitializeResponse = any;
export type SDKControlMcpSetServersResponse = any;
export type SDKControlReloadPluginsResponse = any;
export type StdinMessage = any;
export type SDKPartialAssistantMessage = any;
export type SDKControlPermissionRequest = any;
export type SDKControlCancelRequest = any;
export type SDKControlRequestInner = any;
import type { z } from 'zod'
import type {
SDKControlRequestSchema,
SDKControlResponseSchema,
SDKControlInitializeRequestSchema,
SDKControlInitializeResponseSchema,
SDKControlMcpSetServersResponseSchema,
SDKControlReloadPluginsResponseSchema,
SDKControlPermissionRequestSchema,
SDKControlCancelRequestSchema,
SDKControlRequestInnerSchema,
StdoutMessageSchema,
StdinMessageSchema,
} from './controlSchemas.js'
import type { SDKPartialAssistantMessageSchema } from './coreSchemas.js'

export type SDKControlRequest = z.infer<ReturnType<typeof SDKControlRequestSchema>>
export type SDKControlResponse = z.infer<ReturnType<typeof SDKControlResponseSchema>>
export type StdoutMessage = z.infer<ReturnType<typeof StdoutMessageSchema>>
export type SDKControlInitializeRequest = z.infer<ReturnType<typeof SDKControlInitializeRequestSchema>>
export type SDKControlInitializeResponse = z.infer<ReturnType<typeof SDKControlInitializeResponseSchema>>
export type SDKControlMcpSetServersResponse = z.infer<ReturnType<typeof SDKControlMcpSetServersResponseSchema>>
export type SDKControlReloadPluginsResponse = z.infer<ReturnType<typeof SDKControlReloadPluginsResponseSchema>>
export type StdinMessage = z.infer<ReturnType<typeof StdinMessageSchema>>
export type SDKPartialAssistantMessage = z.infer<ReturnType<typeof SDKPartialAssistantMessageSchema>>
export type SDKControlPermissionRequest = z.infer<ReturnType<typeof SDKControlPermissionRequestSchema>>
export type SDKControlCancelRequest = z.infer<ReturnType<typeof SDKControlCancelRequestSchema>>
export type SDKControlRequestInner = z.infer<ReturnType<typeof SDKControlRequestInnerSchema>>
2 changes: 1 addition & 1 deletion src/hooks/useReplBridge.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -290,7 +290,7 @@ export function useReplBridge(messages: Message[], setMessages: (action: React.S
// to put system/init on the REPL-bridge wire. Skills load is
// async (memoized, cheap after REPL startup); fire-and-forget
// so the connected-state transition isn't blocked.
if (getFeatureValue_CACHED_MAY_BE_STALE('tengu_bridge_system_init', false)) {
if (getFeatureValue_CACHED_MAY_BE_STALE('tengu_bridge_system_init', true)) {
void (async () => {
try {
const skills = await getSlashCommandToolSkills(getCwd());
Expand Down