From 74e51e7e73382355efd1e12fbf128553e46c9608 Mon Sep 17 00:00:00 2001 From: unraid Date: Fri, 3 Apr 2026 03:50:36 +0800 Subject: [PATCH 1/5] feat: enable Remote Control (BRIDGE_MODE) with stub completions - Add BRIDGE_MODE to DEFAULT_FEATURES in dev.ts - Implement peerSessions.ts: cross-session messaging via bridge API - Implement webhookSanitizer.ts: redact secrets from webhook payloads - Replace any stubs in controlTypes.ts with Zod schema-inferred types - Fix tengu_bridge_system_init default to true for app "active" status Co-Authored-By: Claude Opus 4.6 (1M context) --- scripts/dev.ts | 2 +- src/bridge/peerSessions.ts | 76 +++++++++++++++++++++++++++-- src/bridge/webhookSanitizer.ts | 60 +++++++++++++++++++++-- src/entrypoints/sdk/controlTypes.ts | 46 +++++++++++------ src/hooks/useReplBridge.tsx | 2 +- 5 files changed, 164 insertions(+), 22 deletions(-) diff --git a/scripts/dev.ts b/scripts/dev.ts index 40fa6ff60..416f365cd 100644 --- a/scripts/dev.ts +++ b/scripts/dev.ts @@ -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_=1 will also enable that feature. // e.g. FEATURE_PROACTIVE=1 bun run dev diff --git a/src/bridge/peerSessions.ts b/src/bridge/peerSessions.ts index 57fa16549..dad8477f7 100644 --- a/src/bridge/peerSessions.ts +++ b/src/bridge/peerSessions.ts @@ -1,3 +1,73 @@ -// 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 { 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:" 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: boolean; error?: string }> { + try { + const handle = getReplBridgeHandle() + if (!handle) { + return { ok: false, error: 'Bridge not connected' } + } + + if (!target) { + return { ok: false, error: 'No target session specified' } + } + + const compatTarget = toCompatSessionId(target) + const from = toCompatSessionId(handle.bridgeSessionId) + const baseUrl = handle.sessionIngressUrl + + const url = `${baseUrl}/v1/sessions/${compatTarget}/messages` + + const response = await axios.post( + url, + { + type: 'peer_message', + from, + content: message, + }, + { + headers: { + '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 } + } +} diff --git a/src/bridge/webhookSanitizer.ts b/src/bridge/webhookSanitizer.ts index c32323e0f..cb48f974d 100644 --- a/src/bridge/webhookSanitizer.ts +++ b/src/bridge/webhookSanitizer.ts @@ -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 the original content. + */ + +/** 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 + + // Truncate excessively large payloads + if (sanitized.length > MAX_CONTENT_LENGTH) { + sanitized = sanitized.slice(0, MAX_CONTENT_LENGTH) + '\n... [truncated]' + } + + // Redact known secret patterns + for (const { pattern, replacement } of SECRET_PATTERNS) { + // Reset lastIndex for global regexes + pattern.lastIndex = 0 + sanitized = sanitized.replace(pattern, replacement) + } + + return sanitized + } catch { + // Never throw — return original content on any error + return content + } +} diff --git a/src/entrypoints/sdk/controlTypes.ts b/src/entrypoints/sdk/controlTypes.ts index 455c42c07..58d52cca8 100644 --- a/src/entrypoints/sdk/controlTypes.ts +++ b/src/entrypoints/sdk/controlTypes.ts @@ -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> +export type SDKControlResponse = z.infer> +export type StdoutMessage = z.infer> +export type SDKControlInitializeRequest = z.infer> +export type SDKControlInitializeResponse = z.infer> +export type SDKControlMcpSetServersResponse = z.infer> +export type SDKControlReloadPluginsResponse = z.infer> +export type StdinMessage = z.infer> +export type SDKPartialAssistantMessage = z.infer> +export type SDKControlPermissionRequest = z.infer> +export type SDKControlCancelRequest = z.infer> +export type SDKControlRequestInner = z.infer> diff --git a/src/hooks/useReplBridge.tsx b/src/hooks/useReplBridge.tsx index a70bc2b57..6a767a1cd 100644 --- a/src/hooks/useReplBridge.tsx +++ b/src/hooks/useReplBridge.tsx @@ -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()); From 1d38eae536a67bcd91431d43ca33441941524db7 Mon Sep 17 00:00:00 2001 From: unraid Date: Fri, 3 Apr 2026 04:08:04 +0800 Subject: [PATCH 2/5] fix: address CodeRabbit review findings - webhookSanitizer: redact before truncate to avoid split secrets at boundary - webhookSanitizer: return safe placeholder on error instead of raw content - peerSessions: use discriminated union return type for type safety Co-Authored-By: Claude Opus 4.6 (1M context) --- src/bridge/peerSessions.ts | 2 +- src/bridge/webhookSanitizer.ts | 20 ++++++++++---------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/src/bridge/peerSessions.ts b/src/bridge/peerSessions.ts index dad8477f7..4d792c06d 100644 --- a/src/bridge/peerSessions.ts +++ b/src/bridge/peerSessions.ts @@ -18,7 +18,7 @@ import { toCompatSessionId } from './sessionIdCompat.js' export async function postInterClaudeMessage( target: string, message: string, -): Promise<{ ok: boolean; error?: string }> { +): Promise<{ ok: true } | { ok: false; error: string }> { try { const handle = getReplBridgeHandle() if (!handle) { diff --git a/src/bridge/webhookSanitizer.ts b/src/bridge/webhookSanitizer.ts index cb48f974d..a2999b07c 100644 --- a/src/bridge/webhookSanitizer.ts +++ b/src/bridge/webhookSanitizer.ts @@ -5,7 +5,7 @@ * 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 the original content. + * Must be synchronous and never throw — on error, returns a safe placeholder. */ /** Patterns that match known secret/token formats. */ @@ -37,21 +37,21 @@ export function sanitizeInboundWebhookContent(content: string): string { let sanitized = content - // Truncate excessively large payloads - if (sanitized.length > MAX_CONTENT_LENGTH) { - sanitized = sanitized.slice(0, MAX_CONTENT_LENGTH) + '\n... [truncated]' - } - - // Redact known secret patterns + // Redact known secret patterns first (before truncation to avoid + // splitting a secret across the truncation boundary) for (const { pattern, replacement } of SECRET_PATTERNS) { - // Reset lastIndex for global regexes 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 — return original content on any error - return content + // Never throw, never return raw content — return a safe placeholder + return '[webhook content redacted due to sanitization error]' } } From 8645d37b2533afed84fedaebcd1c332212cde240 Mon Sep 17 00:00:00 2001 From: unraid Date: Fri, 3 Apr 2026 04:15:24 +0800 Subject: [PATCH 3/5] fix: add Authorization header to peer message requests getBridgeAccessToken() provides the OAuth Bearer token, matching the auth pattern used by bridgeApi.ts and codeSessionApi.ts. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/bridge/peerSessions.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/bridge/peerSessions.ts b/src/bridge/peerSessions.ts index 4d792c06d..e6a0de857 100644 --- a/src/bridge/peerSessions.ts +++ b/src/bridge/peerSessions.ts @@ -1,6 +1,7 @@ import axios from 'axios' import { logForDebugging } from '../utils/debug.js' import { errorMessage } from '../utils/errors.js' +import { getBridgeAccessToken } from './bridgeConfig.js' import { getReplBridgeHandle } from './replBridgeHandle.js' import { toCompatSessionId } from './sessionIdCompat.js' @@ -29,6 +30,11 @@ export async function postInterClaudeMessage( return { ok: false, error: 'No target session specified' } } + const accessToken = getBridgeAccessToken() + if (!accessToken) { + return { ok: false, error: 'No access token available' } + } + const compatTarget = toCompatSessionId(target) const from = toCompatSessionId(handle.bridgeSessionId) const baseUrl = handle.sessionIngressUrl @@ -44,6 +50,7 @@ export async function postInterClaudeMessage( }, { headers: { + Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json', 'anthropic-version': '2023-06-01', }, From e784f231d443e7cc66123ff093ef33301def3374 Mon Sep 17 00:00:00 2001 From: unraid Date: Fri, 3 Apr 2026 04:23:32 +0800 Subject: [PATCH 4/5] fix: validate and encode target sessionId in peer messages - Trim and normalize target before use - Validate with validateBridgeId allowlist (same as bridgeApi.ts) - URL-encode compatTarget to prevent path traversal/injection Co-Authored-By: Claude Opus 4.6 (1M context) --- src/bridge/peerSessions.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/bridge/peerSessions.ts b/src/bridge/peerSessions.ts index e6a0de857..c194c9b62 100644 --- a/src/bridge/peerSessions.ts +++ b/src/bridge/peerSessions.ts @@ -1,6 +1,7 @@ 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' @@ -26,7 +27,8 @@ export async function postInterClaudeMessage( return { ok: false, error: 'Bridge not connected' } } - if (!target) { + const normalizedTarget = target.trim() + if (!normalizedTarget) { return { ok: false, error: 'No target session specified' } } @@ -35,11 +37,13 @@ export async function postInterClaudeMessage( return { ok: false, error: 'No access token available' } } - const compatTarget = toCompatSessionId(target) + 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/${compatTarget}/messages` + const url = `${baseUrl}/v1/sessions/${encodeURIComponent(compatTarget)}/messages` const response = await axios.post( url, From 67caa5d0170c7cec569ac5a385dc82807dfa0b72 Mon Sep 17 00:00:00 2001 From: unraid Date: Fri, 3 Apr 2026 11:30:58 +0800 Subject: [PATCH 5/5] docs: add Remote Control (BRIDGE_MODE) entry to DEV-LOG Co-Authored-By: Claude Opus 4.6 (1M context) --- DEV-LOG.md | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/DEV-LOG.md b/DEV-LOG.md index f6fc5e9f6..2b4452359 100644 --- a/DEV-LOG.md +++ b/DEV-LOG.md @@ -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>` 从现有 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) + +--- + ## WebSearch Bing 适配器补全 (2026-04-03) 原始 `WebSearchTool` 仅支持 Anthropic API 服务端搜索(`web_search_20250305` server tool),在非官方 API 端点(第三方代理)下搜索功能不可用。本次改动引入适配器架构,新增 Bing 搜索页面解析作为 fallback。