mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-15 21:05:51 +00:00
Compare commits
5 Commits
v1.10.4
...
feature/ad
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0a90b218c3 | ||
|
|
de9494c0a3 | ||
|
|
e7e1f7a34d | ||
|
|
9a5998eaef | ||
|
|
dff678924e |
26
.github/workflows/ci.yml
vendored
26
.github/workflows/ci.yml
vendored
@@ -6,29 +6,18 @@ on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
ci:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- 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: actions/checkout@v4
|
||||
|
||||
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2, 2026-04-25
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- name: Install dependencies
|
||||
env:
|
||||
CLAUDE_CODE_SKIP_CHROME_MCP_SETUP: "1"
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
- name: Type check
|
||||
@@ -37,17 +26,12 @@ jobs:
|
||||
- name: Test with Coverage
|
||||
run: |
|
||||
set -o pipefail
|
||||
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
|
||||
bun test --coverage --coverage-reporter=lcov 2>&1 | grep -vE '^\s*(\(pass\)|\(skip\))' | sed '/^.*\/__tests__\/.*:$/d' | cat -s
|
||||
|
||||
- name: Upload coverage to Codecov
|
||||
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
|
||||
uses: codecov/codecov-action@v5
|
||||
with:
|
||||
fail_ci_if_error: true
|
||||
files: ./coverage/lcov.info
|
||||
disable_search: true
|
||||
file: ./coverage/lcov.info
|
||||
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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2, 2026-04-25
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.inputs.version || github.ref }}
|
||||
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6, 2026-04-25
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "24"
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2, 2026-04-25
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
@@ -66,7 +66,7 @@ jobs:
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2, 2026-04-25
|
||||
uses: softprops/action-gh-release@v2
|
||||
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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2, 2026-04-25
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Login to GHCR
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3, 2026-04-25
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3, 2026-04-25
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Extract version
|
||||
id: version
|
||||
@@ -47,7 +47,7 @@ jobs:
|
||||
echo "tags=$TAGS" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Build Docker image
|
||||
uses: docker/build-push-action@ca052bb54ab0790a636c9b5f226502c73d547a25 # v5, 2026-04-25
|
||||
uses: docker/build-push-action@v5
|
||||
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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2, 2026-04-25
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- uses: jaywcjlove/github-action-contributors@86707f6d4c2469ce6b46bc3367253ebd41ee242c # main, 2026-04-25
|
||||
- uses: jaywcjlove/github-action-contributors@main
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
output: "contributors.svg"
|
||||
repository: ${{ github.repository }}
|
||||
|
||||
- uses: stefanzweifel/git-auto-commit-action@b863ae1933cb653a53c021fe36dbb774e1fb9403 # v5, 2026-04-25
|
||||
- uses: stefanzweifel/git-auto-commit-action@v5
|
||||
with:
|
||||
commit_message: "docs: update contributors"
|
||||
file_pattern: "contributors.svg"
|
||||
|
||||
@@ -99,15 +99,12 @@ ARGUMENTS
|
||||
|
||||
## 四、认证
|
||||
|
||||
默认启动时自动生成随机 token。客户端连接时不要把 token 放在 URL 中:
|
||||
默认启动时自动生成随机 token。客户端连接时需通过 query 参数传递:
|
||||
|
||||
```
|
||||
ws://localhost:9315/ws
|
||||
ws://localhost:9315/ws?token=<your-token>
|
||||
```
|
||||
|
||||
无法发送 `Authorization` header 的 WebSocket 客户端需要使用
|
||||
`rcs.auth.<base64url-token>` 子协议传递 token。
|
||||
|
||||
配置固定 token:
|
||||
|
||||
```bash
|
||||
@@ -138,9 +135,6 @@ 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,11 +225,6 @@ 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)。
|
||||
|
||||
@@ -1,564 +0,0 @@
|
||||
# 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
|
||||
- [ ] 最终变更说明包含风险与未覆盖项
|
||||
@@ -1,74 +0,0 @@
|
||||
# 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 文档。
|
||||
52
package.json
52
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "claude-code-best",
|
||||
"version": "1.10.4",
|
||||
"version": "1.10.2",
|
||||
"description": "Reverse-engineered Anthropic Claude Code CLI — interactive AI coding assistant in the terminal",
|
||||
"type": "module",
|
||||
"author": "claude-code-best <claude-code-best@proton.me>",
|
||||
@@ -78,19 +78,19 @@
|
||||
"@ant/computer-use-input": "workspace:*",
|
||||
"@ant/computer-use-mcp": "workspace:*",
|
||||
"@ant/computer-use-swift": "workspace:*",
|
||||
"@anthropic-ai/bedrock-sdk": "^0.29.0",
|
||||
"@anthropic-ai/bedrock-sdk": "^0.26.4",
|
||||
"@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.81.0",
|
||||
"@anthropic-ai/vertex-sdk": "^0.16.0",
|
||||
"@anthropic-ai/sdk": "^0.80.0",
|
||||
"@anthropic-ai/vertex-sdk": "^0.14.4",
|
||||
"@anthropic/ink": "workspace:*",
|
||||
"@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",
|
||||
"@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",
|
||||
"@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.215.0",
|
||||
"@opentelemetry/api-logs": "^0.214.0",
|
||||
"@opentelemetry/core": "^2.7.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/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/resources": "^2.7.0",
|
||||
"@opentelemetry/sdk-logs": "^0.215.0",
|
||||
"@opentelemetry/sdk-logs": "^0.214.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.2",
|
||||
"axios": "^1.15.0",
|
||||
"bidi-js": "^1.0.3",
|
||||
"cacache": "^20.0.4",
|
||||
"chalk": "^5.6.2",
|
||||
@@ -208,13 +208,5 @@
|
||||
},
|
||||
"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.81.0",
|
||||
"@anthropic-ai/sdk": "^0.80.0",
|
||||
"openai": "^6.33.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,17 +80,13 @@ ARGUMENTS
|
||||
|
||||
## Authentication
|
||||
|
||||
By default, a random token is auto-generated on startup. Connect to the
|
||||
WebSocket endpoint without putting the token in the URL:
|
||||
By default, a random token is auto-generated on startup. Pass it as a query parameter:
|
||||
|
||||
```
|
||||
ws://localhost:9315/ws
|
||||
ws://localhost:9315/ws?token=<your-token>
|
||||
```
|
||||
|
||||
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>`.
|
||||
Set `ACP_AUTH_TOKEN` env var to use a fixed token, or use `--no-auth` to disable (not recommended).
|
||||
|
||||
## 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.12.15",
|
||||
"hono": "^4.7.0",
|
||||
"pino": "^10.3.0",
|
||||
"pino-pretty": "^13.1.3",
|
||||
"selfsigned": "^5.5.0"
|
||||
|
||||
@@ -1,35 +1,5 @@
|
||||
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;
|
||||
}
|
||||
import { describe, test, expect } from "bun:test";
|
||||
import type { ServerConfig } from "../server.js";
|
||||
|
||||
describe("Server HTTP endpoints", () => {
|
||||
test("package.json has correct bin and main entries", async () => {
|
||||
@@ -90,188 +60,6 @@ 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,6 +1,4 @@
|
||||
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"
|
||||
@@ -11,18 +9,6 @@ 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.
|
||||
*
|
||||
@@ -101,7 +87,17 @@ export class RcsUpstreamClient {
|
||||
|
||||
/** Normalize RCS URL: accept http(s) base URL and convert to ws(s) + /acp/ws path */
|
||||
private buildWsUrl(): string {
|
||||
return buildRcsWsUrl(this.config.rcsUrl);
|
||||
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();
|
||||
}
|
||||
|
||||
/** Open connection to RCS: REST register → WS identify */
|
||||
@@ -125,9 +121,7 @@ export class RcsUpstreamClient {
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
this.ws = new WebSocket(wsUrl, [
|
||||
encodeWebSocketAuthProtocol(this.config.apiToken),
|
||||
]);
|
||||
this.ws = new WebSocket(wsUrl);
|
||||
|
||||
this.ws.onopen = () => {
|
||||
RcsUpstreamClient.log.debug("ws open — sending identify");
|
||||
@@ -142,13 +136,8 @@ export class RcsUpstreamClient {
|
||||
this.ws.onmessage = (event) => {
|
||||
let data: Record<string, unknown>;
|
||||
try {
|
||||
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;
|
||||
}
|
||||
data = JSON.parse(event.data as string);
|
||||
} catch {
|
||||
RcsUpstreamClient.log.warn({ raw: String(event.data).slice(0, 200) }, "invalid JSON from server");
|
||||
return;
|
||||
}
|
||||
@@ -163,7 +152,11 @@ export class RcsUpstreamClient {
|
||||
.replace(/\/acp\/ws.*$/, "")
|
||||
.replace(/\/$/, "");
|
||||
console.log();
|
||||
console.log(` 🔗 Dashboard: ${webBase}/code/`);
|
||||
if (this.sessionId) {
|
||||
console.log(` 🔗 Dashboard: ${webBase}/code/?sid=${this.sessionId}`);
|
||||
} else {
|
||||
console.log(` 🔗 Dashboard: ${webBase}/code/`);
|
||||
}
|
||||
if (this.agentId) {
|
||||
console.log(` Agent ID: ${this.agentId}`);
|
||||
}
|
||||
|
||||
@@ -10,13 +10,6 @@ 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;
|
||||
@@ -258,7 +251,6 @@ 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;
|
||||
@@ -342,16 +334,7 @@ async function handleNewSession(
|
||||
|
||||
try {
|
||||
const sessionCwd = params.cwd || AGENT_CWD;
|
||||
let permissionMode: string | undefined;
|
||||
try {
|
||||
permissionMode = resolveNewSessionPermissionMode(
|
||||
params.permissionMode,
|
||||
DEFAULT_PERMISSION_MODE,
|
||||
);
|
||||
} catch (error) {
|
||||
send(ws, "error", { message: (error as Error).message });
|
||||
return;
|
||||
}
|
||||
const permissionMode = params.permissionMode || DEFAULT_PERMISSION_MODE;
|
||||
const result = await state.connection.newSession({
|
||||
cwd: sessionCwd,
|
||||
mcpServers: [],
|
||||
@@ -607,326 +590,9 @@ interface ContentBlock {
|
||||
name?: 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,
|
||||
};
|
||||
interface ProxyMessage {
|
||||
type: "connect" | "disconnect" | "new_session" | "prompt" | "cancel" | "set_session_model";
|
||||
payload?: { cwd?: string } | { content: ContentBlock[] } | { modelId: string };
|
||||
}
|
||||
|
||||
export async function startServer(config: ServerConfig): Promise<void> {
|
||||
@@ -972,9 +638,44 @@ export async function startServer(config: ServerConfig): Promise<void> {
|
||||
|
||||
rcsUpstream.setMessageHandler(async (msg) => {
|
||||
try {
|
||||
const data = decodeClientMessage(msg);
|
||||
logRelay.debug({ type: data.type }, "processing");
|
||||
await dispatchClientMessage(relayWs, data);
|
||||
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");
|
||||
}
|
||||
} catch (error) {
|
||||
logRelay.error({ error: (error as Error).message }, "handler error");
|
||||
}
|
||||
@@ -999,11 +700,9 @@ export async function startServer(config: ServerConfig): Promise<void> {
|
||||
"/ws",
|
||||
upgradeWebSocket((c) => {
|
||||
if (AUTH_TOKEN) {
|
||||
const providedToken = extractWebSocketAuthToken({
|
||||
authorization: c.req.header("Authorization"),
|
||||
protocol: c.req.header("Sec-WebSocket-Protocol"),
|
||||
});
|
||||
if (!authTokensEqual(providedToken, AUTH_TOKEN)) {
|
||||
const url = new URL(c.req.url);
|
||||
const providedToken = url.searchParams.get("token");
|
||||
if (providedToken !== AUTH_TOKEN) {
|
||||
logWs.warn("connection rejected: invalid token");
|
||||
return {
|
||||
onOpen(_event, ws) {
|
||||
@@ -1035,31 +734,63 @@ export async function startServer(config: ServerConfig): Promise<void> {
|
||||
state.isAlive = true;
|
||||
});
|
||||
},
|
||||
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}` });
|
||||
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}` });
|
||||
}
|
||||
},
|
||||
onClose(_event, ws) {
|
||||
logWs.info("client disconnected");
|
||||
const state = clients.get(ws);
|
||||
if (state) {
|
||||
cancelPendingPermissions(state);
|
||||
}
|
||||
handleDisconnect(ws);
|
||||
clients.delete(ws);
|
||||
},
|
||||
};
|
||||
} 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);
|
||||
},
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -1124,7 +855,7 @@ export async function startServer(config: ServerConfig): Promise<void> {
|
||||
console.log(` URL: ${localWsUrl}`);
|
||||
}
|
||||
if (AUTH_TOKEN) {
|
||||
console.log(` Token: configured`);
|
||||
console.log(` Token: ${AUTH_TOKEN}`);
|
||||
}
|
||||
console.log();
|
||||
if (!AUTH_TOKEN) {
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
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));
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,145 +0,0 @@
|
||||
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,34 +82,6 @@ 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).
|
||||
@@ -314,7 +286,7 @@ export async function getWithPermittedRedirects(
|
||||
error.response &&
|
||||
[301, 302, 307, 308].includes(error.response.status)
|
||||
) {
|
||||
const redirectLocation = getResponseHeader(error.response.headers, 'location')
|
||||
const redirectLocation = error.response.headers.location
|
||||
if (!redirectLocation) {
|
||||
throw new Error('Redirect missing Location header')
|
||||
}
|
||||
@@ -346,8 +318,7 @@ export async function getWithPermittedRedirects(
|
||||
if (
|
||||
axios.isAxiosError(error) &&
|
||||
error.response?.status === 403 &&
|
||||
getResponseHeader(error.response.headers, 'x-proxy-error') ===
|
||||
'blocked-by-allowlist'
|
||||
error.response.headers['x-proxy-error'] === 'blocked-by-allowlist'
|
||||
) {
|
||||
const hostname = new URL(url).hostname
|
||||
throw new EgressBlockedError(hostname)
|
||||
@@ -459,7 +430,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 = getResponseHeader(response.headers, 'content-type') ?? ''
|
||||
const contentType = 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,9 +13,10 @@
|
||||
"dependencies": {
|
||||
"@ai-sdk/react": "^3.0.170",
|
||||
"ai": "^6.0.168",
|
||||
"hono": "^4.12.15",
|
||||
"hono": "^4.7.0",
|
||||
"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",
|
||||
@@ -50,6 +51,7 @@
|
||||
"@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,9 +10,6 @@ const mockConfig = {
|
||||
heartbeatInterval: 20,
|
||||
jwtExpiresIn: 3600,
|
||||
disconnectTimeout: 300,
|
||||
webCorsOrigins: [],
|
||||
wsIdleTimeout: 30,
|
||||
wsKeepaliveInterval: 20,
|
||||
};
|
||||
|
||||
mock.module("../config", () => ({
|
||||
|
||||
@@ -10,9 +10,6 @@ const mockConfig = {
|
||||
heartbeatInterval: 20,
|
||||
jwtExpiresIn: 3600,
|
||||
disconnectTimeout: 300,
|
||||
webCorsOrigins: [],
|
||||
wsIdleTimeout: 30,
|
||||
wsKeepaliveInterval: 20,
|
||||
};
|
||||
|
||||
mock.module("../config", () => ({
|
||||
|
||||
@@ -10,9 +10,6 @@ const mockConfig = {
|
||||
heartbeatInterval: 20,
|
||||
jwtExpiresIn: 3600,
|
||||
disconnectTimeout: 300,
|
||||
webCorsOrigins: ["https://dashboard.example"],
|
||||
wsIdleTimeout: 30,
|
||||
wsKeepaliveInterval: 20,
|
||||
};
|
||||
|
||||
mock.module("../config", () => ({
|
||||
@@ -21,23 +18,10 @@ mock.module("../config", () => ({
|
||||
}));
|
||||
|
||||
import { Hono } from "hono";
|
||||
import { cors } from "hono/cors";
|
||||
import { storeReset, storeCreateUser } from "../store";
|
||||
import {
|
||||
apiKeyAuth,
|
||||
encodeWebSocketAuthProtocol,
|
||||
extractWebSocketAuthToken,
|
||||
sessionIngressAuth,
|
||||
uuidAuth,
|
||||
getUuidFromRequest,
|
||||
} from "../auth/middleware";
|
||||
import { apiKeyAuth, 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() {
|
||||
@@ -63,10 +47,6 @@ function createTestApp() {
|
||||
return c.json({ uuid: getUuidFromRequest(c) });
|
||||
});
|
||||
|
||||
app.get("/ws-auth-token", (c) => {
|
||||
return c.json({ token: extractWebSocketAuthToken(c) ?? null });
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
@@ -123,11 +103,13 @@ describe("Auth Middleware", () => {
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
test("rejects session token from query param", async () => {
|
||||
test("accepts 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(401);
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.username).toBe("dave");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -147,15 +129,6 @@ 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", {
|
||||
@@ -188,24 +161,6 @@ 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");
|
||||
@@ -251,45 +206,3 @@ 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,9 +10,6 @@ const mockConfig = {
|
||||
heartbeatInterval: 20,
|
||||
jwtExpiresIn: 3600,
|
||||
disconnectTimeout: 300,
|
||||
webCorsOrigins: [],
|
||||
wsIdleTimeout: 30,
|
||||
wsKeepaliveInterval: 20,
|
||||
};
|
||||
|
||||
mock.module("../config", () => ({
|
||||
@@ -25,23 +22,12 @@ 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, {
|
||||
decodeSessionIngressWsMessage,
|
||||
handleSessionIngressWsPayload,
|
||||
websocket as sessionIngressWebsocket,
|
||||
} from "../routes/v1/session-ingress";
|
||||
import {
|
||||
decodeAcpWsMessageData,
|
||||
hasAcpRelayAuth,
|
||||
handleAcpWsPayload,
|
||||
} from "../routes/acp";
|
||||
import acpRoutes from "../routes/acp";
|
||||
import v1SessionIngress, { websocket as sessionIngressWebsocket } from "../routes/v1/session-ingress";
|
||||
import v2CodeSessions from "../routes/v2/code-sessions";
|
||||
import v2Worker from "../routes/v2/worker";
|
||||
import v2WorkerEventsStream from "../routes/v2/worker-events-stream";
|
||||
@@ -65,7 +51,6 @@ function createApp() {
|
||||
app.route("/web", webSessions);
|
||||
app.route("/web", webControl);
|
||||
app.route("/web", webEnvironments);
|
||||
app.route("/acp", acpRoutes);
|
||||
return app;
|
||||
}
|
||||
|
||||
@@ -1175,83 +1160,6 @@ 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",
|
||||
@@ -1276,9 +1184,7 @@ 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}`, [
|
||||
encodeWebSocketAuthProtocol("test-api-key"),
|
||||
]);
|
||||
const ws = new WebSocket(`ws://127.0.0.1:${server.port}/v2/session_ingress/ws/${compatId}?token=test-api-key`);
|
||||
const timeout = setTimeout(() => {
|
||||
ws.close();
|
||||
reject(new Error("Timed out waiting for compat WebSocket replay"));
|
||||
@@ -1299,7 +1205,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);
|
||||
@@ -1307,383 +1213,6 @@ 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,9 +10,6 @@ const mockConfig = {
|
||||
heartbeatInterval: 20,
|
||||
jwtExpiresIn: 3600,
|
||||
disconnectTimeout: 300,
|
||||
webCorsOrigins: [],
|
||||
wsIdleTimeout: 30,
|
||||
wsKeepaliveInterval: 20,
|
||||
};
|
||||
|
||||
mock.module("../config", () => ({
|
||||
|
||||
@@ -10,9 +10,6 @@ const mockConfig = {
|
||||
heartbeatInterval: 20,
|
||||
jwtExpiresIn: 3600,
|
||||
disconnectTimeout: 300,
|
||||
webCorsOrigins: [],
|
||||
wsIdleTimeout: 30,
|
||||
wsKeepaliveInterval: 20,
|
||||
};
|
||||
|
||||
mock.module("../config", () => ({
|
||||
|
||||
@@ -10,9 +10,6 @@ const mockConfig = {
|
||||
heartbeatInterval: 20,
|
||||
jwtExpiresIn: 3600,
|
||||
disconnectTimeout: 300,
|
||||
webCorsOrigins: [],
|
||||
wsIdleTimeout: 30,
|
||||
wsKeepaliveInterval: 20,
|
||||
};
|
||||
|
||||
mock.module("../config", () => ({
|
||||
|
||||
@@ -10,9 +10,6 @@ const mockConfig = {
|
||||
heartbeatInterval: 20,
|
||||
jwtExpiresIn: 3600,
|
||||
disconnectTimeout: 300,
|
||||
webCorsOrigins: [],
|
||||
wsIdleTimeout: 30,
|
||||
wsKeepaliveInterval: 20,
|
||||
};
|
||||
|
||||
mock.module("../config", () => ({
|
||||
|
||||
@@ -1,15 +1,10 @@
|
||||
import { createHash, timingSafeEqual } from "node:crypto";
|
||||
import { createHash } 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;
|
||||
const tokenHash = sha256(token);
|
||||
return config.apiKeys.some((key) => timingSafeEqual(tokenHash, sha256(key)));
|
||||
return config.apiKeys.includes(token);
|
||||
}
|
||||
|
||||
export function hashApiKey(key: string): string {
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
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,49 +3,11 @@ import { validateApiKey } from "./api-key";
|
||||
import { verifyWorkerJwt } from "./jwt";
|
||||
import { resolveToken } from "./token";
|
||||
|
||||
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 {
|
||||
/** Extract Bearer token from Authorization header or ?token= query param */
|
||||
function extractBearerToken(c: Context): string | undefined {
|
||||
const authHeader = c.req.header("Authorization");
|
||||
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"));
|
||||
const queryToken = c.req.query("token");
|
||||
return authHeader?.replace("Bearer ", "") || queryToken;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -87,7 +49,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 = extractWebSocketAuthToken(c);
|
||||
const token = extractBearerToken(c);
|
||||
|
||||
if (!token) {
|
||||
return c.json({ error: { type: "unauthorized", message: "Missing auth token" } }, 401);
|
||||
|
||||
@@ -8,10 +8,6 @@ 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,7 +11,6 @@ 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";
|
||||
@@ -45,7 +44,7 @@ app.use("*", async (c, next) => {
|
||||
}
|
||||
await next();
|
||||
});
|
||||
app.use("/web/*", cors(webCorsOptions));
|
||||
app.use("/web/*", cors());
|
||||
|
||||
// Health check
|
||||
app.get("/health", (c) => c.json({ status: "ok", version: config.version }));
|
||||
|
||||
@@ -1,16 +1,6 @@
|
||||
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 {
|
||||
decodeWsPayload,
|
||||
handleSizedWsPayload,
|
||||
} from "../../transport/ws-payload";
|
||||
import {
|
||||
extractBearerToken,
|
||||
extractWebSocketAuthToken,
|
||||
} from "../../auth/middleware";
|
||||
import { apiKeyAuth } from "../../auth/middleware";
|
||||
import { validateApiKey } from "../../auth/api-key";
|
||||
import {
|
||||
handleAcpWsOpen,
|
||||
@@ -32,14 +22,8 @@ import { log, error as logError } from "../../logger";
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
type WsMessageEvent = {
|
||||
data: WSMessageReceive;
|
||||
};
|
||||
|
||||
type WsCloseEvent = {
|
||||
code?: number;
|
||||
reason?: string;
|
||||
};
|
||||
/** Maximum WebSocket message size: 10 MB */
|
||||
const MAX_WS_MESSAGE_SIZE = 10 * 1024 * 1024;
|
||||
|
||||
/** Response shape for an ACP agent */
|
||||
function toAcpAgentResponse(env: ReturnType<typeof storeGetEnvironment> & {}) {
|
||||
@@ -55,33 +39,28 @@ function toAcpAgentResponse(env: ReturnType<typeof storeGetEnvironment> & {}) {
|
||||
};
|
||||
}
|
||||
|
||||
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) */
|
||||
/** GET /acp/agents — List all registered ACP agents (UUID or API key auth) */
|
||||
app.get("/agents", async (c) => {
|
||||
if (!hasAcpReadAuth(c)) {
|
||||
return acpReadUnauthorized(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);
|
||||
}
|
||||
const agents = storeListAcpAgents();
|
||||
return c.json(agents.map((a) => toAcpAgentResponse(a)).filter(Boolean));
|
||||
});
|
||||
|
||||
/** GET /acp/channel-groups — List all channel groups with member agents (API key auth) */
|
||||
/** GET /acp/channel-groups — List all channel groups with member agents (UUID or API key auth) */
|
||||
app.get("/channel-groups", async (c) => {
|
||||
if (!hasAcpReadAuth(c)) {
|
||||
return acpReadUnauthorized(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);
|
||||
}
|
||||
const agents = storeListAcpAgents();
|
||||
const groupMap = new Map<string, typeof agents>();
|
||||
@@ -100,12 +79,8 @@ app.get("/channel-groups", async (c) => {
|
||||
return c.json(groups);
|
||||
});
|
||||
|
||||
/** GET /acp/channel-groups/:id — Specific channel group detail (API key auth) */
|
||||
/** GET /acp/channel-groups/:id — Specific channel group detail (no auth for web UI) */
|
||||
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) {
|
||||
@@ -118,18 +93,14 @@ app.get("/channel-groups/:id", async (c) => {
|
||||
});
|
||||
});
|
||||
|
||||
/** SSE /acp/channel-groups/:id/events — Event stream for external consumers (API key auth) */
|
||||
/** SSE /acp/channel-groups/:id/events — Event stream for external consumers (no auth for web UI) */
|
||||
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, 10) : lastEventId ? parseInt(lastEventId, 10) : 0;
|
||||
const fromSeqNum = fromSeq ? parseInt(fromSeq) : lastEventId ? parseInt(lastEventId) : 0;
|
||||
|
||||
return createAcpSSEStream(c, groupId, fromSeqNum);
|
||||
});
|
||||
@@ -138,38 +109,46 @@ app.get("/channel-groups/:id/events", async (c) => {
|
||||
app.get(
|
||||
"/ws",
|
||||
upgradeWebSocket(async (c) => {
|
||||
const token = extractWebSocketAuthToken(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;
|
||||
|
||||
if (!token || !validateApiKey(token)) {
|
||||
log("[ACP-WS] Upgrade rejected: unauthorized");
|
||||
return {
|
||||
onOpen(_evt: Event, ws: WSContext) {
|
||||
onOpen(_evt: any, ws: any) {
|
||||
ws.close(4003, "unauthorized");
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Generate unique wsId for this connection
|
||||
const wsId = `acp_ws_${randomUUID().replace(/-/g, "")}`;
|
||||
const { v4: uuid } = await import("uuid");
|
||||
const wsId = `acp_ws_${uuid().replace(/-/g, "")}`;
|
||||
|
||||
log(`[ACP-WS] Upgrade accepted: wsId=${wsId}`);
|
||||
return {
|
||||
onOpen(_evt: Event, ws: WSContext) {
|
||||
onOpen(_evt: any, ws: any) {
|
||||
handleAcpWsOpen(ws, wsId);
|
||||
},
|
||||
onMessage(evt: WsMessageEvent, ws: WSContext) {
|
||||
handleAcpWsPayload(
|
||||
ws,
|
||||
"[ACP-WS]",
|
||||
`wsId=${wsId}`,
|
||||
evt.data,
|
||||
data => handleAcpWsMessage(ws, wsId, data),
|
||||
);
|
||||
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);
|
||||
},
|
||||
onClose(evt: WsCloseEvent, ws: WSContext) {
|
||||
handleAcpWsClose(ws, wsId, evt.code, evt.reason);
|
||||
onClose(evt: any, ws: any) {
|
||||
const closeEvt = evt as unknown as CloseEvent;
|
||||
handleAcpWsClose(ws, wsId, closeEvt?.code, closeEvt?.reason);
|
||||
},
|
||||
onError(evt: Event, ws: WSContext) {
|
||||
onError(evt: any, ws: any) {
|
||||
logError(`[ACP-WS] Error on wsId=${wsId}:`, evt);
|
||||
handleAcpWsClose(ws, wsId, 1006, "websocket error");
|
||||
},
|
||||
@@ -181,36 +160,50 @@ app.get(
|
||||
app.get(
|
||||
"/relay/:agentId",
|
||||
upgradeWebSocket(async (c) => {
|
||||
if (!hasAcpRelayAuth(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) {
|
||||
log("[ACP-Relay] Upgrade rejected: unauthorized");
|
||||
return {
|
||||
onOpen(_evt: Event, ws: WSContext) {
|
||||
onOpen(_evt: any, ws: any) {
|
||||
ws.close(4003, "unauthorized");
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const agentId = c.req.param("agentId")!;
|
||||
const relayWsId = `relay_${randomUUID().replace(/-/g, "")}`;
|
||||
const { v4: uuid } = await import("uuid");
|
||||
const relayWsId = `relay_${uuid().replace(/-/g, "")}`;
|
||||
|
||||
log(`[ACP-Relay] Upgrade accepted: relayWsId=${relayWsId} agentId=${agentId}`);
|
||||
return {
|
||||
onOpen(_evt: Event, ws: WSContext) {
|
||||
onOpen(_evt: any, ws: any) {
|
||||
handleRelayOpen(ws, relayWsId, agentId);
|
||||
},
|
||||
onMessage(evt: WsMessageEvent, ws: WSContext) {
|
||||
handleAcpWsPayload(
|
||||
ws,
|
||||
"[ACP-Relay]",
|
||||
`relayWsId=${relayWsId}`,
|
||||
evt.data,
|
||||
data => handleRelayMessage(ws, relayWsId, data),
|
||||
);
|
||||
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);
|
||||
},
|
||||
onClose(evt: WsCloseEvent, ws: WSContext) {
|
||||
handleRelayClose(ws, relayWsId, evt.code, evt.reason);
|
||||
onClose(evt: any, ws: any) {
|
||||
const closeEvt = evt as unknown as CloseEvent;
|
||||
handleRelayClose(ws, relayWsId, closeEvt?.code, closeEvt?.reason);
|
||||
},
|
||||
onError(evt: Event, ws: WSContext) {
|
||||
onError(evt: any, ws: any) {
|
||||
logError(`[ACP-Relay] Error on relayWsId=${relayWsId}:`, evt);
|
||||
handleRelayClose(ws, relayWsId, 1006, "websocket error");
|
||||
},
|
||||
@@ -218,16 +211,4 @@ 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,15 +1,8 @@
|
||||
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,
|
||||
@@ -20,18 +13,11 @@ import { getSession, resolveExistingSessionId } from "../../services/session";
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
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);
|
||||
/** 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;
|
||||
|
||||
// Try API key first
|
||||
if (validateApiKey(token)) {
|
||||
@@ -90,7 +76,7 @@ app.get(
|
||||
|
||||
if (!authenticateRequest(c, `WS ${sessionId}`, sessionId)) {
|
||||
return {
|
||||
onOpen(_evt: Event, ws: WSContext) {
|
||||
onOpen(_evt, ws) {
|
||||
ws.close(4003, "unauthorized");
|
||||
},
|
||||
};
|
||||
@@ -100,7 +86,7 @@ app.get(
|
||||
if (!session) {
|
||||
log(`[WS] Upgrade rejected: session ${sessionId} not found`);
|
||||
return {
|
||||
onOpen(_evt: Event, ws: WSContext) {
|
||||
onOpen(_evt, ws) {
|
||||
ws.close(4001, "session not found");
|
||||
},
|
||||
};
|
||||
@@ -108,38 +94,27 @@ app.get(
|
||||
|
||||
log(`[WS] Upgrade accepted: session=${sessionId}`);
|
||||
return {
|
||||
onOpen(_evt: Event, ws: WSContext) {
|
||||
handleWebSocketOpen(ws, sessionId);
|
||||
onOpen(_evt, ws) {
|
||||
handleWebSocketOpen(ws as any, sessionId);
|
||||
},
|
||||
onMessage(evt: WsMessageEvent, ws: WSContext) {
|
||||
handleSessionIngressWsPayload(ws, sessionId, evt.data);
|
||||
onMessage(evt, ws) {
|
||||
const data =
|
||||
typeof evt.data === "string"
|
||||
? evt.data
|
||||
: new TextDecoder().decode(evt.data as ArrayBuffer);
|
||||
handleWebSocketMessage(ws as any, sessionId, data);
|
||||
},
|
||||
onClose(evt: WsCloseEvent, ws: WSContext) {
|
||||
handleWebSocketClose(ws, sessionId, evt.code, evt.reason);
|
||||
onClose(evt, ws) {
|
||||
const closeEvt = evt as unknown as CloseEvent;
|
||||
handleWebSocketClose(ws as any, sessionId, closeEvt?.code, closeEvt?.reason);
|
||||
},
|
||||
onError(evt: Event, ws: WSContext) {
|
||||
onError(evt, ws) {
|
||||
logError(`[WS] Error on session=${sessionId}:`, evt);
|
||||
handleWebSocketClose(ws, sessionId, 1006, "websocket error");
|
||||
handleWebSocketClose(ws as any, 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,5 +1,4 @@
|
||||
import { Hono } from "hono";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { getSession, incrementEpoch, touchSession, updateSessionStatus } from "../../services/session";
|
||||
import {
|
||||
automationStatesEqual,
|
||||
@@ -8,6 +7,7 @@ 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: randomUUID(),
|
||||
id: uuid(),
|
||||
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: randomUUID(),
|
||||
id: uuid(),
|
||||
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 = randomUUID();
|
||||
const eventId = uuid();
|
||||
|
||||
const normalized = normalizePayload(type, payload);
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { v4 as uuid } from "uuid";
|
||||
|
||||
// ---------- Types ----------
|
||||
|
||||
@@ -110,7 +110,7 @@ export function storeCreateEnvironment(req: {
|
||||
username?: string;
|
||||
capabilities?: Record<string, unknown>;
|
||||
}): EnvironmentRecord {
|
||||
const id = `env_${randomUUID().replace(/-/g, "")}`;
|
||||
const id = `env_${uuid().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_"}${randomUUID().replace(/-/g, "")}`;
|
||||
const id = `${req.idPrefix || "session_"}${uuid().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_${randomUUID().replace(/-/g, "")}`;
|
||||
const id = `work_${uuid().replace(/-/g, "")}`;
|
||||
const now = new Date();
|
||||
const record: WorkItemRecord = {
|
||||
id,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { WSContext } from "hono/ws";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { v4 as uuid } from "uuid";
|
||||
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_${randomUUID().replace(/-/g, "").slice(0, 12)}`;
|
||||
const channelGroupId = (msg.channel_group_id as string) || `group_${uuid().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_${randomUUID().replace(/-/g, "").slice(0, 12)}`;
|
||||
const channelGroupId = record.bridgeId || `group_${uuid().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: randomUUID(),
|
||||
id: uuid(),
|
||||
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: randomUUID(),
|
||||
id: uuid(),
|
||||
sessionId: entry.channelGroupId,
|
||||
type: "agent_disconnect",
|
||||
payload: { agentId: entry.agentId },
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
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,25 +14,23 @@ 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 the URL fragment so it is not sent in HTTP requests.
|
||||
// Get token from URL query param (for pre-filled URLs from server)
|
||||
function getTokenFromUrl(): string | undefined {
|
||||
try {
|
||||
const url = new URL(window.location.href);
|
||||
const hashParams = new URLSearchParams(url.hash.replace(/^#/, ""));
|
||||
return hashParams.get("token") || undefined;
|
||||
return url.searchParams.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);
|
||||
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")) {
|
||||
// Only infer if we have a token param (indicates user came from server-printed URL)
|
||||
if (!url.searchParams.has("token")) {
|
||||
return undefined;
|
||||
}
|
||||
const protocol = url.protocol === "https:" ? "wss:" : "ws:";
|
||||
@@ -42,23 +40,6 @@ 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 };
|
||||
@@ -138,12 +119,6 @@ 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,7 +28,6 @@ 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) => {
|
||||
@@ -42,11 +41,15 @@ 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()
|
||||
@@ -60,10 +63,8 @@ describe("getUuid", () => {
|
||||
|
||||
test("generates and stores new UUID when none exists", () => {
|
||||
const uuid = getUuid();
|
||||
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);
|
||||
expect(uuid).toBe("test-uuid-12345678");
|
||||
expect(store["rcs_uuid"]).toBe("test-uuid-12345678");
|
||||
});
|
||||
|
||||
test("returns same UUID on subsequent calls", () => {
|
||||
@@ -126,21 +127,6 @@ 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" };
|
||||
@@ -155,18 +141,3 @@ 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 { afterEach, describe, test, expect } from "bun:test";
|
||||
import { describe, test, expect } from "bun:test";
|
||||
|
||||
const {
|
||||
formatTime,
|
||||
@@ -10,33 +10,6 @@ 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()
|
||||
// =============================================================================
|
||||
@@ -149,42 +122,10 @@ describe("truncate", () => {
|
||||
// =============================================================================
|
||||
|
||||
describe("generateMessageUuid", () => {
|
||||
test("returns an RFC 4122 v4 UUID", () => {
|
||||
test("returns a non-empty string", () => {
|
||||
const uuid = generateMessageUuid();
|
||||
expect(typeof uuid).toBe("string");
|
||||
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");
|
||||
expect(uuid.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -20,19 +20,6 @@ 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.
|
||||
@@ -289,12 +276,14 @@ export class ACPClient {
|
||||
this.connectReject = reject;
|
||||
|
||||
try {
|
||||
const ws = new WebSocket(
|
||||
this.settings.proxyUrl,
|
||||
this.settings.token
|
||||
? [encodeWebSocketAuthProtocol(this.settings.token)]
|
||||
: undefined,
|
||||
);
|
||||
// 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);
|
||||
this.ws = ws;
|
||||
|
||||
ws.onopen = () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ACPClient } from "./client";
|
||||
import type { ACPSettings } from "./types";
|
||||
import { getActiveApiToken } from "../api/client";
|
||||
import { getUuid } from "../api/client";
|
||||
|
||||
/**
|
||||
* Build the RCS relay WebSocket URL for a given agent.
|
||||
@@ -8,7 +8,8 @@ import { getActiveApiToken } from "../api/client";
|
||||
*/
|
||||
export function buildRelayUrl(agentId: string): string {
|
||||
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
return `${protocol}//${window.location.host}/acp/relay/${agentId}`;
|
||||
const uuid = getUuid();
|
||||
return `${protocol}//${window.location.host}/acp/relay/${agentId}?uuid=${encodeURIComponent(uuid)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -18,9 +19,6 @@ export function buildRelayUrl(agentId: string): string {
|
||||
*/
|
||||
export function createRelayClient(agentId: string): ACPClient {
|
||||
const relayUrl = buildRelayUrl(agentId);
|
||||
const token = getActiveApiToken();
|
||||
const settings: ACPSettings = token
|
||||
? { proxyUrl: relayUrl, token }
|
||||
: { proxyUrl: relayUrl };
|
||||
const settings: ACPSettings = { proxyUrl: relayUrl };
|
||||
return new ACPClient(settings);
|
||||
}
|
||||
|
||||
@@ -549,7 +549,7 @@ export interface SessionModelState {
|
||||
// Settings
|
||||
export interface ACPSettings {
|
||||
proxyUrl: string;
|
||||
/** Auth token for remote access (sent via WebSocket subprotocol) */
|
||||
/** Auth token for remote access (passed as ?token=xxx query param) */
|
||||
token?: string;
|
||||
/** Working directory for the agent session */
|
||||
cwd?: string;
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
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 = generateMessageUuid();
|
||||
uuid = generateUuid();
|
||||
localStorage.setItem("rcs_uuid", uuid);
|
||||
}
|
||||
return uuid;
|
||||
@@ -34,9 +42,17 @@ async function api<T>(method: string, path: string, body?: unknown): Promise<T>
|
||||
headers["Authorization"] = `Bearer ${_activeToken}`;
|
||||
}
|
||||
|
||||
const uuid = getUuid();
|
||||
const sep = path.includes("?") ? "&" : "?";
|
||||
const url = `${BASE}${path}${sep}uuid=${encodeURIComponent(uuid)}`;
|
||||
// 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 opts: RequestInit = { method, headers };
|
||||
if (body !== undefined) opts.body = JSON.stringify(body);
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { SetStateAction } from "react";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import {
|
||||
apiFetchSession,
|
||||
apiFetchSessionHistory,
|
||||
@@ -8,7 +9,6 @@ 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: generateMessageUuid(),
|
||||
uuid: uuidv4(),
|
||||
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: generateMessageUuid(),
|
||||
uuid: uuidv4(),
|
||||
content: text,
|
||||
message: { content: text },
|
||||
}),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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));
|
||||
@@ -41,31 +42,8 @@ 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 {
|
||||
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);
|
||||
return uuidv4();
|
||||
}
|
||||
|
||||
export function extractEventText(payload: Record<string, unknown> | null | undefined): string {
|
||||
|
||||
@@ -9,39 +9,14 @@
|
||||
*/
|
||||
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdirSync } from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
if (process.env.CLAUDE_CODE_SKIP_CHROME_MCP_SETUP === "1") {
|
||||
process.exit(0);
|
||||
}
|
||||
import { dirname, join } from "node:path";
|
||||
|
||||
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" });
|
||||
@@ -53,8 +28,6 @@ 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 * 2 ** (attempt - 1))
|
||||
const delay = addJitter(baseDelayMs * Math.pow(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 * 2 ** (this.reconnectAttempts - 1),
|
||||
RECONNECT_BASE_DELAY_MS * Math.pow(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 * 2 ** (attempt - 1),
|
||||
POST_BASE_DELAY_MS * Math.pow(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 * 2 ** (this.reconnectAttempts - 1),
|
||||
DEFAULT_BASE_RECONNECT_DELAY * Math.pow(2, this.reconnectAttempts - 1),
|
||||
DEFAULT_MAX_RECONNECT_DELAY,
|
||||
)
|
||||
// Add ±25% jitter to avoid thundering herd
|
||||
|
||||
@@ -10,10 +10,6 @@ import {
|
||||
getOriginalCwd,
|
||||
getSessionId,
|
||||
regenerateSessionId,
|
||||
resetCostState,
|
||||
setLastAPIRequest,
|
||||
setLastAPIRequestMessages,
|
||||
setLastClassifierRequests,
|
||||
} from '../../bootstrap/state.js'
|
||||
import type { SDKStatusMessage } from '../../entrypoints/sdk/coreTypes.js'
|
||||
import {
|
||||
@@ -148,14 +144,6 @@ export async function clearConversation({
|
||||
// tracking) is retained so those agents keep functioning.
|
||||
clearSessionCaches(preservedAgentIds)
|
||||
|
||||
// Clear large STATE-held data that outlives the message array.
|
||||
// lastAPIRequestMessages can hold the full post-compaction conversation
|
||||
// (hundreds of KB–MB) for /share; resetCostState clears modelUsage.
|
||||
setLastAPIRequest(null)
|
||||
setLastAPIRequestMessages(null)
|
||||
setLastClassifierRequests(null)
|
||||
resetCostState()
|
||||
|
||||
setCwd(getOriginalCwd())
|
||||
readFileState.clear()
|
||||
discoveredSkillNames?.clear()
|
||||
|
||||
@@ -61,7 +61,7 @@ function IDEScreen({
|
||||
} else if (value === 'None' && shouldShowDisableAutoConnectDialog()) {
|
||||
setShowDisableAutoConnectDialog(true)
|
||||
} else {
|
||||
onSelect(availableIDEs.find(ide => ide.port === parseInt(value, 10)))
|
||||
onSelect(availableIDEs.find(ide => ide.port === parseInt(value)))
|
||||
}
|
||||
},
|
||||
[availableIDEs, onSelect],
|
||||
@@ -216,7 +216,7 @@ function IDEOpenSelection({
|
||||
const handleSelectIDE = useCallback(
|
||||
(value: string) => {
|
||||
const selectedIDE = availableIDEs.find(
|
||||
ide => ide.port === parseInt(value, 10),
|
||||
ide => ide.port === parseInt(value),
|
||||
)
|
||||
onSelectIDE(selectedIDE)
|
||||
},
|
||||
|
||||
@@ -78,7 +78,7 @@ function ModelPickerWrapper({
|
||||
}
|
||||
|
||||
// Turn off fast mode if switching to unsupported model
|
||||
let wasFastModeToggledOn
|
||||
let wasFastModeToggledOn = undefined
|
||||
if (isFastModeEnabled()) {
|
||||
clearFastModeCooldown()
|
||||
if (!isFastModeSupportedByModel(model) && isFastMode) {
|
||||
@@ -214,7 +214,7 @@ function SetModelAndClose({
|
||||
}))
|
||||
let message = `Set model to ${chalk.bold(renderModelLabel(modelValue))}`
|
||||
|
||||
let wasFastModeToggledOn
|
||||
let wasFastModeToggledOn = undefined
|
||||
if (isFastModeEnabled()) {
|
||||
clearFastModeCooldown()
|
||||
if (!isFastModeSupportedByModel(modelValue) && isFastMode) {
|
||||
|
||||
@@ -133,6 +133,7 @@ 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,6 +190,7 @@ 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,6 +204,7 @@ 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,6 +13,7 @@ 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,6 +879,7 @@ function computeDiffStatsBetweenMessages(
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -169,6 +169,7 @@ 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,17 +254,18 @@ 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,13 +258,14 @@ 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()
|
||||
@@ -301,7 +302,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
|
||||
@@ -310,13 +311,14 @@ 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 = 0.5 ** (gap / WHEEL_DECAY_HALFLIFE_MS)
|
||||
const m = Math.pow(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 = 0.5 ** (gap / WHEEL_DECAY_HALFLIFE_MS)
|
||||
const m = Math.pow(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,6 +370,7 @@ 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
|
||||
@@ -383,6 +384,7 @@ 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(), 10) || 0
|
||||
const count = parseInt(commitsStr.trim()) || 0
|
||||
setCommitCount(count)
|
||||
|
||||
// If no changes and no commits, clean up silently
|
||||
@@ -94,6 +94,7 @@ 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.indexOf(currentColor),
|
||||
COLOR_OPTIONS.findIndex(opt => opt === 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
|
||||
let isAuthenticated: boolean | undefined = undefined
|
||||
|
||||
if (isSSE || isHTTP) {
|
||||
const authProvider = new ClaudeAuthProvider(
|
||||
|
||||
@@ -88,7 +88,7 @@ export function MCPToolListView({
|
||||
<Select
|
||||
options={toolOptions}
|
||||
onChange={value => {
|
||||
const index = parseInt(value, 10)
|
||||
const index = parseInt(value)
|
||||
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,6 +77,7 @@ 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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2, 2026-04-25
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Run Claude Code
|
||||
id: claude
|
||||
uses: anthropics/claude-code-action@567fe954a4527e81f132d87d1bdbcc94f7737434 # v1, 2026-04-25
|
||||
uses: anthropics/claude-code-action@v1
|
||||
with:
|
||||
anthropic_api_key: \${{ secrets.ANTHROPIC_API_KEY }}
|
||||
|
||||
@@ -126,13 +126,13 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2, 2026-04-25
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Run Claude Code Review
|
||||
id: claude-review
|
||||
uses: anthropics/claude-code-action@567fe954a4527e81f132d87d1bdbcc94f7737434 # v1, 2026-04-25
|
||||
uses: anthropics/claude-code-action@v1
|
||||
with:
|
||||
anthropic_api_key: \${{ secrets.ANTHROPIC_API_KEY }}
|
||||
plugin_marketplaces: 'https://github.com/anthropics/claude-code.git'
|
||||
|
||||
@@ -80,6 +80,7 @@ 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
|
||||
}
|
||||
@@ -100,6 +101,7 @@ 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
|
||||
let renderedPlaceholder: string | undefined = 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
|
||||
let pendingLoadModeFilter: HistoryMode | undefined = 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,6 +202,7 @@ 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,13 +236,14 @@ 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)
|
||||
: ''
|
||||
|
||||
@@ -415,9 +416,10 @@ 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,6 +1758,7 @@ 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`",
|
||||
@@ -1825,6 +1826,7 @@ 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.",
|
||||
@@ -2458,6 +2460,7 @@ 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.`,
|
||||
);
|
||||
@@ -2742,6 +2745,7 @@ async function run(): Promise<CommanderCommand> {
|
||||
|
||||
// Print any warnings from initialization
|
||||
warnings.forEach((warning) => {
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.error(warning);
|
||||
});
|
||||
|
||||
@@ -2803,6 +2807,7 @@ 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);
|
||||
}
|
||||
@@ -2810,6 +2815,7 @@ 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.`,
|
||||
);
|
||||
@@ -2822,6 +2828,7 @@ 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.`,
|
||||
);
|
||||
@@ -2835,6 +2842,7 @@ 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.`,
|
||||
);
|
||||
@@ -6053,6 +6061,7 @@ 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
|
||||
|
||||
for (let i = 0; i < readyCount; i++) {
|
||||
outer: 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,6 +277,7 @@ 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!
|
||||
}
|
||||
|
||||
@@ -330,7 +331,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
|
||||
let taskBudgetRemaining: number | undefined = undefined
|
||||
|
||||
// Snapshot immutable env/statsig/session state once at entry. See QueryConfig
|
||||
// for what's included and why feature() gates are intentionally excluded.
|
||||
|
||||
@@ -639,7 +639,6 @@ 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
|
||||
@@ -653,14 +652,14 @@ function TranscriptSearchBar({
|
||||
setIndexStatus(null);
|
||||
} else {
|
||||
setIndexStatus({ ms });
|
||||
hideTimeout = setTimeout(() => alive && setIndexStatus(null), 2000);
|
||||
setTimeout(() => alive && setIndexStatus(null), 2000);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
if (hideTimeout) clearTimeout(hideTimeout);
|
||||
};
|
||||
}, [jumpRef]); // mount-only per stable search bar ref
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []); // mount-only: bar opens once per /
|
||||
// Gate the query effect on warm completion. setHighlight stays instant
|
||||
// (screen-space overlay, no indexing). setSearchQuery (the scan) waits.
|
||||
const warmDone = indexStatus !== 'building';
|
||||
@@ -668,7 +667,8 @@ function TranscriptSearchBar({
|
||||
if (!warmDone) return;
|
||||
jumpRef.current?.setSearchQuery(query);
|
||||
setHighlight(query);
|
||||
}, [jumpRef, query, setHighlight, warmDone]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [query, warmDone]);
|
||||
const off = cursorOffset;
|
||||
const cursorChar = off < query.length ? query[off] : ' ';
|
||||
return (
|
||||
@@ -3051,22 +3051,12 @@ export function REPL({
|
||||
// are O(n) per render, so drop everything before the previous
|
||||
// boundary to keep n bounded across multi-day sessions.
|
||||
if (isFullscreenEnvEnabled()) {
|
||||
setMessages(old => {
|
||||
const postBoundary = getMessagesAfterCompactBoundary(old, {
|
||||
setMessages(old => [
|
||||
...getMessagesAfterCompactBoundary(old, {
|
||||
includeSnipped: true,
|
||||
})
|
||||
// Hard cap: keep at most 500 messages in fullscreen scrollback
|
||||
// to prevent unbounded memory growth in multi-day sessions.
|
||||
// normalizeMessages/applyGrouping are O(n), and Ink fiber
|
||||
// trees cost ~250KB RSS per message. Without this cap,
|
||||
// scrollback after several compactions can reach thousands
|
||||
// of messages (observed: 13k+, 1GB+ heap).
|
||||
const MAX_FULLSCREEN_SCROLLBACK = 500
|
||||
const kept = postBoundary.length > MAX_FULLSCREEN_SCROLLBACK
|
||||
? postBoundary.slice(-MAX_FULLSCREEN_SCROLLBACK)
|
||||
: postBoundary
|
||||
return [...kept, newMessage]
|
||||
});
|
||||
}),
|
||||
newMessage,
|
||||
]);
|
||||
} else {
|
||||
setMessages(() => [newMessage]);
|
||||
}
|
||||
@@ -3092,23 +3082,17 @@ export function REPL({
|
||||
// history). Replacing those leaves the AgentTool UI stuck at
|
||||
// "Initializing…" because it renders the full progress trail.
|
||||
setMessages(oldMessages => {
|
||||
const last = oldMessages.at(-1);
|
||||
const lastData = last?.data as Record<string, unknown> | undefined;
|
||||
const newData = newMessage.data as Record<string, unknown>;
|
||||
// Scan backwards to find the last ephemeral progress with matching
|
||||
// parentToolUseID and type. Previously only checked the last message,
|
||||
// so interleaved non-ephemeral messages caused duplicate progress
|
||||
// entries to accumulate (observed 13k+ entries in sleep-heavy sessions).
|
||||
for (let i = oldMessages.length - 1; i >= 0; i--) {
|
||||
const m = oldMessages[i]!
|
||||
if (m.type !== 'progress') break
|
||||
const mData = m.data as Record<string, unknown> | undefined
|
||||
if (
|
||||
m.parentToolUseID === newMessage.parentToolUseID &&
|
||||
mData?.type === newData.type
|
||||
) {
|
||||
const copy = oldMessages.slice();
|
||||
copy[i] = newMessage;
|
||||
return copy;
|
||||
}
|
||||
if (
|
||||
last?.type === 'progress' &&
|
||||
last.parentToolUseID === newMessage.parentToolUseID &&
|
||||
lastData?.type === newData.type
|
||||
) {
|
||||
const copy = oldMessages.slice();
|
||||
copy[copy.length - 1] = newMessage;
|
||||
return copy;
|
||||
}
|
||||
return [...oldMessages, newMessage];
|
||||
});
|
||||
@@ -5008,19 +4992,16 @@ export function REPL({
|
||||
}
|
||||
}, [queuedCommands]);
|
||||
|
||||
const onInitRef = useRef(onInit);
|
||||
onInitRef.current = onInit;
|
||||
const diagnosticTrackerRef = useRef(diagnosticTracker);
|
||||
diagnosticTrackerRef.current = diagnosticTracker;
|
||||
|
||||
// Initial load
|
||||
useEffect(() => {
|
||||
void onInitRef.current();
|
||||
void onInit();
|
||||
|
||||
// Cleanup on unmount
|
||||
return () => {
|
||||
void diagnosticTrackerRef.current.shutdown();
|
||||
void diagnosticTracker.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.hasOwn(variables, key)
|
||||
Object.prototype.hasOwnProperty.call(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.hasOwn(variables, key)
|
||||
Object.prototype.hasOwnProperty.call(variables, key)
|
||||
? variables[key]!
|
||||
: match,
|
||||
)
|
||||
|
||||
@@ -4,74 +4,56 @@ 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. Preserve real exports before partial module mocking
|
||||
// in the same worker. safeMockModule snapshots real exports before 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 mockModulePreservingExports(
|
||||
tsPath: string,
|
||||
overrides: Record<string, unknown>,
|
||||
) {
|
||||
function safeMockModule(tsPath: string, overrides: Record<string, unknown>) {
|
||||
const jsPath = tsPath.replace(/\.ts$/, '.js')
|
||||
const snapshot = { ...(require(tsPath) as Record<string, unknown>) }
|
||||
const real = require(tsPath)
|
||||
const snapshot = { ...real }
|
||||
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) {})
|
||||
|
||||
mockModulePreservingExports('../../../QueryEngine.ts', {
|
||||
// Fully synthetic — no real module to snapshot, so plain mock.module suffices.
|
||||
mock.module('../../../QueryEngine.js', () => ({
|
||||
QueryEngine: class MockQueryEngine {
|
||||
submitMessage = mockSubmitMessage
|
||||
submitMessage = mock(async function* () {})
|
||||
interrupt = mock(() => {})
|
||||
resetAbortController = mock(() => {})
|
||||
getAbortSignal = mock(() => new AbortController().signal)
|
||||
setModel = mockSetModel
|
||||
},
|
||||
})
|
||||
}))
|
||||
|
||||
mockModulePreservingExports('../../../tools.ts', {
|
||||
safeMockModule('../../../tools.ts', {
|
||||
getTools: mock(() => []),
|
||||
})
|
||||
|
||||
mockModulePreservingExports('../../../Tool.ts', {
|
||||
safeMockModule('../../../Tool.ts', {
|
||||
toolMatchesName: mock(() => false),
|
||||
findToolByName: mock(() => undefined),
|
||||
filterToolProgressMessages: mock(() => []),
|
||||
buildTool: mock((def: any) => def),
|
||||
})
|
||||
|
||||
mockModulePreservingExports('../../../utils/config.ts', {
|
||||
safeMockModule('../../../utils/config.ts', {
|
||||
enableConfigs: mock(() => {}),
|
||||
})
|
||||
|
||||
mockModulePreservingExports('../../../bootstrap/state.ts', {
|
||||
safeMockModule('../../../bootstrap/state.ts', {
|
||||
setOriginalCwd: mock(() => {}),
|
||||
addSlowOperation: mock(() => {}),
|
||||
})
|
||||
@@ -93,16 +75,24 @@ const mockGetDefaultAppState = mock(() => ({
|
||||
mainLoopModelForSession: null,
|
||||
}))
|
||||
|
||||
mockModulePreservingExports('../../../state/AppStateStore.ts', {
|
||||
safeMockModule('../../../state/AppStateStore.ts', {
|
||||
getDefaultAppState: mockGetDefaultAppState,
|
||||
})
|
||||
|
||||
mockModulePreservingExports('../utils.ts', {
|
||||
// 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'),
|
||||
computeSessionFingerprint: mock(() => '{}'),
|
||||
sanitizeTitle: mock((s: string) => s),
|
||||
})
|
||||
|
||||
mockModulePreservingExports('../bridge.ts', {
|
||||
safeMockModule('../bridge.ts', {
|
||||
forwardSessionUpdates: mock(async () => ({
|
||||
stopReason: 'end_turn' as const,
|
||||
})),
|
||||
@@ -115,38 +105,33 @@ mockModulePreservingExports('../bridge.ts', {
|
||||
})),
|
||||
})
|
||||
|
||||
mockModulePreservingExports('../../../utils/listSessionsImpl.ts', {
|
||||
safeMockModule('../../../utils/listSessionsImpl.ts', {
|
||||
listSessionsImpl: mock(async () => []),
|
||||
})
|
||||
|
||||
const mockGetMainLoopModel = mock(() => 'claude-sonnet-4-6')
|
||||
|
||||
mockModulePreservingExports('../../../utils/model/model.ts', {
|
||||
safeMockModule('../../../utils/model/model.ts', {
|
||||
getMainLoopModel: mockGetMainLoopModel,
|
||||
})
|
||||
|
||||
mockModulePreservingExports('../../../utils/model/modelOptions.ts', {
|
||||
safeMockModule('../../../utils/model/modelOptions.ts', {
|
||||
getModelOptions: mock(() => []),
|
||||
})
|
||||
|
||||
const mockApplySafeEnvVars = mock(() => {})
|
||||
mockModulePreservingExports('../../../utils/managedEnv.ts', {
|
||||
safeMockModule('../../../utils/managedEnv.ts', {
|
||||
applySafeConfigEnvironmentVariables: mockApplySafeEnvVars,
|
||||
})
|
||||
|
||||
const mockGetSettings = mock(() => ({}))
|
||||
mockModulePreservingExports('../../../utils/settings/settings.ts', {
|
||||
getSettings_DEPRECATED: mockGetSettings,
|
||||
})
|
||||
|
||||
const mockDeserializeMessages = mock((msgs: unknown[]) => msgs)
|
||||
mockModulePreservingExports('../../../utils/conversationRecovery.ts', {
|
||||
safeMockModule('../../../utils/conversationRecovery.ts', {
|
||||
deserializeMessages: mockDeserializeMessages,
|
||||
})
|
||||
|
||||
const mockGetLastSessionLog = mock(async () => null)
|
||||
const mockSessionIdExists = mock(() => false)
|
||||
mockModulePreservingExports('../../../utils/sessionStorage.ts', {
|
||||
safeMockModule('../../../utils/sessionStorage.ts', {
|
||||
getLastSessionLog: mockGetLastSessionLog,
|
||||
sessionIdExists: mockSessionIdExists,
|
||||
})
|
||||
@@ -176,7 +161,7 @@ const mockGetCommands = mock(async () => [
|
||||
},
|
||||
])
|
||||
|
||||
mockModulePreservingExports('../../../commands.ts', {
|
||||
safeMockModule('../../../commands.ts', {
|
||||
getCommands: mockGetCommands,
|
||||
})
|
||||
|
||||
@@ -196,48 +181,16 @@ 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', () => {
|
||||
@@ -302,13 +255,6 @@ 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)
|
||||
@@ -344,99 +290,6 @@ 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', () => {
|
||||
@@ -522,7 +375,7 @@ describe('AcpAgent', () => {
|
||||
expect(res2.stopReason).toBe('end_turn')
|
||||
})
|
||||
|
||||
test('propagates unexpected prompt errors', async () => {
|
||||
test('returns end_turn on unexpected error', async () => {
|
||||
const agent = new AcpAgent(makeConn())
|
||||
const { sessionId } = await agent.newSession({ cwd: '/tmp' } as any)
|
||||
;(
|
||||
@@ -530,13 +383,16 @@ describe('AcpAgent', () => {
|
||||
).mockImplementationOnce(async () => {
|
||||
throw new Error('unexpected')
|
||||
})
|
||||
|
||||
await expect(
|
||||
agent.prompt({
|
||||
const errorSpy = spyOn(console, 'error').mockImplementation(() => {})
|
||||
try {
|
||||
const res = await agent.prompt({
|
||||
sessionId,
|
||||
prompt: [{ type: 'text', text: 'hello' }],
|
||||
} as any),
|
||||
).rejects.toThrow('unexpected')
|
||||
} as any)
|
||||
expect(res.stopReason).toBe('end_turn')
|
||||
} finally {
|
||||
errorSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
test('returns usage from forwardSessionUpdates', async () => {
|
||||
@@ -820,28 +676,15 @@ describe('AcpAgent', () => {
|
||||
).rejects.toThrow('Session not found')
|
||||
})
|
||||
|
||||
test('availableModes excludes bypassPermissions without a local ACP bypass gate', async () => {
|
||||
test('availableModes includes bypassPermissions when not root', 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).not.toContain('bypassPermissions')
|
||||
expect(modeIds).toContain('bypassPermissions')
|
||||
})
|
||||
|
||||
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'
|
||||
test('can switch to bypassPermissions mode', async () => {
|
||||
const agent = new AcpAgent(makeConn())
|
||||
const { sessionId } = await agent.newSession({ cwd: '/tmp' } as any)
|
||||
await agent.setSessionMode({
|
||||
@@ -854,21 +697,6 @@ 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', () => {
|
||||
@@ -895,24 +723,6 @@ 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', () => {
|
||||
@@ -948,94 +758,6 @@ 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)
|
||||
@@ -1065,46 +787,6 @@ 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,7 +5,6 @@ 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'
|
||||
@@ -337,20 +336,6 @@ 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', () => {
|
||||
@@ -724,87 +709,6 @@ 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[] = [
|
||||
@@ -861,7 +765,6 @@ describe('forwardSessionUpdates', () => {
|
||||
|
||||
test('forwards tool_use block as tool_call', async () => {
|
||||
const conn = makeConn()
|
||||
const input = { command: 'ls' }
|
||||
const msgs: SDKMessage[] = [
|
||||
{
|
||||
type: 'assistant',
|
||||
@@ -871,7 +774,7 @@ describe('forwardSessionUpdates', () => {
|
||||
type: 'tool_use',
|
||||
id: 'tu_1',
|
||||
name: 'Bash',
|
||||
input,
|
||||
input: { command: 'ls' },
|
||||
},
|
||||
],
|
||||
role: 'assistant',
|
||||
@@ -891,8 +794,6 @@ 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,306 +1,144 @@
|
||||
import { afterAll, beforeEach, describe, expect, mock, spyOn, test } from 'bun:test'
|
||||
import { describe, expect, test, mock } from 'bun:test'
|
||||
import type { AgentSideConnection } from '@agentclientprotocol/sdk'
|
||||
import type { Tool as ToolType, ToolUseContext } from '../../../Tool.js'
|
||||
import type { AssistantMessage } from '../../../types/message.js'
|
||||
import type { Tool as ToolType } from '../../../Tool.js'
|
||||
|
||||
const askDecision = {
|
||||
behavior: 'ask',
|
||||
message: 'approval required',
|
||||
decisionReason: { type: 'mode', mode: 'default' },
|
||||
} as const
|
||||
// ── 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 hasPermissionsMock = mock(async (): Promise<unknown> => askDecision)
|
||||
const toolInfoMock = mock(() => ({
|
||||
title: 'Bash',
|
||||
kind: 'execute',
|
||||
content: [],
|
||||
locations: [],
|
||||
}))
|
||||
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 permissionsModuleSnapshot = {
|
||||
...(require('../../../utils/permissions/permissions.ts') as Record<
|
||||
string,
|
||||
unknown
|
||||
>),
|
||||
}
|
||||
const bridgeModuleSnapshot = {
|
||||
...(require('../bridge.ts') as Record<string, unknown>),
|
||||
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' }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
function makeConn(permissionResponse: Record<string, unknown>) {
|
||||
return {
|
||||
requestPermission: mock(async () => permissionResponse),
|
||||
sessionUpdate: mock(async () => {}),
|
||||
} as unknown as AgentSideConnection
|
||||
}
|
||||
|
||||
function makeTool(name: string): ToolType {
|
||||
function makeTool(name: string) {
|
||||
return { name } as unknown as ToolType
|
||||
}
|
||||
|
||||
const dummyContext = {} as unknown as ToolUseContext
|
||||
const dummyMsg = {} as unknown as AssistantMessage
|
||||
const dummyContext = {} as Record<string, unknown>
|
||||
const dummyMsg = {} as Record<string, unknown>
|
||||
|
||||
describe('createAcpCanUseTool', () => {
|
||||
beforeEach(() => {
|
||||
hasPermissionsMock.mockReset()
|
||||
hasPermissionsMock.mockResolvedValue(askDecision)
|
||||
toolInfoMock.mockClear()
|
||||
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')
|
||||
})
|
||||
|
||||
test('returns pipeline allow without client delegation', async () => {
|
||||
const conn = makeConn()
|
||||
const input = { command: 'ls' }
|
||||
hasPermissionsMock.mockResolvedValueOnce({
|
||||
behavior: 'allow',
|
||||
updatedInput: input,
|
||||
})
|
||||
|
||||
test('returns deny when client selects reject option', async () => {
|
||||
const conn = makeConn({ outcome: { outcome: 'selected', optionId: 'reject' } })
|
||||
const canUseTool = createAcpCanUseTool(conn, 'sess-1', () => 'default')
|
||||
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',
|
||||
)
|
||||
|
||||
const result = await canUseTool(makeTool('Bash'), {}, dummyContext as any, dummyMsg as any, 'tu_2')
|
||||
expect(result.behavior).toBe('deny')
|
||||
expect(
|
||||
(conn.requestPermission as ReturnType<typeof mock>).mock.calls,
|
||||
).toHaveLength(0)
|
||||
})
|
||||
|
||||
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' }
|
||||
test('returns deny when client cancels', async () => {
|
||||
const conn = makeConn({ outcome: { outcome: 'cancelled' } })
|
||||
const canUseTool = createAcpCanUseTool(conn, 'sess-1', () => 'default')
|
||||
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',
|
||||
)
|
||||
const result = await canUseTool(makeTool('Read'), { file_path: '/tmp/x' }, dummyContext as any, dummyMsg as any, 'tu_3')
|
||||
expect(result.behavior).toBe('deny')
|
||||
})
|
||||
|
||||
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 () => {
|
||||
test('returns deny when requestPermission throws', 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 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()
|
||||
}
|
||||
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')
|
||||
})
|
||||
|
||||
test('options include allow always, allow once, and reject once', async () => {
|
||||
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 () => {
|
||||
const conn = makeConn({ outcome: { outcome: 'cancelled' } })
|
||||
const canUseTool = createAcpCanUseTool(conn, 'sess-3', () => 'default')
|
||||
await canUseTool(makeTool('Write'), {}, dummyContext, dummyMsg, 'tu_8')
|
||||
|
||||
const { options } = (conn.requestPermission as ReturnType<typeof mock>).mock
|
||||
.calls[0][0] as Record<string, unknown>
|
||||
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>
|
||||
const opts = options as Array<Record<string, unknown>>
|
||||
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)
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
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,6 +33,7 @@ import type {
|
||||
SetSessionModelResponse,
|
||||
SetSessionConfigOptionRequest,
|
||||
SetSessionConfigOptionResponse,
|
||||
ContentBlock,
|
||||
ClientCapabilities,
|
||||
SessionModeState,
|
||||
SessionModelState,
|
||||
@@ -62,39 +63,31 @@ 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, PendingPrompt>
|
||||
pendingQueue: string[]
|
||||
pendingQueueHead: number
|
||||
pendingMessages: Map<string, { resolve: (cancelled: boolean) => void; order: number }>
|
||||
nextPendingOrder: number
|
||||
toolUseCache: ToolUseCache
|
||||
clientCapabilities?: ClientCapabilities
|
||||
appState: AppState
|
||||
commands: Command[]
|
||||
}
|
||||
|
||||
type PendingPrompt = {
|
||||
resolve: (cancelled: boolean) => void
|
||||
}
|
||||
|
||||
// ── Agent class ───────────────────────────────────────────────────
|
||||
|
||||
export class AcpAgent implements Agent {
|
||||
@@ -164,9 +157,7 @@ export class AcpAgent implements Agent {
|
||||
// ── newSession ────────────────────────────────────────────────
|
||||
|
||||
async newSession(params: NewSessionRequest): Promise<NewSessionResponse> {
|
||||
const result = await this.createSession(params)
|
||||
this.scheduleAvailableCommandsUpdate(result.sessionId)
|
||||
return result
|
||||
return this.createSession(params)
|
||||
}
|
||||
|
||||
// ── resumeSession ──────────────────────────────────────────────
|
||||
@@ -175,7 +166,9 @@ export class AcpAgent implements Agent {
|
||||
params: ResumeSessionRequest,
|
||||
): Promise<ResumeSessionResponse> {
|
||||
const result = await this.getOrCreateSession(params)
|
||||
this.scheduleAvailableCommandsUpdate(result.sessionId)
|
||||
setTimeout(() => {
|
||||
this.sendAvailableCommandsUpdate(params.sessionId)
|
||||
}, 0)
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -183,7 +176,9 @@ export class AcpAgent implements Agent {
|
||||
|
||||
async loadSession(params: LoadSessionRequest): Promise<LoadSessionResponse> {
|
||||
const result = await this.getOrCreateSession(params)
|
||||
this.scheduleAvailableCommandsUpdate(result.sessionId)
|
||||
setTimeout(() => {
|
||||
this.sendAvailableCommandsUpdate(params.sessionId)
|
||||
}, 0)
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -221,7 +216,9 @@ export class AcpAgent implements Agent {
|
||||
_meta: params._meta,
|
||||
},
|
||||
)
|
||||
this.scheduleAvailableCommandsUpdate(response.sessionId)
|
||||
setTimeout(() => {
|
||||
this.sendAvailableCommandsUpdate(response.sessionId)
|
||||
}, 0)
|
||||
return response
|
||||
}
|
||||
|
||||
@@ -246,6 +243,9 @@ 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,27 +253,18 @@ 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.pendingQueue.push(promptUuid)
|
||||
session.pendingMessages.set(promptUuid, { resolve })
|
||||
session.pendingMessages.set(promptUuid, { resolve, order })
|
||||
})
|
||||
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 {
|
||||
@@ -333,15 +324,19 @@ export class AcpAgent implements Agent {
|
||||
)
|
||||
}
|
||||
|
||||
throw err
|
||||
console.error('[ACP] prompt error:', err)
|
||||
return { stopReason: 'end_turn' }
|
||||
} finally {
|
||||
session.promptRunning = false
|
||||
// Resolve next pending prompt if any
|
||||
const nextPrompt = popNextPendingPrompt(session)
|
||||
if (nextPrompt) {
|
||||
session.promptRunning = true
|
||||
nextPrompt.resolve(false)
|
||||
} else {
|
||||
session.promptRunning = false
|
||||
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])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -354,15 +349,12 @@ 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()
|
||||
@@ -387,7 +379,7 @@ export class AcpAgent implements Agent {
|
||||
|
||||
async unstable_setSessionModel(
|
||||
params: SetSessionModelRequest,
|
||||
): Promise<SetSessionModelResponse> {
|
||||
): Promise<SetSessionModelResponse | void> {
|
||||
const session = this.sessions.get(params.sessionId)
|
||||
if (!session) {
|
||||
throw new Error('Session not found')
|
||||
@@ -396,7 +388,6 @@ 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 ──────────────────────────────────────
|
||||
@@ -458,32 +449,23 @@ 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 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,
|
||||
// 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'),
|
||||
)
|
||||
console.log('[ACP Agent] Resolved permissionMode:', permissionMode)
|
||||
|
||||
// Create the permission bridge canUseTool function
|
||||
const canUseTool = createAcpCanUseTool(
|
||||
@@ -493,15 +475,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
|
||||
|
||||
// ACP clients can expose bypass only when both the process and local config allow it.
|
||||
const isBypassAvailable = isAcpBypassPermissionModeAvailable(settingsPermissionMode)
|
||||
// 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
|
||||
|
||||
// Create a mutable AppState for the session
|
||||
const appState: AppState = {
|
||||
@@ -537,7 +519,7 @@ export class AcpAgent implements Agent {
|
||||
|
||||
const queryEngine = new QueryEngine(engineConfig)
|
||||
|
||||
// Build modes — bypassPermissions is opt-in for ACP clients.
|
||||
// Build modes — bypassPermissions only available when not running as root (or in sandbox)
|
||||
const availableModes = [
|
||||
{ id: 'default', name: 'Default', description: 'Standard behavior, prompts for dangerous operations' },
|
||||
{ id: 'acceptEdits', name: 'Accept Edits', description: 'Auto-accept file edit operations' },
|
||||
@@ -575,15 +557,13 @@ export class AcpAgent implements Agent {
|
||||
const session: AcpSession = {
|
||||
queryEngine,
|
||||
cancelled: false,
|
||||
cancelGeneration: 0,
|
||||
cwd,
|
||||
modes,
|
||||
models,
|
||||
configOptions,
|
||||
promptRunning: false,
|
||||
pendingMessages: new Map(),
|
||||
pendingQueue: [],
|
||||
pendingQueueHead: 0,
|
||||
nextPendingOrder: 0,
|
||||
toolUseCache: {},
|
||||
clientCapabilities: this.clientCapabilities,
|
||||
appState,
|
||||
@@ -596,17 +576,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: {
|
||||
@@ -692,22 +672,12 @@ export class AcpAgent implements Agent {
|
||||
}
|
||||
|
||||
private applySessionMode(sessionId: string, modeId: string): void {
|
||||
if (!isPermissionMode(modeId)) {
|
||||
const validModes = ['auto', 'default', 'acceptEdits', 'bypassPermissions', 'dontAsk', 'plan']
|
||||
if (!validModes.includes(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 = {
|
||||
@@ -780,160 +750,38 @@ 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 {
|
||||
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
|
||||
// Simplified: read from environment or return undefined
|
||||
// In a full implementation, this would read from settings.json
|
||||
return undefined as T | undefined
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────
|
||||
|
||||
const permissionModeIds: readonly PermissionMode[] = [
|
||||
'auto',
|
||||
'default',
|
||||
'acceptEdits',
|
||||
'bypassPermissions',
|
||||
'dontAsk',
|
||||
'plan',
|
||||
]
|
||||
/** Extract prompt text from ACP ContentBlock array for QueryEngine input */
|
||||
function promptToQueryInput(
|
||||
prompt: Array<ContentBlock> | undefined,
|
||||
): string {
|
||||
if (!prompt || prompt.length === 0) return ''
|
||||
|
||||
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.',
|
||||
)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
// Ignore image and other types for text-based prompt
|
||||
}
|
||||
return parts.join('\n')
|
||||
}
|
||||
|
||||
function buildConfigOptions(
|
||||
|
||||
@@ -514,25 +514,28 @@ export function toolUpdateFromEditToolResponse(toolResponse: unknown): {
|
||||
return result
|
||||
}
|
||||
|
||||
function nextSdkMessageOrAbort(
|
||||
sdkMessages: AsyncGenerator<SDKMessage, void, unknown>,
|
||||
abortSignal: AbortSignal,
|
||||
): Promise<IteratorResult<SDKMessage, void>> {
|
||||
if (abortSignal.aborted) {
|
||||
return Promise.resolve({ done: true, value: undefined })
|
||||
}
|
||||
// ── Prompt conversion ─────────────────────────────────────────────
|
||||
|
||||
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)
|
||||
}
|
||||
})
|
||||
/**
|
||||
* 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')
|
||||
}
|
||||
|
||||
// ── Main forwarding function ──────────────────────────────────────
|
||||
@@ -570,7 +573,17 @@ 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 nextSdkMessageOrAbort(sdkMessages, abortSignal)
|
||||
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 })
|
||||
}),
|
||||
])
|
||||
if (nextResult.done || abortSignal.aborted) break
|
||||
const msg = nextResult.value
|
||||
|
||||
@@ -1046,7 +1059,12 @@ function toAcpNotifications(
|
||||
}
|
||||
} else {
|
||||
// Regular tool call
|
||||
const rawInput = toolInput ? { ...toolInput } : {}
|
||||
let rawInput: Record<string, unknown> | undefined
|
||||
try {
|
||||
rawInput = JSON.parse(JSON.stringify(toolInput ?? {}))
|
||||
} catch {
|
||||
// Ignore parse failures
|
||||
}
|
||||
|
||||
if (alreadyCached) {
|
||||
// Second encounter — send as tool_call_update
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user