mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-17 05:45:51 +00:00
Compare commits
17 Commits
v1.1.0.0
...
codex/code
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f3f8c9339b | ||
|
|
3305da0d49 | ||
|
|
2c7131cea6 | ||
|
|
5ad3b316d5 | ||
|
|
bc72dc2b09 | ||
|
|
52b61c2c06 | ||
|
|
3cb4828de6 | ||
|
|
f5c3ee5b5d | ||
|
|
c2ac9a74c1 | ||
|
|
fc438bd222 | ||
|
|
4591432a1d | ||
|
|
901628b4d9 | ||
|
|
cf33c06021 | ||
|
|
e0ca1d054c | ||
|
|
6585d0f67c | ||
|
|
e4403ff010 | ||
|
|
9e61e7a90d |
26
.github/workflows/ci.yml
vendored
26
.github/workflows/ci.yml
vendored
@@ -6,18 +6,29 @@ on:
|
|||||||
pull_request:
|
pull_request:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
ci:
|
ci:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2, 2026-04-25
|
||||||
|
env:
|
||||||
|
GIT_CONFIG_COUNT: 2
|
||||||
|
GIT_CONFIG_KEY_0: init.defaultBranch
|
||||||
|
GIT_CONFIG_VALUE_0: main
|
||||||
|
GIT_CONFIG_KEY_1: advice.defaultBranchName
|
||||||
|
GIT_CONFIG_VALUE_1: "false"
|
||||||
|
|
||||||
- uses: oven-sh/setup-bun@v2
|
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2, 2026-04-25
|
||||||
with:
|
with:
|
||||||
bun-version: latest
|
bun-version: latest
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
|
env:
|
||||||
|
CLAUDE_CODE_SKIP_CHROME_MCP_SETUP: "1"
|
||||||
run: bun install --frozen-lockfile
|
run: bun install --frozen-lockfile
|
||||||
|
|
||||||
- name: Type check
|
- name: Type check
|
||||||
@@ -26,12 +37,17 @@ jobs:
|
|||||||
- name: Test with Coverage
|
- name: Test with Coverage
|
||||||
run: |
|
run: |
|
||||||
set -o pipefail
|
set -o pipefail
|
||||||
bun test --coverage --coverage-reporter=lcov 2>&1 | grep -vE '^\s*(\(pass\)|\(skip\))' | sed '/^.*\/__tests__\/.*:$/d' | cat -s
|
bun test --coverage --coverage-reporter lcov --coverage-dir coverage 2>&1 | grep -vE '^\s*(\(pass\)|\(skip\))' | sed '/^.*\/__tests__\/.*:$/d' | cat -s
|
||||||
|
test -s coverage/lcov.info
|
||||||
|
grep -q '^SF:' coverage/lcov.info
|
||||||
|
|
||||||
- name: Upload coverage to Codecov
|
- name: Upload coverage to Codecov
|
||||||
uses: codecov/codecov-action@v5
|
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }}
|
||||||
|
uses: codecov/codecov-action@75cd11691c0faa626561e295848008c8a7dddffe # v5, 2026-04-25
|
||||||
with:
|
with:
|
||||||
file: ./coverage/lcov.info
|
fail_ci_if_error: true
|
||||||
|
files: ./coverage/lcov.info
|
||||||
|
disable_search: true
|
||||||
token: ${{ secrets.CODECOV_TOKEN }}
|
token: ${{ secrets.CODECOV_TOKEN }}
|
||||||
|
|
||||||
- name: Build
|
- name: Build
|
||||||
|
|||||||
8
.github/workflows/publish-npm.yml
vendored
8
.github/workflows/publish-npm.yml
vendored
@@ -20,17 +20,17 @@ jobs:
|
|||||||
publish:
|
publish:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2, 2026-04-25
|
||||||
with:
|
with:
|
||||||
ref: ${{ github.event.inputs.version || github.ref }}
|
ref: ${{ github.event.inputs.version || github.ref }}
|
||||||
|
|
||||||
- uses: actions/setup-node@v6
|
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6, 2026-04-25
|
||||||
with:
|
with:
|
||||||
node-version: "24"
|
node-version: "24"
|
||||||
registry-url: "https://registry.npmjs.org"
|
registry-url: "https://registry.npmjs.org"
|
||||||
|
|
||||||
- name: Setup Bun
|
- name: Setup Bun
|
||||||
uses: oven-sh/setup-bun@v2
|
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2, 2026-04-25
|
||||||
with:
|
with:
|
||||||
bun-version: latest
|
bun-version: latest
|
||||||
|
|
||||||
@@ -66,7 +66,7 @@ jobs:
|
|||||||
} >> "$GITHUB_OUTPUT"
|
} >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
- name: Create GitHub Release
|
- name: Create GitHub Release
|
||||||
uses: softprops/action-gh-release@v2
|
uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2, 2026-04-25
|
||||||
with:
|
with:
|
||||||
name: ${{ github.event.inputs.version || github.ref_name }}
|
name: ${{ github.event.inputs.version || github.ref_name }}
|
||||||
body: |
|
body: |
|
||||||
|
|||||||
8
.github/workflows/release-rcs.yml
vendored
8
.github/workflows/release-rcs.yml
vendored
@@ -17,17 +17,17 @@ jobs:
|
|||||||
packages: write
|
packages: write
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2, 2026-04-25
|
||||||
|
|
||||||
- name: Login to GHCR
|
- name: Login to GHCR
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3, 2026-04-25
|
||||||
with:
|
with:
|
||||||
registry: ${{ env.REGISTRY }}
|
registry: ${{ env.REGISTRY }}
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3, 2026-04-25
|
||||||
|
|
||||||
- name: Extract version
|
- name: Extract version
|
||||||
id: version
|
id: version
|
||||||
@@ -47,7 +47,7 @@ jobs:
|
|||||||
echo "tags=$TAGS" >> "$GITHUB_OUTPUT"
|
echo "tags=$TAGS" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
- name: Build Docker image
|
- name: Build Docker image
|
||||||
uses: docker/build-push-action@v5
|
uses: docker/build-push-action@ca052bb54ab0790a636c9b5f226502c73d547a25 # v5, 2026-04-25
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: packages/remote-control-server/Dockerfile
|
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:
|
update:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2, 2026-04-25
|
||||||
with:
|
with:
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- uses: jaywcjlove/github-action-contributors@main
|
- uses: jaywcjlove/github-action-contributors@86707f6d4c2469ce6b46bc3367253ebd41ee242c # main, 2026-04-25
|
||||||
with:
|
with:
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
output: "contributors.svg"
|
output: "contributors.svg"
|
||||||
repository: ${{ github.repository }}
|
repository: ${{ github.repository }}
|
||||||
|
|
||||||
- uses: stefanzweifel/git-auto-commit-action@v5
|
- uses: stefanzweifel/git-auto-commit-action@b863ae1933cb653a53c021fe36dbb774e1fb9403 # v5, 2026-04-25
|
||||||
with:
|
with:
|
||||||
commit_message: "docs: update contributors"
|
commit_message: "docs: update contributors"
|
||||||
file_pattern: "contributors.svg"
|
file_pattern: "contributors.svg"
|
||||||
|
|||||||
@@ -188,7 +188,7 @@ The TUI (REPL) mode requires a real terminal and cannot be launched directly via
|
|||||||
## Documentation & Links
|
## Documentation & Links
|
||||||
|
|
||||||
- **Online docs (Mintlify)**: [ccb.agent-aura.top](https://ccb.agent-aura.top/) — source in [`docs/`](docs/), PR contributions welcome
|
- **Online docs (Mintlify)**: [ccb.agent-aura.top](https://ccb.agent-aura.top/) — source in [`docs/`](docs/), PR contributions welcome
|
||||||
- **DeepWiki**: <https://deepwiki.com/claude-code-best/claude-code>
|
- **DeepWiki**: https://deepwiki.com/claude-code-best/claude-code
|
||||||
|
|
||||||
## Contributors
|
## Contributors
|
||||||
|
|
||||||
|
|||||||
@@ -99,12 +99,15 @@ ARGUMENTS
|
|||||||
|
|
||||||
## 四、认证
|
## 四、认证
|
||||||
|
|
||||||
默认启动时自动生成随机 token。客户端连接时需通过 query 参数传递:
|
默认启动时自动生成随机 token。客户端连接时不要把 token 放在 URL 中:
|
||||||
|
|
||||||
```
|
```
|
||||||
ws://localhost:9315/ws?token=<your-token>
|
ws://localhost:9315/ws
|
||||||
```
|
```
|
||||||
|
|
||||||
|
无法发送 `Authorization` header 的 WebSocket 客户端需要使用
|
||||||
|
`rcs.auth.<base64url-token>` 子协议传递 token。
|
||||||
|
|
||||||
配置固定 token:
|
配置固定 token:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -135,6 +138,9 @@ acp-link ccb-bun -- --acp
|
|||||||
1. **REST 注册**:通过 `POST /v1/environments/bridge` 向 RCS 注册环境
|
1. **REST 注册**:通过 `POST /v1/environments/bridge` 向 RCS 注册环境
|
||||||
2. **WS identify**:建立 WebSocket 连接后发送 `identify` 消息(携带 agentId),替代完整 `register`
|
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
|
acp-link RCS
|
||||||
│ │
|
│ │
|
||||||
|
|||||||
@@ -145,8 +145,8 @@ M 键(或 ← / →)用于在两种路由模式之间切换,**无需展开
|
|||||||
|
|
||||||
```
|
```
|
||||||
/pipes — 显示所有实例 + 切换选择面板
|
/pipes — 显示所有实例 + 切换选择面板
|
||||||
/pipes select <name> — 选中某实例(消息会广播到它)
|
/pipes select <name> — 选中某实例(消息会广播到它)
|
||||||
/pipes deselect <name> — 取消选中
|
/pipes deselect <name> — 取消选中
|
||||||
/pipes all — 全选
|
/pipes all — 全选
|
||||||
/pipes none — 全部取消
|
/pipes none — 全部取消
|
||||||
```
|
```
|
||||||
@@ -169,7 +169,7 @@ LAN Peers:
|
|||||||
Selected: cli-da029538
|
Selected: cli-da029538
|
||||||
```
|
```
|
||||||
|
|
||||||
### /attach <name>
|
### /attach <name>
|
||||||
|
|
||||||
手动 attach 到一个实例,使其成为你的 slave。
|
手动 attach 到一个实例,使其成为你的 slave。
|
||||||
|
|
||||||
@@ -179,7 +179,7 @@ Selected: cli-da029538
|
|||||||
|
|
||||||
attach 后,对方变为 slave,你变为 master。可以向它发送 prompt。通常不需要手动 attach——heartbeat 会自动发现并连接。
|
attach 后,对方变为 slave,你变为 master。可以向它发送 prompt。通常不需要手动 attach——heartbeat 会自动发现并连接。
|
||||||
|
|
||||||
### /detach <name>
|
### /detach <name>
|
||||||
|
|
||||||
断开与某个 slave 的连接。
|
断开与某个 slave 的连接。
|
||||||
|
|
||||||
@@ -187,7 +187,7 @@ attach 后,对方变为 slave,你变为 master。可以向它发送 prompt
|
|||||||
/detach cli-04d67950
|
/detach cli-04d67950
|
||||||
```
|
```
|
||||||
|
|
||||||
### /send <name> <message>
|
### /send <name> <message>
|
||||||
|
|
||||||
向指定 pipe 发送消息(不依赖选择状态,直接指定目标)。
|
向指定 pipe 发送消息(不依赖选择状态,直接指定目标)。
|
||||||
|
|
||||||
|
|||||||
@@ -225,6 +225,11 @@ acp-link ◄──ACP relay──► RCS ◄──Web UI WS──► 浏览器
|
|||||||
| `src/transport/acp-relay-handler.ts` | 前端 WS → acp-link 透传 + EventBus inbound 转发 |
|
| `src/transport/acp-relay-handler.ts` | 前端 WS → acp-link 透传 + EventBus inbound 转发 |
|
||||||
| `src/transport/acp-sse-writer.ts` | SSE event stream 供外部消费者订阅 |
|
| `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 文档](./acp-link.md)。
|
详见 [acp-link 文档](./acp-link.md)。
|
||||||
|
|||||||
564
docs/internals/agent-comm-fix-jira-tasks.md
Normal file
564
docs/internals/agent-comm-fix-jira-tasks.md
Normal file
@@ -0,0 +1,564 @@
|
|||||||
|
# Agent 通讯修复 Jira Task
|
||||||
|
|
||||||
|
- 版本:v1.0
|
||||||
|
- 生成日期:2026-04-25
|
||||||
|
- 来源:由按文件执行清单、Claude 交叉验证意见整理合并
|
||||||
|
- 范围:ACP Agent / Bridge / Remote Control Server / REPL Hook 生命周期
|
||||||
|
- 使用方式:这是唯一执行任务文档;每个 `JIRA-*` 小节可直接拆成一个 Jira issue,字段保持统一,便于复制或二次导入。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 方案性质
|
||||||
|
|
||||||
|
本文档是目标状态式执行方案,不是临时补丁清单。每张 ticket 必须交付明确的代码终态、测试覆盖和回归边界;不得只用局部 workaround 掩盖问题。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 执行总则
|
||||||
|
|
||||||
|
1. 先边界安全,后内部优化:先修 WS 入站大小与输入校验,避免线上风险扩大。
|
||||||
|
2. 单文件可回滚:每个文件内修改保持内聚,便于回滚与 bisect。
|
||||||
|
3. 不改协议语义,只修实现缺陷:除 `resource_link` 表达形式统一外,不改变主流程契约。
|
||||||
|
4. 每个文件必须有验收输出:要么测试用例,要么日志/指标验证。
|
||||||
|
5. 发布前必须确认协议层行为无回归:`stopReason` 决策与 `sessionUpdate` 发送顺序保持稳定。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Epic
|
||||||
|
|
||||||
|
### JIRA-EPIC-001:提升 Agent 通讯链路稳定性与边界安全
|
||||||
|
|
||||||
|
- Issue Type:Epic
|
||||||
|
- Priority:P0
|
||||||
|
- Owner:核心通讯 / 后端网关 / QA
|
||||||
|
- Scope:ACP Agent、ACP Bridge、Remote Control Server、REPL 初始化生命周期
|
||||||
|
- Goal:修复长会话资源泄漏、补齐 WebSocket 入站边界、统一 prompt 转换、收敛类型风险,并补充关键回归测试。
|
||||||
|
|
||||||
|
#### Epic 验收标准
|
||||||
|
|
||||||
|
- `bun run typecheck` 0 error。
|
||||||
|
- P0 WebSocket 超大消息拒绝逻辑已实现并覆盖测试。
|
||||||
|
- ACP bridge abort listener 生命周期无累积。
|
||||||
|
- prompt 转换实现单源化。
|
||||||
|
- settings/defaultMode 能真实影响 ACP permission mode,且 `_meta.permissionMode` 保持最高优先级。
|
||||||
|
- REPL 目标 hook suppress 清理完成,timer cleanup 完整。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## P0 Tickets
|
||||||
|
|
||||||
|
### JIRA-001:为 session ingress WebSocket 补齐消息大小限制
|
||||||
|
|
||||||
|
- Issue Type:Bug
|
||||||
|
- Priority:P0
|
||||||
|
- Story Points:3
|
||||||
|
- Owner:后端/网关
|
||||||
|
- Files:
|
||||||
|
- `packages/remote-control-server/src/routes/v1/session-ingress.ts`
|
||||||
|
- 后续票:JIRA-008(同文件 P1 类型与 decode path 收尾)
|
||||||
|
|
||||||
|
#### 参考代码位置
|
||||||
|
|
||||||
|
- `packages/remote-control-server/src/routes/v1/session-ingress.ts:100-106`
|
||||||
|
|
||||||
|
#### 背景
|
||||||
|
|
||||||
|
`session-ingress` 当前缺少 WebSocket message size limit。ACP 路由已有类似限制,两个入口边界不一致,可能导致大包占用内存或绕过入口保护。
|
||||||
|
|
||||||
|
#### 实施要求
|
||||||
|
|
||||||
|
- 新增 `MAX_WS_MESSAGE_SIZE = 10 * 1024 * 1024`,与 ACP 路由的 10MB 上限保持一致。
|
||||||
|
- 在 `onMessage` decode 后优先检查 payload size。
|
||||||
|
- 超限时执行 `ws.close(1009, "message too large")`。
|
||||||
|
- 日志记录 `sessionId`、payload size、limit。
|
||||||
|
- 对 `string`、`ArrayBuffer`、`Uint8Array` 进行统一 decode 分流。
|
||||||
|
- 非支持类型直接拒绝并记录,不进入业务 handler。
|
||||||
|
|
||||||
|
#### 验收标准
|
||||||
|
|
||||||
|
- 11MB payload 被 1009 close。
|
||||||
|
- 1KB 合法 payload 仍正常进入 handler。
|
||||||
|
- 非支持类型 payload 不进入 handler。
|
||||||
|
- 不改变 URL、auth、session 解析逻辑。
|
||||||
|
|
||||||
|
#### 回归范围
|
||||||
|
|
||||||
|
- Remote Control Server session ingress WebSocket。
|
||||||
|
- 正常会话消息转发。
|
||||||
|
- WebSocket close code 行为。
|
||||||
|
|
||||||
|
#### 风险等级
|
||||||
|
|
||||||
|
- 中。入口逻辑变更可能影响特殊客户端 payload 类型。
|
||||||
|
|
||||||
|
#### 必须验证
|
||||||
|
|
||||||
|
- 在 `packages/remote-control-server/src/__tests__/routes.test.ts` 增加 session-ingress WebSocket 大包、小包、坏类型 payload 用例。
|
||||||
|
- 运行 `bun run typecheck`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### JIRA-002:修复 ACP bridge abort listener 生命周期泄漏
|
||||||
|
|
||||||
|
- Issue Type:Bug
|
||||||
|
- Priority:P0
|
||||||
|
- Story Points:3
|
||||||
|
- Owner:核心通讯
|
||||||
|
- Files:
|
||||||
|
- `src/services/acp/bridge.ts`
|
||||||
|
|
||||||
|
#### 参考代码位置
|
||||||
|
|
||||||
|
- `src/services/acp/bridge.ts:576-585`
|
||||||
|
|
||||||
|
#### 背景
|
||||||
|
|
||||||
|
ACP bridge 的 `Promise.race` abort 分支注册 listener 后缺少完整 cleanup。长会话或高频 next 场景可能出现 listener 累积。
|
||||||
|
|
||||||
|
#### 实施要求
|
||||||
|
|
||||||
|
- 将 abort race 改为可清理监听器写法。
|
||||||
|
- 注册 listener 后保留 handler 引用。
|
||||||
|
- `sdkMessages.next()` 先返回时必须 `removeEventListener`。
|
||||||
|
- abort、throw、return 等路径都在 `finally` 中清理。
|
||||||
|
- 不改变 `stopReason` 决策逻辑。
|
||||||
|
- 不改变 `sessionUpdate` 发送顺序。
|
||||||
|
|
||||||
|
#### 验收标准
|
||||||
|
|
||||||
|
- 模拟 10k 次 next 且不 abort,listener 不增长。
|
||||||
|
- abort 场景仍返回 `cancelled`。
|
||||||
|
- 原有 streaming/session update 行为无回归。
|
||||||
|
|
||||||
|
#### 回归范围
|
||||||
|
|
||||||
|
- ACP bridge streaming loop。
|
||||||
|
- 用户取消请求。
|
||||||
|
- SDK generator 异常路径。
|
||||||
|
|
||||||
|
#### 风险等级
|
||||||
|
|
||||||
|
- 中。异步控制流变更需要覆盖取消与异常路径。
|
||||||
|
|
||||||
|
#### 必须验证
|
||||||
|
|
||||||
|
- 新增 listener cleanup 单元测试。
|
||||||
|
- 运行 `bun run typecheck`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## P1 Tickets
|
||||||
|
|
||||||
|
### JIRA-003:优化 ACP agent pending prompt 队列为 O(1) 出队
|
||||||
|
|
||||||
|
- Issue Type:Task
|
||||||
|
- Priority:P1
|
||||||
|
- Story Points:5
|
||||||
|
- Owner:核心通讯
|
||||||
|
- Files:
|
||||||
|
- `src/services/acp/agent.ts`
|
||||||
|
|
||||||
|
#### 参考代码位置
|
||||||
|
|
||||||
|
- `src/services/acp/agent.ts:332-339`
|
||||||
|
|
||||||
|
#### 背景
|
||||||
|
|
||||||
|
当前 pending prompt 队列使用 `Map + sort` 获取下一项,排队量上升时会带来不必要的排序成本。
|
||||||
|
|
||||||
|
#### 实施要求
|
||||||
|
|
||||||
|
- 改为 `queue: string[]` + `pendingMap: Map<string, PendingPrompt>` 组合。
|
||||||
|
- 入队执行 `queue.push(id)` 与 `pendingMap.set(id, prompt)`。
|
||||||
|
- 出队从队首惰性跳过已取消项。
|
||||||
|
- 取消只从 `pendingMap` 删除,不做数组中间删除。
|
||||||
|
- 保持现有取消语义和出队顺序。
|
||||||
|
|
||||||
|
#### 验收标准
|
||||||
|
|
||||||
|
- 1000 pending prompt 场景下出队顺序正确。
|
||||||
|
- 已取消 prompt 不会被 resolve。
|
||||||
|
- 出队不再依赖全量 sort。
|
||||||
|
- 1000 排队场景下出队耗时低于旧实现;测试记录旧实现复杂度风险和新实现 O(1) 出队路径。
|
||||||
|
- 行为与旧实现兼容。
|
||||||
|
|
||||||
|
#### 回归范围
|
||||||
|
|
||||||
|
- ACP prompt queue。
|
||||||
|
- 并发 prompt 请求。
|
||||||
|
- prompt cancel / resolve 边界。
|
||||||
|
|
||||||
|
#### 风险等级
|
||||||
|
|
||||||
|
- 中。队列结构变更可能引入取消边界问题。
|
||||||
|
|
||||||
|
#### 必须验证
|
||||||
|
|
||||||
|
- 新增 queue 顺序与取消测试。
|
||||||
|
- 对 1000 prompt 场景做性能断言或日志记录。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### JIRA-004:接入真实 settings 读取并校验 ACP permission mode
|
||||||
|
|
||||||
|
- Issue Type:Bug
|
||||||
|
- Priority:P1
|
||||||
|
- Story Points:3
|
||||||
|
- Owner:核心通讯
|
||||||
|
- Files:
|
||||||
|
- `src/services/acp/agent.ts`
|
||||||
|
|
||||||
|
#### 参考代码位置
|
||||||
|
|
||||||
|
- `src/services/acp/agent.ts:465-467`
|
||||||
|
|
||||||
|
#### 背景
|
||||||
|
|
||||||
|
`getSetting()` 当前未真正接入项目配置,导致默认 permission mode 配置无法按预期生效。
|
||||||
|
|
||||||
|
#### 实施要求
|
||||||
|
|
||||||
|
- 接入项目现有 settings/config 读取逻辑。
|
||||||
|
- 仅接受合法 permission mode 枚举值。
|
||||||
|
- 非法值 fallback 到 `default`。
|
||||||
|
- `_meta.permissionMode` 继续保持最高优先级。
|
||||||
|
- 不改变外部协议字段。
|
||||||
|
|
||||||
|
#### 验收标准
|
||||||
|
|
||||||
|
- settings/defaultMode 能影响默认 permission mode。
|
||||||
|
- `_meta.permissionMode` 能覆盖 settings。
|
||||||
|
- 非法 settings 值不会传播到运行时。
|
||||||
|
- 类型检查通过。
|
||||||
|
|
||||||
|
#### 回归范围
|
||||||
|
|
||||||
|
- ACP agent session 初始化。
|
||||||
|
- 权限模式同步。
|
||||||
|
- 客户端 `_meta` 覆盖逻辑。
|
||||||
|
|
||||||
|
#### 风险等级
|
||||||
|
|
||||||
|
- 中。配置优先级错误会影响权限行为。
|
||||||
|
|
||||||
|
#### 必须验证
|
||||||
|
|
||||||
|
- 新增 defaultMode / `_meta.permissionMode` 优先级测试。
|
||||||
|
- 运行 `bun run typecheck`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### JIRA-005:单源化 ACP prompt 转换逻辑
|
||||||
|
|
||||||
|
- Issue Type:Refactor
|
||||||
|
- Priority:P1
|
||||||
|
- Story Points:5
|
||||||
|
- Owner:核心通讯
|
||||||
|
- Files:
|
||||||
|
- `src/services/acp/agent.ts`
|
||||||
|
- `src/services/acp/bridge.ts`
|
||||||
|
- `src/services/acp/promptConversion.ts`(新增)
|
||||||
|
|
||||||
|
#### 参考代码位置
|
||||||
|
|
||||||
|
- `src/services/acp/agent.ts:754-758`
|
||||||
|
- `src/services/acp/agent.ts:764-785`
|
||||||
|
- `src/services/acp/bridge.ts:522-537`
|
||||||
|
|
||||||
|
#### 背景
|
||||||
|
|
||||||
|
ACP agent 与 bridge 存在重复 prompt 转换逻辑,`resource_link` 等 block 的输出策略容易分叉。
|
||||||
|
|
||||||
|
#### 实施要求
|
||||||
|
|
||||||
|
- 新增共享转换模块 `src/services/acp/promptConversion.ts`。
|
||||||
|
- `agent.ts` 与 `bridge.ts` 改为调用共享转换函数。
|
||||||
|
- 删除 `bridge.ts` 中 `promptToQueryContent` 的真实实现;如导出仍需保留,则只允许保留调用共享函数的 wrapper。
|
||||||
|
- `resource_link` 输出改为稳定纯文本元信息,禁止 markdown link。
|
||||||
|
- 保持其他 block 转换语义不变。
|
||||||
|
|
||||||
|
#### 验收标准
|
||||||
|
|
||||||
|
- 全仓库仅保留一个真实 prompt 转换实现。
|
||||||
|
- 相同 input block 在 agent/bridge 输出一致。
|
||||||
|
- `resource_link` 不再输出 `[name](uri)` 形式。
|
||||||
|
- 相关测试覆盖转换一致性。
|
||||||
|
|
||||||
|
#### 回归范围
|
||||||
|
|
||||||
|
- ACP prompt input。
|
||||||
|
- bridge query content。
|
||||||
|
- resource link prompt 表达。
|
||||||
|
|
||||||
|
#### 风险等级
|
||||||
|
|
||||||
|
- 中。文本格式变化可能影响下游 prompt 快照或断言。
|
||||||
|
|
||||||
|
#### 必须验证
|
||||||
|
|
||||||
|
- 新增 shared conversion 单元测试。
|
||||||
|
- 全仓库搜索重复转换函数。
|
||||||
|
- 运行 `bun run typecheck`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### JIRA-006:治理 REPL onInit effect 依赖并补齐 timer cleanup
|
||||||
|
|
||||||
|
- Issue Type:Task
|
||||||
|
- Priority:P1
|
||||||
|
- Story Points:3
|
||||||
|
- Owner:终端 UI
|
||||||
|
- Files:
|
||||||
|
- `src/screens/REPL.tsx`
|
||||||
|
|
||||||
|
#### 参考代码位置
|
||||||
|
|
||||||
|
- `src/screens/REPL.tsx:654-662`
|
||||||
|
- `src/screens/REPL.tsx:4996-5005`
|
||||||
|
|
||||||
|
#### 背景
|
||||||
|
|
||||||
|
REPL 中目标初始化 effect 存在 hook dependency suppress,warm-up timer 也需要显式 cleanup,避免频繁挂载/卸载时留下悬挂任务。
|
||||||
|
|
||||||
|
#### 实施要求
|
||||||
|
|
||||||
|
- 整理 `onInit` 生命周期,使用稳定引用或 effect 内联。
|
||||||
|
- 移除目标段 `exhaustive-deps` suppress。
|
||||||
|
- 保持 unmount cleanup 行为不变。
|
||||||
|
- warm-up effect 中记录 timeout id。
|
||||||
|
- cleanup 中执行 `clearTimeout(timeoutId)`。
|
||||||
|
- 保留 `alive` 判定作为并发保护。
|
||||||
|
|
||||||
|
#### 验收标准
|
||||||
|
|
||||||
|
- 目标段不再需要 hooks lint suppress。
|
||||||
|
- 高频打开/关闭搜索栏无悬挂 timer 增长。
|
||||||
|
- REPL 初始化行为无回归。
|
||||||
|
|
||||||
|
#### 回归范围
|
||||||
|
|
||||||
|
- REPL 初始化。
|
||||||
|
- 搜索栏 warm-up。
|
||||||
|
- 组件卸载 cleanup。
|
||||||
|
|
||||||
|
#### 风险等级
|
||||||
|
|
||||||
|
- 中。React effect 依赖治理可能改变初始化时机。
|
||||||
|
|
||||||
|
#### 必须验证
|
||||||
|
|
||||||
|
- 运行 lint/typecheck。
|
||||||
|
- 手动或测试覆盖 REPL mount/unmount。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### JIRA-007:收敛 ACP route WebSocket 事件 any 类型
|
||||||
|
|
||||||
|
- Issue Type:Task
|
||||||
|
- Priority:P1
|
||||||
|
- Story Points:2
|
||||||
|
- Owner:后端/网关
|
||||||
|
- Files:
|
||||||
|
- `packages/remote-control-server/src/routes/acp/index.ts`
|
||||||
|
|
||||||
|
#### 参考代码位置
|
||||||
|
|
||||||
|
- `packages/remote-control-server/src/routes/acp/index.ts:108-146`
|
||||||
|
|
||||||
|
#### 背景
|
||||||
|
|
||||||
|
ACP route 中 WebSocket 事件和 socket 参数存在 `any`,降低编译期保护。
|
||||||
|
|
||||||
|
#### 实施要求
|
||||||
|
|
||||||
|
- 定义最小 WebSocket 事件类型:open/message/close/error。
|
||||||
|
- 将 `_evt: any`、`evt: any`、`ws: any` 替换为窄类型。
|
||||||
|
- 不改变 payload decode 与大小检查策略。
|
||||||
|
- 不改变现有 handler 行为。
|
||||||
|
|
||||||
|
#### 验收标准
|
||||||
|
|
||||||
|
- 编译期能捕获错误事件字段访问。
|
||||||
|
- 现有 WebSocket 行为不变。
|
||||||
|
- `bun run typecheck` 通过。
|
||||||
|
|
||||||
|
#### 回归范围
|
||||||
|
|
||||||
|
- ACP WebSocket route。
|
||||||
|
- message decode。
|
||||||
|
- close/error handler。
|
||||||
|
|
||||||
|
#### 风险等级
|
||||||
|
|
||||||
|
- 低。类型收敛为主。
|
||||||
|
|
||||||
|
#### 必须验证
|
||||||
|
|
||||||
|
- 运行 `bun run typecheck`。
|
||||||
|
- 保留现有测试通过。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### JIRA-008:收敛 session ingress WebSocket 事件类型与 decode path
|
||||||
|
|
||||||
|
- Issue Type:Task
|
||||||
|
- Priority:P1
|
||||||
|
- Story Points:3
|
||||||
|
- Owner:后端/网关
|
||||||
|
- Files:
|
||||||
|
- `packages/remote-control-server/src/routes/v1/session-ingress.ts`
|
||||||
|
- 前置依赖:JIRA-001 已合并
|
||||||
|
|
||||||
|
#### 参考代码位置
|
||||||
|
|
||||||
|
- `packages/remote-control-server/src/routes/v1/session-ingress.ts:100-106`
|
||||||
|
|
||||||
|
#### 背景
|
||||||
|
|
||||||
|
在完成 P0 size guard 后,session ingress 仍需要进一步收敛事件类型与 decode path,减少隐式类型风险。
|
||||||
|
|
||||||
|
#### 实施要求
|
||||||
|
|
||||||
|
- 定义或复用最小 WebSocket message event 类型。
|
||||||
|
- 将 message decode 分支集中到一个小函数。
|
||||||
|
- 保持 P0 size guard 与 close code 语义。
|
||||||
|
- 不改变 auth/session 解析。
|
||||||
|
|
||||||
|
#### 验收标准
|
||||||
|
|
||||||
|
- decode path 单一清晰。
|
||||||
|
- 不支持 payload 类型有明确拒绝路径。
|
||||||
|
- `bun run typecheck` 通过。
|
||||||
|
|
||||||
|
#### 回归范围
|
||||||
|
|
||||||
|
- Session ingress WebSocket message handling。
|
||||||
|
- P0 大包拒绝逻辑。
|
||||||
|
|
||||||
|
#### 风险等级
|
||||||
|
|
||||||
|
- 低到中。与 P0 同文件,注意避免重复改动冲突。
|
||||||
|
|
||||||
|
#### 必须验证
|
||||||
|
|
||||||
|
- 与 JIRA-001 同批测试。
|
||||||
|
- 运行 `bun run typecheck`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## QA Tickets
|
||||||
|
|
||||||
|
### JIRA-009:补充 ACP 通讯回归测试
|
||||||
|
|
||||||
|
- Issue Type:Test
|
||||||
|
- Priority:P1
|
||||||
|
- Story Points:5
|
||||||
|
- Owner:QA/核心通讯
|
||||||
|
- Files:
|
||||||
|
- `src/services/acp/agent.ts`
|
||||||
|
- `src/services/acp/bridge.ts`
|
||||||
|
- `src/services/acp/promptConversion.ts`
|
||||||
|
- `src/services/acp/__tests__/agent.test.ts`
|
||||||
|
- `src/services/acp/__tests__/bridge.test.ts`
|
||||||
|
- `src/services/acp/__tests__/promptConversion.test.ts`
|
||||||
|
|
||||||
|
#### 覆盖场景
|
||||||
|
|
||||||
|
- 长会话 10k turn,无 abort listener 累积。
|
||||||
|
- prompt queue 1000 并发排队,取消/出队顺序正确。
|
||||||
|
- settings/defaultMode 与 `_meta.permissionMode` 优先级正确。
|
||||||
|
- `resource_link` 转换在 agent 与 bridge 输出一致。
|
||||||
|
|
||||||
|
#### 验收标准
|
||||||
|
|
||||||
|
- 新增测试在本地稳定通过。
|
||||||
|
- 不依赖真实网络或外部服务。
|
||||||
|
- 测试 mock 遵守仓库规范,只 mock 有副作用链路。
|
||||||
|
|
||||||
|
#### 回归范围
|
||||||
|
|
||||||
|
- ACP bridge。
|
||||||
|
- ACP agent。
|
||||||
|
- prompt conversion。
|
||||||
|
- permission mode resolution。
|
||||||
|
|
||||||
|
#### 风险等级
|
||||||
|
|
||||||
|
- 中。异步测试可能有稳定性问题,需要避免时间敏感断言。
|
||||||
|
|
||||||
|
#### 必须验证
|
||||||
|
|
||||||
|
- 运行相关 `bun test`。
|
||||||
|
- 运行 `bun run typecheck`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### JIRA-010:补充 Remote Control Server WebSocket 入站回归测试
|
||||||
|
|
||||||
|
- Issue Type:Test
|
||||||
|
- Priority:P1
|
||||||
|
- Story Points:3
|
||||||
|
- Owner:QA/后端
|
||||||
|
- Files:
|
||||||
|
- `packages/remote-control-server/src/__tests__/routes.test.ts`
|
||||||
|
- `packages/remote-control-server/src/routes/v1/session-ingress.ts`
|
||||||
|
|
||||||
|
#### 覆盖场景
|
||||||
|
|
||||||
|
- 11MB session ingress payload 被 1009 close(与 10MB 上限对齐)。
|
||||||
|
- 合法小 payload 正常进入 handler。
|
||||||
|
- 非支持 payload 类型被拒绝。
|
||||||
|
- 日志或可观测输出包含 sessionId、payload size、limit。
|
||||||
|
|
||||||
|
#### 验收标准
|
||||||
|
|
||||||
|
- 11MB payload 被 1009 close(与 10MB 上限对齐)。
|
||||||
|
- 新增测试稳定通过。
|
||||||
|
- 不启动真实外部服务。
|
||||||
|
- 不改变现有 route public contract。
|
||||||
|
|
||||||
|
#### 回归范围
|
||||||
|
|
||||||
|
- RCS session ingress route。
|
||||||
|
- WebSocket message handling。
|
||||||
|
- close code 行为。
|
||||||
|
|
||||||
|
#### 风险等级
|
||||||
|
|
||||||
|
- 中。测试需要适配现有 WebSocket/mock 基础设施。
|
||||||
|
|
||||||
|
#### 必须验证
|
||||||
|
|
||||||
|
- 运行 RCS package 相关测试。
|
||||||
|
- 运行 `bun run typecheck`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 推荐执行顺序
|
||||||
|
|
||||||
|
执行节奏与原计划保持一致:先完成 P0 全部改动和冒烟验证,再启动 P1 改造;测试票可穿插执行,但不得绕过 P0 gate。
|
||||||
|
|
||||||
|
1. JIRA-001:先封入口大包风险。
|
||||||
|
2. JIRA-002:修长会话 listener 生命周期。
|
||||||
|
3. JIRA-010:补 RCS 入站测试,锁住 P0 行为。
|
||||||
|
4. JIRA-003:优化 pending prompt queue。
|
||||||
|
5. JIRA-004:接入 settings/defaultMode。
|
||||||
|
6. JIRA-005:单源化 prompt 转换。
|
||||||
|
7. JIRA-009:补 ACP 回归测试。
|
||||||
|
8. JIRA-006:治理 REPL effect/timer。
|
||||||
|
9. JIRA-007:收敛 ACP route 类型。
|
||||||
|
10. JIRA-008:收敛 session ingress 类型与 decode path。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Release Checklist
|
||||||
|
|
||||||
|
- [ ] `bun run typecheck` 0 error
|
||||||
|
- [ ] P0 tickets 已合并并测试通过
|
||||||
|
- [ ] ACP 回归测试通过
|
||||||
|
- [ ] RCS WebSocket 入站测试通过
|
||||||
|
- [ ] prompt conversion 单源化已通过代码搜索确认
|
||||||
|
- [ ] permission mode 优先级测试通过
|
||||||
|
- [ ] 协议层行为无回归(stopReason 决策、sessionUpdate 发送顺序)
|
||||||
|
- [ ] REPL hook/timer 改动通过 lint/typecheck
|
||||||
|
- [ ] 最终变更说明包含风险与未覆盖项
|
||||||
74
docs/internals/agent-comm-fix-questions.md
Normal file
74
docs/internals/agent-comm-fix-questions.md
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
# Agent 通讯修复问题文档
|
||||||
|
|
||||||
|
- 版本:v1.0
|
||||||
|
- 生成日期:2026-04-25
|
||||||
|
- 范围:ACP Agent / Bridge / Remote Control Server / REPL Hook 生命周期
|
||||||
|
- 配套执行文档:`docs/internals/agent-comm-fix-jira-tasks.md`
|
||||||
|
- 目的:保留决策前要问的问题、交叉验证提示词和已确认结论;不要在这里写 Jira 执行步骤。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 当前已确认结论
|
||||||
|
|
||||||
|
- 只保留两份交付文档:本问题文档 + Jira Task 文档。
|
||||||
|
- Jira Task 文档是唯一执行入口,包含 Owner、优先级、文件范围、验收标准、风险和验证建议。
|
||||||
|
- Claude 交叉验证结论:整体通过,无 blocking findings;建议补充协议回归 gate、JIRA-001/008 依赖、代码参考位置和阈值一致性,这些建议已合并到 Jira Task 文档。
|
||||||
|
- 本次已进入业务代码修复阶段,必须运行 `bun run typecheck` 和相关回归测试。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 执行前必须问清的问题
|
||||||
|
|
||||||
|
1. `session-ingress` 的 WebSocket 上限是否固定为 10MB,并与 ACP route 保持一致?
|
||||||
|
2. 超限 close code 是否统一使用 `1009`,close reason 是否固定为 `message too large`?
|
||||||
|
3. `resource_link` 的纯文本格式是否已有下游依赖,能否替代当前 markdown link 表达?
|
||||||
|
4. ACP permission mode 的真实 settings key 是哪个,非法值 fallback 是否统一为 `default`?
|
||||||
|
5. `_meta.permissionMode` 是否必须始终覆盖 settings/defaultMode?
|
||||||
|
6. abort listener 测试中,是否能通过 mock signal 或计数器稳定证明 10k next 后无 listener 累积?
|
||||||
|
7. pending prompt queue 的取消语义是否允许惰性清理,而不是立刻从数组中删除?
|
||||||
|
8. REPL hook suppress 的清理范围是否只限目标段,不顺手改其他 decompiled React Compiler 结构?
|
||||||
|
9. RCS WebSocket 测试应放在现有哪个 `__tests__` 布局下,是否已有 route/mock 基础设施可复用?
|
||||||
|
10. 发布 gate 是否必须包含 `stopReason` 决策与 `sessionUpdate` 发送顺序不回归?
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 给 Claude 或 Reviewer 的复核问题
|
||||||
|
|
||||||
|
```text
|
||||||
|
请作为外部审查者,复核 docs/internals/agent-comm-fix-jira-tasks.md。
|
||||||
|
|
||||||
|
请检查:
|
||||||
|
1. 是否仍满足“按文件分工的执行清单”和“Jira task 文档”要求。
|
||||||
|
2. 是否存在遗漏的文件、验收标准、风险或前置依赖。
|
||||||
|
3. 是否有重复、误导执行者、优先级不合理或测试不可落地的问题。
|
||||||
|
4. 是否还有必须阻断实施的 finding。
|
||||||
|
|
||||||
|
请用中文输出:
|
||||||
|
- Verdict
|
||||||
|
- Blocking Findings
|
||||||
|
- Non-blocking Findings
|
||||||
|
- Suggested Edits
|
||||||
|
- Final Recommendation
|
||||||
|
|
||||||
|
不要修改文件,只输出审查意见。
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 已处理的复核建议
|
||||||
|
|
||||||
|
- Release Checklist 已补充协议层行为无回归 gate。
|
||||||
|
- JIRA-001 与 JIRA-008 已明确同文件前后置关系。
|
||||||
|
- JIRA-001 到 JIRA-008 已补充参考代码位置。
|
||||||
|
- JIRA-003 已补回 1000 排队场景下的出队耗时验收。
|
||||||
|
- JIRA-008 story points 已从 2 调整为 3。
|
||||||
|
- JIRA-010 已明确 11MB payload 对齐 10MB 上限并触发 1009 close。
|
||||||
|
- 推荐执行顺序已明确 P0 gate:P0 全部改动和冒烟验证完成后,再启动 P1 改造。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 不在本文档维护的内容
|
||||||
|
|
||||||
|
- 不维护 Jira ticket 正文;统一在 `docs/internals/agent-comm-fix-jira-tasks.md` 修改。
|
||||||
|
- 不维护业务代码实现方案;实现时按具体 ticket 读取对应文件。
|
||||||
|
- 不维护历史中间稿;旧执行清单已合并进 Jira Task 文档。
|
||||||
@@ -200,9 +200,9 @@ LSP 服务器通过插件提供。插件的 `manifest.json` 中可以声明 LSP
|
|||||||
|------|------|------|------|
|
|------|------|------|------|
|
||||||
| `command` | string | 是 | LSP 服务器可执行命令(不含空格) |
|
| `command` | string | 是 | LSP 服务器可执行命令(不含空格) |
|
||||||
| `args` | string[] | 否 | 命令行参数 |
|
| `args` | string[] | 否 | 命令行参数 |
|
||||||
| `extensionToLanguage` | Record<string, string> | 是 | 文件扩展名到语言 ID 的映射(至少一个) |
|
| `extensionToLanguage` | `Record<string, string>` | 是 | 文件扩展名到语言 ID 的映射(至少一个) |
|
||||||
| `transport` | `"stdio"` \| `"socket"` | 否 | 通信方式,默认 `stdio` |
|
| `transport` | `"stdio"` \| `"socket"` | 否 | 通信方式,默认 `stdio` |
|
||||||
| `env` | Record<string, string> | 否 | 启动服务器时设置的环境变量 |
|
| `env` | `Record<string, string>` | 否 | 启动服务器时设置的环境变量 |
|
||||||
| `initializationOptions` | unknown | 否 | 传给服务器的初始化选项 |
|
| `initializationOptions` | unknown | 否 | 传给服务器的初始化选项 |
|
||||||
| `settings` | unknown | 否 | 通过 `workspace/didChangeConfiguration` 传递的设置 |
|
| `settings` | unknown | 否 | 通过 `workspace/didChangeConfiguration` 传递的设置 |
|
||||||
| `workspaceFolder` | string | 否 | 工作区目录路径 |
|
| `workspaceFolder` | string | 否 | 工作区目录路径 |
|
||||||
|
|||||||
@@ -175,7 +175,7 @@ F. getCompletedResults() → 空
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
#### #8 stream_event (input_json_delta: '{"file_path":')
|
#### #8 stream_event (input_json_delta: `'{"file_path":'`)
|
||||||
|
|
||||||
```
|
```
|
||||||
D. yield message ✅ → REPL 追加工具输入 JSON 碎片
|
D. yield message ✅ → REPL 追加工具输入 JSON 碎片
|
||||||
|
|||||||
52
package.json
52
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "claude-code-best",
|
"name": "claude-code-best",
|
||||||
"version": "1.10.0",
|
"version": "1.10.4",
|
||||||
"description": "Reverse-engineered Anthropic Claude Code CLI — interactive AI coding assistant in the terminal",
|
"description": "Reverse-engineered Anthropic Claude Code CLI — interactive AI coding assistant in the terminal",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"author": "claude-code-best <claude-code-best@proton.me>",
|
"author": "claude-code-best <claude-code-best@proton.me>",
|
||||||
@@ -78,19 +78,19 @@
|
|||||||
"@ant/computer-use-input": "workspace:*",
|
"@ant/computer-use-input": "workspace:*",
|
||||||
"@ant/computer-use-mcp": "workspace:*",
|
"@ant/computer-use-mcp": "workspace:*",
|
||||||
"@ant/computer-use-swift": "workspace:*",
|
"@ant/computer-use-swift": "workspace:*",
|
||||||
"@anthropic-ai/bedrock-sdk": "^0.26.4",
|
"@anthropic-ai/bedrock-sdk": "^0.29.0",
|
||||||
"@anthropic-ai/claude-agent-sdk": "^0.2.114",
|
"@anthropic-ai/claude-agent-sdk": "^0.2.114",
|
||||||
"@anthropic-ai/foundry-sdk": "^0.2.3",
|
"@anthropic-ai/foundry-sdk": "^0.2.3",
|
||||||
"@anthropic-ai/mcpb": "^2.1.2",
|
"@anthropic-ai/mcpb": "^2.1.2",
|
||||||
"@anthropic-ai/sandbox-runtime": "^0.0.44",
|
"@anthropic-ai/sandbox-runtime": "^0.0.44",
|
||||||
"@anthropic-ai/sdk": "^0.80.0",
|
"@anthropic-ai/sdk": "^0.81.0",
|
||||||
"@anthropic-ai/vertex-sdk": "^0.14.4",
|
"@anthropic-ai/vertex-sdk": "^0.16.0",
|
||||||
"@anthropic/ink": "workspace:*",
|
"@anthropic/ink": "workspace:*",
|
||||||
"@aws-sdk/client-bedrock": "^3.1032.0",
|
"@aws-sdk/client-bedrock": "^3.1037.0",
|
||||||
"@aws-sdk/client-bedrock-runtime": "^3.1032.0",
|
"@aws-sdk/client-bedrock-runtime": "^3.1037.0",
|
||||||
"@aws-sdk/client-sts": "^3.1032.0",
|
"@aws-sdk/client-sts": "^3.1037.0",
|
||||||
"@aws-sdk/credential-provider-node": "^3.972.32",
|
"@aws-sdk/credential-provider-node": "^3.972.36",
|
||||||
"@aws-sdk/credential-providers": "^3.1032.0",
|
"@aws-sdk/credential-providers": "^3.1037.0",
|
||||||
"@azure/identity": "^4.13.1",
|
"@azure/identity": "^4.13.1",
|
||||||
"@biomejs/biome": "^2.4.12",
|
"@biomejs/biome": "^2.4.12",
|
||||||
"@claude-code-best/agent-tools": "workspace:*",
|
"@claude-code-best/agent-tools": "workspace:*",
|
||||||
@@ -103,20 +103,20 @@
|
|||||||
"@langfuse/tracing": "^5.1.0",
|
"@langfuse/tracing": "^5.1.0",
|
||||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||||
"@opentelemetry/api": "^1.9.1",
|
"@opentelemetry/api": "^1.9.1",
|
||||||
"@opentelemetry/api-logs": "^0.214.0",
|
"@opentelemetry/api-logs": "^0.215.0",
|
||||||
"@opentelemetry/core": "^2.7.0",
|
"@opentelemetry/core": "^2.7.0",
|
||||||
"@opentelemetry/exporter-logs-otlp-grpc": "^0.214.0",
|
"@opentelemetry/exporter-logs-otlp-grpc": "^0.215.0",
|
||||||
"@opentelemetry/exporter-logs-otlp-http": "^0.214.0",
|
"@opentelemetry/exporter-logs-otlp-http": "^0.215.0",
|
||||||
"@opentelemetry/exporter-logs-otlp-proto": "^0.214.0",
|
"@opentelemetry/exporter-logs-otlp-proto": "^0.215.0",
|
||||||
"@opentelemetry/exporter-metrics-otlp-grpc": "^0.214.0",
|
"@opentelemetry/exporter-metrics-otlp-grpc": "^0.215.0",
|
||||||
"@opentelemetry/exporter-metrics-otlp-http": "^0.214.0",
|
"@opentelemetry/exporter-metrics-otlp-http": "^0.215.0",
|
||||||
"@opentelemetry/exporter-metrics-otlp-proto": "^0.214.0",
|
"@opentelemetry/exporter-metrics-otlp-proto": "^0.215.0",
|
||||||
"@opentelemetry/exporter-prometheus": "^0.214.0",
|
"@opentelemetry/exporter-prometheus": "^0.215.0",
|
||||||
"@opentelemetry/exporter-trace-otlp-grpc": "^0.214.0",
|
"@opentelemetry/exporter-trace-otlp-grpc": "^0.215.0",
|
||||||
"@opentelemetry/exporter-trace-otlp-http": "^0.214.0",
|
"@opentelemetry/exporter-trace-otlp-http": "^0.215.0",
|
||||||
"@opentelemetry/exporter-trace-otlp-proto": "^0.214.0",
|
"@opentelemetry/exporter-trace-otlp-proto": "^0.215.0",
|
||||||
"@opentelemetry/resources": "^2.7.0",
|
"@opentelemetry/resources": "^2.7.0",
|
||||||
"@opentelemetry/sdk-logs": "^0.214.0",
|
"@opentelemetry/sdk-logs": "^0.215.0",
|
||||||
"@opentelemetry/sdk-metrics": "^2.7.0",
|
"@opentelemetry/sdk-metrics": "^2.7.0",
|
||||||
"@opentelemetry/sdk-trace-base": "^2.7.0",
|
"@opentelemetry/sdk-trace-base": "^2.7.0",
|
||||||
"@opentelemetry/semantic-conventions": "^1.40.0",
|
"@opentelemetry/semantic-conventions": "^1.40.0",
|
||||||
@@ -144,7 +144,7 @@
|
|||||||
"asciichart": "^1.5.25",
|
"asciichart": "^1.5.25",
|
||||||
"audio-capture-napi": "workspace:*",
|
"audio-capture-napi": "workspace:*",
|
||||||
"auto-bind": "^5.0.1",
|
"auto-bind": "^5.0.1",
|
||||||
"axios": "^1.15.0",
|
"axios": "^1.15.2",
|
||||||
"bidi-js": "^1.0.3",
|
"bidi-js": "^1.0.3",
|
||||||
"cacache": "^20.0.4",
|
"cacache": "^20.0.4",
|
||||||
"chalk": "^5.6.2",
|
"chalk": "^5.6.2",
|
||||||
@@ -208,5 +208,13 @@
|
|||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"doubaoime-asr": "^0.1.0"
|
"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"
|
"./client": "./src/client/index.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@anthropic-ai/sdk": "^0.80.0",
|
"@anthropic-ai/sdk": "^0.81.0",
|
||||||
"openai": "^6.33.0"
|
"openai": "^6.33.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -80,13 +80,17 @@ ARGUMENTS
|
|||||||
|
|
||||||
## Authentication
|
## Authentication
|
||||||
|
|
||||||
By default, a random token is auto-generated on startup. Pass it as a query parameter:
|
By default, a random token is auto-generated on startup. Connect to the
|
||||||
|
WebSocket endpoint without putting the token in the URL:
|
||||||
|
|
||||||
```
|
```
|
||||||
ws://localhost:9315/ws?token=<your-token>
|
ws://localhost:9315/ws
|
||||||
```
|
```
|
||||||
|
|
||||||
Set `ACP_AUTH_TOKEN` env var to use a fixed token, or use `--no-auth` to disable (not recommended).
|
Set `ACP_AUTH_TOKEN` env var to use a fixed token, or use `--no-auth` to
|
||||||
|
disable (not recommended). Clients that cannot send an `Authorization` header
|
||||||
|
must send the token in a WebSocket subprotocol named
|
||||||
|
`rcs.auth.<base64url-token>`.
|
||||||
|
|
||||||
## RCS Upstream
|
## RCS Upstream
|
||||||
|
|
||||||
|
|||||||
@@ -30,7 +30,7 @@
|
|||||||
"@hono/node-ws": "^1.0.5",
|
"@hono/node-ws": "^1.0.5",
|
||||||
"@stricli/auto-complete": "^1.2.4",
|
"@stricli/auto-complete": "^1.2.4",
|
||||||
"@stricli/core": "^1.2.4",
|
"@stricli/core": "^1.2.4",
|
||||||
"hono": "^4.7.0",
|
"hono": "^4.12.15",
|
||||||
"pino": "^10.3.0",
|
"pino": "^10.3.0",
|
||||||
"pino-pretty": "^13.1.3",
|
"pino-pretty": "^13.1.3",
|
||||||
"selfsigned": "^5.5.0"
|
"selfsigned": "^5.5.0"
|
||||||
|
|||||||
@@ -1,5 +1,35 @@
|
|||||||
import { describe, test, expect } from "bun:test";
|
import { describe, test, expect, mock } from "bun:test";
|
||||||
import type { ServerConfig } from "../server.js";
|
import {
|
||||||
|
__testing,
|
||||||
|
decodeClientWsMessage,
|
||||||
|
MAX_CLIENT_WS_PAYLOAD_BYTES,
|
||||||
|
resolveNewSessionPermissionMode,
|
||||||
|
type ServerConfig,
|
||||||
|
} from "../server.js";
|
||||||
|
import {
|
||||||
|
authTokensEqual,
|
||||||
|
decodeWebSocketAuthProtocol,
|
||||||
|
encodeWebSocketAuthProtocol,
|
||||||
|
extractWebSocketAuthToken,
|
||||||
|
} from "../ws-auth.js";
|
||||||
|
import { buildRcsWsUrl } from "../rcs-upstream.js";
|
||||||
|
|
||||||
|
function makeTestWs(sent: unknown[]) {
|
||||||
|
type TestWs = Parameters<typeof __testing.dispatchClientMessage>[0];
|
||||||
|
|
||||||
|
return {
|
||||||
|
readyState: 1,
|
||||||
|
send: mock((message: string) => {
|
||||||
|
sent.push(JSON.parse(message));
|
||||||
|
}),
|
||||||
|
close: mock(() => {}),
|
||||||
|
raw: null,
|
||||||
|
isInner: false,
|
||||||
|
url: "",
|
||||||
|
origin: "",
|
||||||
|
protocol: "",
|
||||||
|
} as unknown as TestWs;
|
||||||
|
}
|
||||||
|
|
||||||
describe("Server HTTP endpoints", () => {
|
describe("Server HTTP endpoints", () => {
|
||||||
test("package.json has correct bin and main entries", async () => {
|
test("package.json has correct bin and main entries", async () => {
|
||||||
@@ -60,6 +90,188 @@ describe("WebSocket message types", () => {
|
|||||||
expect(clientMessageTypes).toContain("connect");
|
expect(clientMessageTypes).toContain("connect");
|
||||||
expect(clientMessageTypes).toContain("cancel");
|
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", () => {
|
describe("Heartbeat constants", () => {
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { createLogger } from "./logger.js";
|
import { createLogger } from "./logger.js";
|
||||||
|
import { decodeJsonWsMessage, WsPayloadTooLargeError } from "./ws-message.js";
|
||||||
|
import { encodeWebSocketAuthProtocol } from "./ws-auth.js";
|
||||||
|
|
||||||
export interface RcsUpstreamConfig {
|
export interface RcsUpstreamConfig {
|
||||||
rcsUrl: string; // e.g. "http://localhost:3000"
|
rcsUrl: string; // e.g. "http://localhost:3000"
|
||||||
@@ -9,6 +11,18 @@ export interface RcsUpstreamConfig {
|
|||||||
maxSessions?: number;
|
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.
|
* RCS upstream client — connects acp-link to a Remote Control Server.
|
||||||
*
|
*
|
||||||
@@ -87,17 +101,7 @@ export class RcsUpstreamClient {
|
|||||||
|
|
||||||
/** Normalize RCS URL: accept http(s) base URL and convert to ws(s) + /acp/ws path */
|
/** Normalize RCS URL: accept http(s) base URL and convert to ws(s) + /acp/ws path */
|
||||||
private buildWsUrl(): string {
|
private buildWsUrl(): string {
|
||||||
let raw = this.config.rcsUrl;
|
return buildRcsWsUrl(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 */
|
/** Open connection to RCS: REST register → WS identify */
|
||||||
@@ -121,7 +125,9 @@ export class RcsUpstreamClient {
|
|||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
try {
|
try {
|
||||||
this.ws = new WebSocket(wsUrl);
|
this.ws = new WebSocket(wsUrl, [
|
||||||
|
encodeWebSocketAuthProtocol(this.config.apiToken),
|
||||||
|
]);
|
||||||
|
|
||||||
this.ws.onopen = () => {
|
this.ws.onopen = () => {
|
||||||
RcsUpstreamClient.log.debug("ws open — sending identify");
|
RcsUpstreamClient.log.debug("ws open — sending identify");
|
||||||
@@ -136,8 +142,13 @@ export class RcsUpstreamClient {
|
|||||||
this.ws.onmessage = (event) => {
|
this.ws.onmessage = (event) => {
|
||||||
let data: Record<string, unknown>;
|
let data: Record<string, unknown>;
|
||||||
try {
|
try {
|
||||||
data = JSON.parse(event.data as string);
|
data = decodeJsonWsMessage(event.data);
|
||||||
} catch {
|
} catch (err) {
|
||||||
|
if (err instanceof WsPayloadTooLargeError) {
|
||||||
|
RcsUpstreamClient.log.warn({ error: err.message }, "server message too large");
|
||||||
|
this.ws?.close(1009, "message too large");
|
||||||
|
return;
|
||||||
|
}
|
||||||
RcsUpstreamClient.log.warn({ raw: String(event.data).slice(0, 200) }, "invalid JSON from server");
|
RcsUpstreamClient.log.warn({ raw: String(event.data).slice(0, 200) }, "invalid JSON from server");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -152,11 +163,7 @@ export class RcsUpstreamClient {
|
|||||||
.replace(/\/acp\/ws.*$/, "")
|
.replace(/\/acp\/ws.*$/, "")
|
||||||
.replace(/\/$/, "");
|
.replace(/\/$/, "");
|
||||||
console.log();
|
console.log();
|
||||||
if (this.sessionId) {
|
console.log(` 🔗 Dashboard: ${webBase}/code/`);
|
||||||
console.log(` 🔗 Dashboard: ${webBase}/code/?sid=${this.sessionId}`);
|
|
||||||
} else {
|
|
||||||
console.log(` 🔗 Dashboard: ${webBase}/code/`);
|
|
||||||
}
|
|
||||||
if (this.agentId) {
|
if (this.agentId) {
|
||||||
console.log(` Agent ID: ${this.agentId}`);
|
console.log(` Agent ID: ${this.agentId}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,13 @@ import type { WebSocket as RawWebSocket } from "ws";
|
|||||||
import { createLogger } from "./logger.js";
|
import { createLogger } from "./logger.js";
|
||||||
import { getOrCreateCertificate, getLanIPs } from "./cert.js";
|
import { getOrCreateCertificate, getLanIPs } from "./cert.js";
|
||||||
import { RcsUpstreamClient, type RcsUpstreamConfig } from "./rcs-upstream.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 {
|
export interface ServerConfig {
|
||||||
port: number;
|
port: number;
|
||||||
@@ -251,6 +258,7 @@ async function handleConnect(ws: WSContext): Promise<void> {
|
|||||||
const agentProcess = spawn(AGENT_COMMAND, AGENT_ARGS, {
|
const agentProcess = spawn(AGENT_COMMAND, AGENT_ARGS, {
|
||||||
cwd: AGENT_CWD,
|
cwd: AGENT_CWD,
|
||||||
stdio: ["pipe", "pipe", "inherit"],
|
stdio: ["pipe", "pipe", "inherit"],
|
||||||
|
env: buildAgentEnv(),
|
||||||
});
|
});
|
||||||
|
|
||||||
state.process = agentProcess;
|
state.process = agentProcess;
|
||||||
@@ -334,7 +342,16 @@ async function handleNewSession(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const sessionCwd = params.cwd || AGENT_CWD;
|
const sessionCwd = params.cwd || AGENT_CWD;
|
||||||
const permissionMode = params.permissionMode || DEFAULT_PERMISSION_MODE;
|
let permissionMode: string | undefined;
|
||||||
|
try {
|
||||||
|
permissionMode = resolveNewSessionPermissionMode(
|
||||||
|
params.permissionMode,
|
||||||
|
DEFAULT_PERMISSION_MODE,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
send(ws, "error", { message: (error as Error).message });
|
||||||
|
return;
|
||||||
|
}
|
||||||
const result = await state.connection.newSession({
|
const result = await state.connection.newSession({
|
||||||
cwd: sessionCwd,
|
cwd: sessionCwd,
|
||||||
mcpServers: [],
|
mcpServers: [],
|
||||||
@@ -590,9 +607,326 @@ interface ContentBlock {
|
|||||||
name?: string;
|
name?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ProxyMessage {
|
type PermissionResponsePayload = {
|
||||||
type: "connect" | "disconnect" | "new_session" | "prompt" | "cancel" | "set_session_model";
|
requestId: string;
|
||||||
payload?: { cwd?: string } | { content: ContentBlock[] } | { modelId: string };
|
outcome: { outcome: "cancelled" } | { outcome: "selected"; optionId: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
type ProxyMessage =
|
||||||
|
| { type: "connect" }
|
||||||
|
| { type: "disconnect" }
|
||||||
|
| { type: "new_session"; payload: { cwd?: string; permissionMode?: string } }
|
||||||
|
| { type: "prompt"; payload: { content: ContentBlock[] } }
|
||||||
|
| { type: "permission_response"; payload: PermissionResponsePayload }
|
||||||
|
| { type: "cancel" }
|
||||||
|
| { type: "set_session_model"; payload: { modelId: string } }
|
||||||
|
| { type: "list_sessions"; payload: { cwd?: string; cursor?: string } }
|
||||||
|
| { type: "load_session"; payload: { sessionId: string; cwd?: string } }
|
||||||
|
| { type: "resume_session"; payload: { sessionId: string; cwd?: string } }
|
||||||
|
| { type: "ping" };
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function optionalString(value: unknown): string | undefined {
|
||||||
|
return typeof value === "string" ? value : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function optionalStringField(
|
||||||
|
payload: Record<string, unknown>,
|
||||||
|
key: string,
|
||||||
|
source: string,
|
||||||
|
): string | undefined {
|
||||||
|
if (!Object.hasOwn(payload, key)) return undefined;
|
||||||
|
const value = payload[key];
|
||||||
|
if (typeof value === "string") return value;
|
||||||
|
throw new Error(`Invalid ${source}: expected a string`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function payloadRecord(value: unknown, type: string): Record<string, unknown> {
|
||||||
|
if (!isRecord(value)) {
|
||||||
|
throw new Error(`Invalid ${type} payload`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function optionalPayloadRecord(value: unknown, type: string): Record<string, unknown> {
|
||||||
|
if (value === undefined) return {};
|
||||||
|
return payloadRecord(value, type);
|
||||||
|
}
|
||||||
|
|
||||||
|
function optionalRecord(value: unknown): Record<string, unknown> {
|
||||||
|
return isRecord(value) ? value : {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeContentBlocks(value: unknown): ContentBlock[] {
|
||||||
|
if (
|
||||||
|
!Array.isArray(value) ||
|
||||||
|
!value.every(block => isRecord(block) && typeof block.type === "string")
|
||||||
|
) {
|
||||||
|
throw new Error("Invalid prompt payload");
|
||||||
|
}
|
||||||
|
return value as ContentBlock[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodePermissionResponsePayload(value: unknown): PermissionResponsePayload {
|
||||||
|
const payload = payloadRecord(value, "permission_response");
|
||||||
|
if (typeof payload.requestId !== "string" || !isRecord(payload.outcome)) {
|
||||||
|
throw new Error("Invalid permission_response payload");
|
||||||
|
}
|
||||||
|
if (payload.outcome.outcome === "cancelled") {
|
||||||
|
return { requestId: payload.requestId, outcome: { outcome: "cancelled" } };
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
payload.outcome.outcome === "selected" &&
|
||||||
|
typeof payload.outcome.optionId === "string"
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
requestId: payload.requestId,
|
||||||
|
outcome: { outcome: "selected", optionId: payload.outcome.optionId },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
throw new Error("Invalid permission_response payload");
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeClientMessage(message: Record<string, unknown>): ProxyMessage {
|
||||||
|
if (typeof message.type !== "string") {
|
||||||
|
throw new Error("Invalid WebSocket message payload");
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (message.type) {
|
||||||
|
case "connect":
|
||||||
|
case "disconnect":
|
||||||
|
case "cancel":
|
||||||
|
case "ping":
|
||||||
|
return { type: message.type };
|
||||||
|
case "new_session": {
|
||||||
|
const payload = optionalPayloadRecord(message.payload, "new_session");
|
||||||
|
return {
|
||||||
|
type: "new_session",
|
||||||
|
payload: {
|
||||||
|
cwd: optionalStringField(payload, "cwd", "new_session.cwd"),
|
||||||
|
permissionMode: optionalStringField(
|
||||||
|
payload,
|
||||||
|
"permissionMode",
|
||||||
|
"new_session.permissionMode",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case "prompt": {
|
||||||
|
const payload = payloadRecord(message.payload, "prompt");
|
||||||
|
return {
|
||||||
|
type: "prompt",
|
||||||
|
payload: { content: decodeContentBlocks(payload.content) },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case "permission_response":
|
||||||
|
return {
|
||||||
|
type: "permission_response",
|
||||||
|
payload: decodePermissionResponsePayload(message.payload),
|
||||||
|
};
|
||||||
|
case "set_session_model": {
|
||||||
|
const payload = payloadRecord(message.payload, "set_session_model");
|
||||||
|
if (typeof payload.modelId !== "string") {
|
||||||
|
throw new Error("Invalid set_session_model payload");
|
||||||
|
}
|
||||||
|
return { type: "set_session_model", payload: { modelId: payload.modelId } };
|
||||||
|
}
|
||||||
|
case "list_sessions": {
|
||||||
|
const payload = optionalRecord(message.payload);
|
||||||
|
return {
|
||||||
|
type: "list_sessions",
|
||||||
|
payload: {
|
||||||
|
cwd: optionalString(payload.cwd),
|
||||||
|
cursor: optionalString(payload.cursor),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case "load_session":
|
||||||
|
case "resume_session": {
|
||||||
|
const payload = payloadRecord(message.payload, message.type);
|
||||||
|
if (typeof payload.sessionId !== "string") {
|
||||||
|
throw new Error(`Invalid ${message.type} payload`);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
type: message.type,
|
||||||
|
payload: {
|
||||||
|
sessionId: payload.sessionId,
|
||||||
|
cwd: optionalString(payload.cwd),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
throw new Error(`Unknown message type: ${message.type}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decodeClientWsMessage(data: unknown): ProxyMessage {
|
||||||
|
return decodeClientMessage(decodeJsonWsMessage(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function dispatchClientMessage(ws: WSContext, data: ProxyMessage): Promise<void> {
|
||||||
|
switch (data.type) {
|
||||||
|
case "connect":
|
||||||
|
await handleConnect(ws);
|
||||||
|
break;
|
||||||
|
case "disconnect":
|
||||||
|
handleDisconnect(ws);
|
||||||
|
break;
|
||||||
|
case "new_session":
|
||||||
|
await handleNewSession(ws, data.payload);
|
||||||
|
break;
|
||||||
|
case "prompt":
|
||||||
|
await handlePrompt(ws, data.payload);
|
||||||
|
break;
|
||||||
|
case "permission_response":
|
||||||
|
handlePermissionResponse(ws, data.payload);
|
||||||
|
break;
|
||||||
|
case "cancel":
|
||||||
|
await handleCancel(ws);
|
||||||
|
break;
|
||||||
|
case "set_session_model":
|
||||||
|
await handleSetSessionModel(ws, data.payload);
|
||||||
|
break;
|
||||||
|
case "list_sessions":
|
||||||
|
await handleListSessions(ws, data.payload);
|
||||||
|
break;
|
||||||
|
case "load_session":
|
||||||
|
await handleLoadSession(ws, data.payload);
|
||||||
|
break;
|
||||||
|
case "resume_session":
|
||||||
|
await handleResumeSession(ws, data.payload);
|
||||||
|
break;
|
||||||
|
case "ping":
|
||||||
|
send(ws, "pong");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const __testing = {
|
||||||
|
dispatchClientMessage(
|
||||||
|
ws: WSContext,
|
||||||
|
data: unknown,
|
||||||
|
): Promise<void> {
|
||||||
|
assertTestingInternalsEnabled();
|
||||||
|
return dispatchClientMessage(ws, data as ProxyMessage);
|
||||||
|
},
|
||||||
|
registerClient(
|
||||||
|
ws: WSContext,
|
||||||
|
state: {
|
||||||
|
connection?: unknown;
|
||||||
|
process?: ChildProcess | null;
|
||||||
|
sessionId?: string | null;
|
||||||
|
},
|
||||||
|
): () => void {
|
||||||
|
assertTestingInternalsEnabled();
|
||||||
|
clients.set(ws, {
|
||||||
|
process: state.process ?? null,
|
||||||
|
connection: (state.connection ?? null) as acp.ClientSideConnection | null,
|
||||||
|
sessionId: state.sessionId ?? null,
|
||||||
|
pendingPermissions: new Map(),
|
||||||
|
agentCapabilities: null,
|
||||||
|
promptCapabilities: null,
|
||||||
|
modelState: null,
|
||||||
|
isAlive: true,
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
clients.delete(ws);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
getClientSessionId(ws: WSContext): string | null | undefined {
|
||||||
|
assertTestingInternalsEnabled();
|
||||||
|
return clients.get(ws)?.sessionId;
|
||||||
|
},
|
||||||
|
setDefaultPermissionMode(mode: string | undefined): () => void {
|
||||||
|
assertTestingInternalsEnabled();
|
||||||
|
const previous = DEFAULT_PERMISSION_MODE;
|
||||||
|
DEFAULT_PERMISSION_MODE = mode;
|
||||||
|
return () => {
|
||||||
|
DEFAULT_PERMISSION_MODE = previous;
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function assertTestingInternalsEnabled(): void {
|
||||||
|
if (process.env.ACP_LINK_TEST_INTERNALS === "1") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(
|
||||||
|
"acp-link test internals are disabled outside test execution.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ACP_LINK_PERMISSION_MODE_ALIASES = {
|
||||||
|
auto: "auto",
|
||||||
|
default: "default",
|
||||||
|
acceptedits: "acceptEdits",
|
||||||
|
dontask: "dontAsk",
|
||||||
|
plan: "plan",
|
||||||
|
bypasspermissions: "bypassPermissions",
|
||||||
|
bypass: "bypassPermissions",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
type AcpLinkPermissionMode =
|
||||||
|
(typeof ACP_LINK_PERMISSION_MODE_ALIASES)[keyof typeof ACP_LINK_PERMISSION_MODE_ALIASES];
|
||||||
|
|
||||||
|
export function resolveNewSessionPermissionMode(
|
||||||
|
requestedMode: string | undefined,
|
||||||
|
defaultMode: string | undefined,
|
||||||
|
): string | undefined {
|
||||||
|
const requested = resolveAcpLinkPermissionMode(requestedMode);
|
||||||
|
const localDefault = resolveAcpLinkPermissionMode(defaultMode);
|
||||||
|
|
||||||
|
if (!requested) {
|
||||||
|
return localDefault;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requested !== "bypassPermissions") {
|
||||||
|
return requested;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (localDefault === "bypassPermissions") {
|
||||||
|
return "bypassPermissions";
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(
|
||||||
|
"bypassPermissions requires local ACP_PERMISSION_MODE=bypassPermissions before a client can request it.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveAcpLinkPermissionMode(
|
||||||
|
mode: string | undefined,
|
||||||
|
): AcpLinkPermissionMode | undefined {
|
||||||
|
if (mode === undefined) return undefined;
|
||||||
|
|
||||||
|
const normalized = mode?.trim().toLowerCase();
|
||||||
|
if (!normalized) {
|
||||||
|
throw new Error("Invalid permissionMode: expected a non-empty string.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolved =
|
||||||
|
ACP_LINK_PERMISSION_MODE_ALIASES[
|
||||||
|
normalized as keyof typeof ACP_LINK_PERMISSION_MODE_ALIASES
|
||||||
|
];
|
||||||
|
if (!resolved) {
|
||||||
|
throw new Error(`Invalid permissionMode: ${mode}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAgentEnv(): NodeJS.ProcessEnv {
|
||||||
|
if (!DEFAULT_PERMISSION_MODE) {
|
||||||
|
return process.env;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...process.env,
|
||||||
|
ACP_PERMISSION_MODE: DEFAULT_PERMISSION_MODE,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function startServer(config: ServerConfig): Promise<void> {
|
export async function startServer(config: ServerConfig): Promise<void> {
|
||||||
@@ -638,44 +972,9 @@ export async function startServer(config: ServerConfig): Promise<void> {
|
|||||||
|
|
||||||
rcsUpstream.setMessageHandler(async (msg) => {
|
rcsUpstream.setMessageHandler(async (msg) => {
|
||||||
try {
|
try {
|
||||||
logRelay.debug({ type: msg.type }, "processing");
|
const data = decodeClientMessage(msg);
|
||||||
switch (msg.type) {
|
logRelay.debug({ type: data.type }, "processing");
|
||||||
case "connect":
|
await dispatchClientMessage(relayWs, data);
|
||||||
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) {
|
} catch (error) {
|
||||||
logRelay.error({ error: (error as Error).message }, "handler error");
|
logRelay.error({ error: (error as Error).message }, "handler error");
|
||||||
}
|
}
|
||||||
@@ -700,9 +999,11 @@ export async function startServer(config: ServerConfig): Promise<void> {
|
|||||||
"/ws",
|
"/ws",
|
||||||
upgradeWebSocket((c) => {
|
upgradeWebSocket((c) => {
|
||||||
if (AUTH_TOKEN) {
|
if (AUTH_TOKEN) {
|
||||||
const url = new URL(c.req.url);
|
const providedToken = extractWebSocketAuthToken({
|
||||||
const providedToken = url.searchParams.get("token");
|
authorization: c.req.header("Authorization"),
|
||||||
if (providedToken !== AUTH_TOKEN) {
|
protocol: c.req.header("Sec-WebSocket-Protocol"),
|
||||||
|
});
|
||||||
|
if (!authTokensEqual(providedToken, AUTH_TOKEN)) {
|
||||||
logWs.warn("connection rejected: invalid token");
|
logWs.warn("connection rejected: invalid token");
|
||||||
return {
|
return {
|
||||||
onOpen(_event, ws) {
|
onOpen(_event, ws) {
|
||||||
@@ -734,63 +1035,31 @@ export async function startServer(config: ServerConfig): Promise<void> {
|
|||||||
state.isAlive = true;
|
state.isAlive = true;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
async onMessage(event, ws) {
|
async onMessage(event, ws) {
|
||||||
try {
|
try {
|
||||||
const data = JSON.parse(event.data.toString());
|
const data = decodeClientWsMessage(event.data);
|
||||||
logWs.debug({ type: data.type }, "received");
|
logWs.debug({ type: data.type }, "received");
|
||||||
|
await dispatchClientMessage(ws, data);
|
||||||
switch (data.type) {
|
} catch (error) {
|
||||||
case "connect":
|
if (error instanceof WsPayloadTooLargeError) {
|
||||||
await handleConnect(ws);
|
logWs.warn({ error: error.message }, "message too large");
|
||||||
break;
|
ws.close(1009, "message too large");
|
||||||
case "disconnect":
|
return;
|
||||||
handleDisconnect(ws);
|
}
|
||||||
break;
|
logWs.error({ error: (error as Error).message }, "message error");
|
||||||
case "new_session":
|
send(ws, "error", { message: `Error: ${(error as Error).message}` });
|
||||||
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}` });
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
},
|
||||||
logWs.error({ error: (error as Error).message }, "message error");
|
onClose(_event, ws) {
|
||||||
send(ws, "error", { message: `Error: ${(error as Error).message}` });
|
logWs.info("client disconnected");
|
||||||
}
|
const state = clients.get(ws);
|
||||||
},
|
if (state) {
|
||||||
onClose(_event, ws) {
|
cancelPendingPermissions(state);
|
||||||
logWs.info("client disconnected");
|
}
|
||||||
const state = clients.get(ws);
|
handleDisconnect(ws);
|
||||||
if (state) {
|
clients.delete(ws);
|
||||||
cancelPendingPermissions(state);
|
},
|
||||||
}
|
};
|
||||||
handleDisconnect(ws);
|
|
||||||
clients.delete(ws);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -855,7 +1124,7 @@ export async function startServer(config: ServerConfig): Promise<void> {
|
|||||||
console.log(` URL: ${localWsUrl}`);
|
console.log(` URL: ${localWsUrl}`);
|
||||||
}
|
}
|
||||||
if (AUTH_TOKEN) {
|
if (AUTH_TOKEN) {
|
||||||
console.log(` Token: ${AUTH_TOKEN}`);
|
console.log(` Token: configured`);
|
||||||
}
|
}
|
||||||
console.log();
|
console.log();
|
||||||
if (!AUTH_TOKEN) {
|
if (!AUTH_TOKEN) {
|
||||||
|
|||||||
62
packages/acp-link/src/ws-auth.ts
Normal file
62
packages/acp-link/src/ws-auth.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import { createHash, timingSafeEqual } from "node:crypto";
|
||||||
|
|
||||||
|
const WS_AUTH_PROTOCOL_PREFIX = "rcs.auth.";
|
||||||
|
|
||||||
|
function sha256(value: string): Buffer {
|
||||||
|
return createHash("sha256").update(value).digest();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function encodeWebSocketAuthProtocol(token: string): string {
|
||||||
|
return `${WS_AUTH_PROTOCOL_PREFIX}${Buffer.from(token, "utf8").toString("base64url")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decodeWebSocketAuthProtocol(protocolHeader: string | undefined): string | undefined {
|
||||||
|
if (!protocolHeader) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const protocol of protocolHeader.split(",")) {
|
||||||
|
const trimmed = protocol.trim();
|
||||||
|
if (!trimmed.startsWith(WS_AUTH_PROTOCOL_PREFIX)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const encoded = trimmed.slice(WS_AUTH_PROTOCOL_PREFIX.length);
|
||||||
|
if (!encoded) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const token = Buffer.from(encoded, "base64url").toString("utf8");
|
||||||
|
return token.length > 0 ? token : undefined;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractBearerToken(authorizationHeader: string | undefined): string | undefined {
|
||||||
|
return authorizationHeader?.startsWith("Bearer ")
|
||||||
|
? authorizationHeader.slice("Bearer ".length)
|
||||||
|
: undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractWebSocketAuthToken(headers: {
|
||||||
|
authorization?: string;
|
||||||
|
protocol?: string;
|
||||||
|
}): string | undefined {
|
||||||
|
return extractBearerToken(headers.authorization) ??
|
||||||
|
decodeWebSocketAuthProtocol(headers.protocol);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function authTokensEqual(
|
||||||
|
providedToken: string | undefined,
|
||||||
|
expectedToken: string | undefined,
|
||||||
|
): boolean {
|
||||||
|
if (!providedToken || !expectedToken) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return timingSafeEqual(sha256(providedToken), sha256(expectedToken));
|
||||||
|
}
|
||||||
60
packages/acp-link/src/ws-message.ts
Normal file
60
packages/acp-link/src/ws-message.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
export const MAX_CLIENT_WS_PAYLOAD_BYTES = 10 * 1024 * 1024;
|
||||||
|
|
||||||
|
export class WsPayloadTooLargeError extends Error {
|
||||||
|
constructor(byteLength: number) {
|
||||||
|
super(`WebSocket message too large: ${byteLength} bytes`);
|
||||||
|
this.name = "WsPayloadTooLargeError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface JsonWsMessage {
|
||||||
|
type: string;
|
||||||
|
payload?: unknown;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertPayloadSize(byteLength: number): void {
|
||||||
|
if (byteLength > MAX_CLIENT_WS_PAYLOAD_BYTES) {
|
||||||
|
throw new WsPayloadTooLargeError(byteLength);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeWsText(data: unknown): string {
|
||||||
|
if (typeof data === "string") {
|
||||||
|
assertPayloadSize(Buffer.byteLength(data, "utf8"));
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data instanceof ArrayBuffer) {
|
||||||
|
assertPayloadSize(data.byteLength);
|
||||||
|
return new TextDecoder().decode(new Uint8Array(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ArrayBuffer.isView(data)) {
|
||||||
|
assertPayloadSize(data.byteLength);
|
||||||
|
return new TextDecoder().decode(
|
||||||
|
new Uint8Array(data.buffer, data.byteOffset, data.byteLength),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(data) && data.every(Buffer.isBuffer)) {
|
||||||
|
const byteLength = data.reduce((total, chunk) => total + chunk.byteLength, 0);
|
||||||
|
assertPayloadSize(byteLength);
|
||||||
|
return Buffer.concat(data, byteLength).toString("utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error("Unsupported WebSocket message payload");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decodeJsonWsMessage(data: unknown): JsonWsMessage {
|
||||||
|
const parsed = JSON.parse(decodeWsText(data)) as unknown;
|
||||||
|
if (
|
||||||
|
typeof parsed !== "object" ||
|
||||||
|
parsed === null ||
|
||||||
|
!("type" in parsed) ||
|
||||||
|
typeof parsed.type !== "string"
|
||||||
|
) {
|
||||||
|
throw new Error("Invalid WebSocket message payload");
|
||||||
|
}
|
||||||
|
return parsed as JsonWsMessage;
|
||||||
|
}
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
import { describe, expect, test } from 'bun:test'
|
||||||
|
import type { Message } from 'src/types/message.js'
|
||||||
|
import { filterIncompleteToolCalls } from '../filterIncompleteToolCalls.js'
|
||||||
|
|
||||||
|
describe('filterIncompleteToolCalls', () => {
|
||||||
|
test('drops assistant tool uses that do not have matching results', () => {
|
||||||
|
const messages = [
|
||||||
|
{
|
||||||
|
type: 'assistant',
|
||||||
|
uuid: 'a1',
|
||||||
|
message: {
|
||||||
|
role: 'assistant',
|
||||||
|
content: [{ type: 'tool_use', id: 'missing', name: 'Read' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'user',
|
||||||
|
uuid: 'u1',
|
||||||
|
message: { role: 'user', content: 'continue' },
|
||||||
|
},
|
||||||
|
] as unknown as Message[]
|
||||||
|
|
||||||
|
expect(
|
||||||
|
filterIncompleteToolCalls(messages).map(message => String(message.uuid)),
|
||||||
|
).toEqual(['u1'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('preserves assistant text when dropping orphan tool uses', () => {
|
||||||
|
const messages = [
|
||||||
|
{
|
||||||
|
type: 'assistant',
|
||||||
|
uuid: 'a1',
|
||||||
|
message: {
|
||||||
|
role: 'assistant',
|
||||||
|
content: [
|
||||||
|
{ type: 'text', text: 'I will read the file.' },
|
||||||
|
{ type: 'tool_use', id: 'missing', name: 'Read' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
] as unknown as Message[]
|
||||||
|
|
||||||
|
const filtered = filterIncompleteToolCalls(messages)
|
||||||
|
expect(filtered).toHaveLength(1)
|
||||||
|
const first = filtered[0]!
|
||||||
|
const content = first.message!.content
|
||||||
|
expect(
|
||||||
|
Array.isArray(content) ? content.map(block => block.type) : [],
|
||||||
|
).toEqual(['text'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('keeps completed parallel tool calls when dropping an orphan', () => {
|
||||||
|
const messages = [
|
||||||
|
{
|
||||||
|
type: 'assistant',
|
||||||
|
uuid: 'a1',
|
||||||
|
message: {
|
||||||
|
role: 'assistant',
|
||||||
|
content: [
|
||||||
|
{ type: 'tool_use', id: 'done', name: 'Read' },
|
||||||
|
{ type: 'tool_use', id: 'missing', name: 'Grep' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'user',
|
||||||
|
uuid: 'u1',
|
||||||
|
message: {
|
||||||
|
role: 'user',
|
||||||
|
content: [{ type: 'tool_result', tool_use_id: 'done', content: 'ok' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
] as unknown as Message[]
|
||||||
|
|
||||||
|
const filtered = filterIncompleteToolCalls(messages)
|
||||||
|
expect(filtered.map(message => String(message.uuid))).toEqual(['a1', 'u1'])
|
||||||
|
const first = filtered[0]!
|
||||||
|
const content = first.message!.content
|
||||||
|
expect(
|
||||||
|
Array.isArray(content)
|
||||||
|
? content.map(block =>
|
||||||
|
block.type === 'tool_use' ? block.id : block.type,
|
||||||
|
)
|
||||||
|
: [],
|
||||||
|
).toEqual(['done'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('keeps assistant tool uses that have matching results', () => {
|
||||||
|
const messages = [
|
||||||
|
{
|
||||||
|
type: 'assistant',
|
||||||
|
uuid: 'a1',
|
||||||
|
message: {
|
||||||
|
role: 'assistant',
|
||||||
|
content: [{ type: 'tool_use', id: 'done', name: 'Read' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'user',
|
||||||
|
uuid: 'u1',
|
||||||
|
message: {
|
||||||
|
role: 'user',
|
||||||
|
content: [{ type: 'tool_result', tool_use_id: 'done', content: 'ok' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
] as unknown as Message[]
|
||||||
|
|
||||||
|
expect(
|
||||||
|
filterIncompleteToolCalls(messages).map(message => String(message.uuid)),
|
||||||
|
).toEqual(['a1', 'u1'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('drops orphan tool results when their tool use was removed', () => {
|
||||||
|
const messages = [
|
||||||
|
{
|
||||||
|
type: 'user',
|
||||||
|
uuid: 'u1',
|
||||||
|
message: {
|
||||||
|
role: 'user',
|
||||||
|
content: [
|
||||||
|
{ type: 'tool_result', tool_use_id: 'missing', content: 'late' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
] as unknown as Message[]
|
||||||
|
|
||||||
|
expect(filterIncompleteToolCalls(messages)).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('keeps user text while dropping orphan tool results', () => {
|
||||||
|
const messages = [
|
||||||
|
{
|
||||||
|
type: 'assistant',
|
||||||
|
uuid: 'a1',
|
||||||
|
message: { role: 'assistant', content: 'done' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'user',
|
||||||
|
uuid: 'u1',
|
||||||
|
message: {
|
||||||
|
role: 'user',
|
||||||
|
content: [
|
||||||
|
{ type: 'text', text: 'keep this' },
|
||||||
|
{ type: 'tool_result', tool_use_id: 'missing', content: 'late' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
] as unknown as Message[]
|
||||||
|
|
||||||
|
const filtered = filterIncompleteToolCalls(messages)
|
||||||
|
expect(filtered.map(message => String(message.uuid))).toEqual(['a1', 'u1'])
|
||||||
|
const content = filtered[1]!.message!.content
|
||||||
|
expect(Array.isArray(content) ? content : []).toEqual([
|
||||||
|
{ type: 'text', text: 'keep this' },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('drops malformed tool blocks without ids', () => {
|
||||||
|
const messages = [
|
||||||
|
{
|
||||||
|
type: 'assistant',
|
||||||
|
uuid: 'a1',
|
||||||
|
message: {
|
||||||
|
role: 'assistant',
|
||||||
|
content: [{ type: 'tool_use', name: 'Read' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'user',
|
||||||
|
uuid: 'u1',
|
||||||
|
message: {
|
||||||
|
role: 'user',
|
||||||
|
content: [{ type: 'tool_result', content: 'late' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
] as unknown as Message[]
|
||||||
|
|
||||||
|
expect(filterIncompleteToolCalls(messages)).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import type {
|
||||||
|
AssistantMessage,
|
||||||
|
Message,
|
||||||
|
UserMessage,
|
||||||
|
} from 'src/types/message.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes invalid or orphaned tool_use/tool_result blocks while preserving
|
||||||
|
* completed tool-call pairs. This is intentionally block-level, not
|
||||||
|
* message-level, so completed parallel tool calls stay paired with results.
|
||||||
|
*/
|
||||||
|
export function filterIncompleteToolCalls(messages: Message[]): Message[] {
|
||||||
|
const toolUseIdsWithResults = new Set<string>()
|
||||||
|
|
||||||
|
for (const message of messages) {
|
||||||
|
if (message?.type === 'user') {
|
||||||
|
const userMessage = message as UserMessage
|
||||||
|
const content = userMessage.message.content
|
||||||
|
if (Array.isArray(content)) {
|
||||||
|
for (const block of content) {
|
||||||
|
if (block.type === 'tool_result' && block.tool_use_id) {
|
||||||
|
toolUseIdsWithResults.add(block.tool_use_id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const retainedToolUseIds = new Set<string>()
|
||||||
|
const withoutOrphanToolUses: Message[] = []
|
||||||
|
|
||||||
|
for (const message of messages) {
|
||||||
|
if (message?.type === 'assistant') {
|
||||||
|
const assistantMessage = message as AssistantMessage
|
||||||
|
const content = assistantMessage.message.content
|
||||||
|
if (Array.isArray(content)) {
|
||||||
|
let changed = false
|
||||||
|
const filteredContent = content.filter(block => {
|
||||||
|
if (block.type !== 'tool_use') return true
|
||||||
|
if (!block.id) {
|
||||||
|
changed = true
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (toolUseIdsWithResults.has(block.id)) {
|
||||||
|
retainedToolUseIds.add(block.id)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
changed = true
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!changed) {
|
||||||
|
withoutOrphanToolUses.push(message)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (filteredContent.length > 0) {
|
||||||
|
withoutOrphanToolUses.push({
|
||||||
|
...assistantMessage,
|
||||||
|
message: {
|
||||||
|
...assistantMessage.message,
|
||||||
|
content: filteredContent,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
withoutOrphanToolUses.push(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredMessages: Message[] = []
|
||||||
|
for (const message of withoutOrphanToolUses) {
|
||||||
|
if (message?.type !== 'user') {
|
||||||
|
filteredMessages.push(message)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const userMessage = message as UserMessage
|
||||||
|
const content = userMessage.message.content
|
||||||
|
if (!Array.isArray(content)) {
|
||||||
|
filteredMessages.push(message)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
let changed = false
|
||||||
|
const filteredContent = content.filter(block => {
|
||||||
|
if (block.type !== 'tool_result') return true
|
||||||
|
if (!block.tool_use_id) {
|
||||||
|
changed = true
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (retainedToolUseIds.has(block.tool_use_id)) return true
|
||||||
|
changed = true
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
if (!changed) {
|
||||||
|
filteredMessages.push(message)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (filteredContent.length > 0) {
|
||||||
|
filteredMessages.push({
|
||||||
|
...userMessage,
|
||||||
|
message: {
|
||||||
|
...userMessage.message,
|
||||||
|
content: filteredContent,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return filteredMessages
|
||||||
|
}
|
||||||
@@ -86,8 +86,11 @@ import {
|
|||||||
import type { ContentReplacementState } from 'src/utils/toolResultStorage.js'
|
import type { ContentReplacementState } from 'src/utils/toolResultStorage.js'
|
||||||
import { createAgentId } from 'src/utils/uuid.js'
|
import { createAgentId } from 'src/utils/uuid.js'
|
||||||
import { resolveAgentTools } from './agentToolUtils.js'
|
import { resolveAgentTools } from './agentToolUtils.js'
|
||||||
|
import { filterIncompleteToolCalls } from './filterIncompleteToolCalls.js'
|
||||||
import { type AgentDefinition, isBuiltInAgent } from './loadAgentsDir.js'
|
import { type AgentDefinition, isBuiltInAgent } from './loadAgentsDir.js'
|
||||||
|
|
||||||
|
export { filterIncompleteToolCalls } from './filterIncompleteToolCalls.js'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize agent-specific MCP servers
|
* Initialize agent-specific MCP servers
|
||||||
* Agents can define their own MCP servers in their frontmatter that are additive
|
* Agents can define their own MCP servers in their frontmatter that are additive
|
||||||
@@ -886,50 +889,6 @@ export async function* runAgent({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Filters out assistant messages with incomplete tool calls (tool uses without results).
|
|
||||||
* This prevents API errors when sending messages with orphaned tool calls.
|
|
||||||
*/
|
|
||||||
export function filterIncompleteToolCalls(messages: Message[]): Message[] {
|
|
||||||
// Build a set of tool use IDs that have results
|
|
||||||
const toolUseIdsWithResults = new Set<string>()
|
|
||||||
|
|
||||||
for (const message of messages) {
|
|
||||||
if (message?.type === 'user') {
|
|
||||||
const userMessage = message as UserMessage
|
|
||||||
const content = userMessage.message.content
|
|
||||||
if (Array.isArray(content)) {
|
|
||||||
for (const block of content) {
|
|
||||||
if (block.type === 'tool_result' && block.tool_use_id) {
|
|
||||||
toolUseIdsWithResults.add(block.tool_use_id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter out assistant messages that contain tool calls without results
|
|
||||||
return messages.filter(message => {
|
|
||||||
if (message?.type === 'assistant') {
|
|
||||||
const assistantMessage = message as AssistantMessage
|
|
||||||
const content = assistantMessage.message.content
|
|
||||||
if (Array.isArray(content)) {
|
|
||||||
// Check if this assistant message has any tool uses without results
|
|
||||||
const hasIncompleteToolCall = content.some(
|
|
||||||
block =>
|
|
||||||
block.type === 'tool_use' &&
|
|
||||||
block.id &&
|
|
||||||
!toolUseIdsWithResults.has(block.id),
|
|
||||||
)
|
|
||||||
// Exclude messages with incomplete tool calls
|
|
||||||
return !hasIncompleteToolCall
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Keep all non-assistant messages and assistant messages without tool calls
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getAgentSystemPrompt(
|
async function getAgentSystemPrompt(
|
||||||
agentDefinition: AgentDefinition,
|
agentDefinition: AgentDefinition,
|
||||||
toolUseContext: Pick<ToolUseContext, 'options'>,
|
toolUseContext: Pick<ToolUseContext, 'options'>,
|
||||||
|
|||||||
@@ -84,22 +84,48 @@ Use this tool to discover messaging targets before sending cross-session message
|
|||||||
// UDS socket directory. The implementation scans for live sockets
|
// UDS socket directory. The implementation scans for live sockets
|
||||||
// and optionally includes Remote Control bridge peers.
|
// and optionally includes Remote Control bridge peers.
|
||||||
const peers: PeerInfo[] = []
|
const peers: PeerInfo[] = []
|
||||||
|
const seen = new Set<string>()
|
||||||
|
const addPeer = (peer: PeerInfo): void => {
|
||||||
|
if (seen.has(peer.address)) return
|
||||||
|
seen.add(peer.address)
|
||||||
|
peers.push(peer)
|
||||||
|
}
|
||||||
|
|
||||||
// Discovery is handled by the UDS messaging subsystem initialized in setup.ts.
|
/* eslint-disable @typescript-eslint/no-require-imports */
|
||||||
// Return discovered peers from the app state.
|
const udsMessaging =
|
||||||
const appState = context.getAppState()
|
require('src/utils/udsMessaging.js') as typeof import('src/utils/udsMessaging.js')
|
||||||
const messagingSocketPath = (appState as Record<string, unknown>).messagingSocketPath as string | undefined
|
const udsClient =
|
||||||
|
require('src/utils/udsClient.js') as typeof import('src/utils/udsClient.js')
|
||||||
|
const bridgePeers =
|
||||||
|
require('src/bridge/peerSessions.js') as typeof import('src/bridge/peerSessions.js')
|
||||||
|
/* eslint-enable @typescript-eslint/no-require-imports */
|
||||||
|
|
||||||
|
const messagingSocketPath = udsMessaging.getUdsMessagingSocketPath()
|
||||||
if (messagingSocketPath) {
|
if (messagingSocketPath) {
|
||||||
// Self entry for reference
|
// Self entry for reference
|
||||||
if (_input.include_self) {
|
if (_input.include_self) {
|
||||||
peers.push({
|
addPeer({
|
||||||
address: `uds:${messagingSocketPath}`,
|
address: udsMessaging.formatUdsAddress(messagingSocketPath),
|
||||||
name: 'self',
|
name: 'self',
|
||||||
pid: process.pid,
|
pid: process.pid,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (const peer of await udsClient.listPeers()) {
|
||||||
|
if (!peer.messagingSocketPath) continue
|
||||||
|
addPeer({
|
||||||
|
address: udsMessaging.formatUdsAddress(peer.messagingSocketPath),
|
||||||
|
name: peer.name ?? peer.kind,
|
||||||
|
cwd: peer.cwd,
|
||||||
|
pid: peer.pid,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const peer of await bridgePeers.listBridgePeers()) {
|
||||||
|
addPeer(peer)
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
data: { peers },
|
data: { peers },
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -421,7 +421,7 @@ export const PowerShellTool = buildTool({
|
|||||||
isSearch: boolean
|
isSearch: boolean
|
||||||
isRead: boolean
|
isRead: boolean
|
||||||
} {
|
} {
|
||||||
if (!input.command) {
|
if (!input?.command) {
|
||||||
return { isSearch: false, isRead: false }
|
return { isSearch: false, isRead: false }
|
||||||
}
|
}
|
||||||
return isSearchOrReadPowerShellCommand(input.command)
|
return isSearchOrReadPowerShellCommand(input.command)
|
||||||
|
|||||||
@@ -130,6 +130,41 @@ export type SendMessageToolOutput =
|
|||||||
| RequestOutput
|
| RequestOutput
|
||||||
| ResponseOutput
|
| ResponseOutput
|
||||||
|
|
||||||
|
const UDS_INLINE_TOKEN_MARKER = '#token='
|
||||||
|
|
||||||
|
function stripInlineUdsToken(target: string): string {
|
||||||
|
const markerIndex = target.indexOf(UDS_INLINE_TOKEN_MARKER)
|
||||||
|
return markerIndex === -1 ? target : target.slice(0, markerIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasInlineUdsToken(to: string): boolean {
|
||||||
|
const addr = parseAddress(to)
|
||||||
|
// Empty-token markers are still inline-token attempts. Observable input
|
||||||
|
// redaction preserves "#token=" so cloned inputs remain rejected.
|
||||||
|
return (
|
||||||
|
addr.scheme === 'uds' && addr.target.includes(UDS_INLINE_TOKEN_MARKER)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function recipientForDisplay(to: string): string {
|
||||||
|
const addr = parseAddress(to)
|
||||||
|
if (addr.scheme !== 'uds') return to
|
||||||
|
return `uds:${stripInlineUdsToken(addr.target)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function redactInlineUdsTokenForRejection(to: string): string {
|
||||||
|
const addr = parseAddress(to)
|
||||||
|
if (addr.scheme !== 'uds') return to
|
||||||
|
const markerIndex = addr.target.indexOf(UDS_INLINE_TOKEN_MARKER)
|
||||||
|
if (markerIndex === -1) return to
|
||||||
|
return `uds:${addr.target.slice(0, markerIndex)}${UDS_INLINE_TOKEN_MARKER}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function redactObservableInlineUdsToken(input: { to: string }): void {
|
||||||
|
if (!hasInlineUdsToken(input.to)) return
|
||||||
|
input.to = redactInlineUdsTokenForRejection(input.to)
|
||||||
|
}
|
||||||
|
|
||||||
function findTeammateColor(
|
function findTeammateColor(
|
||||||
appState: {
|
appState: {
|
||||||
teamContext?: { teammates: { [id: string]: { color?: string } } }
|
teamContext?: { teammates: { [id: string]: { color?: string } } }
|
||||||
@@ -541,15 +576,17 @@ export const SendMessageTool: Tool<InputSchema, SendMessageToolOutput> =
|
|||||||
},
|
},
|
||||||
|
|
||||||
backfillObservableInput(input) {
|
backfillObservableInput(input) {
|
||||||
if ('type' in input) return
|
|
||||||
if (typeof input.to !== 'string') return
|
if (typeof input.to !== 'string') return
|
||||||
|
|
||||||
|
redactObservableInlineUdsToken(input as { to: string })
|
||||||
|
if ('type' in input) return
|
||||||
|
|
||||||
if (input.to === '*') {
|
if (input.to === '*') {
|
||||||
input.type = 'broadcast'
|
input.type = 'broadcast'
|
||||||
if (typeof input.message === 'string') input.content = input.message
|
if (typeof input.message === 'string') input.content = input.message
|
||||||
} else if (typeof input.message === 'string') {
|
} else if (typeof input.message === 'string') {
|
||||||
input.type = 'message'
|
input.type = 'message'
|
||||||
input.recipient = input.to
|
input.recipient = recipientForDisplay(input.to)
|
||||||
input.content = input.message
|
input.content = input.message
|
||||||
} else if (typeof input.message === 'object' && input.message !== null) {
|
} else if (typeof input.message === 'object' && input.message !== null) {
|
||||||
const msg = input.message as {
|
const msg = input.message as {
|
||||||
@@ -560,7 +597,7 @@ export const SendMessageTool: Tool<InputSchema, SendMessageToolOutput> =
|
|||||||
feedback?: string
|
feedback?: string
|
||||||
}
|
}
|
||||||
input.type = msg.type
|
input.type = msg.type
|
||||||
input.recipient = input.to
|
input.recipient = recipientForDisplay(input.to)
|
||||||
if (msg.request_id !== undefined) input.request_id = msg.request_id
|
if (msg.request_id !== undefined) input.request_id = msg.request_id
|
||||||
if (msg.approve !== undefined) input.approve = msg.approve
|
if (msg.approve !== undefined) input.approve = msg.approve
|
||||||
const content = msg.reason ?? msg.feedback
|
const content = msg.reason ?? msg.feedback
|
||||||
@@ -569,16 +606,17 @@ export const SendMessageTool: Tool<InputSchema, SendMessageToolOutput> =
|
|||||||
},
|
},
|
||||||
|
|
||||||
toAutoClassifierInput(input) {
|
toAutoClassifierInput(input) {
|
||||||
|
const recipient = recipientForDisplay(input.to)
|
||||||
if (typeof input.message === 'string') {
|
if (typeof input.message === 'string') {
|
||||||
return `to ${input.to}: ${input.message}`
|
return `to ${recipient}: ${input.message}`
|
||||||
}
|
}
|
||||||
switch (input.message.type) {
|
switch (input.message.type) {
|
||||||
case 'shutdown_request':
|
case 'shutdown_request':
|
||||||
return `shutdown_request to ${input.to}`
|
return `shutdown_request to ${recipient}`
|
||||||
case 'shutdown_response':
|
case 'shutdown_response':
|
||||||
return `shutdown_response ${input.message.approve ? 'approve' : 'reject'} ${input.message.request_id}`
|
return `shutdown_response ${input.message.approve ? 'approve' : 'reject'} ${input.message.request_id}`
|
||||||
case 'plan_approval_response':
|
case 'plan_approval_response':
|
||||||
return `plan_approval ${input.message.approve ? 'approve' : 'reject'} to ${input.to}`
|
return `plan_approval ${input.message.approve ? 'approve' : 'reject'} to ${recipient}`
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -630,6 +668,17 @@ export const SendMessageTool: Tool<InputSchema, SendMessageToolOutput> =
|
|||||||
errorCode: 9,
|
errorCode: 9,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
addr.scheme === 'uds' &&
|
||||||
|
hasInlineUdsToken(input.to)
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
result: false,
|
||||||
|
message:
|
||||||
|
'uds addresses must not include inline auth tokens; use the ListPeers address',
|
||||||
|
errorCode: 9,
|
||||||
|
}
|
||||||
|
}
|
||||||
if (input.to.includes('@')) {
|
if (input.to.includes('@')) {
|
||||||
return {
|
return {
|
||||||
result: false,
|
result: false,
|
||||||
@@ -753,6 +802,19 @@ export const SendMessageTool: Tool<InputSchema, SendMessageToolOutput> =
|
|||||||
},
|
},
|
||||||
|
|
||||||
async call(input, context, canUseTool, assistantMessage) {
|
async call(input, context, canUseTool, assistantMessage) {
|
||||||
|
if (typeof input.message === 'string') {
|
||||||
|
const addr = parseAddress(input.to)
|
||||||
|
if (addr.scheme === 'uds' && hasInlineUdsToken(input.to)) {
|
||||||
|
return {
|
||||||
|
data: {
|
||||||
|
success: false,
|
||||||
|
message:
|
||||||
|
'uds addresses must not include inline auth tokens; use the ListPeers address',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (feature('UDS_INBOX') && typeof input.message === 'string') {
|
if (feature('UDS_INBOX') && typeof input.message === 'string') {
|
||||||
const addr = parseAddress(input.to)
|
const addr = parseAddress(input.to)
|
||||||
if (addr.scheme === 'bridge') {
|
if (addr.scheme === 'bridge') {
|
||||||
@@ -772,10 +834,10 @@ export const SendMessageTool: Tool<InputSchema, SendMessageToolOutput> =
|
|||||||
const { postInterClaudeMessage } =
|
const { postInterClaudeMessage } =
|
||||||
require('src/bridge/peerSessions.js') as typeof import('src/bridge/peerSessions.js')
|
require('src/bridge/peerSessions.js') as typeof import('src/bridge/peerSessions.js')
|
||||||
/* eslint-enable @typescript-eslint/no-require-imports */
|
/* eslint-enable @typescript-eslint/no-require-imports */
|
||||||
const result = await postInterClaudeMessage(
|
const result = (await postInterClaudeMessage(
|
||||||
addr.target,
|
addr.target,
|
||||||
input.message,
|
input.message,
|
||||||
) as { ok: boolean; error?: string }
|
)) as { ok: boolean; error?: string }
|
||||||
const preview = input.summary || truncate(input.message, 50)
|
const preview = input.summary || truncate(input.message, 50)
|
||||||
return {
|
return {
|
||||||
data: {
|
data: {
|
||||||
@@ -787,6 +849,7 @@ export const SendMessageTool: Tool<InputSchema, SendMessageToolOutput> =
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (addr.scheme === 'uds') {
|
if (addr.scheme === 'uds') {
|
||||||
|
const recipient = recipientForDisplay(input.to)
|
||||||
/* eslint-disable @typescript-eslint/no-require-imports */
|
/* eslint-disable @typescript-eslint/no-require-imports */
|
||||||
const { sendToUdsSocket } =
|
const { sendToUdsSocket } =
|
||||||
require('src/utils/udsClient.js') as typeof import('src/utils/udsClient.js')
|
require('src/utils/udsClient.js') as typeof import('src/utils/udsClient.js')
|
||||||
@@ -797,14 +860,14 @@ export const SendMessageTool: Tool<InputSchema, SendMessageToolOutput> =
|
|||||||
return {
|
return {
|
||||||
data: {
|
data: {
|
||||||
success: true,
|
success: true,
|
||||||
message: `”${preview}” → ${input.to}`,
|
message: `”${preview}” → ${recipient}`,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return {
|
return {
|
||||||
data: {
|
data: {
|
||||||
success: false,
|
success: false,
|
||||||
message: `Failed to send to ${input.to}: ${errorMessage(e)}`,
|
message: `Failed to send to ${recipient}: ${errorMessage(e)}`,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,181 @@
|
|||||||
|
import { describe, expect, test } from 'bun:test'
|
||||||
|
import { SendMessageTool } from '../SendMessageTool.js'
|
||||||
|
|
||||||
|
describe('SendMessageTool UDS recipient handling', () => {
|
||||||
|
test('redacts inline UDS tokens before classifier and observable paths', async () => {
|
||||||
|
const tokenAddress = 'uds:/tmp/peer.sock#token=secret-token'
|
||||||
|
|
||||||
|
const observableInput = {
|
||||||
|
to: tokenAddress,
|
||||||
|
message: 'hello',
|
||||||
|
} as Record<string, unknown>
|
||||||
|
SendMessageTool.backfillObservableInput!(observableInput)
|
||||||
|
|
||||||
|
expect(observableInput.recipient).toBe('uds:/tmp/peer.sock')
|
||||||
|
expect(observableInput.to).toBe('uds:/tmp/peer.sock#token=')
|
||||||
|
expect(JSON.stringify(observableInput)).not.toContain('secret-token')
|
||||||
|
expect(
|
||||||
|
SendMessageTool.toAutoClassifierInput({
|
||||||
|
to: tokenAddress,
|
||||||
|
message: 'hello',
|
||||||
|
}),
|
||||||
|
).toBe('to uds:/tmp/peer.sock: hello')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('keeps redacted UDS token rejection through observable backfill', async () => {
|
||||||
|
const observableInput = {
|
||||||
|
to: 'uds:/tmp/peer.sock#token=secret-token',
|
||||||
|
message: {
|
||||||
|
type: 'plan_approval_response',
|
||||||
|
request_id: 'req-1',
|
||||||
|
approve: false,
|
||||||
|
reason: 'needs tests',
|
||||||
|
},
|
||||||
|
} as Record<string, unknown>
|
||||||
|
|
||||||
|
SendMessageTool.backfillObservableInput!(observableInput)
|
||||||
|
|
||||||
|
expect(observableInput.to).toBe('uds:/tmp/peer.sock#token=')
|
||||||
|
expect(observableInput.recipient).toBe('uds:/tmp/peer.sock')
|
||||||
|
expect(observableInput.type).toBe('plan_approval_response')
|
||||||
|
expect(observableInput.request_id).toBe('req-1')
|
||||||
|
expect(observableInput.approve).toBe(false)
|
||||||
|
expect(observableInput.content).toBe('needs tests')
|
||||||
|
expect(JSON.stringify(observableInput)).not.toContain('secret-token')
|
||||||
|
|
||||||
|
const result = await SendMessageTool.validateInput!(
|
||||||
|
observableInput as never,
|
||||||
|
{} as never,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(result.result).toBe(false)
|
||||||
|
if (result.result !== false) {
|
||||||
|
throw new Error('expected validation to reject redacted inline UDS token')
|
||||||
|
}
|
||||||
|
expect(result.message).toContain('inline auth tokens')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('keeps inline-token rejection when observable input is cloned', async () => {
|
||||||
|
const observableInput = {
|
||||||
|
to: 'uds:/tmp/peer.sock#token=secret-token',
|
||||||
|
message: 'hello',
|
||||||
|
} as Record<string, unknown>
|
||||||
|
|
||||||
|
SendMessageTool.backfillObservableInput!(observableInput)
|
||||||
|
const clonedInput = {
|
||||||
|
to: observableInput.to,
|
||||||
|
message: observableInput.message,
|
||||||
|
summary: 'hello peer',
|
||||||
|
}
|
||||||
|
|
||||||
|
const validation = await SendMessageTool.validateInput!(
|
||||||
|
clonedInput as never,
|
||||||
|
{} as never,
|
||||||
|
)
|
||||||
|
const result = await SendMessageTool.call(
|
||||||
|
clonedInput as never,
|
||||||
|
{} as never,
|
||||||
|
undefined as never,
|
||||||
|
undefined as never,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(validation.result).toBe(false)
|
||||||
|
expect(result.data.success).toBe(false)
|
||||||
|
expect(JSON.stringify(clonedInput)).not.toContain('secret-token')
|
||||||
|
expect(JSON.stringify(result)).not.toContain('secret-token')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('redacts UDS tokens in structured classifier text', async () => {
|
||||||
|
const to = 'uds:/tmp/peer.sock#token=secret-token'
|
||||||
|
|
||||||
|
expect(
|
||||||
|
SendMessageTool.toAutoClassifierInput({
|
||||||
|
to,
|
||||||
|
message: { type: 'shutdown_request' },
|
||||||
|
}),
|
||||||
|
).toBe('shutdown_request to uds:/tmp/peer.sock')
|
||||||
|
expect(
|
||||||
|
SendMessageTool.toAutoClassifierInput({
|
||||||
|
to,
|
||||||
|
message: {
|
||||||
|
type: 'plan_approval_response',
|
||||||
|
request_id: 'req-1',
|
||||||
|
approve: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).toBe('plan_approval approve to uds:/tmp/peer.sock')
|
||||||
|
expect(
|
||||||
|
SendMessageTool.toAutoClassifierInput({
|
||||||
|
to,
|
||||||
|
message: {
|
||||||
|
type: 'plan_approval_response',
|
||||||
|
request_id: 'req-2',
|
||||||
|
approve: false,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).toBe('plan_approval reject to uds:/tmp/peer.sock')
|
||||||
|
expect(
|
||||||
|
SendMessageTool.toAutoClassifierInput({
|
||||||
|
to,
|
||||||
|
message: {
|
||||||
|
type: 'shutdown_response',
|
||||||
|
request_id: 'shutdown-1',
|
||||||
|
approve: false,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).toBe('shutdown_response reject shutdown-1')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('redacts from the first inline UDS token marker', async () => {
|
||||||
|
const tokenAddress = 'uds:/tmp/peer.sock#token=first#token=second'
|
||||||
|
|
||||||
|
const observableInput = {
|
||||||
|
to: tokenAddress,
|
||||||
|
message: 'hello',
|
||||||
|
} as Record<string, unknown>
|
||||||
|
SendMessageTool.backfillObservableInput!(observableInput)
|
||||||
|
|
||||||
|
expect(observableInput.to).toBe('uds:/tmp/peer.sock#token=')
|
||||||
|
expect(observableInput.recipient).toBe('uds:/tmp/peer.sock')
|
||||||
|
expect(JSON.stringify(observableInput)).not.toContain('first')
|
||||||
|
expect(JSON.stringify(observableInput)).not.toContain('second')
|
||||||
|
expect(
|
||||||
|
SendMessageTool.toAutoClassifierInput({
|
||||||
|
to: tokenAddress,
|
||||||
|
message: 'hello',
|
||||||
|
}),
|
||||||
|
).toBe('to uds:/tmp/peer.sock: hello')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('rejects inline UDS tokens during validation', async () => {
|
||||||
|
const result = await SendMessageTool.validateInput!(
|
||||||
|
{
|
||||||
|
to: 'uds:/tmp/peer.sock#token=secret-token',
|
||||||
|
message: 'hello',
|
||||||
|
},
|
||||||
|
{} as never,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(result.result).toBe(false)
|
||||||
|
if (result.result !== false) {
|
||||||
|
throw new Error('expected validation to reject inline UDS token')
|
||||||
|
}
|
||||||
|
expect(result.message).toContain('inline auth tokens')
|
||||||
|
expect(JSON.stringify(result)).not.toContain('secret-token')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('rejects inline UDS tokens during execution without leaking them', async () => {
|
||||||
|
const result = await SendMessageTool.call(
|
||||||
|
{
|
||||||
|
to: 'uds:/tmp/peer.sock#token=secret-token',
|
||||||
|
message: 'hello',
|
||||||
|
},
|
||||||
|
{} as never,
|
||||||
|
undefined as never,
|
||||||
|
undefined as never,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(result.data.success).toBe(false)
|
||||||
|
expect(JSON.stringify(result)).not.toContain('secret-token')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import { beforeEach, describe, expect, mock, test } from 'bun:test'
|
||||||
|
import { logMock } from '../../../../../../tests/mocks/log'
|
||||||
|
|
||||||
|
type MockAxiosResponse = {
|
||||||
|
data: ArrayBuffer
|
||||||
|
headers: Record<string, unknown>
|
||||||
|
status: number
|
||||||
|
statusText: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type MockAxiosError = Error & {
|
||||||
|
isAxiosError: true
|
||||||
|
response?: {
|
||||||
|
headers: Record<string, unknown>
|
||||||
|
status: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let getMock: (url: string) => Promise<MockAxiosResponse>
|
||||||
|
|
||||||
|
mock.module('axios', () => {
|
||||||
|
const axiosMock = {
|
||||||
|
get: (url: string) => getMock(url),
|
||||||
|
isAxiosError: (error: unknown): error is MockAxiosError =>
|
||||||
|
typeof error === 'object' &&
|
||||||
|
error !== null &&
|
||||||
|
(error as { isAxiosError?: unknown }).isAxiosError === true,
|
||||||
|
}
|
||||||
|
|
||||||
|
return { default: axiosMock }
|
||||||
|
})
|
||||||
|
|
||||||
|
mock.module('src/services/analytics/index.js', () => ({
|
||||||
|
logEvent: () => {},
|
||||||
|
}))
|
||||||
|
|
||||||
|
mock.module('src/services/api/claude.js', () => ({
|
||||||
|
queryHaiku: async () => ({ message: { content: [] } }),
|
||||||
|
}))
|
||||||
|
|
||||||
|
mock.module('src/utils/http.js', () => ({
|
||||||
|
getWebFetchUserAgent: () => 'TestAgent/1.0',
|
||||||
|
}))
|
||||||
|
|
||||||
|
mock.module('src/utils/log.ts', logMock)
|
||||||
|
|
||||||
|
mock.module('src/utils/mcpOutputStorage.js', () => ({
|
||||||
|
isBinaryContentType: (contentType: string) =>
|
||||||
|
!contentType.toLowerCase().startsWith('text/'),
|
||||||
|
persistBinaryContent: async () => ({
|
||||||
|
filepath: '/tmp/webfetch-test.bin',
|
||||||
|
size: 0,
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
mock.module('src/utils/settings/settings.js', () => ({
|
||||||
|
getInitialSettings: () => ({}),
|
||||||
|
getSettings_DEPRECATED: () => ({ skipWebFetchPreflight: true }),
|
||||||
|
}))
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
getMock = async () => ({
|
||||||
|
data: new TextEncoder().encode('hello').buffer,
|
||||||
|
headers: { 'content-type': 'text/plain' },
|
||||||
|
status: 200,
|
||||||
|
statusText: 'OK',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('WebFetch response headers', () => {
|
||||||
|
test('reads redirect Location from AxiosHeaders-style get()', async () => {
|
||||||
|
getMock = async () => {
|
||||||
|
const error = new Error('redirect') as MockAxiosError
|
||||||
|
error.isAxiosError = true
|
||||||
|
error.response = {
|
||||||
|
headers: {
|
||||||
|
get: (name: string) =>
|
||||||
|
name.toLowerCase() === 'location' ? '/next' : undefined,
|
||||||
|
},
|
||||||
|
status: 302,
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
const { getWithPermittedRedirects } = await import('../utils')
|
||||||
|
const result = await getWithPermittedRedirects(
|
||||||
|
'https://example.com/old',
|
||||||
|
new AbortController().signal,
|
||||||
|
() => false,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
type: 'redirect',
|
||||||
|
originalUrl: 'https://example.com/old',
|
||||||
|
redirectUrl: 'https://example.com/next',
|
||||||
|
statusCode: 302,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('reads proxy block markers from normalized headers', async () => {
|
||||||
|
getMock = async () => {
|
||||||
|
const error = new Error('blocked') as MockAxiosError
|
||||||
|
error.isAxiosError = true
|
||||||
|
error.response = {
|
||||||
|
headers: { 'x-proxy-error': 'blocked-by-allowlist' },
|
||||||
|
status: 403,
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
const { getWithPermittedRedirects } = await import('../utils')
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
getWithPermittedRedirects(
|
||||||
|
'https://blocked.example/path',
|
||||||
|
new AbortController().signal,
|
||||||
|
() => false,
|
||||||
|
),
|
||||||
|
).rejects.toThrow('EGRESS_BLOCKED')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('normalizes array content-type before cache and parsing', async () => {
|
||||||
|
getMock = async () => ({
|
||||||
|
data: new TextEncoder().encode('plain body').buffer,
|
||||||
|
headers: { 'content-type': ['text/plain', 'charset=utf-8'] },
|
||||||
|
status: 200,
|
||||||
|
statusText: 'OK',
|
||||||
|
})
|
||||||
|
|
||||||
|
const { clearWebFetchCache, getURLMarkdownContent } = await import('../utils')
|
||||||
|
clearWebFetchCache()
|
||||||
|
|
||||||
|
const result = await getURLMarkdownContent(
|
||||||
|
'https://example.com/plain.txt',
|
||||||
|
new AbortController(),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect('type' in result).toBe(false)
|
||||||
|
if ('type' in result) {
|
||||||
|
throw new Error('unexpected redirect result')
|
||||||
|
}
|
||||||
|
expect(result.content).toBe('plain body')
|
||||||
|
expect(result.contentType).toBe('text/plain, charset=utf-8')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -82,6 +82,34 @@ export function clearWebFetchCache(): void {
|
|||||||
DOMAIN_CHECK_CACHE.clear()
|
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
|
// Lazy singleton — defers the turndown → @mixmark-io/domino import (~1.4MB
|
||||||
// retained heap) until the first HTML fetch, and reuses one instance across
|
// retained heap) until the first HTML fetch, and reuses one instance across
|
||||||
// calls (construction builds 15 rule objects; .turndown() is stateless).
|
// calls (construction builds 15 rule objects; .turndown() is stateless).
|
||||||
@@ -286,7 +314,7 @@ export async function getWithPermittedRedirects(
|
|||||||
error.response &&
|
error.response &&
|
||||||
[301, 302, 307, 308].includes(error.response.status)
|
[301, 302, 307, 308].includes(error.response.status)
|
||||||
) {
|
) {
|
||||||
const redirectLocation = error.response.headers.location
|
const redirectLocation = getResponseHeader(error.response.headers, 'location')
|
||||||
if (!redirectLocation) {
|
if (!redirectLocation) {
|
||||||
throw new Error('Redirect missing Location header')
|
throw new Error('Redirect missing Location header')
|
||||||
}
|
}
|
||||||
@@ -318,7 +346,8 @@ export async function getWithPermittedRedirects(
|
|||||||
if (
|
if (
|
||||||
axios.isAxiosError(error) &&
|
axios.isAxiosError(error) &&
|
||||||
error.response?.status === 403 &&
|
error.response?.status === 403 &&
|
||||||
error.response.headers['x-proxy-error'] === 'blocked-by-allowlist'
|
getResponseHeader(error.response.headers, 'x-proxy-error') ===
|
||||||
|
'blocked-by-allowlist'
|
||||||
) {
|
) {
|
||||||
const hostname = new URL(url).hostname
|
const hostname = new URL(url).hostname
|
||||||
throw new EgressBlockedError(hostname)
|
throw new EgressBlockedError(hostname)
|
||||||
@@ -430,7 +459,7 @@ export async function getURLMarkdownContent(
|
|||||||
// This lets GC reclaim up to MAX_HTTP_CONTENT_LENGTH (10MB) before Turndown
|
// 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).
|
// builds its DOM tree (which can be 3-5x the HTML size).
|
||||||
;(response as { data: unknown }).data = null
|
;(response as { data: unknown }).data = null
|
||||||
const contentType = response.headers['content-type'] ?? ''
|
const contentType = getResponseHeader(response.headers, 'content-type') ?? ''
|
||||||
|
|
||||||
// Binary content: save raw bytes to disk with a proper extension so Claude
|
// 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 +
|
// can inspect the file later. We still fall through to the utf-8 decode +
|
||||||
|
|||||||
@@ -13,10 +13,9 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ai-sdk/react": "^3.0.170",
|
"@ai-sdk/react": "^3.0.170",
|
||||||
"ai": "^6.0.168",
|
"ai": "^6.0.168",
|
||||||
"hono": "^4.7.0",
|
"hono": "^4.12.15",
|
||||||
"jsqr": "^1.4.0",
|
"jsqr": "^1.4.0",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"uuid": "^11.0.0",
|
|
||||||
"@radix-ui/react-collapsible": "^1.1.12",
|
"@radix-ui/react-collapsible": "^1.1.12",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
@@ -51,7 +50,6 @@
|
|||||||
"@types/qrcode": "^1.5.6",
|
"@types/qrcode": "^1.5.6",
|
||||||
"@types/react": "^19.0.0",
|
"@types/react": "^19.0.0",
|
||||||
"@types/react-dom": "^19.0.0",
|
"@types/react-dom": "^19.0.0",
|
||||||
"@types/uuid": "^10.0.0",
|
|
||||||
"@vitejs/plugin-react": "^4.0.0",
|
"@vitejs/plugin-react": "^4.0.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ const mockConfig = {
|
|||||||
heartbeatInterval: 20,
|
heartbeatInterval: 20,
|
||||||
jwtExpiresIn: 3600,
|
jwtExpiresIn: 3600,
|
||||||
disconnectTimeout: 300,
|
disconnectTimeout: 300,
|
||||||
|
webCorsOrigins: [],
|
||||||
|
wsIdleTimeout: 30,
|
||||||
|
wsKeepaliveInterval: 20,
|
||||||
};
|
};
|
||||||
|
|
||||||
mock.module("../config", () => ({
|
mock.module("../config", () => ({
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ const mockConfig = {
|
|||||||
heartbeatInterval: 20,
|
heartbeatInterval: 20,
|
||||||
jwtExpiresIn: 3600,
|
jwtExpiresIn: 3600,
|
||||||
disconnectTimeout: 300,
|
disconnectTimeout: 300,
|
||||||
|
webCorsOrigins: [],
|
||||||
|
wsIdleTimeout: 30,
|
||||||
|
wsKeepaliveInterval: 20,
|
||||||
};
|
};
|
||||||
|
|
||||||
mock.module("../config", () => ({
|
mock.module("../config", () => ({
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ const mockConfig = {
|
|||||||
heartbeatInterval: 20,
|
heartbeatInterval: 20,
|
||||||
jwtExpiresIn: 3600,
|
jwtExpiresIn: 3600,
|
||||||
disconnectTimeout: 300,
|
disconnectTimeout: 300,
|
||||||
|
webCorsOrigins: ["https://dashboard.example"],
|
||||||
|
wsIdleTimeout: 30,
|
||||||
|
wsKeepaliveInterval: 20,
|
||||||
};
|
};
|
||||||
|
|
||||||
mock.module("../config", () => ({
|
mock.module("../config", () => ({
|
||||||
@@ -18,10 +21,23 @@ mock.module("../config", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
|
import { cors } from "hono/cors";
|
||||||
import { storeReset, storeCreateUser } from "../store";
|
import { storeReset, storeCreateUser } from "../store";
|
||||||
import { apiKeyAuth, sessionIngressAuth, uuidAuth, getUuidFromRequest } from "../auth/middleware";
|
import {
|
||||||
|
apiKeyAuth,
|
||||||
|
encodeWebSocketAuthProtocol,
|
||||||
|
extractWebSocketAuthToken,
|
||||||
|
sessionIngressAuth,
|
||||||
|
uuidAuth,
|
||||||
|
getUuidFromRequest,
|
||||||
|
} from "../auth/middleware";
|
||||||
import { issueToken } from "../auth/token";
|
import { issueToken } from "../auth/token";
|
||||||
import { generateWorkerJwt } from "../auth/jwt";
|
import { generateWorkerJwt } from "../auth/jwt";
|
||||||
|
import {
|
||||||
|
getAllowedWebCorsOrigins,
|
||||||
|
resolveWebCorsOrigin,
|
||||||
|
webCorsOptions,
|
||||||
|
} from "../auth/cors";
|
||||||
|
|
||||||
// Helper: create a test app with middleware and a simple handler
|
// Helper: create a test app with middleware and a simple handler
|
||||||
function createTestApp() {
|
function createTestApp() {
|
||||||
@@ -47,6 +63,10 @@ function createTestApp() {
|
|||||||
return c.json({ uuid: getUuidFromRequest(c) });
|
return c.json({ uuid: getUuidFromRequest(c) });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.get("/ws-auth-token", (c) => {
|
||||||
|
return c.json({ token: extractWebSocketAuthToken(c) ?? null });
|
||||||
|
});
|
||||||
|
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,13 +123,11 @@ describe("Auth Middleware", () => {
|
|||||||
expect(res.status).toBe(401);
|
expect(res.status).toBe(401);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("accepts token from query param", async () => {
|
test("rejects session token from query param", async () => {
|
||||||
storeCreateUser("dave");
|
storeCreateUser("dave");
|
||||||
const { token } = issueToken("dave");
|
const { token } = issueToken("dave");
|
||||||
const res = await app.request(`/api-key-test?token=${token}`);
|
const res = await app.request(`/api-key-test?token=${token}`);
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(401);
|
||||||
const body = await res.json();
|
|
||||||
expect(body.username).toBe("dave");
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -129,6 +147,15 @@ describe("Auth Middleware", () => {
|
|||||||
expect(res.status).toBe(200);
|
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 () => {
|
test("accepts valid JWT with matching session_id", async () => {
|
||||||
const jwt = generateWorkerJwt("ses_123", 3600);
|
const jwt = generateWorkerJwt("ses_123", 3600);
|
||||||
const res = await app.request("/ingress/ses_123", {
|
const res = await app.request("/ingress/ses_123", {
|
||||||
@@ -161,6 +188,24 @@ describe("Auth Middleware", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("extractWebSocketAuthToken", () => {
|
||||||
|
test("does not read tokens from query params", async () => {
|
||||||
|
const res = await app.request("/ws-auth-token?token=test-api-key");
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.token).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("reads tokens from WebSocket protocol header", async () => {
|
||||||
|
const res = await app.request("/ws-auth-token", {
|
||||||
|
headers: {
|
||||||
|
"Sec-WebSocket-Protocol": encodeWebSocketAuthProtocol("test-api-key"),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.token).toBe("test-api-key");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("uuidAuth", () => {
|
describe("uuidAuth", () => {
|
||||||
test("accepts UUID from query param", async () => {
|
test("accepts UUID from query param", async () => {
|
||||||
const res = await app.request("/uuid-test?uuid=test-uuid-1");
|
const res = await app.request("/uuid-test?uuid=test-uuid-1");
|
||||||
@@ -206,3 +251,45 @@ describe("Auth Middleware", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("Web CORS", () => {
|
||||||
|
function createCorsApp() {
|
||||||
|
const corsApp = new Hono();
|
||||||
|
corsApp.use("/web/*", cors(webCorsOptions));
|
||||||
|
corsApp.get("/web/ping", (c) => c.text("ok"));
|
||||||
|
return corsApp;
|
||||||
|
}
|
||||||
|
|
||||||
|
test("allows configured origins plus local server origins", () => {
|
||||||
|
expect(getAllowedWebCorsOrigins()).toContain("https://dashboard.example");
|
||||||
|
expect(getAllowedWebCorsOrigins()).toContain("http://localhost:3000");
|
||||||
|
expect(getAllowedWebCorsOrigins()).toContain("http://127.0.0.1:3000");
|
||||||
|
expect(resolveWebCorsOrigin("https://dashboard.example")).toBe(
|
||||||
|
"https://dashboard.example",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects unknown origins by default", () => {
|
||||||
|
expect(resolveWebCorsOrigin("https://attacker.example")).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("does not emit CORS allow-origin for unknown web origins", async () => {
|
||||||
|
const res = await createCorsApp().request("/web/ping", {
|
||||||
|
headers: { Origin: "https://attacker.example" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.headers.get("Access-Control-Allow-Origin")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("emits CORS allow-origin for configured web origins", async () => {
|
||||||
|
const res = await createCorsApp().request("/web/ping", {
|
||||||
|
headers: { Origin: "https://dashboard.example" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.headers.get("Access-Control-Allow-Origin")).toBe(
|
||||||
|
"https://dashboard.example",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ const mockConfig = {
|
|||||||
heartbeatInterval: 20,
|
heartbeatInterval: 20,
|
||||||
jwtExpiresIn: 3600,
|
jwtExpiresIn: 3600,
|
||||||
disconnectTimeout: 300,
|
disconnectTimeout: 300,
|
||||||
|
webCorsOrigins: [],
|
||||||
|
wsIdleTimeout: 30,
|
||||||
|
wsKeepaliveInterval: 20,
|
||||||
};
|
};
|
||||||
|
|
||||||
mock.module("../config", () => ({
|
mock.module("../config", () => ({
|
||||||
@@ -22,12 +25,23 @@ import { storeReset, storeCreateSession, storeCreateEnvironment, storeBindSessio
|
|||||||
import { removeEventBus, getAllEventBuses, getEventBus } from "../transport/event-bus";
|
import { removeEventBus, getAllEventBuses, getEventBus } from "../transport/event-bus";
|
||||||
import { issueToken } from "../auth/token";
|
import { issueToken } from "../auth/token";
|
||||||
import { publishSessionEvent } from "../services/transport";
|
import { publishSessionEvent } from "../services/transport";
|
||||||
|
import { encodeWebSocketAuthProtocol } from "../auth/middleware";
|
||||||
|
|
||||||
// Import route modules
|
// Import route modules
|
||||||
import v1Sessions from "../routes/v1/sessions";
|
import v1Sessions from "../routes/v1/sessions";
|
||||||
import v1Environments from "../routes/v1/environments";
|
import v1Environments from "../routes/v1/environments";
|
||||||
import v1EnvironmentsWork from "../routes/v1/environments.work";
|
import v1EnvironmentsWork from "../routes/v1/environments.work";
|
||||||
import v1SessionIngress, { websocket as sessionIngressWebsocket } from "../routes/v1/session-ingress";
|
import v1SessionIngress, {
|
||||||
|
decodeSessionIngressWsMessage,
|
||||||
|
handleSessionIngressWsPayload,
|
||||||
|
websocket as sessionIngressWebsocket,
|
||||||
|
} from "../routes/v1/session-ingress";
|
||||||
|
import {
|
||||||
|
decodeAcpWsMessageData,
|
||||||
|
hasAcpRelayAuth,
|
||||||
|
handleAcpWsPayload,
|
||||||
|
} from "../routes/acp";
|
||||||
|
import acpRoutes from "../routes/acp";
|
||||||
import v2CodeSessions from "../routes/v2/code-sessions";
|
import v2CodeSessions from "../routes/v2/code-sessions";
|
||||||
import v2Worker from "../routes/v2/worker";
|
import v2Worker from "../routes/v2/worker";
|
||||||
import v2WorkerEventsStream from "../routes/v2/worker-events-stream";
|
import v2WorkerEventsStream from "../routes/v2/worker-events-stream";
|
||||||
@@ -51,6 +65,7 @@ function createApp() {
|
|||||||
app.route("/web", webSessions);
|
app.route("/web", webSessions);
|
||||||
app.route("/web", webControl);
|
app.route("/web", webControl);
|
||||||
app.route("/web", webEnvironments);
|
app.route("/web", webEnvironments);
|
||||||
|
app.route("/acp", acpRoutes);
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1160,6 +1175,83 @@ describe("V1 Session Ingress Routes (HTTP)", () => {
|
|||||||
expect(events[0]?.type).toBe("assistant");
|
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 () => {
|
test("GET /v2/session_ingress/ws/:sessionId — resolves compat code session IDs", async () => {
|
||||||
const sessRes = await app.request("/v1/code/sessions", {
|
const sessRes = await app.request("/v1/code/sessions", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -1184,7 +1276,9 @@ describe("V1 Session Ingress Routes (HTTP)", () => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const message = await new Promise<string>((resolve, reject) => {
|
const message = await new Promise<string>((resolve, reject) => {
|
||||||
const ws = new WebSocket(`ws://127.0.0.1:${server.port}/v2/session_ingress/ws/${compatId}?token=test-api-key`);
|
const ws = new WebSocket(`ws://127.0.0.1:${server.port}/v2/session_ingress/ws/${compatId}`, [
|
||||||
|
encodeWebSocketAuthProtocol("test-api-key"),
|
||||||
|
]);
|
||||||
const timeout = setTimeout(() => {
|
const timeout = setTimeout(() => {
|
||||||
ws.close();
|
ws.close();
|
||||||
reject(new Error("Timed out waiting for compat WebSocket replay"));
|
reject(new Error("Timed out waiting for compat WebSocket replay"));
|
||||||
@@ -1205,7 +1299,7 @@ describe("V1 Session Ingress Routes (HTTP)", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(message).toContain("\"type\":\"user\"");
|
expect(message).toContain("\"type\":\"user\"");
|
||||||
expect(message).toContain(`\"session_id\":\"${id}\"`);
|
expect(message).toContain(`"session_id":"${id}"`);
|
||||||
expect(message).toContain("compat ws replay");
|
expect(message).toContain("compat ws replay");
|
||||||
} finally {
|
} finally {
|
||||||
await server.stop(true);
|
await server.stop(true);
|
||||||
@@ -1213,6 +1307,383 @@ describe("V1 Session Ingress Routes (HTTP)", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("ACP Routes", () => {
|
||||||
|
let app: Hono;
|
||||||
|
|
||||||
|
function createRelayAuthApp() {
|
||||||
|
const authApp = new Hono();
|
||||||
|
authApp.get("/relay-auth", (c) => c.json({ ok: hasAcpRelayAuth(c) }));
|
||||||
|
return authApp;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
storeReset();
|
||||||
|
for (const [key] of getAllEventBuses()) {
|
||||||
|
removeEventBus(key);
|
||||||
|
}
|
||||||
|
app = createApp();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("GET /acp/agents requires auth", async () => {
|
||||||
|
const res = await app.request("/acp/agents");
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("GET /acp/agents rejects UUID-only auth", async () => {
|
||||||
|
const res = await app.request("/acp/agents?uuid=user-1");
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("GET /acp/agents accepts API key header", async () => {
|
||||||
|
storeCreateEnvironment({
|
||||||
|
secret: "secret",
|
||||||
|
machineName: "agent-one",
|
||||||
|
workerType: "acp",
|
||||||
|
bridgeId: "group-one",
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await app.request("/acp/agents", {
|
||||||
|
headers: AUTH_HEADERS,
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body).toHaveLength(1);
|
||||||
|
expect(body[0].agent_name).toBe("agent-one");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("GET /acp/channel-groups requires auth", async () => {
|
||||||
|
const res = await app.request("/acp/channel-groups");
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("GET /acp/channel-groups rejects UUID-only auth", async () => {
|
||||||
|
const res = await app.request("/acp/channel-groups?uuid=user-1");
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("GET /acp/channel-groups accepts API key header", async () => {
|
||||||
|
storeCreateEnvironment({
|
||||||
|
secret: "secret",
|
||||||
|
machineName: "agent-one",
|
||||||
|
workerType: "acp",
|
||||||
|
bridgeId: "group-one",
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await app.request("/acp/channel-groups", {
|
||||||
|
headers: AUTH_HEADERS,
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body).toHaveLength(1);
|
||||||
|
expect(body[0].channel_group_id).toBe("group-one");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("GET /acp/channel-groups/:id requires auth", async () => {
|
||||||
|
storeCreateEnvironment({
|
||||||
|
secret: "secret",
|
||||||
|
machineName: "agent-one",
|
||||||
|
workerType: "acp",
|
||||||
|
bridgeId: "group-one",
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await app.request("/acp/channel-groups/group-one");
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("GET /acp/channel-groups/:id rejects query token auth", async () => {
|
||||||
|
storeCreateEnvironment({
|
||||||
|
secret: "secret",
|
||||||
|
machineName: "agent-one",
|
||||||
|
workerType: "acp",
|
||||||
|
bridgeId: "group-one",
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await app.request("/acp/channel-groups/group-one?token=test-api-key");
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("GET /acp/channel-groups/:id rejects UUID-only auth", async () => {
|
||||||
|
storeCreateEnvironment({
|
||||||
|
secret: "secret",
|
||||||
|
machineName: "agent-one",
|
||||||
|
workerType: "acp",
|
||||||
|
bridgeId: "group-one",
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await app.request("/acp/channel-groups/group-one?uuid=user-1");
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("GET /acp/channel-groups/:id returns group with API key auth", async () => {
|
||||||
|
storeCreateEnvironment({
|
||||||
|
secret: "secret",
|
||||||
|
machineName: "agent-one",
|
||||||
|
workerType: "acp",
|
||||||
|
bridgeId: "group-one",
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await app.request("/acp/channel-groups/group-one", {
|
||||||
|
headers: AUTH_HEADERS,
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.channel_group_id).toBe("group-one");
|
||||||
|
expect(body.member_count).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("GET /acp/channel-groups/:id/events requires auth", async () => {
|
||||||
|
const res = await app.request("/acp/channel-groups/group-one/events");
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("GET /acp/channel-groups/:id/events rejects UUID-only auth", async () => {
|
||||||
|
const res = await app.request("/acp/channel-groups/group-one/events?uuid=user-1");
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("GET /acp/channel-groups/:id/events accepts API key header", async () => {
|
||||||
|
const res = await app.request("/acp/channel-groups/group-one/events", {
|
||||||
|
headers: AUTH_HEADERS,
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.headers.get("Content-Type")).toBe("text/event-stream");
|
||||||
|
|
||||||
|
await res.body?.cancel();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ACP relay auth rejects UUID-only auth", async () => {
|
||||||
|
const res = await createRelayAuthApp().request("/relay-auth?uuid=user-1");
|
||||||
|
expect(await res.json()).toEqual({ ok: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ACP relay auth accepts API key header", async () => {
|
||||||
|
const res = await createRelayAuthApp().request("/relay-auth", {
|
||||||
|
headers: AUTH_HEADERS,
|
||||||
|
});
|
||||||
|
expect(await res.json()).toEqual({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ACP relay auth accepts WebSocket protocol auth", async () => {
|
||||||
|
const res = await createRelayAuthApp().request("/relay-auth", {
|
||||||
|
headers: {
|
||||||
|
"Sec-WebSocket-Protocol": encodeWebSocketAuthProtocol("test-api-key"),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(await res.json()).toEqual({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ACP WebSocket rejects legacy query-token auth on the real upgrade path", async () => {
|
||||||
|
const server = Bun.serve({
|
||||||
|
port: 0,
|
||||||
|
fetch: app.fetch,
|
||||||
|
websocket: {
|
||||||
|
...sessionIngressWebsocket,
|
||||||
|
idleTimeout: 30,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const close = await new Promise<CloseEvent>((resolve, reject) => {
|
||||||
|
const ws = new WebSocket(`ws://127.0.0.1:${server.port}/acp/ws?token=test-api-key`);
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
ws.close();
|
||||||
|
reject(new Error("Timed out waiting for ACP WebSocket auth rejection"));
|
||||||
|
}, 2000);
|
||||||
|
|
||||||
|
ws.onclose = (event) => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
resolve(event);
|
||||||
|
};
|
||||||
|
ws.onerror = () => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
reject(new Error("ACP WebSocket query-token test failed before close"));
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(close.code).toBe(4003);
|
||||||
|
expect(close.reason).toBe("unauthorized");
|
||||||
|
} finally {
|
||||||
|
server.stop(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ACP WebSocket accepts subprotocol auth on the real upgrade path", async () => {
|
||||||
|
const server = Bun.serve({
|
||||||
|
port: 0,
|
||||||
|
fetch: app.fetch,
|
||||||
|
websocket: {
|
||||||
|
...sessionIngressWebsocket,
|
||||||
|
idleTimeout: 30,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const message = await new Promise<string>((resolve, reject) => {
|
||||||
|
const ws = new WebSocket(`ws://127.0.0.1:${server.port}/acp/ws`, [
|
||||||
|
encodeWebSocketAuthProtocol("test-api-key"),
|
||||||
|
]);
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
ws.close();
|
||||||
|
reject(new Error("Timed out waiting for ACP WebSocket registration"));
|
||||||
|
}, 2000);
|
||||||
|
|
||||||
|
ws.onopen = () => {
|
||||||
|
ws.send(JSON.stringify({ type: "register", agent_name: "agent-one" }) + "\n");
|
||||||
|
};
|
||||||
|
ws.onmessage = (event) => {
|
||||||
|
const data = typeof event.data === "string" ? event.data : String(event.data);
|
||||||
|
if (data.includes("\"type\":\"registered\"")) {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
ws.close();
|
||||||
|
resolve(data);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
ws.onerror = () => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
reject(new Error("ACP WebSocket subprotocol auth failed"));
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(message).toContain("\"agent_id\"");
|
||||||
|
} finally {
|
||||||
|
await server.stop(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ACP relay WebSocket rejects legacy query-token auth on the real upgrade path", async () => {
|
||||||
|
const server = Bun.serve({
|
||||||
|
port: 0,
|
||||||
|
fetch: app.fetch,
|
||||||
|
websocket: {
|
||||||
|
...sessionIngressWebsocket,
|
||||||
|
idleTimeout: 30,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const close = await new Promise<CloseEvent>((resolve, reject) => {
|
||||||
|
const ws = new WebSocket(`ws://127.0.0.1:${server.port}/acp/relay/agent_123?token=test-api-key`);
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
ws.close();
|
||||||
|
reject(new Error("Timed out waiting for ACP relay query-token rejection"));
|
||||||
|
}, 2000);
|
||||||
|
|
||||||
|
ws.onclose = (event) => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
resolve(event);
|
||||||
|
};
|
||||||
|
ws.onerror = () => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
reject(new Error("ACP relay query-token test failed before close"));
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(close.code).toBe(4003);
|
||||||
|
expect(close.reason).toBe("unauthorized");
|
||||||
|
} finally {
|
||||||
|
server.stop(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ACP relay WebSocket accepts subprotocol auth on the real upgrade path", async () => {
|
||||||
|
const server = Bun.serve({
|
||||||
|
port: 0,
|
||||||
|
fetch: app.fetch,
|
||||||
|
websocket: {
|
||||||
|
...sessionIngressWebsocket,
|
||||||
|
idleTimeout: 30,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const close = await new Promise<CloseEvent>((resolve, reject) => {
|
||||||
|
const ws = new WebSocket(`ws://127.0.0.1:${server.port}/acp/relay/agent_123`, [
|
||||||
|
encodeWebSocketAuthProtocol("test-api-key"),
|
||||||
|
]);
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
ws.close();
|
||||||
|
reject(new Error("Timed out waiting for ACP relay authenticated close"));
|
||||||
|
}, 2000);
|
||||||
|
|
||||||
|
ws.onclose = (event) => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
resolve(event);
|
||||||
|
};
|
||||||
|
ws.onerror = () => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
reject(new Error("ACP relay subprotocol auth failed before close"));
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(close.code).toBe(4004);
|
||||||
|
expect(close.reason).toBe("agent not found");
|
||||||
|
} finally {
|
||||||
|
server.stop(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ACP WebSocket payload guards", () => {
|
||||||
|
test("rejects oversized multibyte text by byte size", () => {
|
||||||
|
const close = mock(() => {});
|
||||||
|
const handleMessage = mock(() => {});
|
||||||
|
const payload = "你".repeat(4 * 1024 * 1024);
|
||||||
|
const decoded = decodeAcpWsMessageData(payload);
|
||||||
|
const handled = handleAcpWsPayload(
|
||||||
|
{ close } as any,
|
||||||
|
"[ACP-WS]",
|
||||||
|
"wsId=multibyte",
|
||||||
|
payload,
|
||||||
|
handleMessage,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(decoded.ok && decoded.size).toBeGreaterThan(10 * 1024 * 1024);
|
||||||
|
expect(handled).toBe(false);
|
||||||
|
expect(handleMessage).not.toHaveBeenCalled();
|
||||||
|
expect(close).toHaveBeenCalledWith(1009, "message too large");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects oversized binary payload by byte size", () => {
|
||||||
|
const close = mock(() => {});
|
||||||
|
const handleMessage = mock(() => {});
|
||||||
|
const payload = new Uint8Array(11 * 1024 * 1024);
|
||||||
|
const decoded = decodeAcpWsMessageData(payload);
|
||||||
|
const handled = handleAcpWsPayload(
|
||||||
|
{ close } as any,
|
||||||
|
"[ACP-Relay]",
|
||||||
|
"relayWsId=binary",
|
||||||
|
payload,
|
||||||
|
handleMessage,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(decoded).toEqual({
|
||||||
|
ok: false,
|
||||||
|
reason: "message too large",
|
||||||
|
size: 11 * 1024 * 1024,
|
||||||
|
});
|
||||||
|
expect(handled).toBe(false);
|
||||||
|
expect(handleMessage).not.toHaveBeenCalled();
|
||||||
|
expect(close).toHaveBeenCalledWith(1009, "message too large");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("accepts small payload into ACP handler", () => {
|
||||||
|
const close = mock(() => {});
|
||||||
|
const handleMessage = mock(() => {});
|
||||||
|
const handled = handleAcpWsPayload(
|
||||||
|
{ close } as any,
|
||||||
|
"[ACP-WS]",
|
||||||
|
"wsId=small",
|
||||||
|
'{"type":"keep_alive"}',
|
||||||
|
handleMessage,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(handled).toBe(true);
|
||||||
|
expect(handleMessage).toHaveBeenCalledWith('{"type":"keep_alive"}');
|
||||||
|
expect(close).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("V2 Worker Events Routes", () => {
|
describe("V2 Worker Events Routes", () => {
|
||||||
let app: Hono;
|
let app: Hono;
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ const mockConfig = {
|
|||||||
heartbeatInterval: 20,
|
heartbeatInterval: 20,
|
||||||
jwtExpiresIn: 3600,
|
jwtExpiresIn: 3600,
|
||||||
disconnectTimeout: 300,
|
disconnectTimeout: 300,
|
||||||
|
webCorsOrigins: [],
|
||||||
|
wsIdleTimeout: 30,
|
||||||
|
wsKeepaliveInterval: 20,
|
||||||
};
|
};
|
||||||
|
|
||||||
mock.module("../config", () => ({
|
mock.module("../config", () => ({
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ const mockConfig = {
|
|||||||
heartbeatInterval: 20,
|
heartbeatInterval: 20,
|
||||||
jwtExpiresIn: 3600,
|
jwtExpiresIn: 3600,
|
||||||
disconnectTimeout: 300,
|
disconnectTimeout: 300,
|
||||||
|
webCorsOrigins: [],
|
||||||
|
wsIdleTimeout: 30,
|
||||||
|
wsKeepaliveInterval: 20,
|
||||||
};
|
};
|
||||||
|
|
||||||
mock.module("../config", () => ({
|
mock.module("../config", () => ({
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ const mockConfig = {
|
|||||||
heartbeatInterval: 20,
|
heartbeatInterval: 20,
|
||||||
jwtExpiresIn: 3600,
|
jwtExpiresIn: 3600,
|
||||||
disconnectTimeout: 300,
|
disconnectTimeout: 300,
|
||||||
|
webCorsOrigins: [],
|
||||||
|
wsIdleTimeout: 30,
|
||||||
|
wsKeepaliveInterval: 20,
|
||||||
};
|
};
|
||||||
|
|
||||||
mock.module("../config", () => ({
|
mock.module("../config", () => ({
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ const mockConfig = {
|
|||||||
heartbeatInterval: 20,
|
heartbeatInterval: 20,
|
||||||
jwtExpiresIn: 3600,
|
jwtExpiresIn: 3600,
|
||||||
disconnectTimeout: 300,
|
disconnectTimeout: 300,
|
||||||
|
webCorsOrigins: [],
|
||||||
|
wsIdleTimeout: 30,
|
||||||
|
wsKeepaliveInterval: 20,
|
||||||
};
|
};
|
||||||
|
|
||||||
mock.module("../config", () => ({
|
mock.module("../config", () => ({
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
import { createHash } from "node:crypto";
|
import { createHash, timingSafeEqual } from "node:crypto";
|
||||||
import { config } from "../config";
|
import { config } from "../config";
|
||||||
|
|
||||||
|
function sha256(value: string): Buffer {
|
||||||
|
return createHash("sha256").update(value).digest();
|
||||||
|
}
|
||||||
|
|
||||||
/** Validate a raw API key token string */
|
/** Validate a raw API key token string */
|
||||||
export function validateApiKey(token: string | undefined): boolean {
|
export function validateApiKey(token: string | undefined): boolean {
|
||||||
if (!token) return false;
|
if (!token) return false;
|
||||||
return config.apiKeys.includes(token);
|
const tokenHash = sha256(token);
|
||||||
|
return config.apiKeys.some((key) => timingSafeEqual(tokenHash, sha256(key)));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function hashApiKey(key: string): string {
|
export function hashApiKey(key: string): string {
|
||||||
|
|||||||
34
packages/remote-control-server/src/auth/cors.ts
Normal file
34
packages/remote-control-server/src/auth/cors.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { config } from "../config";
|
||||||
|
|
||||||
|
function originFromUrl(rawUrl: string): string | undefined {
|
||||||
|
try {
|
||||||
|
return new URL(rawUrl).origin;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAllowedWebCorsOrigins(): string[] {
|
||||||
|
const origins = new Set<string>(config.webCorsOrigins);
|
||||||
|
|
||||||
|
const baseOrigin = config.baseUrl ? originFromUrl(config.baseUrl) : undefined;
|
||||||
|
if (baseOrigin) {
|
||||||
|
origins.add(baseOrigin);
|
||||||
|
}
|
||||||
|
|
||||||
|
origins.add(`http://localhost:${config.port}`);
|
||||||
|
origins.add(`http://127.0.0.1:${config.port}`);
|
||||||
|
|
||||||
|
return [...origins];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveWebCorsOrigin(origin: string): string | undefined {
|
||||||
|
return getAllowedWebCorsOrigins().includes(origin) ? origin : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const webCorsOptions = {
|
||||||
|
origin: resolveWebCorsOrigin,
|
||||||
|
allowHeaders: ["Authorization", "Content-Type", "X-UUID"],
|
||||||
|
allowMethods: ["GET", "POST", "OPTIONS"],
|
||||||
|
credentials: false,
|
||||||
|
};
|
||||||
@@ -3,11 +3,49 @@ import { validateApiKey } from "./api-key";
|
|||||||
import { verifyWorkerJwt } from "./jwt";
|
import { verifyWorkerJwt } from "./jwt";
|
||||||
import { resolveToken } from "./token";
|
import { resolveToken } from "./token";
|
||||||
|
|
||||||
/** Extract Bearer token from Authorization header or ?token= query param */
|
const WS_AUTH_PROTOCOL_PREFIX = "rcs.auth.";
|
||||||
function extractBearerToken(c: Context): string | undefined {
|
|
||||||
|
/** Encode a bearer token for WebSocket clients that cannot send auth headers. */
|
||||||
|
export function encodeWebSocketAuthProtocol(token: string): string {
|
||||||
|
return `${WS_AUTH_PROTOCOL_PREFIX}${Buffer.from(token, "utf8").toString("base64url")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeWebSocketAuthProtocol(protocolHeader: string | undefined): string | undefined {
|
||||||
|
if (!protocolHeader) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const protocol of protocolHeader.split(",")) {
|
||||||
|
const trimmed = protocol.trim();
|
||||||
|
if (!trimmed.startsWith(WS_AUTH_PROTOCOL_PREFIX)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const encoded = trimmed.slice(WS_AUTH_PROTOCOL_PREFIX.length);
|
||||||
|
if (!encoded) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const token = Buffer.from(encoded, "base64url").toString("utf8");
|
||||||
|
return token.length > 0 ? token : undefined;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Extract a Bearer token from the Authorization header only. */
|
||||||
|
export function extractBearerToken(c: Context): string | undefined {
|
||||||
const authHeader = c.req.header("Authorization");
|
const authHeader = c.req.header("Authorization");
|
||||||
const queryToken = c.req.query("token");
|
return authHeader?.startsWith("Bearer ") ? authHeader.slice("Bearer ".length) : undefined;
|
||||||
return authHeader?.replace("Bearer ", "") || queryToken;
|
}
|
||||||
|
|
||||||
|
/** Extract auth for WebSocket upgrades without putting secrets in query strings. */
|
||||||
|
export function extractWebSocketAuthToken(c: Context): string | undefined {
|
||||||
|
return extractBearerToken(c) ?? decodeWebSocketAuthProtocol(c.req.header("Sec-WebSocket-Protocol"));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -49,7 +87,7 @@ export async function apiKeyAuth(c: Context, next: Next) {
|
|||||||
* downstream handlers to inspect session_id if needed.
|
* downstream handlers to inspect session_id if needed.
|
||||||
*/
|
*/
|
||||||
export async function sessionIngressAuth(c: Context, next: Next) {
|
export async function sessionIngressAuth(c: Context, next: Next) {
|
||||||
const token = extractBearerToken(c);
|
const token = extractWebSocketAuthToken(c);
|
||||||
|
|
||||||
if (!token) {
|
if (!token) {
|
||||||
return c.json({ error: { type: "unauthorized", message: "Missing auth token" } }, 401);
|
return c.json({ error: { type: "unauthorized", message: "Missing auth token" } }, 401);
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ export const config = {
|
|||||||
heartbeatInterval: parseInt(process.env.RCS_HEARTBEAT_INTERVAL || "20"),
|
heartbeatInterval: parseInt(process.env.RCS_HEARTBEAT_INTERVAL || "20"),
|
||||||
jwtExpiresIn: parseInt(process.env.RCS_JWT_EXPIRES_IN || "3600"),
|
jwtExpiresIn: parseInt(process.env.RCS_JWT_EXPIRES_IN || "3600"),
|
||||||
disconnectTimeout: parseInt(process.env.RCS_DISCONNECT_TIMEOUT || "300"),
|
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
|
/** Bun WebSocket idle timeout (seconds). Bun sends protocol-level pings after
|
||||||
* this many seconds of no received data. Must be shorter than any reverse
|
* this many seconds of no received data. Must be shorter than any reverse
|
||||||
* proxy's idle timeout (nginx default 60s, Cloudflare 100s). Default 30s. */
|
* proxy's idle timeout (nginx default 60s, Cloudflare 100s). Default 30s. */
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { dirname, resolve } from "node:path";
|
|||||||
import { existsSync } from "node:fs";
|
import { existsSync } from "node:fs";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import acpRoutes from "./routes/acp";
|
import acpRoutes from "./routes/acp";
|
||||||
|
import { webCorsOptions } from "./auth/cors";
|
||||||
|
|
||||||
// Routes
|
// Routes
|
||||||
import v1Environments from "./routes/v1/environments";
|
import v1Environments from "./routes/v1/environments";
|
||||||
@@ -44,7 +45,7 @@ app.use("*", async (c, next) => {
|
|||||||
}
|
}
|
||||||
await next();
|
await next();
|
||||||
});
|
});
|
||||||
app.use("/web/*", cors());
|
app.use("/web/*", cors(webCorsOptions));
|
||||||
|
|
||||||
// Health check
|
// Health check
|
||||||
app.get("/health", (c) => c.json({ status: "ok", version: config.version }));
|
app.get("/health", (c) => c.json({ status: "ok", version: config.version }));
|
||||||
|
|||||||
@@ -1,6 +1,16 @@
|
|||||||
import { Hono } from "hono";
|
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 { upgradeWebSocket } from "../../transport/ws-shared";
|
||||||
import { apiKeyAuth } from "../../auth/middleware";
|
import {
|
||||||
|
decodeWsPayload,
|
||||||
|
handleSizedWsPayload,
|
||||||
|
} from "../../transport/ws-payload";
|
||||||
|
import {
|
||||||
|
extractBearerToken,
|
||||||
|
extractWebSocketAuthToken,
|
||||||
|
} from "../../auth/middleware";
|
||||||
import { validateApiKey } from "../../auth/api-key";
|
import { validateApiKey } from "../../auth/api-key";
|
||||||
import {
|
import {
|
||||||
handleAcpWsOpen,
|
handleAcpWsOpen,
|
||||||
@@ -22,8 +32,14 @@ import { log, error as logError } from "../../logger";
|
|||||||
|
|
||||||
const app = new Hono();
|
const app = new Hono();
|
||||||
|
|
||||||
/** Maximum WebSocket message size: 10 MB */
|
type WsMessageEvent = {
|
||||||
const MAX_WS_MESSAGE_SIZE = 10 * 1024 * 1024;
|
data: WSMessageReceive;
|
||||||
|
};
|
||||||
|
|
||||||
|
type WsCloseEvent = {
|
||||||
|
code?: number;
|
||||||
|
reason?: string;
|
||||||
|
};
|
||||||
|
|
||||||
/** Response shape for an ACP agent */
|
/** Response shape for an ACP agent */
|
||||||
function toAcpAgentResponse(env: ReturnType<typeof storeGetEnvironment> & {}) {
|
function toAcpAgentResponse(env: ReturnType<typeof storeGetEnvironment> & {}) {
|
||||||
@@ -39,28 +55,33 @@ function toAcpAgentResponse(env: ReturnType<typeof storeGetEnvironment> & {}) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** GET /acp/agents — List all registered ACP agents (UUID or API key auth) */
|
function hasAcpReadAuth(c: Context): boolean {
|
||||||
|
const token = extractBearerToken(c);
|
||||||
|
return !!token && validateApiKey(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasAcpRelayAuth(c: Context): boolean {
|
||||||
|
const token = extractWebSocketAuthToken(c);
|
||||||
|
return !!token && validateApiKey(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
function acpReadUnauthorized(c: Context) {
|
||||||
|
return c.json({ error: { type: "unauthorized", message: "Missing auth" } }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET /acp/agents — List all registered ACP agents (API key auth) */
|
||||||
app.get("/agents", async (c) => {
|
app.get("/agents", async (c) => {
|
||||||
// Require at least UUID auth
|
if (!hasAcpReadAuth(c)) {
|
||||||
const uuid = c.req.query("uuid");
|
return acpReadUnauthorized(c);
|
||||||
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 agents = storeListAcpAgents();
|
||||||
return c.json(agents.map((a) => toAcpAgentResponse(a)).filter(Boolean));
|
return c.json(agents.map((a) => toAcpAgentResponse(a)).filter(Boolean));
|
||||||
});
|
});
|
||||||
|
|
||||||
/** GET /acp/channel-groups — List all channel groups with member agents (UUID or API key auth) */
|
/** GET /acp/channel-groups — List all channel groups with member agents (API key auth) */
|
||||||
app.get("/channel-groups", async (c) => {
|
app.get("/channel-groups", async (c) => {
|
||||||
const uuid = c.req.query("uuid");
|
if (!hasAcpReadAuth(c)) {
|
||||||
const authHeader = c.req.header("Authorization");
|
return acpReadUnauthorized(c);
|
||||||
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 agents = storeListAcpAgents();
|
||||||
const groupMap = new Map<string, typeof agents>();
|
const groupMap = new Map<string, typeof agents>();
|
||||||
@@ -79,8 +100,12 @@ app.get("/channel-groups", async (c) => {
|
|||||||
return c.json(groups);
|
return c.json(groups);
|
||||||
});
|
});
|
||||||
|
|
||||||
/** GET /acp/channel-groups/:id — Specific channel group detail (no auth for web UI) */
|
/** GET /acp/channel-groups/:id — Specific channel group detail (API key auth) */
|
||||||
app.get("/channel-groups/:id", async (c) => {
|
app.get("/channel-groups/:id", async (c) => {
|
||||||
|
if (!hasAcpReadAuth(c)) {
|
||||||
|
return acpReadUnauthorized(c);
|
||||||
|
}
|
||||||
|
|
||||||
const groupId = c.req.param("id")!;
|
const groupId = c.req.param("id")!;
|
||||||
const members = storeListAcpAgentsByChannelGroup(groupId);
|
const members = storeListAcpAgentsByChannelGroup(groupId);
|
||||||
if (members.length === 0) {
|
if (members.length === 0) {
|
||||||
@@ -93,14 +118,18 @@ app.get("/channel-groups/:id", async (c) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
/** SSE /acp/channel-groups/:id/events — Event stream for external consumers (no auth for web UI) */
|
/** SSE /acp/channel-groups/:id/events — Event stream for external consumers (API key auth) */
|
||||||
app.get("/channel-groups/:id/events", async (c) => {
|
app.get("/channel-groups/:id/events", async (c) => {
|
||||||
|
if (!hasAcpReadAuth(c)) {
|
||||||
|
return acpReadUnauthorized(c);
|
||||||
|
}
|
||||||
|
|
||||||
const groupId = c.req.param("id")!;
|
const groupId = c.req.param("id")!;
|
||||||
|
|
||||||
// Support Last-Event-ID / from_sequence_num for reconnection
|
// Support Last-Event-ID / from_sequence_num for reconnection
|
||||||
const lastEventId = c.req.header("Last-Event-ID");
|
const lastEventId = c.req.header("Last-Event-ID");
|
||||||
const fromSeq = c.req.query("from_sequence_num");
|
const fromSeq = c.req.query("from_sequence_num");
|
||||||
const fromSeqNum = fromSeq ? parseInt(fromSeq) : lastEventId ? parseInt(lastEventId) : 0;
|
const fromSeqNum = fromSeq ? parseInt(fromSeq, 10) : lastEventId ? parseInt(lastEventId, 10) : 0;
|
||||||
|
|
||||||
return createAcpSSEStream(c, groupId, fromSeqNum);
|
return createAcpSSEStream(c, groupId, fromSeqNum);
|
||||||
});
|
});
|
||||||
@@ -109,46 +138,38 @@ app.get("/channel-groups/:id/events", async (c) => {
|
|||||||
app.get(
|
app.get(
|
||||||
"/ws",
|
"/ws",
|
||||||
upgradeWebSocket(async (c) => {
|
upgradeWebSocket(async (c) => {
|
||||||
// Authenticate via API key in query param or header
|
const token = extractWebSocketAuthToken(c);
|
||||||
const authHeader = c.req.header("Authorization");
|
|
||||||
const queryToken = c.req.query("token");
|
|
||||||
const token = authHeader?.replace("Bearer ", "") || queryToken;
|
|
||||||
|
|
||||||
if (!token || !validateApiKey(token)) {
|
if (!token || !validateApiKey(token)) {
|
||||||
log("[ACP-WS] Upgrade rejected: unauthorized");
|
log("[ACP-WS] Upgrade rejected: unauthorized");
|
||||||
return {
|
return {
|
||||||
onOpen(_evt: any, ws: any) {
|
onOpen(_evt: Event, ws: WSContext) {
|
||||||
ws.close(4003, "unauthorized");
|
ws.close(4003, "unauthorized");
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate unique wsId for this connection
|
// Generate unique wsId for this connection
|
||||||
const { v4: uuid } = await import("uuid");
|
const wsId = `acp_ws_${randomUUID().replace(/-/g, "")}`;
|
||||||
const wsId = `acp_ws_${uuid().replace(/-/g, "")}`;
|
|
||||||
|
|
||||||
log(`[ACP-WS] Upgrade accepted: wsId=${wsId}`);
|
log(`[ACP-WS] Upgrade accepted: wsId=${wsId}`);
|
||||||
return {
|
return {
|
||||||
onOpen(_evt: any, ws: any) {
|
onOpen(_evt: Event, ws: WSContext) {
|
||||||
handleAcpWsOpen(ws, wsId);
|
handleAcpWsOpen(ws, wsId);
|
||||||
},
|
},
|
||||||
onMessage(evt: any, ws: any) {
|
onMessage(evt: WsMessageEvent, ws: WSContext) {
|
||||||
const data =
|
handleAcpWsPayload(
|
||||||
typeof evt.data === "string"
|
ws,
|
||||||
? evt.data
|
"[ACP-WS]",
|
||||||
: new TextDecoder().decode(evt.data as ArrayBuffer);
|
`wsId=${wsId}`,
|
||||||
if (data.length > MAX_WS_MESSAGE_SIZE) {
|
evt.data,
|
||||||
logError(`[ACP-WS] Message too large on wsId=${wsId}: ${data.length} bytes`);
|
data => handleAcpWsMessage(ws, wsId, data),
|
||||||
ws.close(1009, "message too large");
|
);
|
||||||
return;
|
|
||||||
}
|
|
||||||
handleAcpWsMessage(ws, wsId, data);
|
|
||||||
},
|
},
|
||||||
onClose(evt: any, ws: any) {
|
onClose(evt: WsCloseEvent, ws: WSContext) {
|
||||||
const closeEvt = evt as unknown as CloseEvent;
|
handleAcpWsClose(ws, wsId, evt.code, evt.reason);
|
||||||
handleAcpWsClose(ws, wsId, closeEvt?.code, closeEvt?.reason);
|
|
||||||
},
|
},
|
||||||
onError(evt: any, ws: any) {
|
onError(evt: Event, ws: WSContext) {
|
||||||
logError(`[ACP-WS] Error on wsId=${wsId}:`, evt);
|
logError(`[ACP-WS] Error on wsId=${wsId}:`, evt);
|
||||||
handleAcpWsClose(ws, wsId, 1006, "websocket error");
|
handleAcpWsClose(ws, wsId, 1006, "websocket error");
|
||||||
},
|
},
|
||||||
@@ -160,50 +181,36 @@ app.get(
|
|||||||
app.get(
|
app.get(
|
||||||
"/relay/:agentId",
|
"/relay/:agentId",
|
||||||
upgradeWebSocket(async (c) => {
|
upgradeWebSocket(async (c) => {
|
||||||
// Authenticate via UUID (web frontend) or API key (legacy)
|
if (!hasAcpRelayAuth(c)) {
|
||||||
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");
|
log("[ACP-Relay] Upgrade rejected: unauthorized");
|
||||||
return {
|
return {
|
||||||
onOpen(_evt: any, ws: any) {
|
onOpen(_evt: Event, ws: WSContext) {
|
||||||
ws.close(4003, "unauthorized");
|
ws.close(4003, "unauthorized");
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const agentId = c.req.param("agentId")!;
|
const agentId = c.req.param("agentId")!;
|
||||||
const { v4: uuid } = await import("uuid");
|
const relayWsId = `relay_${randomUUID().replace(/-/g, "")}`;
|
||||||
const relayWsId = `relay_${uuid().replace(/-/g, "")}`;
|
|
||||||
|
|
||||||
log(`[ACP-Relay] Upgrade accepted: relayWsId=${relayWsId} agentId=${agentId}`);
|
log(`[ACP-Relay] Upgrade accepted: relayWsId=${relayWsId} agentId=${agentId}`);
|
||||||
return {
|
return {
|
||||||
onOpen(_evt: any, ws: any) {
|
onOpen(_evt: Event, ws: WSContext) {
|
||||||
handleRelayOpen(ws, relayWsId, agentId);
|
handleRelayOpen(ws, relayWsId, agentId);
|
||||||
},
|
},
|
||||||
onMessage(evt: any, ws: any) {
|
onMessage(evt: WsMessageEvent, ws: WSContext) {
|
||||||
const data =
|
handleAcpWsPayload(
|
||||||
typeof evt.data === "string"
|
ws,
|
||||||
? evt.data
|
"[ACP-Relay]",
|
||||||
: new TextDecoder().decode(evt.data as ArrayBuffer);
|
`relayWsId=${relayWsId}`,
|
||||||
if (data.length > MAX_WS_MESSAGE_SIZE) {
|
evt.data,
|
||||||
logError(`[ACP-Relay] Message too large on relayWsId=${relayWsId}: ${data.length} bytes`);
|
data => handleRelayMessage(ws, relayWsId, data),
|
||||||
ws.close(1009, "message too large");
|
);
|
||||||
return;
|
|
||||||
}
|
|
||||||
handleRelayMessage(ws, relayWsId, data);
|
|
||||||
},
|
},
|
||||||
onClose(evt: any, ws: any) {
|
onClose(evt: WsCloseEvent, ws: WSContext) {
|
||||||
const closeEvt = evt as unknown as CloseEvent;
|
handleRelayClose(ws, relayWsId, evt.code, evt.reason);
|
||||||
handleRelayClose(ws, relayWsId, closeEvt?.code, closeEvt?.reason);
|
|
||||||
},
|
},
|
||||||
onError(evt: any, ws: any) {
|
onError(evt: Event, ws: WSContext) {
|
||||||
logError(`[ACP-Relay] Error on relayWsId=${relayWsId}:`, evt);
|
logError(`[ACP-Relay] Error on relayWsId=${relayWsId}:`, evt);
|
||||||
handleRelayClose(ws, relayWsId, 1006, "websocket error");
|
handleRelayClose(ws, relayWsId, 1006, "websocket error");
|
||||||
},
|
},
|
||||||
@@ -211,4 +218,16 @@ app.get(
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const decodeAcpWsMessageData = decodeWsPayload;
|
||||||
|
|
||||||
|
export function handleAcpWsPayload(
|
||||||
|
ws: WSContext,
|
||||||
|
logPrefix: string,
|
||||||
|
label: string,
|
||||||
|
payload: unknown,
|
||||||
|
handleMessage: (data: string) => void,
|
||||||
|
): boolean {
|
||||||
|
return handleSizedWsPayload(ws, logPrefix, label, payload, handleMessage);
|
||||||
|
}
|
||||||
|
|
||||||
export default app;
|
export default app;
|
||||||
|
|||||||
@@ -1,8 +1,15 @@
|
|||||||
import { log, error as logError } from "../../logger";
|
import { log, error as logError } from "../../logger";
|
||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
|
import type { Context } from "hono";
|
||||||
|
import type { WSContext, WSMessageReceive } from "hono/ws";
|
||||||
import { upgradeWebSocket, websocket } from "../../transport/ws-shared";
|
import { upgradeWebSocket, websocket } from "../../transport/ws-shared";
|
||||||
|
import {
|
||||||
|
decodeWsPayload,
|
||||||
|
handleSizedWsPayload,
|
||||||
|
} from "../../transport/ws-payload";
|
||||||
import { validateApiKey } from "../../auth/api-key";
|
import { validateApiKey } from "../../auth/api-key";
|
||||||
import { verifyWorkerJwt } from "../../auth/jwt";
|
import { verifyWorkerJwt } from "../../auth/jwt";
|
||||||
|
import { extractWebSocketAuthToken } from "../../auth/middleware";
|
||||||
import {
|
import {
|
||||||
handleWebSocketOpen,
|
handleWebSocketOpen,
|
||||||
handleWebSocketMessage,
|
handleWebSocketMessage,
|
||||||
@@ -13,11 +20,18 @@ import { getSession, resolveExistingSessionId } from "../../services/session";
|
|||||||
|
|
||||||
const app = new Hono();
|
const app = new Hono();
|
||||||
|
|
||||||
/** Authenticate via API key or worker JWT in Authorization header or ?token= query param */
|
type WsMessageEvent = {
|
||||||
function authenticateRequest(c: any, label: string, expectedSessionId?: string): boolean {
|
data: WSMessageReceive;
|
||||||
const authHeader = c.req.header("Authorization");
|
};
|
||||||
const queryToken = c.req.query("token");
|
|
||||||
const token = authHeader?.replace("Bearer ", "") || queryToken;
|
type WsCloseEvent = {
|
||||||
|
code?: number;
|
||||||
|
reason?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Authenticate via API key or worker JWT without accepting URL query secrets. */
|
||||||
|
function authenticateRequest(c: Context, label: string, expectedSessionId?: string): boolean {
|
||||||
|
const token = extractWebSocketAuthToken(c);
|
||||||
|
|
||||||
// Try API key first
|
// Try API key first
|
||||||
if (validateApiKey(token)) {
|
if (validateApiKey(token)) {
|
||||||
@@ -76,7 +90,7 @@ app.get(
|
|||||||
|
|
||||||
if (!authenticateRequest(c, `WS ${sessionId}`, sessionId)) {
|
if (!authenticateRequest(c, `WS ${sessionId}`, sessionId)) {
|
||||||
return {
|
return {
|
||||||
onOpen(_evt, ws) {
|
onOpen(_evt: Event, ws: WSContext) {
|
||||||
ws.close(4003, "unauthorized");
|
ws.close(4003, "unauthorized");
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -86,7 +100,7 @@ app.get(
|
|||||||
if (!session) {
|
if (!session) {
|
||||||
log(`[WS] Upgrade rejected: session ${sessionId} not found`);
|
log(`[WS] Upgrade rejected: session ${sessionId} not found`);
|
||||||
return {
|
return {
|
||||||
onOpen(_evt, ws) {
|
onOpen(_evt: Event, ws: WSContext) {
|
||||||
ws.close(4001, "session not found");
|
ws.close(4001, "session not found");
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -94,27 +108,38 @@ app.get(
|
|||||||
|
|
||||||
log(`[WS] Upgrade accepted: session=${sessionId}`);
|
log(`[WS] Upgrade accepted: session=${sessionId}`);
|
||||||
return {
|
return {
|
||||||
onOpen(_evt, ws) {
|
onOpen(_evt: Event, ws: WSContext) {
|
||||||
handleWebSocketOpen(ws as any, sessionId);
|
handleWebSocketOpen(ws, sessionId);
|
||||||
},
|
},
|
||||||
onMessage(evt, ws) {
|
onMessage(evt: WsMessageEvent, ws: WSContext) {
|
||||||
const data =
|
handleSessionIngressWsPayload(ws, sessionId, evt.data);
|
||||||
typeof evt.data === "string"
|
|
||||||
? evt.data
|
|
||||||
: new TextDecoder().decode(evt.data as ArrayBuffer);
|
|
||||||
handleWebSocketMessage(ws as any, sessionId, data);
|
|
||||||
},
|
},
|
||||||
onClose(evt, ws) {
|
onClose(evt: WsCloseEvent, ws: WSContext) {
|
||||||
const closeEvt = evt as unknown as CloseEvent;
|
handleWebSocketClose(ws, sessionId, evt.code, evt.reason);
|
||||||
handleWebSocketClose(ws as any, sessionId, closeEvt?.code, closeEvt?.reason);
|
|
||||||
},
|
},
|
||||||
onError(evt, ws) {
|
onError(evt: Event, ws: WSContext) {
|
||||||
logError(`[WS] Error on session=${sessionId}:`, evt);
|
logError(`[WS] Error on session=${sessionId}:`, evt);
|
||||||
handleWebSocketClose(ws as any, sessionId, 1006, "websocket error");
|
handleWebSocketClose(ws, sessionId, 1006, "websocket error");
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const decodeSessionIngressWsMessage = decodeWsPayload;
|
||||||
|
|
||||||
|
export function handleSessionIngressWsPayload(
|
||||||
|
ws: WSContext,
|
||||||
|
sessionId: string,
|
||||||
|
payload: unknown,
|
||||||
|
): boolean {
|
||||||
|
return handleSizedWsPayload(
|
||||||
|
ws,
|
||||||
|
"[WS]",
|
||||||
|
`session=${sessionId}`,
|
||||||
|
payload,
|
||||||
|
data => handleWebSocketMessage(ws, sessionId, data),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export { websocket };
|
export { websocket };
|
||||||
export default app;
|
export default app;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
import { getSession, incrementEpoch, touchSession, updateSessionStatus } from "../../services/session";
|
import { getSession, incrementEpoch, touchSession, updateSessionStatus } from "../../services/session";
|
||||||
import {
|
import {
|
||||||
automationStatesEqual,
|
automationStatesEqual,
|
||||||
@@ -7,7 +8,6 @@ import {
|
|||||||
import { apiKeyAuth, acceptCliHeaders, sessionIngressAuth } from "../../auth/middleware";
|
import { apiKeyAuth, acceptCliHeaders, sessionIngressAuth } from "../../auth/middleware";
|
||||||
import { getEventBus } from "../../transport/event-bus";
|
import { getEventBus } from "../../transport/event-bus";
|
||||||
import { storeGetSessionWorker, storeUpsertSessionWorker } from "../../store";
|
import { storeGetSessionWorker, storeUpsertSessionWorker } from "../../store";
|
||||||
import { v4 as uuid } from "uuid";
|
|
||||||
|
|
||||||
const app = new Hono();
|
const app = new Hono();
|
||||||
|
|
||||||
@@ -57,7 +57,7 @@ app.put("/:id/worker", acceptCliHeaders, sessionIngressAuth, async (c) => {
|
|||||||
|
|
||||||
if (!automationStatesEqual(prevAutomationState, nextAutomationState)) {
|
if (!automationStatesEqual(prevAutomationState, nextAutomationState)) {
|
||||||
getEventBus(sessionId).publish({
|
getEventBus(sessionId).publish({
|
||||||
id: uuid(),
|
id: randomUUID(),
|
||||||
sessionId,
|
sessionId,
|
||||||
type: "automation_state",
|
type: "automation_state",
|
||||||
payload: nextAutomationState,
|
payload: nextAutomationState,
|
||||||
|
|||||||
@@ -10,9 +10,9 @@ import {
|
|||||||
storeListSessionsByEnvironment,
|
storeListSessionsByEnvironment,
|
||||||
storeListSessionsByOwnerUuid,
|
storeListSessionsByOwnerUuid,
|
||||||
} from "../store";
|
} from "../store";
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
import { getAllEventBuses, removeEventBus } from "../transport/event-bus";
|
import { getAllEventBuses, removeEventBus } from "../transport/event-bus";
|
||||||
import type { CreateSessionRequest, CreateCodeSessionRequest, SessionResponse, SessionSummaryResponse } from "../types/api";
|
import type { CreateSessionRequest, CreateCodeSessionRequest, SessionResponse, SessionSummaryResponse } from "../types/api";
|
||||||
import { v4 as uuid } from "uuid";
|
|
||||||
|
|
||||||
const CODE_SESSION_PREFIX = "cse_";
|
const CODE_SESSION_PREFIX = "cse_";
|
||||||
const WEB_SESSION_PREFIX = "session_";
|
const WEB_SESSION_PREFIX = "session_";
|
||||||
@@ -145,7 +145,7 @@ export function updateSessionStatus(sessionId: string, status: string) {
|
|||||||
if (!bus) return;
|
if (!bus) return;
|
||||||
|
|
||||||
bus.publish({
|
bus.publish({
|
||||||
id: uuid(),
|
id: randomUUID(),
|
||||||
sessionId,
|
sessionId,
|
||||||
type: "session_status",
|
type: "session_status",
|
||||||
payload: { status },
|
payload: { status },
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
import { getEventBus } from "../transport/event-bus";
|
import { getEventBus } from "../transport/event-bus";
|
||||||
import { v4 as uuid } from "uuid";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extract plain text from various message payload formats.
|
* Extract plain text from various message payload formats.
|
||||||
@@ -88,7 +88,7 @@ export function publishSessionEvent(
|
|||||||
direction: "inbound" | "outbound",
|
direction: "inbound" | "outbound",
|
||||||
) {
|
) {
|
||||||
const bus = getEventBus(sessionId);
|
const bus = getEventBus(sessionId);
|
||||||
const eventId = uuid();
|
const eventId = randomUUID();
|
||||||
|
|
||||||
const normalized = normalizePayload(type, payload);
|
const normalized = normalizePayload(type, payload);
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { v4 as uuid } from "uuid";
|
import { randomUUID } from "node:crypto";
|
||||||
|
|
||||||
// ---------- Types ----------
|
// ---------- Types ----------
|
||||||
|
|
||||||
@@ -98,22 +98,6 @@ export function storeDeleteToken(token: string): boolean {
|
|||||||
|
|
||||||
// ---------- Environment ----------
|
// ---------- Environment ----------
|
||||||
|
|
||||||
/** Find an active or offline environment by machineName (optionally filtered by workerType).
|
|
||||||
* Includes "offline" so ACP agents can be reused on reconnect. */
|
|
||||||
export function storeFindEnvironmentByMachineName(
|
|
||||||
machineName: string,
|
|
||||||
workerType?: string,
|
|
||||||
): EnvironmentRecord | undefined {
|
|
||||||
for (const rec of environments.values()) {
|
|
||||||
if (rec.machineName === machineName && (rec.status === "active" || rec.status === "offline")) {
|
|
||||||
if (!workerType || rec.workerType === workerType) {
|
|
||||||
return rec;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function storeCreateEnvironment(req: {
|
export function storeCreateEnvironment(req: {
|
||||||
secret: string;
|
secret: string;
|
||||||
machineName?: string;
|
machineName?: string;
|
||||||
@@ -126,24 +110,7 @@ export function storeCreateEnvironment(req: {
|
|||||||
username?: string;
|
username?: string;
|
||||||
capabilities?: Record<string, unknown>;
|
capabilities?: Record<string, unknown>;
|
||||||
}): EnvironmentRecord {
|
}): EnvironmentRecord {
|
||||||
// ACP: reuse existing active record by machineName
|
const id = `env_${randomUUID().replace(/-/g, "")}`;
|
||||||
if (req.workerType === "acp" && req.machineName) {
|
|
||||||
const existing = storeFindEnvironmentByMachineName(req.machineName, "acp");
|
|
||||||
if (existing) {
|
|
||||||
Object.assign(existing, {
|
|
||||||
status: "active",
|
|
||||||
lastPollAt: new Date(),
|
|
||||||
updatedAt: new Date(),
|
|
||||||
maxSessions: req.maxSessions ?? existing.maxSessions,
|
|
||||||
bridgeId: req.bridgeId ?? existing.bridgeId,
|
|
||||||
capabilities: req.capabilities ?? existing.capabilities,
|
|
||||||
username: req.username ?? existing.username,
|
|
||||||
});
|
|
||||||
return existing;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const id = `env_${uuid().replace(/-/g, "")}`;
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const record: EnvironmentRecord = {
|
const record: EnvironmentRecord = {
|
||||||
id,
|
id,
|
||||||
@@ -195,7 +162,7 @@ export function storeCreateSession(req: {
|
|||||||
idPrefix?: string;
|
idPrefix?: string;
|
||||||
username?: string | null;
|
username?: string | null;
|
||||||
}): SessionRecord {
|
}): SessionRecord {
|
||||||
const id = `${req.idPrefix || "session_"}${uuid().replace(/-/g, "")}`;
|
const id = `${req.idPrefix || "session_"}${randomUUID().replace(/-/g, "")}`;
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const record: SessionRecord = {
|
const record: SessionRecord = {
|
||||||
id,
|
id,
|
||||||
@@ -350,7 +317,7 @@ export function storeCreateWorkItem(req: {
|
|||||||
sessionId: string;
|
sessionId: string;
|
||||||
secret: string;
|
secret: string;
|
||||||
}): WorkItemRecord {
|
}): WorkItemRecord {
|
||||||
const id = `work_${uuid().replace(/-/g, "")}`;
|
const id = `work_${randomUUID().replace(/-/g, "")}`;
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const record: WorkItemRecord = {
|
const record: WorkItemRecord = {
|
||||||
id,
|
id,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { WSContext } from "hono/ws";
|
import type { WSContext } from "hono/ws";
|
||||||
import { v4 as uuid } from "uuid";
|
import { randomUUID } from "node:crypto";
|
||||||
import { getAcpEventBus } from "./event-bus";
|
import { getAcpEventBus } from "./event-bus";
|
||||||
import type { SessionEvent } from "./event-bus";
|
import type { SessionEvent } from "./event-bus";
|
||||||
import {
|
import {
|
||||||
@@ -86,7 +86,7 @@ function handleRegister(wsId: string, msg: Record<string, unknown>): void {
|
|||||||
|
|
||||||
const agentName = (msg.agent_name as string) || "unknown";
|
const agentName = (msg.agent_name as string) || "unknown";
|
||||||
const capabilities = msg.capabilities as Record<string, unknown> | undefined;
|
const capabilities = msg.capabilities as Record<string, unknown> | undefined;
|
||||||
const channelGroupId = (msg.channel_group_id as string) || `group_${uuid().replace(/-/g, "").slice(0, 12)}`;
|
const channelGroupId = (msg.channel_group_id as string) || `group_${randomUUID().replace(/-/g, "").slice(0, 12)}`;
|
||||||
const acpLinkVersion = (msg.acp_link_version as string) || null;
|
const acpLinkVersion = (msg.acp_link_version as string) || null;
|
||||||
const maxSessions = typeof msg.max_sessions === "number" ? msg.max_sessions : 1;
|
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
|
// Update status to active
|
||||||
storeMarkAcpAgentOnline(agentId);
|
storeMarkAcpAgentOnline(agentId);
|
||||||
|
|
||||||
const channelGroupId = record.bridgeId || `group_${uuid().replace(/-/g, "").slice(0, 12)}`;
|
const channelGroupId = record.bridgeId || `group_${randomUUID().replace(/-/g, "").slice(0, 12)}`;
|
||||||
|
|
||||||
entry.agentId = record.id;
|
entry.agentId = record.id;
|
||||||
entry.channelGroupId = channelGroupId;
|
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
|
// Pass-through: publish to channel group EventBus as inbound
|
||||||
const bus = getAcpEventBus(entry.channelGroupId);
|
const bus = getAcpEventBus(entry.channelGroupId);
|
||||||
bus.publish({
|
bus.publish({
|
||||||
id: uuid(),
|
id: randomUUID(),
|
||||||
sessionId: entry.channelGroupId,
|
sessionId: entry.channelGroupId,
|
||||||
type: (msg.type as string) || "acp_message",
|
type: (msg.type as string) || "acp_message",
|
||||||
payload: msg,
|
payload: msg,
|
||||||
@@ -259,7 +259,7 @@ export function handleAcpWsClose(ws: WSContext, wsId: string, code?: number, rea
|
|||||||
if (entry.channelGroupId) {
|
if (entry.channelGroupId) {
|
||||||
const bus = getAcpEventBus(entry.channelGroupId);
|
const bus = getAcpEventBus(entry.channelGroupId);
|
||||||
bus.publish({
|
bus.publish({
|
||||||
id: uuid(),
|
id: randomUUID(),
|
||||||
sessionId: entry.channelGroupId,
|
sessionId: entry.channelGroupId,
|
||||||
type: "agent_disconnect",
|
type: "agent_disconnect",
|
||||||
payload: { agentId: entry.agentId },
|
payload: { agentId: entry.agentId },
|
||||||
|
|||||||
64
packages/remote-control-server/src/transport/ws-payload.ts
Normal file
64
packages/remote-control-server/src/transport/ws-payload.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import { Buffer } from "node:buffer";
|
||||||
|
import type { WSContext } from "hono/ws";
|
||||||
|
import { error as logError } from "../logger";
|
||||||
|
|
||||||
|
const textDecoder = new TextDecoder();
|
||||||
|
|
||||||
|
export const MAX_WS_MESSAGE_SIZE = 10 * 1024 * 1024;
|
||||||
|
|
||||||
|
export type DecodedWsMessage =
|
||||||
|
| { ok: true; data: string; size: number }
|
||||||
|
| { ok: false; reason: string; size?: number };
|
||||||
|
|
||||||
|
export function decodeWsPayload(data: unknown): DecodedWsMessage {
|
||||||
|
if (typeof data === "string") {
|
||||||
|
return { ok: true, data, size: Buffer.byteLength(data, "utf8") };
|
||||||
|
}
|
||||||
|
if (data instanceof ArrayBuffer) {
|
||||||
|
if (data.byteLength > MAX_WS_MESSAGE_SIZE) {
|
||||||
|
return { ok: false, reason: "message too large", size: data.byteLength };
|
||||||
|
}
|
||||||
|
return { ok: true, data: textDecoder.decode(data), size: data.byteLength };
|
||||||
|
}
|
||||||
|
if (data instanceof Uint8Array) {
|
||||||
|
if (data.byteLength > MAX_WS_MESSAGE_SIZE) {
|
||||||
|
return { ok: false, reason: "message too large", size: data.byteLength };
|
||||||
|
}
|
||||||
|
return { ok: true, data: textDecoder.decode(data), size: data.byteLength };
|
||||||
|
}
|
||||||
|
if (typeof SharedArrayBuffer !== "undefined" && data instanceof SharedArrayBuffer) {
|
||||||
|
const bytes = new Uint8Array(data);
|
||||||
|
if (bytes.byteLength > MAX_WS_MESSAGE_SIZE) {
|
||||||
|
return { ok: false, reason: "message too large", size: bytes.byteLength };
|
||||||
|
}
|
||||||
|
return { ok: true, data: textDecoder.decode(bytes), size: bytes.byteLength };
|
||||||
|
}
|
||||||
|
return { ok: false, reason: typeof data };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function handleSizedWsPayload(
|
||||||
|
ws: WSContext,
|
||||||
|
logPrefix: string,
|
||||||
|
label: string,
|
||||||
|
payload: unknown,
|
||||||
|
handleMessage: (data: string) => void,
|
||||||
|
): boolean {
|
||||||
|
const decoded = decodeWsPayload(payload);
|
||||||
|
if (!decoded.ok) {
|
||||||
|
if (decoded.reason === "message too large" && decoded.size !== undefined) {
|
||||||
|
logError(`${logPrefix} Message too large on ${label}: size=${decoded.size} limit=${MAX_WS_MESSAGE_SIZE}`);
|
||||||
|
ws.close(1009, "message too large");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
logError(`${logPrefix} Unsupported message payload on ${label}: ${decoded.reason}`);
|
||||||
|
ws.close(1003, "unsupported message payload");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (decoded.size > MAX_WS_MESSAGE_SIZE) {
|
||||||
|
logError(`${logPrefix} Message too large on ${label}: size=${decoded.size} limit=${MAX_WS_MESSAGE_SIZE}`);
|
||||||
|
ws.close(1009, "message too large");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
handleMessage(decoded.data);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
@@ -14,23 +14,25 @@ import type { ACPSettings, ConnectionState, BrowserToolParams, BrowserToolResult
|
|||||||
import { ChevronDown, FolderOpen, Globe, Image, KeyRound, ScanLine, X } from "lucide-react";
|
import { ChevronDown, FolderOpen, Globe, Image, KeyRound, ScanLine, X } from "lucide-react";
|
||||||
import { useQRScanner, type QRCodeData } from "../src/hooks";
|
import { useQRScanner, type QRCodeData } from "../src/hooks";
|
||||||
|
|
||||||
// Get token from URL query param (for pre-filled URLs from server)
|
// Get token from the URL fragment so it is not sent in HTTP requests.
|
||||||
function getTokenFromUrl(): string | undefined {
|
function getTokenFromUrl(): string | undefined {
|
||||||
try {
|
try {
|
||||||
const url = new URL(window.location.href);
|
const url = new URL(window.location.href);
|
||||||
return url.searchParams.get("token") || undefined;
|
const hashParams = new URLSearchParams(url.hash.replace(/^#/, ""));
|
||||||
|
return hashParams.get("token") || undefined;
|
||||||
} catch {
|
} catch {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Infer WebSocket URL from current page URL (for pre-filled links from server)
|
// 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 {
|
function inferProxyUrlFromPage(): string | undefined {
|
||||||
try {
|
try {
|
||||||
const url = new URL(window.location.href);
|
const url = new URL(window.location.href);
|
||||||
// Only infer if we have a token param (indicates user came from server-printed URL)
|
const hashParams = new URLSearchParams(url.hash.replace(/^#/, ""));
|
||||||
if (!url.searchParams.has("token")) {
|
// Only infer if we have a fragment token (indicates user came from server-printed URL)
|
||||||
|
if (!hashParams.has("token")) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
const protocol = url.protocol === "https:" ? "wss:" : "ws:";
|
const protocol = url.protocol === "https:" ? "wss:" : "ws:";
|
||||||
@@ -40,6 +42,23 @@ function inferProxyUrlFromPage(): string | undefined {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function scrubTokenFromUrl(): void {
|
||||||
|
try {
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
const hashParams = new URLSearchParams(url.hash.replace(/^#/, ""));
|
||||||
|
if (!hashParams.has("token")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
hashParams.delete("token");
|
||||||
|
const nextHash = hashParams.toString();
|
||||||
|
url.hash = nextHash ? `#${nextHash}` : "";
|
||||||
|
window.history.replaceState(null, "", url.toString());
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Get initial settings from defaults, with optional URL overrides
|
// Get initial settings from defaults, with optional URL overrides
|
||||||
function getInitialSettings(inferFromUrl: boolean): ACPSettings {
|
function getInitialSettings(inferFromUrl: boolean): ACPSettings {
|
||||||
const settings = { ...DEFAULT_SETTINGS };
|
const settings = { ...DEFAULT_SETTINGS };
|
||||||
@@ -119,6 +138,12 @@ export function ACPConnect({
|
|||||||
onError: handleQRError,
|
onError: handleQRError,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (inferFromUrl) {
|
||||||
|
scrubTokenFromUrl();
|
||||||
|
}
|
||||||
|
}, [inferFromUrl]);
|
||||||
|
|
||||||
// Recalculate maxHeight after DOM updates (when expanded or isScanning changes)
|
// Recalculate maxHeight after DOM updates (when expanded or isScanning changes)
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (expanded && contentRef.current) {
|
if (expanded && contentRef.current) {
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ beforeEach(() => {
|
|||||||
fetchMock.lastOpts = {};
|
fetchMock.lastOpts = {};
|
||||||
fetchMock.response = { ok: true, status: 200, statusText: "OK" };
|
fetchMock.response = { ok: true, status: 200, statusText: "OK" };
|
||||||
fetchMock.responseData = {};
|
fetchMock.responseData = {};
|
||||||
|
client.setActiveApiToken(null);
|
||||||
});
|
});
|
||||||
|
|
||||||
(globalThis as any).fetch = async (url: string, opts: RequestInit) => {
|
(globalThis as any).fetch = async (url: string, opts: RequestInit) => {
|
||||||
@@ -41,15 +42,11 @@ beforeEach(() => {
|
|||||||
} as Response;
|
} as Response;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Mock crypto.randomUUID
|
|
||||||
(globalThis as any).crypto = {
|
|
||||||
randomUUID: () => "test-uuid-12345678",
|
|
||||||
};
|
|
||||||
|
|
||||||
const { getUuid, setUuid } = await import("../api/client");
|
const { getUuid, setUuid } = await import("../api/client");
|
||||||
|
|
||||||
// Import api* functions - they depend on getUuid and fetch
|
// Import api* functions - they depend on getUuid and fetch
|
||||||
const client = await import("../api/client");
|
const client = await import("../api/client");
|
||||||
|
const relayClient = await import("../acp/relay-client");
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// getUuid()
|
// getUuid()
|
||||||
@@ -63,8 +60,10 @@ describe("getUuid", () => {
|
|||||||
|
|
||||||
test("generates and stores new UUID when none exists", () => {
|
test("generates and stores new UUID when none exists", () => {
|
||||||
const uuid = getUuid();
|
const uuid = getUuid();
|
||||||
expect(uuid).toBe("test-uuid-12345678");
|
expect(uuid).toMatch(
|
||||||
expect(store["rcs_uuid"]).toBe("test-uuid-12345678");
|
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/,
|
||||||
|
);
|
||||||
|
expect(store["rcs_uuid"]).toBe(uuid);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("returns same UUID on subsequent calls", () => {
|
test("returns same UUID on subsequent calls", () => {
|
||||||
@@ -127,6 +126,21 @@ describe("api functions", () => {
|
|||||||
expect(fetchMock.lastOpts.headers).toEqual({ "Content-Type": "application/json" });
|
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 () => {
|
test("throws error on non-ok response", async () => {
|
||||||
store["rcs_uuid"] = "test-uuid";
|
store["rcs_uuid"] = "test-uuid";
|
||||||
fetchMock.response = { ok: false, status: 401, statusText: "Unauthorized" };
|
fetchMock.response = { ok: false, status: 401, statusText: "Unauthorized" };
|
||||||
@@ -141,3 +155,18 @@ describe("api functions", () => {
|
|||||||
await expect(client.apiFetchSessions()).rejects.toThrow("Internal Server Error");
|
await expect(client.apiFetchSessions()).rejects.toThrow("Internal Server Error");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("ACP relay client", () => {
|
||||||
|
test("builds relay URLs without UUID or token query params", () => {
|
||||||
|
(globalThis as any).window = {
|
||||||
|
location: {
|
||||||
|
protocol: "https:",
|
||||||
|
host: "rcs.example.test",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(relayClient.buildRelayUrl("agent_123")).toBe(
|
||||||
|
"wss://rcs.example.test/acp/relay/agent_123",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { describe, test, expect } from "bun:test";
|
import { afterEach, describe, test, expect } from "bun:test";
|
||||||
|
|
||||||
const {
|
const {
|
||||||
formatTime,
|
formatTime,
|
||||||
@@ -10,6 +10,33 @@ const {
|
|||||||
isConversationClearedStatus,
|
isConversationClearedStatus,
|
||||||
} = await import("../lib/utils");
|
} = 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()
|
// formatTime()
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -122,10 +149,42 @@ describe("truncate", () => {
|
|||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
describe("generateMessageUuid", () => {
|
describe("generateMessageUuid", () => {
|
||||||
test("returns a non-empty string", () => {
|
test("returns an RFC 4122 v4 UUID", () => {
|
||||||
const uuid = generateMessageUuid();
|
const uuid = generateMessageUuid();
|
||||||
expect(typeof uuid).toBe("string");
|
expect(typeof uuid).toBe("string");
|
||||||
expect(uuid.length).toBeGreaterThan(0);
|
expect(uuid).toMatch(
|
||||||
|
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("uses crypto.randomUUID when available", () => {
|
||||||
|
setCryptoForTest({
|
||||||
|
randomUUID: () => "11111111-1111-4111-8111-111111111111",
|
||||||
|
getRandomValues: () => {
|
||||||
|
throw new Error("getRandomValues should not be called");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(generateMessageUuid()).toBe("11111111-1111-4111-8111-111111111111");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("uses crypto.getRandomValues when randomUUID is unavailable", () => {
|
||||||
|
setCryptoForTest({
|
||||||
|
getRandomValues: (array) => {
|
||||||
|
for (let i = 0; i < array.length; i++) {
|
||||||
|
array[i] = i;
|
||||||
|
}
|
||||||
|
return array;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(generateMessageUuid()).toBe("00010203-0405-4607-8809-0a0b0c0d0e0f");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("throws when no secure random source is available", () => {
|
||||||
|
setCryptoForTest({});
|
||||||
|
|
||||||
|
expect(() => generateMessageUuid()).toThrow("crypto.getRandomValues is required");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,19 @@ import type {
|
|||||||
AvailableCommand,
|
AvailableCommand,
|
||||||
} from "./types";
|
} 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.
|
* Error thrown when disconnect() is called while a connection is in progress.
|
||||||
* Callers can use `instanceof` to distinguish this from real connection errors.
|
* Callers can use `instanceof` to distinguish this from real connection errors.
|
||||||
@@ -276,14 +289,12 @@ export class ACPClient {
|
|||||||
this.connectReject = reject;
|
this.connectReject = reject;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Build WebSocket URL with token if provided
|
const ws = new WebSocket(
|
||||||
let wsUrl = this.settings.proxyUrl;
|
this.settings.proxyUrl,
|
||||||
if (this.settings.token) {
|
this.settings.token
|
||||||
const url = new URL(wsUrl);
|
? [encodeWebSocketAuthProtocol(this.settings.token)]
|
||||||
url.searchParams.set("token", this.settings.token);
|
: undefined,
|
||||||
wsUrl = url.toString();
|
);
|
||||||
}
|
|
||||||
const ws = new WebSocket(wsUrl);
|
|
||||||
this.ws = ws;
|
this.ws = ws;
|
||||||
|
|
||||||
ws.onopen = () => {
|
ws.onopen = () => {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { ACPClient } from "./client";
|
import { ACPClient } from "./client";
|
||||||
import type { ACPSettings } from "./types";
|
import type { ACPSettings } from "./types";
|
||||||
import { getUuid } from "../api/client";
|
import { getActiveApiToken } from "../api/client";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build the RCS relay WebSocket URL for a given agent.
|
* Build the RCS relay WebSocket URL for a given agent.
|
||||||
@@ -8,8 +8,7 @@ import { getUuid } from "../api/client";
|
|||||||
*/
|
*/
|
||||||
export function buildRelayUrl(agentId: string): string {
|
export function buildRelayUrl(agentId: string): string {
|
||||||
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||||
const uuid = getUuid();
|
return `${protocol}//${window.location.host}/acp/relay/${agentId}`;
|
||||||
return `${protocol}//${window.location.host}/acp/relay/${agentId}?uuid=${encodeURIComponent(uuid)}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -19,6 +18,9 @@ export function buildRelayUrl(agentId: string): string {
|
|||||||
*/
|
*/
|
||||||
export function createRelayClient(agentId: string): ACPClient {
|
export function createRelayClient(agentId: string): ACPClient {
|
||||||
const relayUrl = buildRelayUrl(agentId);
|
const relayUrl = buildRelayUrl(agentId);
|
||||||
const settings: ACPSettings = { proxyUrl: relayUrl };
|
const token = getActiveApiToken();
|
||||||
|
const settings: ACPSettings = token
|
||||||
|
? { proxyUrl: relayUrl, token }
|
||||||
|
: { proxyUrl: relayUrl };
|
||||||
return new ACPClient(settings);
|
return new ACPClient(settings);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -549,7 +549,7 @@ export interface SessionModelState {
|
|||||||
// Settings
|
// Settings
|
||||||
export interface ACPSettings {
|
export interface ACPSettings {
|
||||||
proxyUrl: string;
|
proxyUrl: string;
|
||||||
/** Auth token for remote access (passed as ?token=xxx query param) */
|
/** Auth token for remote access (sent via WebSocket subprotocol) */
|
||||||
token?: string;
|
token?: string;
|
||||||
/** Working directory for the agent session */
|
/** Working directory for the agent session */
|
||||||
cwd?: string;
|
cwd?: string;
|
||||||
|
|||||||
@@ -1,20 +1,12 @@
|
|||||||
import type { Session, Environment, ControlResponse, SessionEvent } from "../types";
|
import type { Session, Environment, ControlResponse, SessionEvent } from "../types";
|
||||||
|
import { generateMessageUuid } from "../lib/utils";
|
||||||
|
|
||||||
const BASE = "";
|
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 {
|
export function getUuid(): string {
|
||||||
let uuid = localStorage.getItem("rcs_uuid");
|
let uuid = localStorage.getItem("rcs_uuid");
|
||||||
if (!uuid) {
|
if (!uuid) {
|
||||||
uuid = generateUuid();
|
uuid = generateMessageUuid();
|
||||||
localStorage.setItem("rcs_uuid", uuid);
|
localStorage.setItem("rcs_uuid", uuid);
|
||||||
}
|
}
|
||||||
return uuid;
|
return uuid;
|
||||||
@@ -42,17 +34,9 @@ async function api<T>(method: string, path: string, body?: unknown): Promise<T>
|
|||||||
headers["Authorization"] = `Bearer ${_activeToken}`;
|
headers["Authorization"] = `Bearer ${_activeToken}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// When using Bearer token auth, backend derives UUID from the token — no need to send query param.
|
const uuid = getUuid();
|
||||||
// Otherwise fall back to UUID auth via query param.
|
const sep = path.includes("?") ? "&" : "?";
|
||||||
let url: string;
|
const url = `${BASE}${path}${sep}uuid=${encodeURIComponent(uuid)}`;
|
||||||
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 };
|
const opts: RequestInit = { method, headers };
|
||||||
if (body !== undefined) opts.body = JSON.stringify(body);
|
if (body !== undefined) opts.body = JSON.stringify(body);
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import type { SetStateAction } from "react";
|
import type { SetStateAction } from "react";
|
||||||
import { v4 as uuidv4 } from "uuid";
|
|
||||||
import {
|
import {
|
||||||
apiFetchSession,
|
apiFetchSession,
|
||||||
apiFetchSessionHistory,
|
apiFetchSessionHistory,
|
||||||
@@ -9,6 +8,7 @@ import {
|
|||||||
apiInterrupt,
|
apiInterrupt,
|
||||||
getUuid,
|
getUuid,
|
||||||
} from "../api/client";
|
} from "../api/client";
|
||||||
|
import { generateMessageUuid } from "./utils";
|
||||||
import type { SessionEvent, EventPayload } from "../types";
|
import type { SessionEvent, EventPayload } from "../types";
|
||||||
import type {
|
import type {
|
||||||
ThreadEntry,
|
ThreadEntry,
|
||||||
@@ -422,7 +422,7 @@ export class RCSChatAdapter {
|
|||||||
// Send to backend
|
// Send to backend
|
||||||
await apiSendEvent(this.sessionId, {
|
await apiSendEvent(this.sessionId, {
|
||||||
type: "user",
|
type: "user",
|
||||||
uuid: uuidv4(),
|
uuid: generateMessageUuid(),
|
||||||
content: text,
|
content: text,
|
||||||
message: { content: text },
|
message: { content: text },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { ChatTransport, UIMessage, UIMessageChunk } from "ai";
|
import type { ChatTransport, UIMessage, UIMessageChunk } from "ai";
|
||||||
import { v4 as uuidv4 } from "uuid";
|
|
||||||
import { getUuid } from "../api/client";
|
import { getUuid } from "../api/client";
|
||||||
|
import { generateMessageUuid } from "./utils";
|
||||||
import type { SessionEvent, EventPayload } from "../types";
|
import type { SessionEvent, EventPayload } from "../types";
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -113,7 +113,7 @@ export class RCSTransport implements ChatTransport<UIMessage> {
|
|||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
type: "user",
|
type: "user",
|
||||||
uuid: uuidv4(),
|
uuid: generateMessageUuid(),
|
||||||
content: text,
|
content: text,
|
||||||
message: { content: text },
|
message: { content: text },
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { type ClassValue, clsx } from "clsx";
|
import { type ClassValue, clsx } from "clsx";
|
||||||
import { twMerge } from "tailwind-merge";
|
import { twMerge } from "tailwind-merge";
|
||||||
import { v4 as uuidv4 } from "uuid";
|
|
||||||
|
|
||||||
export function cn(...inputs: ClassValue[]) {
|
export function cn(...inputs: ClassValue[]) {
|
||||||
return twMerge(clsx(inputs));
|
return twMerge(clsx(inputs));
|
||||||
@@ -42,8 +41,31 @@ export function truncate(str: string | null | undefined, max: number): string {
|
|||||||
return s.length > max ? s.slice(0, max) + "..." : s;
|
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 {
|
export function generateMessageUuid(): string {
|
||||||
return uuidv4();
|
const cryptoApi = globalThis.crypto;
|
||||||
|
if (cryptoApi && typeof cryptoApi.randomUUID === "function") {
|
||||||
|
return cryptoApi.randomUUID();
|
||||||
|
}
|
||||||
|
if (!cryptoApi || typeof cryptoApi.getRandomValues !== "function") {
|
||||||
|
throw new Error("crypto.getRandomValues is required to generate message UUIDs");
|
||||||
|
}
|
||||||
|
const bytes = new Uint8Array(16);
|
||||||
|
cryptoApi.getRandomValues(bytes);
|
||||||
|
return formatUuidV4(bytes);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function extractEventText(payload: Record<string, unknown> | null | undefined): string {
|
export function extractEventText(payload: Record<string, unknown> | null | undefined): string {
|
||||||
|
|||||||
@@ -30,33 +30,33 @@ export const DEFAULT_BUILD_FEATURES = [
|
|||||||
'BUDDY', // 陪伴宠物角色(Squirtle Waddles)
|
'BUDDY', // 陪伴宠物角色(Squirtle Waddles)
|
||||||
'TRANSCRIPT_CLASSIFIER', // 对话分类器,用于标注会话类型
|
'TRANSCRIPT_CLASSIFIER', // 对话分类器,用于标注会话类型
|
||||||
'BRIDGE_MODE', // Remote Control / Bridge 模式,远程控制会话
|
'BRIDGE_MODE', // Remote Control / Bridge 模式,远程控制会话
|
||||||
'AGENT_TRIGGERS_REMOTE', // Agent 触发远程会话连接
|
'AGENT_TRIGGERS_REMOTE', // sessionIngress 模块级 Map 累积(非 GB 级主因)
|
||||||
'CHICAGO_MCP', // Chicago MCP 集成(内部代号)
|
'CHICAGO_MCP', // Chicago MCP 集成(内部代号)
|
||||||
'VOICE_MODE', // Push-to-Talk 语音输入模式
|
'VOICE_MODE', // Push-to-Talk 语音输入模式
|
||||||
'SHOT_STATS', // 单次请求统计信息收集
|
'SHOT_STATS', // 单次请求统计信息收集
|
||||||
'PROMPT_CACHE_BREAK_DETECTION', // 检测 prompt cache 是否被打破
|
'PROMPT_CACHE_BREAK_DETECTION', // 检测 prompt cache 是否被打破(有 10 条上限,可控)
|
||||||
'TOKEN_BUDGET', // Token 预算管理与控制
|
'TOKEN_BUDGET', // Token 预算管理与控制
|
||||||
// P0: local features
|
// P0: local features
|
||||||
'AGENT_TRIGGERS', // 本地 Agent 触发器(工具调用时启动子代理)
|
'AGENT_TRIGGERS', // 本地 Agent 触发器(工具调用时启动子代理)
|
||||||
'ULTRATHINK', // 超深度思考模式,增加推理链长度
|
'ULTRATHINK', // 超深度思考模式,增加推理链长度
|
||||||
'BUILTIN_EXPLORE_PLAN_AGENTS', // 内置 Explore/Plan 子代理类型
|
'BUILTIN_EXPLORE_PLAN_AGENTS', // 内置 Explore/Plan 子代理类型
|
||||||
'LODESTONE', // 上下文锚点,优化长对话的相关性检索
|
'LODESTONE', // 上下文锚点,优化长对话的相关性检索
|
||||||
'EXTRACT_MEMORIES', // 自动从对话中提取并持久化记忆
|
'EXTRACT_MEMORIES', // 每次 turn 结束 fork 完整消息历史(非 GB 级主因)
|
||||||
'VERIFICATION_AGENT', // 验证代理,任务完成后自动校验结果
|
'VERIFICATION_AGENT', // 任务完成后 fork 完整消息(非 GB 级主因)
|
||||||
'KAIROS_BRIEF', // Kairos 定时摘要(定时汇报当前状态)
|
'KAIROS_BRIEF', // Kairos 定时摘要(定时汇报当前状态)
|
||||||
'AWAY_SUMMARY', // 离线摘要(用户离开后生成总结)
|
'AWAY_SUMMARY', // 离线摘要(用户离开后生成总结)
|
||||||
'ULTRAPLAN', // 超级规划模式,深度分析后生成实施计划
|
'ULTRAPLAN', // 超级规划模式,深度分析后生成实施计划
|
||||||
// 'DAEMON', // 守护进程模式,长驻 supervisor 管理后台 worker(已禁用:内存占用过高)
|
'DAEMON', // 守护进程模式,长驻 supervisor 管理后台 worker(非 GB 级主因)
|
||||||
'ACP', // ACP 代理协议,支持外部 agent 接入
|
'ACP', // ACP 代理协议,支持外部 agent 接入
|
||||||
'WORKFLOW_SCRIPTS', // 工作流脚本(.claude/workflows/ 中的 YAML/MD)
|
'WORKFLOW_SCRIPTS', // 工作流脚本(.claude/workflows/ 中的 YAML/MD)
|
||||||
'HISTORY_SNIP', // 历史消息裁剪,压缩上下文窗口
|
'HISTORY_SNIP', // 历史消息裁剪,压缩上下文窗口
|
||||||
'CONTEXT_COLLAPSE', // 上下文折叠,自动压缩旧消息
|
'CONTEXT_COLLAPSE', // 上下文折叠,自动压缩旧消息
|
||||||
'MONITOR_TOOL', // Monitor 工具,流式监控后台进程输出
|
'MONITOR_TOOL', // Monitor 工具,流式监控后台进程输出
|
||||||
'FORK_SUBAGENT', // Fork 子代理,在隔离上下文中并行执行任务
|
'FORK_SUBAGENT', // Fork 子代理,在隔离上下文中并行执行任务
|
||||||
'UDS_INBOX', // Unix Domain Socket 收件箱,跨会话消息传递
|
'UDS_INBOX', // inbox 数组只增不减(非 GB 级主因)
|
||||||
'KAIROS', // Kairos 定时任务系统核心
|
'KAIROS', // Kairos 定时任务系统核心
|
||||||
'COORDINATOR_MODE', // 协调者模式,多代理团队任务调度
|
// 'COORDINATOR_MODE', // 已禁用:AgentSummary 30s fork 循环,GB 级泄露主因
|
||||||
'LAN_PIPES', // 局域网管道,LAN 设备间通信
|
'LAN_PIPES', // 依赖 UDS_INBOX(已随 UDS_INBOX 恢复)
|
||||||
'BG_SESSIONS', // 后台会话管理(ps/logs/attach/kill)
|
'BG_SESSIONS', // 后台会话管理(ps/logs/attach/kill)
|
||||||
'TEMPLATES', // 模板任务(new/list/reply 子命令)
|
'TEMPLATES', // 模板任务(new/list/reply 子命令)
|
||||||
// 'REVIEW_ARTIFACT', // 代码审查产物(API 请求无响应,待排查 schema 兼容性)
|
// 'REVIEW_ARTIFACT', // 代码审查产物(API 请求无响应,待排查 schema 兼容性)
|
||||||
@@ -68,11 +68,11 @@ export const DEFAULT_BUILD_FEATURES = [
|
|||||||
'DIRECT_CONNECT', // 直连模式(claude server / claude open)
|
'DIRECT_CONNECT', // 直连模式(claude server / claude open)
|
||||||
// Skill search & learning
|
// Skill search & learning
|
||||||
'EXPERIMENTAL_SKILL_SEARCH', // 实验性技能搜索(DiscoverSkills)
|
'EXPERIMENTAL_SKILL_SEARCH', // 实验性技能搜索(DiscoverSkills)
|
||||||
'SKILL_LEARNING', // 技能学习系统,从对话中自动生成/演化技能
|
'SKILL_LEARNING', // projectContext cache 无淘汰机制(非 GB 级主因)
|
||||||
// P3: poor mode
|
// P3: poor mode
|
||||||
'POOR', // 穷鬼模式,跳过 extract_memories/prompt_suggestion 减少消耗
|
'POOR', // 穷鬼模式,跳过 extract_memories/prompt_suggestion 减少消耗
|
||||||
// Team Memory
|
// Team Memory
|
||||||
'TEAMMEM', // 团队记忆,代理队友间共享记忆文件
|
// 'TEAMMEM', // 已禁用:依赖 COORDINATOR_MODE,邮箱文件无限增长
|
||||||
// SSH Remote
|
// SSH Remote
|
||||||
'SSH_REMOTE', // SSH 远程连接,本地 REPL + 远端工具执行
|
'SSH_REMOTE', // SSH 远程连接,本地 REPL + 远端工具执行
|
||||||
]as const;
|
]as const;
|
||||||
|
|||||||
@@ -9,14 +9,39 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { execFileSync } from "node:child_process";
|
import { execFileSync } from "node:child_process";
|
||||||
|
import { mkdirSync } from "node:fs";
|
||||||
import { createRequire } from "node:module";
|
import { createRequire } from "node:module";
|
||||||
import { dirname, join } from "node:path";
|
import { homedir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
|
||||||
|
if (process.env.CLAUDE_CODE_SKIP_CHROME_MCP_SETUP === "1") {
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
const require = createRequire(import.meta.url);
|
const require = createRequire(import.meta.url);
|
||||||
const cliPath = require.resolve("@claude-code-best/mcp-chrome-bridge/dist/cli.js");
|
const cliPath = require.resolve("@claude-code-best/mcp-chrome-bridge/dist/cli.js");
|
||||||
|
|
||||||
const userArgs = process.argv.slice(2);
|
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) {
|
if (userArgs.length > 0) {
|
||||||
// Forward single sub-command
|
// Forward single sub-command
|
||||||
execFileSync("node", [cliPath, ...userArgs], { stdio: "inherit" });
|
execFileSync("node", [cliPath, ...userArgs], { stdio: "inherit" });
|
||||||
@@ -28,6 +53,8 @@ if (userArgs.length > 0) {
|
|||||||
["doctor"],
|
["doctor"],
|
||||||
];
|
];
|
||||||
|
|
||||||
|
mkdirSync(getChromeMcpLogDir(), { recursive: true });
|
||||||
|
|
||||||
for (let i = 0; i < steps.length; i++) {
|
for (let i = 0; i < steps.length; i++) {
|
||||||
const args = steps[i];
|
const args = steps[i];
|
||||||
const isLast = i === steps.length - 1;
|
const isLast = i === steps.length - 1;
|
||||||
|
|||||||
@@ -1675,7 +1675,7 @@ async function stopWorkWithRetry(
|
|||||||
}
|
}
|
||||||
const errMsg = errorMessage(err)
|
const errMsg = errorMessage(err)
|
||||||
if (attempt < MAX_ATTEMPTS) {
|
if (attempt < MAX_ATTEMPTS) {
|
||||||
const delay = addJitter(baseDelayMs * Math.pow(2, attempt - 1))
|
const delay = addJitter(baseDelayMs * 2 ** (attempt - 1))
|
||||||
logger.logVerbose(
|
logger.logVerbose(
|
||||||
`Failed to stop work ${workId} (attempt ${attempt}/${MAX_ATTEMPTS}), retrying in ${formatDelay(delay)}: ${errMsg}`,
|
`Failed to stop work ${workId} (attempt ${attempt}/${MAX_ATTEMPTS}), retrying in ${formatDelay(delay)}: ${errMsg}`,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -6,6 +6,38 @@ import { getBridgeAccessToken } from './bridgeConfig.js'
|
|||||||
import { getReplBridgeHandle } from './replBridgeHandle.js'
|
import { getReplBridgeHandle } from './replBridgeHandle.js'
|
||||||
import { toCompatSessionId } from './sessionIdCompat.js'
|
import { toCompatSessionId } from './sessionIdCompat.js'
|
||||||
|
|
||||||
|
export type BridgePeerSession = {
|
||||||
|
address: string
|
||||||
|
name?: string
|
||||||
|
cwd?: string
|
||||||
|
pid?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List locally registered sessions that have published a Remote Control
|
||||||
|
* session ID. The PID registry is the local source of truth for bridge peers
|
||||||
|
* already known to this machine; SendMessage can use these bridge:<id>
|
||||||
|
* addresses when the current process has an active bridge handle.
|
||||||
|
*/
|
||||||
|
export async function listBridgePeers(): Promise<BridgePeerSession[]> {
|
||||||
|
const { listAllLiveSessions } = await import('../utils/udsClient.js')
|
||||||
|
const sessions = await listAllLiveSessions()
|
||||||
|
const peers: BridgePeerSession[] = []
|
||||||
|
|
||||||
|
for (const session of sessions) {
|
||||||
|
if (session.pid === process.pid || !session.bridgeSessionId) continue
|
||||||
|
const compatId = toCompatSessionId(session.bridgeSessionId)
|
||||||
|
peers.push({
|
||||||
|
address: `bridge:${compatId}`,
|
||||||
|
name: session.name ?? session.kind,
|
||||||
|
cwd: session.cwd,
|
||||||
|
pid: session.pid,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return peers
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Send a plain-text message to another Claude session via the bridge API.
|
* Send a plain-text message to another Claude session via the bridge API.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -190,12 +190,10 @@ export async function mcpListHandler(): Promise<void> {
|
|||||||
logEvent('tengu_mcp_list', {})
|
logEvent('tengu_mcp_list', {})
|
||||||
const { servers: configs } = await getAllMcpConfigs()
|
const { servers: configs } = await getAllMcpConfigs()
|
||||||
if (Object.keys(configs).length === 0) {
|
if (Object.keys(configs).length === 0) {
|
||||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
|
||||||
console.log(
|
console.log(
|
||||||
'No MCP servers configured. Use `claude mcp add` to add a server.',
|
'No MCP servers configured. Use `claude mcp add` to add a server.',
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
|
||||||
console.log('Checking MCP server health...\n')
|
console.log('Checking MCP server health...\n')
|
||||||
|
|
||||||
// Check servers concurrently
|
// Check servers concurrently
|
||||||
@@ -213,18 +211,14 @@ export async function mcpListHandler(): Promise<void> {
|
|||||||
for (const { name, server, status } of results) {
|
for (const { name, server, status } of results) {
|
||||||
// Intentionally excluding sse-ide servers here since they're internal
|
// Intentionally excluding sse-ide servers here since they're internal
|
||||||
if (server.type === 'sse') {
|
if (server.type === 'sse') {
|
||||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
|
||||||
console.log(`${name}: ${server.url} (SSE) - ${status}`)
|
console.log(`${name}: ${server.url} (SSE) - ${status}`)
|
||||||
} else if (server.type === 'http') {
|
} else if (server.type === 'http') {
|
||||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
|
||||||
console.log(`${name}: ${server.url} (HTTP) - ${status}`)
|
console.log(`${name}: ${server.url} (HTTP) - ${status}`)
|
||||||
} else if (server.type === 'claudeai-proxy') {
|
} else if (server.type === 'claudeai-proxy') {
|
||||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
|
||||||
console.log(`${name}: ${server.url} - ${status}`)
|
console.log(`${name}: ${server.url} - ${status}`)
|
||||||
} else if (!server.type || server.type === 'stdio') {
|
} else if (!server.type || server.type === 'stdio') {
|
||||||
const stdioServer = server as { command: string; args: string[]; type?: string }
|
const stdioServer = server as { command: string; args: string[]; type?: string }
|
||||||
const args = Array.isArray(stdioServer.args) ? stdioServer.args : []
|
const args = Array.isArray(stdioServer.args) ? stdioServer.args : []
|
||||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
|
||||||
console.log(`${name}: ${stdioServer.command} ${args.join(' ')} - ${status}`)
|
console.log(`${name}: ${stdioServer.command} ${args.join(' ')} - ${status}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -244,27 +238,20 @@ export async function mcpGetHandler(name: string): Promise<void> {
|
|||||||
cliError(`No MCP server found with name: ${name}`)
|
cliError(`No MCP server found with name: ${name}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
|
||||||
console.log(`${name}:`)
|
console.log(`${name}:`)
|
||||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
|
||||||
console.log(` Scope: ${getScopeLabel(server.scope)}`)
|
console.log(` Scope: ${getScopeLabel(server.scope)}`)
|
||||||
|
|
||||||
// Check server health
|
// Check server health
|
||||||
const status = await checkMcpServerHealth(name, server)
|
const status = await checkMcpServerHealth(name, server)
|
||||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
|
||||||
console.log(` Status: ${status}`)
|
console.log(` Status: ${status}`)
|
||||||
|
|
||||||
// Intentionally excluding sse-ide servers here since they're internal
|
// Intentionally excluding sse-ide servers here since they're internal
|
||||||
if (server.type === 'sse') {
|
if (server.type === 'sse') {
|
||||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
|
||||||
console.log(` Type: sse`)
|
console.log(` Type: sse`)
|
||||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
|
||||||
console.log(` URL: ${server.url}`)
|
console.log(` URL: ${server.url}`)
|
||||||
if (server.headers) {
|
if (server.headers) {
|
||||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
|
||||||
console.log(' Headers:')
|
console.log(' Headers:')
|
||||||
for (const [key, value] of Object.entries(server.headers)) {
|
for (const [key, value] of Object.entries(server.headers)) {
|
||||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
|
||||||
console.log(` ${key}: ${value}`)
|
console.log(` ${key}: ${value}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -277,19 +264,14 @@ export async function mcpGetHandler(name: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
if (server.oauth.callbackPort)
|
if (server.oauth.callbackPort)
|
||||||
parts.push(`callback_port ${server.oauth.callbackPort}`)
|
parts.push(`callback_port ${server.oauth.callbackPort}`)
|
||||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
|
||||||
console.log(` OAuth: ${parts.join(', ')}`)
|
console.log(` OAuth: ${parts.join(', ')}`)
|
||||||
}
|
}
|
||||||
} else if (server.type === 'http') {
|
} else if (server.type === 'http') {
|
||||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
|
||||||
console.log(` Type: http`)
|
console.log(` Type: http`)
|
||||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
|
||||||
console.log(` URL: ${server.url}`)
|
console.log(` URL: ${server.url}`)
|
||||||
if (server.headers) {
|
if (server.headers) {
|
||||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
|
||||||
console.log(' Headers:')
|
console.log(' Headers:')
|
||||||
for (const [key, value] of Object.entries(server.headers)) {
|
for (const [key, value] of Object.entries(server.headers)) {
|
||||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
|
||||||
console.log(` ${key}: ${value}`)
|
console.log(` ${key}: ${value}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -302,27 +284,20 @@ export async function mcpGetHandler(name: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
if (server.oauth.callbackPort)
|
if (server.oauth.callbackPort)
|
||||||
parts.push(`callback_port ${server.oauth.callbackPort}`)
|
parts.push(`callback_port ${server.oauth.callbackPort}`)
|
||||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
|
||||||
console.log(` OAuth: ${parts.join(', ')}`)
|
console.log(` OAuth: ${parts.join(', ')}`)
|
||||||
}
|
}
|
||||||
} else if (server.type === 'stdio') {
|
} else if (server.type === 'stdio') {
|
||||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
|
||||||
console.log(` Type: stdio`)
|
console.log(` Type: stdio`)
|
||||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
|
||||||
console.log(` Command: ${server.command}`)
|
console.log(` Command: ${server.command}`)
|
||||||
const args = Array.isArray(server.args) ? server.args : []
|
const args = Array.isArray(server.args) ? server.args : []
|
||||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
|
||||||
console.log(` Args: ${args.join(' ')}`)
|
console.log(` Args: ${args.join(' ')}`)
|
||||||
if (server.env) {
|
if (server.env) {
|
||||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
|
||||||
console.log(' Environment:')
|
console.log(' Environment:')
|
||||||
for (const [key, value] of Object.entries(server.env)) {
|
for (const [key, value] of Object.entries(server.env)) {
|
||||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
|
||||||
console.log(` ${key}=${value}`)
|
console.log(` ${key}=${value}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
|
||||||
console.log(
|
console.log(
|
||||||
`\nTo remove this server, run: claude mcp remove "${name}" -s ${server.scope}`,
|
`\nTo remove this server, run: claude mcp remove "${name}" -s ${server.scope}`,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2763,13 +2763,37 @@ function runHeadlessStreaming(
|
|||||||
// when a message arrives via the UDS socket in headless mode.
|
// when a message arrives via the UDS socket in headless mode.
|
||||||
if (feature('UDS_INBOX')) {
|
if (feature('UDS_INBOX')) {
|
||||||
/* eslint-disable @typescript-eslint/no-require-imports */
|
/* eslint-disable @typescript-eslint/no-require-imports */
|
||||||
const { setOnEnqueue } = require('../utils/udsMessaging.js')
|
const { drainInbox, setOnEnqueue } =
|
||||||
|
require('../utils/udsMessaging.js') as typeof import('../utils/udsMessaging.js')
|
||||||
/* eslint-enable @typescript-eslint/no-require-imports */
|
/* eslint-enable @typescript-eslint/no-require-imports */
|
||||||
|
|
||||||
|
const enqueueUdsInboxMessages = (): boolean => {
|
||||||
|
const entries = drainInbox()
|
||||||
|
for (const entry of entries) {
|
||||||
|
const value =
|
||||||
|
typeof entry.message.data === 'string'
|
||||||
|
? entry.message.data
|
||||||
|
: jsonStringify(entry.message.data)
|
||||||
|
enqueue({
|
||||||
|
mode: 'prompt',
|
||||||
|
value,
|
||||||
|
uuid: randomUUID(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return entries.length > 0
|
||||||
|
}
|
||||||
|
|
||||||
setOnEnqueue(() => {
|
setOnEnqueue(() => {
|
||||||
if (!inputClosed) {
|
if (!inputClosed) {
|
||||||
void run()
|
if (enqueueUdsInboxMessages()) {
|
||||||
|
void run()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if (enqueueUdsInboxMessages()) {
|
||||||
|
void run()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cron scheduler: runs scheduled_tasks.json tasks in SDK/-p mode.
|
// Cron scheduler: runs scheduled_tasks.json tasks in SDK/-p mode.
|
||||||
|
|||||||
@@ -518,7 +518,7 @@ export class SSETransport implements Transport {
|
|||||||
this.reconnectAttempts++
|
this.reconnectAttempts++
|
||||||
|
|
||||||
const baseDelay = Math.min(
|
const baseDelay = Math.min(
|
||||||
RECONNECT_BASE_DELAY_MS * Math.pow(2, this.reconnectAttempts - 1),
|
RECONNECT_BASE_DELAY_MS * 2 ** (this.reconnectAttempts - 1),
|
||||||
RECONNECT_MAX_DELAY_MS,
|
RECONNECT_MAX_DELAY_MS,
|
||||||
)
|
)
|
||||||
// Add ±25% jitter
|
// Add ±25% jitter
|
||||||
@@ -668,7 +668,7 @@ export class SSETransport implements Transport {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const delayMs = Math.min(
|
const delayMs = Math.min(
|
||||||
POST_BASE_DELAY_MS * Math.pow(2, attempt - 1),
|
POST_BASE_DELAY_MS * 2 ** (attempt - 1),
|
||||||
POST_MAX_DELAY_MS,
|
POST_MAX_DELAY_MS,
|
||||||
)
|
)
|
||||||
await sleep(delayMs)
|
await sleep(delayMs)
|
||||||
|
|||||||
@@ -516,7 +516,7 @@ export class WebSocketTransport implements Transport {
|
|||||||
this.reconnectAttempts++
|
this.reconnectAttempts++
|
||||||
|
|
||||||
const baseDelay = Math.min(
|
const baseDelay = Math.min(
|
||||||
DEFAULT_BASE_RECONNECT_DELAY * Math.pow(2, this.reconnectAttempts - 1),
|
DEFAULT_BASE_RECONNECT_DELAY * 2 ** (this.reconnectAttempts - 1),
|
||||||
DEFAULT_MAX_RECONNECT_DELAY,
|
DEFAULT_MAX_RECONNECT_DELAY,
|
||||||
)
|
)
|
||||||
// Add ±25% jitter to avoid thundering herd
|
// Add ±25% jitter to avoid thundering herd
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ import {
|
|||||||
getOriginalCwd,
|
getOriginalCwd,
|
||||||
getSessionId,
|
getSessionId,
|
||||||
regenerateSessionId,
|
regenerateSessionId,
|
||||||
|
resetCostState,
|
||||||
|
setLastAPIRequest,
|
||||||
|
setLastAPIRequestMessages,
|
||||||
|
setLastClassifierRequests,
|
||||||
} from '../../bootstrap/state.js'
|
} from '../../bootstrap/state.js'
|
||||||
import type { SDKStatusMessage } from '../../entrypoints/sdk/coreTypes.js'
|
import type { SDKStatusMessage } from '../../entrypoints/sdk/coreTypes.js'
|
||||||
import {
|
import {
|
||||||
@@ -144,6 +148,14 @@ export async function clearConversation({
|
|||||||
// tracking) is retained so those agents keep functioning.
|
// tracking) is retained so those agents keep functioning.
|
||||||
clearSessionCaches(preservedAgentIds)
|
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())
|
setCwd(getOriginalCwd())
|
||||||
readFileState.clear()
|
readFileState.clear()
|
||||||
discoveredSkillNames?.clear()
|
discoveredSkillNames?.clear()
|
||||||
|
|||||||
@@ -169,7 +169,7 @@ export async function call(
|
|||||||
|
|
||||||
if (COMMON_HELP_ARGS.includes(args)) {
|
if (COMMON_HELP_ARGS.includes(args)) {
|
||||||
onDone(
|
onDone(
|
||||||
'Usage: /effort [low|medium|high|max|auto]\n\nEffort levels:\n- low: Quick, straightforward implementation\n- medium: Balanced approach with standard testing\n- high: Comprehensive implementation with extensive testing\n- max: Maximum capability with deepest reasoning (Opus 4.6 only)\n- auto: Use the default effort level for your model',
|
'Usage: /effort [low|medium|high|max|auto]\n\nEffort levels:\n- low: Quick, straightforward implementation\n- medium: Balanced approach with standard testing\n- high: Comprehensive implementation with extensive testing\n- max: Maximum capability with deepest reasoning (Opus 4.6/4.7, DeepSeek V4 Pro)\n- auto: Use the default effort level for your model',
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ function IDEScreen({
|
|||||||
} else if (value === 'None' && shouldShowDisableAutoConnectDialog()) {
|
} else if (value === 'None' && shouldShowDisableAutoConnectDialog()) {
|
||||||
setShowDisableAutoConnectDialog(true)
|
setShowDisableAutoConnectDialog(true)
|
||||||
} else {
|
} else {
|
||||||
onSelect(availableIDEs.find(ide => ide.port === parseInt(value)))
|
onSelect(availableIDEs.find(ide => ide.port === parseInt(value, 10)))
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[availableIDEs, onSelect],
|
[availableIDEs, onSelect],
|
||||||
@@ -216,7 +216,7 @@ function IDEOpenSelection({
|
|||||||
const handleSelectIDE = useCallback(
|
const handleSelectIDE = useCallback(
|
||||||
(value: string) => {
|
(value: string) => {
|
||||||
const selectedIDE = availableIDEs.find(
|
const selectedIDE = availableIDEs.find(
|
||||||
ide => ide.port === parseInt(value),
|
ide => ide.port === parseInt(value, 10),
|
||||||
)
|
)
|
||||||
onSelectIDE(selectedIDE)
|
onSelectIDE(selectedIDE)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ function ModelPickerWrapper({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Turn off fast mode if switching to unsupported model
|
// Turn off fast mode if switching to unsupported model
|
||||||
let wasFastModeToggledOn = undefined
|
let wasFastModeToggledOn
|
||||||
if (isFastModeEnabled()) {
|
if (isFastModeEnabled()) {
|
||||||
clearFastModeCooldown()
|
clearFastModeCooldown()
|
||||||
if (!isFastModeSupportedByModel(model) && isFastMode) {
|
if (!isFastModeSupportedByModel(model) && isFastMode) {
|
||||||
@@ -214,7 +214,7 @@ function SetModelAndClose({
|
|||||||
}))
|
}))
|
||||||
let message = `Set model to ${chalk.bold(renderModelLabel(modelValue))}`
|
let message = `Set model to ${chalk.bold(renderModelLabel(modelValue))}`
|
||||||
|
|
||||||
let wasFastModeToggledOn = undefined
|
let wasFastModeToggledOn
|
||||||
if (isFastModeEnabled()) {
|
if (isFastModeEnabled()) {
|
||||||
clearFastModeCooldown()
|
clearFastModeCooldown()
|
||||||
if (!isFastModeSupportedByModel(modelValue) && isFastMode) {
|
if (!isFastModeSupportedByModel(modelValue) && isFastMode) {
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import type { LocalCommandCall } from '../../types/command.js'
|
import type { LocalCommandCall } from '../../types/command.js'
|
||||||
import { listPeers, isPeerAlive } from '../../utils/udsClient.js'
|
import { listPeers, isPeerAlive } from '../../utils/udsClient.js'
|
||||||
import { getUdsMessagingSocketPath } from '../../utils/udsMessaging.js'
|
import {
|
||||||
|
formatUdsAddress,
|
||||||
|
getUdsMessagingSocketPath,
|
||||||
|
} from '../../utils/udsMessaging.js'
|
||||||
|
|
||||||
export const call: LocalCommandCall = async (_args, _context) => {
|
export const call: LocalCommandCall = async (_args, _context) => {
|
||||||
const mySocket = getUdsMessagingSocketPath()
|
const mySocket = getUdsMessagingSocketPath()
|
||||||
@@ -29,11 +32,11 @@ export const call: LocalCommandCall = async (_args, _context) => {
|
|||||||
? ` started: ${formatAge(peer.startedAt)}`
|
? ` started: ${formatAge(peer.startedAt)}`
|
||||||
: ''
|
: ''
|
||||||
|
|
||||||
lines.push(
|
lines.push(` [${status}] PID ${peer.pid} (${label})${cwd}${age}`)
|
||||||
` [${status}] PID ${peer.pid} (${label})${cwd}${age}`,
|
|
||||||
)
|
|
||||||
if (peer.messagingSocketPath) {
|
if (peer.messagingSocketPath) {
|
||||||
lines.push(` socket: ${peer.messagingSocketPath}`)
|
lines.push(
|
||||||
|
` socket: ${formatUdsAddress(peer.messagingSocketPath)}`,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
if (peer.sessionId) {
|
if (peer.sessionId) {
|
||||||
lines.push(` session: ${peer.sessionId}`)
|
lines.push(` session: ${peer.sessionId}`)
|
||||||
@@ -43,7 +46,7 @@ export const call: LocalCommandCall = async (_args, _context) => {
|
|||||||
|
|
||||||
lines.push('')
|
lines.push('')
|
||||||
lines.push(
|
lines.push(
|
||||||
'To message a peer: use SendMessage with to="uds:<socket-path>"',
|
'To message a peer: use SendMessage with the shown uds:<socket-path> address',
|
||||||
)
|
)
|
||||||
|
|
||||||
return { type: 'text', value: lines.join('\n') }
|
return { type: 'text', value: lines.join('\n') }
|
||||||
|
|||||||
@@ -133,7 +133,6 @@ export function AddMarketplace({
|
|||||||
void handleAdd()
|
void handleAdd()
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
// biome-ignore lint/correctness/useExhaustiveDependencies: intentional
|
|
||||||
}, []) // Only run once on mount
|
}, []) // Only run once on mount
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -190,7 +190,6 @@ export function ManageMarketplaces({
|
|||||||
}
|
}
|
||||||
void loadMarketplaces()
|
void loadMarketplaces()
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
// biome-ignore lint/correctness/useExhaustiveDependencies: intentional
|
|
||||||
}, [targetMarketplace, action, error])
|
}, [targetMarketplace, action, error])
|
||||||
|
|
||||||
// Check if there are any pending changes
|
// Check if there are any pending changes
|
||||||
|
|||||||
@@ -5,7 +5,8 @@
|
|||||||
* After the fix, it reads from / writes to settings.json via
|
* After the fix, it reads from / writes to settings.json via
|
||||||
* getInitialSettings() and updateSettingsForSource().
|
* getInitialSettings() and updateSettingsForSource().
|
||||||
*/
|
*/
|
||||||
import { describe, expect, test, beforeEach, mock } from 'bun:test'
|
import { afterAll, describe, expect, test, beforeEach, mock } from 'bun:test'
|
||||||
|
import * as settingsModule from '../../../utils/settings/settings.js'
|
||||||
|
|
||||||
// ── Mocks must be declared before the module under test is imported ──────────
|
// ── Mocks must be declared before the module under test is imported ──────────
|
||||||
|
|
||||||
@@ -13,24 +14,48 @@ let mockSettings: Record<string, unknown> = {}
|
|||||||
let lastUpdate: { source: string; patch: Record<string, unknown> } | null = null
|
let lastUpdate: { source: string; patch: Record<string, unknown> } | null = null
|
||||||
|
|
||||||
mock.module('src/utils/settings/settings.js', () => ({
|
mock.module('src/utils/settings/settings.js', () => ({
|
||||||
|
loadManagedFileSettings: () => ({ settings: null, errors: [] }),
|
||||||
|
getManagedFileSettingsPresence: () => ({
|
||||||
|
hasBase: false,
|
||||||
|
hasDropIns: false,
|
||||||
|
}),
|
||||||
|
parseSettingsFile: () => ({ settings: null, errors: [] }),
|
||||||
|
getSettingsRootPathForSource: () => '',
|
||||||
|
getSettingsFilePathForSource: () => undefined,
|
||||||
|
getRelativeSettingsFilePathForSource: () => '',
|
||||||
getInitialSettings: () => mockSettings,
|
getInitialSettings: () => mockSettings,
|
||||||
|
getSettingsForSource: () => mockSettings,
|
||||||
|
getPolicySettingsOrigin: () => null,
|
||||||
|
getSettingsWithErrors: () => ({ settings: mockSettings, errors: [] }),
|
||||||
|
getSettingsWithSources: () => ({ effective: mockSettings, sources: [] }),
|
||||||
|
getSettings_DEPRECATED: () => mockSettings,
|
||||||
|
settingsMergeCustomizer: () => undefined,
|
||||||
|
getManagedSettingsKeysForLogging: () => [],
|
||||||
|
// Keep unrelated exports aligned with the real settings module so this
|
||||||
|
// full-surface mock cannot change later test files if Bun keeps it alive.
|
||||||
|
hasAutoModeOptIn: () => true,
|
||||||
|
hasSkipDangerousModePermissionPrompt: () => false,
|
||||||
|
getAutoModeConfig: () => undefined,
|
||||||
|
getUseAutoModeDuringPlan: () => true,
|
||||||
|
rawSettingsContainsKey: (key: string) => key in mockSettings,
|
||||||
updateSettingsForSource: (source: string, patch: Record<string, unknown>) => {
|
updateSettingsForSource: (source: string, patch: Record<string, unknown>) => {
|
||||||
lastUpdate = { source, patch }
|
lastUpdate = { source, patch }
|
||||||
mockSettings = { ...mockSettings, ...patch }
|
mockSettings = { ...mockSettings, ...patch }
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// Import AFTER mocks are registered
|
afterAll(() => {
|
||||||
const { isPoorModeActive, setPoorMode } = await import('../poorMode.js')
|
mock.restore()
|
||||||
|
mock.module('src/utils/settings/settings.js', () => settingsModule)
|
||||||
|
})
|
||||||
|
|
||||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
// Import AFTER mocks are registered. The query suffix gives this file its own
|
||||||
|
// module instance so cross-file poorMode.js mocks cannot replace the subject
|
||||||
/** Reset module-level singleton between tests by re-importing a fresh copy. */
|
// under test during Bun's shared coverage run.
|
||||||
async function freshModule() {
|
const poorModeModulePath = '../poorMode.js?poorModeTest'
|
||||||
// Bun caches modules; we manipulate the exported functions directly since
|
const { isPoorModeActive, setPoorMode } = (await import(
|
||||||
// the singleton `poorModeActive` is reset to null only on first import.
|
poorModeModulePath
|
||||||
// Instead we test the observable behaviour through set/get pairs.
|
)) as typeof import('../poorMode.js')
|
||||||
}
|
|
||||||
|
|
||||||
// ── Tests ────────────────────────────────────────────────────────────────────
|
// ── Tests ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
@@ -204,7 +204,6 @@ export function AutoUpdater({
|
|||||||
// instead so the guard is always current without changing callback
|
// instead so the guard is always current without changing callback
|
||||||
// identity (which would re-trigger the initial-check useEffect below).
|
// identity (which would re-trigger the initial-check useEffect below).
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
// biome-ignore lint/correctness/useExhaustiveDependencies: isUpdating read via ref
|
|
||||||
}, [onAutoUpdaterResult])
|
}, [onAutoUpdaterResult])
|
||||||
|
|
||||||
// Initial check
|
// Initial check
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ export function MemoryUsageIndicator(): React.ReactNode {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||||
// biome-ignore lint/correctness/useHookAtTopLevel: USER_TYPE is a build-time constant
|
|
||||||
const memoryUsage = useMemoryUsage()
|
const memoryUsage = useMemoryUsage()
|
||||||
|
|
||||||
if (!memoryUsage) {
|
if (!memoryUsage) {
|
||||||
|
|||||||
@@ -879,7 +879,6 @@ function computeDiffStatsBetweenMessages(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -169,7 +169,6 @@ export function NativeAutoUpdater({
|
|||||||
// instead so the guard is always current without changing callback
|
// instead so the guard is always current without changing callback
|
||||||
// identity (which would re-trigger the initial-check useEffect below).
|
// identity (which would re-trigger the initial-check useEffect below).
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
// biome-ignore lint/correctness/useExhaustiveDependencies: isUpdating read via ref
|
|
||||||
}, [onAutoUpdaterResult, channel])
|
}, [onAutoUpdaterResult, channel])
|
||||||
|
|
||||||
// Initial check
|
// Initial check
|
||||||
|
|||||||
@@ -254,18 +254,17 @@ function NotificationContent({
|
|||||||
|
|
||||||
// Voice state (VOICE_MODE builds only, runtime-gated by GrowthBook)
|
// Voice state (VOICE_MODE builds only, runtime-gated by GrowthBook)
|
||||||
const voiceState = feature('VOICE_MODE')
|
const voiceState = feature('VOICE_MODE')
|
||||||
? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
|
?
|
||||||
useVoiceState(s => s.voiceState)
|
useVoiceState(s => s.voiceState)
|
||||||
: ('idle' as const)
|
: ('idle' as const)
|
||||||
// biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
|
|
||||||
const voiceEnabled = feature('VOICE_MODE') ? useVoiceEnabled() : false
|
const voiceEnabled = feature('VOICE_MODE') ? useVoiceEnabled() : false
|
||||||
const voiceError = feature('VOICE_MODE')
|
const voiceError = feature('VOICE_MODE')
|
||||||
? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
|
?
|
||||||
useVoiceState(s => s.voiceError)
|
useVoiceState(s => s.voiceError)
|
||||||
: null
|
: null
|
||||||
const isBriefOnly =
|
const isBriefOnly =
|
||||||
feature('KAIROS') || feature('KAIROS_BRIEF')
|
feature('KAIROS') || feature('KAIROS_BRIEF')
|
||||||
? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
|
?
|
||||||
useAppState(s => s.isBriefOnly)
|
useAppState(s => s.isBriefOnly)
|
||||||
: false
|
: false
|
||||||
|
|
||||||
|
|||||||
@@ -449,7 +449,7 @@ function PromptInput({
|
|||||||
// its own marginTop, so the gap stays even without ours.
|
// its own marginTop, so the gap stays even without ours.
|
||||||
const briefOwnsGap =
|
const briefOwnsGap =
|
||||||
feature('KAIROS') || feature('KAIROS_BRIEF')
|
feature('KAIROS') || feature('KAIROS_BRIEF')
|
||||||
? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
|
?
|
||||||
useAppState(s => s.isBriefOnly) && !viewingAgentTaskId
|
useAppState(s => s.isBriefOnly) && !viewingAgentTaskId
|
||||||
: false
|
: false
|
||||||
const mainLoopModel_ = useAppState(s => s.mainLoopModel)
|
const mainLoopModel_ = useAppState(s => s.mainLoopModel)
|
||||||
@@ -2384,7 +2384,7 @@ function PromptInput({
|
|||||||
useBuddyNotification()
|
useBuddyNotification()
|
||||||
|
|
||||||
const companionSpeaking = feature('BUDDY')
|
const companionSpeaking = feature('BUDDY')
|
||||||
? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
|
?
|
||||||
useAppState(s => s.companionReaction !== undefined)
|
useAppState(s => s.companionReaction !== undefined)
|
||||||
: false
|
: false
|
||||||
const { columns, rows } = useTerminalSize()
|
const { columns, rows } = useTerminalSize()
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ import { usePrStatus } from '../../hooks/usePrStatus.js'
|
|||||||
import { Byline, KeyboardShortcutHint } from '@anthropic/ink'
|
import { Byline, KeyboardShortcutHint } from '@anthropic/ink'
|
||||||
import { useTerminalSize } from '../../hooks/useTerminalSize.js'
|
import { useTerminalSize } from '../../hooks/useTerminalSize.js'
|
||||||
import { useTasksV2 } from '../../hooks/useTasksV2.js'
|
import { useTasksV2 } from '../../hooks/useTasksV2.js'
|
||||||
import { formatDuration } from '../../utils/format.js'
|
import { formatDuration, formatFileSize } from '../../utils/format.js'
|
||||||
import { VoiceWarmupHint } from './VoiceIndicator.js'
|
import { VoiceWarmupHint } from './VoiceIndicator.js'
|
||||||
import { useVoiceEnabled } from '../../hooks/useVoiceEnabled.js'
|
import { useVoiceEnabled } from '../../hooks/useVoiceEnabled.js'
|
||||||
import { useVoiceState } from '../../context/voice.js'
|
import { useVoiceState } from '../../context/voice.js'
|
||||||
@@ -63,6 +63,26 @@ const NO_OP_SUBSCRIBE = (_cb: () => void) => () => {}
|
|||||||
const NULL = () => null
|
const NULL = () => null
|
||||||
const MAX_VOICE_HINT_SHOWS = 3
|
const MAX_VOICE_HINT_SHOWS = 3
|
||||||
|
|
||||||
|
const RSS_UPDATE_INTERVAL_MS = 5_000
|
||||||
|
|
||||||
|
type RssState = { text: string; level: 'normal' | 'warning' | 'error' }
|
||||||
|
|
||||||
|
function useRssDisplay(): RssState | null {
|
||||||
|
const [state, setState] = useState<RssState | null>(null)
|
||||||
|
useEffect(() => {
|
||||||
|
function update(): void {
|
||||||
|
const mb = process.memoryUsage().rss / (1024 * 1024)
|
||||||
|
const level = mb >= 1024 ? 'error' : mb >= 512 ? 'warning' : 'normal'
|
||||||
|
const text = formatFileSize(mb * 1024 * 1024)
|
||||||
|
setState(prev => (prev?.text === text ? prev : { text, level }))
|
||||||
|
}
|
||||||
|
update()
|
||||||
|
const timer = setInterval(update, RSS_UPDATE_INTERVAL_MS)
|
||||||
|
return () => clearInterval(timer)
|
||||||
|
}, [])
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
exitMessage: {
|
exitMessage: {
|
||||||
show: boolean
|
show: boolean
|
||||||
@@ -238,14 +258,13 @@ function ModeIndicator({
|
|||||||
proactiveModule?.getNextTickAt ?? NULL,
|
proactiveModule?.getNextTickAt ?? NULL,
|
||||||
NULL,
|
NULL,
|
||||||
)
|
)
|
||||||
// biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
|
|
||||||
const voiceEnabled = feature('VOICE_MODE') ? useVoiceEnabled() : false
|
const voiceEnabled = feature('VOICE_MODE') ? useVoiceEnabled() : false
|
||||||
const voiceState = feature('VOICE_MODE')
|
const voiceState = feature('VOICE_MODE')
|
||||||
? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
|
?
|
||||||
useVoiceState(s => s.voiceState)
|
useVoiceState(s => s.voiceState)
|
||||||
: ('idle' as const)
|
: ('idle' as const)
|
||||||
const voiceWarmingUp = feature('VOICE_MODE')
|
const voiceWarmingUp = feature('VOICE_MODE')
|
||||||
? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
|
?
|
||||||
useVoiceState(s => s.voiceWarmingUp)
|
useVoiceState(s => s.voiceWarmingUp)
|
||||||
: false
|
: false
|
||||||
const hasSelection = useHasSelection()
|
const hasSelection = useHasSelection()
|
||||||
@@ -282,7 +301,7 @@ function ModeIndicator({
|
|||||||
'ctrl+x ctrl+k',
|
'ctrl+x ctrl+k',
|
||||||
)
|
)
|
||||||
const voiceKeyShortcut = feature('VOICE_MODE')
|
const voiceKeyShortcut = feature('VOICE_MODE')
|
||||||
? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
|
?
|
||||||
useShortcutDisplay('voice:pushToTalk', 'Chat', 'Space')
|
useShortcutDisplay('voice:pushToTalk', 'Chat', 'Space')
|
||||||
: ''
|
: ''
|
||||||
// Captured at mount so the hint doesn't flicker mid-session if another
|
// Captured at mount so the hint doesn't flicker mid-session if another
|
||||||
@@ -291,14 +310,13 @@ function ModeIndicator({
|
|||||||
// shown" without tracking the exact render-time condition (which depends
|
// shown" without tracking the exact render-time condition (which depends
|
||||||
// on parts/hintParts computed after the early-return hooks boundary).
|
// on parts/hintParts computed after the early-return hooks boundary).
|
||||||
const [voiceHintUnderCap] = feature('VOICE_MODE')
|
const [voiceHintUnderCap] = feature('VOICE_MODE')
|
||||||
? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
|
?
|
||||||
useState(
|
useState(
|
||||||
() =>
|
() =>
|
||||||
(getGlobalConfig().voiceFooterHintSeenCount ?? 0) <
|
(getGlobalConfig().voiceFooterHintSeenCount ?? 0) <
|
||||||
MAX_VOICE_HINT_SHOWS,
|
MAX_VOICE_HINT_SHOWS,
|
||||||
)
|
)
|
||||||
: [false]
|
: [false]
|
||||||
// biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
|
|
||||||
const voiceHintIncrementedRef = feature('VOICE_MODE') ? useRef(false) : null
|
const voiceHintIncrementedRef = feature('VOICE_MODE') ? useRef(false) : null
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (feature('VOICE_MODE')) {
|
if (feature('VOICE_MODE')) {
|
||||||
@@ -315,6 +333,7 @@ function ModeIndicator({
|
|||||||
const isKillAgentsConfirmShowing = useAppState(
|
const isKillAgentsConfirmShowing = useAppState(
|
||||||
s => s.notifications.current?.key === 'kill-agents-confirm',
|
s => s.notifications.current?.key === 'kill-agents-confirm',
|
||||||
)
|
)
|
||||||
|
const rssState = useRssDisplay()
|
||||||
|
|
||||||
// Derive team info from teamContext (no filesystem I/O needed)
|
// Derive team info from teamContext (no filesystem I/O needed)
|
||||||
// Match the same logic as TeamStatus to avoid trailing separator
|
// Match the same logic as TeamStatus to avoid trailing separator
|
||||||
@@ -428,6 +447,18 @@ function ModeIndicator({
|
|||||||
/>,
|
/>,
|
||||||
]
|
]
|
||||||
: []),
|
: []),
|
||||||
|
// RSS memory indicator — always visible
|
||||||
|
...(rssState
|
||||||
|
? [
|
||||||
|
<Text
|
||||||
|
key="rss"
|
||||||
|
dimColor={rssState.level === 'normal'}
|
||||||
|
color={rssState.level === 'error' ? 'error' : rssState.level === 'warning' ? 'warning' : undefined}
|
||||||
|
>
|
||||||
|
{rssState.text}
|
||||||
|
</Text>,
|
||||||
|
]
|
||||||
|
: []),
|
||||||
]
|
]
|
||||||
|
|
||||||
// Check if any in-process teammates exist (for hint text cycling)
|
// Check if any in-process teammates exist (for hint text cycling)
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ function PromptInputQueuedCommandsImpl(): React.ReactNode {
|
|||||||
// component early-returns when viewing a teammate.
|
// component early-returns when viewing a teammate.
|
||||||
const useBriefLayout =
|
const useBriefLayout =
|
||||||
feature('KAIROS') || feature('KAIROS_BRIEF')
|
feature('KAIROS') || feature('KAIROS_BRIEF')
|
||||||
? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
|
?
|
||||||
useAppState(s => s.isBriefOnly)
|
useAppState(s => s.isBriefOnly)
|
||||||
: false
|
: false
|
||||||
|
|
||||||
|
|||||||
@@ -258,7 +258,7 @@ export function computeWheelStep(
|
|||||||
// the curve handles it (gap=1000ms → m≈0.01 → mult≈1). No frac —
|
// 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
|
// rounding loss is minor at high mult, and frac persisting across idle
|
||||||
// was causing off-by-one on the first click back.
|
// was causing off-by-one on the first click back.
|
||||||
const m = Math.pow(0.5, gap / WHEEL_DECAY_HALFLIFE_MS)
|
const m = 0.5 ** (gap / WHEEL_DECAY_HALFLIFE_MS)
|
||||||
const cap = Math.max(WHEEL_MODE_CAP, state.base * 2)
|
const cap = Math.max(WHEEL_MODE_CAP, state.base * 2)
|
||||||
const next = 1 + (state.mult - 1) * m + WHEEL_MODE_STEP * m
|
const next = 1 + (state.mult - 1) * m + WHEEL_MODE_STEP * m
|
||||||
state.mult = Math.min(cap, next, state.mult + WHEEL_MODE_RAMP)
|
state.mult = Math.min(cap, next, state.mult + WHEEL_MODE_RAMP)
|
||||||
@@ -299,7 +299,7 @@ export function computeWheelStep(
|
|||||||
state.mult = 2
|
state.mult = 2
|
||||||
state.frac = 0
|
state.frac = 0
|
||||||
} else {
|
} else {
|
||||||
const m = Math.pow(0.5, gap / WHEEL_DECAY_HALFLIFE_MS)
|
const m = 0.5 ** (gap / WHEEL_DECAY_HALFLIFE_MS)
|
||||||
const cap =
|
const cap =
|
||||||
gap >= WHEEL_DECAY_GAP_MS ? WHEEL_DECAY_CAP_SLOW : WHEEL_DECAY_CAP_FAST
|
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)
|
state.mult = Math.min(cap, 1 + (state.mult - 1) * m + WHEEL_DECAY_STEP * m)
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
import chalk from 'chalk';
|
import chalk from 'chalk';
|
||||||
import {
|
import {
|
||||||
permissionModeTitle,
|
permissionModeTitle,
|
||||||
|
permissionModeShortTitle,
|
||||||
permissionModeFromString,
|
permissionModeFromString,
|
||||||
toExternalPermissionMode,
|
toExternalPermissionMode,
|
||||||
isExternalPermissionMode,
|
isExternalPermissionMode,
|
||||||
@@ -153,7 +154,7 @@ export function Config({
|
|||||||
const initialLanguage = React.useRef(currentLanguage);
|
const initialLanguage = React.useRef(currentLanguage);
|
||||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||||
const [scrollOffset, setScrollOffset] = useState(0);
|
const [scrollOffset, setScrollOffset] = useState(0);
|
||||||
const [isSearchMode, setIsSearchMode] = useState(true);
|
const [isSearchMode, setIsSearchMode] = useState(false);
|
||||||
const isTerminalFocused = useTerminalFocus();
|
const isTerminalFocused = useTerminalFocus();
|
||||||
const { rows } = useTerminalSize();
|
const { rows } = useTerminalSize();
|
||||||
// contentHeight is set by Settings.tsx (same value passed to Tabs to fix
|
// contentHeight is set by Settings.tsx (same value passed to Tabs to fix
|
||||||
@@ -167,6 +168,9 @@ export function Config({
|
|||||||
const thinkingEnabled = useAppState(s => s.thinkingEnabled);
|
const thinkingEnabled = useAppState(s => s.thinkingEnabled);
|
||||||
const isFastMode = useAppState(s => (isFastModeEnabled() ? s.fastMode : false));
|
const isFastMode = useAppState(s => (isFastModeEnabled() ? s.fastMode : false));
|
||||||
const promptSuggestionEnabled = useAppState(s => s.promptSuggestionEnabled);
|
const promptSuggestionEnabled = useAppState(s => s.promptSuggestionEnabled);
|
||||||
|
const currentDefaultPermissionMode = permissionModeFromString(
|
||||||
|
settingsData?.permissions?.defaultMode ?? 'default',
|
||||||
|
);
|
||||||
// Show auto in the default-mode dropdown when the user has opted in OR the
|
// Show auto in the default-mode dropdown when the user has opted in OR the
|
||||||
// config is fully 'enabled' — even if currently circuit-broken ('disabled'),
|
// config is fully 'enabled' — even if currently circuit-broken ('disabled'),
|
||||||
// an opted-in user should still see it in settings (it's a temporary state).
|
// an opted-in user should still see it in settings (it's a temporary state).
|
||||||
@@ -558,27 +562,23 @@ export function Config({
|
|||||||
{
|
{
|
||||||
id: 'defaultPermissionMode',
|
id: 'defaultPermissionMode',
|
||||||
label: 'Default permission mode',
|
label: 'Default permission mode',
|
||||||
value: settingsData?.permissions?.defaultMode || 'default',
|
value: currentDefaultPermissionMode,
|
||||||
options: (() => {
|
options: (() => {
|
||||||
const priorityOrder: PermissionMode[] = ['default', 'plan'];
|
const priorityOrder: PermissionMode[] = ['default', 'plan'];
|
||||||
const allModes: readonly PermissionMode[] = feature('TRANSCRIPT_CLASSIFIER')
|
return [...priorityOrder, ...PERMISSION_MODES.filter(m => !priorityOrder.includes(m))];
|
||||||
? PERMISSION_MODES
|
|
||||||
: EXTERNAL_PERMISSION_MODES;
|
|
||||||
const excluded: PermissionMode[] = ['bypassPermissions'];
|
|
||||||
if (feature('TRANSCRIPT_CLASSIFIER') && !showAutoInDefaultModePicker) {
|
|
||||||
excluded.push('auto');
|
|
||||||
}
|
|
||||||
return [...priorityOrder, ...allModes.filter(m => !priorityOrder.includes(m) && !excluded.includes(m))];
|
|
||||||
})(),
|
})(),
|
||||||
type: 'enum' as const,
|
type: 'enum' as const,
|
||||||
onChange(mode: string) {
|
onChange(mode: string) {
|
||||||
const parsedMode = permissionModeFromString(mode);
|
const parsedMode = permissionModeFromString(mode);
|
||||||
// Internal modes (e.g. auto) are stored directly
|
// auto is an internal-only mode — store it directly, don't convert
|
||||||
const validatedMode = isExternalPermissionMode(parsedMode) ? toExternalPermissionMode(parsedMode) : parsedMode;
|
// to its external mapping ('default') which would make it invisible.
|
||||||
|
const validatedMode = parsedMode === 'auto'
|
||||||
|
? parsedMode
|
||||||
|
: (isExternalPermissionMode(parsedMode) ? toExternalPermissionMode(parsedMode) : parsedMode);
|
||||||
const result = updateSettingsForSource('userSettings', {
|
const result = updateSettingsForSource('userSettings', {
|
||||||
permissions: {
|
permissions: {
|
||||||
...settingsData?.permissions,
|
...settingsData?.permissions,
|
||||||
defaultMode: validatedMode as ExternalPermissionMode,
|
defaultMode: validatedMode as (typeof PERMISSION_MODES)[number],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1548,6 +1548,8 @@ export function Config({
|
|||||||
'scroll:lineUp': () => moveSelection(-1),
|
'scroll:lineUp': () => moveSelection(-1),
|
||||||
'scroll:lineDown': () => moveSelection(1),
|
'scroll:lineDown': () => moveSelection(1),
|
||||||
'select:accept': toggleSetting,
|
'select:accept': toggleSetting,
|
||||||
|
'select:previousValue': () => toggleSetting(),
|
||||||
|
'select:nextValue': () => toggleSetting(),
|
||||||
'settings:search': () => {
|
'settings:search': () => {
|
||||||
setIsSearchMode(true);
|
setIsSearchMode(true);
|
||||||
setSearchQuery('');
|
setSearchQuery('');
|
||||||
@@ -1936,13 +1938,13 @@ export function Config({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<React.Fragment key={setting.id}>
|
<React.Fragment key={setting.id}>
|
||||||
<Box>
|
<Box width="100%">
|
||||||
<Box width={44}>
|
<Box width={44}>
|
||||||
<Text color={isSelected ? 'suggestion' : undefined}>
|
<Text color={isSelected ? 'suggestion' : undefined}>
|
||||||
{isSelected ? figures.pointer : ' '} {setting.label}
|
{isSelected ? figures.pointer : ' '} {setting.label}
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
<Box key={isSelected ? 'selected' : 'unselected'}>
|
<Box flexGrow={1}>
|
||||||
{setting.type === 'boolean' ? (
|
{setting.type === 'boolean' ? (
|
||||||
<>
|
<>
|
||||||
<Text color={isSelected ? 'suggestion' : undefined}>{setting.value.toString()}</Text>
|
<Text color={isSelected ? 'suggestion' : undefined}>{setting.value.toString()}</Text>
|
||||||
@@ -1963,7 +1965,7 @@ export function Config({
|
|||||||
</Text>
|
</Text>
|
||||||
) : setting.id === 'defaultPermissionMode' ? (
|
) : setting.id === 'defaultPermissionMode' ? (
|
||||||
<Text color={isSelected ? 'suggestion' : undefined}>
|
<Text color={isSelected ? 'suggestion' : undefined}>
|
||||||
{permissionModeTitle(setting.value as PermissionMode)}
|
{permissionModeShortTitle(setting.value as PermissionMode)}
|
||||||
</Text>
|
</Text>
|
||||||
) : setting.id === 'autoUpdatesChannel' && autoUpdaterDisabledReason ? (
|
) : setting.id === 'autoUpdatesChannel' && autoUpdaterDisabledReason ? (
|
||||||
<Box flexDirection="column">
|
<Box flexDirection="column">
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ export function SpinnerWithVerb(props: Props): React.ReactNode {
|
|||||||
// Hoisted to mount-time — this component re-renders at animation framerate.
|
// Hoisted to mount-time — this component re-renders at animation framerate.
|
||||||
const briefEnvEnabled =
|
const briefEnvEnabled =
|
||||||
feature('KAIROS') || feature('KAIROS_BRIEF')
|
feature('KAIROS') || feature('KAIROS_BRIEF')
|
||||||
? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
|
?
|
||||||
useMemo(() => isEnvTruthy(process.env.CLAUDE_CODE_BRIEF), [])
|
useMemo(() => isEnvTruthy(process.env.CLAUDE_CODE_BRIEF), [])
|
||||||
: false
|
: false
|
||||||
|
|
||||||
|
|||||||
@@ -370,7 +370,6 @@ function StatusLineInner({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
// biome-ignore lint/correctness/useExhaustiveDependencies: intentional
|
|
||||||
}, []) // Only run once on mount - settings stable for initial logging
|
}, []) // Only run once on mount - settings stable for initial logging
|
||||||
|
|
||||||
// Initial update on mount + cleanup on unmount
|
// Initial update on mount + cleanup on unmount
|
||||||
@@ -384,7 +383,6 @@ function StatusLineInner({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
// biome-ignore lint/correctness/useExhaustiveDependencies: intentional
|
|
||||||
}, []) // Only run once on mount, not when doUpdate changes
|
}, []) // Only run once on mount, not when doUpdate changes
|
||||||
|
|
||||||
// Get padding from settings or default to 0
|
// 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 reducedMotion = settings.prefersReducedMotion ?? false
|
||||||
|
|
||||||
const voiceState = feature('VOICE_MODE')
|
const voiceState = feature('VOICE_MODE')
|
||||||
? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
|
?
|
||||||
useVoiceState(s => s.voiceState)
|
useVoiceState(s => s.voiceState)
|
||||||
: ('idle' as const)
|
: ('idle' as const)
|
||||||
const isVoiceRecording = voiceState === 'recording'
|
const isVoiceRecording = voiceState === 'recording'
|
||||||
|
|
||||||
const audioLevels = feature('VOICE_MODE')
|
const audioLevels = feature('VOICE_MODE')
|
||||||
? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
|
?
|
||||||
useVoiceState(s => s.voiceAudioLevels)
|
useVoiceState(s => s.voiceAudioLevels)
|
||||||
: []
|
: []
|
||||||
const smoothedRef = useRef<number[]>(new Array(CURSOR_WAVEFORM_WIDTH).fill(0))
|
const smoothedRef = useRef<number[]>(new Array(CURSOR_WAVEFORM_WIDTH).fill(0))
|
||||||
|
|
||||||
const needsAnimation = isVoiceRecording && !reducedMotion
|
const needsAnimation = isVoiceRecording && !reducedMotion
|
||||||
const [animRef, animTime] = feature('VOICE_MODE')
|
const [animRef, animTime] = feature('VOICE_MODE')
|
||||||
? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
|
?
|
||||||
useAnimationFrame(needsAnimation ? 50 : null)
|
useAnimationFrame(needsAnimation ? 50 : null)
|
||||||
: [() => {}, 0]
|
: [() => {}, 0]
|
||||||
|
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ export function WorktreeExitDialog({
|
|||||||
'--count',
|
'--count',
|
||||||
`${worktreeSession.originalHeadCommit}..HEAD`,
|
`${worktreeSession.originalHeadCommit}..HEAD`,
|
||||||
])
|
])
|
||||||
const count = parseInt(commitsStr.trim()) || 0
|
const count = parseInt(commitsStr.trim(), 10) || 0
|
||||||
setCommitCount(count)
|
setCommitCount(count)
|
||||||
|
|
||||||
// If no changes and no commits, clean up silently
|
// If no changes and no commits, clean up silently
|
||||||
@@ -94,7 +94,6 @@ export function WorktreeExitDialog({
|
|||||||
}
|
}
|
||||||
void loadChanges()
|
void loadChanges()
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
// biome-ignore lint/correctness/useExhaustiveDependencies: intentional
|
|
||||||
}, [worktreeSession])
|
}, [worktreeSession])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export function ColorPicker({
|
|||||||
const [selectedIndex, setSelectedIndex] = useState(
|
const [selectedIndex, setSelectedIndex] = useState(
|
||||||
Math.max(
|
Math.max(
|
||||||
0,
|
0,
|
||||||
COLOR_OPTIONS.findIndex(opt => opt === currentColor),
|
COLOR_OPTIONS.indexOf(currentColor),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ export function MCPSettings({ onComplete }: Props): React.ReactNode {
|
|||||||
const isSSE = client.config.type === 'sse'
|
const isSSE = client.config.type === 'sse'
|
||||||
const isHTTP = client.config.type === 'http'
|
const isHTTP = client.config.type === 'http'
|
||||||
const isClaudeAIProxy = client.config.type === 'claudeai-proxy'
|
const isClaudeAIProxy = client.config.type === 'claudeai-proxy'
|
||||||
let isAuthenticated: boolean | undefined = undefined
|
let isAuthenticated: boolean | undefined
|
||||||
|
|
||||||
if (isSSE || isHTTP) {
|
if (isSSE || isHTTP) {
|
||||||
const authProvider = new ClaudeAuthProvider(
|
const authProvider = new ClaudeAuthProvider(
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ export function MCPToolListView({
|
|||||||
<Select
|
<Select
|
||||||
options={toolOptions}
|
options={toolOptions}
|
||||||
onChange={value => {
|
onChange={value => {
|
||||||
const index = parseInt(value)
|
const index = parseInt(value, 10)
|
||||||
const tool = serverTools[index]
|
const tool = serverTools[index]
|
||||||
if (tool) {
|
if (tool) {
|
||||||
onSelectTool(tool, index)
|
onSelectTool(tool, index)
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ export function AttachmentMessage({
|
|||||||
const bg = useSelectedMessageBg()
|
const bg = useSelectedMessageBg()
|
||||||
// Hoisted to mount-time — per-message component, re-renders on every scroll.
|
// Hoisted to mount-time — per-message component, re-renders on every scroll.
|
||||||
const isDemoEnv = feature('EXPERIMENTAL_SKILL_SEARCH')
|
const isDemoEnv = feature('EXPERIMENTAL_SKILL_SEARCH')
|
||||||
? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
|
?
|
||||||
useMemo(() => isEnvTruthy(process.env.IS_DEMO), [])
|
useMemo(() => isEnvTruthy(process.env.IS_DEMO), [])
|
||||||
: false
|
: false
|
||||||
// Handle teammate_mailbox BEFORE switch
|
// Handle teammate_mailbox BEFORE switch
|
||||||
|
|||||||
@@ -50,18 +50,18 @@ export function UserPromptMessage({
|
|||||||
// external builds.
|
// external builds.
|
||||||
const isBriefOnly =
|
const isBriefOnly =
|
||||||
feature('KAIROS') || feature('KAIROS_BRIEF')
|
feature('KAIROS') || feature('KAIROS_BRIEF')
|
||||||
? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
|
?
|
||||||
useAppState(s => s.isBriefOnly)
|
useAppState(s => s.isBriefOnly)
|
||||||
: false
|
: false
|
||||||
const viewingAgentTaskId =
|
const viewingAgentTaskId =
|
||||||
feature('KAIROS') || feature('KAIROS_BRIEF')
|
feature('KAIROS') || feature('KAIROS_BRIEF')
|
||||||
? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
|
?
|
||||||
useAppState(s => s.viewingAgentTaskId)
|
useAppState(s => s.viewingAgentTaskId)
|
||||||
: null
|
: null
|
||||||
// Hoisted to mount-time — per-message component, re-renders on every scroll.
|
// Hoisted to mount-time — per-message component, re-renders on every scroll.
|
||||||
const briefEnvEnabled =
|
const briefEnvEnabled =
|
||||||
feature('KAIROS') || feature('KAIROS_BRIEF')
|
feature('KAIROS') || feature('KAIROS_BRIEF')
|
||||||
? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
|
?
|
||||||
useMemo(() => isEnvTruthy(process.env.CLAUDE_CODE_BRIEF), [])
|
useMemo(() => isEnvTruthy(process.env.CLAUDE_CODE_BRIEF), [])
|
||||||
: false
|
: false
|
||||||
const useBriefLayout =
|
const useBriefLayout =
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ export function UserToolSuccessMessage({
|
|||||||
// UserPromptMessage.tsx.
|
// UserPromptMessage.tsx.
|
||||||
const isBriefOnly =
|
const isBriefOnly =
|
||||||
feature('KAIROS') || feature('KAIROS_BRIEF')
|
feature('KAIROS') || feature('KAIROS_BRIEF')
|
||||||
? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
|
?
|
||||||
useAppState(s => s.isBriefOnly)
|
useAppState(s => s.isBriefOnly)
|
||||||
: false
|
: false
|
||||||
|
|
||||||
|
|||||||
@@ -77,7 +77,6 @@ export function TeamsDialog({ initialTeams, onDone }: Props): React.ReactNode {
|
|||||||
const teammateStatuses = useMemo(() => {
|
const teammateStatuses = useMemo(() => {
|
||||||
return getTeammateStatuses(dialogLevel.teamName);
|
return getTeammateStatuses(dialogLevel.teamName);
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
// biome-ignore lint/correctness/useExhaustiveDependencies: intentional
|
|
||||||
}, [dialogLevel.teamName, refreshKey]);
|
}, [dialogLevel.teamName, refreshKey]);
|
||||||
|
|
||||||
// Periodically refresh to pick up mode changes from teammates
|
// Periodically refresh to pick up mode changes from teammates
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user