Compare commits

...

44 Commits

Author SHA1 Message Date
unraid
379928fa10 fix: prevent agent communication bounds from hiding CI regressions
Tighten the UDS auth, framing, and response-reader boundaries while keeping the AgentSummary lifecycle covered so Codecov and CI fail on real regressions instead of missing coverage. The poorMode settings mock mirrors unrelated real settings defaults to avoid Bun mock retention changing later permission tests.

Constraint: PR #369 must fix Codecov/CI precisely without warning suppression, fallback masking, or mock pollution

Rejected: Delete AgentSummary lifecycle coverage | would hide Codecov loss and stale-summary behavior

Rejected: Store inline UDS rejection in a hidden input sentinel | cloned observable inputs can drop it and bypass rejection

Rejected: Ignore malformed UDS frames until timeout | leaves client slots and SendMessage calls open to exhaustion

Confidence: high

Scope-risk: moderate

Directive: Keep empty #token= markers rejected; do not require a non-empty token value in hasInlineUdsToken

Tested: bun test packages/builtin-tools/src/tools/SendMessageTool/__tests__/udsRecipientSanitization.test.ts src/utils/__tests__/udsMessaging.test.ts src/utils/__tests__/udsResponseReader.test.ts src/utils/__tests__/ndjsonFramer.test.ts

Tested: bunx tsc --noEmit --pretty false

Tested: bun run lint

Tested: bun test --coverage --coverage-reporter lcov --coverage-dir coverage

Tested: bun run test:all

Tested: bun audit

Tested: bun run build

Tested: bun run build:vite

Not-tested: GitHub-hosted Codecov upload until pushed PR checks rerun
2026-04-27 14:51:22 +08:00
unraid
ee0d788e58 fix: harden bounded agent communication review fixes
CodeRabbit and Codecov surfaced real gaps in UDS framing, peer discovery, mailbox retention, and summary context coverage. This tightens those paths without suppressing review or coverage signals.

Constraint: PR #369 must address CodeRabbit and Codecov findings without warning suppression or fake fallbacks

Rejected: Suppress Codecov or CodeRabbit warnings | leaves real receive-path and test-isolation gaps

Rejected: Add unreachable feature-gated tests | bun:bundle keeps those branches compile-time gated in local tests

Confidence: high

Scope-risk: moderate

Directive: Keep UDS auth-token rejection outside feature flags; do not reintroduce inline token fallbacks

Tested: bun test --coverage --coverage-reporter lcov --coverage-dir coverage; bun run test:all; bun run lint; bun run build; bun run build:vite; bun audit; git diff --cached --check

Not-tested: Remote Codecov/CodeRabbit refreshed reports until pushed
2026-04-27 10:32:18 +08:00
unraid
f353eb056a fix: bound agent communication memory growth
UDS messaging now uses private local capabilities instead of exposing auth tokens through SDK metadata, environment variables, session registry, peer listing, or tool output. The receive path bounds NDJSON frames, response buffers, active clients, and pending inbox bytes, and strips auth metadata before messages enter the prompt queue.

Teammate mailboxes now validate file and message sizes, fail closed on corrupt mutation inputs, compact by count and retained bytes, and use stable message identity for in-process acknowledgements. Agent summaries now fork only a bounded recent context using lazy size estimation and content fingerprints instead of retaining or serializing unbounded histories.

