mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-17 22:05:50 +00:00
Compare commits
14 Commits
v1.10.1
...
codex-subs
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1058b7e643 | ||
|
|
d091dd8bae | ||
|
|
4427a6c6db | ||
|
|
13799b5058 | ||
|
|
cd59a88d44 | ||
|
|
bc4a2f1281 | ||
|
|
3cb4828de6 | ||
|
|
f5c3ee5b5d | ||
|
|
c2ac9a74c1 | ||
|
|
fc438bd222 | ||
|
|
4591432a1d | ||
|
|
901628b4d9 | ||
|
|
cf33c06021 | ||
|
|
e0ca1d054c |
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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,3 +61,10 @@ export { anthropicMessagesToOpenAI } from './shared/openaiConvertMessages.js'
|
|||||||
export type { ConvertMessagesOptions } from './shared/openaiConvertMessages.js'
|
export type { ConvertMessagesOptions } from './shared/openaiConvertMessages.js'
|
||||||
export { anthropicToolsToOpenAI, anthropicToolChoiceToOpenAI } from './shared/openaiConvertTools.js'
|
export { anthropicToolsToOpenAI, anthropicToolChoiceToOpenAI } from './shared/openaiConvertTools.js'
|
||||||
export { adaptOpenAIStreamToAnthropic } from './shared/openaiStreamAdapter.js'
|
export { adaptOpenAIStreamToAnthropic } from './shared/openaiStreamAdapter.js'
|
||||||
|
|
||||||
|
// Codex provider utilities
|
||||||
|
export { normalizeCodexCallId, resolveCodexCallId, createCodexFallbackCallId } from './providers/codex/callIds.js'
|
||||||
|
export { resolveCodexModel, resolveCodexMaxTokens } from './providers/codex/modelMapping.js'
|
||||||
|
export { anthropicMessagesToCodexInput } from './providers/codex/convertMessages.js'
|
||||||
|
export type { CodexImageConversionOptions } from './providers/codex/convertMessages.js'
|
||||||
|
export { anthropicToolsToCodex } from './providers/codex/convertTools.js'
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { describe, expect, test, beforeEach, afterEach } from 'bun:test'
|
||||||
|
import { resolveCodexModel } from '../modelMapping.js'
|
||||||
|
|
||||||
|
describe('resolveCodexModel', () => {
|
||||||
|
const originalEnv = {
|
||||||
|
CODEX_MODEL: process.env.CODEX_MODEL,
|
||||||
|
CODEX_DEFAULT_HAIKU_MODEL: process.env.CODEX_DEFAULT_HAIKU_MODEL,
|
||||||
|
CODEX_DEFAULT_SONNET_MODEL: process.env.CODEX_DEFAULT_SONNET_MODEL,
|
||||||
|
CODEX_DEFAULT_OPUS_MODEL: process.env.CODEX_DEFAULT_OPUS_MODEL,
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
delete process.env.CODEX_MODEL
|
||||||
|
delete process.env.CODEX_DEFAULT_HAIKU_MODEL
|
||||||
|
delete process.env.CODEX_DEFAULT_SONNET_MODEL
|
||||||
|
delete process.env.CODEX_DEFAULT_OPUS_MODEL
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
Object.assign(process.env, originalEnv)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('CODEX_MODEL env var overrides all', () => {
|
||||||
|
process.env.CODEX_MODEL = 'my-custom-model'
|
||||||
|
expect(resolveCodexModel('claude-sonnet-4-6')).toBe('my-custom-model')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('CODEX_DEFAULT_SONNET_MODEL overrides default map', () => {
|
||||||
|
process.env.CODEX_DEFAULT_SONNET_MODEL = 'my-sonnet'
|
||||||
|
expect(resolveCodexModel('claude-sonnet-4-6')).toBe('my-sonnet')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('CODEX_DEFAULT_HAIKU_MODEL overrides default map', () => {
|
||||||
|
process.env.CODEX_DEFAULT_HAIKU_MODEL = 'my-haiku'
|
||||||
|
expect(resolveCodexModel('claude-haiku-4-5-20251001')).toBe('my-haiku')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('CODEX_DEFAULT_OPUS_MODEL overrides default map', () => {
|
||||||
|
process.env.CODEX_DEFAULT_OPUS_MODEL = 'my-opus'
|
||||||
|
expect(resolveCodexModel('claude-opus-4-6')).toBe('my-opus')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('maps known sonnet model via DEFAULT_MODEL_MAP', () => {
|
||||||
|
expect(resolveCodexModel('claude-sonnet-4-6')).toBe('gpt-5.4-mini')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('maps known haiku model via DEFAULT_MODEL_MAP', () => {
|
||||||
|
expect(resolveCodexModel('claude-haiku-4-5-20251001')).toBe('gpt-5.4-mini')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('maps known opus model via DEFAULT_MODEL_MAP', () => {
|
||||||
|
expect(resolveCodexModel('claude-opus-4-6')).toBe('gpt-5.4')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('maps legacy sonnet models', () => {
|
||||||
|
expect(resolveCodexModel('claude-sonnet-4-20250514')).toBe('gpt-5.4-mini')
|
||||||
|
expect(resolveCodexModel('claude-3-5-sonnet-20241022')).toBe('gpt-5.4-mini')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('maps legacy haiku models', () => {
|
||||||
|
expect(resolveCodexModel('claude-3-5-haiku-20241022')).toBe('gpt-5.4-mini')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('maps legacy opus models', () => {
|
||||||
|
expect(resolveCodexModel('claude-opus-4-20250514')).toBe('gpt-5.4')
|
||||||
|
expect(resolveCodexModel('claude-opus-4-5-20251101')).toBe('gpt-5.4')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('uses family default for unrecognized haiku model', () => {
|
||||||
|
expect(resolveCodexModel('claude-haiku-99')).toBe('gpt-5.4-mini')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('uses family default for unrecognized sonnet model', () => {
|
||||||
|
expect(resolveCodexModel('claude-sonnet-99')).toBe('gpt-5.4-mini')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('uses family default for unrecognized opus model', () => {
|
||||||
|
expect(resolveCodexModel('claude-opus-99')).toBe('gpt-5.4')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('passes through unknown model name without family', () => {
|
||||||
|
expect(resolveCodexModel('some-random-model')).toBe('some-random-model')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('strips [1m] suffix', () => {
|
||||||
|
expect(resolveCodexModel('claude-sonnet-4-6[1m]')).toBe('gpt-5.4-mini')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('CODEX_MODEL takes precedence over family-specific vars', () => {
|
||||||
|
process.env.CODEX_MODEL = 'global-override'
|
||||||
|
process.env.CODEX_DEFAULT_SONNET_MODEL = 'family-override'
|
||||||
|
expect(resolveCodexModel('claude-sonnet-4-6')).toBe('global-override')
|
||||||
|
})
|
||||||
|
})
|
||||||
31
packages/@ant/model-provider/src/providers/codex/callIds.ts
Normal file
31
packages/@ant/model-provider/src/providers/codex/callIds.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { createHash } from 'crypto'
|
||||||
|
|
||||||
|
const MAX_CODEX_CALL_ID_LENGTH = 96
|
||||||
|
|
||||||
|
export function normalizeCodexCallId(value: unknown): string | null {
|
||||||
|
if (typeof value !== 'string') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const sanitized = value
|
||||||
|
.trim()
|
||||||
|
.replace(/\s+/g, '_')
|
||||||
|
.replace(/[^A-Za-z0-9._:-]/g, '_')
|
||||||
|
.replace(/_+/g, '_')
|
||||||
|
.slice(0, MAX_CODEX_CALL_ID_LENGTH)
|
||||||
|
|
||||||
|
return sanitized.length > 0 ? sanitized : null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createCodexFallbackCallId(seed: string): string {
|
||||||
|
const hash = createHash('sha1')
|
||||||
|
.update(seed.length > 0 ? seed : 'codex-call')
|
||||||
|
.digest('hex')
|
||||||
|
.slice(0, 24)
|
||||||
|
|
||||||
|
return `call_${hash}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveCodexCallId(value: unknown, seed: string): string {
|
||||||
|
return normalizeCodexCallId(value) ?? createCodexFallbackCallId(seed)
|
||||||
|
}
|
||||||
@@ -0,0 +1,392 @@
|
|||||||
|
import type {
|
||||||
|
ResponseFunctionToolCallOutputItem,
|
||||||
|
ResponseInputImage,
|
||||||
|
ResponseInputItem,
|
||||||
|
ResponseInputText,
|
||||||
|
} from 'openai/resources/responses/responses.mjs'
|
||||||
|
import type { Message } from '../../types/index.js'
|
||||||
|
import {
|
||||||
|
normalizeCodexCallId,
|
||||||
|
resolveCodexCallId,
|
||||||
|
} from './callIds.js'
|
||||||
|
|
||||||
|
type ContentBlock = {
|
||||||
|
type: string
|
||||||
|
text?: string
|
||||||
|
source?: {
|
||||||
|
type?: string
|
||||||
|
data?: string
|
||||||
|
media_type?: string
|
||||||
|
url?: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type ToolUseLikeBlock = {
|
||||||
|
type: 'tool_use'
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
input: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
type ToolResultLikeBlock = {
|
||||||
|
type: 'tool_result'
|
||||||
|
tool_use_id: string
|
||||||
|
content?: string | ReadonlyArray<ContentBlock>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CodexImageConversionOptions = {
|
||||||
|
resolveBase64ImageUrl?: (
|
||||||
|
data: string,
|
||||||
|
mediaType?: string,
|
||||||
|
) => Promise<string | null>
|
||||||
|
}
|
||||||
|
|
||||||
|
type CodexCallIdState = {
|
||||||
|
byOriginalId: Map<string, string>
|
||||||
|
sequence: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function createInputText(text: string): ResponseInputText {
|
||||||
|
return {
|
||||||
|
type: 'input_text',
|
||||||
|
text,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createInputImage(imageUrl: string): ResponseInputImage {
|
||||||
|
return {
|
||||||
|
type: 'input_image',
|
||||||
|
image_url: imageUrl,
|
||||||
|
detail: 'high',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getUnsupportedBlockText(type: string): string | null {
|
||||||
|
switch (type) {
|
||||||
|
case 'image':
|
||||||
|
return '[Image omitted: codex gateway currently requires remote image URLs. Configure CODEX_IMGBB_API_KEY to auto-convert local images.]'
|
||||||
|
case 'document':
|
||||||
|
return '[Document omitted: codex gateway does not support document replay.]'
|
||||||
|
default:
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getImageUrl(block: ContentBlock): string | null {
|
||||||
|
const source = block.source
|
||||||
|
if (!source) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (source.type === 'url' && typeof source.url === 'string' && source.url.length > 0) {
|
||||||
|
return source.url
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveImageUrl(
|
||||||
|
block: ContentBlock,
|
||||||
|
options: CodexImageConversionOptions,
|
||||||
|
): Promise<string | null> {
|
||||||
|
const directUrl = getImageUrl(block)
|
||||||
|
if (directUrl) {
|
||||||
|
return directUrl
|
||||||
|
}
|
||||||
|
|
||||||
|
if (block.source?.type !== 'base64') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.resolveBase64ImageUrl && typeof block.source.data === 'string') {
|
||||||
|
const uploadedUrl = await options.resolveBase64ImageUrl(
|
||||||
|
block.source.data,
|
||||||
|
block.source.media_type,
|
||||||
|
)
|
||||||
|
if (uploadedUrl) {
|
||||||
|
return uploadedUrl
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function convertBlocksToInputContent(
|
||||||
|
content: ReadonlyArray<ContentBlock>,
|
||||||
|
options: CodexImageConversionOptions,
|
||||||
|
): Promise<Array<ResponseInputText | ResponseInputImage>> {
|
||||||
|
const output: Array<ResponseInputText | ResponseInputImage> = []
|
||||||
|
|
||||||
|
for (const block of content) {
|
||||||
|
if (block.type === 'text' && block.text) {
|
||||||
|
output.push(createInputText(block.text))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (block.type === 'image') {
|
||||||
|
const imageUrl = await resolveImageUrl(block, options)
|
||||||
|
if (imageUrl) {
|
||||||
|
output.push(createInputImage(imageUrl))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fallback = getUnsupportedBlockText(block.type)
|
||||||
|
if (fallback) {
|
||||||
|
output.push(createInputText(fallback))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return output
|
||||||
|
}
|
||||||
|
|
||||||
|
async function convertToolResultOutput(
|
||||||
|
content: string | ReadonlyArray<ContentBlock> | undefined,
|
||||||
|
options: CodexImageConversionOptions,
|
||||||
|
): Promise<ResponseFunctionToolCallOutputItem['output']> {
|
||||||
|
if (!content) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof content === 'string') {
|
||||||
|
return content
|
||||||
|
}
|
||||||
|
|
||||||
|
const output = await convertBlocksToInputContent(content, options)
|
||||||
|
|
||||||
|
if (output.length === 0) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
if (output.length === 1 && output[0].type === 'input_text') {
|
||||||
|
return output[0].text
|
||||||
|
}
|
||||||
|
|
||||||
|
return output
|
||||||
|
}
|
||||||
|
|
||||||
|
function pushUserMessage(
|
||||||
|
items: ResponseInputItem[],
|
||||||
|
textParts: string[],
|
||||||
|
imageUrls: string[] = [],
|
||||||
|
): void {
|
||||||
|
const text = textParts.join('\n').trim()
|
||||||
|
if (text.length === 0 && imageUrls.length === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
items.push({
|
||||||
|
type: 'message',
|
||||||
|
role: 'user',
|
||||||
|
content: [
|
||||||
|
...(text.length > 0 ? [createInputText(text)] : []),
|
||||||
|
...imageUrls.map(createInputImage),
|
||||||
|
],
|
||||||
|
} as unknown as ResponseInputItem)
|
||||||
|
}
|
||||||
|
|
||||||
|
function pushAssistantMessage(
|
||||||
|
items: ResponseInputItem[],
|
||||||
|
textParts: string[],
|
||||||
|
): void {
|
||||||
|
const text = textParts.join('\n').trim()
|
||||||
|
if (text.length === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
items.push({
|
||||||
|
type: 'message',
|
||||||
|
role: 'assistant',
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'output_text',
|
||||||
|
text,
|
||||||
|
annotations: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} as unknown as ResponseInputItem)
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringifyToolInput(input: unknown): string {
|
||||||
|
if (typeof input === 'string') {
|
||||||
|
return input
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.stringify(input ?? {})
|
||||||
|
} catch {
|
||||||
|
return '{}'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createCodexCallIdState(): CodexCallIdState {
|
||||||
|
return {
|
||||||
|
byOriginalId: new Map(),
|
||||||
|
sequence: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveAssistantCallId(
|
||||||
|
block: ToolUseLikeBlock,
|
||||||
|
state: CodexCallIdState,
|
||||||
|
): string {
|
||||||
|
const originalId = typeof block.id === 'string' ? block.id : ''
|
||||||
|
const seed = `${block.name}:${stringifyToolInput(block.input)}:${state.sequence}`
|
||||||
|
const callId = resolveCodexCallId(originalId, seed)
|
||||||
|
|
||||||
|
if (originalId.length > 0) {
|
||||||
|
state.byOriginalId.set(originalId, callId)
|
||||||
|
}
|
||||||
|
state.sequence += 1
|
||||||
|
|
||||||
|
return callId
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveToolResultCallId(
|
||||||
|
toolUseId: unknown,
|
||||||
|
state: CodexCallIdState,
|
||||||
|
): string | null {
|
||||||
|
if (typeof toolUseId !== 'string') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return state.byOriginalId.get(toolUseId) ?? normalizeCodexCallId(toolUseId)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function convertUserContentToInputItems(
|
||||||
|
items: ResponseInputItem[],
|
||||||
|
content: ReadonlyArray<string | ContentBlock>,
|
||||||
|
options: CodexImageConversionOptions,
|
||||||
|
callIdState: CodexCallIdState,
|
||||||
|
): Promise<void> {
|
||||||
|
const textParts: string[] = []
|
||||||
|
const imageUrls: string[] = []
|
||||||
|
|
||||||
|
for (const block of content) {
|
||||||
|
if (typeof block === 'string') {
|
||||||
|
textParts.push(block)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (block.type === 'tool_result') {
|
||||||
|
pushUserMessage(items, textParts, imageUrls)
|
||||||
|
textParts.length = 0
|
||||||
|
imageUrls.length = 0
|
||||||
|
|
||||||
|
const toolResultBlock = block as ToolResultLikeBlock
|
||||||
|
const callId = resolveToolResultCallId(
|
||||||
|
toolResultBlock.tool_use_id,
|
||||||
|
callIdState,
|
||||||
|
)
|
||||||
|
if (!callId) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
items.push({
|
||||||
|
type: 'function_call_output',
|
||||||
|
call_id: callId,
|
||||||
|
output: await convertToolResultOutput(toolResultBlock.content, options),
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (block.type === 'text' && block.text) {
|
||||||
|
textParts.push(block.text)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (block.type === 'image') {
|
||||||
|
const imageUrl = await resolveImageUrl(block, options)
|
||||||
|
if (imageUrl) {
|
||||||
|
imageUrls.push(imageUrl)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fallback = getUnsupportedBlockText(block.type)
|
||||||
|
if (fallback) {
|
||||||
|
textParts.push(fallback)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pushUserMessage(items, textParts, imageUrls)
|
||||||
|
}
|
||||||
|
|
||||||
|
function convertAssistantContentToInputItems(
|
||||||
|
items: ResponseInputItem[],
|
||||||
|
content: ReadonlyArray<string | ContentBlock>,
|
||||||
|
callIdState: CodexCallIdState,
|
||||||
|
): void {
|
||||||
|
const textParts: string[] = []
|
||||||
|
|
||||||
|
for (const block of content) {
|
||||||
|
if (typeof block === 'string') {
|
||||||
|
textParts.push(block)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (block.type === 'tool_use') {
|
||||||
|
pushAssistantMessage(items, textParts)
|
||||||
|
textParts.length = 0
|
||||||
|
|
||||||
|
const toolUseBlock = block as unknown as ToolUseLikeBlock
|
||||||
|
items.push({
|
||||||
|
type: 'function_call',
|
||||||
|
call_id: resolveAssistantCallId(toolUseBlock, callIdState),
|
||||||
|
name: toolUseBlock.name,
|
||||||
|
arguments: stringifyToolInput(toolUseBlock.input),
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (block.type === 'text' && block.text) {
|
||||||
|
textParts.push(block.text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pushAssistantMessage(items, textParts)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function anthropicMessagesToCodexInput(
|
||||||
|
messages: Message[],
|
||||||
|
options: CodexImageConversionOptions = {},
|
||||||
|
): Promise<ResponseInputItem[]> {
|
||||||
|
const items: ResponseInputItem[] = []
|
||||||
|
const callIdState = createCodexCallIdState()
|
||||||
|
|
||||||
|
for (const message of messages) {
|
||||||
|
if (message.type !== 'user' && message.type !== 'assistant') {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiMessage = message.message
|
||||||
|
if (!apiMessage?.content) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof apiMessage.content === 'string') {
|
||||||
|
if (message.type === 'user') {
|
||||||
|
pushUserMessage(items, [apiMessage.content])
|
||||||
|
} else {
|
||||||
|
pushAssistantMessage(items, [apiMessage.content])
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.type === 'user') {
|
||||||
|
await convertUserContentToInputItems(
|
||||||
|
items,
|
||||||
|
apiMessage.content as ReadonlyArray<string | ContentBlock>,
|
||||||
|
options,
|
||||||
|
callIdState,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
convertAssistantContentToInputItems(
|
||||||
|
items,
|
||||||
|
apiMessage.content as ReadonlyArray<string | ContentBlock>,
|
||||||
|
callIdState,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return items
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import type { BetaToolUnion } from '@anthropic-ai/sdk/resources/beta/messages/messages.mjs'
|
||||||
|
import type { Tool as CodexTool } from 'openai/resources/responses/responses.mjs'
|
||||||
|
|
||||||
|
function isClientFunctionTool(
|
||||||
|
tool: BetaToolUnion,
|
||||||
|
): tool is BetaToolUnion & {
|
||||||
|
name: string
|
||||||
|
description?: string
|
||||||
|
input_schema?: { [key: string]: unknown }
|
||||||
|
strict?: boolean
|
||||||
|
defer_loading?: boolean
|
||||||
|
} {
|
||||||
|
const value = tool as unknown as Record<string, unknown>
|
||||||
|
return typeof value.name === 'string'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function anthropicToolsToCodex(
|
||||||
|
tools: BetaToolUnion[],
|
||||||
|
): CodexTool[] {
|
||||||
|
return tools.flatMap(tool => {
|
||||||
|
const value = tool as unknown as Record<string, unknown>
|
||||||
|
if (
|
||||||
|
value.type === 'advisor_20260301' ||
|
||||||
|
value.type === 'computer_20250124' ||
|
||||||
|
!isClientFunctionTool(tool)
|
||||||
|
) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
return [{
|
||||||
|
type: 'function',
|
||||||
|
name: tool.name,
|
||||||
|
description: tool.description,
|
||||||
|
parameters: tool.input_schema ?? {},
|
||||||
|
strict: tool.strict ?? null,
|
||||||
|
...(tool.defer_loading && { defer_loading: true }),
|
||||||
|
}]
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
/**
|
||||||
|
* Default mapping from Anthropic model names to Codex (OpenAI Responses API) model names.
|
||||||
|
* Used only when CODEX_DEFAULT_{FAMILY}_MODEL env vars are not set.
|
||||||
|
*/
|
||||||
|
const DEFAULT_MODEL_MAP: Record<string, string> = {
|
||||||
|
'claude-sonnet-4-20250514': 'gpt-5.4-mini',
|
||||||
|
'claude-sonnet-4-5-20250929': 'gpt-5.4-mini',
|
||||||
|
'claude-sonnet-4-6': 'gpt-5.4-mini',
|
||||||
|
'claude-3-7-sonnet-20250219': 'gpt-5.4-mini',
|
||||||
|
'claude-3-5-sonnet-20241022': 'gpt-5.4-mini',
|
||||||
|
'claude-opus-4-20250514': 'gpt-5.4',
|
||||||
|
'claude-opus-4-1-20250805': 'gpt-5.4',
|
||||||
|
'claude-opus-4-5-20251101': 'gpt-5.4',
|
||||||
|
'claude-opus-4-6': 'gpt-5.4',
|
||||||
|
'claude-opus-4-7': 'gpt-5.5',
|
||||||
|
'claude-haiku-4-5-20251001': 'gpt-5.4-mini',
|
||||||
|
'claude-3-5-haiku-20241022': 'gpt-5.4-mini',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default model for each family when an exact match is not in DEFAULT_MODEL_MAP.
|
||||||
|
*/
|
||||||
|
const DEFAULT_FAMILY_MAP: Record<string, string> = {
|
||||||
|
haiku: 'gpt-5.4-mini',
|
||||||
|
sonnet: 'gpt-5.4-mini',
|
||||||
|
opus: 'gpt-5.4',
|
||||||
|
}
|
||||||
|
|
||||||
|
function getModelFamily(model: string): 'haiku' | 'sonnet' | 'opus' | null {
|
||||||
|
if (/haiku/i.test(model)) return 'haiku'
|
||||||
|
if (/opus/i.test(model)) return 'opus'
|
||||||
|
if (/sonnet/i.test(model)) return 'sonnet'
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the Codex (OpenAI Responses API) model name for a given Anthropic model.
|
||||||
|
*
|
||||||
|
* Priority:
|
||||||
|
* 1. CODEX_MODEL env var (override all)
|
||||||
|
* 2. CODEX_DEFAULT_{FAMILY}_MODEL env var (e.g. CODEX_DEFAULT_SONNET_MODEL)
|
||||||
|
* 3. DEFAULT_MODEL_MAP lookup (exact Anthropic model name match)
|
||||||
|
* 4. DEFAULT_FAMILY_MAP lookup (family-based default)
|
||||||
|
* 5. Pass through original model name
|
||||||
|
*/
|
||||||
|
export function resolveCodexModel(model: string): string {
|
||||||
|
if (process.env.CODEX_MODEL) {
|
||||||
|
return process.env.CODEX_MODEL
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleanModel = model.replace(/\[1m\]$/, '')
|
||||||
|
const family = getModelFamily(cleanModel)
|
||||||
|
if (family) {
|
||||||
|
const familyOverride = process.env[`CODEX_DEFAULT_${family.toUpperCase()}_MODEL`]
|
||||||
|
if (familyOverride) {
|
||||||
|
return familyOverride
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const mapped = DEFAULT_MODEL_MAP[cleanModel]
|
||||||
|
if (mapped) {
|
||||||
|
return mapped
|
||||||
|
}
|
||||||
|
|
||||||
|
if (family) {
|
||||||
|
return DEFAULT_FAMILY_MAP[family]
|
||||||
|
}
|
||||||
|
|
||||||
|
return cleanModel
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveCodexMaxTokens(
|
||||||
|
upperLimit: number,
|
||||||
|
maxOutputTokensOverride?: number,
|
||||||
|
): number {
|
||||||
|
return (
|
||||||
|
maxOutputTokensOverride ??
|
||||||
|
(process.env.CODEX_MAX_TOKENS
|
||||||
|
? parseInt(process.env.CODEX_MAX_TOKENS, 10) || undefined
|
||||||
|
: undefined) ??
|
||||||
|
(process.env.CLAUDE_CODE_MAX_OUTPUT_TOKENS
|
||||||
|
? parseInt(process.env.CLAUDE_CODE_MAX_OUTPUT_TOKENS, 10) || undefined
|
||||||
|
: undefined) ??
|
||||||
|
upperLimit
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 ----------
|
||||||
|
|
||||||
@@ -110,7 +110,7 @@ export function storeCreateEnvironment(req: {
|
|||||||
username?: string;
|
username?: string;
|
||||||
capabilities?: Record<string, unknown>;
|
capabilities?: Record<string, unknown>;
|
||||||
}): EnvironmentRecord {
|
}): EnvironmentRecord {
|
||||||
const id = `env_${uuid().replace(/-/g, "")}`;
|
const id = `env_${randomUUID().replace(/-/g, "")}`;
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const record: EnvironmentRecord = {
|
const record: EnvironmentRecord = {
|
||||||
id,
|
id,
|
||||||
@@ -162,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,
|
||||||
@@ -317,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 {
|
||||||
|
|||||||
@@ -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}`,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ const call: LocalCommandCall = async (args, context) => {
|
|||||||
const validProviders = [
|
const validProviders = [
|
||||||
'anthropic',
|
'anthropic',
|
||||||
'openai',
|
'openai',
|
||||||
|
'codex',
|
||||||
'gemini',
|
'gemini',
|
||||||
'grok',
|
'grok',
|
||||||
'bedrock',
|
'bedrock',
|
||||||
@@ -120,10 +121,23 @@ const call: LocalCommandCall = async (args, context) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check env vars when switching to codex (including settings.env)
|
||||||
|
if (arg === 'codex') {
|
||||||
|
const mergedEnv = getMergedEnv()
|
||||||
|
const hasKey = !!(mergedEnv.CODEX_API_KEY || mergedEnv.CODEX_ACCESS_TOKEN)
|
||||||
|
if (!hasKey) {
|
||||||
|
updateSettingsForSource('userSettings', { modelType: 'codex' })
|
||||||
|
return {
|
||||||
|
type: 'text',
|
||||||
|
value: `Switched to Codex provider.\nWarning: No CODEX_API_KEY or CODEX_ACCESS_TOKEN found.\nUse /login (ChatGPT Subscription) or set manually.`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Handle different provider types
|
// Handle different provider types
|
||||||
// - 'anthropic', 'openai', 'gemini' are stored in settings.json (persistent)
|
// - 'anthropic', 'openai', 'gemini' are stored in settings.json (persistent)
|
||||||
// - 'bedrock', 'vertex', 'foundry' are env-only (do NOT touch settings.json)
|
// - 'bedrock', 'vertex', 'foundry' are env-only (do NOT touch settings.json)
|
||||||
if (arg === 'anthropic' || arg === 'openai' || arg === 'gemini' || arg === 'grok') {
|
if (arg === 'anthropic' || arg === 'openai' || arg === 'codex' || arg === 'gemini' || arg === 'grok') {
|
||||||
// Clear any cloud provider env vars to avoid conflicts
|
// Clear any cloud provider env vars to avoid conflicts
|
||||||
delete process.env.CLAUDE_CODE_USE_BEDROCK
|
delete process.env.CLAUDE_CODE_USE_BEDROCK
|
||||||
delete process.env.CLAUDE_CODE_USE_VERTEX
|
delete process.env.CLAUDE_CODE_USE_VERTEX
|
||||||
@@ -131,7 +145,7 @@ const call: LocalCommandCall = async (args, context) => {
|
|||||||
delete process.env.CLAUDE_CODE_USE_OPENAI
|
delete process.env.CLAUDE_CODE_USE_OPENAI
|
||||||
delete process.env.CLAUDE_CODE_USE_GEMINI
|
delete process.env.CLAUDE_CODE_USE_GEMINI
|
||||||
delete process.env.CLAUDE_CODE_USE_GROK
|
delete process.env.CLAUDE_CODE_USE_GROK
|
||||||
// Update settings.json
|
delete process.env.CLAUDE_CODE_USE_CODEX
|
||||||
updateSettingsForSource('userSettings', { modelType: arg })
|
updateSettingsForSource('userSettings', { modelType: arg })
|
||||||
// Ensure settings.env gets applied to process.env
|
// Ensure settings.env gets applied to process.env
|
||||||
applyConfigEnvironmentVariables()
|
applyConfigEnvironmentVariables()
|
||||||
@@ -157,9 +171,9 @@ const provider = {
|
|||||||
type: 'local',
|
type: 'local',
|
||||||
name: 'provider',
|
name: 'provider',
|
||||||
description:
|
description:
|
||||||
'Switch API provider (anthropic/openai/gemini/grok/bedrock/vertex/foundry)',
|
'Switch API provider (anthropic/openai/codex/gemini/grok/bedrock/vertex/foundry)',
|
||||||
aliases: ['api'],
|
aliases: ['api'],
|
||||||
argumentHint: '[anthropic|openai|gemini|grok|bedrock|vertex|foundry|unset]',
|
argumentHint: '[anthropic|openai|codex|gemini|grok|bedrock|vertex|foundry|unset]',
|
||||||
supportsNonInteractive: true,
|
supportsNonInteractive: true,
|
||||||
load: () => Promise.resolve({ call }),
|
load: () => Promise.resolve({ call }),
|
||||||
} satisfies Command
|
} satisfies Command
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { useKeybinding } from '../keybindings/useKeybinding.js'
|
|||||||
import { getSSLErrorHint } from '@ant/model-provider'
|
import { getSSLErrorHint } from '@ant/model-provider'
|
||||||
import { sendNotification } from '../services/notifier.js'
|
import { sendNotification } from '../services/notifier.js'
|
||||||
import { OAuthService } from '../services/oauth/index.js'
|
import { OAuthService } from '../services/oauth/index.js'
|
||||||
|
import { performOpenAICodexLogin, parseManualCodeInput } from '../services/oauth/openai-codex.js'
|
||||||
import { getOauthAccountInfo, validateForceLoginOrg } from '../utils/auth.js'
|
import { getOauthAccountInfo, validateForceLoginOrg } from '../utils/auth.js'
|
||||||
import { logError } from '../utils/log.js'
|
import { logError } from '../utils/log.js'
|
||||||
import { getSettings_DEPRECATED, updateSettingsForSource } from '../utils/settings/settings.js'
|
import { getSettings_DEPRECATED, updateSettingsForSource } from '../utils/settings/settings.js'
|
||||||
@@ -55,6 +56,20 @@ type OAuthStatus =
|
|||||||
opusModel: string
|
opusModel: string
|
||||||
activeField: 'base_url' | 'api_key' | 'haiku_model' | 'sonnet_model' | 'opus_model'
|
activeField: 'base_url' | 'api_key' | 'haiku_model' | 'sonnet_model' | 'opus_model'
|
||||||
} // Gemini Generate Content API platform
|
} // Gemini Generate Content API platform
|
||||||
|
| { state: 'codex_oauth_waiting'; url: string } // ChatGPT OAuth browser login in progress
|
||||||
|
| { state: 'codex_oauth_start' } // Trigger ChatGPT OAuth flow
|
||||||
|
| {
|
||||||
|
state: 'codex_models'
|
||||||
|
haikuModel: string
|
||||||
|
sonnetModel: string
|
||||||
|
opusModel: string
|
||||||
|
activeField: 'haiku_model' | 'sonnet_model' | 'opus_model'
|
||||||
|
codexResult: {
|
||||||
|
apiKey: string | null
|
||||||
|
accessToken: string
|
||||||
|
refreshToken: string
|
||||||
|
}
|
||||||
|
} // Codex model name configuration after OAuth success
|
||||||
| { state: 'ready_to_start' } // Flow started, waiting for browser to open
|
| { state: 'ready_to_start' } // Flow started, waiting for browser to open
|
||||||
| { state: 'waiting_for_login'; url: string } // Browser opened, waiting for user to login
|
| { state: 'waiting_for_login'; url: string } // Browser opened, waiting for user to login
|
||||||
| { state: 'creating_api_key' } // Got access token, creating API key
|
| { state: 'creating_api_key' } // Got access token, creating API key
|
||||||
@@ -108,6 +123,13 @@ export function ConsoleOAuthFlow({
|
|||||||
const [showPastePrompt, setShowPastePrompt] = useState(false)
|
const [showPastePrompt, setShowPastePrompt] = useState(false)
|
||||||
const [urlCopied, setUrlCopied] = useState(false)
|
const [urlCopied, setUrlCopied] = useState(false)
|
||||||
|
|
||||||
|
// Codex ChatGPT OAuth states
|
||||||
|
const [showCodexPastePrompt, setShowCodexPastePrompt] = useState(false)
|
||||||
|
const [codexUrlCopied, setCodexUrlCopied] = useState(false)
|
||||||
|
const [codexPastedCode, setCodexPastedCode] = useState('')
|
||||||
|
const [codexPastedCursor, setCodexPastedCursor] = useState(0)
|
||||||
|
const codexManualCodeResolveRef = useRef<((code: string) => void) | null>(null)
|
||||||
|
|
||||||
const textInputColumns = useTerminalSize().columns - PASTE_HERE_MSG.length - 1
|
const textInputColumns = useTerminalSize().columns - PASTE_HERE_MSG.length - 1
|
||||||
|
|
||||||
// Log forced login method on mount
|
// Log forced login method on mount
|
||||||
@@ -186,6 +208,39 @@ export function ConsoleOAuthFlow({
|
|||||||
}
|
}
|
||||||
}, [pastedCode, oauthStatus, showPastePrompt, urlCopied])
|
}, [pastedCode, oauthStatus, showPastePrompt, urlCopied])
|
||||||
|
|
||||||
|
// Codex OAuth: copy URL on 'c'
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
codexPastedCode === 'c' &&
|
||||||
|
oauthStatus.state === 'codex_oauth_waiting' &&
|
||||||
|
showCodexPastePrompt &&
|
||||||
|
!codexUrlCopied
|
||||||
|
) {
|
||||||
|
const url = (oauthStatus as { state: 'codex_oauth_waiting'; url: string }).url
|
||||||
|
void setClipboard(url).then(raw => {
|
||||||
|
if (raw) process.stdout.write(raw)
|
||||||
|
setCodexUrlCopied(true)
|
||||||
|
setTimeout(setCodexUrlCopied, 2000, false)
|
||||||
|
})
|
||||||
|
setCodexPastedCode('')
|
||||||
|
}
|
||||||
|
}, [codexPastedCode, oauthStatus, showCodexPastePrompt, codexUrlCopied])
|
||||||
|
|
||||||
|
// Codex OAuth: submit pasted code
|
||||||
|
const handleCodexPasteSubmit = useCallback((value: string) => {
|
||||||
|
const code = parseManualCodeInput(value)
|
||||||
|
if (!code) {
|
||||||
|
setOAuthStatus({
|
||||||
|
state: 'error',
|
||||||
|
message: 'Invalid code. Paste the full redirect URL or just the authorization code.',
|
||||||
|
toRetry: oauthStatus as any,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
codexManualCodeResolveRef.current?.(code)
|
||||||
|
codexManualCodeResolveRef.current = null
|
||||||
|
}, [oauthStatus])
|
||||||
|
|
||||||
async function handleSubmitCode(value: string, url: string) {
|
async function handleSubmitCode(value: string, url: string) {
|
||||||
try {
|
try {
|
||||||
// Expecting format "authorizationCode#state" from the authorization callback URL
|
// Expecting format "authorizationCode#state" from the authorization callback URL
|
||||||
@@ -301,6 +356,52 @@ export function ConsoleOAuthFlow({
|
|||||||
}
|
}
|
||||||
}, [oauthService, setShowPastePrompt, loginWithClaudeAi, mode, orgUUID])
|
}, [oauthService, setShowPastePrompt, loginWithClaudeAi, mode, orgUUID])
|
||||||
|
|
||||||
|
const startCodexOAuth = useCallback(async () => {
|
||||||
|
setShowCodexPastePrompt(false)
|
||||||
|
setCodexUrlCopied(false)
|
||||||
|
setCodexPastedCode('')
|
||||||
|
setCodexPastedCursor(0)
|
||||||
|
|
||||||
|
let manualCodeResolve: ((code: string) => void) | null = null
|
||||||
|
const manualCodePromise = new Promise<string>(resolve => {
|
||||||
|
manualCodeResolve = resolve
|
||||||
|
})
|
||||||
|
codexManualCodeResolveRef.current = manualCodeResolve
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await performOpenAICodexLogin({
|
||||||
|
onUrl: url => {
|
||||||
|
setOAuthStatus({ state: 'codex_oauth_waiting', url })
|
||||||
|
setTimeout(setShowCodexPastePrompt, 3000, true)
|
||||||
|
},
|
||||||
|
manualCode: manualCodePromise,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Transition to model configuration panel with defaults
|
||||||
|
setOAuthStatus({
|
||||||
|
state: 'codex_models',
|
||||||
|
haikuModel: process.env.CODEX_DEFAULT_HAIKU_MODEL || 'gpt-5.4-mini',
|
||||||
|
sonnetModel: process.env.CODEX_DEFAULT_SONNET_MODEL || 'gpt-5.4-mini',
|
||||||
|
opusModel: process.env.CODEX_DEFAULT_OPUS_MODEL || 'gpt-5.5',
|
||||||
|
activeField: 'haiku_model',
|
||||||
|
codexResult: {
|
||||||
|
apiKey: result.apiKey,
|
||||||
|
accessToken: result.accessToken,
|
||||||
|
refreshToken: result.refreshToken,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
logError(err as Error)
|
||||||
|
setOAuthStatus({
|
||||||
|
state: 'error',
|
||||||
|
message: (err as Error).message,
|
||||||
|
toRetry: { state: 'idle' },
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
codexManualCodeResolveRef.current = null
|
||||||
|
}
|
||||||
|
}, [onDone])
|
||||||
|
|
||||||
const pendingOAuthStartRef = useRef(false)
|
const pendingOAuthStartRef = useRef(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -316,6 +417,19 @@ export function ConsoleOAuthFlow({
|
|||||||
}
|
}
|
||||||
}, [oauthStatus.state, startOAuth])
|
}, [oauthStatus.state, startOAuth])
|
||||||
|
|
||||||
|
const pendingCodexOAuthRef = useRef(false)
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
oauthStatus.state === 'codex_oauth_start' &&
|
||||||
|
!pendingCodexOAuthRef.current
|
||||||
|
) {
|
||||||
|
pendingCodexOAuthRef.current = true
|
||||||
|
void startCodexOAuth().finally(() => {
|
||||||
|
pendingCodexOAuthRef.current = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [oauthStatus.state, startCodexOAuth])
|
||||||
|
|
||||||
// Auto-exit for setup-token mode
|
// Auto-exit for setup-token mode
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (mode === 'setup-token' && oauthStatus.state === 'success') {
|
if (mode === 'setup-token' && oauthStatus.state === 'success') {
|
||||||
@@ -334,6 +448,20 @@ export function ConsoleOAuthFlow({
|
|||||||
}
|
}
|
||||||
}, [mode, oauthStatus, loginWithClaudeAi, onDone])
|
}, [mode, oauthStatus, loginWithClaudeAi, onDone])
|
||||||
|
|
||||||
|
// Cancel codex OAuth with Escape
|
||||||
|
useKeybinding(
|
||||||
|
'confirm:no',
|
||||||
|
() => {
|
||||||
|
setShowCodexPastePrompt(false)
|
||||||
|
setCodexPastedCode('')
|
||||||
|
setOAuthStatus({ state: 'idle' })
|
||||||
|
},
|
||||||
|
{
|
||||||
|
context: 'Confirmation',
|
||||||
|
isActive: oauthStatus.state === 'codex_oauth_waiting',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
// Cleanup OAuth service when component unmounts
|
// Cleanup OAuth service when component unmounts
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
@@ -399,6 +527,13 @@ export function ConsoleOAuthFlow({
|
|||||||
setOAuthStatus={setOAuthStatus}
|
setOAuthStatus={setOAuthStatus}
|
||||||
setLoginWithClaudeAi={setLoginWithClaudeAi}
|
setLoginWithClaudeAi={setLoginWithClaudeAi}
|
||||||
onDone={onDone}
|
onDone={onDone}
|
||||||
|
showCodexPastePrompt={showCodexPastePrompt}
|
||||||
|
codexUrlCopied={codexUrlCopied}
|
||||||
|
codexPastedCode={codexPastedCode}
|
||||||
|
setCodexPastedCode={setCodexPastedCode}
|
||||||
|
codexPastedCursor={codexPastedCursor}
|
||||||
|
setCodexPastedCursor={setCodexPastedCursor}
|
||||||
|
handleCodexPasteSubmit={handleCodexPasteSubmit}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -420,6 +555,14 @@ type OAuthStatusMessageProps = {
|
|||||||
handleSubmitCode: (value: string, url: string) => void
|
handleSubmitCode: (value: string, url: string) => void
|
||||||
setOAuthStatus: (status: OAuthStatus) => void
|
setOAuthStatus: (status: OAuthStatus) => void
|
||||||
setLoginWithClaudeAi: (value: boolean) => void
|
setLoginWithClaudeAi: (value: boolean) => void
|
||||||
|
// Codex ChatGPT OAuth props
|
||||||
|
showCodexPastePrompt: boolean
|
||||||
|
codexUrlCopied: boolean
|
||||||
|
codexPastedCode: string
|
||||||
|
setCodexPastedCode: (value: string) => void
|
||||||
|
codexPastedCursor: number
|
||||||
|
setCodexPastedCursor: (offset: number) => void
|
||||||
|
handleCodexPasteSubmit: (value: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
function OAuthStatusMessage({
|
function OAuthStatusMessage({
|
||||||
@@ -437,6 +580,13 @@ function OAuthStatusMessage({
|
|||||||
setOAuthStatus,
|
setOAuthStatus,
|
||||||
setLoginWithClaudeAi,
|
setLoginWithClaudeAi,
|
||||||
onDone,
|
onDone,
|
||||||
|
showCodexPastePrompt,
|
||||||
|
codexUrlCopied,
|
||||||
|
codexPastedCode,
|
||||||
|
setCodexPastedCode,
|
||||||
|
codexPastedCursor,
|
||||||
|
setCodexPastedCursor,
|
||||||
|
handleCodexPasteSubmit,
|
||||||
}: OAuthStatusMessageProps): React.ReactNode {
|
}: OAuthStatusMessageProps): React.ReactNode {
|
||||||
switch (oauthStatus.state) {
|
switch (oauthStatus.state) {
|
||||||
case 'idle':
|
case 'idle':
|
||||||
@@ -475,6 +625,16 @@ function OAuthStatusMessage({
|
|||||||
),
|
),
|
||||||
value: 'openai_chat_api',
|
value: 'openai_chat_api',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: (
|
||||||
|
<Text>
|
||||||
|
OpenAI Codex (ChatGPT Subscription) -{' '}
|
||||||
|
<Text dimColor>Login with ChatGPT Plus/Pro</Text>
|
||||||
|
{'\n'}
|
||||||
|
</Text>
|
||||||
|
),
|
||||||
|
value: 'codex_chatgpt',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: (
|
label: (
|
||||||
<Text>
|
<Text>
|
||||||
@@ -552,6 +712,39 @@ function OAuthStatusMessage({
|
|||||||
opusModel: process.env.OPENAI_DEFAULT_OPUS_MODEL ?? '',
|
opusModel: process.env.OPENAI_DEFAULT_OPUS_MODEL ?? '',
|
||||||
activeField: 'base_url',
|
activeField: 'base_url',
|
||||||
})
|
})
|
||||||
|
} else if (value === 'codex_chatgpt') {
|
||||||
|
logEvent('tengu_codex_chatgpt_selected', {})
|
||||||
|
// Skip OAuth if already authenticated — go straight to model config
|
||||||
|
const settings = getSettings_DEPRECATED()
|
||||||
|
const hasToken = !!(
|
||||||
|
process.env.CODEX_ACCESS_TOKEN ||
|
||||||
|
settings?.env?.CODEX_ACCESS_TOKEN
|
||||||
|
)
|
||||||
|
if (hasToken) {
|
||||||
|
setOAuthStatus({
|
||||||
|
state: 'codex_models',
|
||||||
|
haikuModel:
|
||||||
|
process.env.CODEX_DEFAULT_HAIKU_MODEL ||
|
||||||
|
settings?.env?.CODEX_DEFAULT_HAIKU_MODEL ||
|
||||||
|
'gpt-5.4-mini',
|
||||||
|
sonnetModel:
|
||||||
|
process.env.CODEX_DEFAULT_SONNET_MODEL ||
|
||||||
|
settings?.env?.CODEX_DEFAULT_SONNET_MODEL ||
|
||||||
|
'gpt-5.4-mini',
|
||||||
|
opusModel:
|
||||||
|
process.env.CODEX_DEFAULT_OPUS_MODEL ||
|
||||||
|
settings?.env?.CODEX_DEFAULT_OPUS_MODEL ||
|
||||||
|
'gpt-5.5',
|
||||||
|
activeField: 'haiku_model',
|
||||||
|
codexResult: {
|
||||||
|
apiKey: process.env.CODEX_API_KEY || null,
|
||||||
|
accessToken: process.env.CODEX_ACCESS_TOKEN || '',
|
||||||
|
refreshToken: process.env.CODEX_REFRESH_TOKEN || '',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
setOAuthStatus({ state: 'codex_oauth_start' })
|
||||||
|
}
|
||||||
} else if (value === 'gemini_api') {
|
} else if (value === 'gemini_api') {
|
||||||
logEvent('tengu_gemini_api_selected', {})
|
logEvent('tengu_gemini_api_selected', {})
|
||||||
setOAuthStatus({
|
setOAuthStatus({
|
||||||
@@ -1275,6 +1468,282 @@ function OAuthStatusMessage({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case 'codex_oauth_waiting': {
|
||||||
|
const { url } = oauthStatus as { state: 'codex_oauth_waiting'; url: string }
|
||||||
|
const codexPasteColumns = useTerminalSize().columns - PASTE_HERE_MSG.length - 1
|
||||||
|
return (
|
||||||
|
<Box flexDirection="column" gap={1}>
|
||||||
|
{!showCodexPastePrompt && (
|
||||||
|
<Box>
|
||||||
|
<Spinner />
|
||||||
|
<Text>Opening browser for ChatGPT login...</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
{showCodexPastePrompt && (
|
||||||
|
<Box flexDirection="column" gap={1}>
|
||||||
|
<Box paddingX={1}>
|
||||||
|
<Text dimColor>
|
||||||
|
Browser didn't open? Use the url below to sign in{' '}
|
||||||
|
</Text>
|
||||||
|
{codexUrlCopied ? (
|
||||||
|
<Text color="success">(Copied!)</Text>
|
||||||
|
) : (
|
||||||
|
<Text dimColor>
|
||||||
|
<KeyboardShortcutHint shortcut="c" action="copy" parens />
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
<Link url={url}>
|
||||||
|
<Text dimColor>{url}</Text>
|
||||||
|
</Link>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
{showCodexPastePrompt && (
|
||||||
|
<Box>
|
||||||
|
<Text>{PASTE_HERE_MSG}</Text>
|
||||||
|
<TextInput
|
||||||
|
value={codexPastedCode}
|
||||||
|
onChange={setCodexPastedCode}
|
||||||
|
onSubmit={handleCodexPasteSubmit}
|
||||||
|
cursorOffset={codexPastedCursor}
|
||||||
|
onChangeCursorOffset={setCodexPastedCursor}
|
||||||
|
columns={codexPasteColumns}
|
||||||
|
mask="*"
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
<Text dimColor>
|
||||||
|
Press <Text bold>Esc</Text> to cancel
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'codex_models': {
|
||||||
|
type CodexField = 'haiku_model' | 'sonnet_model' | 'opus_model'
|
||||||
|
const CODEX_FIELDS: CodexField[] = ['haiku_model', 'sonnet_model', 'opus_model']
|
||||||
|
const cm = oauthStatus as {
|
||||||
|
state: 'codex_models'
|
||||||
|
activeField: CodexField
|
||||||
|
haikuModel: string
|
||||||
|
sonnetModel: string
|
||||||
|
opusModel: string
|
||||||
|
codexResult: { apiKey: string | null; accessToken: string; refreshToken: string }
|
||||||
|
}
|
||||||
|
const { activeField, haikuModel, sonnetModel, opusModel, codexResult } = cm
|
||||||
|
const codexDisplayValues: Record<CodexField, string> = {
|
||||||
|
haiku_model: haikuModel,
|
||||||
|
sonnet_model: sonnetModel,
|
||||||
|
opus_model: opusModel,
|
||||||
|
}
|
||||||
|
|
||||||
|
const [codexModelInput, setCodexModelInput] = useState(
|
||||||
|
() => codexDisplayValues[activeField],
|
||||||
|
)
|
||||||
|
const [codexModelCursor, setCodexModelCursor] = useState(
|
||||||
|
() => codexDisplayValues[activeField].length,
|
||||||
|
)
|
||||||
|
|
||||||
|
const buildCodexModelState = useCallback(
|
||||||
|
(field: CodexField, value: string, newActive?: CodexField) => {
|
||||||
|
const s = {
|
||||||
|
state: 'codex_models' as const,
|
||||||
|
activeField: newActive ?? activeField,
|
||||||
|
haikuModel,
|
||||||
|
sonnetModel,
|
||||||
|
opusModel,
|
||||||
|
codexResult,
|
||||||
|
}
|
||||||
|
switch (field) {
|
||||||
|
case 'haiku_model':
|
||||||
|
return { ...s, haikuModel: value }
|
||||||
|
case 'sonnet_model':
|
||||||
|
return { ...s, sonnetModel: value }
|
||||||
|
case 'opus_model':
|
||||||
|
return { ...s, opusModel: value }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[activeField, haikuModel, sonnetModel, opusModel, codexResult],
|
||||||
|
)
|
||||||
|
|
||||||
|
const doCodexModelSave = useCallback(() => {
|
||||||
|
const finalVals = { ...codexDisplayValues, [activeField]: codexModelInput }
|
||||||
|
const env: Record<string, string | undefined> = {
|
||||||
|
CODEX_API_KEY: codexResult.apiKey ?? undefined,
|
||||||
|
CODEX_ACCESS_TOKEN: codexResult.accessToken,
|
||||||
|
CODEX_REFRESH_TOKEN: codexResult.refreshToken,
|
||||||
|
CODEX_LOGIN_METHOD: 'chatgpt_subscription',
|
||||||
|
CODEX_DEFAULT_HAIKU_MODEL: finalVals.haiku_model,
|
||||||
|
CODEX_DEFAULT_SONNET_MODEL: finalVals.sonnet_model,
|
||||||
|
CODEX_DEFAULT_OPUS_MODEL: finalVals.opus_model,
|
||||||
|
}
|
||||||
|
const { error } = updateSettingsForSource('userSettings', {
|
||||||
|
modelType: 'codex' as any,
|
||||||
|
env,
|
||||||
|
} as any)
|
||||||
|
if (error) {
|
||||||
|
setOAuthStatus({
|
||||||
|
state: 'error',
|
||||||
|
message: 'Failed to save settings. Please try again.',
|
||||||
|
toRetry: {
|
||||||
|
state: 'codex_models',
|
||||||
|
haikuModel: finalVals.haiku_model,
|
||||||
|
sonnetModel: finalVals.sonnet_model,
|
||||||
|
opusModel: finalVals.opus_model,
|
||||||
|
activeField: 'haiku_model',
|
||||||
|
codexResult,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
for (const [k, v] of Object.entries(env)) {
|
||||||
|
if (v !== undefined) {
|
||||||
|
process.env[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setOAuthStatus({ state: 'success' })
|
||||||
|
void onDone()
|
||||||
|
}
|
||||||
|
}, [activeField, codexModelInput, codexDisplayValues, codexResult, setOAuthStatus, onDone])
|
||||||
|
|
||||||
|
const handleCodexModelEnter = useCallback(() => {
|
||||||
|
const idx = CODEX_FIELDS.indexOf(activeField)
|
||||||
|
if (idx === CODEX_FIELDS.length - 1) {
|
||||||
|
setOAuthStatus(buildCodexModelState(activeField, codexModelInput))
|
||||||
|
doCodexModelSave()
|
||||||
|
} else {
|
||||||
|
const next = CODEX_FIELDS[idx + 1]!
|
||||||
|
setOAuthStatus(buildCodexModelState(activeField, codexModelInput, next))
|
||||||
|
setCodexModelInput(codexDisplayValues[next] ?? '')
|
||||||
|
setCodexModelCursor((codexDisplayValues[next] ?? '').length)
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
activeField,
|
||||||
|
codexModelInput,
|
||||||
|
buildCodexModelState,
|
||||||
|
doCodexModelSave,
|
||||||
|
codexDisplayValues,
|
||||||
|
setOAuthStatus,
|
||||||
|
])
|
||||||
|
|
||||||
|
useKeybinding(
|
||||||
|
'tabs:next',
|
||||||
|
() => {
|
||||||
|
const idx = CODEX_FIELDS.indexOf(activeField)
|
||||||
|
if (idx < CODEX_FIELDS.length - 1) {
|
||||||
|
setOAuthStatus(
|
||||||
|
buildCodexModelState(activeField, codexModelInput, CODEX_FIELDS[idx + 1]),
|
||||||
|
)
|
||||||
|
setCodexModelInput(codexDisplayValues[CODEX_FIELDS[idx + 1]!] ?? '')
|
||||||
|
setCodexModelCursor((codexDisplayValues[CODEX_FIELDS[idx + 1]!] ?? '').length)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ context: 'FormField' },
|
||||||
|
)
|
||||||
|
useKeybinding(
|
||||||
|
'tabs:previous',
|
||||||
|
() => {
|
||||||
|
const idx = CODEX_FIELDS.indexOf(activeField)
|
||||||
|
if (idx > 0) {
|
||||||
|
setOAuthStatus(
|
||||||
|
buildCodexModelState(activeField, codexModelInput, CODEX_FIELDS[idx - 1]),
|
||||||
|
)
|
||||||
|
setCodexModelInput(codexDisplayValues[CODEX_FIELDS[idx - 1]!] ?? '')
|
||||||
|
setCodexModelCursor((codexDisplayValues[CODEX_FIELDS[idx - 1]!] ?? '').length)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ context: 'FormField' },
|
||||||
|
)
|
||||||
|
useKeybinding(
|
||||||
|
'confirm:no',
|
||||||
|
() => {
|
||||||
|
setOAuthStatus({ state: 'idle' })
|
||||||
|
},
|
||||||
|
{ context: 'Confirmation' },
|
||||||
|
)
|
||||||
|
|
||||||
|
// Ctrl+D: clear codex login state and re-login
|
||||||
|
useKeybinding(
|
||||||
|
'oauth:codex-relogin',
|
||||||
|
() => {
|
||||||
|
// Clear codex credentials from process.env
|
||||||
|
delete process.env.CODEX_ACCESS_TOKEN
|
||||||
|
delete process.env.CODEX_REFRESH_TOKEN
|
||||||
|
delete process.env.CODEX_API_KEY
|
||||||
|
delete process.env.CODEX_LOGIN_METHOD
|
||||||
|
delete process.env.CODEX_DEFAULT_HAIKU_MODEL
|
||||||
|
delete process.env.CODEX_DEFAULT_SONNET_MODEL
|
||||||
|
delete process.env.CODEX_DEFAULT_OPUS_MODEL
|
||||||
|
// Clear from settings.json
|
||||||
|
updateSettingsForSource('userSettings', {
|
||||||
|
modelType: undefined,
|
||||||
|
env: {
|
||||||
|
CODEX_ACCESS_TOKEN: undefined,
|
||||||
|
CODEX_REFRESH_TOKEN: undefined,
|
||||||
|
CODEX_API_KEY: undefined,
|
||||||
|
CODEX_LOGIN_METHOD: undefined,
|
||||||
|
CODEX_DEFAULT_HAIKU_MODEL: undefined,
|
||||||
|
CODEX_DEFAULT_SONNET_MODEL: undefined,
|
||||||
|
CODEX_DEFAULT_OPUS_MODEL: undefined,
|
||||||
|
},
|
||||||
|
} as any)
|
||||||
|
// Restart OAuth flow
|
||||||
|
setOAuthStatus({ state: 'codex_oauth_start' })
|
||||||
|
},
|
||||||
|
{ context: 'FormField' },
|
||||||
|
)
|
||||||
|
|
||||||
|
const codexModelColumns = useTerminalSize().columns - 20
|
||||||
|
|
||||||
|
const renderCodexModelRow = (
|
||||||
|
field: CodexField,
|
||||||
|
label: string,
|
||||||
|
) => {
|
||||||
|
const active = activeField === field
|
||||||
|
const val = codexDisplayValues[field]
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Text
|
||||||
|
backgroundColor={active ? 'suggestion' : undefined}
|
||||||
|
color={active ? 'inverseText' : undefined}
|
||||||
|
>
|
||||||
|
{` ${label} `}
|
||||||
|
</Text>
|
||||||
|
<Text> </Text>
|
||||||
|
{active ? (
|
||||||
|
<TextInput
|
||||||
|
value={codexModelInput}
|
||||||
|
onChange={setCodexModelInput}
|
||||||
|
onSubmit={handleCodexModelEnter}
|
||||||
|
cursorOffset={codexModelCursor}
|
||||||
|
onChangeCursorOffset={setCodexModelCursor}
|
||||||
|
columns={codexModelColumns}
|
||||||
|
focus={true}
|
||||||
|
/>
|
||||||
|
) : val ? (
|
||||||
|
<Text color="success">{val}</Text>
|
||||||
|
) : null}
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box flexDirection="column" gap={1}>
|
||||||
|
<Text bold>Codex Model Configuration</Text>
|
||||||
|
<Text dimColor>
|
||||||
|
ChatGPT login successful. Configure model names (press Enter on last field to save).
|
||||||
|
</Text>
|
||||||
|
<Box flexDirection="column" gap={1}>
|
||||||
|
{renderCodexModelRow('haiku_model', 'Haiku ')}
|
||||||
|
{renderCodexModelRow('sonnet_model', 'Sonnet ')}
|
||||||
|
{renderCodexModelRow('opus_model', 'Opus ')}
|
||||||
|
</Box>
|
||||||
|
<Text dimColor>
|
||||||
|
↑↓/Tab to switch · Enter on last field to save · Ctrl+R to re-login · Esc to go back
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
case 'platform_setup':
|
case 'platform_setup':
|
||||||
return (
|
return (
|
||||||
<Box flexDirection="column" gap={1} marginTop={1}>
|
<Box flexDirection="column" gap={1} marginTop={1}>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -31,13 +31,13 @@ jobs:
|
|||||||
actions: read # Required for Claude to read CI results on PRs
|
actions: read # Required for Claude to read CI results on PRs
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2, 2026-04-25
|
||||||
with:
|
with:
|
||||||
fetch-depth: 1
|
fetch-depth: 1
|
||||||
|
|
||||||
- name: Run Claude Code
|
- name: Run Claude Code
|
||||||
id: claude
|
id: claude
|
||||||
uses: anthropics/claude-code-action@v1
|
uses: anthropics/claude-code-action@567fe954a4527e81f132d87d1bdbcc94f7737434 # v1, 2026-04-25
|
||||||
with:
|
with:
|
||||||
anthropic_api_key: \${{ secrets.ANTHROPIC_API_KEY }}
|
anthropic_api_key: \${{ secrets.ANTHROPIC_API_KEY }}
|
||||||
|
|
||||||
@@ -126,13 +126,13 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2, 2026-04-25
|
||||||
with:
|
with:
|
||||||
fetch-depth: 1
|
fetch-depth: 1
|
||||||
|
|
||||||
- name: Run Claude Code Review
|
- name: Run Claude Code Review
|
||||||
id: claude-review
|
id: claude-review
|
||||||
uses: anthropics/claude-code-action@v1
|
uses: anthropics/claude-code-action@567fe954a4527e81f132d87d1bdbcc94f7737434 # v1, 2026-04-25
|
||||||
with:
|
with:
|
||||||
anthropic_api_key: \${{ secrets.ANTHROPIC_API_KEY }}
|
anthropic_api_key: \${{ secrets.ANTHROPIC_API_KEY }}
|
||||||
plugin_marketplaces: 'https://github.com/anthropics/claude-code.git'
|
plugin_marketplaces: 'https://github.com/anthropics/claude-code.git'
|
||||||
|
|||||||
@@ -80,7 +80,6 @@ async function main(): Promise<void> {
|
|||||||
(args[0] === '--version' || args[0] === '-v' || args[0] === '-V')
|
(args[0] === '--version' || args[0] === '-v' || args[0] === '-V')
|
||||||
) {
|
) {
|
||||||
// MACRO.VERSION is inlined at build time
|
// MACRO.VERSION is inlined at build time
|
||||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
|
||||||
console.log(`${MACRO.VERSION} (Claude Code)`)
|
console.log(`${MACRO.VERSION} (Claude Code)`)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -101,7 +100,6 @@ async function main(): Promise<void> {
|
|||||||
const model = (modelIdx !== -1 && args[modelIdx + 1]) || getMainLoopModel()
|
const model = (modelIdx !== -1 && args[modelIdx + 1]) || getMainLoopModel()
|
||||||
const { getSystemPrompt } = await import('../constants/prompts.js')
|
const { getSystemPrompt } = await import('../constants/prompts.js')
|
||||||
const prompt = await getSystemPrompt([], model)
|
const prompt = await getSystemPrompt([], model)
|
||||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
|
||||||
console.log(prompt.join('\n'))
|
console.log(prompt.join('\n'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export function renderPlaceholder({
|
|||||||
renderedPlaceholder: string | undefined
|
renderedPlaceholder: string | undefined
|
||||||
showPlaceholder: boolean
|
showPlaceholder: boolean
|
||||||
} {
|
} {
|
||||||
let renderedPlaceholder: string | undefined = undefined
|
let renderedPlaceholder: string | undefined
|
||||||
|
|
||||||
if (placeholder) {
|
if (placeholder) {
|
||||||
if (hidePlaceholderText) {
|
if (hidePlaceholderText) {
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ const HISTORY_CHUNK_SIZE = 10
|
|||||||
// Mode filter is included to ensure we don't mix filtered and unfiltered caches
|
// Mode filter is included to ensure we don't mix filtered and unfiltered caches
|
||||||
let pendingLoad: Promise<HistoryEntry[]> | null = null
|
let pendingLoad: Promise<HistoryEntry[]> | null = null
|
||||||
let pendingLoadTarget = 0
|
let pendingLoadTarget = 0
|
||||||
let pendingLoadModeFilter: HistoryMode | undefined = undefined
|
let pendingLoadModeFilter: HistoryMode | undefined
|
||||||
|
|
||||||
async function loadHistoryEntries(
|
async function loadHistoryEntries(
|
||||||
minCount: number,
|
minCount: number,
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user