mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-17 13:55:50 +00:00
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>
This commit is contained in:
26
.github/workflows/ci.yml
vendored
26
.github/workflows/ci.yml
vendored
@@ -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
|
||||
|
||||
8
.github/workflows/publish-npm.yml
vendored
8
.github/workflows/publish-npm.yml
vendored
@@ -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: |
|
||||
|
||||
8
.github/workflows/release-rcs.yml
vendored
8
.github/workflows/release-rcs.yml
vendored
@@ -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
|
||||
|
||||
6
.github/workflows/update-contributors.yml
vendored
6
.github/workflows/update-contributors.yml
vendored
@@ -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"
|
||||
|
||||
@@ -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
|
||||
│ │
|
||||
|
||||
@@ -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)。
|
||||
|
||||
564
docs/internals/agent-comm-fix-jira-tasks.md
Normal file
564
docs/internals/agent-comm-fix-jira-tasks.md
Normal 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 Type:Epic
|
||||
- Priority:P0
|
||||
- Owner:核心通讯 / 后端网关 / QA
|
||||
- Scope:ACP 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 Type:Bug
|
||||
- Priority:P0
|
||||
- Story Points:3
|
||||
- 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 Type:Bug
|
||||
- Priority:P0
|
||||
- Story Points:3
|
||||
- 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 且不 abort,listener 不增长。
|
||||
- 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 Type:Task
|
||||
- Priority:P1
|
||||
- Story Points:5
|
||||
- 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 Type:Bug
|
||||
- Priority:P1
|
||||
- Story Points:3
|
||||
- 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 Type:Refactor
|
||||
- Priority:P1
|
||||
- Story Points:5
|
||||
- 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 Type:Task
|
||||
- Priority:P1
|
||||
- Story Points:3
|
||||
- Owner:终端 UI
|
||||
- Files:
|
||||
- `src/screens/REPL.tsx`
|
||||
|
||||
#### 参考代码位置
|
||||
|
||||
- `src/screens/REPL.tsx:654-662`
|
||||
- `src/screens/REPL.tsx:4996-5005`
|
||||
|
||||
#### 背景
|
||||
|
||||
REPL 中目标初始化 effect 存在 hook dependency suppress,warm-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 Type:Task
|
||||
- Priority:P1
|
||||
- Story Points:2
|
||||
- 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 Type:Task
|
||||
- Priority:P1
|
||||
- Story Points:3
|
||||
- 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 Type:Test
|
||||
- Priority:P1
|
||||
- Story Points:5
|
||||
- Owner:QA/核心通讯
|
||||
- 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 Type:Test
|
||||
- Priority:P1
|
||||
- Story Points:3
|
||||
- Owner:QA/后端
|
||||
- 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
|
||||
- [ ] 最终变更说明包含风险与未覆盖项
|
||||
74
docs/internals/agent-comm-fix-questions.md
Normal file
74
docs/internals/agent-comm-fix-questions.md
Normal 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 gate:P0 全部改动和冒烟验证完成后,再启动 P1 改造。
|
||||
|
||||
---
|
||||
|
||||
## 5. 不在本文档维护的内容
|
||||
|
||||
- 不维护 Jira ticket 正文;统一在 `docs/internals/agent-comm-fix-jira-tasks.md` 修改。
|
||||
- 不维护业务代码实现方案;实现时按具体 ticket 读取对应文件。
|
||||
- 不维护历史中间稿;旧执行清单已合并进 Jira Task 文档。
|
||||
50
package.json
50
package.json
@@ -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",
|
||||
@@ -208,5 +208,13 @@
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
62
packages/acp-link/src/ws-auth.ts
Normal file
62
packages/acp-link/src/ws-auth.ts
Normal 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));
|
||||
}
|
||||
60
packages/acp-link/src/ws-message.ts
Normal file
60
packages/acp-link/src/ws-message.ts
Normal 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;
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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 +
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -10,6 +10,9 @@ const mockConfig = {
|
||||
heartbeatInterval: 20,
|
||||
jwtExpiresIn: 3600,
|
||||
disconnectTimeout: 300,
|
||||
webCorsOrigins: [],
|
||||
wsIdleTimeout: 30,
|
||||
wsKeepaliveInterval: 20,
|
||||
};
|
||||
|
||||
mock.module("../config", () => ({
|
||||
|
||||
@@ -10,6 +10,9 @@ const mockConfig = {
|
||||
heartbeatInterval: 20,
|
||||
jwtExpiresIn: 3600,
|
||||
disconnectTimeout: 300,
|
||||
webCorsOrigins: [],
|
||||
wsIdleTimeout: 30,
|
||||
wsKeepaliveInterval: 20,
|
||||
};
|
||||
|
||||
mock.module("../config", () => ({
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -10,6 +10,9 @@ const mockConfig = {
|
||||
heartbeatInterval: 20,
|
||||
jwtExpiresIn: 3600,
|
||||
disconnectTimeout: 300,
|
||||
webCorsOrigins: [],
|
||||
wsIdleTimeout: 30,
|
||||
wsKeepaliveInterval: 20,
|
||||
};
|
||||
|
||||
mock.module("../config", () => ({
|
||||
|
||||
@@ -10,6 +10,9 @@ const mockConfig = {
|
||||
heartbeatInterval: 20,
|
||||
jwtExpiresIn: 3600,
|
||||
disconnectTimeout: 300,
|
||||
webCorsOrigins: [],
|
||||
wsIdleTimeout: 30,
|
||||
wsKeepaliveInterval: 20,
|
||||
};
|
||||
|
||||
mock.module("../config", () => ({
|
||||
|
||||
@@ -10,6 +10,9 @@ const mockConfig = {
|
||||
heartbeatInterval: 20,
|
||||
jwtExpiresIn: 3600,
|
||||
disconnectTimeout: 300,
|
||||
webCorsOrigins: [],
|
||||
wsIdleTimeout: 30,
|
||||
wsKeepaliveInterval: 20,
|
||||
};
|
||||
|
||||
mock.module("../config", () => ({
|
||||
|
||||
@@ -10,6 +10,9 @@ const mockConfig = {
|
||||
heartbeatInterval: 20,
|
||||
jwtExpiresIn: 3600,
|
||||
disconnectTimeout: 300,
|
||||
webCorsOrigins: [],
|
||||
wsIdleTimeout: 30,
|
||||
wsKeepaliveInterval: 20,
|
||||
};
|
||||
|
||||
mock.module("../config", () => ({
|
||||
|
||||
@@ -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 {
|
||||
|
||||
34
packages/remote-control-server/src/auth/cors.ts
Normal file
34
packages/remote-control-server/src/auth/cors.ts
Normal 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,
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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 }));
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { v4 as uuid } from "uuid";
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
// ---------- Types ----------
|
||||
|
||||
@@ -110,7 +110,7 @@ export function storeCreateEnvironment(req: {
|
||||
username?: string;
|
||||
capabilities?: Record<string, unknown>;
|
||||
}): EnvironmentRecord {
|
||||
const id = `env_${uuid().replace(/-/g, "")}`;
|
||||
const id = `env_${randomUUID().replace(/-/g, "")}`;
|
||||
const now = new Date();
|
||||
const record: EnvironmentRecord = {
|
||||
id,
|
||||
@@ -162,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,
|
||||
@@ -317,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,
|
||||
|
||||
@@ -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 },
|
||||
|
||||
64
packages/remote-control-server/src/transport/ws-payload.ts
Normal file
64
packages/remote-control-server/src/transport/ws-payload.ts
Normal 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;
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
|
||||
@@ -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 },
|
||||
}),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}`,
|
||||
)
|
||||
|
||||
@@ -518,7 +518,7 @@ export class SSETransport implements Transport {
|
||||
this.reconnectAttempts++
|
||||
|
||||
const baseDelay = Math.min(
|
||||
RECONNECT_BASE_DELAY_MS * Math.pow(2, this.reconnectAttempts - 1),
|
||||
RECONNECT_BASE_DELAY_MS * 2 ** (this.reconnectAttempts - 1),
|
||||
RECONNECT_MAX_DELAY_MS,
|
||||
)
|
||||
// Add ±25% jitter
|
||||
@@ -668,7 +668,7 @@ export class SSETransport implements Transport {
|
||||
}
|
||||
|
||||
const delayMs = Math.min(
|
||||
POST_BASE_DELAY_MS * Math.pow(2, attempt - 1),
|
||||
POST_BASE_DELAY_MS * 2 ** (attempt - 1),
|
||||
POST_MAX_DELAY_MS,
|
||||
)
|
||||
await sleep(delayMs)
|
||||
|
||||
@@ -516,7 +516,7 @@ export class WebSocketTransport implements Transport {
|
||||
this.reconnectAttempts++
|
||||
|
||||
const baseDelay = Math.min(
|
||||
DEFAULT_BASE_RECONNECT_DELAY * Math.pow(2, this.reconnectAttempts - 1),
|
||||
DEFAULT_BASE_RECONNECT_DELAY * 2 ** (this.reconnectAttempts - 1),
|
||||
DEFAULT_MAX_RECONNECT_DELAY,
|
||||
)
|
||||
// Add ±25% jitter to avoid thundering herd
|
||||
|
||||
@@ -61,7 +61,7 @@ function IDEScreen({
|
||||
} else if (value === 'None' && shouldShowDisableAutoConnectDialog()) {
|
||||
setShowDisableAutoConnectDialog(true)
|
||||
} else {
|
||||
onSelect(availableIDEs.find(ide => ide.port === parseInt(value)))
|
||||
onSelect(availableIDEs.find(ide => ide.port === parseInt(value, 10)))
|
||||
}
|
||||
},
|
||||
[availableIDEs, onSelect],
|
||||
@@ -216,7 +216,7 @@ function IDEOpenSelection({
|
||||
const handleSelectIDE = useCallback(
|
||||
(value: string) => {
|
||||
const selectedIDE = availableIDEs.find(
|
||||
ide => ide.port === parseInt(value),
|
||||
ide => ide.port === parseInt(value, 10),
|
||||
)
|
||||
onSelectIDE(selectedIDE)
|
||||
},
|
||||
|
||||
@@ -78,7 +78,7 @@ function ModelPickerWrapper({
|
||||
}
|
||||
|
||||
// Turn off fast mode if switching to unsupported model
|
||||
let wasFastModeToggledOn = undefined
|
||||
let wasFastModeToggledOn
|
||||
if (isFastModeEnabled()) {
|
||||
clearFastModeCooldown()
|
||||
if (!isFastModeSupportedByModel(model) && isFastMode) {
|
||||
@@ -214,7 +214,7 @@ function SetModelAndClose({
|
||||
}))
|
||||
let message = `Set model to ${chalk.bold(renderModelLabel(modelValue))}`
|
||||
|
||||
let wasFastModeToggledOn = undefined
|
||||
let wasFastModeToggledOn
|
||||
if (isFastModeEnabled()) {
|
||||
clearFastModeCooldown()
|
||||
if (!isFastModeSupportedByModel(modelValue) && isFastMode) {
|
||||
|
||||
@@ -133,7 +133,6 @@ export function AddMarketplace({
|
||||
void handleAdd()
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: intentional
|
||||
}, []) // Only run once on mount
|
||||
|
||||
return (
|
||||
|
||||
@@ -190,7 +190,6 @@ export function ManageMarketplaces({
|
||||
}
|
||||
void loadMarketplaces()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: intentional
|
||||
}, [targetMarketplace, action, error])
|
||||
|
||||
// Check if there are any pending changes
|
||||
|
||||
@@ -204,7 +204,6 @@ export function AutoUpdater({
|
||||
// instead so the guard is always current without changing callback
|
||||
// identity (which would re-trigger the initial-check useEffect below).
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: isUpdating read via ref
|
||||
}, [onAutoUpdaterResult])
|
||||
|
||||
// Initial check
|
||||
|
||||
@@ -13,7 +13,6 @@ export function MemoryUsageIndicator(): React.ReactNode {
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
// biome-ignore lint/correctness/useHookAtTopLevel: USER_TYPE is a build-time constant
|
||||
const memoryUsage = useMemoryUsage()
|
||||
|
||||
if (!memoryUsage) {
|
||||
|
||||
@@ -879,7 +879,6 @@ function computeDiffStatsBetweenMessages(
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -169,7 +169,6 @@ export function NativeAutoUpdater({
|
||||
// instead so the guard is always current without changing callback
|
||||
// identity (which would re-trigger the initial-check useEffect below).
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: isUpdating read via ref
|
||||
}, [onAutoUpdaterResult, channel])
|
||||
|
||||
// Initial check
|
||||
|
||||
@@ -254,18 +254,17 @@ function NotificationContent({
|
||||
|
||||
// Voice state (VOICE_MODE builds only, runtime-gated by GrowthBook)
|
||||
const voiceState = feature('VOICE_MODE')
|
||||
? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
|
||||
?
|
||||
useVoiceState(s => s.voiceState)
|
||||
: ('idle' as const)
|
||||
// biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
|
||||
const voiceEnabled = feature('VOICE_MODE') ? useVoiceEnabled() : false
|
||||
const voiceError = feature('VOICE_MODE')
|
||||
? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
|
||||
?
|
||||
useVoiceState(s => s.voiceError)
|
||||
: null
|
||||
const isBriefOnly =
|
||||
feature('KAIROS') || feature('KAIROS_BRIEF')
|
||||
? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
|
||||
?
|
||||
useAppState(s => s.isBriefOnly)
|
||||
: false
|
||||
|
||||
|
||||
@@ -449,7 +449,7 @@ function PromptInput({
|
||||
// its own marginTop, so the gap stays even without ours.
|
||||
const briefOwnsGap =
|
||||
feature('KAIROS') || feature('KAIROS_BRIEF')
|
||||
? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
|
||||
?
|
||||
useAppState(s => s.isBriefOnly) && !viewingAgentTaskId
|
||||
: false
|
||||
const mainLoopModel_ = useAppState(s => s.mainLoopModel)
|
||||
@@ -2384,7 +2384,7 @@ function PromptInput({
|
||||
useBuddyNotification()
|
||||
|
||||
const companionSpeaking = feature('BUDDY')
|
||||
? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
|
||||
?
|
||||
useAppState(s => s.companionReaction !== undefined)
|
||||
: false
|
||||
const { columns, rows } = useTerminalSize()
|
||||
|
||||
@@ -258,14 +258,13 @@ function ModeIndicator({
|
||||
proactiveModule?.getNextTickAt ?? NULL,
|
||||
NULL,
|
||||
)
|
||||
// biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
|
||||
const voiceEnabled = feature('VOICE_MODE') ? useVoiceEnabled() : false
|
||||
const voiceState = feature('VOICE_MODE')
|
||||
? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
|
||||
?
|
||||
useVoiceState(s => s.voiceState)
|
||||
: ('idle' as const)
|
||||
const voiceWarmingUp = feature('VOICE_MODE')
|
||||
? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
|
||||
?
|
||||
useVoiceState(s => s.voiceWarmingUp)
|
||||
: false
|
||||
const hasSelection = useHasSelection()
|
||||
@@ -302,7 +301,7 @@ function ModeIndicator({
|
||||
'ctrl+x ctrl+k',
|
||||
)
|
||||
const voiceKeyShortcut = feature('VOICE_MODE')
|
||||
? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
|
||||
?
|
||||
useShortcutDisplay('voice:pushToTalk', 'Chat', 'Space')
|
||||
: ''
|
||||
// Captured at mount so the hint doesn't flicker mid-session if another
|
||||
@@ -311,14 +310,13 @@ function ModeIndicator({
|
||||
// shown" without tracking the exact render-time condition (which depends
|
||||
// on parts/hintParts computed after the early-return hooks boundary).
|
||||
const [voiceHintUnderCap] = feature('VOICE_MODE')
|
||||
? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
|
||||
?
|
||||
useState(
|
||||
() =>
|
||||
(getGlobalConfig().voiceFooterHintSeenCount ?? 0) <
|
||||
MAX_VOICE_HINT_SHOWS,
|
||||
)
|
||||
: [false]
|
||||
// biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
|
||||
const voiceHintIncrementedRef = feature('VOICE_MODE') ? useRef(false) : null
|
||||
useEffect(() => {
|
||||
if (feature('VOICE_MODE')) {
|
||||
|
||||
@@ -100,7 +100,7 @@ function PromptInputQueuedCommandsImpl(): React.ReactNode {
|
||||
// component early-returns when viewing a teammate.
|
||||
const useBriefLayout =
|
||||
feature('KAIROS') || feature('KAIROS_BRIEF')
|
||||
? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
|
||||
?
|
||||
useAppState(s => s.isBriefOnly)
|
||||
: false
|
||||
|
||||
|
||||
@@ -258,7 +258,7 @@ export function computeWheelStep(
|
||||
// the curve handles it (gap=1000ms → m≈0.01 → mult≈1). No frac —
|
||||
// rounding loss is minor at high mult, and frac persisting across idle
|
||||
// was causing off-by-one on the first click back.
|
||||
const m = Math.pow(0.5, gap / WHEEL_DECAY_HALFLIFE_MS)
|
||||
const m = 0.5 ** (gap / WHEEL_DECAY_HALFLIFE_MS)
|
||||
const cap = Math.max(WHEEL_MODE_CAP, state.base * 2)
|
||||
const next = 1 + (state.mult - 1) * m + WHEEL_MODE_STEP * m
|
||||
state.mult = Math.min(cap, next, state.mult + WHEEL_MODE_RAMP)
|
||||
@@ -299,7 +299,7 @@ export function computeWheelStep(
|
||||
state.mult = 2
|
||||
state.frac = 0
|
||||
} else {
|
||||
const m = Math.pow(0.5, gap / WHEEL_DECAY_HALFLIFE_MS)
|
||||
const m = 0.5 ** (gap / WHEEL_DECAY_HALFLIFE_MS)
|
||||
const cap =
|
||||
gap >= WHEEL_DECAY_GAP_MS ? WHEEL_DECAY_CAP_SLOW : WHEEL_DECAY_CAP_FAST
|
||||
state.mult = Math.min(cap, 1 + (state.mult - 1) * m + WHEEL_DECAY_STEP * m)
|
||||
|
||||
@@ -95,7 +95,7 @@ export function SpinnerWithVerb(props: Props): React.ReactNode {
|
||||
// Hoisted to mount-time — this component re-renders at animation framerate.
|
||||
const briefEnvEnabled =
|
||||
feature('KAIROS') || feature('KAIROS_BRIEF')
|
||||
? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
|
||||
?
|
||||
useMemo(() => isEnvTruthy(process.env.CLAUDE_CODE_BRIEF), [])
|
||||
: false
|
||||
|
||||
|
||||
@@ -370,7 +370,6 @@ function StatusLineInner({
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: intentional
|
||||
}, []) // Only run once on mount - settings stable for initial logging
|
||||
|
||||
// Initial update on mount + cleanup on unmount
|
||||
@@ -384,7 +383,6 @@ function StatusLineInner({
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: intentional
|
||||
}, []) // Only run once on mount, not when doUpdate changes
|
||||
|
||||
// Get padding from settings or default to 0
|
||||
|
||||
@@ -48,20 +48,20 @@ export default function TextInput(props: Props): React.ReactNode {
|
||||
const reducedMotion = settings.prefersReducedMotion ?? false
|
||||
|
||||
const voiceState = feature('VOICE_MODE')
|
||||
? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
|
||||
?
|
||||
useVoiceState(s => s.voiceState)
|
||||
: ('idle' as const)
|
||||
const isVoiceRecording = voiceState === 'recording'
|
||||
|
||||
const audioLevels = feature('VOICE_MODE')
|
||||
? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
|
||||
?
|
||||
useVoiceState(s => s.voiceAudioLevels)
|
||||
: []
|
||||
const smoothedRef = useRef<number[]>(new Array(CURSOR_WAVEFORM_WIDTH).fill(0))
|
||||
|
||||
const needsAnimation = isVoiceRecording && !reducedMotion
|
||||
const [animRef, animTime] = feature('VOICE_MODE')
|
||||
? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
|
||||
?
|
||||
useAnimationFrame(needsAnimation ? 50 : null)
|
||||
: [() => {}, 0]
|
||||
|
||||
|
||||
@@ -63,7 +63,7 @@ export function WorktreeExitDialog({
|
||||
'--count',
|
||||
`${worktreeSession.originalHeadCommit}..HEAD`,
|
||||
])
|
||||
const count = parseInt(commitsStr.trim()) || 0
|
||||
const count = parseInt(commitsStr.trim(), 10) || 0
|
||||
setCommitCount(count)
|
||||
|
||||
// If no changes and no commits, clean up silently
|
||||
@@ -94,7 +94,6 @@ export function WorktreeExitDialog({
|
||||
}
|
||||
void loadChanges()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: intentional
|
||||
}, [worktreeSession])
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -27,7 +27,7 @@ export function ColorPicker({
|
||||
const [selectedIndex, setSelectedIndex] = useState(
|
||||
Math.max(
|
||||
0,
|
||||
COLOR_OPTIONS.findIndex(opt => opt === currentColor),
|
||||
COLOR_OPTIONS.indexOf(currentColor),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -60,7 +60,7 @@ export function MCPSettings({ onComplete }: Props): React.ReactNode {
|
||||
const isSSE = client.config.type === 'sse'
|
||||
const isHTTP = client.config.type === 'http'
|
||||
const isClaudeAIProxy = client.config.type === 'claudeai-proxy'
|
||||
let isAuthenticated: boolean | undefined = undefined
|
||||
let isAuthenticated: boolean | undefined
|
||||
|
||||
if (isSSE || isHTTP) {
|
||||
const authProvider = new ClaudeAuthProvider(
|
||||
|
||||
@@ -88,7 +88,7 @@ export function MCPToolListView({
|
||||
<Select
|
||||
options={toolOptions}
|
||||
onChange={value => {
|
||||
const index = parseInt(value)
|
||||
const index = parseInt(value, 10)
|
||||
const tool = serverTools[index]
|
||||
if (tool) {
|
||||
onSelectTool(tool, index)
|
||||
|
||||
@@ -48,7 +48,7 @@ export function AttachmentMessage({
|
||||
const bg = useSelectedMessageBg()
|
||||
// Hoisted to mount-time — per-message component, re-renders on every scroll.
|
||||
const isDemoEnv = feature('EXPERIMENTAL_SKILL_SEARCH')
|
||||
? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
|
||||
?
|
||||
useMemo(() => isEnvTruthy(process.env.IS_DEMO), [])
|
||||
: false
|
||||
// Handle teammate_mailbox BEFORE switch
|
||||
|
||||
@@ -50,18 +50,18 @@ export function UserPromptMessage({
|
||||
// external builds.
|
||||
const isBriefOnly =
|
||||
feature('KAIROS') || feature('KAIROS_BRIEF')
|
||||
? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
|
||||
?
|
||||
useAppState(s => s.isBriefOnly)
|
||||
: false
|
||||
const viewingAgentTaskId =
|
||||
feature('KAIROS') || feature('KAIROS_BRIEF')
|
||||
? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
|
||||
?
|
||||
useAppState(s => s.viewingAgentTaskId)
|
||||
: null
|
||||
// Hoisted to mount-time — per-message component, re-renders on every scroll.
|
||||
const briefEnvEnabled =
|
||||
feature('KAIROS') || feature('KAIROS_BRIEF')
|
||||
? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
|
||||
?
|
||||
useMemo(() => isEnvTruthy(process.env.CLAUDE_CODE_BRIEF), [])
|
||||
: false
|
||||
const useBriefLayout =
|
||||
|
||||
@@ -53,7 +53,7 @@ export function UserToolSuccessMessage({
|
||||
// UserPromptMessage.tsx.
|
||||
const isBriefOnly =
|
||||
feature('KAIROS') || feature('KAIROS_BRIEF')
|
||||
? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
|
||||
?
|
||||
useAppState(s => s.isBriefOnly)
|
||||
: false
|
||||
|
||||
|
||||
@@ -77,7 +77,6 @@ export function TeamsDialog({ initialTeams, onDone }: Props): React.ReactNode {
|
||||
const teammateStatuses = useMemo(() => {
|
||||
return getTeammateStatuses(dialogLevel.teamName);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: intentional
|
||||
}, [dialogLevel.teamName, refreshKey]);
|
||||
|
||||
// Periodically refresh to pick up mode changes from teammates
|
||||
|
||||
@@ -31,13 +31,13 @@ jobs:
|
||||
actions: read # Required for Claude to read CI results on PRs
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2, 2026-04-25
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Run Claude Code
|
||||
id: claude
|
||||
uses: anthropics/claude-code-action@v1
|
||||
uses: anthropics/claude-code-action@567fe954a4527e81f132d87d1bdbcc94f7737434 # v1, 2026-04-25
|
||||
with:
|
||||
anthropic_api_key: \${{ secrets.ANTHROPIC_API_KEY }}
|
||||
|
||||
@@ -126,13 +126,13 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2, 2026-04-25
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Run Claude Code Review
|
||||
id: claude-review
|
||||
uses: anthropics/claude-code-action@v1
|
||||
uses: anthropics/claude-code-action@567fe954a4527e81f132d87d1bdbcc94f7737434 # v1, 2026-04-25
|
||||
with:
|
||||
anthropic_api_key: \${{ secrets.ANTHROPIC_API_KEY }}
|
||||
plugin_marketplaces: 'https://github.com/anthropics/claude-code.git'
|
||||
|
||||
@@ -80,7 +80,6 @@ async function main(): Promise<void> {
|
||||
(args[0] === '--version' || args[0] === '-v' || args[0] === '-V')
|
||||
) {
|
||||
// MACRO.VERSION is inlined at build time
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(`${MACRO.VERSION} (Claude Code)`)
|
||||
return
|
||||
}
|
||||
@@ -101,7 +100,6 @@ async function main(): Promise<void> {
|
||||
const model = (modelIdx !== -1 && args[modelIdx + 1]) || getMainLoopModel()
|
||||
const { getSystemPrompt } = await import('../constants/prompts.js')
|
||||
const prompt = await getSystemPrompt([], model)
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(prompt.join('\n'))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ export function renderPlaceholder({
|
||||
renderedPlaceholder: string | undefined
|
||||
showPlaceholder: boolean
|
||||
} {
|
||||
let renderedPlaceholder: string | undefined = undefined
|
||||
let renderedPlaceholder: string | undefined
|
||||
|
||||
if (placeholder) {
|
||||
if (hidePlaceholderText) {
|
||||
|
||||
@@ -17,7 +17,7 @@ const HISTORY_CHUNK_SIZE = 10
|
||||
// Mode filter is included to ensure we don't mix filtered and unfiltered caches
|
||||
let pendingLoad: Promise<HistoryEntry[]> | null = null
|
||||
let pendingLoadTarget = 0
|
||||
let pendingLoadModeFilter: HistoryMode | undefined = undefined
|
||||
let pendingLoadModeFilter: HistoryMode | undefined
|
||||
|
||||
async function loadHistoryEntries(
|
||||
minCount: number,
|
||||
|
||||
@@ -92,7 +92,7 @@ export function GlobalKeybindingHandlers({
|
||||
// Brief view has its own dedicated toggle on ctrl+shift+b.
|
||||
const isBriefOnly =
|
||||
feature('KAIROS') || feature('KAIROS_BRIEF')
|
||||
? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
|
||||
?
|
||||
useAppState(s => s.isBriefOnly)
|
||||
: false
|
||||
const handleToggleTranscript = useCallback(() => {
|
||||
@@ -202,7 +202,6 @@ export function GlobalKeybindingHandlers({
|
||||
context: 'Global',
|
||||
})
|
||||
if (feature('KAIROS') || feature('KAIROS_BRIEF')) {
|
||||
// biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
|
||||
useKeybinding('app:toggleBrief', handleToggleBrief, {
|
||||
context: 'Global',
|
||||
})
|
||||
|
||||
@@ -123,23 +123,23 @@ export function useReplBridge(
|
||||
const store = useAppStateStore()
|
||||
const { addNotification } = useNotifications()
|
||||
const replBridgeEnabled = feature('BRIDGE_MODE')
|
||||
? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
|
||||
?
|
||||
useAppState(s => s.replBridgeEnabled)
|
||||
: false
|
||||
const replBridgeConnected = feature('BRIDGE_MODE')
|
||||
? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
|
||||
?
|
||||
useAppState(s => s.replBridgeConnected)
|
||||
: false
|
||||
const replBridgeSessionActive = feature('BRIDGE_MODE')
|
||||
? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
|
||||
?
|
||||
useAppState(s => s.replBridgeSessionActive)
|
||||
: false
|
||||
const replBridgeOutboundOnly = feature('BRIDGE_MODE')
|
||||
? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
|
||||
?
|
||||
useAppState(s => s.replBridgeOutboundOnly)
|
||||
: false
|
||||
const replBridgeInitialName = feature('BRIDGE_MODE')
|
||||
? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
|
||||
?
|
||||
useAppState(s => s.replBridgeInitialName)
|
||||
: undefined
|
||||
|
||||
|
||||
@@ -236,14 +236,13 @@ export function useVoiceIntegration({
|
||||
// Voice state selectors. useVoiceEnabled = user intent (settings) +
|
||||
// auth + GB kill-switch, with the auth half memoized on authVersion so
|
||||
// render loops never hit a cold keychain spawn.
|
||||
// biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
|
||||
const voiceEnabled = feature('VOICE_MODE') ? useVoiceEnabled() : false
|
||||
const voiceState = feature('VOICE_MODE')
|
||||
? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
|
||||
?
|
||||
useVoiceState(s => s.voiceState)
|
||||
: ('idle' as const)
|
||||
const voiceInterimTranscript = feature('VOICE_MODE')
|
||||
? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
|
||||
?
|
||||
useVoiceState(s => s.voiceInterimTranscript)
|
||||
: ''
|
||||
|
||||
@@ -416,10 +415,9 @@ export function useVoiceKeybindingHandler({
|
||||
const setVoiceState = useSetVoiceState()
|
||||
const keybindingContext = useOptionalKeybindingContext()
|
||||
const isModalOverlayActive = useIsModalOverlayActive()
|
||||
// biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
|
||||
const voiceEnabled = feature('VOICE_MODE') ? useVoiceEnabled() : false
|
||||
const voiceState = feature('VOICE_MODE')
|
||||
? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
|
||||
?
|
||||
useVoiceState(s => s.voiceState)
|
||||
: 'idle'
|
||||
|
||||
|
||||
@@ -1758,7 +1758,6 @@ async function run(): Promise<CommanderCommand> {
|
||||
// Ignore "code" as a prompt - treat it the same as no prompt
|
||||
if (prompt === "code") {
|
||||
logEvent("tengu_code_prompt_ignored", {});
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.warn(
|
||||
chalk.yellow(
|
||||
"Tip: You can launch Claude Code with just `claude`",
|
||||
@@ -1826,7 +1825,6 @@ async function run(): Promise<CommanderCommand> {
|
||||
kairosGate
|
||||
) {
|
||||
if (!checkHasTrustDialogAccepted()) {
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.warn(
|
||||
chalk.yellow(
|
||||
"Assistant mode disabled: directory is not trusted. Accept the trust dialog and restart.",
|
||||
@@ -2460,7 +2458,6 @@ async function run(): Promise<CommanderCommand> {
|
||||
});
|
||||
logForDebugging(`[Claude in Chrome] Error: ${error}`);
|
||||
logError(error);
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.error(
|
||||
`Error: Failed to run with Claude in Chrome.`,
|
||||
);
|
||||
@@ -2745,7 +2742,6 @@ async function run(): Promise<CommanderCommand> {
|
||||
|
||||
// Print any warnings from initialization
|
||||
warnings.forEach((warning) => {
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.error(warning);
|
||||
});
|
||||
|
||||
@@ -2807,7 +2803,6 @@ async function run(): Promise<CommanderCommand> {
|
||||
inputFormat !== "text" &&
|
||||
inputFormat !== "stream-json"
|
||||
) {
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.error(`Error: Invalid input format "${inputFormat}".`);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -2815,7 +2810,6 @@ async function run(): Promise<CommanderCommand> {
|
||||
inputFormat === "stream-json" &&
|
||||
outputFormat !== "stream-json"
|
||||
) {
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.error(
|
||||
`Error: --input-format=stream-json requires output-format=stream-json.`,
|
||||
);
|
||||
@@ -2828,7 +2822,6 @@ async function run(): Promise<CommanderCommand> {
|
||||
inputFormat !== "stream-json" ||
|
||||
outputFormat !== "stream-json"
|
||||
) {
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.error(
|
||||
`Error: --sdk-url requires both --input-format=stream-json and --output-format=stream-json.`,
|
||||
);
|
||||
@@ -2842,7 +2835,6 @@ async function run(): Promise<CommanderCommand> {
|
||||
inputFormat !== "stream-json" ||
|
||||
outputFormat !== "stream-json"
|
||||
) {
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.error(
|
||||
`Error: --replay-user-messages requires both --input-format=stream-json and --output-format=stream-json.`,
|
||||
);
|
||||
@@ -6061,7 +6053,6 @@ async function run(): Promise<CommanderCommand> {
|
||||
setDirectConnectServerUrl(serverUrl);
|
||||
connectConfig = session.config;
|
||||
} catch (err) {
|
||||
// biome-ignore lint/suspicious/noConsole: intentional error output
|
||||
console.error(
|
||||
err instanceof DirectConnectError
|
||||
? err.message
|
||||
|
||||
@@ -206,7 +206,7 @@ export class FileIndex {
|
||||
|
||||
const { paths, lowerPaths, charBits, pathLens, readyCount } = this
|
||||
|
||||
outer: for (let i = 0; i < readyCount; i++) {
|
||||
for (let i = 0; i < readyCount; i++) {
|
||||
// O(1) bitmap reject: path must contain every letter in the needle
|
||||
if ((charBits[i]! & needleBitmap) !== needleBitmap) continue
|
||||
|
||||
|
||||
@@ -277,7 +277,6 @@ export async function* query(
|
||||
for (const uuid of consumedCommandUuids) {
|
||||
notifyCommandLifecycle(uuid, 'completed')
|
||||
}
|
||||
// biome-ignore lint/style/noNonNullAssertion: terminal is always assigned when queryLoop returns normally
|
||||
return terminal!
|
||||
}
|
||||
|
||||
@@ -331,7 +330,7 @@ async function* queryLoop(
|
||||
// multiple compacts: each subtracts the final context at that compact's
|
||||
// trigger point. Loop-local (not on State) to avoid touching the 7 continue
|
||||
// sites.
|
||||
let taskBudgetRemaining: number | undefined = undefined
|
||||
let taskBudgetRemaining: number | undefined
|
||||
|
||||
// Snapshot immutable env/statsig/session state once at entry. See QueryConfig
|
||||
// for what's included and why feature() gates are intentionally excluded.
|
||||
|
||||
@@ -639,6 +639,7 @@ function TranscriptSearchBar({
|
||||
const [indexStatus, setIndexStatus] = React.useState<'building' | { ms: number } | null>('building');
|
||||
React.useEffect(() => {
|
||||
let alive = true;
|
||||
let hideTimeout: ReturnType<typeof setTimeout> | undefined;
|
||||
const warm = jumpRef.current?.warmSearchIndex;
|
||||
if (!warm) {
|
||||
setIndexStatus(null); // VML not mounted yet — rare, skip indicator
|
||||
@@ -652,14 +653,14 @@ function TranscriptSearchBar({
|
||||
setIndexStatus(null);
|
||||
} else {
|
||||
setIndexStatus({ ms });
|
||||
setTimeout(() => alive && setIndexStatus(null), 2000);
|
||||
hideTimeout = setTimeout(() => alive && setIndexStatus(null), 2000);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
if (hideTimeout) clearTimeout(hideTimeout);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []); // mount-only: bar opens once per /
|
||||
}, [jumpRef]); // mount-only per stable search bar ref
|
||||
// Gate the query effect on warm completion. setHighlight stays instant
|
||||
// (screen-space overlay, no indexing). setSearchQuery (the scan) waits.
|
||||
const warmDone = indexStatus !== 'building';
|
||||
@@ -667,8 +668,7 @@ function TranscriptSearchBar({
|
||||
if (!warmDone) return;
|
||||
jumpRef.current?.setSearchQuery(query);
|
||||
setHighlight(query);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [query, warmDone]);
|
||||
}, [jumpRef, query, setHighlight, warmDone]);
|
||||
const off = cursorOffset;
|
||||
const cursorChar = off < query.length ? query[off] : ' ';
|
||||
return (
|
||||
@@ -4992,16 +4992,19 @@ export function REPL({
|
||||
}
|
||||
}, [queuedCommands]);
|
||||
|
||||
const onInitRef = useRef(onInit);
|
||||
onInitRef.current = onInit;
|
||||
const diagnosticTrackerRef = useRef(diagnosticTracker);
|
||||
diagnosticTrackerRef.current = diagnosticTracker;
|
||||
|
||||
// Initial load
|
||||
useEffect(() => {
|
||||
void onInit();
|
||||
void onInitRef.current();
|
||||
|
||||
// Cleanup on unmount
|
||||
return () => {
|
||||
void diagnosticTracker.shutdown();
|
||||
void diagnosticTrackerRef.current.shutdown();
|
||||
};
|
||||
// TODO: fix this
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Listen for suspend/resume events
|
||||
|
||||
@@ -86,7 +86,7 @@ function substituteVariables(
|
||||
// (replacer fn treats $ literally), and (2) double-substitution when user
|
||||
// content happens to contain {{varName}} matching a later variable.
|
||||
return template.replace(/\{\{(\w+)\}\}/g, (match, key: string) =>
|
||||
Object.prototype.hasOwnProperty.call(variables, key)
|
||||
Object.hasOwn(variables, key)
|
||||
? variables[key]!
|
||||
: match,
|
||||
)
|
||||
|
||||
@@ -206,7 +206,7 @@ function substituteVariables(
|
||||
// (replacer fn treats $ literally), and (2) double-substitution when user
|
||||
// content happens to contain {{varName}} matching a later variable.
|
||||
return template.replace(/\{\{(\w+)\}\}/g, (match, key: string) =>
|
||||
Object.prototype.hasOwnProperty.call(variables, key)
|
||||
Object.hasOwn(variables, key)
|
||||
? variables[key]!
|
||||
: match,
|
||||
)
|
||||
|
||||
@@ -4,56 +4,74 @@ import {
|
||||
test,
|
||||
mock,
|
||||
beforeEach,
|
||||
afterEach,
|
||||
afterAll,
|
||||
spyOn,
|
||||
} from 'bun:test'
|
||||
|
||||
// ── Mock infrastructure ──────────────────────────────────────────
|
||||
// bun:test mock.module is process-global: it leaks to sibling test files
|
||||
// in the same worker. safeMockModule snapshots real exports before mocking
|
||||
// in the same worker. Preserve real exports before partial module mocking
|
||||
// so afterAll can restore them, preventing cross-file pollution.
|
||||
|
||||
const _restores: (() => void)[] = []
|
||||
const originalCwd = process.cwd()
|
||||
const originalAcpPermissionMode = process.env.ACP_PERMISSION_MODE
|
||||
const originalAcpAllowBypass = process.env.CLAUDE_CODE_ACP_ALLOW_BYPASS_PERMISSIONS
|
||||
|
||||
function safeMockModule(tsPath: string, overrides: Record<string, unknown>) {
|
||||
function mockModulePreservingExports(
|
||||
tsPath: string,
|
||||
overrides: Record<string, unknown>,
|
||||
) {
|
||||
const jsPath = tsPath.replace(/\.ts$/, '.js')
|
||||
const real = require(tsPath)
|
||||
const snapshot = { ...real }
|
||||
const snapshot = { ...(require(tsPath) as Record<string, unknown>) }
|
||||
mock.module(jsPath, () => ({ ...snapshot, ...overrides }))
|
||||
_restores.push(() => mock.module(jsPath, () => snapshot))
|
||||
}
|
||||
|
||||
afterAll(() => {
|
||||
for (let i = _restores.length - 1; i >= 0; i--) {
|
||||
_restores[i]()
|
||||
}
|
||||
_restores.length = 0
|
||||
restoreEnv('ACP_PERMISSION_MODE', originalAcpPermissionMode)
|
||||
restoreEnv(
|
||||
'CLAUDE_CODE_ACP_ALLOW_BYPASS_PERMISSIONS',
|
||||
originalAcpAllowBypass,
|
||||
)
|
||||
})
|
||||
|
||||
// ── Module mocks (must precede any import of the module under test) ──
|
||||
|
||||
const mockSetModel = mock(() => {})
|
||||
const mockSubmitMessage = mock(async function* (_input: string) {})
|
||||
|
||||
// Fully synthetic — no real module to snapshot, so plain mock.module suffices.
|
||||
mock.module('../../../QueryEngine.js', () => ({
|
||||
mockModulePreservingExports('../../../QueryEngine.ts', {
|
||||
QueryEngine: class MockQueryEngine {
|
||||
submitMessage = mock(async function* () {})
|
||||
submitMessage = mockSubmitMessage
|
||||
interrupt = mock(() => {})
|
||||
resetAbortController = mock(() => {})
|
||||
getAbortSignal = mock(() => new AbortController().signal)
|
||||
setModel = mockSetModel
|
||||
},
|
||||
}))
|
||||
})
|
||||
|
||||
safeMockModule('../../../tools.ts', {
|
||||
mockModulePreservingExports('../../../tools.ts', {
|
||||
getTools: mock(() => []),
|
||||
})
|
||||
|
||||
safeMockModule('../../../Tool.ts', {
|
||||
mockModulePreservingExports('../../../Tool.ts', {
|
||||
toolMatchesName: mock(() => false),
|
||||
findToolByName: mock(() => undefined),
|
||||
filterToolProgressMessages: mock(() => []),
|
||||
buildTool: mock((def: any) => def),
|
||||
})
|
||||
|
||||
safeMockModule('../../../utils/config.ts', {
|
||||
mockModulePreservingExports('../../../utils/config.ts', {
|
||||
enableConfigs: mock(() => {}),
|
||||
})
|
||||
|
||||
safeMockModule('../../../bootstrap/state.ts', {
|
||||
mockModulePreservingExports('../../../bootstrap/state.ts', {
|
||||
setOriginalCwd: mock(() => {}),
|
||||
addSlowOperation: mock(() => {}),
|
||||
})
|
||||
@@ -75,24 +93,16 @@ const mockGetDefaultAppState = mock(() => ({
|
||||
mainLoopModelForSession: null,
|
||||
}))
|
||||
|
||||
safeMockModule('../../../state/AppStateStore.ts', {
|
||||
mockModulePreservingExports('../../../state/AppStateStore.ts', {
|
||||
getDefaultAppState: mockGetDefaultAppState,
|
||||
})
|
||||
|
||||
// Single export, fully synthetic — no real module to snapshot.
|
||||
mock.module('../permissions.js', () => ({
|
||||
createAcpCanUseTool: mock(() =>
|
||||
mock(async () => ({ behavior: 'allow', updatedInput: {} })),
|
||||
),
|
||||
}))
|
||||
|
||||
safeMockModule('../utils.ts', {
|
||||
resolvePermissionMode: mock(() => 'default'),
|
||||
mockModulePreservingExports('../utils.ts', {
|
||||
computeSessionFingerprint: mock(() => '{}'),
|
||||
sanitizeTitle: mock((s: string) => s),
|
||||
})
|
||||
|
||||
safeMockModule('../bridge.ts', {
|
||||
mockModulePreservingExports('../bridge.ts', {
|
||||
forwardSessionUpdates: mock(async () => ({
|
||||
stopReason: 'end_turn' as const,
|
||||
})),
|
||||
@@ -105,33 +115,38 @@ safeMockModule('../bridge.ts', {
|
||||
})),
|
||||
})
|
||||
|
||||
safeMockModule('../../../utils/listSessionsImpl.ts', {
|
||||
mockModulePreservingExports('../../../utils/listSessionsImpl.ts', {
|
||||
listSessionsImpl: mock(async () => []),
|
||||
})
|
||||
|
||||
const mockGetMainLoopModel = mock(() => 'claude-sonnet-4-6')
|
||||
|
||||
safeMockModule('../../../utils/model/model.ts', {
|
||||
mockModulePreservingExports('../../../utils/model/model.ts', {
|
||||
getMainLoopModel: mockGetMainLoopModel,
|
||||
})
|
||||
|
||||
safeMockModule('../../../utils/model/modelOptions.ts', {
|
||||
mockModulePreservingExports('../../../utils/model/modelOptions.ts', {
|
||||
getModelOptions: mock(() => []),
|
||||
})
|
||||
|
||||
const mockApplySafeEnvVars = mock(() => {})
|
||||
safeMockModule('../../../utils/managedEnv.ts', {
|
||||
mockModulePreservingExports('../../../utils/managedEnv.ts', {
|
||||
applySafeConfigEnvironmentVariables: mockApplySafeEnvVars,
|
||||
})
|
||||
|
||||
const mockGetSettings = mock(() => ({}))
|
||||
mockModulePreservingExports('../../../utils/settings/settings.ts', {
|
||||
getSettings_DEPRECATED: mockGetSettings,
|
||||
})
|
||||
|
||||
const mockDeserializeMessages = mock((msgs: unknown[]) => msgs)
|
||||
safeMockModule('../../../utils/conversationRecovery.ts', {
|
||||
mockModulePreservingExports('../../../utils/conversationRecovery.ts', {
|
||||
deserializeMessages: mockDeserializeMessages,
|
||||
})
|
||||
|
||||
const mockGetLastSessionLog = mock(async () => null)
|
||||
const mockSessionIdExists = mock(() => false)
|
||||
safeMockModule('../../../utils/sessionStorage.ts', {
|
||||
mockModulePreservingExports('../../../utils/sessionStorage.ts', {
|
||||
getLastSessionLog: mockGetLastSessionLog,
|
||||
sessionIdExists: mockSessionIdExists,
|
||||
})
|
||||
@@ -161,7 +176,7 @@ const mockGetCommands = mock(async () => [
|
||||
},
|
||||
])
|
||||
|
||||
safeMockModule('../../../commands.ts', {
|
||||
mockModulePreservingExports('../../../commands.ts', {
|
||||
getCommands: mockGetCommands,
|
||||
})
|
||||
|
||||
@@ -181,16 +196,48 @@ function makeConn() {
|
||||
} as any
|
||||
}
|
||||
|
||||
function removeBypassMode(session: any) {
|
||||
session.modes = {
|
||||
...session.modes,
|
||||
availableModes: session.modes.availableModes.filter(
|
||||
(mode: any) => mode.id !== 'bypassPermissions',
|
||||
),
|
||||
}
|
||||
session.appState.toolPermissionContext = {
|
||||
...session.appState.toolPermissionContext,
|
||||
isBypassPermissionsModeAvailable: false,
|
||||
}
|
||||
}
|
||||
|
||||
function restoreEnv(name: string, value: string | undefined) {
|
||||
if (value === undefined) {
|
||||
delete process.env[name]
|
||||
} else {
|
||||
process.env[name] = value
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('AcpAgent', () => {
|
||||
afterAll(() => {
|
||||
for (const restore of _restores) restore()
|
||||
})
|
||||
beforeEach(() => {
|
||||
delete process.env.ACP_PERMISSION_MODE
|
||||
delete process.env.CLAUDE_CODE_ACP_ALLOW_BYPASS_PERMISSIONS
|
||||
mockSetModel.mockClear()
|
||||
mockSubmitMessage.mockReset()
|
||||
mockSubmitMessage.mockImplementation(async function* (_input: string) {})
|
||||
mockGetMainLoopModel.mockClear()
|
||||
mockGetDefaultAppState.mockClear()
|
||||
mockGetSettings.mockReset()
|
||||
mockGetSettings.mockImplementation(() => ({}))
|
||||
;(forwardSessionUpdates as ReturnType<typeof mock>).mockReset()
|
||||
;(forwardSessionUpdates as ReturnType<typeof mock>).mockImplementation(
|
||||
async () => ({ stopReason: 'end_turn' as const }),
|
||||
)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
process.chdir(originalCwd)
|
||||
})
|
||||
|
||||
describe('initialize', () => {
|
||||
@@ -255,6 +302,13 @@ describe('AcpAgent', () => {
|
||||
expect(r1.sessionId).not.toBe(r2.sessionId)
|
||||
})
|
||||
|
||||
test('does not leave process cwd changed after session creation', async () => {
|
||||
const cwdBeforeSession = process.cwd()
|
||||
const agent = new AcpAgent(makeConn())
|
||||
await agent.newSession({ cwd: '/tmp' } as any)
|
||||
expect(process.cwd()).toBe(cwdBeforeSession)
|
||||
})
|
||||
|
||||
test('calls getDefaultAppState to build session appState', async () => {
|
||||
const agent = new AcpAgent(makeConn())
|
||||
await agent.newSession({ cwd: '/tmp' } as any)
|
||||
@@ -290,6 +344,99 @@ describe('AcpAgent', () => {
|
||||
const res = await agent.newSession({ cwd: '/tmp' } as any)
|
||||
expect(res.sessionId).toBeDefined()
|
||||
})
|
||||
|
||||
test('uses settings permissions.defaultMode when _meta does not provide a mode', async () => {
|
||||
mockGetSettings.mockImplementationOnce(() => ({
|
||||
permissions: { defaultMode: 'acceptEdits' },
|
||||
}))
|
||||
const agent = new AcpAgent(makeConn())
|
||||
const res = await agent.newSession({ cwd: '/tmp' } as any)
|
||||
|
||||
expect(res.modes?.currentModeId).toBe('acceptEdits')
|
||||
})
|
||||
|
||||
test('uses _meta.permissionMode before settings permissions.defaultMode', async () => {
|
||||
mockGetSettings.mockImplementationOnce(() => ({
|
||||
permissions: { defaultMode: 'acceptEdits' },
|
||||
}))
|
||||
const agent = new AcpAgent(makeConn())
|
||||
const res = await agent.newSession({
|
||||
cwd: '/tmp',
|
||||
_meta: { permissionMode: 'plan' },
|
||||
} as any)
|
||||
|
||||
expect(res.modes?.currentModeId).toBe('plan')
|
||||
})
|
||||
|
||||
test('rejects _meta.permissionMode bypass without a local ACP bypass gate', async () => {
|
||||
mockGetSettings.mockImplementationOnce(() => ({
|
||||
permissions: { defaultMode: 'acceptEdits' },
|
||||
}))
|
||||
const consoleErrorSpy = spyOn(console, 'error').mockImplementation(() => {})
|
||||
const agent = new AcpAgent(makeConn())
|
||||
try {
|
||||
await expect(
|
||||
agent.newSession({
|
||||
cwd: '/tmp',
|
||||
_meta: { permissionMode: 'bypassPermissions' },
|
||||
} as any),
|
||||
).rejects.toThrow('Mode not available: bypassPermissions')
|
||||
|
||||
expect(consoleErrorSpy).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
consoleErrorSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
test('honors _meta.permissionMode bypass with a local ACP bypass gate', async () => {
|
||||
process.env.CLAUDE_CODE_ACP_ALLOW_BYPASS_PERMISSIONS = '1'
|
||||
const agent = new AcpAgent(makeConn())
|
||||
const res = await agent.newSession({
|
||||
cwd: '/tmp',
|
||||
_meta: { permissionMode: 'bypassPermissions' },
|
||||
} as any)
|
||||
|
||||
expect(res.modes?.currentModeId).toBe('bypassPermissions')
|
||||
expect(res.modes?.availableModes.map((mode: any) => mode.id)).toContain(
|
||||
'bypassPermissions',
|
||||
)
|
||||
})
|
||||
|
||||
test('falls back to default when settings permissions.defaultMode is invalid', async () => {
|
||||
mockGetSettings.mockImplementationOnce(() => ({
|
||||
permissions: { defaultMode: 'invalid-mode' },
|
||||
}))
|
||||
const consoleErrorSpy = spyOn(console, 'error').mockImplementation(() => {})
|
||||
const agent = new AcpAgent(makeConn())
|
||||
try {
|
||||
const res = await agent.newSession({ cwd: '/tmp' } as any)
|
||||
|
||||
expect(res.modes?.currentModeId).toBe('default')
|
||||
expect(consoleErrorSpy).toHaveBeenCalled()
|
||||
} finally {
|
||||
consoleErrorSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
test('rejects invalid _meta.permissionMode without falling back to settings', async () => {
|
||||
mockGetSettings.mockImplementationOnce(() => ({
|
||||
permissions: { defaultMode: 'acceptEdits' },
|
||||
}))
|
||||
const consoleErrorSpy = spyOn(console, 'error').mockImplementation(() => {})
|
||||
const agent = new AcpAgent(makeConn())
|
||||
try {
|
||||
await expect(
|
||||
agent.newSession({
|
||||
cwd: '/tmp',
|
||||
_meta: { permissionMode: 'invalid-mode' },
|
||||
} as any),
|
||||
).rejects.toThrow('Invalid _meta.permissionMode: invalid-mode')
|
||||
|
||||
expect(consoleErrorSpy).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
consoleErrorSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('prompt', () => {
|
||||
@@ -375,7 +522,7 @@ describe('AcpAgent', () => {
|
||||
expect(res2.stopReason).toBe('end_turn')
|
||||
})
|
||||
|
||||
test('returns end_turn on unexpected error', async () => {
|
||||
test('propagates unexpected prompt errors', async () => {
|
||||
const agent = new AcpAgent(makeConn())
|
||||
const { sessionId } = await agent.newSession({ cwd: '/tmp' } as any)
|
||||
;(
|
||||
@@ -383,16 +530,13 @@ describe('AcpAgent', () => {
|
||||
).mockImplementationOnce(async () => {
|
||||
throw new Error('unexpected')
|
||||
})
|
||||
const errorSpy = spyOn(console, 'error').mockImplementation(() => {})
|
||||
try {
|
||||
const res = await agent.prompt({
|
||||
|
||||
await expect(
|
||||
agent.prompt({
|
||||
sessionId,
|
||||
prompt: [{ type: 'text', text: 'hello' }],
|
||||
} as any)
|
||||
expect(res.stopReason).toBe('end_turn')
|
||||
} finally {
|
||||
errorSpy.mockRestore()
|
||||
}
|
||||
} as any),
|
||||
).rejects.toThrow('unexpected')
|
||||
})
|
||||
|
||||
test('returns usage from forwardSessionUpdates', async () => {
|
||||
@@ -676,15 +820,28 @@ describe('AcpAgent', () => {
|
||||
).rejects.toThrow('Session not found')
|
||||
})
|
||||
|
||||
test('availableModes includes bypassPermissions when not root', async () => {
|
||||
test('availableModes excludes bypassPermissions without a local ACP bypass gate', async () => {
|
||||
const agent = new AcpAgent(makeConn())
|
||||
const { sessionId } = await agent.newSession({ cwd: '/tmp' } as any)
|
||||
const session = agent.sessions.get(sessionId)
|
||||
const modeIds = session?.modes.availableModes.map((m: any) => m.id)
|
||||
expect(modeIds).toContain('bypassPermissions')
|
||||
expect(modeIds).not.toContain('bypassPermissions')
|
||||
})
|
||||
|
||||
test('can switch to bypassPermissions mode', async () => {
|
||||
test('rejects bypassPermissions without a local ACP bypass gate', async () => {
|
||||
const agent = new AcpAgent(makeConn())
|
||||
const { sessionId } = await agent.newSession({ cwd: '/tmp' } as any)
|
||||
await expect(
|
||||
agent.setSessionMode({ sessionId, modeId: 'bypassPermissions' } as any),
|
||||
).rejects.toThrow('Mode not available')
|
||||
|
||||
const session = agent.sessions.get(sessionId)
|
||||
expect(session?.modes.currentModeId).toBe('default')
|
||||
expect(session?.appState.toolPermissionContext.mode).toBe('default')
|
||||
})
|
||||
|
||||
test('can switch to bypassPermissions mode with a local ACP bypass gate', async () => {
|
||||
process.env.CLAUDE_CODE_ACP_ALLOW_BYPASS_PERMISSIONS = '1'
|
||||
const agent = new AcpAgent(makeConn())
|
||||
const { sessionId } = await agent.newSession({ cwd: '/tmp' } as any)
|
||||
await agent.setSessionMode({
|
||||
@@ -697,6 +854,21 @@ describe('AcpAgent', () => {
|
||||
'bypassPermissions',
|
||||
)
|
||||
})
|
||||
|
||||
test('rejects bypassPermissions when the session does not expose it', async () => {
|
||||
process.env.CLAUDE_CODE_ACP_ALLOW_BYPASS_PERMISSIONS = '1'
|
||||
const agent = new AcpAgent(makeConn())
|
||||
const { sessionId } = await agent.newSession({ cwd: '/tmp' } as any)
|
||||
const session = agent.sessions.get(sessionId)
|
||||
removeBypassMode(session)
|
||||
|
||||
await expect(
|
||||
agent.setSessionMode({ sessionId, modeId: 'bypassPermissions' } as any),
|
||||
).rejects.toThrow('Mode not available')
|
||||
|
||||
expect(session?.modes.currentModeId).toBe('default')
|
||||
expect(session?.appState.toolPermissionContext.mode).toBe('default')
|
||||
})
|
||||
})
|
||||
|
||||
describe('setSessionConfigOption', () => {
|
||||
@@ -723,6 +895,24 @@ describe('AcpAgent', () => {
|
||||
} as any),
|
||||
).rejects.toThrow('Invalid value')
|
||||
})
|
||||
|
||||
test('rejects unavailable mode config values', async () => {
|
||||
const agent = new AcpAgent(makeConn())
|
||||
const { sessionId } = await agent.newSession({ cwd: '/tmp' } as any)
|
||||
const session = agent.sessions.get(sessionId)
|
||||
removeBypassMode(session)
|
||||
|
||||
await expect(
|
||||
agent.setSessionConfigOption({
|
||||
sessionId,
|
||||
configId: 'mode',
|
||||
value: 'bypassPermissions',
|
||||
} as any),
|
||||
).rejects.toThrow('Mode not available')
|
||||
|
||||
expect(session?.modes.currentModeId).toBe('default')
|
||||
expect(session?.appState.toolPermissionContext.mode).toBe('default')
|
||||
})
|
||||
})
|
||||
|
||||
describe('prompt queueing', () => {
|
||||
@@ -758,6 +948,94 @@ describe('AcpAgent', () => {
|
||||
expect(r2.stopReason).toBe('end_turn')
|
||||
})
|
||||
|
||||
test('drains 1000 queued prompts in FIFO order without sorting the pending map', async () => {
|
||||
const agent = new AcpAgent(makeConn())
|
||||
const { sessionId } = await agent.newSession({ cwd: '/tmp' } as any)
|
||||
|
||||
let resolveFirst!: () => void
|
||||
;(
|
||||
forwardSessionUpdates as ReturnType<typeof mock>
|
||||
).mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<{ stopReason: string }>(resolve => {
|
||||
resolveFirst = () => resolve({ stopReason: 'end_turn' })
|
||||
}),
|
||||
)
|
||||
|
||||
const first = agent.prompt({
|
||||
sessionId,
|
||||
prompt: [{ type: 'text', text: 'first' }],
|
||||
} as any)
|
||||
const queued = Array.from({ length: 1000 }, (_, index) =>
|
||||
agent.prompt({
|
||||
sessionId,
|
||||
prompt: [{ type: 'text', text: `queued-${index}` }],
|
||||
} as any),
|
||||
)
|
||||
|
||||
resolveFirst()
|
||||
const results = await Promise.all([first, ...queued])
|
||||
|
||||
expect(results.every(result => result.stopReason === 'end_turn')).toBe(true)
|
||||
expect(mockSubmitMessage.mock.calls.map(call => call[0])).toEqual([
|
||||
'first',
|
||||
...Array.from({ length: 1000 }, (_, index) => `queued-${index}`),
|
||||
])
|
||||
})
|
||||
|
||||
test('keeps promptRunning true while handing off to the next queued prompt', async () => {
|
||||
const agent = new AcpAgent(makeConn())
|
||||
const { sessionId } = await agent.newSession({ cwd: '/tmp' } as any)
|
||||
|
||||
let resolveFirst!: () => void
|
||||
let resolveSecond!: () => void
|
||||
;(forwardSessionUpdates as ReturnType<typeof mock>).mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<{ stopReason: string }>(resolve => {
|
||||
resolveFirst = () => resolve({ stopReason: 'end_turn' })
|
||||
}),
|
||||
)
|
||||
;(forwardSessionUpdates as ReturnType<typeof mock>).mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<{ stopReason: string }>(resolve => {
|
||||
resolveSecond = () => resolve({ stopReason: 'end_turn' })
|
||||
}),
|
||||
)
|
||||
|
||||
const p1 = agent.prompt({
|
||||
sessionId,
|
||||
prompt: [{ type: 'text', text: 'first' }],
|
||||
} as any)
|
||||
const p2 = agent.prompt({
|
||||
sessionId,
|
||||
prompt: [{ type: 'text', text: 'second' }],
|
||||
} as any)
|
||||
|
||||
const p3 = p1.then(() =>
|
||||
agent.prompt({
|
||||
sessionId,
|
||||
prompt: [{ type: 'text', text: 'third' }],
|
||||
} as any),
|
||||
)
|
||||
|
||||
resolveFirst()
|
||||
await p1
|
||||
const session = agent.sessions.get(sessionId)
|
||||
expect(session?.promptRunning).toBe(true)
|
||||
expect(mockSubmitMessage.mock.calls.map(call => call[0])).toEqual([
|
||||
'first',
|
||||
'second',
|
||||
])
|
||||
|
||||
resolveSecond()
|
||||
await Promise.all([p2, p3])
|
||||
expect(mockSubmitMessage.mock.calls.map(call => call[0])).toEqual([
|
||||
'first',
|
||||
'second',
|
||||
'third',
|
||||
])
|
||||
})
|
||||
|
||||
test('queued prompts return cancelled when session is cancelled', async () => {
|
||||
const agent = new AcpAgent(makeConn())
|
||||
const { sessionId } = await agent.newSession({ cwd: '/tmp' } as any)
|
||||
@@ -787,6 +1065,46 @@ describe('AcpAgent', () => {
|
||||
expect(r1.stopReason).toBe('cancelled')
|
||||
expect(r2.stopReason).toBe('cancelled')
|
||||
})
|
||||
|
||||
test('queued prompt does not clear active prompt cancellation', async () => {
|
||||
const agent = new AcpAgent(makeConn())
|
||||
const { sessionId } = await agent.newSession({ cwd: '/tmp' } as any)
|
||||
|
||||
let resolveFirst!: () => void
|
||||
;(
|
||||
forwardSessionUpdates as ReturnType<typeof mock>
|
||||
).mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<{ stopReason: string }>(resolve => {
|
||||
resolveFirst = () => resolve({ stopReason: 'end_turn' })
|
||||
}),
|
||||
)
|
||||
;(forwardSessionUpdates as ReturnType<typeof mock>).mockResolvedValueOnce(
|
||||
{ stopReason: 'end_turn' },
|
||||
)
|
||||
|
||||
const p1 = agent.prompt({
|
||||
sessionId,
|
||||
prompt: [{ type: 'text', text: 'first' }],
|
||||
} as any)
|
||||
|
||||
await agent.cancel({ sessionId } as any)
|
||||
|
||||
const p2 = agent.prompt({
|
||||
sessionId,
|
||||
prompt: [{ type: 'text', text: 'second' }],
|
||||
} as any)
|
||||
|
||||
resolveFirst()
|
||||
|
||||
const [r1, r2] = await Promise.all([p1, p2])
|
||||
expect(r1.stopReason).toBe('cancelled')
|
||||
expect(r2.stopReason).toBe('end_turn')
|
||||
expect(mockSubmitMessage.mock.calls.map(call => call[0])).toEqual([
|
||||
'first',
|
||||
'second',
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('commands', () => {
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
toolUpdateFromEditToolResponse,
|
||||
forwardSessionUpdates,
|
||||
} from '../bridge.js'
|
||||
import { promptToQueryInput } from '../promptConversion.js'
|
||||
import { markdownEscape, toDisplayPath } from '../utils.js'
|
||||
import type { AgentSideConnection, ToolKind } from '@agentclientprotocol/sdk'
|
||||
import type { SDKMessage } from '../../../entrypoints/sdk/coreTypes.js'
|
||||
@@ -336,6 +337,20 @@ describe('toolInfoFromToolUse', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('promptToQueryInput', () => {
|
||||
test('uses shared prompt conversion for resource links', () => {
|
||||
expect(
|
||||
promptToQueryInput([
|
||||
{
|
||||
type: 'resource_link',
|
||||
name: 'Spec',
|
||||
uri: 'file:///tmp/spec.md',
|
||||
} as any,
|
||||
]),
|
||||
).toBe('Resource link: name=Spec, uri=file:///tmp/spec.md')
|
||||
})
|
||||
})
|
||||
|
||||
// ── toolUpdateFromToolResult ───────────────────────────────────────
|
||||
|
||||
describe('toolUpdateFromToolResult', () => {
|
||||
@@ -709,6 +724,87 @@ describe('forwardSessionUpdates', () => {
|
||||
expect(result.stopReason).toBe('cancelled')
|
||||
})
|
||||
|
||||
test('cleans abort listeners when sdkMessages.next wins repeatedly', async () => {
|
||||
const ac = new AbortController()
|
||||
let abortListeners = 0
|
||||
const add = ac.signal.addEventListener.bind(ac.signal)
|
||||
const remove = ac.signal.removeEventListener.bind(ac.signal)
|
||||
const addEventListener: AbortSignal['addEventListener'] = (
|
||||
type: keyof AbortSignalEventMap,
|
||||
listener: EventListenerOrEventListenerObject,
|
||||
options?: boolean | AddEventListenerOptions,
|
||||
) => {
|
||||
if (type === 'abort') abortListeners++
|
||||
return add(type, listener, options)
|
||||
}
|
||||
const removeEventListener: AbortSignal['removeEventListener'] = (
|
||||
type: keyof AbortSignalEventMap,
|
||||
listener: EventListenerOrEventListenerObject,
|
||||
options?: boolean | EventListenerOptions,
|
||||
) => {
|
||||
if (type === 'abort') abortListeners--
|
||||
return remove(type, listener, options)
|
||||
}
|
||||
ac.signal.addEventListener = addEventListener
|
||||
ac.signal.removeEventListener = removeEventListener
|
||||
|
||||
const msgs = Array.from({ length: 10_000 }, () => ({
|
||||
type: 'system',
|
||||
subtype: 'api_retry',
|
||||
}) as unknown as SDKMessage)
|
||||
|
||||
const result = await forwardSessionUpdates(
|
||||
's1',
|
||||
makeStream(msgs),
|
||||
makeConn(),
|
||||
ac.signal,
|
||||
{},
|
||||
)
|
||||
|
||||
expect(result.stopReason).toBe('end_turn')
|
||||
expect(abortListeners).toBe(0)
|
||||
})
|
||||
|
||||
test('cleans abort listeners when abort wins the race', async () => {
|
||||
const ac = new AbortController()
|
||||
let abortListeners = 0
|
||||
const add = ac.signal.addEventListener.bind(ac.signal)
|
||||
const remove = ac.signal.removeEventListener.bind(ac.signal)
|
||||
ac.signal.addEventListener = (
|
||||
type: keyof AbortSignalEventMap,
|
||||
listener: EventListenerOrEventListenerObject,
|
||||
options?: boolean | AddEventListenerOptions,
|
||||
) => {
|
||||
if (type === 'abort') abortListeners++
|
||||
return add(type, listener, options)
|
||||
}
|
||||
ac.signal.removeEventListener = (
|
||||
type: keyof AbortSignalEventMap,
|
||||
listener: EventListenerOrEventListenerObject,
|
||||
options?: boolean | EventListenerOptions,
|
||||
) => {
|
||||
if (type === 'abort') abortListeners--
|
||||
return remove(type, listener, options)
|
||||
}
|
||||
|
||||
async function* never(): AsyncGenerator<SDKMessage, void, unknown> {
|
||||
await new Promise(() => {})
|
||||
}
|
||||
|
||||
const resultPromise = forwardSessionUpdates(
|
||||
's1',
|
||||
never(),
|
||||
makeConn(),
|
||||
ac.signal,
|
||||
{},
|
||||
)
|
||||
ac.abort()
|
||||
const result = await resultPromise
|
||||
|
||||
expect(result.stopReason).toBe('cancelled')
|
||||
expect(abortListeners).toBe(0)
|
||||
})
|
||||
|
||||
test('forwards assistant text message as agent_message_chunk', async () => {
|
||||
const conn = makeConn()
|
||||
const msgs: SDKMessage[] = [
|
||||
@@ -765,6 +861,7 @@ describe('forwardSessionUpdates', () => {
|
||||
|
||||
test('forwards tool_use block as tool_call', async () => {
|
||||
const conn = makeConn()
|
||||
const input = { command: 'ls' }
|
||||
const msgs: SDKMessage[] = [
|
||||
{
|
||||
type: 'assistant',
|
||||
@@ -774,7 +871,7 @@ describe('forwardSessionUpdates', () => {
|
||||
type: 'tool_use',
|
||||
id: 'tu_1',
|
||||
name: 'Bash',
|
||||
input: { command: 'ls' },
|
||||
input,
|
||||
},
|
||||
],
|
||||
role: 'assistant',
|
||||
@@ -794,6 +891,8 @@ describe('forwardSessionUpdates', () => {
|
||||
expect(update.toolCallId).toBe('tu_1')
|
||||
expect(update.kind).toBe('execute' as ToolKind)
|
||||
expect(update.status).toBe('pending')
|
||||
expect(update.rawInput).toEqual(input)
|
||||
expect(update.rawInput).not.toBe(input)
|
||||
})
|
||||
|
||||
test('sends usage_update on result message with correct tokens', async () => {
|
||||
|
||||
@@ -1,144 +1,306 @@
|
||||
import { describe, expect, test, mock } from 'bun:test'
|
||||
import { afterAll, beforeEach, describe, expect, mock, spyOn, test } from 'bun:test'
|
||||
import type { AgentSideConnection } from '@agentclientprotocol/sdk'
|
||||
import type { Tool as ToolType } from '../../../Tool.js'
|
||||
import type { Tool as ToolType, ToolUseContext } from '../../../Tool.js'
|
||||
import type { AssistantMessage } from '../../../types/message.js'
|
||||
|
||||
// ── Inline re-implementation of createAcpCanUseTool for isolated testing ──
|
||||
// We cannot import the real permissions.js because agent.test.ts mocks it globally.
|
||||
// Instead we re-implement the core logic here, using our own mocked bridge.js.
|
||||
const askDecision = {
|
||||
behavior: 'ask',
|
||||
message: 'approval required',
|
||||
decisionReason: { type: 'mode', mode: 'default' },
|
||||
} as const
|
||||
|
||||
function createAcpCanUseTool(
|
||||
conn: AgentSideConnection,
|
||||
sessionId: string,
|
||||
getCurrentMode: () => string,
|
||||
): any {
|
||||
return async (
|
||||
tool: { name: string },
|
||||
input: Record<string, unknown>,
|
||||
_context: any,
|
||||
_assistantMessage: any,
|
||||
toolUseID: string,
|
||||
): Promise<{ behavior: string; message?: string; updatedInput?: Record<string, unknown> }> => {
|
||||
if (getCurrentMode() === 'bypassPermissions') {
|
||||
return { behavior: 'allow', updatedInput: input }
|
||||
}
|
||||
const hasPermissionsMock = mock(async (): Promise<unknown> => askDecision)
|
||||
const toolInfoMock = mock(() => ({
|
||||
title: 'Bash',
|
||||
kind: 'execute',
|
||||
content: [],
|
||||
locations: [],
|
||||
}))
|
||||
|
||||
const TOOL_KIND_MAP: Record<string, string> = {
|
||||
Read: 'read', Edit: 'edit', Write: 'edit',
|
||||
Bash: 'execute', Glob: 'search', Grep: 'search',
|
||||
WebFetch: 'fetch', WebSearch: 'fetch',
|
||||
}
|
||||
|
||||
const toolCall = {
|
||||
toolCallId: toolUseID,
|
||||
title: tool.name,
|
||||
kind: TOOL_KIND_MAP[tool.name] ?? 'other',
|
||||
status: 'pending',
|
||||
rawInput: input,
|
||||
}
|
||||
|
||||
const options = [
|
||||
{ kind: 'allow_always', name: 'Always Allow', optionId: 'allow_always' },
|
||||
{ kind: 'allow_once', name: 'Allow', optionId: 'allow' },
|
||||
{ kind: 'reject_once', name: 'Reject', optionId: 'reject' },
|
||||
]
|
||||
|
||||
try {
|
||||
const response = await (conn as any).requestPermission({ sessionId, toolCall, options })
|
||||
|
||||
if (response.outcome.outcome === 'cancelled') {
|
||||
return { behavior: 'deny', message: 'Permission request cancelled by client' }
|
||||
}
|
||||
|
||||
if (response.outcome.outcome === 'selected' && response.outcome.optionId !== undefined) {
|
||||
const optionId = response.outcome.optionId
|
||||
if (optionId === 'allow' || optionId === 'allow_always') {
|
||||
return { behavior: 'allow', updatedInput: input }
|
||||
}
|
||||
}
|
||||
|
||||
return { behavior: 'deny', message: 'Permission denied by client' }
|
||||
} catch {
|
||||
return { behavior: 'deny', message: 'Permission request failed' }
|
||||
}
|
||||
}
|
||||
const permissionsModuleSnapshot = {
|
||||
...(require('../../../utils/permissions/permissions.ts') as Record<
|
||||
string,
|
||||
unknown
|
||||
>),
|
||||
}
|
||||
const bridgeModuleSnapshot = {
|
||||
...(require('../bridge.ts') as Record<string, unknown>),
|
||||
}
|
||||
|
||||
function makeConn(permissionResponse: Record<string, unknown>) {
|
||||
afterAll(() => {
|
||||
mock.module('../bridge.js', () => bridgeModuleSnapshot)
|
||||
mock.module('../../../utils/permissions/permissions.js', () => permissionsModuleSnapshot)
|
||||
})
|
||||
|
||||
mock.module('../../../utils/permissions/permissions.js', () => ({
|
||||
...permissionsModuleSnapshot,
|
||||
hasPermissionsToUseTool: hasPermissionsMock,
|
||||
}))
|
||||
|
||||
mock.module('../bridge.js', () => ({
|
||||
...bridgeModuleSnapshot,
|
||||
toolInfoFromToolUse: toolInfoMock,
|
||||
}))
|
||||
|
||||
const { createAcpCanUseTool } = await import('../permissions.js')
|
||||
|
||||
type PermissionResponse =
|
||||
| { outcome: { outcome: 'cancelled' } }
|
||||
| { outcome: { outcome: 'selected'; optionId: string } }
|
||||
|
||||
function makeConn(
|
||||
permissionResponse: PermissionResponse = {
|
||||
outcome: { outcome: 'selected', optionId: 'allow' },
|
||||
},
|
||||
): AgentSideConnection {
|
||||
return {
|
||||
requestPermission: mock(async () => permissionResponse),
|
||||
sessionUpdate: mock(async () => {}),
|
||||
} as unknown as AgentSideConnection
|
||||
}
|
||||
|
||||
function makeTool(name: string) {
|
||||
function makeTool(name: string): ToolType {
|
||||
return { name } as unknown as ToolType
|
||||
}
|
||||
|
||||
const dummyContext = {} as Record<string, unknown>
|
||||
const dummyMsg = {} as Record<string, unknown>
|
||||
const dummyContext = {} as unknown as ToolUseContext
|
||||
const dummyMsg = {} as unknown as AssistantMessage
|
||||
|
||||
describe('createAcpCanUseTool', () => {
|
||||
test('returns allow when client selects allow option', async () => {
|
||||
const conn = makeConn({ outcome: { outcome: 'selected', optionId: 'allow' } })
|
||||
const canUseTool = createAcpCanUseTool(conn, 'sess-1', () => 'default')
|
||||
const result = await canUseTool(makeTool('Bash'), { command: 'ls' }, dummyContext as any, dummyMsg as any, 'tu_1')
|
||||
expect(result.behavior).toBe('allow')
|
||||
beforeEach(() => {
|
||||
hasPermissionsMock.mockReset()
|
||||
hasPermissionsMock.mockResolvedValue(askDecision)
|
||||
toolInfoMock.mockClear()
|
||||
})
|
||||
|
||||
test('returns deny when client selects reject option', async () => {
|
||||
const conn = makeConn({ outcome: { outcome: 'selected', optionId: 'reject' } })
|
||||
test('returns pipeline allow without client delegation', async () => {
|
||||
const conn = makeConn()
|
||||
const input = { command: 'ls' }
|
||||
hasPermissionsMock.mockResolvedValueOnce({
|
||||
behavior: 'allow',
|
||||
updatedInput: input,
|
||||
})
|
||||
|
||||
const canUseTool = createAcpCanUseTool(conn, 'sess-1', () => 'default')
|
||||
const result = await canUseTool(makeTool('Bash'), {}, dummyContext as any, dummyMsg as any, 'tu_2')
|
||||
const result = await canUseTool(
|
||||
makeTool('Bash'),
|
||||
input,
|
||||
dummyContext,
|
||||
dummyMsg,
|
||||
'tu_1',
|
||||
)
|
||||
|
||||
expect(result).toEqual({ behavior: 'allow', updatedInput: input })
|
||||
expect(
|
||||
(conn.requestPermission as ReturnType<typeof mock>).mock.calls,
|
||||
).toHaveLength(0)
|
||||
})
|
||||
|
||||
test('returns pipeline deny without client delegation', async () => {
|
||||
const conn = makeConn()
|
||||
hasPermissionsMock.mockResolvedValueOnce({
|
||||
behavior: 'deny',
|
||||
message: 'blocked by policy',
|
||||
decisionReason: { type: 'other', reason: 'blocked by policy' },
|
||||
})
|
||||
|
||||
const canUseTool = createAcpCanUseTool(conn, 'sess-1', () => 'default')
|
||||
const result = await canUseTool(
|
||||
makeTool('Bash'),
|
||||
{ command: 'rm -rf /' },
|
||||
dummyContext,
|
||||
dummyMsg,
|
||||
'tu_2',
|
||||
)
|
||||
|
||||
expect(result.behavior).toBe('deny')
|
||||
expect(
|
||||
(conn.requestPermission as ReturnType<typeof mock>).mock.calls,
|
||||
).toHaveLength(0)
|
||||
})
|
||||
|
||||
test('returns deny when client cancels', async () => {
|
||||
const conn = makeConn({ outcome: { outcome: 'cancelled' } })
|
||||
test('denies when the permission pipeline throws', async () => {
|
||||
const conn = makeConn()
|
||||
hasPermissionsMock.mockRejectedValueOnce(new Error('rule loader failed'))
|
||||
const errorSpy = spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
try {
|
||||
const canUseTool = createAcpCanUseTool(conn, 'sess-1', () => 'default')
|
||||
const result = await canUseTool(
|
||||
makeTool('Edit'),
|
||||
{ file_path: '/tmp/x' },
|
||||
dummyContext,
|
||||
dummyMsg,
|
||||
'tu_3',
|
||||
)
|
||||
|
||||
expect(result).toMatchObject({
|
||||
behavior: 'deny',
|
||||
decisionReason: { type: 'other', reason: 'Permission pipeline failed' },
|
||||
toolUseID: 'tu_3',
|
||||
})
|
||||
if (result.behavior !== 'deny') {
|
||||
throw new Error('expected deny result')
|
||||
}
|
||||
expect(result.message).toBe('Permission pipeline failed')
|
||||
expect(
|
||||
(conn.requestPermission as ReturnType<typeof mock>).mock.calls,
|
||||
).toHaveLength(0)
|
||||
} finally {
|
||||
errorSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
test('delegates ask decisions to the ACP client', async () => {
|
||||
const conn = makeConn({
|
||||
outcome: { outcome: 'selected', optionId: 'allow' },
|
||||
})
|
||||
const input = { command: 'ls' }
|
||||
const canUseTool = createAcpCanUseTool(conn, 'sess-1', () => 'default')
|
||||
const result = await canUseTool(makeTool('Read'), { file_path: '/tmp/x' }, dummyContext as any, dummyMsg as any, 'tu_3')
|
||||
expect(result.behavior).toBe('deny')
|
||||
const result = await canUseTool(
|
||||
makeTool('Bash'),
|
||||
input,
|
||||
dummyContext,
|
||||
dummyMsg,
|
||||
'tu_4',
|
||||
)
|
||||
|
||||
expect(result).toEqual({ behavior: 'allow', updatedInput: input })
|
||||
const callArgs = (conn.requestPermission as ReturnType<typeof mock>).mock
|
||||
.calls[0][0] as Record<string, unknown>
|
||||
expect(callArgs.sessionId).toBe('sess-1')
|
||||
expect((callArgs.toolCall as Record<string, unknown>).toolCallId).toBe(
|
||||
'tu_4',
|
||||
)
|
||||
})
|
||||
|
||||
test('returns deny when requestPermission throws', async () => {
|
||||
test('returns deny when the client rejects or cancels', async () => {
|
||||
const rejectConn = makeConn({
|
||||
outcome: { outcome: 'selected', optionId: 'reject' },
|
||||
})
|
||||
const cancelConn = makeConn({ outcome: { outcome: 'cancelled' } })
|
||||
|
||||
const rejectResult = await createAcpCanUseTool(
|
||||
rejectConn,
|
||||
'sess-1',
|
||||
() => 'default',
|
||||
)(makeTool('Bash'), {}, dummyContext, dummyMsg, 'tu_5')
|
||||
const cancelResult = await createAcpCanUseTool(
|
||||
cancelConn,
|
||||
'sess-1',
|
||||
() => 'default',
|
||||
)(makeTool('Read'), {}, dummyContext, dummyMsg, 'tu_6')
|
||||
|
||||
expect(rejectResult.behavior).toBe('deny')
|
||||
expect(cancelResult.behavior).toBe('deny')
|
||||
})
|
||||
|
||||
test('returns deny when client permission request fails', async () => {
|
||||
const conn = {
|
||||
requestPermission: mock(async () => { throw new Error('connection lost') }),
|
||||
requestPermission: mock(async () => {
|
||||
throw new Error('connection lost')
|
||||
}),
|
||||
sessionUpdate: mock(async () => {}),
|
||||
} as unknown as AgentSideConnection
|
||||
const canUseTool = createAcpCanUseTool(conn, 'sess-1', () => 'default')
|
||||
const result = await canUseTool(makeTool('Edit'), {}, dummyContext as any, dummyMsg as any, 'tu_4')
|
||||
expect(result.behavior).toBe('deny')
|
||||
const errorSpy = spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
try {
|
||||
const result = await createAcpCanUseTool(conn, 'sess-1', () => 'default')(
|
||||
makeTool('Write'),
|
||||
{},
|
||||
dummyContext,
|
||||
dummyMsg,
|
||||
'tu_7',
|
||||
)
|
||||
|
||||
expect(result.behavior).toBe('deny')
|
||||
if (result.behavior !== 'deny') {
|
||||
throw new Error('expected deny result')
|
||||
}
|
||||
expect(result.message).toContain('Permission request failed')
|
||||
} finally {
|
||||
errorSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
test('passes correct sessionId and toolCallId to requestPermission', async () => {
|
||||
const conn = makeConn({ outcome: { outcome: 'selected', optionId: 'allow' } })
|
||||
const canUseTool = createAcpCanUseTool(conn, 'my-session', () => 'default')
|
||||
await canUseTool(makeTool('Glob'), { pattern: '**/*.ts' }, dummyContext as any, dummyMsg as any, 'tu_99')
|
||||
const rpMock = conn.requestPermission as ReturnType<typeof mock>
|
||||
expect(rpMock.mock.calls.length).toBeGreaterThan(0)
|
||||
const callArgs = rpMock.mock.calls[0][0] as Record<string, unknown>
|
||||
expect(callArgs.sessionId).toBe('my-session')
|
||||
expect((callArgs.toolCall as Record<string, unknown>).toolCallId).toBe('tu_99')
|
||||
})
|
||||
|
||||
test('returns allow in bypassPermissions mode without calling requestPermission', async () => {
|
||||
const conn = makeConn({ outcome: { outcome: 'selected', optionId: 'allow' } })
|
||||
const canUseTool = createAcpCanUseTool(conn, 'sess-bypass', () => 'bypassPermissions')
|
||||
const result = await canUseTool(makeTool('Bash'), { command: 'rm -rf /' }, dummyContext as any, dummyMsg as any, 'tu_bp')
|
||||
expect(result.behavior).toBe('allow')
|
||||
const rpMock = conn.requestPermission as ReturnType<typeof mock>
|
||||
expect(rpMock.mock.calls).toHaveLength(0)
|
||||
})
|
||||
|
||||
test('options include allow_always, allow_once and reject_once', async () => {
|
||||
test('options include allow always, allow once, and reject once', async () => {
|
||||
const conn = makeConn({ outcome: { outcome: 'cancelled' } })
|
||||
const canUseTool = createAcpCanUseTool(conn, 'sess-3', () => 'default')
|
||||
await canUseTool(makeTool('Write'), {}, dummyContext as any, dummyMsg as any, 'tu_6')
|
||||
const rpMock = conn.requestPermission as ReturnType<typeof mock>
|
||||
expect(rpMock.mock.calls.length).toBeGreaterThan(0)
|
||||
const { options } = rpMock.mock.calls[0][0] as Record<string, unknown>
|
||||
await canUseTool(makeTool('Write'), {}, dummyContext, dummyMsg, 'tu_8')
|
||||
|
||||
const { options } = (conn.requestPermission as ReturnType<typeof mock>).mock
|
||||
.calls[0][0] as Record<string, unknown>
|
||||
const opts = options as Array<Record<string, unknown>>
|
||||
expect(opts.find((o) => o.kind === 'allow_always')).toBeTruthy()
|
||||
expect(opts.find((o) => o.kind === 'allow_once')).toBeTruthy()
|
||||
expect(opts.find((o) => o.kind === 'reject_once')).toBeTruthy()
|
||||
expect(opts.find(option => option.kind === 'allow_always')).toBeTruthy()
|
||||
expect(opts.find(option => option.kind === 'allow_once')).toBeTruthy()
|
||||
expect(opts.find(option => option.kind === 'reject_once')).toBeTruthy()
|
||||
})
|
||||
|
||||
test('ExitPlanMode omits bypass option when the session does not expose it', async () => {
|
||||
const conn = makeConn({ outcome: { outcome: 'cancelled' } })
|
||||
const canUseTool = createAcpCanUseTool(
|
||||
conn,
|
||||
'sess-4',
|
||||
() => 'plan',
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
() => false,
|
||||
)
|
||||
|
||||
await canUseTool(makeTool('ExitPlanMode'), {}, dummyContext, dummyMsg, 'tu_9')
|
||||
|
||||
const { options } = (conn.requestPermission as ReturnType<typeof mock>).mock
|
||||
.calls[0][0] as Record<string, unknown>
|
||||
const opts = options as Array<Record<string, unknown>>
|
||||
expect(opts.some(option => option.optionId === 'bypassPermissions')).toBe(false)
|
||||
})
|
||||
|
||||
test('ExitPlanMode includes bypass option when the session exposes it', async () => {
|
||||
const conn = makeConn({ outcome: { outcome: 'cancelled' } })
|
||||
const canUseTool = createAcpCanUseTool(
|
||||
conn,
|
||||
'sess-5',
|
||||
() => 'plan',
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
() => true,
|
||||
)
|
||||
|
||||
await canUseTool(makeTool('ExitPlanMode'), {}, dummyContext, dummyMsg, 'tu_10')
|
||||
|
||||
const { options } = (conn.requestPermission as ReturnType<typeof mock>).mock
|
||||
.calls[0][0] as Record<string, unknown>
|
||||
const opts = options as Array<Record<string, unknown>>
|
||||
expect(opts.some(option => option.optionId === 'bypassPermissions')).toBe(true)
|
||||
})
|
||||
|
||||
test('ExitPlanMode rejects a bypass selection that was not offered', async () => {
|
||||
const conn = makeConn({
|
||||
outcome: { outcome: 'selected', optionId: 'bypassPermissions' },
|
||||
})
|
||||
const onModeChange = mock(() => {})
|
||||
const canUseTool = createAcpCanUseTool(
|
||||
conn,
|
||||
'sess-6',
|
||||
() => 'plan',
|
||||
undefined,
|
||||
undefined,
|
||||
onModeChange,
|
||||
() => false,
|
||||
)
|
||||
|
||||
const result = await canUseTool(
|
||||
makeTool('ExitPlanMode'),
|
||||
{},
|
||||
dummyContext,
|
||||
dummyMsg,
|
||||
'tu_11',
|
||||
)
|
||||
|
||||
expect(result.behavior).toBe('deny')
|
||||
expect(onModeChange).not.toHaveBeenCalled()
|
||||
expect((conn.sessionUpdate as ReturnType<typeof mock>).mock.calls).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
28
src/services/acp/__tests__/promptConversion.test.ts
Normal file
28
src/services/acp/__tests__/promptConversion.test.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { promptToQueryInput } from '../promptConversion.js'
|
||||
|
||||
describe('promptToQueryInput', () => {
|
||||
test('converts text and embedded text resources', () => {
|
||||
expect(
|
||||
promptToQueryInput([
|
||||
{ type: 'text', text: 'hello' },
|
||||
{
|
||||
type: 'resource',
|
||||
resource: { text: 'resource body' },
|
||||
} as any,
|
||||
]),
|
||||
).toBe('hello\nresource body')
|
||||
})
|
||||
|
||||
test('renders resource_link as plain metadata instead of markdown link', () => {
|
||||
expect(
|
||||
promptToQueryInput([
|
||||
{
|
||||
type: 'resource_link',
|
||||
name: 'Spec',
|
||||
uri: 'file:///tmp/spec.md',
|
||||
} as any,
|
||||
]),
|
||||
).toBe('Resource link: name=Spec, uri=file:///tmp/spec.md')
|
||||
})
|
||||
})
|
||||
@@ -33,7 +33,6 @@ import type {
|
||||
SetSessionModelResponse,
|
||||
SetSessionConfigOptionRequest,
|
||||
SetSessionConfigOptionResponse,
|
||||
ContentBlock,
|
||||
ClientCapabilities,
|
||||
SessionModeState,
|
||||
SessionModelState,
|
||||
@@ -63,31 +62,39 @@ import {
|
||||
computeSessionFingerprint,
|
||||
sanitizeTitle,
|
||||
} from './utils.js'
|
||||
import { promptToQueryInput } from './promptConversion.js'
|
||||
import {
|
||||
listSessionsImpl,
|
||||
} from '../../utils/listSessionsImpl.js'
|
||||
import { getMainLoopModel } from '../../utils/model/model.js'
|
||||
import { getModelOptions } from '../../utils/model/modelOptions.js'
|
||||
import { getSettings_DEPRECATED } from '../../utils/settings/settings.js'
|
||||
|
||||
// ── Session state ─────────────────────────────────────────────────
|
||||
|
||||
type AcpSession = {
|
||||
queryEngine: QueryEngine
|
||||
cancelled: boolean
|
||||
cancelGeneration: number
|
||||
cwd: string
|
||||
sessionFingerprint: string
|
||||
modes: SessionModeState
|
||||
models: SessionModelState
|
||||
configOptions: SessionConfigOption[]
|
||||
promptRunning: boolean
|
||||
pendingMessages: Map<string, { resolve: (cancelled: boolean) => void; order: number }>
|
||||
nextPendingOrder: number
|
||||
pendingMessages: Map<string, PendingPrompt>
|
||||
pendingQueue: string[]
|
||||
pendingQueueHead: number
|
||||
toolUseCache: ToolUseCache
|
||||
clientCapabilities?: ClientCapabilities
|
||||
appState: AppState
|
||||
commands: Command[]
|
||||
}
|
||||
|
||||
type PendingPrompt = {
|
||||
resolve: (cancelled: boolean) => void
|
||||
}
|
||||
|
||||
// ── Agent class ───────────────────────────────────────────────────
|
||||
|
||||
export class AcpAgent implements Agent {
|
||||
@@ -157,7 +164,9 @@ export class AcpAgent implements Agent {
|
||||
// ── newSession ────────────────────────────────────────────────
|
||||
|
||||
async newSession(params: NewSessionRequest): Promise<NewSessionResponse> {
|
||||
return this.createSession(params)
|
||||
const result = await this.createSession(params)
|
||||
this.scheduleAvailableCommandsUpdate(result.sessionId)
|
||||
return result
|
||||
}
|
||||
|
||||
// ── resumeSession ──────────────────────────────────────────────
|
||||
@@ -166,9 +175,7 @@ export class AcpAgent implements Agent {
|
||||
params: ResumeSessionRequest,
|
||||
): Promise<ResumeSessionResponse> {
|
||||
const result = await this.getOrCreateSession(params)
|
||||
setTimeout(() => {
|
||||
this.sendAvailableCommandsUpdate(params.sessionId)
|
||||
}, 0)
|
||||
this.scheduleAvailableCommandsUpdate(result.sessionId)
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -176,9 +183,7 @@ export class AcpAgent implements Agent {
|
||||
|
||||
async loadSession(params: LoadSessionRequest): Promise<LoadSessionResponse> {
|
||||
const result = await this.getOrCreateSession(params)
|
||||
setTimeout(() => {
|
||||
this.sendAvailableCommandsUpdate(params.sessionId)
|
||||
}, 0)
|
||||
this.scheduleAvailableCommandsUpdate(result.sessionId)
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -216,9 +221,7 @@ export class AcpAgent implements Agent {
|
||||
_meta: params._meta,
|
||||
},
|
||||
)
|
||||
setTimeout(() => {
|
||||
this.sendAvailableCommandsUpdate(response.sessionId)
|
||||
}, 0)
|
||||
this.scheduleAvailableCommandsUpdate(response.sessionId)
|
||||
return response
|
||||
}
|
||||
|
||||
@@ -243,9 +246,6 @@ export class AcpAgent implements Agent {
|
||||
throw new Error(`Session ${params.sessionId} not found`)
|
||||
}
|
||||
|
||||
// Reset cancelled state at the start of each prompt (matches official impl)
|
||||
session.cancelled = false
|
||||
|
||||
// Extract text/image content from the prompt
|
||||
const promptInput = promptToQueryInput(params.prompt)
|
||||
|
||||
@@ -253,18 +253,27 @@ export class AcpAgent implements Agent {
|
||||
return { stopReason: 'end_turn' }
|
||||
}
|
||||
|
||||
const promptCancelGeneration = session.cancelGeneration
|
||||
|
||||
// Handle prompt queuing — if a prompt is already running, queue this one
|
||||
if (session.promptRunning) {
|
||||
const order = session.nextPendingOrder++
|
||||
const promptUuid = randomUUID()
|
||||
const cancelled = await new Promise<boolean>((resolve) => {
|
||||
session.pendingMessages.set(promptUuid, { resolve, order })
|
||||
session.pendingQueue.push(promptUuid)
|
||||
session.pendingMessages.set(promptUuid, { resolve })
|
||||
})
|
||||
if (cancelled) {
|
||||
return { stopReason: 'cancelled' }
|
||||
}
|
||||
}
|
||||
|
||||
if (session.cancelGeneration !== promptCancelGeneration) {
|
||||
return { stopReason: 'cancelled' }
|
||||
}
|
||||
|
||||
// Reset cancellation only when this prompt is about to run. Queued prompts
|
||||
// must not clear the cancellation state for the active prompt.
|
||||
session.cancelled = false
|
||||
session.promptRunning = true
|
||||
|
||||
try {
|
||||
@@ -324,19 +333,15 @@ export class AcpAgent implements Agent {
|
||||
)
|
||||
}
|
||||
|
||||
console.error('[ACP] prompt error:', err)
|
||||
return { stopReason: 'end_turn' }
|
||||
throw err
|
||||
} finally {
|
||||
session.promptRunning = false
|
||||
// Resolve next pending prompt if any
|
||||
if (session.pendingMessages.size > 0) {
|
||||
const next = [...session.pendingMessages.entries()].sort(
|
||||
(a, b) => a[1].order - b[1].order,
|
||||
)[0]
|
||||
if (next) {
|
||||
next[1].resolve(false)
|
||||
session.pendingMessages.delete(next[0])
|
||||
}
|
||||
const nextPrompt = popNextPendingPrompt(session)
|
||||
if (nextPrompt) {
|
||||
session.promptRunning = true
|
||||
nextPrompt.resolve(false)
|
||||
} else {
|
||||
session.promptRunning = false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -349,12 +354,15 @@ export class AcpAgent implements Agent {
|
||||
|
||||
// Set cancelled flag — checked by prompt() loop to break out
|
||||
session.cancelled = true
|
||||
session.cancelGeneration += 1
|
||||
|
||||
// Cancel any queued prompts
|
||||
for (const [, pending] of session.pendingMessages) {
|
||||
pending.resolve(true)
|
||||
}
|
||||
session.pendingMessages.clear()
|
||||
session.pendingQueue = []
|
||||
session.pendingQueueHead = 0
|
||||
|
||||
// Interrupt the query engine to abort the current API call
|
||||
session.queryEngine.interrupt()
|
||||
@@ -379,7 +387,7 @@ export class AcpAgent implements Agent {
|
||||
|
||||
async unstable_setSessionModel(
|
||||
params: SetSessionModelRequest,
|
||||
): Promise<SetSessionModelResponse | void> {
|
||||
): Promise<SetSessionModelResponse> {
|
||||
const session = this.sessions.get(params.sessionId)
|
||||
if (!session) {
|
||||
throw new Error('Session not found')
|
||||
@@ -388,6 +396,7 @@ export class AcpAgent implements Agent {
|
||||
// parseUserSpecifiedModel() to resolve aliases (e.g. "sonnet" → "glm-5.1-turbo")
|
||||
session.queryEngine.setModel(params.modelId)
|
||||
await this.updateConfigOption(params.sessionId, 'model', params.modelId)
|
||||
return {}
|
||||
}
|
||||
|
||||
// ── setSessionConfigOption ──────────────────────────────────────
|
||||
@@ -449,23 +458,32 @@ export class AcpAgent implements Agent {
|
||||
|
||||
// Set CWD for the session
|
||||
setOriginalCwd(cwd)
|
||||
const previousProcessCwd = process.cwd()
|
||||
let processCwdChanged = false
|
||||
try {
|
||||
process.chdir(cwd)
|
||||
processCwdChanged = true
|
||||
} catch {
|
||||
// CWD may not exist yet; best-effort
|
||||
}
|
||||
|
||||
try {
|
||||
// Build tools with a permissive permission context.
|
||||
const permissionContext = getEmptyToolPermissionContext()
|
||||
const tools: Tools = getTools(permissionContext)
|
||||
|
||||
// Parse permission mode from _meta (passed by RCS/acp-link) or fall back to settings
|
||||
const metaPermissionMode = (params._meta as Record<string, unknown> | null | undefined)?.permissionMode as string | undefined
|
||||
console.log('[ACP Agent] Session create _meta:', JSON.stringify(params._meta), 'extracted mode:', metaPermissionMode)
|
||||
const permissionMode = resolvePermissionMode(
|
||||
metaPermissionMode ?? this.getSetting<string>('permissions.defaultMode'),
|
||||
// Parse permission mode from _meta (passed by RCS/acp-link) or settings.
|
||||
const meta = params._meta as Record<string, unknown> | null | undefined
|
||||
const hasMetaPermissionMode = hasOwnField(meta, 'permissionMode')
|
||||
const metaPermissionMode = hasMetaPermissionMode
|
||||
? meta?.permissionMode
|
||||
: undefined
|
||||
const settingsPermissionMode = this.getSetting<string>('permissions.defaultMode')
|
||||
const permissionMode = resolveSessionPermissionMode(
|
||||
metaPermissionMode,
|
||||
hasMetaPermissionMode,
|
||||
settingsPermissionMode,
|
||||
)
|
||||
console.log('[ACP Agent] Resolved permissionMode:', permissionMode)
|
||||
|
||||
// Create the permission bridge canUseTool function
|
||||
const canUseTool = createAcpCanUseTool(
|
||||
@@ -475,15 +493,15 @@ export class AcpAgent implements Agent {
|
||||
this.clientCapabilities,
|
||||
cwd,
|
||||
(modeId: string) => { this.applySessionMode(sessionId, modeId) },
|
||||
() => this.sessions.get(sessionId)?.appState
|
||||
.toolPermissionContext.isBypassPermissionsModeAvailable ?? false,
|
||||
)
|
||||
|
||||
// Parse MCP servers from ACP params
|
||||
// MCP server config is handled separately in the tools system
|
||||
|
||||
// Check if bypass permissions is available (not running as root unless in sandbox)
|
||||
const isBypassAvailable =
|
||||
(typeof process.geteuid === 'function' ? process.geteuid() !== 0 : true) ||
|
||||
!!process.env.IS_SANDBOX
|
||||
// ACP clients can expose bypass only when both the process and local config allow it.
|
||||
const isBypassAvailable = isAcpBypassPermissionModeAvailable(settingsPermissionMode)
|
||||
|
||||
// Create a mutable AppState for the session
|
||||
const appState: AppState = {
|
||||
@@ -519,7 +537,7 @@ export class AcpAgent implements Agent {
|
||||
|
||||
const queryEngine = new QueryEngine(engineConfig)
|
||||
|
||||
// Build modes — bypassPermissions only available when not running as root (or in sandbox)
|
||||
// Build modes — bypassPermissions is opt-in for ACP clients.
|
||||
const availableModes = [
|
||||
{ id: 'default', name: 'Default', description: 'Standard behavior, prompts for dangerous operations' },
|
||||
{ id: 'acceptEdits', name: 'Accept Edits', description: 'Auto-accept file edit operations' },
|
||||
@@ -557,13 +575,15 @@ export class AcpAgent implements Agent {
|
||||
const session: AcpSession = {
|
||||
queryEngine,
|
||||
cancelled: false,
|
||||
cancelGeneration: 0,
|
||||
cwd,
|
||||
modes,
|
||||
models,
|
||||
configOptions,
|
||||
promptRunning: false,
|
||||
pendingMessages: new Map(),
|
||||
nextPendingOrder: 0,
|
||||
pendingQueue: [],
|
||||
pendingQueueHead: 0,
|
||||
toolUseCache: {},
|
||||
clientCapabilities: this.clientCapabilities,
|
||||
appState,
|
||||
@@ -576,17 +596,17 @@ export class AcpAgent implements Agent {
|
||||
|
||||
this.sessions.set(sessionId, session)
|
||||
|
||||
// Send available commands after session creation
|
||||
setTimeout(() => {
|
||||
this.sendAvailableCommandsUpdate(sessionId)
|
||||
}, 0)
|
||||
|
||||
return {
|
||||
sessionId,
|
||||
models,
|
||||
modes,
|
||||
configOptions,
|
||||
}
|
||||
} finally {
|
||||
if (processCwdChanged) {
|
||||
process.chdir(previousProcessCwd)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async getOrCreateSession(params: {
|
||||
@@ -672,12 +692,22 @@ export class AcpAgent implements Agent {
|
||||
}
|
||||
|
||||
private applySessionMode(sessionId: string, modeId: string): void {
|
||||
const validModes = ['auto', 'default', 'acceptEdits', 'bypassPermissions', 'dontAsk', 'plan']
|
||||
if (!validModes.includes(modeId)) {
|
||||
if (!isPermissionMode(modeId)) {
|
||||
throw new Error(`Invalid mode: ${modeId}`)
|
||||
}
|
||||
const session = this.sessions.get(sessionId)
|
||||
if (session) {
|
||||
if (
|
||||
modeId === 'bypassPermissions' &&
|
||||
!session.appState.toolPermissionContext.isBypassPermissionsModeAvailable
|
||||
) {
|
||||
throw new Error(`Mode not available: ${modeId}`)
|
||||
}
|
||||
const isAvailable = session.modes.availableModes.some(mode => mode.id === modeId)
|
||||
if (!isAvailable) {
|
||||
throw new Error(`Mode not available: ${modeId}`)
|
||||
}
|
||||
|
||||
session.modes = { ...session.modes, currentModeId: modeId }
|
||||
// Sync mode to appState so the permission pipeline sees the correct mode
|
||||
session.appState.toolPermissionContext = {
|
||||
@@ -750,38 +780,160 @@ export class AcpAgent implements Agent {
|
||||
})
|
||||
}
|
||||
|
||||
private scheduleAvailableCommandsUpdate(sessionId: string): void {
|
||||
setTimeout(() => {
|
||||
void this.sendAvailableCommandsUpdate(sessionId).catch(err => {
|
||||
console.error('[ACP] Failed to send available commands update:', err)
|
||||
})
|
||||
}, 0)
|
||||
}
|
||||
|
||||
/** Read a setting from Claude config (simplified — no file watching) */
|
||||
private getSetting<T>(key: string): T | undefined {
|
||||
// Simplified: read from environment or return undefined
|
||||
// In a full implementation, this would read from settings.json
|
||||
return undefined as T | undefined
|
||||
const settings = getSettings_DEPRECATED() as Record<string, unknown>
|
||||
const value = key.split('.').reduce<unknown>((current, segment) => {
|
||||
if (!current || typeof current !== 'object') return undefined
|
||||
return (current as Record<string, unknown>)[segment]
|
||||
}, settings)
|
||||
return value as T | undefined
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────
|
||||
|
||||
/** Extract prompt text from ACP ContentBlock array for QueryEngine input */
|
||||
function promptToQueryInput(
|
||||
prompt: Array<ContentBlock> | undefined,
|
||||
): string {
|
||||
if (!prompt || prompt.length === 0) return ''
|
||||
const permissionModeIds: readonly PermissionMode[] = [
|
||||
'auto',
|
||||
'default',
|
||||
'acceptEdits',
|
||||
'bypassPermissions',
|
||||
'dontAsk',
|
||||
'plan',
|
||||
]
|
||||
|
||||
const parts: string[] = []
|
||||
for (const block of prompt) {
|
||||
const b = block as Record<string, unknown>
|
||||
if (b.type === 'text') {
|
||||
parts.push(b.text as string)
|
||||
} else if (b.type === 'resource_link') {
|
||||
parts.push(`[${b.name ?? ''}](${b.uri as string})`)
|
||||
} else if (b.type === 'resource') {
|
||||
const resource = b.resource as Record<string, unknown> | undefined
|
||||
if (resource && 'text' in resource) {
|
||||
parts.push(resource.text as string)
|
||||
}
|
||||
function isPermissionMode(modeId: string): modeId is PermissionMode {
|
||||
return (permissionModeIds as readonly string[]).includes(modeId)
|
||||
}
|
||||
|
||||
function resolveSessionPermissionMode(
|
||||
metaMode: unknown,
|
||||
hasMetaMode: boolean,
|
||||
settingsMode: unknown,
|
||||
): PermissionMode {
|
||||
if (hasMetaMode) {
|
||||
const metaResolved = resolveRequiredPermissionMode(
|
||||
metaMode,
|
||||
'_meta.permissionMode',
|
||||
)
|
||||
if (
|
||||
metaResolved === 'bypassPermissions' &&
|
||||
!isAcpBypassPermissionModeAvailable(settingsMode)
|
||||
) {
|
||||
throw new Error(
|
||||
'Mode not available: bypassPermissions requires a local ACP bypass opt-in.',
|
||||
)
|
||||
}
|
||||
// Ignore image and other types for text-based prompt
|
||||
|
||||
return metaResolved
|
||||
}
|
||||
|
||||
const settingsResolved = resolveConfiguredPermissionMode(settingsMode)
|
||||
return settingsResolved ?? 'default'
|
||||
}
|
||||
|
||||
function resolveRequiredPermissionMode(
|
||||
mode: unknown,
|
||||
source: string,
|
||||
): PermissionMode {
|
||||
if (mode === undefined || mode === null) {
|
||||
throw new Error(`Invalid ${source}: expected a string.`)
|
||||
}
|
||||
|
||||
return resolvePermissionMode(mode, source) as PermissionMode
|
||||
}
|
||||
|
||||
function resolveConfiguredPermissionMode(mode: unknown): PermissionMode | undefined {
|
||||
if (mode === undefined || mode === null) return undefined
|
||||
|
||||
try {
|
||||
return resolvePermissionMode(mode, 'permissions.defaultMode') as PermissionMode
|
||||
} catch (err: unknown) {
|
||||
const reason = err instanceof Error ? err.message : String(err)
|
||||
console.error('[ACP] Invalid permissions.defaultMode, using default:', reason)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
function hasOwnField(
|
||||
value: Record<string, unknown> | null | undefined,
|
||||
key: string,
|
||||
): boolean {
|
||||
return !!value && Object.hasOwn(value, key)
|
||||
}
|
||||
|
||||
function isAcpBypassPermissionModeAvailable(settingsMode?: unknown): boolean {
|
||||
return (
|
||||
isProcessBypassPermissionModeAvailable() &&
|
||||
(isAcpBypassLocallyEnabled() || isSettingsBypassPermissionMode(settingsMode))
|
||||
)
|
||||
}
|
||||
|
||||
function isProcessBypassPermissionModeAvailable(): boolean {
|
||||
if (process.env.IS_SANDBOX) return true
|
||||
if (typeof process.geteuid === 'function') return process.geteuid() !== 0
|
||||
if (typeof process.getuid === 'function') return process.getuid() !== 0
|
||||
return true
|
||||
}
|
||||
|
||||
function isAcpBypassLocallyEnabled(): boolean {
|
||||
return (
|
||||
process.env.ACP_PERMISSION_MODE === 'bypassPermissions' ||
|
||||
isTruthyEnv(process.env.CLAUDE_CODE_ACP_ALLOW_BYPASS_PERMISSIONS)
|
||||
)
|
||||
}
|
||||
|
||||
function isSettingsBypassPermissionMode(settingsMode: unknown): boolean {
|
||||
try {
|
||||
return resolvePermissionMode(settingsMode) === 'bypassPermissions'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function isTruthyEnv(value: string | undefined): boolean {
|
||||
return value === '1' || value?.toLowerCase() === 'true'
|
||||
}
|
||||
|
||||
function popNextPendingPrompt(session: AcpSession): PendingPrompt | undefined {
|
||||
while (session.pendingQueueHead < session.pendingQueue.length) {
|
||||
const nextId = session.pendingQueue[session.pendingQueueHead++]
|
||||
if (!nextId) continue
|
||||
const next = session.pendingMessages.get(nextId)
|
||||
if (!next) continue
|
||||
session.pendingMessages.delete(nextId)
|
||||
compactPendingQueue(session)
|
||||
return next
|
||||
}
|
||||
|
||||
compactPendingQueue(session)
|
||||
return undefined
|
||||
}
|
||||
|
||||
function compactPendingQueue(session: AcpSession): void {
|
||||
if (session.pendingQueueHead === 0) return
|
||||
|
||||
if (session.pendingQueueHead >= session.pendingQueue.length) {
|
||||
session.pendingQueue = []
|
||||
session.pendingQueueHead = 0
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
session.pendingQueueHead > 1024 &&
|
||||
session.pendingQueueHead * 2 > session.pendingQueue.length
|
||||
) {
|
||||
session.pendingQueue = session.pendingQueue.slice(session.pendingQueueHead)
|
||||
session.pendingQueueHead = 0
|
||||
}
|
||||
return parts.join('\n')
|
||||
}
|
||||
|
||||
function buildConfigOptions(
|
||||
|
||||
@@ -514,28 +514,25 @@ export function toolUpdateFromEditToolResponse(toolResponse: unknown): {
|
||||
return result
|
||||
}
|
||||
|
||||
// ── Prompt conversion ─────────────────────────────────────────────
|
||||
function nextSdkMessageOrAbort(
|
||||
sdkMessages: AsyncGenerator<SDKMessage, void, unknown>,
|
||||
abortSignal: AbortSignal,
|
||||
): Promise<IteratorResult<SDKMessage, void>> {
|
||||
if (abortSignal.aborted) {
|
||||
return Promise.resolve({ done: true, value: undefined })
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert ACP PromptRequest content blocks into content for QueryEngine.
|
||||
*/
|
||||
export function promptToQueryContent(
|
||||
prompt: Array<ContentBlock> | undefined,
|
||||
): string {
|
||||
if (!prompt) return ''
|
||||
return prompt
|
||||
.map((block) => {
|
||||
const b = block as Record<string, unknown>
|
||||
if (b.type === 'text') return b.text as string
|
||||
if (b.type === 'resource_link') return `[${b.name ?? ''}](${b.uri as string})`
|
||||
if (b.type === 'resource') {
|
||||
const resource = b.resource as Record<string, unknown> | undefined
|
||||
if (resource && 'text' in resource) return resource.text as string
|
||||
}
|
||||
return ''
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join('\n')
|
||||
let abortHandler: (() => void) | undefined
|
||||
const abortPromise = new Promise<IteratorResult<SDKMessage, void>>((resolve) => {
|
||||
abortHandler = () => resolve({ done: true, value: undefined })
|
||||
abortSignal.addEventListener('abort', abortHandler, { once: true })
|
||||
})
|
||||
|
||||
return Promise.race([sdkMessages.next(), abortPromise]).finally(() => {
|
||||
if (abortHandler) {
|
||||
abortSignal.removeEventListener('abort', abortHandler)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ── Main forwarding function ──────────────────────────────────────
|
||||
@@ -573,17 +570,7 @@ export async function forwardSessionUpdates(
|
||||
// Race the next message against the abort signal so we unblock
|
||||
// immediately when cancelled, even if the generator is waiting for
|
||||
// a slow API response.
|
||||
const nextResult = await Promise.race([
|
||||
sdkMessages.next(),
|
||||
new Promise<IteratorResult<SDKMessage, void>>((resolve) => {
|
||||
if (abortSignal.aborted) {
|
||||
resolve({ done: true, value: undefined })
|
||||
return
|
||||
}
|
||||
const handler = () => resolve({ done: true, value: undefined })
|
||||
abortSignal.addEventListener('abort', handler, { once: true })
|
||||
}),
|
||||
])
|
||||
const nextResult = await nextSdkMessageOrAbort(sdkMessages, abortSignal)
|
||||
if (nextResult.done || abortSignal.aborted) break
|
||||
const msg = nextResult.value
|
||||
|
||||
@@ -1059,12 +1046,7 @@ function toAcpNotifications(
|
||||
}
|
||||
} else {
|
||||
// Regular tool call
|
||||
let rawInput: Record<string, unknown> | undefined
|
||||
try {
|
||||
rawInput = JSON.parse(JSON.stringify(toolInput ?? {}))
|
||||
} catch {
|
||||
// Ignore parse failures
|
||||
}
|
||||
const rawInput = toolInput ? { ...toolInput } : {}
|
||||
|
||||
if (alreadyCached) {
|
||||
// Second encounter — send as tool_call_update
|
||||
|
||||
@@ -25,14 +25,6 @@ import type { AssistantMessage } from '../../types/message.js'
|
||||
import { hasPermissionsToUseTool } from '../../utils/permissions/permissions.js'
|
||||
import { toolInfoFromToolUse } from './bridge.js'
|
||||
|
||||
const IS_ROOT =
|
||||
typeof process.geteuid === 'function'
|
||||
? process.geteuid() === 0
|
||||
: typeof process.getuid === 'function'
|
||||
? process.getuid() === 0
|
||||
: false
|
||||
const ALLOW_BYPASS = !IS_ROOT || !!process.env.IS_SANDBOX
|
||||
|
||||
/**
|
||||
* Creates a CanUseToolFn that delegates permission decisions to the
|
||||
* ACP client via requestPermission().
|
||||
@@ -44,6 +36,7 @@ export function createAcpCanUseTool(
|
||||
clientCapabilities?: ClientCapabilities,
|
||||
cwd?: string,
|
||||
onModeChange?: (modeId: string) => void,
|
||||
isBypassModeAvailable?: () => boolean,
|
||||
): CanUseToolFn {
|
||||
return async (
|
||||
tool: ToolType,
|
||||
@@ -59,6 +52,7 @@ export function createAcpCanUseTool(
|
||||
if (tool.name === 'ExitPlanMode') {
|
||||
return handleExitPlanMode(
|
||||
conn, sessionId, toolUseID, input, supportsTerminalOutput, cwd, onModeChange,
|
||||
isBypassModeAvailable,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -84,8 +78,16 @@ export function createAcpCanUseTool(
|
||||
}
|
||||
// behavior === 'ask' → fall through to client delegation
|
||||
} catch (err) {
|
||||
// If the pipeline fails, fall through to client delegation
|
||||
console.error('[ACP Permissions] Pipeline error, falling back to client:', err)
|
||||
console.error('[ACP Permissions] Pipeline error:', err)
|
||||
return {
|
||||
behavior: 'deny',
|
||||
message: 'Permission pipeline failed',
|
||||
decisionReason: {
|
||||
type: 'other',
|
||||
reason: 'Permission pipeline failed',
|
||||
},
|
||||
toolUseID,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Delegate to ACP client for interactive permission decision ──
|
||||
@@ -144,7 +146,8 @@ export function createAcpCanUseTool(
|
||||
message: 'Permission denied by client',
|
||||
decisionReason: { type: 'mode', mode: 'default' },
|
||||
}
|
||||
} catch {
|
||||
} catch (err) {
|
||||
console.error('[ACP Permissions] Client request error:', err)
|
||||
return {
|
||||
behavior: 'deny',
|
||||
message: 'Permission request failed',
|
||||
@@ -162,6 +165,7 @@ async function handleExitPlanMode(
|
||||
supportsTerminalOutput: boolean,
|
||||
cwd?: string,
|
||||
onModeChange?: (modeId: string) => void,
|
||||
isBypassModeAvailable?: () => boolean,
|
||||
): Promise<PermissionAllowDecision | PermissionDenyDecision> {
|
||||
const options: Array<PermissionOption> = [
|
||||
{ kind: 'allow_always', name: 'Yes, and use "auto" mode', optionId: 'auto' },
|
||||
@@ -169,7 +173,7 @@ async function handleExitPlanMode(
|
||||
{ kind: 'allow_once', name: 'Yes, and manually approve edits', optionId: 'default' },
|
||||
{ kind: 'reject_once', name: 'No, keep planning', optionId: 'plan' },
|
||||
]
|
||||
if (ALLOW_BYPASS) {
|
||||
if (isBypassModeAvailable?.() === true) {
|
||||
options.unshift({
|
||||
kind: 'allow_always',
|
||||
name: 'Yes, and bypass permissions',
|
||||
@@ -211,11 +215,15 @@ async function handleExitPlanMode(
|
||||
response.outcome.optionId !== undefined
|
||||
) {
|
||||
const selectedOption = response.outcome.optionId
|
||||
const isOfferedOption = options.some(option => option.optionId === selectedOption)
|
||||
if (
|
||||
selectedOption === 'default' ||
|
||||
selectedOption === 'acceptEdits' ||
|
||||
selectedOption === 'auto' ||
|
||||
selectedOption === 'bypassPermissions'
|
||||
isOfferedOption &&
|
||||
(
|
||||
selectedOption === 'default' ||
|
||||
selectedOption === 'acceptEdits' ||
|
||||
selectedOption === 'auto' ||
|
||||
selectedOption === 'bypassPermissions'
|
||||
)
|
||||
) {
|
||||
// Sync mode to session state and appState
|
||||
onModeChange?.(selectedOption)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user