Constraint: PR #361 was already merged; this branch is based on upstream/main@c2ac9a74.
Rejected: Default-disabling COORDINATOR_MODE/TEAMMEM only | explicit feature enablement still hit unbounded paths.
Rejected: Persisting UDS auth in SDK/env/session registry | bridge/remote metadata can leak local capability secrets.
Rejected: Inline uds #token addresses | observable/tool/classifier paths can reflect raw addresses outside the UDS request frame.
Rejected: Positional mailbox marking after compaction | compaction can shift indices across the lock boundary.
Confidence: high
Scope-risk: moderate
Directive: Do not expose UDS capability tokens through SDK messages, environment variables, session registry, peer-list output, or SendMessage result/classifier surfaces.
Directive: Do not reintroduce positional mailbox acknowledgements unless compaction is removed or read+mark is atomic under one lock.
Tested: bun test src/utils/__tests__/ndjsonFramer.test.ts src/utils/__tests__/udsMessaging.test.ts packages/builtin-tools/src/tools/SendMessageTool/__tests__/udsRecipientSanitization.test.ts
Tested: bunx tsc --noEmit --pretty false
Tested: bun run lint
Tested: bunx biome lint modified src/package files
Tested: bun run test:all (3704 pass, 0 fail, 6734 expects)
Tested: bun audit (No vulnerabilities found)
Tested: bun run build
Tested: bun run build:vite
Tested: git diff --check
Not-tested: End-to-end external UDS client driving a full production headless model turn.
2026-04-26 21:44:42 +08:00
Dosion
c2ac9a74c1 fix: resolve dependency audit findings precisely (#361)
* fix: harden ACP communication boundaries

Harden ACP communication boundaries

Remote ACP sessions now cannot widen permission mode through untrusted
metadata or client payloads. WebSocket ACP ingress measures payloads by bytes
before binary decode, and prompt queue handoff keeps exactly one prompt active
while queued prompts are drained FIFO.

Constraint: ACP remote clients must not be able to open bypassPermissions without local launch intent
Constraint: WebSocket payload limits must be byte-based and checked before binary decode
Rejected: Keep promptToQueryContent wrapper | no production consumers remained after prompt conversion single-sourcing
Confidence: high
Scope-risk: moderate
Directive: Do not re-enable remote bypassPermissions from _meta unless a local launch gate is verified in both acp-link and agent
Tested: targeted ACP/RCS/acp-link prompt queue, bridge, permission, payload, and prompt conversion tests; bun run typecheck; bun run build
Not-tested: Manual live ACP/RCS session against an external client

* fix: restore repository verification gates

Keep the full repository test, typecheck, build, and Biome lint gates usable
after the ACP fix pass. This commit is intentionally separate from the ACP
behavior change: it fixes Windows-safe Langfuse home redaction, removes stale
lint suppressions, resolves Biome warning/info diagnostics, and keeps env
expansion tests explicit without template-placeholder lint noise.

Constraint: The project completion contract requires full typecheck, lint, test, and build evidence
Rejected: Leave warning/info diagnostics as historical noise | they obscure future gate regressions and weaken flow-impact claims
Confidence: high
Scope-risk: narrow
Directive: Keep repository gate cleanup separate from feature fixes when it is not part of the same runtime path
Tested: bunx biome lint src/; bunx tsc --noEmit; bun test src/services/mcp/__tests__/envExpansion.test.ts src/utils/__tests__/sliceAnsi.test.ts src/utils/__tests__/stringUtils.test.ts; bun test; bun run build
Not-tested: Manual Langfuse export against a real external Langfuse service

* fix: harden ACP failure boundaries after review

Deep review found several paths that made ACP communication failures look normal: prompt errors could finish as end_turn, permission pipeline exceptions could fall through to client approval, tool rawInput was deep-copied with JSON, and acp-link accepted unbounded or unvalidated WebSocket payloads. This keeps the behavior fail-closed, validates WS payloads before dispatch, caps payload size before JSON parse, and preserves cancellation intent with a generation counter.

Constraint: User explicitly rejected pseudo-fixes, fallback behavior, and unbounded payload handling

Rejected: Keep JSON stringify/parse rawInput copy | duplicates large payloads and silently drops non-JSON inputs

Rejected: Delegate permission pipeline errors to client approval | allows a broken local permission check to be bypassed

Confidence: high

Scope-risk: moderate

Directive: Do not convert ACP errors into normal end_turn responses without a protocol-level reason and regression tests

Tested: bun test src/services/acp/__tests__/agent.test.ts src/services/acp/__tests__/bridge.test.ts src/services/acp/__tests__/permissions.test.ts

Tested: bun test packages/acp-link/src/__tests__/server.test.ts

Tested: bunx tsc --noEmit

Tested: bunx biome lint src/ packages/acp-link/src/

Tested: bun run test:all

Tested: bun run build

Not-tested: Manual end-to-end ACP client session over a real editor WebSocket

* fix: prevent ACP coverage runs from seeing partial mocks

GitHub Actions failed under bun test --coverage because permissions.test.ts replaced ../bridge.js with a partial mock that omitted forwardSessionUpdates. Coverage worker ordering on Linux let sibling tests observe that incomplete module.

This isolates ACP test mocks by snapshotting real exports, overriding only requested symbols, and restoring mocks in LIFO order. The shared helper also keeps the same behavior in agent.test.ts without duplicating mock infrastructure.

Constraint: bun:test mock.module is process-global inside a worker.

Rejected: Add fallback exports or production guards | the bridge export exists; the failure was test mock pollution.

Rejected: Keep per-file helper copies | duplication would let restore semantics drift again.

Confidence: high

Scope-risk: narrow

Directive: Prefer safeMockModule for partial mocks of real modules in ACP tests; plain mock.module is only appropriate for fully synthetic modules or isolated tests.

Tested: bun test src/services/acp/__tests__/agent.test.ts src/services/acp/__tests__/bridge.test.ts src/services/acp/__tests__/permissions.test.ts

Tested: bun test --coverage --coverage-reporter=lcov

Tested: bunx tsc --noEmit

Tested: bun run lint

Tested: git diff --check

Not-tested: Linux runner directly before push

* fix: normalize ACP bypass requests without warning noise

The previous CI repair removed the failing partial bridge mock, but it also added a shared safeMockModule helper and left the acp-link bypass normalization warning in the real new_session path.

This tightens the fix: acp-link now treats an unauthorized client bypass request as normal permission-mode normalization without emitting a warning, and the ACP permission test explicitly preserves the real bridge and permission exports instead of using a shared helper. The agent test keeps its local mock preservation but names it by behavior and restores mocks in LIFO order.

Constraint: CI output should not contain expected warning noise for covered policy branches.

Rejected: Silence the test only | the normal new_session path would still warn for an expected normalization branch.

Rejected: Keep the shared safeMockModule helper | the failing module was specific and should be fixed by preserving real exports at the mocking site.

Confidence: high

Scope-risk: narrow

Directive: Treat client-requested bypassPermissions as data to normalize unless the local default explicitly enables bypass.

Tested: bun test packages/acp-link/src/__tests__/server.test.ts

Tested: bun test src/services/acp/__tests__/agent.test.ts src/services/acp/__tests__/bridge.test.ts src/services/acp/__tests__/permissions.test.ts

Tested: bun test --coverage --coverage-reporter=lcov with UPPER_WARN_COUNT=0

Tested: bun run test:all

Tested: bun run lint

Tested: bunx tsc --noEmit

Tested: git diff --check

* fix: harden ACP bypass and CI warning gates

ACP clients must not be able to enter bypassPermissions unless the local ACP gate and process environment both allow it. The same gate now controls session creation, explicit mode changes, and the ExitPlanMode option list, while session setup restores process.cwd so coverage and later work do not inherit ACP session state.

Constraint: CI must stay warning-clean without hiding real ACP permission failures

Rejected: Logging rejected bypass requests on the normal new_session path | it preserves audit text but reintroduces warning noise the runtime should not emit

Rejected: Broad CI=true postinstall skip | it hides explicit Chrome MCP setup checks outside the install path

Confidence: high

Scope-risk: moderate

Directive: Keep bypassPermissions gated through one ACP availability decision before exposing it to clients

Tested: bun test src/services/acp/__tests__/permissions.test.ts src/services/acp/__tests__/agent.test.ts packages/acp-link/src/__tests__/server.test.ts

Tested: bun run test:all

Tested: bun run lint

Tested: bun run build:vite with zero warning matches

Tested: bun test --coverage --coverage-reporter lcov --coverage-dir coverage produced non-empty lcov with SF records and zero filtered warning matches

Not-tested: GitHub Actions result after this push

* fix: remove remaining CI warning noise

The CI log still had three non-failing warnings after the ACP hardening commit: git init default-branch advice from checkout, a Node 20 action-runtime deprecation, and one additional known Vite dynamic-import diagnostic that only surfaced on Linux. The workflow now provides explicit git config and opts actions into Node 24, while Vite keeps a narrow allowlist for acknowledged optimizer diagnostics.

Constraint: Do not use shell log filtering to hide warnings after they happen

Rejected: Grep warning lines out of CI output | it would make future diagnostics harder to find

Confidence: high

Scope-risk: narrow

Directive: Add new Vite warning allowlist entries only after checking that they are existing optimizer diagnostics, not new application defects

Tested: bunx tsc --noEmit --pretty false

Tested: bunx biome lint .github/workflows/ci.yml vite.config.ts

Tested: bun run build:vite with zero warning matches

Not-tested: GitHub Actions result after this push

* fix: reject unauthorized ACP bypass and harden CI actions

ACP clients now fail closed when permissionMode is malformed, unknown, or requests bypass without a local bypass opt-in. acp-link validates new_session input before forwarding to the agent and returns client error frames for expected unauthorized requests without logging create-failed noise. The direct AcpAgent path independently rejects invalid _meta.permissionMode and unauthorized bypass instead of falling back to settings.

CI workflows and generated GitHub App templates now use Node 24-compatible actions pinned to immutable commit SHAs, and acp-link startup output no longer prints the auth token.

Constraint: Must not hide warnings with test isolation or log filtering

Rejected: Silent fallback to local permission mode | accepts invalid client intent and masks boundary behavior

Rejected: Broad dependency churn from bun update | audit remained failing while package and lockfile churn expanded scope

Confidence: high

Scope-risk: moderate

Directive: Client-provided permissionMode must stay fail-closed before reaching AcpAgent; only local settings.defaultMode may fall back to default on invalid local config

Tested: bun test packages/acp-link/src/__tests__/server.test.ts src/services/acp/__tests__/agent.test.ts src/services/acp/__tests__/permissions.test.ts src/services/skillLearning/__tests__/skillLifecycle.test.ts src/utils/settings/__tests__/config.test.ts

Tested: bunx tsc -p packages/acp-link/tsconfig.json --noEmit --pretty false

Tested: bunx tsc --noEmit --pretty false

Tested: bun run lint

Tested: bun run test:all

Tested: local CI equivalent install/typecheck/coverage/build with warning_scan=0

Not-tested: Pre-existing bun audit vulnerabilities require a separate dependency-hardening PR

* fix: resolve dependency audit findings precisely

Use dependency-native upgrades and lockfile resolution to close the audit findings without suppressions. Keep the chrome MCP setup aligned with the new dependency graph and add real integration coverage so the override behavior stays verified.

Constraint: no audit ignores or warning suppression
Rejected: broad google-auth/protobuf overrides | replaced with upstream-compatible resolution
Confidence: high
Scope-risk: moderate
Directive: keep dependency fixes upstream-compatible; do not reintroduce blanket overrides unless the audit surface changes materially
Tested: bun audit; bun audit --json; bun install --frozen-lockfile with CLAUDE_CODE_SKIP_CHROME_MCP_SETUP=1; bunx tsc --noEmit --pretty false; bun run lint; targeted tests; bun run test:all; bun test --coverage --coverage-reporter lcov --coverage-dir coverage; bun run build:vite
Not-tested: unrelated pre-existing ACP/CORS/token fallback residual risks

* fix: keep ACP auth tokens out of URLs

Replace the ad hoc URL-token flow with crypto UUID-backed transport identifiers so the bearer token stays in structured request data instead of query strings. Keep the server, web client, and transport helpers aligned so the ACP/RCS handshake remains compatible after the API shape change.

Constraint: token must not be embedded in the URL
Rejected: token-as-uuid query fallback | leaked bearer tokens in URLs
Confidence: high
Scope-risk: moderate
Directive: preserve the structured auth path; do not reintroduce query-token fallback when adjusting ACP transport code
Tested: targeted ACP/RCS transport tests
Not-tested: unrelated pre-existing ACP/CORS/token fallback residual risks

* fix: normalize WebFetch request headers

Normalize WebFetch headers before dispatch so canonicalization preserves auth semantics and duplicate forms do not slip through. Keep the behavior locked with a focused header test instead of broadening the request pipeline.

Constraint: preserve header semantics without widening the fetch surface
Rejected: ad hoc caller-side normalization | too easy to bypass in future call sites
Confidence: high
Scope-risk: narrow
Directive: keep header normalization close to the WebFetch utility so future callers inherit the same behavior automatically
Tested: targeted WebFetch header tests
Not-tested: unrelated fetch backend behavior beyond header normalization

* fix: harden ACP remote auth surfaces

Tighten the remaining Claude security artifact items by requiring API keys on ACP global reads and relay upgrades, moving WebSocket tokens out of URLs, and replacing open web CORS with an explicit allowlist.

Constraint: Browser WebSocket clients cannot set arbitrary Authorization headers, so the token is carried in a selected subprotocol instead of a query string.
Rejected: Keep UUID auth for ACP channel groups | any caller can mint a UUID and read global ACP data.
Rejected: Preserve ?token= compatibility | secrets leak into logs, history, referrers, and intermediaries.
Confidence: high
Scope-risk: moderate
Directive: Do not reintroduce query-string bearer tokens; use Authorization or rcs.auth.<base64url-token>.
Tested: bunx tsc --noEmit --pretty false
Tested: bun run typecheck in packages/remote-control-server
Tested: bun run build in packages/acp-link
Tested: bun run lint
Tested: bun audit
Tested: focused RCS/acp-link/web tests, 160 pass
Tested: Edge headless browser WebSocket subprotocol handshake
Tested: bun run test:all, 3669 pass
Tested: bun run build:vite
Tested: bun run build
Not-tested: Manual end-to-end relay with a live external ACP agent

* fix: resolve CI dependency override lookup

The CI runner does not expose @grpc/proto-loader as a root-resolvable package, and the test was relying on local hoisting rather than the real dependency owner. Resolve proto-loader through @opentelemetry/exporter-trace-otlp-grpc and @grpc/grpc-js so the smoke test follows the package graph it is validating.

Constraint: Do not add a new root dependency for a transitive smoke test.

Rejected: Skip or weaken the test | the test protects the protobuf 7 override path and should keep exercising loadSync.

Rejected: Add @grpc/proto-loader directly to root package.json | that hides the owning-package resolution issue and broadens dependency surface.

Confidence: high

Scope-risk: narrow

Directive: Dependency override smoke tests should resolve from the package that actually owns the dependency, not from incidental root hoisting.

Tested: bun test tests/integration/dependency-overrides.test.ts; bunx tsc --noEmit --pretty false; bun run lint; bun audit; bun run test:all; git diff --check

---------

Co-authored-by: unraid <local@unraid.local>
2026-04-26 19:49:54 +08:00
claude-code-best
fc438bd222 Feature/add auto mode settings and fix bug (#368)
* refactor: 将 convertMessagesToLangfuse 参数类型从 unknown 收窄为联合类型

将 readonly unknown[] 改为 readonly LangfuseInputMessage[],
其中 LangfuseInputMessage = UserMessage | AssistantMessage | ChatCompletionMessageParam,
让调用方获得编译期类型检查。

* fix: 修复 Config 面板第二次进入时左右键无反应的问题

将左右键枚举值切换从依赖 DOM 焦点的 onKeyDown 改为 useKeybindings 系统,
确保按键在任何焦点状态下都能正确响应。同时修复 isSearchMode 初始值和布局问题。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix: 修复 PowerShellTool.isSearchOrReadCommand 在 input 为 undefined 时崩溃的问题

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat: 添加 RSS 内存指示器并解绑 auto 权限模式与 TRANSCRIPT_CLASSIFIER

- 在 REPL 底栏添加 RSS 内存使用显示,512MB 以下 dimColor,512MB-1GB warning 色,1GB 以上 error 色
- auto 权限模式不再依赖 TRANSCRIPT_CLASSIFIER feature flag,classifier 不可用时 fallback 到 prompting
- Config 面板 defaultPermissionMode 使用类型安全的 permissionModeFromString,显示改用 shortTitle
- bypassPermissions title 缩短为 Bypass 与 shortTitle 一致

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix: 同步 permissionModeTitle 测试断言与 bypassPermissions 的新 title 值

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-26 15:43:25 +08:00
Eric Guo
4591432a1d Fix mintlify validate errors (#367) 2026-04-26 11:07:20 +08:00
WANG HONGXIANG
901628b4d9 fix: 修复 OpenAI provider (gpt-5.4/gpt-5.3-codex等模型)下 内建mcp__plugin_weixin_weixin__reply 微信工具不可见的问题 (#359)
* fix: 修复 OpenAI provider 下 MCP 工具不可见

* docs: 补充 OpenAI MCP 工具列表注释

* fix: 修正 OpenAI Langfuse 输入记录

* refactor: 使用类型守卫收窄 Langfuse role

* fix: 保留 Langfuse OpenAI 数组消息角色

* fix: 合并 Langfuse OpenAI tool_calls

* fix: 修复 OpenAI Langfuse 类型检查
2026-04-26 09:17:09 +08:00
HitMargin
cf33c06021 添加deepseek-v4-pro支持选择max思考深度 (#365)
Co-authored-by: HitMargin <hitmargin@qq.com>
Co-authored-by: Copilot <copilot@github.com>
2026-04-26 09:00:43 +08:00
claude-code-best
e0ca1d054c chore: 1.10.2 2026-04-25 20:37:40 +08:00
claude-code-best
6585d0f67c fix: 禁用 COORDINATOR_MODE 和 TEAMMEM 解决内存溢出问题
COORDINATOR_MODE 的 AgentSummary 每 30s fork 完整消息历史是 GB 级内存泄露的主因,
TEAMMEM 依赖 COORDINATOR_MODE 且邮箱文件无限增长。同时恢复 DAEMON(非主因)。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-25 20:29:52 +08:00
claude-code-best
e4403ff010 fix: 移除 RCS 按 machineName 复用 agent 记录的逻辑
多个同名 acp-link 实例注册到 RCS 时,REST 注册阶段按 machineName
去重导致不同实例共享同一条记录。改为每次注册都创建独立记录,
重连恢复由 WS identify 阶段按 environment_id 精确匹配。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-25 19:27:58 +08:00
claude-code-best
9e61e7a90d chore: 更新 biome 注释 2026-04-25 16:33:02 +08:00
claude-code-best
d03af7bd4e chore: 1.10.0 2026-04-25 14:48:15 +08:00
claude-code-best
e8ef955ff9 docs: 添加 /login 说明 2026-04-25 14:47:43 +08:00
claude-code-best
a8ed0cdce5 fix: 修复构建后 vendor 二进制路径解析错误(ripgrep/audio-capture)
构建后 chunk 文件位于 dist/chunks/(Vite)或 dist/(Bun),vendor 二进制在
dist/vendor/,但 ripgrep 和 audio-capture 的路径解析未考虑 chunks/ 层级,
导致 ENOENT。改用 import.meta.url 路径中 lastIndexOf('dist') 定位 dist 根,
并同步在 build.ts 和 post-build.ts 中添加 ripgrep vendor 文件复制。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-25 14:46:02 +08:00
claude-code-best
1c3b280c6a fix: 尝试修复多轮对话缓存失效 skill 提升的问题 2026-04-25 14:31:32 +08:00
claude-code-best
7a3cc24a00 fix: 尝试修复 nodejs windows 环境的问题 2026-04-25 14:07:45 +08:00
claude-code-best
2e7fc428cd feat: 集成豆包 ASR 语音识别后端,支持 /voice doubao 切换 (#357)
* feat: 集成豆包 ASR 语音识别后端,支持 /voice doubao 切换

- 新增 src/services/doubaoSTT.ts 适配模块,将 doubaoime-asr 的
  AsyncGenerator 协议适配为现有 VoiceStreamConnection 接口
- /voice doubao 启用豆包后端,/voice 使用默认 Anthropic 后端
- 后端选择持久化到 settings.json 的 voiceProvider 字段
- 豆包后端跳过 Anthropic OAuth 认证、语言限制和 Focus Mode
- 豆包后端松手即出结果,跳过 processing 状态
- 凭证文件存放在 ~/.claude/tts/doubao/credentials.json
- doubaoime-asr 作为 optionalDependencies 安装
- 移除 /voice 命令的 claude-ai 可用性限制,所有用户可用

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* docs: 更新 Voice Mode 文档,添加豆包 ASR 后端说明和致谢

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-25 13:57:30 +08:00
claude-code-best
ad09f38fd1 fix: 修复在已有文本前输入斜杠命令无法触发自动补全,以及 Tab 补全覆盖后续文本的问题
当用户在已输入文本前插入 /command 时,光标后的文本包含空格,导致补全逻辑误判命令已有参数而跳过建议。
修复方式:只取光标前的文本(commandInput)进行命令解析和补全生成。

同时修复 Tab 补全斜杠命令时覆盖光标后文本的问题,改为在光标位置拼接补全结果。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-25 09:27:14 +08:00
claude-code-best
b0a3ef90dc chore: 1.9.5 2026-04-25 08:56:31 +08:00
claude-code-best
c07ad4c738 chore: 清理仓库审计问题——修正 CLAUDE.md、删除冗余 yoga-layout、清除 621 个未使用的类型 stub (#354)
- 修正 CLAUDE.md/AGENTS.md 六处过时陈述:modifiers-napi、url-handler-napi 已非 stub,
  Magic Docs/LSP Server/Plugins/Marketplace 已恢复
- 删除未使用的 src/native-ts/yoga-layout/ 冗余副本(2715 行),权威版本保留在 packages/@ant/ink
- 删除 src/ 下 621 个 Auto-generated type stub 文件(全部 export type X = any,无活跃引用)

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-25 08:54:18 +08:00
claude-code-best
e38d45460e fix: 修复 Windows Node.js 构建产物因 stdin.ref() 泄漏导致进程挂起 (#353)
startCapturingEarlyInput() 调用 stdin.ref() 后,如果 Ink 未能接管
(如 raw mode 不支持或 setup 阶段异常),unref() 永远不会被调用,
导致 Node.js 事件循环无法退出。修复包括:
- stopCapturingEarlyInput() 中补充 stdin.unref() 调用
- 新增 10s 安全阀定时器自动清理 leaked ref()
- Ink App.componentWillUnmount 兜底 unref() 非 TTY stdin

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 21:16:15 +08:00
claude-code-best
e0c8e9dafc chore: 添加学习文件夹 2026-04-24 20:33:43 +08:00
claude-code-best
047c85fcbf fix: 修复 DeepSeek V4 reasoning_content 回传导致的 400 错误
- 扩大模型名称检测范围,匹配所有 deepseek 模型(V4、R1 等)
- 始终保留 thinking blocks 为 reasoning_content 回传给 API
- 移除有 bug 的 turn boundary 剥离逻辑

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 20:33:43 +08:00
claude-code-best
da6d06365d fix: 修复 anthropic 煞笔的四个 bug (#352)
* fix: 移除文件编辑前必须先读取的限制

移除 FileEditTool 和 FileWriteTool 中的 "read before edit" 校验,
允许直接编辑未读取过的文件。保留文件修改过期检测。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* docs: 更新 teach-me 自动写 note 笔记的功能

* fix: 修复 DeepSeek V4 reasoning_content 回传导致的 400 错误

- 扩大模型名称检测范围,匹配所有 deepseek 模型(V4、R1 等)
- 始终保留 thinking blocks 为 reasoning_content 回传给 API
- 移除有 bug 的 turn boundary 剥离逻辑

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix: Opus 4.6/4.7 默认推理 effort 从 medium 改为 high

Pro 和 Max/Team 订阅者的 Opus 默认 effort 之前被降级为 medium,
导致用户感知模型「变笨」。恢复为 high。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix: 移除 thinkingClearLatched sticky-on 机制

空闲超过 1 小时后 thinkingClearLatched 会被触发且永不重置,
导致每轮 API 调用都清除 thinking 历史。完整移除该 latch 机制,
clearAllThinking 硬编码为 false。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix: 移除 numeric_length_anchors 系统指令

删除「工具调用间文字 ≤25 词、最终回复 ≤100 词」的硬性限制。
ablation 测试显示该约束使整体智能下降 3%。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix: 修复测试中 reasoning_content 类型断言

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 20:07:18 +08:00
claude-code-best
8613d558a8 Merge pull request #350 from YuanyuanMa03/fix-bun-install-readme
docs: clarify Bun setup without duplicate steps
2026-04-24 19:47:35 +08:00
YuanyuanMa03
017c251f78 docs: clarify bun setup without duplicate steps 2026-04-24 18:03:21 +08:00
YYMa
d4223abc34 Merge pull request #1 from YuanyuanMa03/fix-bun-install-readme
docs: correct Bun post-install instructions
2026-04-24 17:40:00 +08:00
YYMa
5125a159d2 docs: correct Bun post-install instructions 2026-04-24 17:36:57 +08:00
claude-code-best
d09f363414 Merge pull request #347 from amDosion/feat/ssh-remote-v2
feat: 启用 SKILL_LEARNING 编译开关
2026-04-24 16:07:10 +08:00
unraid
9d35f98ec7 feat: 启用 SKILL_LEARNING 编译开关
将 SKILL_LEARNING 加入 DEFAULT_BUILD_FEATURES,
构建产物中默认启用技能学习系统。
2026-04-24 15:18:26 +08:00
claude-code-best
eb833da33b fix: 创建 agent 后刷新 loadMarkdownFilesForSubdir 缓存
新建 agent 后 clearAgentDefinitionsCache 漏清底层
loadMarkdownFilesForSubdir 的 memoize 缓存,导致新
agent 不会立即出现在列表中,需要重启才能生效。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 15:05:05 +08:00
claude-code-best
eadd32ae47 docs: 同步 AGENTS.md 与 CLAUDE.md 2026-04-24 15:05:05 +08:00
claude-code-best
3c55a8c83f Merge pull request #344 from amDosion/feat/ssh-remote-v2
feat: SSH Remote — 本地 REPL + 远端工具执行
2026-04-24 14:36:11 +08:00
claude-code-best
5582bb47ef docs: 五一 lint 提示 2026-04-24 14:35:39 +08:00
claude-code-best
95bb191977 Merge pull request #341 from YuanyuanMa03/docs/bun-installation-guide
docs: 添加 Bun 安装详细说明
2026-04-24 14:29:39 +08:00
unraid
03811f973b feat: 实现 SSH Remote — 本地 REPL + 远端工具执行
SSH Remote 允许在本地运行交互式 REPL,同时将工具调用(Bash、文件读写等)
通过 SSH 隧道转发到远程主机执行。

核心模块:
- SSHSessionManager: NDJSON 双向通信、权限转发、指数退避重连
- SSHAuthProxy: 本地认证代理 + SSH -R 反向端口转发,nonce 验证
- SSHProbe: 远端主机平台/架构/已有二进制探测
- SSHDeploy: 远端二进制部署(scp)
- createSSHSession: 会话编排(probe → deploy → spawn → attach)

新增选项:
- --remote-bin: 跳过 probe/deploy,使用自定义远端二进制
- ANTHROPIC_AUTH_NONCE: API 请求认证 nonce header

包含 17 个单元测试和完整文档。
2026-04-24 14:25:56 +08:00
YuanyuanMa03
02ab1a0307 docs: 添加 Bun 安装详细说明
- 添加 Linux/macOS/Windows 各平台的安装命令
- 添加安装后的操作步骤(重启终端、验证安装、更新版本)
- 同步更新中英文 README
2026-04-24 12:07:18 +08:00
claude-code-best
2a5b263641 chore: 1.9.4 2026-04-24 10:50:53 +08:00
claude-code-best
f2dd5142b3 refactor: 解耦 BRIDGE_MODE 与 DAEMON,禁用 DAEMON 降低内存占用
- 从 DEFAULT_BUILD_FEATURES 注释掉 DAEMON(内存占用过高)
- remoteControlServer 命令门控从 feature('DAEMON') && feature('BRIDGE_MODE')
  改为仅 feature('BRIDGE_MODE'),bridge 不再依赖 daemon
- --daemon-worker 快速路径改为运行时检测,未启用时输出明确错误提示

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 10:01:05 +08:00
claude-code-best
4dcbaf1e66 fix: 修复 ACP 模式下 messageSelector require 失败导致 submitMessage 崩溃
ACP 模式不加载完整的 React/Ink UI 组件,导致 require('src/components/MessageSelector.js')
返回 undefined。添加 try-catch 和 optional chaining fallback。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 09:59:23 +08:00
claude-code-best
0b304730d8 docs: 为 DEFAULT_BUILD_FEATURES 每个 feature flag 添加功能注释
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 09:26:59 +08:00
claude-code-best
7a0dd3057e chore: 1.9.3 2026-04-23 23:21:43 +08:00
claude-code-best
ca1c87f460 fix: 修复 usePipeIpc 中 require 返回 undefined 导致启动崩溃
将 lazy require() 调用全部替换为静态 import,解决构建产物中
模块加载时序问题导致的 'undefined is not an object' 错误。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-23 23:21:38 +08:00
860 changed files with 12666 additions and 6868 deletions

View File

@@ -41,7 +41,8 @@ All teach-me data is stored under `.claude/skills/teach-me/records/`:
.claude/skills/teach-me/records/
├── learner-profile.md # Cross-topic notes (created on first session)
└── {topic-slug}/
── session.md # Learning state: concepts, status, notes
── session.md # Learning state: concepts, status, notes
└── {topic-slug}-notes.md # Learner-facing summary notes (generated at session end)
```
**Slug**: Topic in kebab-case, 2-5 words. Example: "Python decorators" → `python-decorators`
@@ -275,7 +276,8 @@ Update `session.md` after each round:
When all concepts mastered or user ends session:
1. Update `session.md` with final state.
2. Update `.claude/skills/teach-me/records/learner-profile.md` (keep under 30 lines):
2. **Generate learner-facing notes** — write `{topic-slug}-notes.md` in the topic directory. This is a standalone reference document the learner can review later. See "Notes Generation" below for format.
3. Update `.claude/skills/teach-me/records/learner-profile.md` (keep under 30 lines):
```markdown
# Learner Profile
@@ -293,7 +295,48 @@ Updated: {timestamp}
- Python decorators (8/10 concepts, 2025-01-15)
```
3. Give a brief text summary of what was covered, key insights, and areas for further study.
4. Give a brief text summary of what was covered, key insights, and areas for further study.
## Notes Generation
At session end, generate a learner-facing notes file at `{topic-slug}/{topic-slug}-notes.md`. This file is **written for the learner to review later**, not for the tutor. It should be self-contained and organized as a quick-reference.
### Notes Structure
```markdown
# {Topic} 核心笔记
## 1. {Section Name}
{Key concept, mechanism, or principle}
* **One-line summary**: {what it does / why it matters}
* **Detail**: {brief explanation, 2-4 sentences max}
* **Example** (if applicable): {code snippet, command, or concrete scenario}
---
## 2. {Section Name}
...
---
## n. 实战参数 / Cheat Sheet (if applicable)
{Practical commands, config, or quick-reference table}
| Parameter / Concept | What it does | Tuning tip |
|---------------------|-------------|------------|
| ... | ... | ... |
```
### Notes Writing Rules
1. **Start with "what & why"** before "how". Each section should answer: what is this, why does it exist, what problem does it solve.
2. **Use analogies sparingly but effectively**. Only include an analogy if it clarifies a non-obvious mechanism (e.g., "PagedAttention is like OS virtual memory paging").
3. **Include trade-offs**. Every optimization or design choice has a cost. Always state it (e.g., "TP improves throughput but increases communication latency").
4. **Code / command examples should be minimal**. Under 10 lines, self-contained, with comments explaining the key flags.
5. **Organize by concept dependency**, not by chronological teaching order. Foundation concepts first, advanced ones last.
6. **No quiz questions, no misconceptions, no tutor-side notes**. This is a clean reference document.
7. **Language matches the session**. If the session was in Chinese, notes are in Chinese (technical terms can stay in English).
8. **Keep it under 150 lines**. If it gets too long, the learner won't review it. Be ruthless about cutting fluff.
## Resuming Sessions

View File

@@ -6,18 +6,29 @@ on:
pull_request:
branches: [main]
permissions:
contents: read
jobs:
ci:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2, 2026-04-25
env:
GIT_CONFIG_COUNT: 2
GIT_CONFIG_KEY_0: init.defaultBranch
GIT_CONFIG_VALUE_0: main
GIT_CONFIG_KEY_1: advice.defaultBranchName
GIT_CONFIG_VALUE_1: "false"
- uses: oven-sh/setup-bun@v2
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2, 2026-04-25
with:
bun-version: latest
- name: Install dependencies
env:
CLAUDE_CODE_SKIP_CHROME_MCP_SETUP: "1"
run: bun install --frozen-lockfile
- name: Type check
@@ -26,12 +37,17 @@ jobs:
- name: Test with Coverage
run: |
set -o pipefail
bun test --coverage --coverage-reporter=lcov 2>&1 | grep -vE '^\s*(\(pass\)|\(skip\))' | sed '/^.*\/__tests__\/.*:$/d' | cat -s
bun test --coverage --coverage-reporter lcov --coverage-dir coverage 2>&1 | grep -vE '^\s*(\(pass\)|\(skip\))' | sed '/^.*\/__tests__\/.*:$/d' | cat -s
test -s coverage/lcov.info
grep -q '^SF:' coverage/lcov.info
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v5
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }}
uses: codecov/codecov-action@75cd11691c0faa626561e295848008c8a7dddffe # v5, 2026-04-25
with:
file: ./coverage/lcov.info
fail_ci_if_error: true
files: ./coverage/lcov.info
disable_search: true
token: ${{ secrets.CODECOV_TOKEN }}
- name: Build

View File

@@ -20,17 +20,17 @@ jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2, 2026-04-25
with:
ref: ${{ github.event.inputs.version || github.ref }}
- uses: actions/setup-node@v6
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6, 2026-04-25
with:
node-version: "24"
registry-url: "https://registry.npmjs.org"
- name: Setup Bun
uses: oven-sh/setup-bun@v2
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2, 2026-04-25
with:
bun-version: latest
@@ -66,7 +66,7 @@ jobs:
} >> "$GITHUB_OUTPUT"
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2, 2026-04-25
with:
name: ${{ github.event.inputs.version || github.ref_name }}
body: |

View File

@@ -17,17 +17,17 @@ jobs:
packages: write
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2, 2026-04-25
- name: Login to GHCR
uses: docker/login-action@v3
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3, 2026-04-25
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3, 2026-04-25
- name: Extract version
id: version
@@ -47,7 +47,7 @@ jobs:
echo "tags=$TAGS" >> "$GITHUB_OUTPUT"
- name: Build Docker image
uses: docker/build-push-action@v5
uses: docker/build-push-action@ca052bb54ab0790a636c9b5f226502c73d547a25 # v5, 2026-04-25
with:
context: .
file: packages/remote-control-server/Dockerfile

View File

@@ -11,17 +11,17 @@ jobs:
update:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2, 2026-04-25
with:
token: ${{ secrets.GITHUB_TOKEN }}
- uses: jaywcjlove/github-action-contributors@main
- uses: jaywcjlove/github-action-contributors@86707f6d4c2469ce6b46bc3367253ebd41ee242c # main, 2026-04-25
with:
token: ${{ secrets.GITHUB_TOKEN }}
output: "contributors.svg"
repository: ${{ github.repository }}
- uses: stefanzweifel/git-auto-commit-action@v5
- uses: stefanzweifel/git-auto-commit-action@b863ae1933cb653a53c021fe36dbb774e1fb9403 # v5, 2026-04-25
with:
commit_message: "docs: update contributors"
file_pattern: "contributors.svg"

2
.gitignore vendored
View File

@@ -43,3 +43,5 @@ data
.codex/skills/.system/**
!.codex/prompts/
!.codex/prompts/**
teach-me
credentials.json

140
AGENTS.md
View File

@@ -1,10 +1,10 @@
# AGENTS.md
# CLAUDE.md
This file provides guidance to Codex (Codex.ai/code) when working with code in this repository.
This file provides guidance to Claude Code (claude.ai/code) and other AI coding agents when working with code in this repository.
## Project Overview
This is a **reverse-engineered / decompiled** version of Anthropic's official Codex CLI tool. The goal is to restore core functionality while trimming secondary capabilities. Many modules are stubbed or feature-flagged off. TypeScript strict mode is enforced — **`bunx tsc --noEmit` must pass with zero errors**.
This is a **reverse-engineered / decompiled** version of Anthropic's official Claude Code CLI tool. The goal is to restore core functionality while trimming secondary capabilities. Many modules are stubbed or feature-flagged off. TypeScript strict mode is enforced — **`bunx tsc --noEmit` must pass with zero errors**.
## Git Commit Message Convention
@@ -39,10 +39,13 @@ echo "say hello" | bun run src/entrypoints/cli.tsx -p
# Build (code splitting, outputs dist/cli.js + chunk files)
bun run build
# Build with Vite (alternative build pipeline)
bun run build:vite
# Test
bun test # run all tests (2453 tests / 137 files / 0 fail)
bun test # run all tests
bun test src/utils/__tests__/hash.test.ts # run single file
bun test --coverage # with coverage report
bun test --coverage # with coverage report
# Lint & Format (Biome)
bun run lint # check only
@@ -55,6 +58,10 @@ bun run health
# Check unused exports
bun run check:unused
# Full check (typecheck + lint + test) — run after completing any task
bun run test:all
bun run typecheck
# Remote Control Server
bun run rcs
@@ -72,17 +79,17 @@ bun run docs:dev
- **Build**: `build.ts` 执行 `Bun.build()` with `splitting: true`,入口 `src/entrypoints/cli.tsx`,输出 `dist/cli.js` + chunk files。Build 默认启用 19 个 feature见下方 Feature Flag 段)。构建后自动替换 `import.meta.require` 为 Node.js 兼容版本(产物 bun/node 都可运行)。
- **Dev mode**: `scripts/dev.ts` 通过 Bun `-d` flag 注入 `MACRO.*` defines运行 `src/entrypoints/cli.tsx`。默认启用全部 feature。
- **Module system**: ESM (`"type": "module"`), TSX with `react-jsx` transform.
- **Monorepo**: Bun workspaces — 14internal packages in `packages/` resolved via `workspace:*`
- **Monorepo**: Bun workspaces — 15workspace packages + 若干辅助目录 in `packages/` resolved via `workspace:*`
- **Lint/Format**: Biome (`biome.json`)。`bun run lint` / `bun run lint:fix` / `bun run format`
- **Defines**: 集中管理在 `scripts/defines.ts`。当前版本 `2.1.888`
- **CI**: GitHub Actions — `ci.yml`(构建+测试)、`release-rcs.yml`RCS 发布)、`update-contributors.yml`(自动更新贡献者)。
### Entry & Bootstrap
1. **`src/entrypoints/cli.tsx`** (323 行) — True entrypoint。`main()` 函数按优先级处理多条快速路径:
1. **`src/entrypoints/cli.tsx`** — True entrypoint。`main()` 函数按优先级处理多条快速路径:
- `--version` / `-v` — 零模块加载
- `--dump-system-prompt` — feature-gated (DUMP_SYSTEM_PROMPT)
- `--Codex-in-chrome-mcp` / `--chrome-native-host`
- `--claude-in-chrome-mcp` / `--chrome-native-host`
- `--computer-use-mcp` — 独立 MCP server 模式
- `--daemon-worker=<kind>` — feature-gated (DAEMON)
- `remote-control` / `rc` / `remote` / `sync` / `bridge` — feature-gated (BRIDGE_MODE)
@@ -92,26 +99,26 @@ bun run docs:dev
- `environment-runner` / `self-hosted-runner` — BYOC runner
- `--tmux` + `--worktree` 组合
- 默认路径:加载 `main.tsx` 启动完整 CLI
2. **`src/main.tsx`** (~6970 行) — Commander.js CLI definition。注册大量 subcommands`mcp` (serve/add/remove/list...)、`server``ssh``open``auth``plugin``agents``auto-mode``doctor``update` 等。主 `.action()` 处理器负责权限、MCP、会话恢复、REPL/Headless 模式分发。
2. **`src/main.tsx`** (~6981 行) — Commander.js CLI definition。注册大量 subcommands`mcp` (serve/add/remove/list...)、`server``ssh``open``auth``plugin``agents``auto-mode``doctor``update` 等。主 `.action()` 处理器负责权限、MCP、会话恢复、REPL/Headless 模式分发。
3. **`src/entrypoints/init.ts`** — One-time initialization (telemetry, config, trust dialog)。
### Core Loop
- **`src/query.ts`** — The main API query function. Sends messages to Codex API, handles streaming responses, processes tool calls, and manages the conversation turn loop.
- **`src/query.ts`** — The main API query function. Sends messages to Claude API, handles streaming responses, processes tool calls, and manages the conversation turn loop.
- **`src/QueryEngine.ts`** — Higher-level orchestrator wrapping `query()`. Manages conversation state, compaction, file history snapshots, attribution, and turn-level bookkeeping. Used by the REPL screen.
- **`src/screens/REPL.tsx`** — The interactive REPL screen (React/Ink component). Handles user input, message display, tool permission prompts, and keyboard shortcuts.
### API Layer
- **`src/services/api/Codex.ts`** — Core API client. Builds request params (system prompt, messages, tools, betas), calls the Anthropic SDK streaming endpoint, and processes `BetaRawMessageStreamEvent` events.
- **`src/services/api/claude.ts`** — Core API client. Builds request params (system prompt, messages, tools, betas), calls the Anthropic SDK streaming endpoint, and processes `BetaRawMessageStreamEvent` events.
- **7 providers**: `firstParty` (Anthropic direct), `bedrock` (AWS), `vertex` (Google Cloud), `foundry`, `openai`, `gemini`, `grok` (xAI)。
- Provider selection in `src/utils/model/providers.ts`。优先级modelType 参数 > 环境变量 > 默认 firstParty。
### Tool System
- **`src/Tool.ts`** — Tool interface definition (`Tool` type) and utilities (`findToolByName`, `toolMatchesName`).
- **`src/tools.ts`** (387 行) — Tool registry. Assembles the tool list; some tools are conditionally loaded via `feature()` flags or `process.env.USER_TYPE`.
- **`src/tools/<ToolName>/`** — 55 tool 目录。主要分类:
- **`src/tools.ts`** — Tool registry. Assembles the tool list; tools are imported from `@claude-code-best/builtin-tools` package. Some tools are conditionally loaded via `feature()` flags or `process.env.USER_TYPE`.
- **`packages/builtin-tools/src/tools/`** — 59子目录(含 shared/testing 等工具目录),通过 `@claude-code-best/builtin-tools` 包导出。主要分类:
- **文件操作**: FileEditTool, FileReadTool, FileWriteTool, GlobTool, GrepTool
- **Shell/执行**: BashTool, PowerShellTool, REPLTool
- **Agent 系统**: AgentTool, TaskCreateTool, TaskUpdateTool, TaskListTool, TaskGetTool
@@ -119,7 +126,7 @@ bun run docs:dev
- **Web/MCP**: WebFetchTool, WebSearchTool, MCPTool, McpAuthTool
- **调度**: CronCreateTool, CronDeleteTool, CronListTool
- **其他**: LSPTool, ConfigTool, SkillTool, EnterWorktreeTool, ExitWorktreeTool 等
- **`src/tools/shared/`** — Tool 共享工具函数。
- **`src/tools/shared/`** / **`packages/builtin-tools/src/tools/shared/`** — Tool 共享工具函数。
### UI Layer (Ink)
@@ -149,31 +156,46 @@ bun run docs:dev
| `packages/@ant/computer-use-mcp/` | Computer Use MCP server截图/键鼠/剪贴板/应用管理) |
| `packages/@ant/computer-use-input/` | 键鼠模拟dispatcher + darwin/win32/linux backend |
| `packages/@ant/computer-use-swift/` | 截图 + 应用管理dispatcher + per-platform backend |
| `packages/@ant/Codex-for-chrome-mcp/` | Chrome 浏览器控制(通过 `--chrome` 启用) |
| `packages/remote-control-server/` | 自托管 Remote Control ServerDocker 部署,含 Web UI |
| `packages/swarm/` | Swarm 解耦模块 |
| `packages/shell/` | Shell 抽象 |
| `packages/@ant/claude-for-chrome-mcp/` | Chrome 浏览器控制(通过 `--chrome` 启用) |
| `packages/@ant/model-provider/` | Model provider 抽象层 |
| `packages/builtin-tools/` | 内置工具集60 个 tool 实现,通过 `@claude-code-best/builtin-tools` 导出) |
| `packages/agent-tools/` | Agent 工具集 |
| `packages/acp-link/` | ACP 代理服务器WebSocket → ACP agent 桥接) |
| `packages/cc-knowledge/` | Claude Code 知识库(非 workspace 包) |
| `packages/langfuse-dashboard/` | Langfuse 可观测性面板(非 workspace 包) |
| `packages/mcp-client/` | MCP 客户端库 |
| `packages/mcp-server/` | MCP 服务端库(非 workspace 包) |
| `packages/remote-control-server/` | 自托管 Remote Control ServerDocker 部署,含 Web UI— Web UI 已重构为 React + Vite + Radix UI支持 ACP agent 接入 |
| `packages/swarm/` | Swarm 解耦模块(非 workspace 包) |
| `packages/shell/` | Shell 抽象(非 workspace 包) |
| `packages/audio-capture-napi/` | 原生音频捕获(已恢复) |
| `packages/color-diff-napi/` | 颜色差异计算完整实现11 tests |
| `packages/image-processor-napi/` | 图像处理(已恢复) |
| `packages/modifiers-napi/` | 键盘修饰键检测(stub |
| `packages/url-handler-napi/` | URL scheme 处理(stub |
| `packages/modifiers-napi/` | 键盘修饰键检测(macOS FFI 实现 |
| `packages/url-handler-napi/` | URL scheme 处理(环境变量 + CLI 参数读取 |
### Bridge / Remote Control
- **`src/bridge/`** (~37 files) — Remote Control / Bridge 模式。feature-gated by `BRIDGE_MODE`。包含 bridge API、会话管理、JWT 认证、消息传输、权限回调等。Entry: `bridgeMain.ts`
- **`packages/remote-control-server/`** — 自托管 RCS支持 Docker 部署,含 Web UI 控制面板。通过 `bun run rcs` 启动。
- CLI 快速路径: `Codex remote-control` / `Codex rc` / `Codex bridge`
- **`src/bridge/`** — Remote Control / Bridge 模式。feature-gated by `BRIDGE_MODE`。包含 bridge API、会话管理、JWT 认证、消息传输、权限回调等。Entry: `bridgeMain.ts`
- **`packages/remote-control-server/`** — 自托管 RCS支持 Docker 部署,含 Web UI 控制面板React 19 + Vite + Radix UI。支持 ACP agent 通过 acp-link 接入ACP WebSocket handler、relay handler、SSE event stream。通过 `bun run rcs` 启动。
- CLI 快速路径: `claude remote-control` / `claude rc` / `claude bridge`
- 详见 `docs/features/remote-control-self-hosting.md`
### ACP Protocol (Agent Client Protocol)
- **`src/services/acp/`** — ACP agent 实现,包含 `agent.ts`AcpAgent 类)、`bridge.ts`Claude Code ↔ ACP 桥接)、`permissions.ts`(权限处理)、`entry.ts`(入口)。
- **`packages/acp-link/`** — ACP 代理服务器,将 WebSocket 客户端桥接到 ACP agent。提供 `acp-link` CLI 命令,支持自定义端口/HTTPS/认证/会话管理、RCS 集成REST 注册 + WS identify 两步流程、权限模式透传fallback: 客户端传值 > config > `ACP_PERMISSION_MODE` 环境变量)。
- ACP 权限管道改进:`createAcpCanUseTool` 统一权限流水线,`applySessionMode` 模式同步,`bypassPermissions` 可用性检测(非 root/sandbox 环境)。
- ACP Plan 可视化已支持 `session/update plan` 类型的消息展示PlanView 组件,含进度条/状态图标/优先级标签)。
### Daemon Mode
- **`src/daemon/`** — Daemon 模式(长驻 supervisor。feature-gated by `DAEMON`。包含 `main.ts`entry`workerRegistry.ts`worker 管理)。
### Context & System Prompt
- **`src/context.ts`** — Builds system/user context for the API call (git status, date, AGENTS.md contents, memory files).
- **`src/utils/claudemd.ts`** — Discovers and loads AGENTS.md files from project hierarchy.
- **`src/context.ts`** — Builds system/user context for the API call (git status, date, CLAUDE.md contents, memory files).
- **`src/utils/claudemd.ts`** — Discovers and loads CLAUDE.md files from project hierarchy.
### Feature Flag System
@@ -196,7 +218,7 @@ Feature flags control which functionality is enabled at runtime. 代码中统一
### Multi-API 兼容层
所有兼容层均采用流适配器模式:将第三方 API 格式转为 Anthropic 内部格式,下游代码完全不改。
所有兼容层均采用流适配器模式:将第三方 API 格式转为 Anthropic 内部格式,下游代码完全不改。通过 `/login` 命令配置。
#### OpenAI 兼容层
@@ -221,18 +243,24 @@ Feature flags control which functionality is enabled at runtime. 代码中统一
详见各兼容层的 docs 文档。
### 穷鬼模式Budget Mode
- 通过 `/poor` 命令切换,持久化到 `settings.json`
- 启用后跳过 `extract_memories``prompt_suggestion``verification_agent`,显著减少 token 消耗。
- 实现在 `src/commands/poor/poorMode.ts`
### Stubbed/Deleted Modules
| Module | Status |
|--------|--------|
| Computer Use (`@ant/*`) | Restored — macOS + Windows + Linux后端完整度不一 |
| `*-napi` packages | `audio-capture-napi``image-processor-napi` 已恢复;`color-diff-napi` 完整;`modifiers-napi``url-handler-napi` 仍为 stub |
| `*-napi` packages | 全部已恢复/实现:`audio-capture-napi``image-processor-napi` 已恢复;`color-diff-napi` 完整;`modifiers-napi`macOS FFI`url-handler-napi`(环境变量+CLI |
| Voice Mode | Restored — Push-to-Talk 语音输入(需 Anthropic OAuth |
| OpenAI/Gemini/Grok 兼容层 | Restored |
| Remote Control Server | Restored — 自托管 RCS + Web UI |
| Analytics / GrowthBook / Sentry | Empty implementations |
| Magic Docs / LSP Server | Removed |
| Plugins / Marketplace | Removed |
| Magic Docs / LSP Server | Restored — Magic Docs 自动更新 + LSP 服务器管理器 |
| Plugins / Marketplace | Restored — 插件安装/卸载/启用/禁用 + Marketplace 浏览 |
| MCP OAuth | Simplified |
### Key Type Files
@@ -245,20 +273,40 @@ Feature flags control which functionality is enabled at runtime. 代码中统一
## Testing
- **框架**: `bun:test`(内置断言 + mock
- **当前状态**: 2472 tests / 138 files / 0 fail
- **单元测试**: 就近放置于 `src/**/__tests__/`,文件名 `<module>.test.ts`
- **集成测试**: `tests/integration/` — 4 个文件cli-arguments, context-build, message-pipeline, tool-chain
- **共享 mock/fixture**: `tests/mocks/`api-responses, file-system, fixtures/
- **命名**: `describe("functionName")` + `test("behavior description")`,英文
- **Mock 模式**: 对重依赖模块使用 `mock.module()` + `await import()` 解锁(必须内联在测试文件中,不能从共享 helper 导入)
- **包测试**: `packages/` 下各包也有独立测试(如 `color-diff-napi` 11 tests
### Mock 使用规范
**只 mock 有副作用的依赖链,不 mock 纯函数/纯数据模块。**
被迫 mock 的根源:`log.ts` / `debug.ts``bootstrap/state.ts`(模块级 `realpathSync` / `randomUUID` 副作用)。必须 mock 的模块:`log.ts``debug.ts``bun:bundle``settings/settings.js``config.ts``auth.ts`、第三方网络库。
**`log.ts``debug.ts` 使用共享 mock**`tests/mocks/log.ts` / `tests/mocks/debug.ts`),不要在测试文件中内联 mock 定义。使用方式:
```ts
import { logMock } from "../../../tests/mocks/log";
mock.module("src/utils/log.ts", logMock);
import { debugMock } from "../../../../tests/mocks/debug";
mock.module("src/utils/debug.ts", debugMock);
```
源文件导出变更时只需更新 `tests/mocks/` 下的对应文件,不需要逐个修改测试。
不要 mock纯函数模块`errors.ts``stringUtils.js`、mock 值与真实实现相同的模块、mock 路径与实际 import 不匹配的模块。
路径规则:统一用 `.ts` 扩展名 + `src/*` 别名路径,禁止双重 mock 同一模块。
### 类型检查
项目使用 TypeScript strict 模式,**tsc 必须零错误**。每次修改后运行:
```bash
bunx tsc --noEmit
bun run typecheck
```
**类型规范**
@@ -271,7 +319,7 @@ bunx tsc --noEmit
## Working with This Codebase
- **tsc must pass** — `bunx tsc --noEmit` 必须零错误,任何修改都不能引入新的类型错误。
- **tsc must pass** — `bun run typecheck` 必须零错误,任何修改都不能引入新的类型错误。
- **Feature flags** — 默认全部关闭(`feature()` 返回 `false`。Dev/build 各有自己的默认启用列表。不要在 `cli.tsx` 中重定义 `feature` 函数。
- **React Compiler output** — Components have decompiled memoization boilerplate (`const $ = _c(N)`). This is normal.
- **`bun:bundle` import** — `import { feature } from 'bun:bundle'` 是 Bun 内置模块,由运行时/构建器解析。不要用自定义函数替代它。**`feature()` 只能直接用在 `if` 语句或三元表达式的条件位置**Bun 编译器限制),不能赋值给变量、不能放在箭头函数体里、不能作为 `&&` 链的一部分。正确:`if (feature('X')) {}``feature('X') ? a : b`
@@ -281,3 +329,29 @@ bunx tsc --noEmit
- **Biome 配置** — 大量 lint 规则被关闭decompiled 代码不适合严格 lint`.tsx` 文件用 120 行宽 + 强制分号;其他文件 80 行宽 + 按需分号。
- **Ink 框架在 `packages/@ant/ink/`** — 不是 `src/ink/`该目录不存在。Ink 相关的组件、hooks、keybindings 都在 packages 中。
- **Provider 优先级** — `modelType` 参数 > 环境变量 > 默认 `firstParty`。新增 provider 需在 `src/utils/model/providers.ts` 注册。
## Design Context
Impeccable 设计上下文保存在 `.impeccable.md` 中。设计 Web UIRCS 控制面板、文档站、着陆页)时必须参考该文件。
### 核心设计原则
1. **Considered over clever** — 每个设计选择都应感觉有意为之,而非追逐潮流
2. **Warmth through subtlety** — 通过橙色色调的中性色、留白布局、有温度的文案来传达温暖
3. **Density with clarity** — 技术用户需要信息密度,但不能混乱
4. **Community voice** — 设计应感觉是由使用者创造的,而非遥远的设计团队
5. **Anthropic's shadow** — 遵循 Anthropic 的设计直觉:干净的布局、充足的间距、温暖的色温
### 品牌色
- 主色Claude Orange `#D77757`terra cotta
- 辅色Claude Blue `#5769F7`
- 暗色模式使用温暖的深色表面(非冷蓝黑色)
### 目标用户
技术团队/企业,在专业工作流中使用 AI 辅助编程。友好的开源社区氛围,非企业 SaaS 风格。
### 视觉参考
Anthropic 公司的设计风格 — 干净、考究、温暖的底色。大量留白,以排版为核心。避免 AI 产品常见的设计套路(渐变文字、玻璃态、霓虹色)。

View File

@@ -1,10 +1,10 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
This file provides guidance to Claude Code (claude.ai/code) and other AI coding agents when working with code in this repository.
## Project Overview
This is a **reverse-engineered / decompiled** version of Anthropic's official Claude Code CLI tool. The goal is to restore core functionality while trimming secondary capabilities. Many modules are stubbed or feature-flagged off. TypeScript strict mode is enforced(见 Working with This Codebase 段的 tsc 要求)。
This is a **reverse-engineered / decompiled** version of Anthropic's official Claude Code CLI tool. The goal is to restore core functionality while trimming secondary capabilities. Many modules are stubbed or feature-flagged off. TypeScript strict mode is enforced**`bunx tsc --noEmit` must pass with zero errors**.
## Git Commit Message Convention
@@ -43,9 +43,9 @@ bun run build
bun run build:vite
# Test
bun test # run all tests (3175 tests / 207 files / 0 fail)
bun test # run all tests
bun test src/utils/__tests__/hash.test.ts # run single file
bun test --coverage # with coverage report
bun test --coverage # with coverage report
# Lint & Format (Biome)
bun run lint # check only
@@ -60,7 +60,6 @@ bun run check:unused
# Full check (typecheck + lint + test) — run after completing any task
bun run test:all
bun run typecheck
# Remote Control Server
@@ -77,7 +76,9 @@ bun run docs:dev
### Runtime & Build
- **Runtime**: Bun (not Node.js). All imports, builds, and execution use Bun APIs.
- **Build**: `build.ts` 执行 `Bun.build()` with `splitting: true`,入口 `src/entrypoints/cli.tsx`,输出 `dist/cli.js` + chunk files。Build 默认启用 19 个 feature见下方 Feature Flag 段)。构建后自动替换 `import.meta.require` 为 Node.js 兼容版本(产物 bun/node 都可运行)。
- **Build**: `build.ts` 执行 `Bun.build()` with `splitting: true`,入口 `src/entrypoints/cli.tsx`,输出 `dist/cli.js` + chunk files。Build 默认启用 19 个 feature见下方 Feature Flag 段)。构建后自动替换 `import.meta.require` 为 Node.js 兼容版本(产物 bun/node 都可运行)。构建时会将 `vendor/audio-capture/``src/utils/vendor/ripgrep/` 复制到 `dist/vendor/` 下。
- **Build (Vite)**: `vite.config.ts` + `scripts/post-build.ts`chunk 输出到 `dist/chunks/`。post-build 同样复制 vendor 文件到 `dist/vendor/`
- **Vendor 路径解析**: 构建后 chunk 文件位于 `dist/``dist/chunks/`vendor 二进制在 `dist/vendor/``src/utils/ripgrep.ts``packages/audio-capture-napi/src/index.ts` 均通过 `import.meta.url` 路径中 `lastIndexOf('dist')` 定位 dist 根目录,再拼接 `vendor/` 子路径,确保不同构建产物层级下路径一致。
- **Dev mode**: `scripts/dev.ts` 通过 Bun `-d` flag 注入 `MACRO.*` defines运行 `src/entrypoints/cli.tsx`。默认启用全部 feature。
- **Module system**: ESM (`"type": "module"`), TSX with `react-jsx` transform.
- **Monorepo**: Bun workspaces — 15 个 workspace packages + 若干辅助目录 in `packages/` resolved via `workspace:*`
@@ -87,7 +88,7 @@ bun run docs:dev
### Entry & Bootstrap
1. **`src/entrypoints/cli.tsx`** (373 行) — True entrypoint。`main()` 函数按优先级处理多条快速路径:
1. **`src/entrypoints/cli.tsx`** — True entrypoint。`main()` 函数按优先级处理多条快速路径:
- `--version` / `-v` — 零模块加载
- `--dump-system-prompt` — feature-gated (DUMP_SYSTEM_PROMPT)
- `--claude-in-chrome-mcp` / `--chrome-native-host`
@@ -118,7 +119,7 @@ bun run docs:dev
### Tool System
- **`src/Tool.ts`** — Tool interface definition (`Tool` type) and utilities (`findToolByName`, `toolMatchesName`).
- **`src/tools.ts`** (392 行) — Tool registry. Assembles the tool list; tools are imported from `@claude-code-best/builtin-tools` package. Some tools are conditionally loaded via `feature()` flags or `process.env.USER_TYPE`.
- **`src/tools.ts`** — Tool registry. Assembles the tool list; tools are imported from `@claude-code-best/builtin-tools` package. Some tools are conditionally loaded via `feature()` flags or `process.env.USER_TYPE`.
- **`packages/builtin-tools/src/tools/`** — 59 个子目录(含 shared/testing 等工具目录),通过 `@claude-code-best/builtin-tools` 包导出。主要分类:
- **文件操作**: FileEditTool, FileReadTool, FileWriteTool, GlobTool, GrepTool
- **Shell/执行**: BashTool, PowerShellTool, REPLTool
@@ -127,6 +128,7 @@ bun run docs:dev
- **Web/MCP**: WebFetchTool, WebSearchTool, MCPTool, McpAuthTool
- **调度**: CronCreateTool, CronDeleteTool, CronListTool
- **其他**: LSPTool, ConfigTool, SkillTool, EnterWorktreeTool, ExitWorktreeTool 等
- **`src/tools/shared/`** / **`packages/builtin-tools/src/tools/shared/`** — Tool 共享工具函数。
### UI Layer (Ink)
@@ -171,12 +173,12 @@ bun run docs:dev
| `packages/audio-capture-napi/` | 原生音频捕获(已恢复) |
| `packages/color-diff-napi/` | 颜色差异计算完整实现11 tests |
| `packages/image-processor-napi/` | 图像处理(已恢复) |
| `packages/modifiers-napi/` | 键盘修饰键检测(stub |
| `packages/url-handler-napi/` | URL scheme 处理(stub |
| `packages/modifiers-napi/` | 键盘修饰键检测(macOS FFI 实现 |
| `packages/url-handler-napi/` | URL scheme 处理(环境变量 + CLI 参数读取 |
### Bridge / Remote Control
- **`src/bridge/`** (~38 files) — Remote Control / Bridge 模式。feature-gated by `BRIDGE_MODE`。包含 bridge API、会话管理、JWT 认证、消息传输、权限回调等。Entry: `bridgeMain.ts`
- **`src/bridge/`** — Remote Control / Bridge 模式。feature-gated by `BRIDGE_MODE`。包含 bridge API、会话管理、JWT 认证、消息传输、权限回调等。Entry: `bridgeMain.ts`
- **`packages/remote-control-server/`** — 自托管 RCS支持 Docker 部署,含 Web UI 控制面板React 19 + Vite + Radix UI。支持 ACP agent 通过 acp-link 接入ACP WebSocket handler、relay handler、SSE event stream。通过 `bun run rcs` 启动。
- CLI 快速路径: `claude remote-control` / `claude rc` / `claude bridge`
- 详见 `docs/features/remote-control-self-hosting.md`
@@ -218,7 +220,30 @@ Feature flags control which functionality is enabled at runtime. 代码中统一
### Multi-API 兼容层
支持 OpenAI、Gemini、Grok 三种第三方 API通过 `/login` 命令配置,均采用流适配器模式转为 Anthropic 内部格式。详见各兼容层的 docs 文档
所有兼容层均采用流适配器模式:将第三方 API 格式转为 Anthropic 内部格式,下游代码完全不改。通过 `/login` 命令配置
#### OpenAI 兼容层
通过 `CLAUDE_CODE_USE_OPENAI=1` 启用,支持 Ollama/DeepSeek/vLLM 等任意 OpenAI Chat Completions 协议端点。含 DeepSeek thinking mode 支持。
- **`src/services/api/openai/`** — client、消息/工具转换、流适配、模型映射
- 关键环境变量:`CLAUDE_CODE_USE_OPENAI``OPENAI_API_KEY``OPENAI_BASE_URL``OPENAI_MODEL`
#### Gemini 兼容层
通过 `CLAUDE_CODE_USE_GEMINI=1` 启用。独立环境变量体系。
- **`src/services/api/gemini/`** — client、模型映射、类型定义
- 关键环境变量:`GEMINI_API_KEY`(必填)、`GEMINI_MODEL`(直接指定)、`GEMINI_DEFAULT_SONNET_MODEL`/`GEMINI_DEFAULT_OPUS_MODEL`(按能力映射)
- 模型映射优先级:`GEMINI_MODEL` > `GEMINI_DEFAULT_*_MODEL` > `ANTHROPIC_DEFAULT_*_MODEL`(已废弃) > 原样返回
#### Grok 兼容层
通过 `CLAUDE_CODE_USE_GROK=1` 启用。自定义模型映射支持 xAI Grok API。
- **`src/services/api/grok/`** — client、模型映射
详见各兼容层的 docs 文档。
### 穷鬼模式Budget Mode
@@ -231,13 +256,13 @@ Feature flags control which functionality is enabled at runtime. 代码中统一
| Module | Status |
|--------|--------|
| Computer Use (`@ant/*`) | Restored — macOS + Windows + Linux后端完整度不一 |
| `*-napi` packages | `audio-capture-napi``image-processor-napi` 已恢复;`color-diff-napi` 完整;`modifiers-napi``url-handler-napi` 仍为 stub |
| `*-napi` packages | 全部已恢复/实现:`audio-capture-napi``image-processor-napi` 已恢复;`color-diff-napi` 完整;`modifiers-napi`macOS FFI`url-handler-napi`(环境变量+CLI |
| Voice Mode | Restored — Push-to-Talk 语音输入(需 Anthropic OAuth |
| OpenAI/Gemini/Grok 兼容层 | Restored |
| Remote Control Server | Restored — 自托管 RCS + Web UI |
| Analytics / GrowthBook / Sentry | Empty implementations |
| Magic Docs / LSP Server | Removed |
| Plugins / Marketplace | Removed |
| Magic Docs / LSP Server | Restored — Magic Docs 自动更新 + LSP 服务器管理器 |
| Plugins / Marketplace | Restored — 插件安装/卸载/启用/禁用 + Marketplace 浏览 |
| MCP OAuth | Simplified |
### Key Type Files
@@ -250,7 +275,6 @@ Feature flags control which functionality is enabled at runtime. 代码中统一
## Testing
- **框架**: `bun:test`(内置断言 + mock
- **当前状态**: 3175 tests / 207 files / 0 fail
- **单元测试**: 就近放置于 `src/**/__tests__/`,文件名 `<module>.test.ts`
- **集成测试**: `tests/integration/` — 4 个文件cli-arguments, context-build, message-pipeline, tool-chain
- **共享 mock/fixture**: `tests/mocks/`api-responses, file-system, fixtures/
@@ -284,7 +308,7 @@ mock.module("src/utils/debug.ts", debugMock);
项目使用 TypeScript strict 模式,**tsc 必须零错误**。每次修改后运行:
```bash
bun run typecheck # equivalent to bun run typecheck
bun run typecheck
```
**类型规范**

View File

@@ -12,20 +12,22 @@
牢 A (Anthropic) 官方 [Claude Code](https://docs.anthropic.com/en/docs/claude-code) CLI 工具的源码反编译/逆向还原项目。目标是将 Claude Code 大部分功能及工程化能力复现 (问就是老佛爷已经付过钱了)。虽然很难绷, 但是它叫做 CCB(踩踩背)... 而且, 我们实现了企业版或者需要登陆 Claude 账号才能使用的特性, 实现技术普惠
> 我们将会在五一期间进行整个代码仓库的 lint 规范化, 这个期间提交的 PR 可能会有非常多的冲突, 所以大的功能请尽量在这之前提交哈
[文档在这里, 支持投稿 PR](https://ccb.agent-aura.top/) | [留影文档在这里](./Friends.md) | [Discord 群组](https://discord.gg/uApuzJWGKX)
| 特性 | 说明 | 文档 |
| --------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
| **Claude 群控技术** | Pipe IPC 多实例协作:同机 main/sub 自动编排 + LAN 跨机器零配置发现与通讯,`/pipes` 选择面板 + `Shift+↓` 交互 + 消息广播路由 | [Pipe IPC](https://ccb.agent-aura.top/docs/features/pipes-and-lan) / [LAN](https://ccb.agent-aura.top/docs/features/lan-pipes) |
| **Claude 群控技术** | Pipe IPC 多实例协作:同机 main/sub 自动编排 + LAN 跨机器零配置发现与通讯,`/pipes` 选择面板 + `Shift+↓` 交互 + 消息广播路由 | [Pipe IPC](https://ccb.agent-aura.top/docs/features/uds-inbox) / [LAN](https://ccb.agent-aura.top/docs/features/lan-pipes) |
| **ACP 协议一等一支持** | 支持接入 Zed、Cursor 等 IDE支持会话恢复、Skills、权限桥接 | [文档](https://ccb.agent-aura.top/docs/features/acp-zed) |
| **Remote Control 私有部署** | Docker 自托管远程界面, 可以手机上看 CC | [文档](https://ccb.agent-aura.top/docs/features/remote-control-self-hosting) |
| **Langfuse 监控** | 企业级 Agent 监控, 可以清晰看到每次 agent loop 细节, 可以一键转化为数据集 | [文档](https://ccb.agent-aura.top/docs/features/langfuse-monitoring) |
| **Web Search** | 内置网页搜索工具, 支持 bing 和 brave 搜索 | [文档](https://ccb.agent-aura.top/docs/features/web-browser-tool) |
| **Poor Mode** | 穷鬼模式,关闭记忆提取和键入建议,大幅度减少并发请求 | /poor 可以开关 |
| **Channels 频道通知** | MCP 服务器推送外部消息到会话(飞书/Slack/Discord/微信等),`--channels plugin:name@marketplace` 启用 | [文档](https://ccb.agent-aura.top/docs/features/channels) |
| **自定义模型供应商** | OpenAI/Anthropic/Gemini/Grok 兼容 | [文档](https://ccb.agent-aura.top/docs/features/custom-platform-login) |
| Voice Mode | Push-to-Talk 语音输入 | [文档](https://ccb.agent-aura.top/docs/features/voice-mode) |
| **自定义模型供应商** | OpenAI/Anthropic/Gemini/Grok 兼容 (`/login`) | [文档](https://ccb.agent-aura.top/docs/features/all-features-guide) |
| Voice Mode | 语音输入,支持豆包语言输入(`/voice doubao` | [文档](https://ccb.agent-aura.top/docs/features/voice-mode) |
| Computer Use | 屏幕截图、键鼠控制 | [文档](https://ccb.agent-aura.top/docs/features/computer-use) |
| Chrome Use | 浏览器自动化、表单填写、数据抓取 | [自托管](https://ccb.agent-aura.top/docs/features/chrome-use-mcp) [原生版](https://ccb.agent-aura.top/docs/features/claude-in-chrome-mcp) |
| Sentry | 企业级错误追踪 | [文档](https://ccb.agent-aura.top/docs/internals/sentry-setup) |
@@ -60,11 +62,66 @@ CLAUDE_BRIDGE_BASE_URL=https://remote-control.claude-code-best.win/ CLAUDE_BRIDG
一定要最新版本的 bun 啊, 不然一堆奇奇怪怪的 BUG!!! bun upgrade!!!
- 📦 [Bun](https://bun.sh/) >= 1.3.11
**安装 Bun**
```bash
# Linux 和 macOS
curl -fsSL https://bun.sh/install | bash
# Windows (PowerShell)
powershell -c "irm bun.sh/install.ps1 | iex"
```
**安装后的操作:**
1. **让当前终端识别 `bun` 命令**
安装脚本会把 `~/.bun/bin` 写入对应的 shell 配置文件。macOS 默认 zsh 环境通常会看到:
```text
Added "~/.bun/bin" to $PATH in "~/.zshrc"
```
可以按安装脚本提示重启当前 shell
```bash
exec /bin/zsh
```
如果你使用 bash重新加载 bash 配置:
```bash
source ~/.bashrc
```
Windows PowerShell 用户关闭并重新打开 PowerShell 即可。
2. **验证 Bun 是否可用**
```bash
bun --help
bun --version
```
3. **如果已经安装过 Bun更新到最新版本**
```bash
bun upgrade
```
- ⚙️ 常规的配置 CC 的方式, 各大提供商都有自己的配置方式
### 📍 命令执行位置
- 安装或检查 Bun 的命令可以在任意目录执行:
`curl -fsSL https://bun.sh/install | bash`、`bun --help`、`bun --version`、`bun upgrade`
- 安装本项目依赖、启动开发模式、构建项目时,必须先进入本仓库根目录,也就是包含 `package.json` 的目录。
### 📥 安装
```bash
cd /path/to/claude-code
bun install
```
@@ -176,6 +233,10 @@ TUI (REPL) 模式需要真实终端,无法直接通过 VS Code launch 启动
</picture>
</a>
## 致谢
- [doubaoime-asr](https://github.com/starccy/doubaoime-asr) — 豆包 ASR 语音识别 SDK为 Voice Mode 提供无需 Anthropic OAuth 的语音输入方案
## 许可证
本项目仅供学习研究用途。Claude Code 的所有权利归 [Anthropic](https://www.anthropic.com/) 所有。

View File

@@ -48,11 +48,64 @@ Sponsor placeholder.
Make sure you're on the latest version of Bun, otherwise you'll run into all sorts of weird bugs. Run `bun upgrade`!
- [Bun](https://bun.sh/) >= 1.3.11
**Install Bun:**
```bash
# Linux and macOS
curl -fsSL https://bun.sh/install | bash
# Windows (PowerShell)
powershell -c "irm bun.sh/install.ps1 | iex"
```
**Post-installation steps:**
1. **Make `bun` available in the current terminal**
The installer adds `~/.bun/bin` to the matching shell configuration file. On macOS with the default zsh shell, you may see:
```text
Added "~/.bun/bin" to $PATH in "~/.zshrc"
```
Restart the current shell as the installer suggests:
```bash
exec /bin/zsh
```
If you use bash, reload the bash configuration:
```bash
source ~/.bashrc
```
Windows PowerShell users can close and reopen PowerShell.
2. **Verify that Bun is available:**
```bash
bun --help
bun --version
```
3. **Update to latest version (if already installed):**
```bash
bun upgrade
```
- Standard Claude Code configuration — each provider has its own setup method
### Command Execution Location
- Bun installation and checking commands can be run from any directory:
`curl -fsSL https://bun.sh/install | bash`, `bun --help`, `bun --version`, `bun upgrade`
- Project dependency installation, development mode, and builds must be run from this repository root, the directory containing `package.json`.
### Install
```bash
cd /path/to/claude-code
bun install
```
@@ -135,7 +188,7 @@ The TUI (REPL) mode requires a real terminal and cannot be launched directly via
## Documentation & Links
- **Online docs (Mintlify)**: [ccb.agent-aura.top](https://ccb.agent-aura.top/) — source in [`docs/`](docs/), PR contributions welcome
- **DeepWiki**: <https://deepwiki.com/claude-code-best/claude-code>
- **DeepWiki**: https://deepwiki.com/claude-code-best/claude-code
## Contributors

View File

@@ -75,10 +75,14 @@ console.log(
`Bundled ${result.outputs.length} files to ${outdir}/ (patched ${patched} for import.meta.require, ${bunPatched} for Bun destructure)`,
)
// Step 4: Copy native .node addon files (audio-capture)
const vendorDir = join(outdir, 'vendor', 'audio-capture')
await cp('vendor/audio-capture', vendorDir, { recursive: true })
console.log(`Copied vendor/audio-capture/ → ${vendorDir}/`)
// Step 4: Copy native .node addon files (audio-capture) and vendored binaries (ripgrep)
const audioCaptureDir = join(outdir, 'vendor', 'audio-capture')
await cp('vendor/audio-capture', audioCaptureDir, { recursive: true })
console.log(`Copied vendor/audio-capture/ → ${audioCaptureDir}/`)
const ripgrepDir = join(outdir, 'vendor', 'ripgrep')
await cp('src/utils/vendor/ripgrep', ripgrepDir, { recursive: true })
console.log(`Copied src/utils/vendor/ripgrep/ → ${ripgrepDir}/`)
// Step 5: Generate cli-bun and cli-node executable entry points
const cliBun = join(outdir, 'cli-bun.js')

1059
bun.lock

File diff suppressed because it is too large Load Diff

View File

@@ -99,12 +99,15 @@ ARGUMENTS
## 四、认证
默认启动时自动生成随机 token。客户端连接时需通过 query 参数传递
默认启动时自动生成随机 token。客户端连接时不要把 token 放在 URL 中
```
ws://localhost:9315/ws?token=<your-token>
ws://localhost:9315/ws
```
无法发送 `Authorization` header 的 WebSocket 客户端需要使用
`rcs.auth.<base64url-token>` 子协议传递 token。
配置固定 token
```bash
@@ -135,6 +138,9 @@ acp-link ccb-bun -- --acp
1. **REST 注册**:通过 `POST /v1/environments/bridge` 向 RCS 注册环境
2. **WS identify**:建立 WebSocket 连接后发送 `identify` 消息(携带 agentId替代完整 `register`
RCS 的 ACP WebSocket 连接不接受 URL query token。acp-link 会通过
`rcs.auth.<base64url-token>` WebSocket 子协议发送 `ACP_RCS_TOKEN`
```
acp-link RCS
│ │

View File

@@ -145,8 +145,8 @@ M 键(或 ← / →)用于在两种路由模式之间切换,**无需展开
```
/pipes — 显示所有实例 + 切换选择面板
/pipes select <name> — 选中某实例(消息会广播到它)
/pipes deselect <name> — 取消选中
/pipes select &lt;name&gt; — 选中某实例(消息会广播到它)
/pipes deselect &lt;name&gt; — 取消选中
/pipes all — 全选
/pipes none — 全部取消
```
@@ -169,7 +169,7 @@ LAN Peers:
Selected: cli-da029538
```
### /attach <name>
### /attach &lt;name&gt;
手动 attach 到一个实例,使其成为你的 slave。
@@ -179,7 +179,7 @@ Selected: cli-da029538
attach 后,对方变为 slave你变为 master。可以向它发送 prompt。通常不需要手动 attach——heartbeat 会自动发现并连接。
### /detach <name>
### /detach &lt;name&gt;
断开与某个 slave 的连接。
@@ -187,7 +187,7 @@ attach 后,对方变为 slave你变为 master。可以向它发送 prompt
/detach cli-04d67950
```
### /send <name> <message>
### /send &lt;name&gt; &lt;message&gt;
向指定 pipe 发送消息(不依赖选择状态,直接指定目标)。

View File

@@ -225,6 +225,11 @@ acp-link ◄──ACP relay──► RCS ◄──Web UI WS──► 浏览器
| `src/transport/acp-relay-handler.ts` | 前端 WS → acp-link 透传 + EventBus inbound 转发 |
| `src/transport/acp-sse-writer.ts` | SSE event stream 供外部消费者订阅 |
ACP 的 agents、channel groups、relay 和 channel-group SSE 端点都要求有效
API key。浏览器 `EventSource` 不能发送 `Authorization` header外部订阅
`/acp/channel-groups/:id/events` 时需要使用 `fetch` + `ReadableStream` 并带
`Authorization: Bearer <api-key>`
### acp-link 连接
详见 [acp-link 文档](./acp-link.md)。

426
docs/features/ssh-remote.md Normal file
View File

@@ -0,0 +1,426 @@
# SSH Remote — 远程主机运行 Claude Code
## 概述
SSH Remote 提供两种方式在远程 Linux 主机上运行 Claude Code
1. **SSH Remote 模块**`ccb ssh <host>`)— 本地 REPL + 远程工具执行,自动部署二进制 + 认证隧道
2. **直接 SSH 运行**`ssh <host> -t ccb`)— 远程已安装 ccb直接启动交互式会话
## 架构
### 方式一SSH Remote 模块(完整模式)
适用场景:远端没有 API 凭据或没有安装 ccb。
```
┌──────────────── 本地 Windows/Mac/Linux ───────────┐
│ │
│ ccb ssh <host> [dir] │
│ │ │
│ ├── 1. SSHProbe: 探测远端平台/架构/已有二进制 │
│ ├── 2. SSHDeploy: 部署 dist/ 到远端 │
│ ├── 3. SSHAuthProxy: 启动本地认证代理 │
│ │ ├─ Unix Socket (Linux/Mac) │
│ │ └─ TCP 127.0.0.1:<port> (Windows) │
│ │ │
│ └── 4. SSH -R 反向隧道 + 启动远端 CLI │
│ ssh -R <remote>:<local> <host> \ │
│ ANTHROPIC_BASE_URL=... \ │
│ ANTHROPIC_AUTH_NONCE=... \ │
│ ccb --output-format stream-json │
│ │
│ ┌─────── 本地 REPL (Ink TUI) ───────┐ │
│ │ 用户输入 → NDJSON → SSH stdin │ │
│ │ SSH stdout → NDJSON → 渲染消息 │ │
│ │ 工具权限请求 → 本地审批 → 回传 │ │
│ └────────────────────────────────────┘ │
└────────────────────────────────────────────────────┘
│ SSH 连接 (加密通道)
┌───────────────── 远端 Linux ──────────────────────┐
│ │
│ ccb (自动部署或已存在) │
│ ├── --output-format stream-json │
│ ├── --input-format stream-json │
│ ├── --verbose -p │
│ │ │
│ ├── API 请求 → ANTHROPIC_BASE_URL │
│ │ → SSH 反向隧道 → 本地 AuthProxy │
│ │ → 注入真实凭据 → api.anthropic.com │
│ │ │
│ └── 工具执行 (Bash/Read/Write/...) │
│ 直接在远端文件系统上操作 │
└────────────────────────────────────────────────────┘
```
### 方式二:直接 SSH 运行(简单模式)
适用场景:远端已安装 ccb 且已有 API 凭据(订阅或 API Key
```
┌─────── 本地终端 ───────┐ ┌──────── 远端 Linux ────────┐
│ │ SSH │ │
│ ssh <host> -t ccb │ ──────→ │ ccb (全局安装) │
│ │ │ ├── 使用远端自身凭据 │
│ 终端直接显示远端 TUI │ ←────── │ ├── 远端文件系统操作 │
│ │ TTY │ └── API 直连 Anthropic │
└─────────────────────────┘ └─────────────────────────────┘
```
### 适用场景对比
| | SSH Remote 模块 | 直接 SSH 运行 |
|---|---|---|
| 远端需要安装 ccb | 不需要(自动部署) | 需要 |
| 远端需要 API 凭据 | 不需要(本地隧道) | 需要 |
| 本地需要安装 ccb | 需要 | 不需要(任何终端) |
| 斜杠命令 | 本地处理 | 远端处理 |
| 网络延迟敏感 | 高NDJSON 双向) | 低(仅 TTY |
| 推荐场景 | 远端无凭据/无安装 | 远端已配置完整 |
---
## 前置准备SSH 密钥配置
两种方式都依赖 SSH 免密连接。以下是完整的密钥配置步骤。
### 1. 生成 SSH 密钥对(本地)
```bash
# 生成 Ed25519 密钥(推荐)
ssh-keygen -t ed25519 -C "your-email@example.com" -f ~/.ssh/id_remote
# 或 RSA 4096 位
ssh-keygen -t rsa -b 4096 -C "your-email@example.com" -f ~/.ssh/id_remote
```
生成两个文件:
- `~/.ssh/id_remote` — 私钥(不可泄露)
- `~/.ssh/id_remote.pub` — 公钥(部署到远端)
### 2. 将公钥部署到远端
```bash
# 方式 Assh-copy-id推荐
ssh-copy-id -i ~/.ssh/id_remote.pub user@remote-host
# 方式 B手动复制
cat ~/.ssh/id_remote.pub | ssh user@remote-host "mkdir -p ~/.ssh && chmod 700 ~/.ssh && cat >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys"
```
### 3. 配置 SSH Config本地
编辑 `~/.ssh/config`(不存在则创建):
```
Host my-server
HostName 192.168.1.100 # 远端 IP 或域名
User root # 远端用户名
IdentityFile ~/.ssh/id_remote # 私钥路径
ServerAliveInterval 60 # 防止连接超时断开
ServerAliveCountMax 3
```
配置后可直接用别名连接:
```bash
ssh my-server # 等同于 ssh -i ~/.ssh/id_remote root@192.168.1.100
```
### 4. 文件权限设置
#### Linux / macOS
```bash
chmod 700 ~/.ssh
chmod 600 ~/.ssh/config
chmod 600 ~/.ssh/id_remote
chmod 644 ~/.ssh/id_remote.pub
```
#### WindowsOpenSSH 强制 ACL 检查)
```powershell
# 重置 .ssh 目录权限:仅允许当前用户 + SYSTEM
icacls "$env:USERPROFILE\.ssh" /inheritance:r /grant:r "$($env:USERNAME):(OI)(CI)F" /grant "SYSTEM:(OI)(CI)F"
# 修复 config 文件权限
icacls "$env:USERPROFILE\.ssh\config" /inheritance:r /grant:r "$($env:USERNAME):F" /grant "SYSTEM:F"
# 修复私钥权限
icacls "$env:USERPROFILE\.ssh\id_remote" /inheritance:r /grant:r "$($env:USERNAME):F" /grant "SYSTEM:F"
```
> **Windows 常见错误**:如果 `icacls` 显示 `UNKNOWN\UNKNOWN` ACL 条目,需要先移除再重新授权。权限错误会导致 SSH 拒绝使用密钥。
### 5. 验证免密连接
```bash
ssh my-server "echo 'SSH connection OK'"
# 应直接输出 "SSH connection OK",不要求输入密码
```
---
## 使用方式
### 方式一SSH Remote 模块
```bash
# 基本用法 — 自动探测、部署、启动
ccb ssh user@remote-host
# 使用 SSH Config 别名
ccb ssh my-server
# 指定远端工作目录
ccb ssh my-server /home/user/project
# 使用自定义远端二进制(跳过探测/部署)
ccb ssh my-server --remote-bin "bun /opt/ccb/dist/cli.js"
# 权限控制
ccb ssh my-server --permission-mode auto
ccb ssh my-server --dangerously-skip-permissions
# 恢复远端会话
ccb ssh my-server --continue
ccb ssh my-server --resume <session-uuid>
# 选择模型
ccb ssh my-server --model claude-sonnet-4-6-20250514
# 本地测试模式(不连接远端,测试 auth proxy 管道)
ccb ssh localhost --local
```
### 方式二:直接 SSH 运行
```bash
# 启动交互式会话
ssh my-server -t ccb
# 指定工作目录
ssh my-server -t "ccb --cwd /home/user/project"
# 使用特定模型
ssh my-server -t "ccb --model claude-sonnet-4-6-20250514"
```
---
## 构建与部署
### 构建产物
```bash
# 安装依赖
bun install
# 构建(输出到 dist/
bun run build
```
产物说明:
| 文件 | 说明 |
|------|------|
| `dist/cli.js` | Bun 入口(`#!/usr/bin/env bun` |
| `dist/cli-node.js` | Node.js 入口(`#!/usr/bin/env node``import ./cli.js` |
| `dist/cli-bun.js` | Bun 专用入口 |
| `dist/chunk-*.js` | 代码分割 chunk 文件(约 668 个) |
### 运行方式
```bash
# 方式 A通过 bun 直接运行(开发/调试)
bun run dev
# 方式 B运行构建产物bun 运行时)
bun dist/cli.js
# 方式 C运行构建产物node 运行时)
node dist/cli-node.js
# 方式 D全局安装后使用命令名
ccb
```
### 全局安装
在项目根目录执行:
```bash
# bun 全局安装(推荐)
bun install -g .
# 创建的命令:
# ccb → dist/cli-node.js
# ccb-bun → dist/cli-bun.js
# claude-code-best → dist/cli-node.js
# 安装位置:~/.bun/bin/ccb
```
或使用 npm
```bash
npm install -g .
```
验证:
```bash
ccb --version
# → x.x.x (Claude Code)
```
### 远端部署(全流程)
```bash
# 1. 登录远端
ssh my-server
# 2. 克隆或同步项目代码
git clone <repo-url> ~/ccb-project
cd ~/ccb-project
# 3. 安装运行时(如果没有 bun
curl -fsSL https://bun.sh/install | bash
source ~/.bashrc
# 4. 安装依赖 + 构建
bun install
bun run build
# 5. 全局安装
bun install -g .
# 6. 确保非交互式 SSH 可访问 ccb 命令
# bun install -g 安装到 ~/.bun/bin/,但非交互式 SSH 不加载 .bashrc
# 所以 PATH 中不包含 ~/.bun/bin/
# 解决方式(任选其一):
# 方式 A符号链接到系统 PATH推荐
ln -sf ~/.bun/bin/ccb /usr/local/bin/ccb
# 方式 B添加到 /etc/profile.d/(所有用户生效)
echo 'export PATH="$HOME/.bun/bin:$PATH"' > /etc/profile.d/bun-path.sh
# 方式 C添加到 ~/.bash_profile当前用户ssh -t 时生效)
echo 'export PATH="$HOME/.bun/bin:$PATH"' >> ~/.bash_profile
# 7. 验证
ccb --version
# 8. 从本地测试
# (在本地终端)
ssh my-server -t ccb
```
### SSH Remote 自动部署
使用 `ccb ssh <host>` 时,模块自动处理:
1. **SSHProbe** 探测远端 `~/.local/bin/claude``command -v claude`
2. 若二进制不存在或版本不匹配,**SSHDeploy** 通过 `scp` 传输 `dist/` 目录
3. 在远端创建 wrapper 脚本(`~/.local/bin/claude`
4. 无需手动安装
---
## 模块结构
```
src/ssh/
├── createSSHSession.ts — 会话工厂:编排 probe → deploy → proxy → spawn
├── SSHSessionManager.ts — 双向 NDJSON 通信管理 + 权限转发 + 重连
├── SSHAuthProxy.ts — 本地认证代理API 凭据隧道)
├── SSHProbe.ts — 远端主机探测(平台/架构/已有二进制)
├── SSHDeploy.ts — 远端二进制部署scp + wrapper 脚本)
└── __tests__/
└── SSHSessionManager.test.ts — 17 个单元测试
```
## 关键技术细节
### 认证隧道
- **AuthProxy** 在本地监听Unix socket 或 TCP接收远端 CLI 的 API 请求
- 通过 SSH `-R` 反向端口转发隧道到远端
- AuthProxy 注入本地真实凭据API key 或 OAuth token转发到 `api.anthropic.com`
- `ANTHROPIC_AUTH_NONCE` header 防止未授权访问nonce 通过环境变量传递给远端 CLI远端 CLI 在每个 API 请求中携带此 header
### waitForInit vs 存活检查
- **标准模式**`waitForInit` 等待远端 CLI 发送 `{type:'system', subtype:'init'}` JSON 消息
- **`--remote-bin` 模式**:跳过 `waitForInit`print+stream-json 模式下 init 只在首次查询后发送),改用 3 秒进程存活检查
### 重连机制
- `SSHSessionManager` 检测 SSH 连接断开后自动重连
- 重连时在远端 CLI 命令中追加 `--continue` 恢复会话
- 指数退避重试(最多 5 次,间隔 1s → 2s → 4s → 8s → 16s
## Feature Flag
SSH Remote 功能受 `SSH_REMOTE` feature flag 控制:
- **Dev 模式**:默认启用
- **Build 模式**:需在 `build.ts``DEFAULT_BUILD_FEATURES` 中添加 `'SSH_REMOTE'`
- **运行时**`FEATURE_SSH_REMOTE=1` 环境变量
---
## 常见问题
### `ccb: command not found`SSH 远程执行时)
非交互式 SSH 不加载 `.bashrc``~/.bun/bin` 不在 PATH 中。
```bash
# 解决:创建符号链接
ln -sf ~/.bun/bin/ccb /usr/local/bin/ccb
```
### SSH 密钥被拒绝
```
Permission denied (publickey)
```
1. 确认公钥已添加到远端 `~/.ssh/authorized_keys`
2. 确认本地私钥文件权限正确(`chmod 600`
3. 确认 `~/.ssh/config``IdentityFile` 路径正确
4. Windows 用户检查 ACL 权限(见上方 Windows 权限设置)
### SSH 连接超时
```
ssh: connect to host x.x.x.x port 22: Connection timed out
```
1. 确认远端 SSH 服务正在运行:`systemctl status sshd`
2. 确认防火墙允许 22 端口
3. 确认 IP 地址/域名正确
4.`~/.ssh/config` 中添加 `ConnectTimeout 10`
### 403 ForbiddenSSH Remote 模块)
AuthProxy 的 nonce 验证失败。确认:
1. 远端 CLI 版本包含 nonce header 注入修复
2. `ANTHROPIC_AUTH_NONCE` 环境变量正确传递到远端
3. `src/services/api/client.ts``x-auth-nonce` header 已启用
### 远端 CLI 启动后立即退出
```
Remote process exited immediately (code 1)
```
1. 确认远端 `bun` / `node` 运行时可用
2. 手动在远端执行 `ccb --version` 验证安装
3. 检查 `--remote-bin` 路径是否正确
4. 查看 stderr 输出获取详细错误信息

View File

@@ -1,27 +1,32 @@
# VOICE_MODE — 语音输入
> Feature Flag: `FEATURE_VOICE_MODE=1`
> 实现状态:完整可用(需要 Anthropic OAuth
> 实现状态:完整可用(双后端:Anthropic OAuth / 豆包 ASR
> 引用数46
## 一、功能概述
VOICE_MODE 实现"按键说话"Push-to-Talk语音输入。用户按住空格键录音音频通过 WebSocket 流式传输到 Anthropic STT 端点Nova 3,实时转录显示在终端中。
VOICE_MODE 实现"按键说话"Push-to-Talk语音输入。用户按住空格键录音音频流式传输到 STT 后端,实时转录显示在终端中。支持两个后端:
- **Anthropic STT默认**:通过 WebSocket 流式传输到 Nova 3 端点,需要 Anthropic OAuth
- **豆包 ASRDoubao**:通过 `doubaoime-asr` 包的 AsyncGenerator 协议流式识别,使用独立凭证文件,无需 Anthropic OAuth
### 核心特性
- **Push-to-Talk**:长按空格键录音,释放后自动发送
- **流式转录**:录音过程中实时显示中间转录结果
- **无缝集成**:转录文本直接作为用户消息提交到对话
- **双后端切换**:通过 `/voice` 命令参数选择 STT 后端,持久化到 settings.json
## 二、用户交互
| 操作 | 行为 |
|------|------|
| 长按空格 | 开始录音,显示录音状态 |
| 释放空格 | 停止录音,等待最终转录 |
| 转录完成 | 自动插入到输入框并提交 |
| `/voice` 命令 | 切换语音模式开关 |
| 释放空格 | 停止录音,转录结果自动提交 |
| `/voice` | 切换语音模式开关(默认使用 Anthropic 后端) |
| `/voice doubao` | 启用语音模式并使用豆包 ASR 后端 |
| `/voice anthropic` | 切换回 Anthropic STT 后端 |
### UI 反馈
@@ -35,26 +40,37 @@ VOICE_MODE 实现"按键说话"Push-to-Talk语音输入。用户按住空
文件:`src/voice/voiceModeEnabled.ts`
层检查:
层检查函数
```ts
// Anthropic 后端(需要 OAuth
isVoiceModeEnabled() = hasVoiceAuth() && isVoiceGrowthBookEnabled()
// 豆包后端 / 通用可用性检查(不需要 OAuth
isVoiceAvailable() = isVoiceGrowthBookEnabled()
```
1. **Feature Flag**`feature('VOICE_MODE')` — 编译时/运行时开关
2. **GrowthBook Kill-Switch**`!getFeatureValue_CACHED_MAY_BE_STALE('tengu_amber_quartz_disabled', false)` — 紧急关闭开关(默认 false = 未禁用)
3. **Auth 检查**`hasVoiceAuth()` — 需要 Anthropic OAuth token非 API key
3. **Auth 检查(仅 Anthropic**`hasVoiceAuth()` — 需要 Anthropic OAuth token非 API key
4. **Provider 检查**`voiceProvider` 设置决定使用哪个后端,豆包后端跳过 OAuth 检查
### 3.2 核心模块
| 模块 | 职责 |
|------|------|
| `src/voice/voiceModeEnabled.ts` | Feature flag + GrowthBook + Auth 三层门控 |
| `src/hooks/useVoice.ts` | React hook 管理录音状态和 WebSocket 连接 |
| `src/services/voiceStreamSTT.ts` | WebSocket 流式传输到 Anthropic STT |
| `src/hooks/useVoice.ts` | React hook 管理录音状态和后端连接 |
| `src/services/voiceStreamSTT.ts` | Anthropic WebSocket 流式 STT |
| `src/services/doubaoSTT.ts` | 豆包 ASR 适配器AsyncGenerator → VoiceStreamConnection |
| `src/commands/voice/voice.ts` | `/voice` 命令实现,处理后端选择和持久化 |
| `src/hooks/useVoiceEnabled.ts` | 语音启用状态 hook根据 provider 决定是否跳过 OAuth |
| `src/utils/settings/types.ts` | `voiceProvider: 'anthropic' | 'doubao'` 设置类型定义 |
### 3.3 数据流
#### Anthropic 后端
```
用户按下空格键
@@ -79,20 +95,108 @@ WebSocket 连接到 Anthropic STT 端点
转录文本 → 插入输入框 → 自动提交
```
#### 豆包 ASR 后端
```
用户按下空格键
useVoice hook 激活(检测到 voiceProvider === 'doubao'
macOS 原生音频 / SoX 开始录音
connectDoubaoStream() 创建 AudioChunkQueue + VoiceStreamConnection
├──→ onReady 立即触发(无需等待握手)
音频数据通过 AudioChunkQueue 传入 transcribeRealtime()
├──→ INTERIM_RESULT → 实时显示中间转录
├──→ FINAL_RESULT → 显示最终转录
用户释放空格键
finalize() 立即返回(豆包在录音过程中已返回结果,无需等待)
转录文本 → 插入输入框 → 自动提交
```
### 3.4 音频录制
支持两种音频后端:
支持两种音频后端(两个 STT 后端共享)
- **macOS 原生音频**:优先使用,低延迟
- **SoXSound eXchange**:回退方案,跨平台
音频流通过 WebSocket 发送到 Anthropic 的 Nova 3 STT 模型。
### 3.5 豆包 ASR 适配器设计
文件:`src/services/doubaoSTT.ts`
豆包后端使用适配器模式,将 `doubaoime-asr` 的 AsyncGenerator 协议桥接到 `VoiceStreamConnection` 接口:
**AudioChunkQueue** — push 式异步队列:
- 实现 `AsyncIterable<Uint8Array>` 接口
- `push(chunk)` 将音频数据入队,`push(null)` 发送结束信号
- 内部维护等待者waiting和缓冲队列chunks两个状态
**connectDoubaoStream()** — 连接入口:
- 动态导入 `doubaoime-asr`optionalDependencies
-`~/.claude/tts/doubao/credentials.json` 加载凭证
- 创建 AudioChunkQueue 和 VoiceStreamConnection
- 立即触发 `onReady`(避免与 useVoice 的音频缓冲死锁)
- `finalize()` 立即返回(豆包在录音过程中已返回结果)
- 后台 async IIFE 消费 `transcribeRealtime` generator映射响应类型到回调
**响应类型映射**
| doubaoime-asr ResponseType | 回调映射 |
|----------------------------|----------|
| SESSION_STARTED | 日志记录 |
| VAD_START | 日志记录 |
| INTERIM_RESULT | `onTranscript(text, false)` |
| FINAL_RESULT | `onTranscript(text, true)` |
| ERROR | `onError(errorMsg)` |
| SESSION_FINISHED | 日志记录 |
### 3.6 后端选择逻辑
文件:`src/hooks/useVoice.ts`
```ts
// 判断当前 provider
isDoubaoProvider() settings.voiceProvider
// handleKeyEvent 中的可用性检查
const sttAvailable = isDoubaoProvider()
? isDoubaoAvailableSync() // 乐观检查(首次返回 true
: isVoiceStreamAvailable() // Anthropic WebSocket 检查
// attemptConnect 中的连接函数选择
const connectFn = isDoubaoProvider()
? connectDoubaoStream
: connectVoiceStream
```
豆包后端的特殊处理:
- 跳过 `getVoiceKeyterms()` 调用(豆包无需关键词提示)
- 跳过 Focus Mode`if (!enabled || !focusMode || isDoubaoProvider())`
## 四、关键设计决策
1. **OAuth 独占**:语音模式使用 `voice_stream` 端点claude.ai仅 Anthropic OAuth 用户可用。API key、Bedrock、Vertex 用户无法使用
2. **GrowthBook 负向门控**`tengu_amber_quartz_disabled` 默认 `false`,新安装自动可用(无需等 GrowthBook 初始化)
3. **Keychain 缓存**`getClaudeAIOAuthTokens()` 首次调用访问 macOS keychain~20-50ms后续缓存命中
4. **独立于主 feature flag**`isVoiceGrowthBookEnabled()` 在 feature flag 关闭时短路返回 `false`,不触发任何模块加载
1. **双后端共存**:豆包后端作为独立适配器与 Anthropic 后端并存,不替换原有流程,通过 `voiceProvider` 设置切换
2. **设置持久化**`voiceProvider` 存储在 `settings.json`,通过 `/voice` 命令修改,跨会话生效
3. **OAuth 独占Anthropic**Anthropic 后端使用 `voice_stream` 端点claude.ai仅 OAuth 用户可用
4. **豆包无需 OAuth**:豆包后端使用独立凭证文件,不依赖 Anthropic 认证,通过 `isVoiceAvailable()` 放宽门控
5. **GrowthBook 负向门控**`tengu_amber_quartz_disabled` 默认 `false`,新安装自动可用
6. **onReady 立即触发**:豆包后端在连接建立后立即触发 `onReady`,避免与 useVoice 音频缓冲的时序死锁Anthropic 需要等待 WebSocket 握手)
7. **finalize() 立即返回**:豆包在录音过程中已返回所有结果,用户抬手时无需等待处理
8. **乐观可用性检查**`isDoubaoAvailableSync()` 在首次调用时返回 `true`,实际导入错误在 `connectDoubaoStream` 中处理
9. **optionalDependencies**`doubaoime-asr` 作为可选依赖,安装失败不影响 Anthropic 后端
## 五、使用方式
@@ -100,26 +204,60 @@ WebSocket 连接到 Anthropic STT 端点
# 启用 feature
FEATURE_VOICE_MODE=1 bun run dev
# 在 REPL 中使用
# 在 REPL 中使用 Anthropic 后端
# 1. 确保已通过 OAuth 登录claude.ai 订阅)
# 2. 按住空格键说话
# 3. 释放空格键等待转录
# 4. 或使用 /voice 命令切换开关
# 2. 输入 /voice 启用
# 3. 按住空格键说话
# 4. 释放空格键等待转录
# 在 REPL 中使用豆包 ASR 后端
# 1. 确保 doubaoime-asr 已安装bun add doubaoime-asr
# 2. 配置凭证文件:~/.claude/tts/doubao/credentials.json
# 3. 输入 /voice doubao 启用
# 4. 按住空格键说话
# 5. 释放空格键,转录结果即刻显示
# 切换后端
/voice doubao # 切换到豆包 ASR
/voice anthropic # 切换回 Anthropic STT
/voice # 关闭语音模式
```
### 豆包凭证配置
凭证文件路径:`~/.claude/tts/doubao/credentials.json`
```json
{
"deviceId": "...",
"installId": "...",
"cdid": "...",
"openudid": "...",
"clientudid": "...",
"token": "..."
}
```
## 六、外部依赖
| 依赖 | 说明 |
|------|------|
| Anthropic OAuth | claude.ai 订阅登录,非 API key |
| GrowthBook | `tengu_amber_quartz_disabled` 紧急关闭 |
| macOS 原生音频 或 SoX | 音频录制 |
| Nova 3 STT | 语音转文本模型 |
| 依赖 | 说明 | 适用后端 |
|------|------|----------|
| Anthropic OAuth | claude.ai 订阅登录,非 API key | Anthropic |
| GrowthBook | `tengu_amber_quartz_disabled` 紧急关闭 | 通用 |
| macOS 原生音频 或 SoX | 音频录制 | 通用 |
| Nova 3 STT | Anthropic 语音转文本模型 | Anthropic |
| doubaoime-asr | 豆包 ASR SDKoptionalDependencies | 豆包 |
| 凭证文件 | `~/.claude/tts/doubao/credentials.json` | 豆包 |
## 七、文件索引
| 文件 | 行数 | 职责 |
|------|------|------|
| `src/voice/voiceModeEnabled.ts` | 54 | 三层门控逻辑 |
| `src/hooks/useVoice.ts` | — | React hook录音状态 + WebSocket |
| `src/services/voiceStreamSTT.ts` | — | STT WebSocket 流式传输 |
| 文件 | 职责 |
|------|------|
| `src/voice/voiceModeEnabled.ts` | 三层门控逻辑 + `isVoiceAvailable()` |
| `src/hooks/useVoice.ts` | React hook录音状态 + 后端选择 + 连接管理 |
| `src/hooks/useVoiceEnabled.ts` | 语音启用状态 hook按 provider 决定 OAuth 检查) |
| `src/services/voiceStreamSTT.ts` | Anthropic STT WebSocket 流式传输 |
| `src/services/doubaoSTT.ts` | 豆包 ASR 适配器AudioChunkQueue + connectDoubaoStream |
| `src/commands/voice/voice.ts` | `/voice` 命令(开关 + 后端选择) |
| `src/commands/voice/index.ts` | 命令注册(去除 availability 限制) |
| `src/utils/settings/types.ts` | `voiceProvider` 类型定义 |

View File

@@ -0,0 +1,564 @@
# Agent 通讯修复 Jira Task
- 版本v1.0
- 生成日期2026-04-25
- 来源由按文件执行清单、Claude 交叉验证意见整理合并
- 范围ACP Agent / Bridge / Remote Control Server / REPL Hook 生命周期
- 使用方式:这是唯一执行任务文档;每个 `JIRA-*` 小节可直接拆成一个 Jira issue字段保持统一便于复制或二次导入。
---
## 方案性质
本文档是目标状态式执行方案,不是临时补丁清单。每张 ticket 必须交付明确的代码终态、测试覆盖和回归边界;不得只用局部 workaround 掩盖问题。
---
## 执行总则
1. 先边界安全,后内部优化:先修 WS 入站大小与输入校验,避免线上风险扩大。
2. 单文件可回滚:每个文件内修改保持内聚,便于回滚与 bisect。
3. 不改协议语义,只修实现缺陷:除 `resource_link` 表达形式统一外,不改变主流程契约。
4. 每个文件必须有验收输出:要么测试用例,要么日志/指标验证。
5. 发布前必须确认协议层行为无回归:`stopReason` 决策与 `sessionUpdate` 发送顺序保持稳定。
---
## Epic
### JIRA-EPIC-001提升 Agent 通讯链路稳定性与边界安全
- Issue TypeEpic
- PriorityP0
- Owner核心通讯 / 后端网关 / QA
- ScopeACP Agent、ACP Bridge、Remote Control Server、REPL 初始化生命周期
- Goal修复长会话资源泄漏、补齐 WebSocket 入站边界、统一 prompt 转换、收敛类型风险,并补充关键回归测试。
#### Epic 验收标准
- `bun run typecheck` 0 error。
- P0 WebSocket 超大消息拒绝逻辑已实现并覆盖测试。
- ACP bridge abort listener 生命周期无累积。
- prompt 转换实现单源化。
- settings/defaultMode 能真实影响 ACP permission mode`_meta.permissionMode` 保持最高优先级。
- REPL 目标 hook suppress 清理完成timer cleanup 完整。
---
## P0 Tickets
### JIRA-001为 session ingress WebSocket 补齐消息大小限制
- Issue TypeBug
- PriorityP0
- Story Points3
- Owner后端/网关
- Files
- `packages/remote-control-server/src/routes/v1/session-ingress.ts`
- 后续票JIRA-008同文件 P1 类型与 decode path 收尾)
#### 参考代码位置
- `packages/remote-control-server/src/routes/v1/session-ingress.ts:100-106`
#### 背景
`session-ingress` 当前缺少 WebSocket message size limit。ACP 路由已有类似限制,两个入口边界不一致,可能导致大包占用内存或绕过入口保护。
#### 实施要求
- 新增 `MAX_WS_MESSAGE_SIZE = 10 * 1024 * 1024`,与 ACP 路由的 10MB 上限保持一致。
-`onMessage` decode 后优先检查 payload size。
- 超限时执行 `ws.close(1009, "message too large")`
- 日志记录 `sessionId`、payload size、limit。
-`string``ArrayBuffer``Uint8Array` 进行统一 decode 分流。
- 非支持类型直接拒绝并记录,不进入业务 handler。
#### 验收标准
- 11MB payload 被 1009 close。
- 1KB 合法 payload 仍正常进入 handler。
- 非支持类型 payload 不进入 handler。
- 不改变 URL、auth、session 解析逻辑。
#### 回归范围
- Remote Control Server session ingress WebSocket。
- 正常会话消息转发。
- WebSocket close code 行为。
#### 风险等级
- 中。入口逻辑变更可能影响特殊客户端 payload 类型。
#### 必须验证
-`packages/remote-control-server/src/__tests__/routes.test.ts` 增加 session-ingress WebSocket 大包、小包、坏类型 payload 用例。
- 运行 `bun run typecheck`
---
### JIRA-002修复 ACP bridge abort listener 生命周期泄漏
- Issue TypeBug
- PriorityP0
- Story Points3
- Owner核心通讯
- Files
- `src/services/acp/bridge.ts`
#### 参考代码位置
- `src/services/acp/bridge.ts:576-585`
#### 背景
ACP bridge 的 `Promise.race` abort 分支注册 listener 后缺少完整 cleanup。长会话或高频 next 场景可能出现 listener 累积。
#### 实施要求
- 将 abort race 改为可清理监听器写法。
- 注册 listener 后保留 handler 引用。
- `sdkMessages.next()` 先返回时必须 `removeEventListener`
- abort、throw、return 等路径都在 `finally` 中清理。
- 不改变 `stopReason` 决策逻辑。
- 不改变 `sessionUpdate` 发送顺序。
#### 验收标准
- 模拟 10k 次 next 且不 abortlistener 不增长。
- abort 场景仍返回 `cancelled`
- 原有 streaming/session update 行为无回归。
#### 回归范围
- ACP bridge streaming loop。
- 用户取消请求。
- SDK generator 异常路径。
#### 风险等级
- 中。异步控制流变更需要覆盖取消与异常路径。
#### 必须验证
- 新增 listener cleanup 单元测试。
- 运行 `bun run typecheck`
---
## P1 Tickets
### JIRA-003优化 ACP agent pending prompt 队列为 O(1) 出队
- Issue TypeTask
- PriorityP1
- Story Points5
- Owner核心通讯
- Files
- `src/services/acp/agent.ts`
#### 参考代码位置
- `src/services/acp/agent.ts:332-339`
#### 背景
当前 pending prompt 队列使用 `Map + sort` 获取下一项,排队量上升时会带来不必要的排序成本。
#### 实施要求
- 改为 `queue: string[]` + `pendingMap: Map<string, PendingPrompt>` 组合。
- 入队执行 `queue.push(id)``pendingMap.set(id, prompt)`
- 出队从队首惰性跳过已取消项。
- 取消只从 `pendingMap` 删除,不做数组中间删除。
- 保持现有取消语义和出队顺序。
#### 验收标准
- 1000 pending prompt 场景下出队顺序正确。
- 已取消 prompt 不会被 resolve。
- 出队不再依赖全量 sort。
- 1000 排队场景下出队耗时低于旧实现;测试记录旧实现复杂度风险和新实现 O(1) 出队路径。
- 行为与旧实现兼容。
#### 回归范围
- ACP prompt queue。
- 并发 prompt 请求。
- prompt cancel / resolve 边界。
#### 风险等级
- 中。队列结构变更可能引入取消边界问题。
#### 必须验证
- 新增 queue 顺序与取消测试。
- 对 1000 prompt 场景做性能断言或日志记录。
---
### JIRA-004接入真实 settings 读取并校验 ACP permission mode
- Issue TypeBug
- PriorityP1
- Story Points3
- Owner核心通讯
- Files
- `src/services/acp/agent.ts`
#### 参考代码位置
- `src/services/acp/agent.ts:465-467`
#### 背景
`getSetting()` 当前未真正接入项目配置,导致默认 permission mode 配置无法按预期生效。
#### 实施要求
- 接入项目现有 settings/config 读取逻辑。
- 仅接受合法 permission mode 枚举值。
- 非法值 fallback 到 `default`
- `_meta.permissionMode` 继续保持最高优先级。
- 不改变外部协议字段。
#### 验收标准
- settings/defaultMode 能影响默认 permission mode。
- `_meta.permissionMode` 能覆盖 settings。
- 非法 settings 值不会传播到运行时。
- 类型检查通过。
#### 回归范围
- ACP agent session 初始化。
- 权限模式同步。
- 客户端 `_meta` 覆盖逻辑。
#### 风险等级
- 中。配置优先级错误会影响权限行为。
#### 必须验证
- 新增 defaultMode / `_meta.permissionMode` 优先级测试。
- 运行 `bun run typecheck`
---
### JIRA-005单源化 ACP prompt 转换逻辑
- Issue TypeRefactor
- PriorityP1
- Story Points5
- Owner核心通讯
- Files
- `src/services/acp/agent.ts`
- `src/services/acp/bridge.ts`
- `src/services/acp/promptConversion.ts`(新增)
#### 参考代码位置
- `src/services/acp/agent.ts:754-758`
- `src/services/acp/agent.ts:764-785`
- `src/services/acp/bridge.ts:522-537`
#### 背景
ACP agent 与 bridge 存在重复 prompt 转换逻辑,`resource_link` 等 block 的输出策略容易分叉。
#### 实施要求
- 新增共享转换模块 `src/services/acp/promptConversion.ts`
- `agent.ts``bridge.ts` 改为调用共享转换函数。
- 删除 `bridge.ts``promptToQueryContent` 的真实实现;如导出仍需保留,则只允许保留调用共享函数的 wrapper。
- `resource_link` 输出改为稳定纯文本元信息,禁止 markdown link。
- 保持其他 block 转换语义不变。
#### 验收标准
- 全仓库仅保留一个真实 prompt 转换实现。
- 相同 input block 在 agent/bridge 输出一致。
- `resource_link` 不再输出 `[name](uri)` 形式。
- 相关测试覆盖转换一致性。
#### 回归范围
- ACP prompt input。
- bridge query content。
- resource link prompt 表达。
#### 风险等级
- 中。文本格式变化可能影响下游 prompt 快照或断言。
#### 必须验证
- 新增 shared conversion 单元测试。
- 全仓库搜索重复转换函数。
- 运行 `bun run typecheck`
---
### JIRA-006治理 REPL onInit effect 依赖并补齐 timer cleanup
- Issue TypeTask
- PriorityP1
- Story Points3
- Owner终端 UI
- Files
- `src/screens/REPL.tsx`
#### 参考代码位置
- `src/screens/REPL.tsx:654-662`
- `src/screens/REPL.tsx:4996-5005`
#### 背景
REPL 中目标初始化 effect 存在 hook dependency suppresswarm-up timer 也需要显式 cleanup避免频繁挂载/卸载时留下悬挂任务。
#### 实施要求
- 整理 `onInit` 生命周期,使用稳定引用或 effect 内联。
- 移除目标段 `exhaustive-deps` suppress。
- 保持 unmount cleanup 行为不变。
- warm-up effect 中记录 timeout id。
- cleanup 中执行 `clearTimeout(timeoutId)`
- 保留 `alive` 判定作为并发保护。
#### 验收标准
- 目标段不再需要 hooks lint suppress。
- 高频打开/关闭搜索栏无悬挂 timer 增长。
- REPL 初始化行为无回归。
#### 回归范围
- REPL 初始化。
- 搜索栏 warm-up。
- 组件卸载 cleanup。
#### 风险等级
- 中。React effect 依赖治理可能改变初始化时机。
#### 必须验证
- 运行 lint/typecheck。
- 手动或测试覆盖 REPL mount/unmount。
---
### JIRA-007收敛 ACP route WebSocket 事件 any 类型
- Issue TypeTask
- PriorityP1
- Story Points2
- Owner后端/网关
- Files
- `packages/remote-control-server/src/routes/acp/index.ts`
#### 参考代码位置
- `packages/remote-control-server/src/routes/acp/index.ts:108-146`
#### 背景
ACP route 中 WebSocket 事件和 socket 参数存在 `any`,降低编译期保护。
#### 实施要求
- 定义最小 WebSocket 事件类型open/message/close/error。
-`_evt: any``evt: any``ws: any` 替换为窄类型。
- 不改变 payload decode 与大小检查策略。
- 不改变现有 handler 行为。
#### 验收标准
- 编译期能捕获错误事件字段访问。
- 现有 WebSocket 行为不变。
- `bun run typecheck` 通过。
#### 回归范围
- ACP WebSocket route。
- message decode。
- close/error handler。
#### 风险等级
- 低。类型收敛为主。
#### 必须验证
- 运行 `bun run typecheck`
- 保留现有测试通过。
---
### JIRA-008收敛 session ingress WebSocket 事件类型与 decode path
- Issue TypeTask
- PriorityP1
- Story Points3
- Owner后端/网关
- Files
- `packages/remote-control-server/src/routes/v1/session-ingress.ts`
- 前置依赖JIRA-001 已合并
#### 参考代码位置
- `packages/remote-control-server/src/routes/v1/session-ingress.ts:100-106`
#### 背景
在完成 P0 size guard 后session ingress 仍需要进一步收敛事件类型与 decode path减少隐式类型风险。
#### 实施要求
- 定义或复用最小 WebSocket message event 类型。
- 将 message decode 分支集中到一个小函数。
- 保持 P0 size guard 与 close code 语义。
- 不改变 auth/session 解析。
#### 验收标准
- decode path 单一清晰。
- 不支持 payload 类型有明确拒绝路径。
- `bun run typecheck` 通过。
#### 回归范围
- Session ingress WebSocket message handling。
- P0 大包拒绝逻辑。
#### 风险等级
- 低到中。与 P0 同文件,注意避免重复改动冲突。
#### 必须验证
- 与 JIRA-001 同批测试。
- 运行 `bun run typecheck`
---
## QA Tickets
### JIRA-009补充 ACP 通讯回归测试
- Issue TypeTest
- PriorityP1
- Story Points5
- OwnerQA/核心通讯
- Files
- `src/services/acp/agent.ts`
- `src/services/acp/bridge.ts`
- `src/services/acp/promptConversion.ts`
- `src/services/acp/__tests__/agent.test.ts`
- `src/services/acp/__tests__/bridge.test.ts`
- `src/services/acp/__tests__/promptConversion.test.ts`
#### 覆盖场景
- 长会话 10k turn无 abort listener 累积。
- prompt queue 1000 并发排队,取消/出队顺序正确。
- settings/defaultMode 与 `_meta.permissionMode` 优先级正确。
- `resource_link` 转换在 agent 与 bridge 输出一致。
#### 验收标准
- 新增测试在本地稳定通过。
- 不依赖真实网络或外部服务。
- 测试 mock 遵守仓库规范,只 mock 有副作用链路。
#### 回归范围
- ACP bridge。
- ACP agent。
- prompt conversion。
- permission mode resolution。
#### 风险等级
- 中。异步测试可能有稳定性问题,需要避免时间敏感断言。
#### 必须验证
- 运行相关 `bun test`
- 运行 `bun run typecheck`
---
### JIRA-010补充 Remote Control Server WebSocket 入站回归测试
- Issue TypeTest
- PriorityP1
- Story Points3
- OwnerQA/后端
- Files
- `packages/remote-control-server/src/__tests__/routes.test.ts`
- `packages/remote-control-server/src/routes/v1/session-ingress.ts`
#### 覆盖场景
- 11MB session ingress payload 被 1009 close与 10MB 上限对齐)。
- 合法小 payload 正常进入 handler。
- 非支持 payload 类型被拒绝。
- 日志或可观测输出包含 sessionId、payload size、limit。
#### 验收标准
- 11MB payload 被 1009 close与 10MB 上限对齐)。
- 新增测试稳定通过。
- 不启动真实外部服务。
- 不改变现有 route public contract。
#### 回归范围
- RCS session ingress route。
- WebSocket message handling。
- close code 行为。
#### 风险等级
- 中。测试需要适配现有 WebSocket/mock 基础设施。
#### 必须验证
- 运行 RCS package 相关测试。
- 运行 `bun run typecheck`
---
## 推荐执行顺序
执行节奏与原计划保持一致:先完成 P0 全部改动和冒烟验证,再启动 P1 改造;测试票可穿插执行,但不得绕过 P0 gate。
1. JIRA-001先封入口大包风险。
2. JIRA-002修长会话 listener 生命周期。
3. JIRA-010补 RCS 入站测试,锁住 P0 行为。
4. JIRA-003优化 pending prompt queue。
5. JIRA-004接入 settings/defaultMode。
6. JIRA-005单源化 prompt 转换。
7. JIRA-009补 ACP 回归测试。
8. JIRA-006治理 REPL effect/timer。
9. JIRA-007收敛 ACP route 类型。
10. JIRA-008收敛 session ingress 类型与 decode path。
---
## Release Checklist
- [ ] `bun run typecheck` 0 error
- [ ] P0 tickets 已合并并测试通过
- [ ] ACP 回归测试通过
- [ ] RCS WebSocket 入站测试通过
- [ ] prompt conversion 单源化已通过代码搜索确认
- [ ] permission mode 优先级测试通过
- [ ] 协议层行为无回归stopReason 决策、sessionUpdate 发送顺序)
- [ ] REPL hook/timer 改动通过 lint/typecheck
- [ ] 最终变更说明包含风险与未覆盖项

View File

@@ -0,0 +1,74 @@
# Agent 通讯修复问题文档
- 版本v1.0
- 生成日期2026-04-25
- 范围ACP Agent / Bridge / Remote Control Server / REPL Hook 生命周期
- 配套执行文档:`docs/internals/agent-comm-fix-jira-tasks.md`
- 目的:保留决策前要问的问题、交叉验证提示词和已确认结论;不要在这里写 Jira 执行步骤。
---
## 1. 当前已确认结论
- 只保留两份交付文档:本问题文档 + Jira Task 文档。
- Jira Task 文档是唯一执行入口,包含 Owner、优先级、文件范围、验收标准、风险和验证建议。
- Claude 交叉验证结论:整体通过,无 blocking findings建议补充协议回归 gate、JIRA-001/008 依赖、代码参考位置和阈值一致性,这些建议已合并到 Jira Task 文档。
- 本次已进入业务代码修复阶段,必须运行 `bun run typecheck` 和相关回归测试。
---
## 2. 执行前必须问清的问题
1. `session-ingress` 的 WebSocket 上限是否固定为 10MB并与 ACP route 保持一致?
2. 超限 close code 是否统一使用 `1009`close reason 是否固定为 `message too large`
3. `resource_link` 的纯文本格式是否已有下游依赖,能否替代当前 markdown link 表达?
4. ACP permission mode 的真实 settings key 是哪个,非法值 fallback 是否统一为 `default`
5. `_meta.permissionMode` 是否必须始终覆盖 settings/defaultMode
6. abort listener 测试中,是否能通过 mock signal 或计数器稳定证明 10k next 后无 listener 累积?
7. pending prompt queue 的取消语义是否允许惰性清理,而不是立刻从数组中删除?
8. REPL hook suppress 的清理范围是否只限目标段,不顺手改其他 decompiled React Compiler 结构?
9. RCS WebSocket 测试应放在现有哪个 `__tests__` 布局下,是否已有 route/mock 基础设施可复用?
10. 发布 gate 是否必须包含 `stopReason` 决策与 `sessionUpdate` 发送顺序不回归?
---
## 3. 给 Claude 或 Reviewer 的复核问题
```text
请作为外部审查者,复核 docs/internals/agent-comm-fix-jira-tasks.md。
请检查:
1. 是否仍满足“按文件分工的执行清单”和“Jira task 文档”要求。
2. 是否存在遗漏的文件、验收标准、风险或前置依赖。
3. 是否有重复、误导执行者、优先级不合理或测试不可落地的问题。
4. 是否还有必须阻断实施的 finding。
请用中文输出:
- Verdict
- Blocking Findings
- Non-blocking Findings
- Suggested Edits
- Final Recommendation
不要修改文件,只输出审查意见。
```
---
## 4. 已处理的复核建议
- Release Checklist 已补充协议层行为无回归 gate。
- JIRA-001 与 JIRA-008 已明确同文件前后置关系。
- JIRA-001 到 JIRA-008 已补充参考代码位置。
- JIRA-003 已补回 1000 排队场景下的出队耗时验收。
- JIRA-008 story points 已从 2 调整为 3。
- JIRA-010 已明确 11MB payload 对齐 10MB 上限并触发 1009 close。
- 推荐执行顺序已明确 P0 gateP0 全部改动和冒烟验证完成后,再启动 P1 改造。
---
## 5. 不在本文档维护的内容
- 不维护 Jira ticket 正文;统一在 `docs/internals/agent-comm-fix-jira-tasks.md` 修改。
- 不维护业务代码实现方案;实现时按具体 ticket 读取对应文件。
- 不维护历史中间稿;旧执行清单已合并进 Jira Task 文档。

View File

@@ -200,9 +200,9 @@ LSP 服务器通过插件提供。插件的 `manifest.json` 中可以声明 LSP
|------|------|------|------|
| `command` | string | 是 | LSP 服务器可执行命令(不含空格) |
| `args` | string[] | 否 | 命令行参数 |
| `extensionToLanguage` | Record<string, string> | 是 | 文件扩展名到语言 ID 的映射(至少一个) |
| `extensionToLanguage` | `Record<string, string>` | 是 | 文件扩展名到语言 ID 的映射(至少一个) |
| `transport` | `"stdio"` \| `"socket"` | 否 | 通信方式,默认 `stdio` |
| `env` | Record<string, string> | 否 | 启动服务器时设置的环境变量 |
| `env` | `Record<string, string>` | 否 | 启动服务器时设置的环境变量 |
| `initializationOptions` | unknown | 否 | 传给服务器的初始化选项 |
| `settings` | unknown | 否 | 通过 `workspace/didChangeConfiguration` 传递的设置 |
| `workspaceFolder` | string | 否 | 工作区目录路径 |

View File

@@ -175,7 +175,7 @@ F. getCompletedResults() → 空
---
#### #8 stream_event (input_json_delta: '{"file_path":')
#### #8 stream_event (input_json_delta: `'{"file_path":'`)
```
D. yield message ✅ → REPL 追加工具输入 JSON 碎片

View File

@@ -1,6 +1,6 @@
{
"name": "claude-code-best",
"version": "1.9.2",
"version": "1.10.2",
"description": "Reverse-engineered Anthropic Claude Code CLI — interactive AI coding assistant in the terminal",
"type": "module",
"author": "claude-code-best <claude-code-best@proton.me>",
@@ -78,19 +78,19 @@
"@ant/computer-use-input": "workspace:*",
"@ant/computer-use-mcp": "workspace:*",
"@ant/computer-use-swift": "workspace:*",
"@anthropic-ai/bedrock-sdk": "^0.26.4",
"@anthropic-ai/bedrock-sdk": "^0.29.0",
"@anthropic-ai/claude-agent-sdk": "^0.2.114",
"@anthropic-ai/foundry-sdk": "^0.2.3",
"@anthropic-ai/mcpb": "^2.1.2",
"@anthropic-ai/sandbox-runtime": "^0.0.44",
"@anthropic-ai/sdk": "^0.80.0",
"@anthropic-ai/vertex-sdk": "^0.14.4",
"@anthropic-ai/sdk": "^0.81.0",
"@anthropic-ai/vertex-sdk": "^0.16.0",
"@anthropic/ink": "workspace:*",
"@aws-sdk/client-bedrock": "^3.1032.0",
"@aws-sdk/client-bedrock-runtime": "^3.1032.0",
"@aws-sdk/client-sts": "^3.1032.0",
"@aws-sdk/credential-provider-node": "^3.972.32",
"@aws-sdk/credential-providers": "^3.1032.0",
"@aws-sdk/client-bedrock": "^3.1037.0",
"@aws-sdk/client-bedrock-runtime": "^3.1037.0",
"@aws-sdk/client-sts": "^3.1037.0",
"@aws-sdk/credential-provider-node": "^3.972.36",
"@aws-sdk/credential-providers": "^3.1037.0",
"@azure/identity": "^4.13.1",
"@biomejs/biome": "^2.4.12",
"@claude-code-best/agent-tools": "workspace:*",
@@ -103,20 +103,20 @@
"@langfuse/tracing": "^5.1.0",
"@modelcontextprotocol/sdk": "^1.29.0",
"@opentelemetry/api": "^1.9.1",
"@opentelemetry/api-logs": "^0.214.0",
"@opentelemetry/api-logs": "^0.215.0",
"@opentelemetry/core": "^2.7.0",
"@opentelemetry/exporter-logs-otlp-grpc": "^0.214.0",
"@opentelemetry/exporter-logs-otlp-http": "^0.214.0",
"@opentelemetry/exporter-logs-otlp-proto": "^0.214.0",
"@opentelemetry/exporter-metrics-otlp-grpc": "^0.214.0",
"@opentelemetry/exporter-metrics-otlp-http": "^0.214.0",
"@opentelemetry/exporter-metrics-otlp-proto": "^0.214.0",
"@opentelemetry/exporter-prometheus": "^0.214.0",
"@opentelemetry/exporter-trace-otlp-grpc": "^0.214.0",
"@opentelemetry/exporter-trace-otlp-http": "^0.214.0",
"@opentelemetry/exporter-trace-otlp-proto": "^0.214.0",
"@opentelemetry/exporter-logs-otlp-grpc": "^0.215.0",
"@opentelemetry/exporter-logs-otlp-http": "^0.215.0",
"@opentelemetry/exporter-logs-otlp-proto": "^0.215.0",
"@opentelemetry/exporter-metrics-otlp-grpc": "^0.215.0",
"@opentelemetry/exporter-metrics-otlp-http": "^0.215.0",
"@opentelemetry/exporter-metrics-otlp-proto": "^0.215.0",
"@opentelemetry/exporter-prometheus": "^0.215.0",
"@opentelemetry/exporter-trace-otlp-grpc": "^0.215.0",
"@opentelemetry/exporter-trace-otlp-http": "^0.215.0",
"@opentelemetry/exporter-trace-otlp-proto": "^0.215.0",
"@opentelemetry/resources": "^2.7.0",
"@opentelemetry/sdk-logs": "^0.214.0",
"@opentelemetry/sdk-logs": "^0.215.0",
"@opentelemetry/sdk-metrics": "^2.7.0",
"@opentelemetry/sdk-trace-base": "^2.7.0",
"@opentelemetry/semantic-conventions": "^1.40.0",
@@ -144,7 +144,7 @@
"asciichart": "^1.5.25",
"audio-capture-napi": "workspace:*",
"auto-bind": "^5.0.1",
"axios": "^1.15.0",
"axios": "^1.15.2",
"bidi-js": "^1.0.3",
"cacache": "^20.0.4",
"chalk": "^5.6.2",
@@ -205,5 +205,16 @@
"xss": "^1.0.15",
"yaml": "^2.8.3",
"zod": "^4.3.6"
},
"optionalDependencies": {
"doubaoime-asr": "^0.1.0"
},
"overrides": {
"@inquirer/prompts": "8.4.2",
"@xmldom/xmldom": "0.8.13",
"follow-redirects": "1.16.0",
"hono": "4.12.15",
"postcss": "8.5.10",
"uuid": "14.0.0"
}
}

View File

@@ -286,6 +286,15 @@ export default class App extends PureComponent<Props, State> {
// ignore calling setRawMode on an handle stdin it cannot be called
if (this.isRawModeSupported()) {
this.handleSetRawMode(false)
} else {
// Even when raw mode was never enabled (e.g. non-TTY stdin on
// Windows Node.js), ensure stdin is unref'd so the process can
// exit. earlyInput may have called ref() before Ink mounted.
try {
this.props.stdin.unref()
} catch {
// stdin may already be destroyed
}
}
}

View File

@@ -12,7 +12,7 @@
"./client": "./src/client/index.ts"
},
"dependencies": {
"@anthropic-ai/sdk": "^0.80.0",
"@anthropic-ai/sdk": "^0.81.0",
"openai": "^6.33.0"
}
}

View File

@@ -121,7 +121,7 @@ describe('anthropicMessagesToOpenAI', () => {
])
})
test('strips thinking blocks', () => {
test('preserves thinking blocks as reasoning_content', () => {
const result = anthropicMessagesToOpenAI(
[
makeAssistantMsg([
@@ -131,7 +131,7 @@ describe('anthropicMessagesToOpenAI', () => {
],
[] as any,
)
expect(result).toEqual([{ role: 'assistant', content: 'visible response' }])
expect(result).toEqual([{ role: 'assistant', content: 'visible response', reasoning_content: 'internal thoughts...' }] as any)
})
test('handles full conversation with tools', () => {
@@ -299,7 +299,7 @@ describe('DeepSeek thinking mode (enableThinking)', () => {
expect(assistant.reasoning_content).toBe('Let me reason about this...')
})
test('drops thinking block when enableThinking is false (default)', () => {
test('preserves thinking block as reasoning_content even without enableThinking', () => {
const result = anthropicMessagesToOpenAI(
[
makeAssistantMsg([
@@ -311,7 +311,7 @@ describe('DeepSeek thinking mode (enableThinking)', () => {
)
const assistant = result[0] as any
expect(assistant.content).toBe('visible response')
expect(assistant.reasoning_content).toBeUndefined()
expect(assistant.reasoning_content).toBe('internal thoughts...')
})
test('preserves reasoning_content with tool_calls in same turn', () => {
@@ -352,7 +352,7 @@ describe('DeepSeek thinking mode (enableThinking)', () => {
expect(assistant.tool_calls[0].function.name).toBe('get_weather')
})
test('strips reasoning_content from previous turns', () => {
test('always preserves reasoning_content from all turns', () => {
const result = anthropicMessagesToOpenAI(
[
// Turn 1: user → assistant (with thinking)
@@ -361,7 +361,8 @@ describe('DeepSeek thinking mode (enableThinking)', () => {
{ type: 'thinking' as const, thinking: 'Turn 1 reasoning...' },
{ type: 'text', text: 'Turn 1 answer' },
]),
// Turn 2: new user message → previous reasoning should be stripped
// Turn 2: new user message → reasoning should still be preserved
// (DeepSeek requires reasoning_content to be passed back when tool calls are involved)
makeUserMsg('question 2'),
makeAssistantMsg([
{ type: 'thinking' as const, thinking: 'Turn 2 reasoning...' },
@@ -373,10 +374,9 @@ describe('DeepSeek thinking mode (enableThinking)', () => {
)
const assistants = result.filter(m => m.role === 'assistant')
// Turn 1 assistant: reasoning should be stripped (previous turn)
expect((assistants[0] as any).reasoning_content).toBeUndefined()
// Both turns preserve reasoning_content (DeepSeek API requires it for tool calls)
expect((assistants[0] as any).reasoning_content).toBe('Turn 1 reasoning...')
expect((assistants[0] as any).content).toBe('Turn 1 answer')
// Turn 2 assistant: reasoning should be preserved (current turn)
expect((assistants[1] as any).reasoning_content).toBe('Turn 2 reasoning...')
expect((assistants[1] as any).content).toBe('Turn 2 answer')
})

View File

@@ -26,16 +26,16 @@ export interface ConvertMessagesOptions {
* - system prompt → role: "system" message prepended
* - tool_use blocks → tool_calls[] on assistant message
* - tool_result blocks → role: "tool" messages
* - thinking blocks → silently dropped (or preserved as reasoning_content when enableThinking=true)
* - thinking blocks → preserved as reasoning_content (DeepSeek requires passing it back)
* - cache_control → stripped
*/
export function anthropicMessagesToOpenAI(
messages: (UserMessage | AssistantMessage)[],
systemPrompt: SystemPrompt,
options?: ConvertMessagesOptions,
// options retained for API compatibility; thinking blocks are now always preserved
_options?: ConvertMessagesOptions,
): ChatCompletionMessageParam[] {
const result: ChatCompletionMessageParam[] = []
const enableThinking = options?.enableThinking ?? false
// Prepend system prompt as system message
const systemText = systemPromptToText(systemPrompt)
@@ -46,53 +46,13 @@ export function anthropicMessagesToOpenAI(
} satisfies ChatCompletionSystemMessageParam)
}
// When thinking mode is on, detect turn boundaries so that reasoning_content
// from *previous* user turns is stripped (saves bandwidth; DeepSeek ignores it).
// A "new turn" starts when a user text message appears after at least one assistant response.
const turnBoundaries = new Set<number>()
if (enableThinking) {
let hasSeenAssistant = false
for (let i = 0; i < messages.length; i++) {
const msg = messages[i]
if (msg.type === 'assistant') {
hasSeenAssistant = true
}
if (msg.type === 'user' && hasSeenAssistant) {
const content = msg.message.content
// A user message starts a new turn if it contains any non-tool_result content
// (text, image, or other media). Tool results alone do NOT start a new turn
// because they are continuations of the previous assistant tool call.
const startsNewUserTurn =
typeof content === 'string'
? content.length > 0
: Array.isArray(content) &&
content.some(
(b: any) =>
typeof b === 'string' ||
(b &&
typeof b === 'object' &&
'type' in b &&
b.type !== 'tool_result'),
)
if (startsNewUserTurn) {
turnBoundaries.add(i)
}
}
}
}
for (let i = 0; i < messages.length; i++) {
const msg = messages[i]
for (const msg of messages) {
switch (msg.type) {
case 'user':
result.push(...convertInternalUserMessage(msg))
break
case 'assistant':
// Preserve reasoning_content unless we're before a turn boundary
// (i.e., from a previous user Q&A round)
const preserveReasoning =
enableThinking && !isBeforeAnyTurnBoundary(i, turnBoundaries)
result.push(...convertInternalAssistantMessage(msg, preserveReasoning))
result.push(...convertInternalAssistantMessage(msg))
break
default:
break
@@ -107,17 +67,6 @@ function systemPromptToText(systemPrompt: SystemPrompt): string {
return systemPrompt.filter(Boolean).join('\n\n')
}
/**
* Check if index `i` falls before any turn boundary (i.e. it belongs to a previous turn).
* A message at index i is "before" a boundary if there exists a boundary j where i < j.
*/
function isBeforeAnyTurnBoundary(i: number, boundaries: Set<number>): boolean {
for (const b of boundaries) {
if (i < b) return true
}
return false
}
function convertInternalUserMessage(
msg: UserMessage,
): ChatCompletionMessageParam[] {
@@ -213,7 +162,6 @@ function convertToolResult(
function convertInternalAssistantMessage(
msg: AssistantMessage,
preserveReasoning = false,
): ChatCompletionMessageParam[] {
const content = msg.message.content
@@ -257,8 +205,10 @@ function convertInternalAssistantMessage(
typeof tu.input === 'string' ? tu.input : JSON.stringify(tu.input),
},
})
} else if (block.type === 'thinking' && preserveReasoning) {
// DeepSeek thinking mode: preserve reasoning_content for tool call iterations
} else if (block.type === 'thinking') {
// DeepSeek thinking mode: always preserve reasoning_content.
// DeepSeek requires reasoning_content to be passed back in subsequent requests,
// especially when tool calls are involved (returns 400 if missing).
const thinkingText = (block as unknown as Record<string, unknown>)
.thinking
if (typeof thinkingText === 'string' && thinkingText) {

View File

@@ -80,13 +80,17 @@ ARGUMENTS
## Authentication
By default, a random token is auto-generated on startup. Pass it as a query parameter:
By default, a random token is auto-generated on startup. Connect to the
WebSocket endpoint without putting the token in the URL:
```
ws://localhost:9315/ws?token=<your-token>
ws://localhost:9315/ws
```
Set `ACP_AUTH_TOKEN` env var to use a fixed token, or use `--no-auth` to disable (not recommended).
Set `ACP_AUTH_TOKEN` env var to use a fixed token, or use `--no-auth` to
disable (not recommended). Clients that cannot send an `Authorization` header
must send the token in a WebSocket subprotocol named
`rcs.auth.<base64url-token>`.
## RCS Upstream

View File

@@ -30,7 +30,7 @@
"@hono/node-ws": "^1.0.5",
"@stricli/auto-complete": "^1.2.4",
"@stricli/core": "^1.2.4",
"hono": "^4.7.0",
"hono": "^4.12.15",
"pino": "^10.3.0",
"pino-pretty": "^13.1.3",
"selfsigned": "^5.5.0"

View File

@@ -1,5 +1,35 @@
import { describe, test, expect } from "bun:test";
import type { ServerConfig } from "../server.js";
import { describe, test, expect, mock } from "bun:test";
import {
__testing,
decodeClientWsMessage,
MAX_CLIENT_WS_PAYLOAD_BYTES,
resolveNewSessionPermissionMode,
type ServerConfig,
} from "../server.js";
import {
authTokensEqual,
decodeWebSocketAuthProtocol,
encodeWebSocketAuthProtocol,
extractWebSocketAuthToken,
} from "../ws-auth.js";
import { buildRcsWsUrl } from "../rcs-upstream.js";
function makeTestWs(sent: unknown[]) {
type TestWs = Parameters<typeof __testing.dispatchClientMessage>[0];
return {
readyState: 1,
send: mock((message: string) => {
sent.push(JSON.parse(message));
}),
close: mock(() => {}),
raw: null,
isInner: false,
url: "",
origin: "",
protocol: "",
} as unknown as TestWs;
}
describe("Server HTTP endpoints", () => {
test("package.json has correct bin and main entries", async () => {
@@ -60,6 +90,188 @@ describe("WebSocket message types", () => {
expect(clientMessageTypes).toContain("connect");
expect(clientMessageTypes).toContain("cancel");
});
test("decodes supported client message payloads", () => {
expect(decodeClientWsMessage('{"type":"ping"}')).toEqual({ type: "ping" });
expect(
decodeClientWsMessage(Buffer.from('{"type":"prompt","payload":{"content":[]}}')),
).toEqual({ type: "prompt", payload: { content: [] } });
expect(
decodeClientWsMessage(new TextEncoder().encode('{"type":"cancel"}').buffer),
).toEqual({ type: "cancel" });
expect(
decodeClientWsMessage([
Buffer.from('{"type":"list_sessions","payload":{"cursor":"'),
Buffer.from('next"}}'),
]),
).toEqual({ type: "list_sessions", payload: { cwd: undefined, cursor: "next" } });
});
test("rejects malformed typed client payloads", () => {
expect(() => decodeClientWsMessage('{"type":"prompt"}')).toThrow(
"Invalid prompt payload",
);
expect(() =>
decodeClientWsMessage('{"type":"load_session","payload":{}}'),
).toThrow("Invalid load_session payload");
expect(() => decodeClientWsMessage('{"type":"unknown"}')).toThrow(
"Unknown message type",
);
expect(() =>
decodeClientWsMessage(
'{"type":"new_session","payload":{"permissionMode":123}}',
),
).toThrow("Invalid new_session.permissionMode");
expect(() =>
decodeClientWsMessage(
'{"type":"new_session","payload":{"permissionMode":{}}}',
),
).toThrow("Invalid new_session.permissionMode");
expect(() =>
decodeClientWsMessage(
'{"type":"new_session","payload":{"permissionMode":null}}',
),
).toThrow("Invalid new_session.permissionMode");
});
test("rejects oversized client message payloads before decoding", () => {
const payload = "x".repeat(MAX_CLIENT_WS_PAYLOAD_BYTES + 1);
expect(() => decodeClientWsMessage(payload)).toThrow("WebSocket message too large");
});
});
describe("WebSocket auth protocol", () => {
test("round-trips tokens through a WebSocket subprotocol token", () => {
const protocol = encodeWebSocketAuthProtocol("secret/token+with=symbols");
expect(protocol).toStartWith("rcs.auth.");
expect(protocol).not.toContain("secret/token");
expect(decodeWebSocketAuthProtocol(protocol)).toBe("secret/token+with=symbols");
});
test("ignores query-token style inputs", () => {
expect(decodeWebSocketAuthProtocol(undefined)).toBeUndefined();
expect(decodeWebSocketAuthProtocol("token=secret")).toBeUndefined();
expect(decodeWebSocketAuthProtocol("other, rcs.auth.")).toBeUndefined();
});
test("prefers Authorization headers and supports protocol auth", () => {
expect(
extractWebSocketAuthToken({
authorization: "Bearer header-token",
protocol: encodeWebSocketAuthProtocol("protocol-token"),
}),
).toBe("header-token");
expect(
extractWebSocketAuthToken({
protocol: encodeWebSocketAuthProtocol("protocol-token"),
}),
).toBe("protocol-token");
});
test("compares auth tokens through the shared constant-time path", () => {
expect(authTokensEqual("secret-token", "secret-token")).toBe(true);
expect(authTokensEqual("secret-token", "wrong-token")).toBe(false);
expect(authTokensEqual(undefined, "secret-token")).toBe(false);
});
});
describe("RCS upstream URL normalization", () => {
test("removes legacy token query params from WebSocket URLs", () => {
expect(
buildRcsWsUrl("http://example.test/acp/ws?token=old-secret&x=1"),
).toBe("ws://example.test/acp/ws?x=1");
});
test("adds /acp/ws for base URLs", () => {
expect(buildRcsWsUrl("https://example.test/")).toBe(
"wss://example.test/acp/ws",
);
});
});
describe("permission mode resolution", () => {
test("uses client requested non-bypass modes", () => {
expect(resolveNewSessionPermissionMode("plan", "acceptEdits")).toBe("plan");
});
test("uses local default when client does not request a mode", () => {
expect(resolveNewSessionPermissionMode(undefined, "acceptEdits")).toBe("acceptEdits");
});
test("rejects client requested bypassPermissions without local default", () => {
expect(() =>
resolveNewSessionPermissionMode("bypassPermissions", "acceptEdits"),
).toThrow("bypassPermissions requires local ACP_PERMISSION_MODE");
expect(() =>
resolveNewSessionPermissionMode("bypass", "acceptEdits"),
).toThrow("bypassPermissions requires local ACP_PERMISSION_MODE");
expect(() =>
resolveNewSessionPermissionMode("bypasspermissions", "acceptEdits"),
).toThrow("bypassPermissions requires local ACP_PERMISSION_MODE");
expect(() =>
resolveNewSessionPermissionMode("bypassPermissions", undefined),
).toThrow("bypassPermissions requires local ACP_PERMISSION_MODE");
});
test("rejects unknown client permission modes before forwarding", () => {
expect(() =>
resolveNewSessionPermissionMode("unknown-mode", "acceptEdits"),
).toThrow("Invalid permissionMode: unknown-mode");
});
test("allows bypassPermissions when local default already enables it", () => {
expect(resolveNewSessionPermissionMode("bypassPermissions", "bypassPermissions")).toBe("bypassPermissions");
expect(resolveNewSessionPermissionMode("bypass", "bypassPermissions")).toBe("bypassPermissions");
expect(resolveNewSessionPermissionMode("bypassPermissions", "bypass")).toBe("bypassPermissions");
});
test("new_session rejects client bypass before forwarding to the agent", async () => {
const sent: unknown[] = [];
const ws = makeTestWs(sent);
const originalTestInternals = process.env.ACP_LINK_TEST_INTERNALS;
process.env.ACP_LINK_TEST_INTERNALS = "1";
let unregisterClient = () => {};
let restoreMode = () => {};
try {
const newSession = mock(async () => ({
sessionId: "should-not-be-created",
}));
unregisterClient = __testing.registerClient(ws, {
connection: { newSession },
});
restoreMode = __testing.setDefaultPermissionMode("acceptEdits");
await __testing.dispatchClientMessage(ws, {
type: "new_session",
payload: {
cwd: "/tmp",
permissionMode: "bypass",
},
});
expect(newSession).not.toHaveBeenCalled();
expect(__testing.getClientSessionId(ws)).toBeNull();
expect(sent).toEqual([
{
type: "error",
payload: {
message: expect.stringContaining(
"bypassPermissions requires local ACP_PERMISSION_MODE",
),
},
},
]);
} finally {
restoreMode();
unregisterClient();
if (originalTestInternals === undefined) {
delete process.env.ACP_LINK_TEST_INTERNALS;
} else {
process.env.ACP_LINK_TEST_INTERNALS = originalTestInternals;
}
}
});
});
describe("Heartbeat constants", () => {

View File

@@ -1,4 +1,6 @@
import { createLogger } from "./logger.js";
import { decodeJsonWsMessage, WsPayloadTooLargeError } from "./ws-message.js";
import { encodeWebSocketAuthProtocol } from "./ws-auth.js";
export interface RcsUpstreamConfig {
rcsUrl: string; // e.g. "http://localhost:3000"
@@ -9,6 +11,18 @@ export interface RcsUpstreamConfig {
maxSessions?: number;
}
export function buildRcsWsUrl(rcsUrl: string): string {
let raw = rcsUrl;
raw = raw.replace(/^http:\/\//, "ws://").replace(/^https:\/\//, "wss://");
const url = new URL(raw);
const path = url.pathname.replace(/\/+$/, "");
if (!path || path === "/") {
url.pathname = "/acp/ws";
}
url.searchParams.delete("token");
return url.toString();
}
/**
* RCS upstream client — connects acp-link to a Remote Control Server.
*
@@ -87,17 +101,7 @@ export class RcsUpstreamClient {
/** Normalize RCS URL: accept http(s) base URL and convert to ws(s) + /acp/ws path */
private buildWsUrl(): string {
let raw = this.config.rcsUrl;
raw = raw.replace(/^http:\/\//, "ws://").replace(/^https:\/\//, "wss://");
const url = new URL(raw);
const path = url.pathname.replace(/\/+$/, "");
if (!path || path === "/") {
url.pathname = "/acp/ws";
}
if (this.config.apiToken) {
url.searchParams.set("token", this.config.apiToken);
}
return url.toString();
return buildRcsWsUrl(this.config.rcsUrl);
}
/** Open connection to RCS: REST register → WS identify */
@@ -121,7 +125,9 @@ export class RcsUpstreamClient {
return new Promise((resolve, reject) => {
try {
this.ws = new WebSocket(wsUrl);
this.ws = new WebSocket(wsUrl, [
encodeWebSocketAuthProtocol(this.config.apiToken),
]);
this.ws.onopen = () => {
RcsUpstreamClient.log.debug("ws open — sending identify");
@@ -136,8 +142,13 @@ export class RcsUpstreamClient {
this.ws.onmessage = (event) => {
let data: Record<string, unknown>;
try {
data = JSON.parse(event.data as string);
} catch {
data = decodeJsonWsMessage(event.data);
} catch (err) {
if (err instanceof WsPayloadTooLargeError) {
RcsUpstreamClient.log.warn({ error: err.message }, "server message too large");
this.ws?.close(1009, "message too large");
return;
}
RcsUpstreamClient.log.warn({ raw: String(event.data).slice(0, 200) }, "invalid JSON from server");
return;
}
@@ -152,11 +163,7 @@ export class RcsUpstreamClient {
.replace(/\/acp\/ws.*$/, "")
.replace(/\/$/, "");
console.log();
if (this.sessionId) {
console.log(` 🔗 Dashboard: ${webBase}/code/?sid=${this.sessionId}`);
} else {
console.log(` 🔗 Dashboard: ${webBase}/code/`);
}
console.log(` 🔗 Dashboard: ${webBase}/code/`);
if (this.agentId) {
console.log(` Agent ID: ${this.agentId}`);
}

View File

@@ -10,6 +10,13 @@ import type { WebSocket as RawWebSocket } from "ws";
import { createLogger } from "./logger.js";
import { getOrCreateCertificate, getLanIPs } from "./cert.js";
import { RcsUpstreamClient, type RcsUpstreamConfig } from "./rcs-upstream.js";
import {
decodeJsonWsMessage,
WsPayloadTooLargeError,
} from "./ws-message.js";
import { authTokensEqual, extractWebSocketAuthToken } from "./ws-auth.js";
export { MAX_CLIENT_WS_PAYLOAD_BYTES } from "./ws-message.js";
export interface ServerConfig {
port: number;
@@ -251,6 +258,7 @@ async function handleConnect(ws: WSContext): Promise<void> {
const agentProcess = spawn(AGENT_COMMAND, AGENT_ARGS, {
cwd: AGENT_CWD,
stdio: ["pipe", "pipe", "inherit"],
env: buildAgentEnv(),
});
state.process = agentProcess;
@@ -334,7 +342,16 @@ async function handleNewSession(
try {
const sessionCwd = params.cwd || AGENT_CWD;
const permissionMode = params.permissionMode || DEFAULT_PERMISSION_MODE;
let permissionMode: string | undefined;
try {
permissionMode = resolveNewSessionPermissionMode(
params.permissionMode,
DEFAULT_PERMISSION_MODE,
);
} catch (error) {
send(ws, "error", { message: (error as Error).message });
return;
}
const result = await state.connection.newSession({
cwd: sessionCwd,
mcpServers: [],
@@ -590,9 +607,326 @@ interface ContentBlock {
name?: string;
}
interface ProxyMessage {
type: "connect" | "disconnect" | "new_session" | "prompt" | "cancel" | "set_session_model";
payload?: { cwd?: string } | { content: ContentBlock[] } | { modelId: string };
type PermissionResponsePayload = {
requestId: string;
outcome: { outcome: "cancelled" } | { outcome: "selected"; optionId: string };
};
type ProxyMessage =
| { type: "connect" }
| { type: "disconnect" }
| { type: "new_session"; payload: { cwd?: string; permissionMode?: string } }
| { type: "prompt"; payload: { content: ContentBlock[] } }
| { type: "permission_response"; payload: PermissionResponsePayload }
| { type: "cancel" }
| { type: "set_session_model"; payload: { modelId: string } }
| { type: "list_sessions"; payload: { cwd?: string; cursor?: string } }
| { type: "load_session"; payload: { sessionId: string; cwd?: string } }
| { type: "resume_session"; payload: { sessionId: string; cwd?: string } }
| { type: "ping" };
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function optionalString(value: unknown): string | undefined {
return typeof value === "string" ? value : undefined;
}
function optionalStringField(
payload: Record<string, unknown>,
key: string,
source: string,
): string | undefined {
if (!Object.hasOwn(payload, key)) return undefined;
const value = payload[key];
if (typeof value === "string") return value;
throw new Error(`Invalid ${source}: expected a string`);
}
function payloadRecord(value: unknown, type: string): Record<string, unknown> {
if (!isRecord(value)) {
throw new Error(`Invalid ${type} payload`);
}
return value;
}
function optionalPayloadRecord(value: unknown, type: string): Record<string, unknown> {
if (value === undefined) return {};
return payloadRecord(value, type);
}
function optionalRecord(value: unknown): Record<string, unknown> {
return isRecord(value) ? value : {};
}
function decodeContentBlocks(value: unknown): ContentBlock[] {
if (
!Array.isArray(value) ||
!value.every(block => isRecord(block) && typeof block.type === "string")
) {
throw new Error("Invalid prompt payload");
}
return value as ContentBlock[];
}
function decodePermissionResponsePayload(value: unknown): PermissionResponsePayload {
const payload = payloadRecord(value, "permission_response");
if (typeof payload.requestId !== "string" || !isRecord(payload.outcome)) {
throw new Error("Invalid permission_response payload");
}
if (payload.outcome.outcome === "cancelled") {
return { requestId: payload.requestId, outcome: { outcome: "cancelled" } };
}
if (
payload.outcome.outcome === "selected" &&
typeof payload.outcome.optionId === "string"
) {
return {
requestId: payload.requestId,
outcome: { outcome: "selected", optionId: payload.outcome.optionId },
};
}
throw new Error("Invalid permission_response payload");
}
function decodeClientMessage(message: Record<string, unknown>): ProxyMessage {
if (typeof message.type !== "string") {
throw new Error("Invalid WebSocket message payload");
}
switch (message.type) {
case "connect":
case "disconnect":
case "cancel":
case "ping":
return { type: message.type };
case "new_session": {
const payload = optionalPayloadRecord(message.payload, "new_session");
return {
type: "new_session",
payload: {
cwd: optionalStringField(payload, "cwd", "new_session.cwd"),
permissionMode: optionalStringField(
payload,
"permissionMode",
"new_session.permissionMode",
),
},
};
}
case "prompt": {
const payload = payloadRecord(message.payload, "prompt");
return {
type: "prompt",
payload: { content: decodeContentBlocks(payload.content) },
};
}
case "permission_response":
return {
type: "permission_response",
payload: decodePermissionResponsePayload(message.payload),
};
case "set_session_model": {
const payload = payloadRecord(message.payload, "set_session_model");
if (typeof payload.modelId !== "string") {
throw new Error("Invalid set_session_model payload");
}
return { type: "set_session_model", payload: { modelId: payload.modelId } };
}
case "list_sessions": {
const payload = optionalRecord(message.payload);
return {
type: "list_sessions",
payload: {
cwd: optionalString(payload.cwd),
cursor: optionalString(payload.cursor),
},
};
}
case "load_session":
case "resume_session": {
const payload = payloadRecord(message.payload, message.type);
if (typeof payload.sessionId !== "string") {
throw new Error(`Invalid ${message.type} payload`);
}
return {
type: message.type,
payload: {
sessionId: payload.sessionId,
cwd: optionalString(payload.cwd),
},
};
}
default:
throw new Error(`Unknown message type: ${message.type}`);
}
}
export function decodeClientWsMessage(data: unknown): ProxyMessage {
return decodeClientMessage(decodeJsonWsMessage(data));
}
async function dispatchClientMessage(ws: WSContext, data: ProxyMessage): Promise<void> {
switch (data.type) {
case "connect":
await handleConnect(ws);
break;
case "disconnect":
handleDisconnect(ws);
break;
case "new_session":
await handleNewSession(ws, data.payload);
break;
case "prompt":
await handlePrompt(ws, data.payload);
break;
case "permission_response":
handlePermissionResponse(ws, data.payload);
break;
case "cancel":
await handleCancel(ws);
break;
case "set_session_model":
await handleSetSessionModel(ws, data.payload);
break;
case "list_sessions":
await handleListSessions(ws, data.payload);
break;
case "load_session":
await handleLoadSession(ws, data.payload);
break;
case "resume_session":
await handleResumeSession(ws, data.payload);
break;
case "ping":
send(ws, "pong");
break;
}
}
export const __testing = {
dispatchClientMessage(
ws: WSContext,
data: unknown,
): Promise<void> {
assertTestingInternalsEnabled();
return dispatchClientMessage(ws, data as ProxyMessage);
},
registerClient(
ws: WSContext,
state: {
connection?: unknown;
process?: ChildProcess | null;
sessionId?: string | null;
},
): () => void {
assertTestingInternalsEnabled();
clients.set(ws, {
process: state.process ?? null,
connection: (state.connection ?? null) as acp.ClientSideConnection | null,
sessionId: state.sessionId ?? null,
pendingPermissions: new Map(),
agentCapabilities: null,
promptCapabilities: null,
modelState: null,
isAlive: true,
});
return () => {
clients.delete(ws);
};
},
getClientSessionId(ws: WSContext): string | null | undefined {
assertTestingInternalsEnabled();
return clients.get(ws)?.sessionId;
},
setDefaultPermissionMode(mode: string | undefined): () => void {
assertTestingInternalsEnabled();
const previous = DEFAULT_PERMISSION_MODE;
DEFAULT_PERMISSION_MODE = mode;
return () => {
DEFAULT_PERMISSION_MODE = previous;
};
},
};
function assertTestingInternalsEnabled(): void {
if (process.env.ACP_LINK_TEST_INTERNALS === "1") {
return;
}
throw new Error(
"acp-link test internals are disabled outside test execution.",
);
}
const ACP_LINK_PERMISSION_MODE_ALIASES = {
auto: "auto",
default: "default",
acceptedits: "acceptEdits",
dontask: "dontAsk",
plan: "plan",
bypasspermissions: "bypassPermissions",
bypass: "bypassPermissions",
} as const;
type AcpLinkPermissionMode =
(typeof ACP_LINK_PERMISSION_MODE_ALIASES)[keyof typeof ACP_LINK_PERMISSION_MODE_ALIASES];
export function resolveNewSessionPermissionMode(
requestedMode: string | undefined,
defaultMode: string | undefined,
): string | undefined {
const requested = resolveAcpLinkPermissionMode(requestedMode);
const localDefault = resolveAcpLinkPermissionMode(defaultMode);
if (!requested) {
return localDefault;
}
if (requested !== "bypassPermissions") {
return requested;
}
if (localDefault === "bypassPermissions") {
return "bypassPermissions";
}
throw new Error(
"bypassPermissions requires local ACP_PERMISSION_MODE=bypassPermissions before a client can request it.",
);
}
function resolveAcpLinkPermissionMode(
mode: string | undefined,
): AcpLinkPermissionMode | undefined {
if (mode === undefined) return undefined;
const normalized = mode?.trim().toLowerCase();
if (!normalized) {
throw new Error("Invalid permissionMode: expected a non-empty string.");
}
const resolved =
ACP_LINK_PERMISSION_MODE_ALIASES[
normalized as keyof typeof ACP_LINK_PERMISSION_MODE_ALIASES
];
if (!resolved) {
throw new Error(`Invalid permissionMode: ${mode}.`);
}
return resolved;
}
function buildAgentEnv(): NodeJS.ProcessEnv {
if (!DEFAULT_PERMISSION_MODE) {
return process.env;
}
return {
...process.env,
ACP_PERMISSION_MODE: DEFAULT_PERMISSION_MODE,
};
}
export async function startServer(config: ServerConfig): Promise<void> {
@@ -638,44 +972,9 @@ export async function startServer(config: ServerConfig): Promise<void> {
rcsUpstream.setMessageHandler(async (msg) => {
try {
logRelay.debug({ type: msg.type }, "processing");
switch (msg.type) {
case "connect":
await handleConnect(relayWs);
break;
case "disconnect":
handleDisconnect(relayWs);
break;
case "new_session":
await handleNewSession(relayWs, (msg.payload as { cwd?: string; permissionMode?: string }) || {});
break;
case "prompt":
await handlePrompt(relayWs, msg.payload as { content: ContentBlock[] });
break;
case "permission_response":
handlePermissionResponse(relayWs, msg.payload as { requestId: string; outcome: { outcome: "cancelled" } | { outcome: "selected"; optionId: string } });
break;
case "cancel":
await handleCancel(relayWs);
break;
case "set_session_model":
await handleSetSessionModel(relayWs, msg.payload as { modelId: string });
break;
case "list_sessions":
await handleListSessions(relayWs, (msg.payload as { cwd?: string; cursor?: string }) || {});
break;
case "load_session":
await handleLoadSession(relayWs, msg.payload as { sessionId: string; cwd?: string });
break;
case "resume_session":
await handleResumeSession(relayWs, msg.payload as { sessionId: string; cwd?: string });
break;
case "ping":
send(relayWs, "pong");
break;
default:
logRelay.warn({ type: msg.type }, "unknown message type");
}
const data = decodeClientMessage(msg);
logRelay.debug({ type: data.type }, "processing");
await dispatchClientMessage(relayWs, data);
} catch (error) {
logRelay.error({ error: (error as Error).message }, "handler error");
}
@@ -700,9 +999,11 @@ export async function startServer(config: ServerConfig): Promise<void> {
"/ws",
upgradeWebSocket((c) => {
if (AUTH_TOKEN) {
const url = new URL(c.req.url);
const providedToken = url.searchParams.get("token");
if (providedToken !== AUTH_TOKEN) {
const providedToken = extractWebSocketAuthToken({
authorization: c.req.header("Authorization"),
protocol: c.req.header("Sec-WebSocket-Protocol"),
});
if (!authTokensEqual(providedToken, AUTH_TOKEN)) {
logWs.warn("connection rejected: invalid token");
return {
onOpen(_event, ws) {
@@ -734,63 +1035,31 @@ export async function startServer(config: ServerConfig): Promise<void> {
state.isAlive = true;
});
},
async onMessage(event, ws) {
try {
const data = JSON.parse(event.data.toString());
logWs.debug({ type: data.type }, "received");
switch (data.type) {
case "connect":
await handleConnect(ws);
break;
case "disconnect":
handleDisconnect(ws);
break;
case "new_session":
await handleNewSession(ws, (data.payload as { cwd?: string; permissionMode?: string }) || {});
break;
case "prompt":
await handlePrompt(ws, data.payload as { content: ContentBlock[] });
break;
case "permission_response":
handlePermissionResponse(ws, data.payload);
break;
case "cancel":
await handleCancel(ws);
break;
case "set_session_model":
await handleSetSessionModel(ws, data.payload as { modelId: string });
break;
case "list_sessions":
await handleListSessions(ws, (data.payload as { cwd?: string; cursor?: string }) || {});
break;
case "load_session":
await handleLoadSession(ws, data.payload as { sessionId: string; cwd?: string });
break;
case "resume_session":
await handleResumeSession(ws, data.payload as { sessionId: string; cwd?: string });
break;
case "ping":
send(ws, "pong");
break;
default:
send(ws, "error", { message: `Unknown message type: ${data.type}` });
async onMessage(event, ws) {
try {
const data = decodeClientWsMessage(event.data);
logWs.debug({ type: data.type }, "received");
await dispatchClientMessage(ws, data);
} catch (error) {
if (error instanceof WsPayloadTooLargeError) {
logWs.warn({ error: error.message }, "message too large");
ws.close(1009, "message too large");
return;
}
logWs.error({ error: (error as Error).message }, "message error");
send(ws, "error", { message: `Error: ${(error as Error).message}` });
}
} catch (error) {
logWs.error({ error: (error as Error).message }, "message error");
send(ws, "error", { message: `Error: ${(error as Error).message}` });
}
},
onClose(_event, ws) {
logWs.info("client disconnected");
const state = clients.get(ws);
if (state) {
cancelPendingPermissions(state);
}
handleDisconnect(ws);
clients.delete(ws);
},
};
},
onClose(_event, ws) {
logWs.info("client disconnected");
const state = clients.get(ws);
if (state) {
cancelPendingPermissions(state);
}
handleDisconnect(ws);
clients.delete(ws);
},
};
}),
);
@@ -855,7 +1124,7 @@ export async function startServer(config: ServerConfig): Promise<void> {
console.log(` URL: ${localWsUrl}`);
}
if (AUTH_TOKEN) {
console.log(` Token: ${AUTH_TOKEN}`);
console.log(` Token: configured`);
}
console.log();
if (!AUTH_TOKEN) {

View File

@@ -0,0 +1,62 @@
import { createHash, timingSafeEqual } from "node:crypto";
const WS_AUTH_PROTOCOL_PREFIX = "rcs.auth.";
function sha256(value: string): Buffer {
return createHash("sha256").update(value).digest();
}
export function encodeWebSocketAuthProtocol(token: string): string {
return `${WS_AUTH_PROTOCOL_PREFIX}${Buffer.from(token, "utf8").toString("base64url")}`;
}
export function decodeWebSocketAuthProtocol(protocolHeader: string | undefined): string | undefined {
if (!protocolHeader) {
return undefined;
}
for (const protocol of protocolHeader.split(",")) {
const trimmed = protocol.trim();
if (!trimmed.startsWith(WS_AUTH_PROTOCOL_PREFIX)) {
continue;
}
const encoded = trimmed.slice(WS_AUTH_PROTOCOL_PREFIX.length);
if (!encoded) {
return undefined;
}
try {
const token = Buffer.from(encoded, "base64url").toString("utf8");
return token.length > 0 ? token : undefined;
} catch {
return undefined;
}
}
return undefined;
}
export function extractBearerToken(authorizationHeader: string | undefined): string | undefined {
return authorizationHeader?.startsWith("Bearer ")
? authorizationHeader.slice("Bearer ".length)
: undefined;
}
export function extractWebSocketAuthToken(headers: {
authorization?: string;
protocol?: string;
}): string | undefined {
return extractBearerToken(headers.authorization) ??
decodeWebSocketAuthProtocol(headers.protocol);
}
export function authTokensEqual(
providedToken: string | undefined,
expectedToken: string | undefined,
): boolean {
if (!providedToken || !expectedToken) {
return false;
}
return timingSafeEqual(sha256(providedToken), sha256(expectedToken));
}

View File

@@ -0,0 +1,60 @@
export const MAX_CLIENT_WS_PAYLOAD_BYTES = 10 * 1024 * 1024;
export class WsPayloadTooLargeError extends Error {
constructor(byteLength: number) {
super(`WebSocket message too large: ${byteLength} bytes`);
this.name = "WsPayloadTooLargeError";
}
}
export interface JsonWsMessage {
type: string;
payload?: unknown;
[key: string]: unknown;
}
function assertPayloadSize(byteLength: number): void {
if (byteLength > MAX_CLIENT_WS_PAYLOAD_BYTES) {
throw new WsPayloadTooLargeError(byteLength);
}
}
function decodeWsText(data: unknown): string {
if (typeof data === "string") {
assertPayloadSize(Buffer.byteLength(data, "utf8"));
return data;
}
if (data instanceof ArrayBuffer) {
assertPayloadSize(data.byteLength);
return new TextDecoder().decode(new Uint8Array(data));
}
if (ArrayBuffer.isView(data)) {
assertPayloadSize(data.byteLength);
return new TextDecoder().decode(
new Uint8Array(data.buffer, data.byteOffset, data.byteLength),
);
}
if (Array.isArray(data) && data.every(Buffer.isBuffer)) {
const byteLength = data.reduce((total, chunk) => total + chunk.byteLength, 0);
assertPayloadSize(byteLength);
return Buffer.concat(data, byteLength).toString("utf8");
}
throw new Error("Unsupported WebSocket message payload");
}
export function decodeJsonWsMessage(data: unknown): JsonWsMessage {
const parsed = JSON.parse(decodeWsText(data)) as unknown;
if (
typeof parsed !== "object" ||
parsed === null ||
!("type" in parsed) ||
typeof parsed.type !== "string"
) {
throw new Error("Invalid WebSocket message payload");
}
return parsed as JsonWsMessage;
}

View File

@@ -1,10 +1,33 @@
import { createRequire } from 'node:module'
import { dirname, resolve, sep } from 'node:path'
import { fileURLToPath } from 'node:url'
// createRequire works in both Bun and Node.js ESM contexts.
// Needed because this package is "type": "module" but uses require() for
// loading native .node addons — bare require is not available in Node.js ESM.
const nodeRequire = createRequire(import.meta.url)
/**
* Resolve the "vendor root" directory where native .node binaries live.
*
* - Dev mode: import.meta.url → packages/audio-capture-napi/src/index.ts
* → vendor root = <project>/vendor/
* - Bun build: import.meta.url → dist/chunk-xxx.js
* → vendor root = <project>/dist/vendor/
* - Vite build: import.meta.url → dist/chunks/chunk-xxx.js
* → vendor root = <project>/dist/vendor/
*/
function getVendorRoot(): string {
const filePath = fileURLToPath(import.meta.url)
const dir = dirname(filePath)
const parts = dir.split(sep)
const distIdx = parts.lastIndexOf('dist')
if (distIdx !== -1) {
return parts.slice(0, distIdx + 1).join(sep) + sep + 'vendor'
}
// Dev mode — go up from packages/audio-capture-napi/src/ to project root
return resolve(dir, '..', '..', '..', 'vendor')
}
type AudioCaptureNapi = {
startRecording(
onData: (data: Buffer) => void,
@@ -56,15 +79,18 @@ function loadModule(): AudioCaptureNapi | null {
}
}
// Candidates 2-4: npm-install, dev/source, and workspace layouts.
// In bundled output, require() resolves relative to cli.js at the package root.
// In dev, it resolves relative to this file. When loaded from a workspace
// package (packages/audio-capture-napi/src/), we need an absolute path fallback.
// Candidates 2-5: resolved vendor path + relative fallbacks.
// The primary candidate uses getVendorRoot() to find the correct dist root
// regardless of chunk nesting depth. Relative fallbacks cover edge cases.
const platformDir = `${process.arch}-${platform}`
const binaryRel = `audio-capture/${platformDir}/audio-capture.node`
const vendorRoot = getVendorRoot()
const fallbacks = [
`./vendor/audio-capture/${platformDir}/audio-capture.node`,
`../audio-capture/${platformDir}/audio-capture.node`,
`${process.cwd()}/vendor/audio-capture/${platformDir}/audio-capture.node`,
resolve(vendorRoot, binaryRel),
`./vendor/${binaryRel}`,
`../vendor/${binaryRel}`,
`../../vendor/${binaryRel}`,
`${process.cwd()}/vendor/${binaryRel}`,
]
for (const p of fallbacks) {
try {

View File

@@ -0,0 +1,180 @@
import { describe, expect, test } from 'bun:test'
import type { Message } from 'src/types/message.js'
import { filterIncompleteToolCalls } from '../filterIncompleteToolCalls.js'
describe('filterIncompleteToolCalls', () => {
test('drops assistant tool uses that do not have matching results', () => {
const messages = [
{
type: 'assistant',
uuid: 'a1',
message: {
role: 'assistant',
content: [{ type: 'tool_use', id: 'missing', name: 'Read' }],
},
},
{
type: 'user',
uuid: 'u1',
message: { role: 'user', content: 'continue' },
},
] as unknown as Message[]
expect(
filterIncompleteToolCalls(messages).map(message => String(message.uuid)),
).toEqual(['u1'])
})
test('preserves assistant text when dropping orphan tool uses', () => {
const messages = [
{
type: 'assistant',
uuid: 'a1',
message: {
role: 'assistant',
content: [
{ type: 'text', text: 'I will read the file.' },
{ type: 'tool_use', id: 'missing', name: 'Read' },
],
},
},
] as unknown as Message[]
const filtered = filterIncompleteToolCalls(messages)
expect(filtered).toHaveLength(1)
const first = filtered[0]!
const content = first.message!.content
expect(
Array.isArray(content) ? content.map(block => block.type) : [],
).toEqual(['text'])
})
test('keeps completed parallel tool calls when dropping an orphan', () => {
const messages = [
{
type: 'assistant',
uuid: 'a1',
message: {
role: 'assistant',
content: [
{ type: 'tool_use', id: 'done', name: 'Read' },
{ type: 'tool_use', id: 'missing', name: 'Grep' },
],
},
},
{
type: 'user',
uuid: 'u1',
message: {
role: 'user',
content: [{ type: 'tool_result', tool_use_id: 'done', content: 'ok' }],
},
},
] as unknown as Message[]
const filtered = filterIncompleteToolCalls(messages)
expect(filtered.map(message => String(message.uuid))).toEqual(['a1', 'u1'])
const first = filtered[0]!
const content = first.message!.content
expect(
Array.isArray(content)
? content.map(block =>
block.type === 'tool_use' ? block.id : block.type,
)
: [],
).toEqual(['done'])
})
test('keeps assistant tool uses that have matching results', () => {
const messages = [
{
type: 'assistant',
uuid: 'a1',
message: {
role: 'assistant',
content: [{ type: 'tool_use', id: 'done', name: 'Read' }],
},
},
{
type: 'user',
uuid: 'u1',
message: {
role: 'user',
content: [{ type: 'tool_result', tool_use_id: 'done', content: 'ok' }],
},
},
] as unknown as Message[]
expect(
filterIncompleteToolCalls(messages).map(message => String(message.uuid)),
).toEqual(['a1', 'u1'])
})
test('drops orphan tool results when their tool use was removed', () => {
const messages = [
{
type: 'user',
uuid: 'u1',
message: {
role: 'user',
content: [
{ type: 'tool_result', tool_use_id: 'missing', content: 'late' },
],
},
},
] as unknown as Message[]
expect(filterIncompleteToolCalls(messages)).toEqual([])
})
test('keeps user text while dropping orphan tool results', () => {
const messages = [
{
type: 'assistant',
uuid: 'a1',
message: { role: 'assistant', content: 'done' },
},
{
type: 'user',
uuid: 'u1',
message: {
role: 'user',
content: [
{ type: 'text', text: 'keep this' },
{ type: 'tool_result', tool_use_id: 'missing', content: 'late' },
],
},
},
] as unknown as Message[]
const filtered = filterIncompleteToolCalls(messages)
expect(filtered.map(message => String(message.uuid))).toEqual(['a1', 'u1'])
const content = filtered[1]!.message!.content
expect(Array.isArray(content) ? content : []).toEqual([
{ type: 'text', text: 'keep this' },
])
})
test('drops malformed tool blocks without ids', () => {
const messages = [
{
type: 'assistant',
uuid: 'a1',
message: {
role: 'assistant',
content: [{ type: 'tool_use', name: 'Read' }],
},
},
{
type: 'user',
uuid: 'u1',
message: {
role: 'user',
content: [{ type: 'tool_result', content: 'late' }],
},
},
] as unknown as Message[]
expect(filterIncompleteToolCalls(messages)).toEqual([])
})
})

View File

@@ -0,0 +1,110 @@
import type {
AssistantMessage,
Message,
UserMessage,
} from 'src/types/message.js'
/**
* Removes invalid or orphaned tool_use/tool_result blocks while preserving
* completed tool-call pairs. This is intentionally block-level, not
* message-level, so completed parallel tool calls stay paired with results.
*/
export function filterIncompleteToolCalls(messages: Message[]): Message[] {
const toolUseIdsWithResults = new Set<string>()
for (const message of messages) {
if (message?.type === 'user') {
const userMessage = message as UserMessage
const content = userMessage.message.content
if (Array.isArray(content)) {
for (const block of content) {
if (block.type === 'tool_result' && block.tool_use_id) {
toolUseIdsWithResults.add(block.tool_use_id)
}
}
}
}
}
const retainedToolUseIds = new Set<string>()
const withoutOrphanToolUses: Message[] = []
for (const message of messages) {
if (message?.type === 'assistant') {
const assistantMessage = message as AssistantMessage
const content = assistantMessage.message.content
if (Array.isArray(content)) {
let changed = false
const filteredContent = content.filter(block => {
if (block.type !== 'tool_use') return true
if (!block.id) {
changed = true
return false
}
if (toolUseIdsWithResults.has(block.id)) {
retainedToolUseIds.add(block.id)
return true
}
changed = true
return false
})
if (!changed) {
withoutOrphanToolUses.push(message)
continue
}
if (filteredContent.length > 0) {
withoutOrphanToolUses.push({
...assistantMessage,
message: {
...assistantMessage.message,
content: filteredContent,
},
})
}
continue
}
}
withoutOrphanToolUses.push(message)
}
const filteredMessages: Message[] = []
for (const message of withoutOrphanToolUses) {
if (message?.type !== 'user') {
filteredMessages.push(message)
continue
}
const userMessage = message as UserMessage
const content = userMessage.message.content
if (!Array.isArray(content)) {
filteredMessages.push(message)
continue
}
let changed = false
const filteredContent = content.filter(block => {
if (block.type !== 'tool_result') return true
if (!block.tool_use_id) {
changed = true
return false
}
if (retainedToolUseIds.has(block.tool_use_id)) return true
changed = true
return false
})
if (!changed) {
filteredMessages.push(message)
continue
}
if (filteredContent.length > 0) {
filteredMessages.push({
...userMessage,
message: {
...userMessage.message,
content: filteredContent,
},
})
}
}
return filteredMessages
}

View File

@@ -394,6 +394,7 @@ export const getAgentDefinitionsWithOverrides = memoize(
export function clearAgentDefinitionsCache(): void {
getAgentDefinitionsWithOverrides.cache.clear?.()
loadMarkdownFilesForSubdir.cache?.clear?.()
clearPluginAgentCache()
}

View File

@@ -86,8 +86,11 @@ import {
import type { ContentReplacementState } from 'src/utils/toolResultStorage.js'
import { createAgentId } from 'src/utils/uuid.js'
import { resolveAgentTools } from './agentToolUtils.js'
import { filterIncompleteToolCalls } from './filterIncompleteToolCalls.js'
import { type AgentDefinition, isBuiltInAgent } from './loadAgentsDir.js'
export { filterIncompleteToolCalls } from './filterIncompleteToolCalls.js'
/**
* Initialize agent-specific MCP servers
* Agents can define their own MCP servers in their frontmatter that are additive
@@ -886,50 +889,6 @@ export async function* runAgent({
}
}
/**
* Filters out assistant messages with incomplete tool calls (tool uses without results).
* This prevents API errors when sending messages with orphaned tool calls.
*/
export function filterIncompleteToolCalls(messages: Message[]): Message[] {
// Build a set of tool use IDs that have results
const toolUseIdsWithResults = new Set<string>()
for (const message of messages) {
if (message?.type === 'user') {
const userMessage = message as UserMessage
const content = userMessage.message.content
if (Array.isArray(content)) {
for (const block of content) {
if (block.type === 'tool_result' && block.tool_use_id) {
toolUseIdsWithResults.add(block.tool_use_id)
}
}
}
}
}
// Filter out assistant messages that contain tool calls without results
return messages.filter(message => {
if (message?.type === 'assistant') {
const assistantMessage = message as AssistantMessage
const content = assistantMessage.message.content
if (Array.isArray(content)) {
// Check if this assistant message has any tool uses without results
const hasIncompleteToolCall = content.some(
block =>
block.type === 'tool_use' &&
block.id &&
!toolUseIdsWithResults.has(block.id),
)
// Exclude messages with incomplete tool calls
return !hasIncompleteToolCall
}
}
// Keep all non-assistant messages and assistant messages without tool calls
return true
})
}
async function getAgentSystemPrompt(
agentDefinition: AgentDefinition,
toolUseContext: Pick<ToolUseContext, 'options'>,

View File

@@ -273,18 +273,6 @@ export const FileEditTool = buildTool({
}
const readTimestamp = toolUseContext.readFileState.get(fullFilePath)
if (!readTimestamp || readTimestamp.isPartialView) {
return {
result: false,
behavior: 'ask',
message:
'File has not been read yet. Read it first before writing to it.',
meta: {
isFilePathAbsolute: String(isAbsolute(file_path)),
},
errorCode: 6,
}
}
// Check if file exists and get its last modified time
if (readTimestamp) {

View File

@@ -186,14 +186,6 @@ export function renderToolUseErrorMessage(
extractTag(result, 'tool_use_error')
) {
const errorMessage = extractTag(result, 'tool_use_error')
// Show a less scary message for intended behavior
if (errorMessage?.includes('File has not been read yet')) {
return (
<MessageResponse>
<Text dimColor>File must be read first</Text>
</MessageResponse>
)
}
if (errorMessage?.includes(FILE_NOT_FOUND_CWD_NOTE)) {
return (
<MessageResponse>

View File

@@ -196,25 +196,18 @@ export const FileWriteTool = buildTool({
}
const readTimestamp = toolUseContext.readFileState.get(fullFilePath)
if (!readTimestamp || readTimestamp.isPartialView) {
return {
result: false,
message:
'File has not been read yet. Read it first before writing to it.',
errorCode: 2,
}
}
// Reuse mtime from the stat above — avoids a redundant statSync via
// getFileModificationTime. The readTimestamp guard above ensures this
// block is always reached when the file exists.
const lastWriteTime = Math.floor(fileMtimeMs)
if (lastWriteTime > readTimestamp.timestamp) {
return {
result: false,
message:
'File has been modified since read, either by the user or by a linter. Read it again before attempting to write it.',
errorCode: 3,
// getFileModificationTime.
if (readTimestamp) {
const lastWriteTime = Math.floor(fileMtimeMs)
if (lastWriteTime > readTimestamp.timestamp) {
return {
result: false,
message:
'File has been modified since read, either by the user or by a linter. Read it again before attempting to write it.',
errorCode: 3,
}
}
}

View File

@@ -84,22 +84,48 @@ Use this tool to discover messaging targets before sending cross-session message
// UDS socket directory. The implementation scans for live sockets
// and optionally includes Remote Control bridge peers.
const peers: PeerInfo[] = []
const seen = new Set<string>()
const addPeer = (peer: PeerInfo): void => {
if (seen.has(peer.address)) return
seen.add(peer.address)
peers.push(peer)
}
// Discovery is handled by the UDS messaging subsystem initialized in setup.ts.
// Return discovered peers from the app state.
const appState = context.getAppState()
const messagingSocketPath = (appState as Record<string, unknown>).messagingSocketPath as string | undefined
/* eslint-disable @typescript-eslint/no-require-imports */
const udsMessaging =
require('src/utils/udsMessaging.js') as typeof import('src/utils/udsMessaging.js')
const udsClient =
require('src/utils/udsClient.js') as typeof import('src/utils/udsClient.js')
const bridgePeers =
require('src/bridge/peerSessions.js') as typeof import('src/bridge/peerSessions.js')
/* eslint-enable @typescript-eslint/no-require-imports */
const messagingSocketPath = udsMessaging.getUdsMessagingSocketPath()
if (messagingSocketPath) {
// Self entry for reference
if (_input.include_self) {
peers.push({
address: `uds:${messagingSocketPath}`,
addPeer({
address: udsMessaging.formatUdsAddress(messagingSocketPath),
name: 'self',
pid: process.pid,
})
}
}
for (const peer of await udsClient.listPeers()) {
if (!peer.messagingSocketPath) continue
addPeer({
address: udsMessaging.formatUdsAddress(peer.messagingSocketPath),
name: peer.name ?? peer.kind,
cwd: peer.cwd,
pid: peer.pid,
})
}
for (const peer of await bridgePeers.listBridgePeers()) {
addPeer(peer)
}
return {
data: { peers },
}

View File

@@ -421,7 +421,7 @@ export const PowerShellTool = buildTool({
isSearch: boolean
isRead: boolean
} {
if (!input.command) {
if (!input?.command) {
return { isSearch: false, isRead: false }
}
return isSearchOrReadPowerShellCommand(input.command)

View File

@@ -130,6 +130,41 @@ export type SendMessageToolOutput =
| RequestOutput
| ResponseOutput
const UDS_INLINE_TOKEN_MARKER = '#token='
function stripInlineUdsToken(target: string): string {
const markerIndex = target.indexOf(UDS_INLINE_TOKEN_MARKER)
return markerIndex === -1 ? target : target.slice(0, markerIndex)
}
function hasInlineUdsToken(to: string): boolean {
const addr = parseAddress(to)
// Empty-token markers are still inline-token attempts. Observable input
// redaction preserves "#token=" so cloned inputs remain rejected.
return (
addr.scheme === 'uds' && addr.target.includes(UDS_INLINE_TOKEN_MARKER)
)
}
function recipientForDisplay(to: string): string {
const addr = parseAddress(to)
if (addr.scheme !== 'uds') return to
return `uds:${stripInlineUdsToken(addr.target)}`
}
function redactInlineUdsTokenForRejection(to: string): string {
const addr = parseAddress(to)
if (addr.scheme !== 'uds') return to
const markerIndex = addr.target.indexOf(UDS_INLINE_TOKEN_MARKER)
if (markerIndex === -1) return to
return `uds:${addr.target.slice(0, markerIndex)}${UDS_INLINE_TOKEN_MARKER}`
}
function redactObservableInlineUdsToken(input: { to: string }): void {
if (!hasInlineUdsToken(input.to)) return
input.to = redactInlineUdsTokenForRejection(input.to)
}
function findTeammateColor(
appState: {
teamContext?: { teammates: { [id: string]: { color?: string } } }
@@ -541,15 +576,17 @@ export const SendMessageTool: Tool<InputSchema, SendMessageToolOutput> =
},
backfillObservableInput(input) {
if ('type' in input) return
if (typeof input.to !== 'string') return
redactObservableInlineUdsToken(input as { to: string })
if ('type' in input) return
if (input.to === '*') {
input.type = 'broadcast'
if (typeof input.message === 'string') input.content = input.message
} else if (typeof input.message === 'string') {
input.type = 'message'
input.recipient = input.to
input.recipient = recipientForDisplay(input.to)
input.content = input.message
} else if (typeof input.message === 'object' && input.message !== null) {
const msg = input.message as {
@@ -560,7 +597,7 @@ export const SendMessageTool: Tool<InputSchema, SendMessageToolOutput> =
feedback?: string
}
input.type = msg.type
input.recipient = input.to
input.recipient = recipientForDisplay(input.to)
if (msg.request_id !== undefined) input.request_id = msg.request_id
if (msg.approve !== undefined) input.approve = msg.approve
const content = msg.reason ?? msg.feedback
@@ -569,16 +606,20 @@ export const SendMessageTool: Tool<InputSchema, SendMessageToolOutput> =
},
toAutoClassifierInput(input) {
const recipient = recipientForDisplay(input.to)
if (typeof input.message === 'string') {
return `to ${input.to}: ${input.message}`
return `to ${recipient}: ${input.message}`
}
switch (input.message.type) {
case 'shutdown_request':
return `shutdown_request to ${input.to}`
return `shutdown_request to ${recipient}`
case 'shutdown_response':
return `shutdown_response ${input.message.approve ? 'approve' : 'reject'} ${input.message.request_id}`
case 'plan_approval_response':
return `plan_approval ${input.message.approve ? 'approve' : 'reject'} to ${input.to}`
const planApprovalDecision = input.message.approve
? 'approve'
: 'reject'
return `plan_approval ${planApprovalDecision} to ${recipient}`
}
},
@@ -630,6 +671,17 @@ export const SendMessageTool: Tool<InputSchema, SendMessageToolOutput> =
errorCode: 9,
}
}
if (
addr.scheme === 'uds' &&
hasInlineUdsToken(input.to)
) {
return {
result: false,
message:
'uds addresses must not include inline auth tokens; use the ListPeers address',
errorCode: 9,
}
}
if (input.to.includes('@')) {
return {
result: false,
@@ -753,6 +805,19 @@ export const SendMessageTool: Tool<InputSchema, SendMessageToolOutput> =
},
async call(input, context, canUseTool, assistantMessage) {
if (typeof input.message === 'string') {
const addr = parseAddress(input.to)
if (addr.scheme === 'uds' && hasInlineUdsToken(input.to)) {
return {
data: {
success: false,
message:
'uds addresses must not include inline auth tokens; use the ListPeers address',
},
}
}
}
if (feature('UDS_INBOX') && typeof input.message === 'string') {
const addr = parseAddress(input.to)
if (addr.scheme === 'bridge') {

View File

@@ -0,0 +1,181 @@
import { describe, expect, test } from 'bun:test'
import { SendMessageTool } from '../SendMessageTool.js'
describe('SendMessageTool UDS recipient handling', () => {
test('redacts inline UDS tokens before classifier and observable paths', async () => {
const tokenAddress = 'uds:/tmp/peer.sock#token=secret-token'
const observableInput = {
to: tokenAddress,
message: 'hello',
} as Record<string, unknown>
SendMessageTool.backfillObservableInput!(observableInput)
expect(observableInput.recipient).toBe('uds:/tmp/peer.sock')
expect(observableInput.to).toBe('uds:/tmp/peer.sock#token=')
expect(JSON.stringify(observableInput)).not.toContain('secret-token')
expect(
SendMessageTool.toAutoClassifierInput({
to: tokenAddress,
message: 'hello',
}),
).toBe('to uds:/tmp/peer.sock: hello')
})
test('keeps redacted UDS token rejection through observable backfill', async () => {
const observableInput = {
to: 'uds:/tmp/peer.sock#token=secret-token',
message: {
type: 'plan_approval_response',
request_id: 'req-1',
approve: false,
reason: 'needs tests',
},
} as Record<string, unknown>
SendMessageTool.backfillObservableInput!(observableInput)
expect(observableInput.to).toBe('uds:/tmp/peer.sock#token=')
expect(observableInput.recipient).toBe('uds:/tmp/peer.sock')
expect(observableInput.type).toBe('plan_approval_response')
expect(observableInput.request_id).toBe('req-1')
expect(observableInput.approve).toBe(false)
expect(observableInput.content).toBe('needs tests')
expect(JSON.stringify(observableInput)).not.toContain('secret-token')
const result = await SendMessageTool.validateInput!(
observableInput as never,
{} as never,
)
expect(result.result).toBe(false)
if (result.result !== false) {
throw new Error('expected validation to reject redacted inline UDS token')
}
expect(result.message).toContain('inline auth tokens')
})
test('keeps inline-token rejection when observable input is cloned', async () => {
const observableInput = {
to: 'uds:/tmp/peer.sock#token=secret-token',
message: 'hello',
} as Record<string, unknown>
SendMessageTool.backfillObservableInput!(observableInput)
const clonedInput = {
to: observableInput.to,
message: observableInput.message,
summary: 'hello peer',
}
const validation = await SendMessageTool.validateInput!(
clonedInput as never,
{} as never,
)
const result = await SendMessageTool.call(
clonedInput as never,
{} as never,
undefined as never,
undefined as never,
)
expect(validation.result).toBe(false)
expect(result.data.success).toBe(false)
expect(JSON.stringify(clonedInput)).not.toContain('secret-token')
expect(JSON.stringify(result)).not.toContain('secret-token')
})
test('redacts UDS tokens in structured classifier text', async () => {
const to = 'uds:/tmp/peer.sock#token=secret-token'
expect(
SendMessageTool.toAutoClassifierInput({
to,
message: { type: 'shutdown_request' },
}),
).toBe('shutdown_request to uds:/tmp/peer.sock')
expect(
SendMessageTool.toAutoClassifierInput({
to,
message: {
type: 'plan_approval_response',
request_id: 'req-1',
approve: true,
},
}),
).toBe('plan_approval approve to uds:/tmp/peer.sock')
expect(
SendMessageTool.toAutoClassifierInput({
to,
message: {
type: 'plan_approval_response',
request_id: 'req-2',
approve: false,
},
}),
).toBe('plan_approval reject to uds:/tmp/peer.sock')
expect(
SendMessageTool.toAutoClassifierInput({
to,
message: {
type: 'shutdown_response',
request_id: 'shutdown-1',
approve: false,
},
}),
).toBe('shutdown_response reject shutdown-1')
})
test('redacts from the first inline UDS token marker', async () => {
const tokenAddress = 'uds:/tmp/peer.sock#token=first#token=second'
const observableInput = {
to: tokenAddress,
message: 'hello',
} as Record<string, unknown>
SendMessageTool.backfillObservableInput!(observableInput)
expect(observableInput.to).toBe('uds:/tmp/peer.sock#token=')
expect(observableInput.recipient).toBe('uds:/tmp/peer.sock')
expect(JSON.stringify(observableInput)).not.toContain('first')
expect(JSON.stringify(observableInput)).not.toContain('second')
expect(
SendMessageTool.toAutoClassifierInput({
to: tokenAddress,
message: 'hello',
}),
).toBe('to uds:/tmp/peer.sock: hello')
})
test('rejects inline UDS tokens during validation', async () => {
const result = await SendMessageTool.validateInput!(
{
to: 'uds:/tmp/peer.sock#token=secret-token',
message: 'hello',
},
{} as never,
)
expect(result.result).toBe(false)
if (result.result !== false) {
throw new Error('expected validation to reject inline UDS token')
}
expect(result.message).toContain('inline auth tokens')
expect(JSON.stringify(result)).not.toContain('secret-token')
})
test('rejects inline UDS tokens during execution without leaking them', async () => {
const result = await SendMessageTool.call(
{
to: 'uds:/tmp/peer.sock#token=secret-token',
message: 'hello',
},
{} as never,
undefined as never,
undefined as never,
)
expect(result.data.success).toBe(false)
expect(JSON.stringify(result)).not.toContain('secret-token')
})
})

View File

@@ -0,0 +1,145 @@
import { beforeEach, describe, expect, mock, test } from 'bun:test'
import { logMock } from '../../../../../../tests/mocks/log'
type MockAxiosResponse = {
data: ArrayBuffer
headers: Record<string, unknown>
status: number
statusText: string
}
type MockAxiosError = Error & {
isAxiosError: true
response?: {
headers: Record<string, unknown>
status: number
}
}
let getMock: (url: string) => Promise<MockAxiosResponse>
mock.module('axios', () => {
const axiosMock = {
get: (url: string) => getMock(url),
isAxiosError: (error: unknown): error is MockAxiosError =>
typeof error === 'object' &&
error !== null &&
(error as { isAxiosError?: unknown }).isAxiosError === true,
}
return { default: axiosMock }
})
mock.module('src/services/analytics/index.js', () => ({
logEvent: () => {},
}))
mock.module('src/services/api/claude.js', () => ({
queryHaiku: async () => ({ message: { content: [] } }),
}))
mock.module('src/utils/http.js', () => ({
getWebFetchUserAgent: () => 'TestAgent/1.0',
}))
mock.module('src/utils/log.ts', logMock)
mock.module('src/utils/mcpOutputStorage.js', () => ({
isBinaryContentType: (contentType: string) =>
!contentType.toLowerCase().startsWith('text/'),
persistBinaryContent: async () => ({
filepath: '/tmp/webfetch-test.bin',
size: 0,
}),
}))
mock.module('src/utils/settings/settings.js', () => ({
getInitialSettings: () => ({}),
getSettings_DEPRECATED: () => ({ skipWebFetchPreflight: true }),
}))
beforeEach(() => {
getMock = async () => ({
data: new TextEncoder().encode('hello').buffer,
headers: { 'content-type': 'text/plain' },
status: 200,
statusText: 'OK',
})
})
describe('WebFetch response headers', () => {
test('reads redirect Location from AxiosHeaders-style get()', async () => {
getMock = async () => {
const error = new Error('redirect') as MockAxiosError
error.isAxiosError = true
error.response = {
headers: {
get: (name: string) =>
name.toLowerCase() === 'location' ? '/next' : undefined,
},
status: 302,
}
throw error
}
const { getWithPermittedRedirects } = await import('../utils')
const result = await getWithPermittedRedirects(
'https://example.com/old',
new AbortController().signal,
() => false,
)
expect(result).toEqual({
type: 'redirect',
originalUrl: 'https://example.com/old',
redirectUrl: 'https://example.com/next',
statusCode: 302,
})
})
test('reads proxy block markers from normalized headers', async () => {
getMock = async () => {
const error = new Error('blocked') as MockAxiosError
error.isAxiosError = true
error.response = {
headers: { 'x-proxy-error': 'blocked-by-allowlist' },
status: 403,
}
throw error
}
const { getWithPermittedRedirects } = await import('../utils')
await expect(
getWithPermittedRedirects(
'https://blocked.example/path',
new AbortController().signal,
() => false,
),
).rejects.toThrow('EGRESS_BLOCKED')
})
test('normalizes array content-type before cache and parsing', async () => {
getMock = async () => ({
data: new TextEncoder().encode('plain body').buffer,
headers: { 'content-type': ['text/plain', 'charset=utf-8'] },
status: 200,
statusText: 'OK',
})
const { clearWebFetchCache, getURLMarkdownContent } = await import('../utils')
clearWebFetchCache()
const result = await getURLMarkdownContent(
'https://example.com/plain.txt',
new AbortController(),
)
expect('type' in result).toBe(false)
if ('type' in result) {
throw new Error('unexpected redirect result')
}
expect(result.content).toBe('plain body')
expect(result.contentType).toBe('text/plain, charset=utf-8')
})
})

View File

@@ -82,6 +82,34 @@ export function clearWebFetchCache(): void {
DOMAIN_CHECK_CACHE.clear()
}
function responseHeaderToString(value: unknown): string | undefined {
if (typeof value === 'string') {
return value
}
if (Array.isArray(value)) {
const parts = value
.map(responseHeaderToString)
.filter((part): part is string => part !== undefined)
return parts.length > 0 ? parts.join(', ') : undefined
}
return undefined
}
function getResponseHeader(
headers: AxiosResponse<unknown>['headers'],
name: string,
): string | undefined {
const headersWithGet = headers as { get?: (headerName: string) => unknown }
if (typeof headersWithGet.get === 'function') {
const value = responseHeaderToString(headersWithGet.get(name))
if (value !== undefined) {
return value
}
}
return responseHeaderToString(headers[name.toLowerCase()])
}
// Lazy singleton — defers the turndown → @mixmark-io/domino import (~1.4MB
// retained heap) until the first HTML fetch, and reuses one instance across
// calls (construction builds 15 rule objects; .turndown() is stateless).
@@ -286,7 +314,7 @@ export async function getWithPermittedRedirects(
error.response &&
[301, 302, 307, 308].includes(error.response.status)
) {
const redirectLocation = error.response.headers.location
const redirectLocation = getResponseHeader(error.response.headers, 'location')
if (!redirectLocation) {
throw new Error('Redirect missing Location header')
}
@@ -318,7 +346,8 @@ export async function getWithPermittedRedirects(
if (
axios.isAxiosError(error) &&
error.response?.status === 403 &&
error.response.headers['x-proxy-error'] === 'blocked-by-allowlist'
getResponseHeader(error.response.headers, 'x-proxy-error') ===
'blocked-by-allowlist'
) {
const hostname = new URL(url).hostname
throw new EgressBlockedError(hostname)
@@ -430,7 +459,7 @@ export async function getURLMarkdownContent(
// This lets GC reclaim up to MAX_HTTP_CONTENT_LENGTH (10MB) before Turndown
// builds its DOM tree (which can be 3-5x the HTML size).
;(response as { data: unknown }).data = null
const contentType = response.headers['content-type'] ?? ''
const contentType = getResponseHeader(response.headers, 'content-type') ?? ''
// Binary content: save raw bytes to disk with a proper extension so Claude
// can inspect the file later. We still fall through to the utf-8 decode +

View File

@@ -13,10 +13,9 @@
"dependencies": {
"@ai-sdk/react": "^3.0.170",
"ai": "^6.0.168",
"hono": "^4.7.0",
"hono": "^4.12.15",
"jsqr": "^1.4.0",
"qrcode": "^1.5.4",
"uuid": "^11.0.0",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
@@ -51,7 +50,6 @@
"@types/qrcode": "^1.5.6",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@types/uuid": "^10.0.0",
"@vitejs/plugin-react": "^4.0.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",

View File

@@ -10,6 +10,9 @@ const mockConfig = {
heartbeatInterval: 20,
jwtExpiresIn: 3600,
disconnectTimeout: 300,
webCorsOrigins: [],
wsIdleTimeout: 30,
wsKeepaliveInterval: 20,
};
mock.module("../config", () => ({

View File

@@ -10,6 +10,9 @@ const mockConfig = {
heartbeatInterval: 20,
jwtExpiresIn: 3600,
disconnectTimeout: 300,
webCorsOrigins: [],
wsIdleTimeout: 30,
wsKeepaliveInterval: 20,
};
mock.module("../config", () => ({

View File

@@ -10,6 +10,9 @@ const mockConfig = {
heartbeatInterval: 20,
jwtExpiresIn: 3600,
disconnectTimeout: 300,
webCorsOrigins: ["https://dashboard.example"],
wsIdleTimeout: 30,
wsKeepaliveInterval: 20,
};
mock.module("../config", () => ({
@@ -18,10 +21,23 @@ mock.module("../config", () => ({
}));
import { Hono } from "hono";
import { cors } from "hono/cors";
import { storeReset, storeCreateUser } from "../store";
import { apiKeyAuth, sessionIngressAuth, uuidAuth, getUuidFromRequest } from "../auth/middleware";
import {
apiKeyAuth,
encodeWebSocketAuthProtocol,
extractWebSocketAuthToken,
sessionIngressAuth,
uuidAuth,
getUuidFromRequest,
} from "../auth/middleware";
import { issueToken } from "../auth/token";
import { generateWorkerJwt } from "../auth/jwt";
import {
getAllowedWebCorsOrigins,
resolveWebCorsOrigin,
webCorsOptions,
} from "../auth/cors";
// Helper: create a test app with middleware and a simple handler
function createTestApp() {
@@ -47,6 +63,10 @@ function createTestApp() {
return c.json({ uuid: getUuidFromRequest(c) });
});
app.get("/ws-auth-token", (c) => {
return c.json({ token: extractWebSocketAuthToken(c) ?? null });
});
return app;
}
@@ -103,13 +123,11 @@ describe("Auth Middleware", () => {
expect(res.status).toBe(401);
});
test("accepts token from query param", async () => {
test("rejects session token from query param", async () => {
storeCreateUser("dave");
const { token } = issueToken("dave");
const res = await app.request(`/api-key-test?token=${token}`);
expect(res.status).toBe(200);
const body = await res.json();
expect(body.username).toBe("dave");
expect(res.status).toBe(401);
});
});
@@ -129,6 +147,15 @@ describe("Auth Middleware", () => {
expect(res.status).toBe(200);
});
test("accepts API key from WebSocket protocol header", async () => {
const res = await app.request("/ingress/ses_123", {
headers: {
"Sec-WebSocket-Protocol": encodeWebSocketAuthProtocol("test-api-key"),
},
});
expect(res.status).toBe(200);
});
test("accepts valid JWT with matching session_id", async () => {
const jwt = generateWorkerJwt("ses_123", 3600);
const res = await app.request("/ingress/ses_123", {
@@ -161,6 +188,24 @@ describe("Auth Middleware", () => {
});
});
describe("extractWebSocketAuthToken", () => {
test("does not read tokens from query params", async () => {
const res = await app.request("/ws-auth-token?token=test-api-key");
const body = await res.json();
expect(body.token).toBeNull();
});
test("reads tokens from WebSocket protocol header", async () => {
const res = await app.request("/ws-auth-token", {
headers: {
"Sec-WebSocket-Protocol": encodeWebSocketAuthProtocol("test-api-key"),
},
});
const body = await res.json();
expect(body.token).toBe("test-api-key");
});
});
describe("uuidAuth", () => {
test("accepts UUID from query param", async () => {
const res = await app.request("/uuid-test?uuid=test-uuid-1");
@@ -206,3 +251,45 @@ describe("Auth Middleware", () => {
});
});
});
describe("Web CORS", () => {
function createCorsApp() {
const corsApp = new Hono();
corsApp.use("/web/*", cors(webCorsOptions));
corsApp.get("/web/ping", (c) => c.text("ok"));
return corsApp;
}
test("allows configured origins plus local server origins", () => {
expect(getAllowedWebCorsOrigins()).toContain("https://dashboard.example");
expect(getAllowedWebCorsOrigins()).toContain("http://localhost:3000");
expect(getAllowedWebCorsOrigins()).toContain("http://127.0.0.1:3000");
expect(resolveWebCorsOrigin("https://dashboard.example")).toBe(
"https://dashboard.example",
);
});
test("rejects unknown origins by default", () => {
expect(resolveWebCorsOrigin("https://attacker.example")).toBeUndefined();
});
test("does not emit CORS allow-origin for unknown web origins", async () => {
const res = await createCorsApp().request("/web/ping", {
headers: { Origin: "https://attacker.example" },
});
expect(res.status).toBe(200);
expect(res.headers.get("Access-Control-Allow-Origin")).toBeNull();
});
test("emits CORS allow-origin for configured web origins", async () => {
const res = await createCorsApp().request("/web/ping", {
headers: { Origin: "https://dashboard.example" },
});
expect(res.status).toBe(200);
expect(res.headers.get("Access-Control-Allow-Origin")).toBe(
"https://dashboard.example",
);
});
});

View File

@@ -10,6 +10,9 @@ const mockConfig = {
heartbeatInterval: 20,
jwtExpiresIn: 3600,
disconnectTimeout: 300,
webCorsOrigins: [],
wsIdleTimeout: 30,
wsKeepaliveInterval: 20,
};
mock.module("../config", () => ({
@@ -22,12 +25,23 @@ import { storeReset, storeCreateSession, storeCreateEnvironment, storeBindSessio
import { removeEventBus, getAllEventBuses, getEventBus } from "../transport/event-bus";
import { issueToken } from "../auth/token";
import { publishSessionEvent } from "../services/transport";
import { encodeWebSocketAuthProtocol } from "../auth/middleware";
// Import route modules
import v1Sessions from "../routes/v1/sessions";
import v1Environments from "../routes/v1/environments";
import v1EnvironmentsWork from "../routes/v1/environments.work";
import v1SessionIngress, { websocket as sessionIngressWebsocket } from "../routes/v1/session-ingress";
import v1SessionIngress, {
decodeSessionIngressWsMessage,
handleSessionIngressWsPayload,
websocket as sessionIngressWebsocket,
} from "../routes/v1/session-ingress";
import {
decodeAcpWsMessageData,
hasAcpRelayAuth,
handleAcpWsPayload,
} from "../routes/acp";
import acpRoutes from "../routes/acp";
import v2CodeSessions from "../routes/v2/code-sessions";
import v2Worker from "../routes/v2/worker";
import v2WorkerEventsStream from "../routes/v2/worker-events-stream";
@@ -51,6 +65,7 @@ function createApp() {
app.route("/web", webSessions);
app.route("/web", webControl);
app.route("/web", webEnvironments);
app.route("/acp", acpRoutes);
return app;
}
@@ -1160,6 +1175,83 @@ describe("V1 Session Ingress Routes (HTTP)", () => {
expect(events[0]?.type).toBe("assistant");
});
test("GET /v2/session_ingress/ws/:sessionId — accepts small payload into handler", async () => {
const sessRes = await app.request("/v1/sessions", {
method: "POST",
headers: { ...AUTH_HEADERS, "Content-Type": "application/json" },
body: JSON.stringify({}),
});
const { id } = await sessRes.json();
const server = Bun.serve({
port: 0,
fetch: app.fetch,
websocket: {
...sessionIngressWebsocket,
idleTimeout: 30,
},
});
try {
const event = await new Promise((resolve, reject) => {
let ws: WebSocket | undefined;
const timeout = setTimeout(() => {
ws?.close();
reject(new Error("Timed out waiting for inbound WebSocket payload"));
}, 2000);
const bus = getEventBus(id);
const unsub = bus.subscribe((sessionEvent) => {
if (sessionEvent.direction === "inbound" && sessionEvent.type === "user") {
clearTimeout(timeout);
unsub();
ws?.close();
resolve(sessionEvent);
}
});
ws = new WebSocket(`ws://127.0.0.1:${server.port}/v2/session_ingress/ws/${id}`, [
encodeWebSocketAuthProtocol("test-api-key"),
]);
ws.onopen = () => {
ws.send(JSON.stringify({ type: "user", message: { role: "user", content: "hello" } }) + "\n");
};
ws.onerror = () => {
clearTimeout(timeout);
unsub();
reject(new Error("Session ingress WebSocket connection failed"));
};
});
expect((event as { type?: string }).type).toBe("user");
} finally {
await server.stop(true);
}
});
test("GET /v2/session_ingress/ws/:sessionId — closes 11MB payload with 1009", () => {
const close = mock(() => {});
const handled = handleSessionIngressWsPayload(
{ close } as any,
"session_large",
"x".repeat(11 * 1024 * 1024),
);
expect(handled).toBe(false);
expect(close).toHaveBeenCalledWith(1009, "message too large");
});
test("session ingress decode rejects unsupported payload types", () => {
const close = mock(() => {});
const handled = handleSessionIngressWsPayload(
{ close } as any,
"session_bad",
{ data: "bad" },
);
expect(decodeSessionIngressWsMessage({ data: "bad" }).ok).toBe(false);
expect(handled).toBe(false);
expect(close).toHaveBeenCalledWith(1003, "unsupported message payload");
});
test("GET /v2/session_ingress/ws/:sessionId — resolves compat code session IDs", async () => {
const sessRes = await app.request("/v1/code/sessions", {
method: "POST",
@@ -1184,7 +1276,9 @@ describe("V1 Session Ingress Routes (HTTP)", () => {
try {
const message = await new Promise<string>((resolve, reject) => {
const ws = new WebSocket(`ws://127.0.0.1:${server.port}/v2/session_ingress/ws/${compatId}?token=test-api-key`);
const ws = new WebSocket(`ws://127.0.0.1:${server.port}/v2/session_ingress/ws/${compatId}`, [
encodeWebSocketAuthProtocol("test-api-key"),
]);
const timeout = setTimeout(() => {
ws.close();
reject(new Error("Timed out waiting for compat WebSocket replay"));
@@ -1205,7 +1299,7 @@ describe("V1 Session Ingress Routes (HTTP)", () => {
});
expect(message).toContain("\"type\":\"user\"");
expect(message).toContain(`\"session_id\":\"${id}\"`);
expect(message).toContain(`"session_id":"${id}"`);
expect(message).toContain("compat ws replay");
} finally {
await server.stop(true);
@@ -1213,6 +1307,383 @@ describe("V1 Session Ingress Routes (HTTP)", () => {
});
});
describe("ACP Routes", () => {
let app: Hono;
function createRelayAuthApp() {
const authApp = new Hono();
authApp.get("/relay-auth", (c) => c.json({ ok: hasAcpRelayAuth(c) }));
return authApp;
}
beforeEach(() => {
storeReset();
for (const [key] of getAllEventBuses()) {
removeEventBus(key);
}
app = createApp();
});
test("GET /acp/agents requires auth", async () => {
const res = await app.request("/acp/agents");
expect(res.status).toBe(401);
});
test("GET /acp/agents rejects UUID-only auth", async () => {
const res = await app.request("/acp/agents?uuid=user-1");
expect(res.status).toBe(401);
});
test("GET /acp/agents accepts API key header", async () => {
storeCreateEnvironment({
secret: "secret",
machineName: "agent-one",
workerType: "acp",
bridgeId: "group-one",
});
const res = await app.request("/acp/agents", {
headers: AUTH_HEADERS,
});
expect(res.status).toBe(200);
const body = await res.json();
expect(body).toHaveLength(1);
expect(body[0].agent_name).toBe("agent-one");
});
test("GET /acp/channel-groups requires auth", async () => {
const res = await app.request("/acp/channel-groups");
expect(res.status).toBe(401);
});
test("GET /acp/channel-groups rejects UUID-only auth", async () => {
const res = await app.request("/acp/channel-groups?uuid=user-1");
expect(res.status).toBe(401);
});
test("GET /acp/channel-groups accepts API key header", async () => {
storeCreateEnvironment({
secret: "secret",
machineName: "agent-one",
workerType: "acp",
bridgeId: "group-one",
});
const res = await app.request("/acp/channel-groups", {
headers: AUTH_HEADERS,
});
expect(res.status).toBe(200);
const body = await res.json();
expect(body).toHaveLength(1);
expect(body[0].channel_group_id).toBe("group-one");
});
test("GET /acp/channel-groups/:id requires auth", async () => {
storeCreateEnvironment({
secret: "secret",
machineName: "agent-one",
workerType: "acp",
bridgeId: "group-one",
});
const res = await app.request("/acp/channel-groups/group-one");
expect(res.status).toBe(401);
});
test("GET /acp/channel-groups/:id rejects query token auth", async () => {
storeCreateEnvironment({
secret: "secret",
machineName: "agent-one",
workerType: "acp",
bridgeId: "group-one",
});
const res = await app.request("/acp/channel-groups/group-one?token=test-api-key");
expect(res.status).toBe(401);
});
test("GET /acp/channel-groups/:id rejects UUID-only auth", async () => {
storeCreateEnvironment({
secret: "secret",
machineName: "agent-one",
workerType: "acp",
bridgeId: "group-one",
});
const res = await app.request("/acp/channel-groups/group-one?uuid=user-1");
expect(res.status).toBe(401);
});
test("GET /acp/channel-groups/:id returns group with API key auth", async () => {
storeCreateEnvironment({
secret: "secret",
machineName: "agent-one",
workerType: "acp",
bridgeId: "group-one",
});
const res = await app.request("/acp/channel-groups/group-one", {
headers: AUTH_HEADERS,
});
expect(res.status).toBe(200);
const body = await res.json();
expect(body.channel_group_id).toBe("group-one");
expect(body.member_count).toBe(1);
});
test("GET /acp/channel-groups/:id/events requires auth", async () => {
const res = await app.request("/acp/channel-groups/group-one/events");
expect(res.status).toBe(401);
});
test("GET /acp/channel-groups/:id/events rejects UUID-only auth", async () => {
const res = await app.request("/acp/channel-groups/group-one/events?uuid=user-1");
expect(res.status).toBe(401);
});
test("GET /acp/channel-groups/:id/events accepts API key header", async () => {
const res = await app.request("/acp/channel-groups/group-one/events", {
headers: AUTH_HEADERS,
});
expect(res.status).toBe(200);
expect(res.headers.get("Content-Type")).toBe("text/event-stream");
await res.body?.cancel();
});
test("ACP relay auth rejects UUID-only auth", async () => {
const res = await createRelayAuthApp().request("/relay-auth?uuid=user-1");
expect(await res.json()).toEqual({ ok: false });
});
test("ACP relay auth accepts API key header", async () => {
const res = await createRelayAuthApp().request("/relay-auth", {
headers: AUTH_HEADERS,
});
expect(await res.json()).toEqual({ ok: true });
});
test("ACP relay auth accepts WebSocket protocol auth", async () => {
const res = await createRelayAuthApp().request("/relay-auth", {
headers: {
"Sec-WebSocket-Protocol": encodeWebSocketAuthProtocol("test-api-key"),
},
});
expect(await res.json()).toEqual({ ok: true });
});
test("ACP WebSocket rejects legacy query-token auth on the real upgrade path", async () => {
const server = Bun.serve({
port: 0,
fetch: app.fetch,
websocket: {
...sessionIngressWebsocket,
idleTimeout: 30,
},
});
try {
const close = await new Promise<CloseEvent>((resolve, reject) => {
const ws = new WebSocket(`ws://127.0.0.1:${server.port}/acp/ws?token=test-api-key`);
const timeout = setTimeout(() => {
ws.close();
reject(new Error("Timed out waiting for ACP WebSocket auth rejection"));
}, 2000);
ws.onclose = (event) => {
clearTimeout(timeout);
resolve(event);
};
ws.onerror = () => {
clearTimeout(timeout);
reject(new Error("ACP WebSocket query-token test failed before close"));
};
});
expect(close.code).toBe(4003);
expect(close.reason).toBe("unauthorized");
} finally {
server.stop(true);
}
});
test("ACP WebSocket accepts subprotocol auth on the real upgrade path", async () => {
const server = Bun.serve({
port: 0,
fetch: app.fetch,
websocket: {
...sessionIngressWebsocket,
idleTimeout: 30,
},
});
try {
const message = await new Promise<string>((resolve, reject) => {
const ws = new WebSocket(`ws://127.0.0.1:${server.port}/acp/ws`, [
encodeWebSocketAuthProtocol("test-api-key"),
]);
const timeout = setTimeout(() => {
ws.close();
reject(new Error("Timed out waiting for ACP WebSocket registration"));
}, 2000);
ws.onopen = () => {
ws.send(JSON.stringify({ type: "register", agent_name: "agent-one" }) + "\n");
};
ws.onmessage = (event) => {
const data = typeof event.data === "string" ? event.data : String(event.data);
if (data.includes("\"type\":\"registered\"")) {
clearTimeout(timeout);
ws.close();
resolve(data);
}
};
ws.onerror = () => {
clearTimeout(timeout);
reject(new Error("ACP WebSocket subprotocol auth failed"));
};
});
expect(message).toContain("\"agent_id\"");
} finally {
await server.stop(true);
}
});
test("ACP relay WebSocket rejects legacy query-token auth on the real upgrade path", async () => {
const server = Bun.serve({
port: 0,
fetch: app.fetch,
websocket: {
...sessionIngressWebsocket,
idleTimeout: 30,
},
});
try {
const close = await new Promise<CloseEvent>((resolve, reject) => {
const ws = new WebSocket(`ws://127.0.0.1:${server.port}/acp/relay/agent_123?token=test-api-key`);
const timeout = setTimeout(() => {
ws.close();
reject(new Error("Timed out waiting for ACP relay query-token rejection"));
}, 2000);
ws.onclose = (event) => {
clearTimeout(timeout);
resolve(event);
};
ws.onerror = () => {
clearTimeout(timeout);
reject(new Error("ACP relay query-token test failed before close"));
};
});
expect(close.code).toBe(4003);
expect(close.reason).toBe("unauthorized");
} finally {
server.stop(true);
}
});
test("ACP relay WebSocket accepts subprotocol auth on the real upgrade path", async () => {
const server = Bun.serve({
port: 0,
fetch: app.fetch,
websocket: {
...sessionIngressWebsocket,
idleTimeout: 30,
},
});
try {
const close = await new Promise<CloseEvent>((resolve, reject) => {
const ws = new WebSocket(`ws://127.0.0.1:${server.port}/acp/relay/agent_123`, [
encodeWebSocketAuthProtocol("test-api-key"),
]);
const timeout = setTimeout(() => {
ws.close();
reject(new Error("Timed out waiting for ACP relay authenticated close"));
}, 2000);
ws.onclose = (event) => {
clearTimeout(timeout);
resolve(event);
};
ws.onerror = () => {
clearTimeout(timeout);
reject(new Error("ACP relay subprotocol auth failed before close"));
};
});
expect(close.code).toBe(4004);
expect(close.reason).toBe("agent not found");
} finally {
server.stop(true);
}
});
});
describe("ACP WebSocket payload guards", () => {
test("rejects oversized multibyte text by byte size", () => {
const close = mock(() => {});
const handleMessage = mock(() => {});
const payload = "你".repeat(4 * 1024 * 1024);
const decoded = decodeAcpWsMessageData(payload);
const handled = handleAcpWsPayload(
{ close } as any,
"[ACP-WS]",
"wsId=multibyte",
payload,
handleMessage,
);
expect(decoded.ok && decoded.size).toBeGreaterThan(10 * 1024 * 1024);
expect(handled).toBe(false);
expect(handleMessage).not.toHaveBeenCalled();
expect(close).toHaveBeenCalledWith(1009, "message too large");
});
test("rejects oversized binary payload by byte size", () => {
const close = mock(() => {});
const handleMessage = mock(() => {});
const payload = new Uint8Array(11 * 1024 * 1024);
const decoded = decodeAcpWsMessageData(payload);
const handled = handleAcpWsPayload(
{ close } as any,
"[ACP-Relay]",
"relayWsId=binary",
payload,
handleMessage,
);
expect(decoded).toEqual({
ok: false,
reason: "message too large",
size: 11 * 1024 * 1024,
});
expect(handled).toBe(false);
expect(handleMessage).not.toHaveBeenCalled();
expect(close).toHaveBeenCalledWith(1009, "message too large");
});
test("accepts small payload into ACP handler", () => {
const close = mock(() => {});
const handleMessage = mock(() => {});
const handled = handleAcpWsPayload(
{ close } as any,
"[ACP-WS]",
"wsId=small",
'{"type":"keep_alive"}',
handleMessage,
);
expect(handled).toBe(true);
expect(handleMessage).toHaveBeenCalledWith('{"type":"keep_alive"}');
expect(close).not.toHaveBeenCalled();
});
});
describe("V2 Worker Events Routes", () => {
let app: Hono;

View File

@@ -10,6 +10,9 @@ const mockConfig = {
heartbeatInterval: 20,
jwtExpiresIn: 3600,
disconnectTimeout: 300,
webCorsOrigins: [],
wsIdleTimeout: 30,
wsKeepaliveInterval: 20,
};
mock.module("../config", () => ({

View File

@@ -10,6 +10,9 @@ const mockConfig = {
heartbeatInterval: 20,
jwtExpiresIn: 3600,
disconnectTimeout: 300,
webCorsOrigins: [],
wsIdleTimeout: 30,
wsKeepaliveInterval: 20,
};
mock.module("../config", () => ({

View File

@@ -10,6 +10,9 @@ const mockConfig = {
heartbeatInterval: 20,
jwtExpiresIn: 3600,
disconnectTimeout: 300,
webCorsOrigins: [],
wsIdleTimeout: 30,
wsKeepaliveInterval: 20,
};
mock.module("../config", () => ({

View File

@@ -10,6 +10,9 @@ const mockConfig = {
heartbeatInterval: 20,
jwtExpiresIn: 3600,
disconnectTimeout: 300,
webCorsOrigins: [],
wsIdleTimeout: 30,
wsKeepaliveInterval: 20,
};
mock.module("../config", () => ({

View File

@@ -1,10 +1,15 @@
import { createHash } from "node:crypto";
import { createHash, timingSafeEqual } from "node:crypto";
import { config } from "../config";
function sha256(value: string): Buffer {
return createHash("sha256").update(value).digest();
}
/** Validate a raw API key token string */
export function validateApiKey(token: string | undefined): boolean {
if (!token) return false;
return config.apiKeys.includes(token);
const tokenHash = sha256(token);
return config.apiKeys.some((key) => timingSafeEqual(tokenHash, sha256(key)));
}
export function hashApiKey(key: string): string {

View File

@@ -0,0 +1,34 @@
import { config } from "../config";
function originFromUrl(rawUrl: string): string | undefined {
try {
return new URL(rawUrl).origin;
} catch {
return undefined;
}
}
export function getAllowedWebCorsOrigins(): string[] {
const origins = new Set<string>(config.webCorsOrigins);
const baseOrigin = config.baseUrl ? originFromUrl(config.baseUrl) : undefined;
if (baseOrigin) {
origins.add(baseOrigin);
}
origins.add(`http://localhost:${config.port}`);
origins.add(`http://127.0.0.1:${config.port}`);
return [...origins];
}
export function resolveWebCorsOrigin(origin: string): string | undefined {
return getAllowedWebCorsOrigins().includes(origin) ? origin : undefined;
}
export const webCorsOptions = {
origin: resolveWebCorsOrigin,
allowHeaders: ["Authorization", "Content-Type", "X-UUID"],
allowMethods: ["GET", "POST", "OPTIONS"],
credentials: false,
};

View File

@@ -3,11 +3,49 @@ import { validateApiKey } from "./api-key";
import { verifyWorkerJwt } from "./jwt";
import { resolveToken } from "./token";
/** Extract Bearer token from Authorization header or ?token= query param */
function extractBearerToken(c: Context): string | undefined {
const WS_AUTH_PROTOCOL_PREFIX = "rcs.auth.";
/** Encode a bearer token for WebSocket clients that cannot send auth headers. */
export function encodeWebSocketAuthProtocol(token: string): string {
return `${WS_AUTH_PROTOCOL_PREFIX}${Buffer.from(token, "utf8").toString("base64url")}`;
}
function decodeWebSocketAuthProtocol(protocolHeader: string | undefined): string | undefined {
if (!protocolHeader) {
return undefined;
}
for (const protocol of protocolHeader.split(",")) {
const trimmed = protocol.trim();
if (!trimmed.startsWith(WS_AUTH_PROTOCOL_PREFIX)) {
continue;
}
const encoded = trimmed.slice(WS_AUTH_PROTOCOL_PREFIX.length);
if (!encoded) {
return undefined;
}
try {
const token = Buffer.from(encoded, "base64url").toString("utf8");
return token.length > 0 ? token : undefined;
} catch {
return undefined;
}
}
return undefined;
}
/** Extract a Bearer token from the Authorization header only. */
export function extractBearerToken(c: Context): string | undefined {
const authHeader = c.req.header("Authorization");
const queryToken = c.req.query("token");
return authHeader?.replace("Bearer ", "") || queryToken;
return authHeader?.startsWith("Bearer ") ? authHeader.slice("Bearer ".length) : undefined;
}
/** Extract auth for WebSocket upgrades without putting secrets in query strings. */
export function extractWebSocketAuthToken(c: Context): string | undefined {
return extractBearerToken(c) ?? decodeWebSocketAuthProtocol(c.req.header("Sec-WebSocket-Protocol"));
}
/**
@@ -49,7 +87,7 @@ export async function apiKeyAuth(c: Context, next: Next) {
* downstream handlers to inspect session_id if needed.
*/
export async function sessionIngressAuth(c: Context, next: Next) {
const token = extractBearerToken(c);
const token = extractWebSocketAuthToken(c);
if (!token) {
return c.json({ error: { type: "unauthorized", message: "Missing auth token" } }, 401);

View File

@@ -8,6 +8,10 @@ export const config = {
heartbeatInterval: parseInt(process.env.RCS_HEARTBEAT_INTERVAL || "20"),
jwtExpiresIn: parseInt(process.env.RCS_JWT_EXPIRES_IN || "3600"),
disconnectTimeout: parseInt(process.env.RCS_DISCONNECT_TIMEOUT || "300"),
webCorsOrigins: (process.env.RCS_WEB_CORS_ORIGINS || "")
.split(",")
.map((origin) => origin.trim())
.filter(Boolean),
/** Bun WebSocket idle timeout (seconds). Bun sends protocol-level pings after
* this many seconds of no received data. Must be shorter than any reverse
* proxy's idle timeout (nginx default 60s, Cloudflare 100s). Default 30s. */

View File

@@ -11,6 +11,7 @@ import { dirname, resolve } from "node:path";
import { existsSync } from "node:fs";
import { fileURLToPath } from "node:url";
import acpRoutes from "./routes/acp";
import { webCorsOptions } from "./auth/cors";
// Routes
import v1Environments from "./routes/v1/environments";
@@ -44,7 +45,7 @@ app.use("*", async (c, next) => {
}
await next();
});
app.use("/web/*", cors());
app.use("/web/*", cors(webCorsOptions));
// Health check
app.get("/health", (c) => c.json({ status: "ok", version: config.version }));

View File

@@ -1,6 +1,16 @@
import { Hono } from "hono";
import { randomUUID } from "node:crypto";
import type { Context } from "hono";
import type { WSContext, WSMessageReceive } from "hono/ws";
import { upgradeWebSocket } from "../../transport/ws-shared";
import { apiKeyAuth } from "../../auth/middleware";
import {
decodeWsPayload,
handleSizedWsPayload,
} from "../../transport/ws-payload";
import {
extractBearerToken,
extractWebSocketAuthToken,
} from "../../auth/middleware";
import { validateApiKey } from "../../auth/api-key";
import {
handleAcpWsOpen,
@@ -22,8 +32,14 @@ import { log, error as logError } from "../../logger";
const app = new Hono();
/** Maximum WebSocket message size: 10 MB */
const MAX_WS_MESSAGE_SIZE = 10 * 1024 * 1024;
type WsMessageEvent = {
data: WSMessageReceive;
};
type WsCloseEvent = {
code?: number;
reason?: string;
};
/** Response shape for an ACP agent */
function toAcpAgentResponse(env: ReturnType<typeof storeGetEnvironment> & {}) {
@@ -39,28 +55,33 @@ function toAcpAgentResponse(env: ReturnType<typeof storeGetEnvironment> & {}) {
};
}
/** GET /acp/agents — List all registered ACP agents (UUID or API key auth) */
function hasAcpReadAuth(c: Context): boolean {
const token = extractBearerToken(c);
return !!token && validateApiKey(token);
}
export function hasAcpRelayAuth(c: Context): boolean {
const token = extractWebSocketAuthToken(c);
return !!token && validateApiKey(token);
}
function acpReadUnauthorized(c: Context) {
return c.json({ error: { type: "unauthorized", message: "Missing auth" } }, 401);
}
/** GET /acp/agents — List all registered ACP agents (API key auth) */
app.get("/agents", async (c) => {
// Require at least UUID auth
const uuid = c.req.query("uuid");
const authHeader = c.req.header("Authorization");
const queryToken = c.req.query("token");
const token = authHeader?.replace("Bearer ", "") || queryToken;
if (!uuid && !(token && validateApiKey(token))) {
return c.json({ error: { type: "unauthorized", message: "Missing auth" } }, 401);
if (!hasAcpReadAuth(c)) {
return acpReadUnauthorized(c);
}
const agents = storeListAcpAgents();
return c.json(agents.map((a) => toAcpAgentResponse(a)).filter(Boolean));
});
/** GET /acp/channel-groups — List all channel groups with member agents (UUID or API key auth) */
/** GET /acp/channel-groups — List all channel groups with member agents (API key auth) */
app.get("/channel-groups", async (c) => {
const uuid = c.req.query("uuid");
const authHeader = c.req.header("Authorization");
const queryToken = c.req.query("token");
const token = authHeader?.replace("Bearer ", "") || queryToken;
if (!uuid && !(token && validateApiKey(token))) {
return c.json({ error: { type: "unauthorized", message: "Missing auth" } }, 401);
if (!hasAcpReadAuth(c)) {
return acpReadUnauthorized(c);
}
const agents = storeListAcpAgents();
const groupMap = new Map<string, typeof agents>();
@@ -79,8 +100,12 @@ app.get("/channel-groups", async (c) => {
return c.json(groups);
});
/** GET /acp/channel-groups/:id — Specific channel group detail (no auth for web UI) */
/** GET /acp/channel-groups/:id — Specific channel group detail (API key auth) */
app.get("/channel-groups/:id", async (c) => {
if (!hasAcpReadAuth(c)) {
return acpReadUnauthorized(c);
}
const groupId = c.req.param("id")!;
const members = storeListAcpAgentsByChannelGroup(groupId);
if (members.length === 0) {
@@ -93,14 +118,18 @@ app.get("/channel-groups/:id", async (c) => {
});
});
/** SSE /acp/channel-groups/:id/events — Event stream for external consumers (no auth for web UI) */
/** SSE /acp/channel-groups/:id/events — Event stream for external consumers (API key auth) */
app.get("/channel-groups/:id/events", async (c) => {
if (!hasAcpReadAuth(c)) {
return acpReadUnauthorized(c);
}
const groupId = c.req.param("id")!;
// Support Last-Event-ID / from_sequence_num for reconnection
const lastEventId = c.req.header("Last-Event-ID");
const fromSeq = c.req.query("from_sequence_num");
const fromSeqNum = fromSeq ? parseInt(fromSeq) : lastEventId ? parseInt(lastEventId) : 0;
const fromSeqNum = fromSeq ? parseInt(fromSeq, 10) : lastEventId ? parseInt(lastEventId, 10) : 0;
return createAcpSSEStream(c, groupId, fromSeqNum);
});
@@ -109,46 +138,38 @@ app.get("/channel-groups/:id/events", async (c) => {
app.get(
"/ws",
upgradeWebSocket(async (c) => {
// Authenticate via API key in query param or header
const authHeader = c.req.header("Authorization");
const queryToken = c.req.query("token");
const token = authHeader?.replace("Bearer ", "") || queryToken;
const token = extractWebSocketAuthToken(c);
if (!token || !validateApiKey(token)) {
log("[ACP-WS] Upgrade rejected: unauthorized");
return {
onOpen(_evt: any, ws: any) {
onOpen(_evt: Event, ws: WSContext) {
ws.close(4003, "unauthorized");
},
};
}
// Generate unique wsId for this connection
const { v4: uuid } = await import("uuid");
const wsId = `acp_ws_${uuid().replace(/-/g, "")}`;
const wsId = `acp_ws_${randomUUID().replace(/-/g, "")}`;
log(`[ACP-WS] Upgrade accepted: wsId=${wsId}`);
return {
onOpen(_evt: any, ws: any) {
onOpen(_evt: Event, ws: WSContext) {
handleAcpWsOpen(ws, wsId);
},
onMessage(evt: any, ws: any) {
const data =
typeof evt.data === "string"
? evt.data
: new TextDecoder().decode(evt.data as ArrayBuffer);
if (data.length > MAX_WS_MESSAGE_SIZE) {
logError(`[ACP-WS] Message too large on wsId=${wsId}: ${data.length} bytes`);
ws.close(1009, "message too large");
return;
}
handleAcpWsMessage(ws, wsId, data);
onMessage(evt: WsMessageEvent, ws: WSContext) {
handleAcpWsPayload(
ws,
"[ACP-WS]",
`wsId=${wsId}`,
evt.data,
data => handleAcpWsMessage(ws, wsId, data),
);
},
onClose(evt: any, ws: any) {
const closeEvt = evt as unknown as CloseEvent;
handleAcpWsClose(ws, wsId, closeEvt?.code, closeEvt?.reason);
onClose(evt: WsCloseEvent, ws: WSContext) {
handleAcpWsClose(ws, wsId, evt.code, evt.reason);
},
onError(evt: any, ws: any) {
onError(evt: Event, ws: WSContext) {
logError(`[ACP-WS] Error on wsId=${wsId}:`, evt);
handleAcpWsClose(ws, wsId, 1006, "websocket error");
},
@@ -160,50 +181,36 @@ app.get(
app.get(
"/relay/:agentId",
upgradeWebSocket(async (c) => {
// Authenticate via UUID (web frontend) or API key (legacy)
const clientUuid = c.req.query("uuid");
const authHeader = c.req.header("Authorization");
const queryToken = c.req.query("token");
const token = authHeader?.replace("Bearer ", "") || queryToken;
const hasUuid = !!clientUuid;
const hasApiKey = !!token && validateApiKey(token);
if (!hasUuid && !hasApiKey) {
if (!hasAcpRelayAuth(c)) {
log("[ACP-Relay] Upgrade rejected: unauthorized");
return {
onOpen(_evt: any, ws: any) {
onOpen(_evt: Event, ws: WSContext) {
ws.close(4003, "unauthorized");
},
};
}
const agentId = c.req.param("agentId")!;
const { v4: uuid } = await import("uuid");
const relayWsId = `relay_${uuid().replace(/-/g, "")}`;
const relayWsId = `relay_${randomUUID().replace(/-/g, "")}`;
log(`[ACP-Relay] Upgrade accepted: relayWsId=${relayWsId} agentId=${agentId}`);
return {
onOpen(_evt: any, ws: any) {
onOpen(_evt: Event, ws: WSContext) {
handleRelayOpen(ws, relayWsId, agentId);
},
onMessage(evt: any, ws: any) {
const data =
typeof evt.data === "string"
? evt.data
: new TextDecoder().decode(evt.data as ArrayBuffer);
if (data.length > MAX_WS_MESSAGE_SIZE) {
logError(`[ACP-Relay] Message too large on relayWsId=${relayWsId}: ${data.length} bytes`);
ws.close(1009, "message too large");
return;
}
handleRelayMessage(ws, relayWsId, data);
onMessage(evt: WsMessageEvent, ws: WSContext) {
handleAcpWsPayload(
ws,
"[ACP-Relay]",
`relayWsId=${relayWsId}`,
evt.data,
data => handleRelayMessage(ws, relayWsId, data),
);
},
onClose(evt: any, ws: any) {
const closeEvt = evt as unknown as CloseEvent;
handleRelayClose(ws, relayWsId, closeEvt?.code, closeEvt?.reason);
onClose(evt: WsCloseEvent, ws: WSContext) {
handleRelayClose(ws, relayWsId, evt.code, evt.reason);
},
onError(evt: any, ws: any) {
onError(evt: Event, ws: WSContext) {
logError(`[ACP-Relay] Error on relayWsId=${relayWsId}:`, evt);
handleRelayClose(ws, relayWsId, 1006, "websocket error");
},
@@ -211,4 +218,16 @@ app.get(
}),
);
export const decodeAcpWsMessageData = decodeWsPayload;
export function handleAcpWsPayload(
ws: WSContext,
logPrefix: string,
label: string,
payload: unknown,
handleMessage: (data: string) => void,
): boolean {
return handleSizedWsPayload(ws, logPrefix, label, payload, handleMessage);
}
export default app;

View File

@@ -1,8 +1,15 @@
import { log, error as logError } from "../../logger";
import { Hono } from "hono";
import type { Context } from "hono";
import type { WSContext, WSMessageReceive } from "hono/ws";
import { upgradeWebSocket, websocket } from "../../transport/ws-shared";
import {
decodeWsPayload,
handleSizedWsPayload,
} from "../../transport/ws-payload";
import { validateApiKey } from "../../auth/api-key";
import { verifyWorkerJwt } from "../../auth/jwt";
import { extractWebSocketAuthToken } from "../../auth/middleware";
import {
handleWebSocketOpen,
handleWebSocketMessage,
@@ -13,11 +20,18 @@ import { getSession, resolveExistingSessionId } from "../../services/session";
const app = new Hono();
/** Authenticate via API key or worker JWT in Authorization header or ?token= query param */
function authenticateRequest(c: any, label: string, expectedSessionId?: string): boolean {
const authHeader = c.req.header("Authorization");
const queryToken = c.req.query("token");
const token = authHeader?.replace("Bearer ", "") || queryToken;
type WsMessageEvent = {
data: WSMessageReceive;
};
type WsCloseEvent = {
code?: number;
reason?: string;
};
/** Authenticate via API key or worker JWT without accepting URL query secrets. */
function authenticateRequest(c: Context, label: string, expectedSessionId?: string): boolean {
const token = extractWebSocketAuthToken(c);
// Try API key first
if (validateApiKey(token)) {
@@ -76,7 +90,7 @@ app.get(
if (!authenticateRequest(c, `WS ${sessionId}`, sessionId)) {
return {
onOpen(_evt, ws) {
onOpen(_evt: Event, ws: WSContext) {
ws.close(4003, "unauthorized");
},
};
@@ -86,7 +100,7 @@ app.get(
if (!session) {
log(`[WS] Upgrade rejected: session ${sessionId} not found`);
return {
onOpen(_evt, ws) {
onOpen(_evt: Event, ws: WSContext) {
ws.close(4001, "session not found");
},
};
@@ -94,27 +108,38 @@ app.get(
log(`[WS] Upgrade accepted: session=${sessionId}`);
return {
onOpen(_evt, ws) {
handleWebSocketOpen(ws as any, sessionId);
onOpen(_evt: Event, ws: WSContext) {
handleWebSocketOpen(ws, sessionId);
},
onMessage(evt, ws) {
const data =
typeof evt.data === "string"
? evt.data
: new TextDecoder().decode(evt.data as ArrayBuffer);
handleWebSocketMessage(ws as any, sessionId, data);
onMessage(evt: WsMessageEvent, ws: WSContext) {
handleSessionIngressWsPayload(ws, sessionId, evt.data);
},
onClose(evt, ws) {
const closeEvt = evt as unknown as CloseEvent;
handleWebSocketClose(ws as any, sessionId, closeEvt?.code, closeEvt?.reason);
onClose(evt: WsCloseEvent, ws: WSContext) {
handleWebSocketClose(ws, sessionId, evt.code, evt.reason);
},
onError(evt, ws) {
onError(evt: Event, ws: WSContext) {
logError(`[WS] Error on session=${sessionId}:`, evt);
handleWebSocketClose(ws as any, sessionId, 1006, "websocket error");
handleWebSocketClose(ws, sessionId, 1006, "websocket error");
},
};
}),
);
export const decodeSessionIngressWsMessage = decodeWsPayload;
export function handleSessionIngressWsPayload(
ws: WSContext,
sessionId: string,
payload: unknown,
): boolean {
return handleSizedWsPayload(
ws,
"[WS]",
`session=${sessionId}`,
payload,
data => handleWebSocketMessage(ws, sessionId, data),
);
}
export { websocket };
export default app;

View File

@@ -1,4 +1,5 @@
import { Hono } from "hono";
import { randomUUID } from "node:crypto";
import { getSession, incrementEpoch, touchSession, updateSessionStatus } from "../../services/session";
import {
automationStatesEqual,
@@ -7,7 +8,6 @@ import {
import { apiKeyAuth, acceptCliHeaders, sessionIngressAuth } from "../../auth/middleware";
import { getEventBus } from "../../transport/event-bus";
import { storeGetSessionWorker, storeUpsertSessionWorker } from "../../store";
import { v4 as uuid } from "uuid";
const app = new Hono();
@@ -57,7 +57,7 @@ app.put("/:id/worker", acceptCliHeaders, sessionIngressAuth, async (c) => {
if (!automationStatesEqual(prevAutomationState, nextAutomationState)) {
getEventBus(sessionId).publish({
id: uuid(),
id: randomUUID(),
sessionId,
type: "automation_state",
payload: nextAutomationState,

View File

@@ -10,9 +10,9 @@ import {
storeListSessionsByEnvironment,
storeListSessionsByOwnerUuid,
} from "../store";
import { randomUUID } from "node:crypto";
import { getAllEventBuses, removeEventBus } from "../transport/event-bus";
import type { CreateSessionRequest, CreateCodeSessionRequest, SessionResponse, SessionSummaryResponse } from "../types/api";
import { v4 as uuid } from "uuid";
const CODE_SESSION_PREFIX = "cse_";
const WEB_SESSION_PREFIX = "session_";
@@ -145,7 +145,7 @@ export function updateSessionStatus(sessionId: string, status: string) {
if (!bus) return;
bus.publish({
id: uuid(),
id: randomUUID(),
sessionId,
type: "session_status",
payload: { status },

View File

@@ -1,5 +1,5 @@
import { randomUUID } from "node:crypto";
import { getEventBus } from "../transport/event-bus";
import { v4 as uuid } from "uuid";
/**
* Extract plain text from various message payload formats.
@@ -88,7 +88,7 @@ export function publishSessionEvent(
direction: "inbound" | "outbound",
) {
const bus = getEventBus(sessionId);
const eventId = uuid();
const eventId = randomUUID();
const normalized = normalizePayload(type, payload);

View File

@@ -1,4 +1,4 @@
import { v4 as uuid } from "uuid";
import { randomUUID } from "node:crypto";
// ---------- Types ----------
@@ -98,22 +98,6 @@ export function storeDeleteToken(token: string): boolean {
// ---------- Environment ----------
/** Find an active or offline environment by machineName (optionally filtered by workerType).
* Includes "offline" so ACP agents can be reused on reconnect. */
export function storeFindEnvironmentByMachineName(
machineName: string,
workerType?: string,
): EnvironmentRecord | undefined {
for (const rec of environments.values()) {
if (rec.machineName === machineName && (rec.status === "active" || rec.status === "offline")) {
if (!workerType || rec.workerType === workerType) {
return rec;
}
}
}
return undefined;
}
export function storeCreateEnvironment(req: {
secret: string;
machineName?: string;
@@ -126,24 +110,7 @@ export function storeCreateEnvironment(req: {
username?: string;
capabilities?: Record<string, unknown>;
}): EnvironmentRecord {
// ACP: reuse existing active record by machineName
if (req.workerType === "acp" && req.machineName) {
const existing = storeFindEnvironmentByMachineName(req.machineName, "acp");
if (existing) {
Object.assign(existing, {
status: "active",
lastPollAt: new Date(),
updatedAt: new Date(),
maxSessions: req.maxSessions ?? existing.maxSessions,
bridgeId: req.bridgeId ?? existing.bridgeId,
capabilities: req.capabilities ?? existing.capabilities,
username: req.username ?? existing.username,
});
return existing;
}
}
const id = `env_${uuid().replace(/-/g, "")}`;
const id = `env_${randomUUID().replace(/-/g, "")}`;
const now = new Date();
const record: EnvironmentRecord = {
id,
@@ -195,7 +162,7 @@ export function storeCreateSession(req: {
idPrefix?: string;
username?: string | null;
}): SessionRecord {
const id = `${req.idPrefix || "session_"}${uuid().replace(/-/g, "")}`;
const id = `${req.idPrefix || "session_"}${randomUUID().replace(/-/g, "")}`;
const now = new Date();
const record: SessionRecord = {
id,
@@ -350,7 +317,7 @@ export function storeCreateWorkItem(req: {
sessionId: string;
secret: string;
}): WorkItemRecord {
const id = `work_${uuid().replace(/-/g, "")}`;
const id = `work_${randomUUID().replace(/-/g, "")}`;
const now = new Date();
const record: WorkItemRecord = {
id,

View File

@@ -1,5 +1,5 @@
import type { WSContext } from "hono/ws";
import { v4 as uuid } from "uuid";
import { randomUUID } from "node:crypto";
import { getAcpEventBus } from "./event-bus";
import type { SessionEvent } from "./event-bus";
import {
@@ -86,7 +86,7 @@ function handleRegister(wsId: string, msg: Record<string, unknown>): void {
const agentName = (msg.agent_name as string) || "unknown";
const capabilities = msg.capabilities as Record<string, unknown> | undefined;
const channelGroupId = (msg.channel_group_id as string) || `group_${uuid().replace(/-/g, "").slice(0, 12)}`;
const channelGroupId = (msg.channel_group_id as string) || `group_${randomUUID().replace(/-/g, "").slice(0, 12)}`;
const acpLinkVersion = (msg.acp_link_version as string) || null;
const maxSessions = typeof msg.max_sessions === "number" ? msg.max_sessions : 1;
@@ -154,7 +154,7 @@ function handleIdentify(wsId: string, msg: Record<string, unknown>): void {
// Update status to active
storeMarkAcpAgentOnline(agentId);
const channelGroupId = record.bridgeId || `group_${uuid().replace(/-/g, "").slice(0, 12)}`;
const channelGroupId = record.bridgeId || `group_${randomUUID().replace(/-/g, "").slice(0, 12)}`;
entry.agentId = record.id;
entry.channelGroupId = channelGroupId;
@@ -227,7 +227,7 @@ export function handleAcpWsMessage(ws: WSContext, wsId: string, data: string): v
// Pass-through: publish to channel group EventBus as inbound
const bus = getAcpEventBus(entry.channelGroupId);
bus.publish({
id: uuid(),
id: randomUUID(),
sessionId: entry.channelGroupId,
type: (msg.type as string) || "acp_message",
payload: msg,
@@ -259,7 +259,7 @@ export function handleAcpWsClose(ws: WSContext, wsId: string, code?: number, rea
if (entry.channelGroupId) {
const bus = getAcpEventBus(entry.channelGroupId);
bus.publish({
id: uuid(),
id: randomUUID(),
sessionId: entry.channelGroupId,
type: "agent_disconnect",
payload: { agentId: entry.agentId },

View File

@@ -0,0 +1,64 @@
import { Buffer } from "node:buffer";
import type { WSContext } from "hono/ws";
import { error as logError } from "../logger";
const textDecoder = new TextDecoder();
export const MAX_WS_MESSAGE_SIZE = 10 * 1024 * 1024;
export type DecodedWsMessage =
| { ok: true; data: string; size: number }
| { ok: false; reason: string; size?: number };
export function decodeWsPayload(data: unknown): DecodedWsMessage {
if (typeof data === "string") {
return { ok: true, data, size: Buffer.byteLength(data, "utf8") };
}
if (data instanceof ArrayBuffer) {
if (data.byteLength > MAX_WS_MESSAGE_SIZE) {
return { ok: false, reason: "message too large", size: data.byteLength };
}
return { ok: true, data: textDecoder.decode(data), size: data.byteLength };
}
if (data instanceof Uint8Array) {
if (data.byteLength > MAX_WS_MESSAGE_SIZE) {
return { ok: false, reason: "message too large", size: data.byteLength };
}
return { ok: true, data: textDecoder.decode(data), size: data.byteLength };
}
if (typeof SharedArrayBuffer !== "undefined" && data instanceof SharedArrayBuffer) {
const bytes = new Uint8Array(data);
if (bytes.byteLength > MAX_WS_MESSAGE_SIZE) {
return { ok: false, reason: "message too large", size: bytes.byteLength };
}
return { ok: true, data: textDecoder.decode(bytes), size: bytes.byteLength };
}
return { ok: false, reason: typeof data };
}
export function handleSizedWsPayload(
ws: WSContext,
logPrefix: string,
label: string,
payload: unknown,
handleMessage: (data: string) => void,
): boolean {
const decoded = decodeWsPayload(payload);
if (!decoded.ok) {
if (decoded.reason === "message too large" && decoded.size !== undefined) {
logError(`${logPrefix} Message too large on ${label}: size=${decoded.size} limit=${MAX_WS_MESSAGE_SIZE}`);
ws.close(1009, "message too large");
return false;
}
logError(`${logPrefix} Unsupported message payload on ${label}: ${decoded.reason}`);
ws.close(1003, "unsupported message payload");
return false;
}
if (decoded.size > MAX_WS_MESSAGE_SIZE) {
logError(`${logPrefix} Message too large on ${label}: size=${decoded.size} limit=${MAX_WS_MESSAGE_SIZE}`);
ws.close(1009, "message too large");
return false;
}
handleMessage(decoded.data);
return true;
}

View File

@@ -14,23 +14,25 @@ import type { ACPSettings, ConnectionState, BrowserToolParams, BrowserToolResult
import { ChevronDown, FolderOpen, Globe, Image, KeyRound, ScanLine, X } from "lucide-react";
import { useQRScanner, type QRCodeData } from "../src/hooks";
// Get token from URL query param (for pre-filled URLs from server)
// Get token from the URL fragment so it is not sent in HTTP requests.
function getTokenFromUrl(): string | undefined {
try {
const url = new URL(window.location.href);
return url.searchParams.get("token") || undefined;
const hashParams = new URLSearchParams(url.hash.replace(/^#/, ""));
return hashParams.get("token") || undefined;
} catch {
return undefined;
}
}
// Infer WebSocket URL from current page URL (for pre-filled links from server)
// e.g., http://localhost:9315/app?token=xxx -> ws://localhost:9315/ws
// e.g., http://localhost:9315/app#token=xxx -> ws://localhost:9315/ws
function inferProxyUrlFromPage(): string | undefined {
try {
const url = new URL(window.location.href);
// Only infer if we have a token param (indicates user came from server-printed URL)
if (!url.searchParams.has("token")) {
const hashParams = new URLSearchParams(url.hash.replace(/^#/, ""));
// Only infer if we have a fragment token (indicates user came from server-printed URL)
if (!hashParams.has("token")) {
return undefined;
}
const protocol = url.protocol === "https:" ? "wss:" : "ws:";
@@ -40,6 +42,23 @@ function inferProxyUrlFromPage(): string | undefined {
}
}
function scrubTokenFromUrl(): void {
try {
const url = new URL(window.location.href);
const hashParams = new URLSearchParams(url.hash.replace(/^#/, ""));
if (!hashParams.has("token")) {
return;
}
hashParams.delete("token");
const nextHash = hashParams.toString();
url.hash = nextHash ? `#${nextHash}` : "";
window.history.replaceState(null, "", url.toString());
} catch {
return;
}
}
// Get initial settings from defaults, with optional URL overrides
function getInitialSettings(inferFromUrl: boolean): ACPSettings {
const settings = { ...DEFAULT_SETTINGS };
@@ -119,6 +138,12 @@ export function ACPConnect({
onError: handleQRError,
});
useLayoutEffect(() => {
if (inferFromUrl) {
scrubTokenFromUrl();
}
}, [inferFromUrl]);
// Recalculate maxHeight after DOM updates (when expanded or isScanning changes)
useLayoutEffect(() => {
if (expanded && contentRef.current) {

View File

@@ -28,6 +28,7 @@ beforeEach(() => {
fetchMock.lastOpts = {};
fetchMock.response = { ok: true, status: 200, statusText: "OK" };
fetchMock.responseData = {};
client.setActiveApiToken(null);
});
(globalThis as any).fetch = async (url: string, opts: RequestInit) => {
@@ -41,15 +42,11 @@ beforeEach(() => {
} as Response;
};
// Mock crypto.randomUUID
(globalThis as any).crypto = {
randomUUID: () => "test-uuid-12345678",
};
const { getUuid, setUuid } = await import("../api/client");
// Import api* functions - they depend on getUuid and fetch
const client = await import("../api/client");
const relayClient = await import("../acp/relay-client");
// =============================================================================
// getUuid()
@@ -63,8 +60,10 @@ describe("getUuid", () => {
test("generates and stores new UUID when none exists", () => {
const uuid = getUuid();
expect(uuid).toBe("test-uuid-12345678");
expect(store["rcs_uuid"]).toBe("test-uuid-12345678");
expect(uuid).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/,
);
expect(store["rcs_uuid"]).toBe(uuid);
});
test("returns same UUID on subsequent calls", () => {
@@ -127,6 +126,21 @@ describe("api functions", () => {
expect(fetchMock.lastOpts.headers).toEqual({ "Content-Type": "application/json" });
});
test("active API token is sent only in Authorization header", async () => {
store["rcs_uuid"] = "browser-uuid";
fetchMock.responseData = [];
client.setActiveApiToken("secret-token");
await client.apiFetchSessions();
expect(fetchMock.lastUrl).toContain("uuid=browser-uuid");
expect(fetchMock.lastUrl).not.toContain("secret-token");
expect(fetchMock.lastOpts.headers).toEqual({
"Content-Type": "application/json",
Authorization: "Bearer secret-token",
});
});
test("throws error on non-ok response", async () => {
store["rcs_uuid"] = "test-uuid";
fetchMock.response = { ok: false, status: 401, statusText: "Unauthorized" };
@@ -141,3 +155,18 @@ describe("api functions", () => {
await expect(client.apiFetchSessions()).rejects.toThrow("Internal Server Error");
});
});
describe("ACP relay client", () => {
test("builds relay URLs without UUID or token query params", () => {
(globalThis as any).window = {
location: {
protocol: "https:",
host: "rcs.example.test",
},
};
expect(relayClient.buildRelayUrl("agent_123")).toBe(
"wss://rcs.example.test/acp/relay/agent_123",
);
});
});

View File

@@ -1,4 +1,4 @@
import { describe, test, expect } from "bun:test";
import { afterEach, describe, test, expect } from "bun:test";
const {
formatTime,
@@ -10,6 +10,33 @@ const {
isConversationClearedStatus,
} = await import("../lib/utils");
type UuidCrypto = {
randomUUID?: () => string;
getRandomValues?: (array: Uint8Array) => Uint8Array;
};
const originalCryptoDescriptor = Object.getOwnPropertyDescriptor(globalThis, "crypto");
function setCryptoForTest(value: UuidCrypto): void {
Object.defineProperty(globalThis, "crypto", {
configurable: true,
writable: true,
value,
});
}
function restoreCryptoForTest(): void {
if (originalCryptoDescriptor) {
Object.defineProperty(globalThis, "crypto", originalCryptoDescriptor);
} else {
Reflect.deleteProperty(globalThis, "crypto");
}
}
afterEach(() => {
restoreCryptoForTest();
});
// =============================================================================
// formatTime()
// =============================================================================
@@ -122,10 +149,42 @@ describe("truncate", () => {
// =============================================================================
describe("generateMessageUuid", () => {
test("returns a non-empty string", () => {
test("returns an RFC 4122 v4 UUID", () => {
const uuid = generateMessageUuid();
expect(typeof uuid).toBe("string");
expect(uuid.length).toBeGreaterThan(0);
expect(uuid).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/,
);
});
test("uses crypto.randomUUID when available", () => {
setCryptoForTest({
randomUUID: () => "11111111-1111-4111-8111-111111111111",
getRandomValues: () => {
throw new Error("getRandomValues should not be called");
},
});
expect(generateMessageUuid()).toBe("11111111-1111-4111-8111-111111111111");
});
test("uses crypto.getRandomValues when randomUUID is unavailable", () => {
setCryptoForTest({
getRandomValues: (array) => {
for (let i = 0; i < array.length; i++) {
array[i] = i;
}
return array;
},
});
expect(generateMessageUuid()).toBe("00010203-0405-4607-8809-0a0b0c0d0e0f");
});
test("throws when no secure random source is available", () => {
setCryptoForTest({});
expect(() => generateMessageUuid()).toThrow("crypto.getRandomValues is required");
});
});

View File

@@ -20,6 +20,19 @@ import type {
AvailableCommand,
} from "./types";
function encodeWebSocketAuthProtocol(token: string): string {
const bytes = new TextEncoder().encode(token);
let binary = "";
for (const byte of bytes) {
binary += String.fromCharCode(byte);
}
const encoded = btoa(binary)
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
return `rcs.auth.${encoded}`;
}
/**
* Error thrown when disconnect() is called while a connection is in progress.
* Callers can use `instanceof` to distinguish this from real connection errors.
@@ -276,14 +289,12 @@ export class ACPClient {
this.connectReject = reject;
try {
// Build WebSocket URL with token if provided
let wsUrl = this.settings.proxyUrl;
if (this.settings.token) {
const url = new URL(wsUrl);
url.searchParams.set("token", this.settings.token);
wsUrl = url.toString();
}
const ws = new WebSocket(wsUrl);
const ws = new WebSocket(
this.settings.proxyUrl,
this.settings.token
? [encodeWebSocketAuthProtocol(this.settings.token)]
: undefined,
);
this.ws = ws;
ws.onopen = () => {

View File

@@ -1,6 +1,6 @@
import { ACPClient } from "./client";
import type { ACPSettings } from "./types";
import { getUuid } from "../api/client";
import { getActiveApiToken } from "../api/client";
/**
* Build the RCS relay WebSocket URL for a given agent.
@@ -8,8 +8,7 @@ import { getUuid } from "../api/client";
*/
export function buildRelayUrl(agentId: string): string {
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const uuid = getUuid();
return `${protocol}//${window.location.host}/acp/relay/${agentId}?uuid=${encodeURIComponent(uuid)}`;
return `${protocol}//${window.location.host}/acp/relay/${agentId}`;
}
/**
@@ -19,6 +18,9 @@ export function buildRelayUrl(agentId: string): string {
*/
export function createRelayClient(agentId: string): ACPClient {
const relayUrl = buildRelayUrl(agentId);
const settings: ACPSettings = { proxyUrl: relayUrl };
const token = getActiveApiToken();
const settings: ACPSettings = token
? { proxyUrl: relayUrl, token }
: { proxyUrl: relayUrl };
return new ACPClient(settings);
}

View File

@@ -549,7 +549,7 @@ export interface SessionModelState {
// Settings
export interface ACPSettings {
proxyUrl: string;
/** Auth token for remote access (passed as ?token=xxx query param) */
/** Auth token for remote access (sent via WebSocket subprotocol) */
token?: string;
/** Working directory for the agent session */
cwd?: string;

View File

@@ -1,20 +1,12 @@
import type { Session, Environment, ControlResponse, SessionEvent } from "../types";
import { generateMessageUuid } from "../lib/utils";
const BASE = "";
function generateUuid(): string {
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
return crypto.randomUUID();
}
return "10000000-1000-4000-8000-100000000000".replace(/[018]/g, (c) =>
(Number(c) ^ (crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (Number(c) / 4)))).toString(16),
);
}
export function getUuid(): string {
let uuid = localStorage.getItem("rcs_uuid");
if (!uuid) {
uuid = generateUuid();
uuid = generateMessageUuid();
localStorage.setItem("rcs_uuid", uuid);
}
return uuid;
@@ -42,17 +34,9 @@ async function api<T>(method: string, path: string, body?: unknown): Promise<T>
headers["Authorization"] = `Bearer ${_activeToken}`;
}
// When using Bearer token auth, backend derives UUID from the token — no need to send query param.
// Otherwise fall back to UUID auth via query param.
let url: string;
if (_activeToken) {
const sep = path.includes("?") ? "&" : "?";
url = `${BASE}${path}${sep}uuid=${encodeURIComponent(_activeToken)}`;
} else {
const uuid = getUuid();
const sep = path.includes("?") ? "&" : "?";
url = `${BASE}${path}${sep}uuid=${encodeURIComponent(uuid)}`;
}
const uuid = getUuid();
const sep = path.includes("?") ? "&" : "?";
const url = `${BASE}${path}${sep}uuid=${encodeURIComponent(uuid)}`;
const opts: RequestInit = { method, headers };
if (body !== undefined) opts.body = JSON.stringify(body);

View File

@@ -1,5 +1,4 @@
import type { SetStateAction } from "react";
import { v4 as uuidv4 } from "uuid";
import {
apiFetchSession,
apiFetchSessionHistory,
@@ -9,6 +8,7 @@ import {
apiInterrupt,
getUuid,
} from "../api/client";
import { generateMessageUuid } from "./utils";
import type { SessionEvent, EventPayload } from "../types";
import type {
ThreadEntry,
@@ -422,7 +422,7 @@ export class RCSChatAdapter {
// Send to backend
await apiSendEvent(this.sessionId, {
type: "user",
uuid: uuidv4(),
uuid: generateMessageUuid(),
content: text,
message: { content: text },
});

View File

@@ -1,6 +1,6 @@
import type { ChatTransport, UIMessage, UIMessageChunk } from "ai";
import { v4 as uuidv4 } from "uuid";
import { getUuid } from "../api/client";
import { generateMessageUuid } from "./utils";
import type { SessionEvent, EventPayload } from "../types";
// ============================================================
@@ -113,7 +113,7 @@ export class RCSTransport implements ChatTransport<UIMessage> {
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
type: "user",
uuid: uuidv4(),
uuid: generateMessageUuid(),
content: text,
message: { content: text },
}),

View File

@@ -1,6 +1,5 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
import { v4 as uuidv4 } from "uuid";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
@@ -42,8 +41,31 @@ export function truncate(str: string | null | undefined, max: number): string {
return s.length > max ? s.slice(0, max) + "..." : s;
}
function formatUuidV4(bytes: Uint8Array): string {
bytes[6] = (bytes[6] & 0x0f) | 0x40;
bytes[8] = (bytes[8] & 0x3f) | 0x80;
const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0"));
return [
hex.slice(0, 4).join(""),
hex.slice(4, 6).join(""),
hex.slice(6, 8).join(""),
hex.slice(8, 10).join(""),
hex.slice(10, 16).join(""),
].join("-");
}
export function generateMessageUuid(): string {
return uuidv4();
const cryptoApi = globalThis.crypto;
if (cryptoApi && typeof cryptoApi.randomUUID === "function") {
return cryptoApi.randomUUID();
}
if (!cryptoApi || typeof cryptoApi.getRandomValues !== "function") {
throw new Error("crypto.getRandomValues is required to generate message UUIDs");
}
const bytes = new Uint8Array(16);
cryptoApi.getRandomValues(bytes);
return formatUuidV4(bytes);
}
export function extractEventText(payload: Record<string, unknown> | null | undefined): string {

View File

@@ -27,51 +27,52 @@ export function getMacroDefines(): Record<string, string> {
* - scripts/dev.ts (bun run dev)
*/
export const DEFAULT_BUILD_FEATURES = [
'BUDDY', 'TRANSCRIPT_CLASSIFIER', 'BRIDGE_MODE',
'AGENT_TRIGGERS_REMOTE',
'CHICAGO_MCP',
'VOICE_MODE',
'SHOT_STATS',
'PROMPT_CACHE_BREAK_DETECTION',
'TOKEN_BUDGET',
'BUDDY', // 陪伴宠物角色Squirtle Waddles
'TRANSCRIPT_CLASSIFIER', // 对话分类器,用于标注会话类型
'BRIDGE_MODE', // Remote Control / Bridge 模式,远程控制会话
'AGENT_TRIGGERS_REMOTE', // sessionIngress 模块级 Map 累积(非 GB 级主因)
'CHICAGO_MCP', // Chicago MCP 集成(内部代号)
'VOICE_MODE', // Push-to-Talk 语音输入模式
'SHOT_STATS', // 单次请求统计信息收集
'PROMPT_CACHE_BREAK_DETECTION', // 检测 prompt cache 是否被打破(有 10 条上限,可控)
'TOKEN_BUDGET', // Token 预算管理与控制
// P0: local features
'AGENT_TRIGGERS',
'ULTRATHINK',
'BUILTIN_EXPLORE_PLAN_AGENTS',
'LODESTONE',
// P1: API-dependent features
'EXTRACT_MEMORIES',
'VERIFICATION_AGENT',
'KAIROS_BRIEF',
'AWAY_SUMMARY',
'ULTRAPLAN',
// P2: daemon + remote control server
'DAEMON',
// ACP (Agent Client Protocol) agent mode
'ACP',
// PR-package restored features
'WORKFLOW_SCRIPTS',
'HISTORY_SNIP',
'CONTEXT_COLLAPSE',
'MONITOR_TOOL',
'FORK_SUBAGENT',
'UDS_INBOX',
'KAIROS',
'COORDINATOR_MODE',
'LAN_PIPES',
'BG_SESSIONS',
'TEMPLATES',
// 'REVIEW_ARTIFACT', // API 请求无响应,需进一步排查 schema 兼容性
'AGENT_TRIGGERS', // 本地 Agent 触发器(工具调用时启动子代理)
'ULTRATHINK', // 超深度思考模式,增加推理链长度
'BUILTIN_EXPLORE_PLAN_AGENTS', // 内置 Explore/Plan 子代理类型
'LODESTONE', // 上下文锚点,优化长对话的相关性检索
'EXTRACT_MEMORIES', // 每次 turn 结束 fork 完整消息历史(非 GB 级主因)
'VERIFICATION_AGENT', // 任务完成后 fork 完整消息(非 GB 级主因)
'KAIROS_BRIEF', // Kairos 定时摘要(定时汇报当前状态)
'AWAY_SUMMARY', // 离线摘要(用户离开后生成总结)
'ULTRAPLAN', // 超级规划模式,深度分析后生成实施计划
'DAEMON', // 守护进程模式,长驻 supervisor 管理后台 worker非 GB 级主因)
'ACP', // ACP 代理协议,支持外部 agent 接入
'WORKFLOW_SCRIPTS', // 工作流脚本(.claude/workflows/ 中的 YAML/MD
'HISTORY_SNIP', // 历史消息裁剪,压缩上下文窗口
'CONTEXT_COLLAPSE', // 上下文折叠,自动压缩旧消息
'MONITOR_TOOL', // Monitor 工具,流式监控后台进程输出
'FORK_SUBAGENT', // Fork 子代理,在隔离上下文中并行执行任务
'UDS_INBOX', // inbox 数组只增不减(非 GB 级主因)
'KAIROS', // Kairos 定时任务系统核心
// 'COORDINATOR_MODE', // 已禁用AgentSummary 30s fork 循环GB 级泄露主因
'LAN_PIPES', // 依赖 UDS_INBOX已随 UDS_INBOX 恢复)
'BG_SESSIONS', // 后台会话管理ps/logs/attach/kill
'TEMPLATES', // 模板任务new/list/reply 子命令)
// 'REVIEW_ARTIFACT', // 代码审查产物API 请求无响应,待排查 schema 兼容性)
// API content block types
'CONNECTOR_TEXT',
'CONNECTOR_TEXT', // Connector 文本块类型,扩展 API 内容格式
// Attribution tracking
'COMMIT_ATTRIBUTION',
'COMMIT_ATTRIBUTION', // Git 提交归属追踪(记录 AI 辅助贡献)
// Server mode (claude server / claude open)
'DIRECT_CONNECT',
// Skill search
'EXPERIMENTAL_SKILL_SEARCH',
// P3: poor mode (disable extract_memories + prompt_suggestion)
'POOR',
// Team Memory (shared memory files between agent teammates)
'TEAMMEM',
'DIRECT_CONNECT', // 直连模式claude server / claude open
// Skill search & learning
'EXPERIMENTAL_SKILL_SEARCH', // 实验性技能搜索DiscoverSkills
'SKILL_LEARNING', // projectContext cache 无淘汰机制(非 GB 级主因)
// P3: poor mode
'POOR', // 穷鬼模式,跳过 extract_memories/prompt_suggestion 减少消耗
// Team Memory
// 'TEAMMEM', // 已禁用:依赖 COORDINATOR_MODE邮箱文件无限增长
// SSH Remote
'SSH_REMOTE', // SSH 远程连接,本地 REPL + 远端工具执行
]as const;

View File

@@ -36,9 +36,13 @@ async function postBuild() {
}
// Step 2: Copy native addon files
const vendorDir = join(outdir, "vendor", "audio-capture");
await cp("vendor/audio-capture", vendorDir, { recursive: true } as never);
console.log(`Copied vendor/audio-capture/ → ${vendorDir}/`);
const audioCaptureDir = join(outdir, "vendor", "audio-capture");
await cp("vendor/audio-capture", audioCaptureDir, { recursive: true } as never);
console.log(`Copied vendor/audio-capture/ → ${audioCaptureDir}/`);
const ripgrepDir = join(outdir, "vendor", "ripgrep");
await cp("src/utils/vendor/ripgrep", ripgrepDir, { recursive: true } as never);
console.log(`Copied src/utils/vendor/ripgrep/ → ${ripgrepDir}/`);
// Step 3: Generate dual entry points
const cliBun = join(outdir, "cli-bun.js");

View File

@@ -9,14 +9,39 @@
*/
import { execFileSync } from "node:child_process";
import { mkdirSync } from "node:fs";
import { createRequire } from "node:module";
import { dirname, join } from "node:path";
import { homedir } from "node:os";
import { join } from "node:path";
if (process.env.CLAUDE_CODE_SKIP_CHROME_MCP_SETUP === "1") {
process.exit(0);
}
const require = createRequire(import.meta.url);
const cliPath = require.resolve("@claude-code-best/mcp-chrome-bridge/dist/cli.js");
const userArgs = process.argv.slice(2);
function getChromeMcpLogDir() {
const home = homedir();
if (process.platform === "darwin") {
return join(home, "Library", "Logs", "mcp-chrome-bridge");
}
if (process.platform === "win32") {
return join(
process.env.LOCALAPPDATA || join(home, "AppData", "Local"),
"mcp-chrome-bridge",
"logs",
);
}
return join(
process.env.XDG_STATE_HOME || join(home, ".local", "state"),
"mcp-chrome-bridge",
"logs",
);
}
if (userArgs.length > 0) {
// Forward single sub-command
execFileSync("node", [cliPath, ...userArgs], { stdio: "inherit" });
@@ -28,6 +53,8 @@ if (userArgs.length > 0) {
["doctor"],
];
mkdirSync(getChromeMcpLogDir(), { recursive: true });
for (let i = 0; i < steps.length; i++) {
const args = steps[i];
const isLast = i === steps.length - 1;

View File

@@ -86,9 +86,13 @@ import {
// Lazy: MessageSelector.tsx pulls React/ink; only needed for message filtering at query time
/* eslint-disable @typescript-eslint/no-require-imports */
const messageSelector =
(): typeof import('src/components/MessageSelector.js') =>
require('src/components/MessageSelector.js')
const messageSelector = (): typeof import('src/components/MessageSelector.js') | null => {
try {
return require('src/components/MessageSelector.js')
} catch {
return null
}
}
import {
localCommandOutputToSDKAssistantMessage,
@@ -466,12 +470,13 @@ export class QueryEngine {
}
// Filter messages that should be acknowledged after transcript
const _selector = messageSelector()
const replayableMessages = messagesFromUserInput.filter(
msg =>
(msg.type === 'user' &&
!msg.isMeta && // Skip synthetic caveat messages
!msg.toolUseResult && // Skip tool results (they'll be acked from query)
messageSelector().selectableUserMessagesFilter(msg)) || // Skip non-user-authored messages (task notifications, etc.)
(_selector?.selectableUserMessagesFilter(msg) ?? true)) || // Skip non-user-authored messages (task notifications, etc.)
(msg.type === 'system' && msg.subtype === 'compact_boundary'), // Always ack compact boundaries
)
const messagesToAck = replayUserMessages ? replayableMessages : []
@@ -643,8 +648,10 @@ export class QueryEngine {
}
if (fileHistoryEnabled() && persistSession) {
const _sel = messageSelector()
const _filter = _sel?.selectableUserMessagesFilter ?? ((_msg: unknown) => true)
messagesFromUserInput
.filter(messageSelector().selectableUserMessagesFilter)
.filter(_filter)
.forEach(message => {
void fileHistoryMakeSnapshot(
(updater: (prev: FileHistoryState) => FileHistoryState) => {

View File

@@ -1,3 +0,0 @@
// Auto-generated type stub — replace with real implementation
export type HookEvent = any;
export type ModelUsage = any;

View File

@@ -1,2 +0,0 @@
// Auto-generated type stub — replace with real implementation
export type AgentColorName = any;

View File

@@ -1,2 +0,0 @@
// Auto-generated type stub — replace with real implementation
export type HookCallbackMatcher = any;

View File

@@ -1,2 +0,0 @@
// Auto-generated type stub — replace with real implementation
export type SessionId = any;

View File

@@ -1,2 +0,0 @@
// Auto-generated type stub — replace with real implementation
export type randomUUID = any;

View File

@@ -1,2 +0,0 @@
// Auto-generated type stub — replace with real implementation
export type ModelSetting = any;

View File

@@ -1,2 +0,0 @@
// Auto-generated type stub — replace with real implementation
export type ModelStrings = any;

View File

@@ -1,2 +0,0 @@
// Auto-generated type stub — replace with real implementation
export type SettingSource = any;

View File

@@ -1,2 +0,0 @@
// Auto-generated type stub — replace with real implementation
export type resetSettingsCache = any;

View File

@@ -1,2 +0,0 @@
// Auto-generated type stub — replace with real implementation
export type PluginHookMatcher = any;

View File

@@ -1,2 +0,0 @@
// Auto-generated type stub — replace with real implementation
export type createSignal = any;

View File

@@ -235,11 +235,6 @@ type State = {
// microcompact is first enabled, keep sending the header so mid-session
// GrowthBook/settings toggles don't bust the prompt cache.
cacheEditingHeaderLatched: boolean | null
// Sticky-on latch for clearing thinking from prior tool loops. Triggered
// when >1h since last API call (confirmed cache miss — no cache-hit
// benefit to keeping thinking). Once latched, stays on so the newly-warmed
// thinking-cleared cache isn't busted by flipping back to keep:'all'.
thinkingClearLatched: boolean | null
// Current prompt ID (UUID) correlating a user prompt with subsequent OTel events
promptId: string | null
// Last API requestId for the main conversation chain (not subagents).
@@ -414,7 +409,6 @@ function getInitialState(): State {
afkModeHeaderLatched: null,
fastModeHeaderLatched: null,
cacheEditingHeaderLatched: null,
thinkingClearLatched: null,
// Current prompt ID
promptId: null,
lastMainRequestId: undefined,
@@ -1729,14 +1723,6 @@ export function setCacheEditingHeaderLatched(v: boolean): void {
STATE.cacheEditingHeaderLatched = v
}
export function getThinkingClearLatched(): boolean | null {
return STATE.thinkingClearLatched
}
export function setThinkingClearLatched(v: boolean): void {
STATE.thinkingClearLatched = v
}
/**
* Reset beta header latches to null. Called on /clear and /compact so a
* fresh conversation gets fresh header evaluation.
@@ -1745,7 +1731,6 @@ export function clearBetaHeaderLatches(): void {
STATE.afkModeHeaderLatched = null
STATE.fastModeHeaderLatched = null
STATE.cacheEditingHeaderLatched = null
STATE.thinkingClearLatched = null
}
export function getPromptId(): string | null {

View File

@@ -1675,7 +1675,7 @@ async function stopWorkWithRetry(
}
const errMsg = errorMessage(err)
if (attempt < MAX_ATTEMPTS) {
const delay = addJitter(baseDelayMs * Math.pow(2, attempt - 1))
const delay = addJitter(baseDelayMs * 2 ** (attempt - 1))
logger.logVerbose(
`Failed to stop work ${workId} (attempt ${attempt}/${MAX_ATTEMPTS}), retrying in ${formatDelay(delay)}: ${errMsg}`,
)

View File

@@ -6,6 +6,38 @@ import { getBridgeAccessToken } from './bridgeConfig.js'
import { getReplBridgeHandle } from './replBridgeHandle.js'
import { toCompatSessionId } from './sessionIdCompat.js'
export type BridgePeerSession = {
address: string
name?: string
cwd?: string
pid?: number
}
/**
* List locally registered sessions that have published a Remote Control
* session ID. The PID registry is the local source of truth for bridge peers
* already known to this machine; SendMessage can use these bridge:<id>
* addresses when the current process has an active bridge handle.
*/
export async function listBridgePeers(): Promise<BridgePeerSession[]> {
const { listAllLiveSessions } = await import('../utils/udsClient.js')
const sessions = await listAllLiveSessions()
const peers: BridgePeerSession[] = []
for (const session of sessions) {
if (session.pid === process.pid || !session.bridgeSessionId) continue
const compatId = toCompatSessionId(session.bridgeSessionId)
peers.push({
address: `bridge:${compatId}`,
name: session.name ?? session.kind,
cwd: session.cwd,
pid: session.pid,
})
}
return peers
}
/**
* Send a plain-text message to another Claude session via the bridge API.
*

View File

@@ -1,2 +0,0 @@
// Auto-generated type stub — replace with real implementation
export type StdoutMessage = any;

View File

@@ -190,12 +190,10 @@ export async function mcpListHandler(): Promise<void> {
logEvent('tengu_mcp_list', {})
const { servers: configs } = await getAllMcpConfigs()
if (Object.keys(configs).length === 0) {
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.log(
'No MCP servers configured. Use `claude mcp add` to add a server.',
)
} else {
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.log('Checking MCP server health...\n')
// Check servers concurrently
@@ -213,18 +211,14 @@ export async function mcpListHandler(): Promise<void> {
for (const { name, server, status } of results) {
// Intentionally excluding sse-ide servers here since they're internal
if (server.type === 'sse') {
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.log(`${name}: ${server.url} (SSE) - ${status}`)
} else if (server.type === 'http') {
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.log(`${name}: ${server.url} (HTTP) - ${status}`)
} else if (server.type === 'claudeai-proxy') {
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.log(`${name}: ${server.url} - ${status}`)
} else if (!server.type || server.type === 'stdio') {
const stdioServer = server as { command: string; args: string[]; type?: string }
const args = Array.isArray(stdioServer.args) ? stdioServer.args : []
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.log(`${name}: ${stdioServer.command} ${args.join(' ')} - ${status}`)
}
}
@@ -244,27 +238,20 @@ export async function mcpGetHandler(name: string): Promise<void> {
cliError(`No MCP server found with name: ${name}`)
}
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.log(`${name}:`)
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.log(` Scope: ${getScopeLabel(server.scope)}`)
// Check server health
const status = await checkMcpServerHealth(name, server)
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.log(` Status: ${status}`)
// Intentionally excluding sse-ide servers here since they're internal
if (server.type === 'sse') {
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.log(` Type: sse`)
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.log(` URL: ${server.url}`)
if (server.headers) {
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.log(' Headers:')
for (const [key, value] of Object.entries(server.headers)) {
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.log(` ${key}: ${value}`)
}
}
@@ -277,19 +264,14 @@ export async function mcpGetHandler(name: string): Promise<void> {
}
if (server.oauth.callbackPort)
parts.push(`callback_port ${server.oauth.callbackPort}`)
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.log(` OAuth: ${parts.join(', ')}`)
}
} else if (server.type === 'http') {
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.log(` Type: http`)
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.log(` URL: ${server.url}`)
if (server.headers) {
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.log(' Headers:')
for (const [key, value] of Object.entries(server.headers)) {
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.log(` ${key}: ${value}`)
}
}
@@ -302,27 +284,20 @@ export async function mcpGetHandler(name: string): Promise<void> {
}
if (server.oauth.callbackPort)
parts.push(`callback_port ${server.oauth.callbackPort}`)
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.log(` OAuth: ${parts.join(', ')}`)
}
} else if (server.type === 'stdio') {
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.log(` Type: stdio`)
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.log(` Command: ${server.command}`)
const args = Array.isArray(server.args) ? server.args : []
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.log(` Args: ${args.join(' ')}`)
if (server.env) {
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.log(' Environment:')
for (const [key, value] of Object.entries(server.env)) {
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.log(` ${key}=${value}`)
}
}
}
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.log(
`\nTo remove this server, run: claude mcp remove "${name}" -s ${server.scope}`,
)

View File

@@ -2763,13 +2763,37 @@ function runHeadlessStreaming(
// when a message arrives via the UDS socket in headless mode.
if (feature('UDS_INBOX')) {
/* eslint-disable @typescript-eslint/no-require-imports */
const { setOnEnqueue } = require('../utils/udsMessaging.js')
const { drainInbox, setOnEnqueue } =
require('../utils/udsMessaging.js') as typeof import('../utils/udsMessaging.js')
/* eslint-enable @typescript-eslint/no-require-imports */
const enqueueUdsInboxMessages = (): boolean => {
const entries = drainInbox()
for (const entry of entries) {
const value =
typeof entry.message.data === 'string'
? entry.message.data
: jsonStringify(entry.message.data)
enqueue({
mode: 'prompt',
value,
uuid: randomUUID(),
})
}
return entries.length > 0
}
setOnEnqueue(() => {
if (!inputClosed) {
void run()
if (enqueueUdsInboxMessages()) {
void run()
}
}
})
if (enqueueUdsInboxMessages()) {
void run()
}
}
// Cron scheduler: runs scheduled_tasks.json tasks in SDK/-p mode.

Some files were not shown because too many files have changed in this diff Show More