mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-05 00:47:48 +08:00
Compare commits
336
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7b8048c6ae | ||
|
|
ec95f2ca1f | ||
|
|
aa7dbe67d3 | ||
|
|
a26680f460 | ||
|
|
522b979052 | ||
|
|
808946312a | ||
|
|
741107bf71 | ||
|
|
6962731220 | ||
|
|
062e111c03 | ||
|
|
470c59e197 | ||
|
|
2f929e74c7 | ||
|
|
fc0417ceb9 | ||
|
|
44174a31e0 | ||
|
|
b599fb7354 | ||
|
|
14f96c9fa0 | ||
|
|
6948852992 | ||
|
|
1b01b08c31 | ||
|
|
2281f2b754 | ||
|
|
b5ed802277 | ||
|
|
d1b5eb08ee | ||
|
|
dba5e6e9e9 | ||
|
|
c125e78c5f | ||
|
|
d1cb0ebecf | ||
|
|
9d7a0665c0 | ||
|
|
882bb43125 | ||
|
|
db6c522d60 | ||
|
|
e29442a06a | ||
|
|
e15ea0d5d3 | ||
|
|
2f374d6af2 | ||
|
|
4a356f4ea5 | ||
|
|
c7676d567d | ||
|
|
5ca4f87951 | ||
|
|
1fee8954cc | ||
|
|
f69b770f5e | ||
|
|
856accdced | ||
|
|
92749b4d6e | ||
|
|
f08c2e6729 | ||
|
|
e420bc6324 | ||
|
|
d723fb92d3 | ||
|
|
5b1de5f921 | ||
|
|
7ed48e7b58 | ||
|
|
af712ebdbf | ||
|
|
33d5cd5993 | ||
|
|
f5e1420ee6 | ||
|
|
b37b252b14 | ||
|
|
0097ea89ad | ||
|
|
10e63507f0 | ||
|
|
9ff4d73d5c | ||
|
|
0e3bd7eff4 | ||
|
|
1c89b5f9ab | ||
|
|
cdbbda40a6 | ||
|
|
29a9d608d9 | ||
|
|
a6dc43d5f6 | ||
|
|
c6718754d3 | ||
|
|
afdd033745 | ||
|
|
d5f54ffe8b | ||
|
|
f5ec76c5c8 | ||
|
|
784a1e0611 | ||
|
|
507cb33089 | ||
|
|
b08fa3bdb6 | ||
|
|
018af84d7d | ||
|
|
27b0381a9a | ||
|
|
57cdef4b8d | ||
|
|
36e9d21e3f | ||
|
|
b72b6ab137 | ||
|
|
30b2c8548a | ||
|
|
7c5cce4b3c | ||
|
|
9362c34fcd | ||
|
|
344b3031e9 | ||
|
|
e89c3aa674 | ||
|
|
ddbbf835af | ||
|
|
cb58a63ee3 | ||
|
|
6b1074cfcd | ||
|
|
d6894b5532 | ||
|
|
635c6765d9 | ||
|
|
86f7cc0d58 | ||
|
|
206995645b | ||
|
|
9282cce1d6 | ||
|
|
c005700a7e | ||
|
|
14744abd57 | ||
|
|
66d6c17d2d | ||
|
|
c142d39951 | ||
|
|
1eb2d10dec | ||
|
|
dabaeb8dfa | ||
|
|
2d17d4b73f | ||
|
|
18d78dd6c9 | ||
|
|
499942e3e7 | ||
|
|
ba11a72214 | ||
|
|
12571764bc | ||
|
|
1e13fa032c | ||
|
|
47b21a25d3 | ||
|
|
45a3ba8829 | ||
|
|
03f2914044 | ||
|
|
c8d1ae3e7e | ||
|
|
c5ae9c2c77 | ||
|
|
4e47c00154 | ||
|
|
313a637982 | ||
|
|
fe8ff268df | ||
|
|
bac6d6866a | ||
|
|
579f2c7cc1 | ||
|
|
ddcbeb3ae9 | ||
|
|
e25fc984af | ||
|
|
4cf47b1dee | ||
|
|
95cbd43097 | ||
|
|
09005939bf | ||
|
|
7b612b8b5a | ||
|
|
670d5e8d33 | ||
|
|
1de2e70d41 | ||
|
|
89b57464d2 | ||
|
|
09ef3adf70 | ||
|
|
3dfc15963c | ||
|
|
f6884eb8c4 | ||
|
|
f8b4382a54 | ||
|
|
d78b5a81fb | ||
|
|
89fe9e9f0a | ||
|
|
4291a91dc0 | ||
|
|
4c6bafe255 | ||
|
|
979dbc4b33 | ||
|
|
9309ad844f | ||
|
|
d672ba2068 | ||
|
|
587486ab0c | ||
|
|
40a5e1470d | ||
|
|
058660ec2e | ||
|
|
668bf5e40f | ||
|
|
77f93c638d | ||
|
|
d0c0996b9f | ||
|
|
2cb4d554aa | ||
|
|
76fb8905c9 | ||
|
|
f822df6cce | ||
|
|
45c840b8d3 | ||
|
|
214f3d6406 | ||
|
|
e8d9877b79 | ||
|
|
cae9aa4134 | ||
|
|
7323d41fbe | ||
|
|
e3644c6142 | ||
|
|
415b2da81b | ||
|
|
cc6f5e89b6 | ||
|
|
2ed2cc66ef | ||
|
|
b1bf7837cf | ||
|
|
77229943d1 | ||
|
|
a0369cf49a | ||
|
|
dbbe7b22ab | ||
|
|
166236c2ee | ||
|
|
0371a961d6 | ||
|
|
144a28f544 | ||
|
|
611c29f1f5 | ||
|
|
6540c1e46a | ||
|
|
24bf92a8bf | ||
|
|
7ae984df4b | ||
|
|
e2154629ca | ||
|
|
0bfd48b9db | ||
|
|
d5f34b2ee2 | ||
|
|
88d2b002be | ||
|
|
30a75832f8 | ||
|
|
5059093d29 | ||
|
|
5a69cfe40d | ||
|
|
3d87bbf230 | ||
|
|
633363e190 | ||
|
|
715f2773c3 | ||
|
|
d07dc86376 | ||
|
|
ef7caa40e7 | ||
|
|
7fb8d5fc0a | ||
|
|
3e540ce589 | ||
|
|
6c71f87589 | ||
|
|
a39048ecce | ||
|
|
b538aa2d66 | ||
|
|
57abb20778 | ||
|
|
d117a0cd13 | ||
|
|
ee55f46962 | ||
|
|
9210502e77 | ||
|
|
2fe2600021 | ||
|
|
9b819169d5 | ||
|
|
9631b229b3 | ||
|
|
9372d6cfa5 | ||
|
|
4dbf98163e | ||
|
|
6ec0771297 | ||
|
|
56395945c0 | ||
|
|
8032497045 | ||
|
|
b35364d7fd | ||
|
|
f085fdf918 | ||
|
|
36daba7a34 | ||
|
|
9837ce1197 | ||
|
|
1bc2287baa | ||
|
|
a519bcf705 | ||
|
|
9461b1004f | ||
|
|
3c15f523be | ||
|
|
5bcdcca784 | ||
|
|
83098f98b6 | ||
|
|
5ab35ae6ba | ||
|
|
f0b0064f3d | ||
|
|
4879295f23 | ||
|
|
64e5725331 | ||
|
|
1995198b18 | ||
|
|
5b6fce1a77 | ||
|
|
fa8e443f7b | ||
|
|
08e7530adb | ||
|
|
5687dad177 | ||
|
|
dd2958a458 | ||
|
|
c4b4dfa996 | ||
|
|
d88c454a2c | ||
|
|
8cdfa338e5 | ||
|
|
4da8c57fe3 | ||
|
|
7892aa9485 | ||
|
|
9d9892be6a | ||
|
|
e2b003af24 | ||
|
|
ffca7e0402 | ||
|
|
ec6ddb43a7 | ||
|
|
2f2d444f97 | ||
|
|
42deab67b3 | ||
|
|
1b1be918a9 | ||
|
|
3f2b67f191 | ||
|
|
6a9eea34a0 | ||
|
|
453a0b3ee7 | ||
|
|
2cd20da1ec | ||
|
|
ea4453321d | ||
|
|
acde38b8e7 | ||
|
|
9996e75a34 | ||
|
|
16f96d73ec | ||
|
|
2c89202001 | ||
|
|
fe38dcd294 | ||
|
|
4185ad1b1e | ||
|
|
6916e9da76 | ||
|
|
654f798d25 | ||
|
|
bef282cfee | ||
|
|
d21d8ce9f5 | ||
|
|
342f8b6a5f | ||
|
|
c50a1c6c46 | ||
|
|
535ee098c3 | ||
|
|
b45df89ce4 | ||
|
|
c8118edf36 | ||
|
|
4a0775c4ea | ||
|
|
6fc02dad3e | ||
|
|
dbf2809bd6 | ||
|
|
1d3051cb89 | ||
|
|
59e27524da | ||
|
|
247e7105a2 | ||
|
|
dc3743aecf | ||
|
|
1c5ee5228c | ||
|
|
9d80281b53 | ||
|
|
3b036299d4 | ||
|
|
f70ae68273 | ||
|
|
1353d76e07 | ||
|
|
621a528083 | ||
|
|
a498875591 | ||
|
|
71b54070e8 | ||
|
|
9a0d346ff3 | ||
|
|
32944538e9 | ||
|
|
0b17026eab | ||
|
|
b13d9b9b40 | ||
|
|
b7fca851b8 | ||
|
|
810c3dfe2b | ||
|
|
a1d64e5239 | ||
|
|
fb33ea57b0 | ||
|
|
5b0c763086 | ||
|
|
f3a12c1008 | ||
|
|
ca35e09eaa | ||
|
|
8cf381b0c3 | ||
|
|
654c4f6978 | ||
|
|
edb8362adc | ||
|
|
29fa4aed19 | ||
|
|
41e93858e1 | ||
|
|
8d918d0459 | ||
|
|
3a759fae89 | ||
|
|
985ff3c36a | ||
|
|
908d4f2603 | ||
|
|
4d67569873 | ||
|
|
1aab31a148 | ||
|
|
aedff9a704 | ||
|
|
669f4bddc5 | ||
|
|
1a4eede34d | ||
|
|
0318808db9 | ||
|
|
06f5d3c8c0 | ||
|
|
082407fa51 | ||
|
|
6688ee26db | ||
|
|
beb003b7ad | ||
|
|
6ecfe0f0a1 | ||
|
|
12057db476 | ||
|
|
ff47d8d48a | ||
|
|
ef5f36cc2b | ||
|
|
84022c4d48 | ||
|
|
118f441029 | ||
|
|
20399b004d | ||
|
|
050eb77508 | ||
|
|
1ab4f079c9 | ||
|
|
6c733f7590 | ||
|
|
d7d8db45ba | ||
|
|
8cf9af79da | ||
|
|
e55793c765 | ||
|
|
d8902ea612 | ||
|
|
a04673a90d | ||
|
|
a97acc07fc | ||
|
|
f8000012f7 | ||
|
|
6080f8cc88 | ||
|
|
37df5b93b1 | ||
|
|
e53abdaec2 | ||
|
|
2db32ea97e | ||
|
|
581897ee74 | ||
|
|
9a88f966d8 | ||
|
|
9d9316e434 | ||
|
|
1b697b1111 | ||
|
|
3043982486 | ||
|
|
0bf92ffffc | ||
|
|
f0f87b56a3 | ||
|
|
4148ab1931 | ||
|
|
550cc36760 | ||
|
|
531cf11025 | ||
|
|
79b70f7b5c | ||
|
|
10d369f59c | ||
|
|
2ef7ac79bc | ||
|
|
778cfb1a5c | ||
|
|
764e9fd131 | ||
|
|
387134ca87 | ||
|
|
a0767d957c | ||
|
|
b94ef91d07 | ||
|
|
e7910751d9 | ||
|
|
1d2655432d | ||
|
|
323273ff30 | ||
|
|
fb2009c65b | ||
|
|
e186cc6848 | ||
|
|
615ac99ad7 | ||
|
|
ec36cfbf75 | ||
|
|
7bf228a33c | ||
|
|
3606290ac8 | ||
|
|
e49024d33b | ||
|
|
fdbc2607ec | ||
|
|
c7cc8fd7db | ||
|
|
07efcb5146 | ||
|
|
856605defa | ||
|
|
713010fa0a | ||
|
|
cd2fbeeead | ||
|
|
a4350a482a | ||
|
|
c825375367 | ||
|
|
fc92c4f431 | ||
|
|
b61c590bdb | ||
|
|
7756c0913f | ||
|
|
c34ec7c1ee |
@@ -9,6 +9,10 @@ target/
|
||||
frontend/node_modules/
|
||||
frontend/dist/
|
||||
frontend/.vite/
|
||||
aether-vscodex/web/node_modules/
|
||||
aether-vscodex/web/dist/
|
||||
aether-vscodex/vscode-extension/node_modules/
|
||||
aether-vscodex/vscode-extension/dist/
|
||||
|
||||
# Development
|
||||
.git/
|
||||
|
||||
+55
-10
@@ -15,6 +15,11 @@ APP_PORT=8084
|
||||
# APP_IMAGE=ghcr.io/fawney19/aether:beta
|
||||
# APP_IMAGE=ghcr.io/fawney19/aether:0.7.0-rc.1
|
||||
|
||||
# Compose 应用容器的非 root 数字身份。
|
||||
# install.sh 会自动写入安装用户的 UID/GID。
|
||||
AETHER_CONTAINER_UID=65532
|
||||
AETHER_CONTAINER_GID=65532
|
||||
|
||||
# API Key 前缀(默认 sk)
|
||||
API_KEY_PREFIX=sk
|
||||
|
||||
@@ -30,17 +35,17 @@ RUST_LOG=aether_gateway=info
|
||||
# AUTH_REFRESH_COOKIE_SAMESITE=None
|
||||
# AUTH_REFRESH_COOKIE_SECURE=true
|
||||
|
||||
# 数据库配置
|
||||
# 数据库配置(仅支持 PostgreSQL)
|
||||
DB_HOST=localhost
|
||||
DB_PORT=5432
|
||||
DB_USER=postgres
|
||||
DB_NAME=aether
|
||||
DB_PASSWORD=aether
|
||||
DB_PASSWORD=
|
||||
|
||||
# Redis 配置
|
||||
REDIS_HOST=localhost
|
||||
REDIS_PORT=6379
|
||||
REDIS_PASSWORD=aether
|
||||
REDIS_PASSWORD=
|
||||
|
||||
# JWT密钥(使用 ./generate_keys.sh 生成)
|
||||
# 用于用户登录 token 签名,更换后所有用户需重新登录
|
||||
@@ -50,8 +55,12 @@ JWT_SECRET_KEY=change-this-to-a-secure-random-string
|
||||
# 注意:更换此密钥后需要在管理面板重新配置所有 Provider API Key
|
||||
ENCRYPTION_KEY=change-this-to-another-secure-random-string
|
||||
|
||||
# S3 备份的独立加密密钥(推荐)。未配置时为兼容旧部署,会回退到 ENCRYPTION_KEY。
|
||||
# 密钥轮换前必须保留旧值,离线恢复工具需要它解密历史备份。
|
||||
# AETHER_BACKUP_ENCRYPTION_KEY=change-this-to-a-dedicated-secure-random-string
|
||||
|
||||
# 启动自举管理员(仅在当前库里还没有活动管理员时生效)
|
||||
# 手动部署时取消注释并设置;install.sh 首次生成配置时会提示输入。
|
||||
# 首次启动前必须设置 ADMIN_PASSWORD;install.sh 首次生成配置时会提示输入。
|
||||
ADMIN_EMAIL=[email protected]
|
||||
ADMIN_USERNAME=admin123456
|
||||
# ADMIN_PASSWORD=
|
||||
@@ -63,20 +72,56 @@ ADMIN_USERNAME=admin123456
|
||||
# Docker/Nginx 位于独立容器时,请按实际容器网络设置,例如:172.16.0.0/12。
|
||||
# AETHER_TRUSTED_PROXY_CIDRS=127.0.0.0/8,::1/128,172.16.0.0/12
|
||||
|
||||
# docker compose 下 app 启动前自动执行 pending migration/backfill(默认 true)
|
||||
# AETHER_GATEWAY_AUTO_PREPARE_DATABASE=true
|
||||
# VS Code Codex 云端协同(仅在叠加 aether-vscodex/docker-compose.aether.yml 时需要)
|
||||
# 内部 token 至少 24 字节,建议使用:openssl rand -base64 32
|
||||
# AETHER_VSCODEX_INTERNAL_TOKEN=replace-with-a-long-random-secret
|
||||
# AETHER_VSCODEX_PUBLIC_WS_URL=wss://aether.example.com/api/vscodex/ws
|
||||
# AETHER_VSCODEX_ALLOWED_ORIGINS=https://aether.example.com
|
||||
|
||||
# PostgreSQL 连接池配置(默认按 CPU 自动计算;正式高并发环境可显式预算)
|
||||
# 启动时的数据库准备策略:auto(默认)或 verify-only
|
||||
# AETHER_GATEWAY_DATABASE_MODE=auto
|
||||
|
||||
# PostgreSQL 连接池配置(默认每核 4 条、总池至少 32 条且最多 100 条;多实例部署应显式分配每实例预算)
|
||||
# AETHER_GATEWAY_DATA_POSTGRES_MIN_CONNECTIONS=12
|
||||
# AETHER_GATEWAY_DATA_POSTGRES_MAX_CONNECTIONS=80
|
||||
# AETHER_GATEWAY_MAX_IN_FLIGHT_REQUESTS=2048
|
||||
# AETHER_GATEWAY_REQUEST_BODY_BUFFER_BUDGET_MB=256
|
||||
# AETHER_GATEWAY_REQUEST_BODY_READ_TIMEOUT_MS=120000
|
||||
# AETHER_MAX_REQUEST_BODY_MB=64
|
||||
# 请求体完整读取总超时默认关闭;确需限制时配置 1000-600000 毫秒的非零值。
|
||||
# AETHER_GATEWAY_REQUEST_BODY_READ_TIMEOUT_MS=0
|
||||
# 单请求解压后 Payload 上限(MiB),默认 256;显式设为 0 才表示不限制。
|
||||
# AETHER_MAX_REQUEST_BODY_MB=256
|
||||
# AETHER_GATEWAY_SECURITY_CACHE_TTL_MS=1000
|
||||
# AETHER_MAX_REDACTED_SYNC_RESPONSE_BODY_MB=64
|
||||
# AETHER_MAX_INTERNAL_BUFFERED_BODY_MB=128
|
||||
# AETHER_MAX_INTERNAL_BUFFERED_BODY_MB=64
|
||||
# AETHER_TUNNEL_NODE_STATUS_QUEUE_CAPACITY=1024
|
||||
# Tunnel relay 使用的独立 HMAC 密钥。启用 HTTP tunnel relay 或多网关 owner 转发时必须配置,
|
||||
# 所有网关实例必须使用同一个至少 32 字节的随机值;不要复用 JWT 或数据加密密钥。
|
||||
# AETHER_TUNNEL_RELAY_AUTH_SECRET=
|
||||
# 旧版 /api/internal/gateway/* 控制面默认关闭。确需独立服务调用时,配置至少 32 字节的
|
||||
# 独立 HMAC 密钥;不要复用 JWT、数据加密或 tunnel relay 密钥。多节点必须使用相同值和共享 Redis。
|
||||
# AETHER_INTERNAL_GATEWAY_AUTH_SECRET=
|
||||
# 远程 relay 地址必须使用 HTTPS;HTTP 仅允许 localhost 或回环 IP。
|
||||
# AETHER_TUNNEL_RELAY_BASE_URL=https://gateway-a.example.com
|
||||
# 跨网关 relay 解析到受控私有地址时才显式开启;默认关闭以防止被篡改的 attachment
|
||||
# 记录诱导网关向内网转发 relay 凭据。该开关不放宽普通 provider 的目标地址策略。
|
||||
# AETHER_TUNNEL_RELAY_ALLOW_PRIVATE_TARGETS=false
|
||||
# 更推荐按 relay 主机名精确放行私网部署(逗号分隔,大小写不敏感);不支持通配符/后缀。
|
||||
# AETHER_TUNNEL_RELAY_PRIVATE_HOST_ALLOWLIST=gateway-a.internal,gateway-b.internal
|
||||
# Bark 自建服务默认仅允许公网 HTTPS。确需明文 HTTP 或内网目标时分别显式开启:
|
||||
# AETHER_BARK_ALLOW_HTTP=false
|
||||
# AETHER_BARK_ALLOW_PRIVATE_TARGETS=false
|
||||
|
||||
# 普通 Provider 反代(包括 Provider OAuth)不按 DNS 地址过滤上游,兼容任意
|
||||
# Fake-IP 域名及内网 DNS。仅信任管理员配置的上游;没有严格 DNS 过滤开关。
|
||||
# URL 协议、字面 IP、TLS 证书,以及隧道中继和登录 OAuth 的校验仍保留。
|
||||
|
||||
# 可选 Provider OAuth 客户端。Gemini CLI 授权及刷新必须配置 client secret。
|
||||
# Antigravity 默认使用内置 native-app 客户端凭据;自定义 client ID 时必须同时配置
|
||||
# 对应的 client secret。未配置 client ID 时使用内置的公开 native-app client ID。
|
||||
# AETHER_GEMINI_CLI_OAUTH_CLIENT_ID=
|
||||
# AETHER_GEMINI_CLI_OAUTH_CLIENT_SECRET=
|
||||
# AETHER_ANTIGRAVITY_OAUTH_CLIENT_ID=
|
||||
# AETHER_ANTIGRAVITY_OAUTH_CLIENT_SECRET=
|
||||
|
||||
# PostgreSQL 容器调优:docker-compose.yml 已内置通用默认值,通常不用配置。
|
||||
# 只有在 Postgres 独占大内存、或压测显示 DB 缓存/排序/维护任务成为瓶颈时再覆盖。
|
||||
|
||||
@@ -6,7 +6,8 @@ on:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
actions: read
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: build-tunnel-${{ github.ref }}
|
||||
@@ -17,7 +18,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
|
||||
|
||||
- name: Ensure tunnel tag matches Cargo version
|
||||
shell: bash
|
||||
@@ -78,10 +79,10 @@ jobs:
|
||||
use_cross: false
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
|
||||
@@ -89,14 +90,14 @@ jobs:
|
||||
run: rustup target add ${{ matrix.target }}
|
||||
|
||||
- name: Rust cache
|
||||
uses: Swatinem/rust-cache@v2
|
||||
uses: Swatinem/rust-cache@49a0bdc70d2e1b713ca9e2869b211fcce03d3c1c # v2
|
||||
with:
|
||||
workspaces: apps/aether-tunnel -> target
|
||||
key: ${{ matrix.target }}
|
||||
|
||||
- name: Install cross
|
||||
if: matrix.use_cross
|
||||
uses: taiki-e/install-action@cross
|
||||
uses: taiki-e/install-action@1ae7257be536a92d9218a6b343dc6e6ba650f7e1 # cross
|
||||
|
||||
- name: Build
|
||||
working-directory: apps/aether-tunnel
|
||||
@@ -122,9 +123,10 @@ jobs:
|
||||
run: |
|
||||
cd target/${{ matrix.target }}/release
|
||||
7z a ../../../aether-tunnel-${{ matrix.name }}.zip aether-tunnel.exe
|
||||
tar czf ../../../aether-tunnel-${{ matrix.name }}.tar.gz aether-tunnel.exe
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v5
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5
|
||||
with:
|
||||
name: aether-tunnel-${{ matrix.name }}
|
||||
path: |
|
||||
@@ -137,9 +139,14 @@ jobs:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
permissions:
|
||||
actions: read
|
||||
attestations: write
|
||||
contents: write
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@v5
|
||||
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5
|
||||
with:
|
||||
merge-multiple: true
|
||||
path: artifacts
|
||||
@@ -148,6 +155,20 @@ jobs:
|
||||
working-directory: artifacts
|
||||
run: sha256sum aether-tunnel-* > SHA256SUMS.txt
|
||||
|
||||
- name: Attest tunnel release provenance
|
||||
id: attest-release
|
||||
uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2
|
||||
with:
|
||||
subject-path: |
|
||||
artifacts/aether-tunnel-*.tar.gz
|
||||
artifacts/aether-tunnel-*.zip
|
||||
artifacts/SHA256SUMS.txt
|
||||
|
||||
- name: Bundle tunnel release provenance
|
||||
env:
|
||||
ATTESTATION_BUNDLE: ${{ steps.attest-release.outputs.bundle-path }}
|
||||
run: install -m 0644 "${ATTESTATION_BUNDLE}" artifacts/AETHER_TUNNEL_RELEASE_PROVENANCE.sigstore.json
|
||||
|
||||
- name: Delete stale draft releases for tag
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
@@ -170,12 +191,13 @@ jobs:
|
||||
done <<< "${draft_ids}"
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2
|
||||
with:
|
||||
name: "${{ github.ref_name }}"
|
||||
generate_release_notes: true
|
||||
files: |
|
||||
artifacts/aether-tunnel-*
|
||||
artifacts/AETHER_TUNNEL_RELEASE_PROVENANCE.sigstore.json
|
||||
artifacts/SHA256SUMS.txt
|
||||
fail_on_unmatched_files: true
|
||||
|
||||
@@ -183,8 +205,10 @@ jobs:
|
||||
needs: release
|
||||
runs-on: ubuntu-latest
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
|
||||
with:
|
||||
ref: main
|
||||
|
||||
|
||||
@@ -7,8 +7,6 @@ on:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pages: write
|
||||
id-token: write
|
||||
|
||||
concurrency:
|
||||
group: pages
|
||||
@@ -46,14 +44,22 @@ jobs:
|
||||
if: needs.preflight.outputs.deploy_pages == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v5
|
||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
cache-dependency-path: |
|
||||
frontend/package-lock.json
|
||||
aether-vscodex/web/package-lock.json
|
||||
|
||||
- name: Build aether-vscodex web
|
||||
working-directory: aether-vscodex/web
|
||||
run: |
|
||||
npm ci
|
||||
npm run build
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: frontend
|
||||
@@ -69,10 +75,10 @@ jobs:
|
||||
run: cp frontend/dist/index.html frontend/dist/404.html
|
||||
|
||||
- name: Setup Pages
|
||||
uses: actions/configure-pages@v5
|
||||
uses: actions/configure-pages@983d7736d9b0ae728b81ab479565c72886d7745b # v5
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-pages-artifact@v3
|
||||
uses: actions/upload-pages-artifact@56afc609e74202658d3ffba0e8f6dda462b719fa # v3
|
||||
with:
|
||||
path: frontend/dist
|
||||
|
||||
@@ -82,7 +88,10 @@ jobs:
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
runs-on: ubuntu-latest
|
||||
needs: build
|
||||
permissions:
|
||||
id-token: write
|
||||
pages: write
|
||||
steps:
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v4
|
||||
uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4
|
||||
|
||||
@@ -0,0 +1,620 @@
|
||||
name: Nightly Release
|
||||
|
||||
on:
|
||||
# 02:17 Asia/Shanghai (18:17 UTC) every day.
|
||||
schedule:
|
||||
- cron: '17 18 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
# Checks and builds only need read access. Publishing jobs opt into write access
|
||||
# below so a failed build cannot modify the existing nightly release.
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
|
||||
# A rolling tag and image are shared by scheduled and manually retried runs.
|
||||
# Keep GitHub Release immutability disabled for this repository: the tag and
|
||||
# assets intentionally move after each successful daily build.
|
||||
concurrency:
|
||||
group: nightly-main
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
CARGO_INCREMENTAL: '0'
|
||||
CARGO_PROFILE_DEV_DEBUG: '0'
|
||||
CARGO_PROFILE_TEST_DEBUG: '0'
|
||||
CARGO_TERM_COLOR: always
|
||||
RUST_BACKTRACE: '1'
|
||||
|
||||
jobs:
|
||||
source:
|
||||
name: Resolve main snapshot
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
outputs:
|
||||
sha: ${{ steps.snapshot.outputs.sha }}
|
||||
short_sha: ${{ steps.snapshot.outputs.short_sha }}
|
||||
date: ${{ steps.snapshot.outputs.date }}
|
||||
ghcr_image: ${{ steps.snapshot.outputs.ghcr_image }}
|
||||
steps:
|
||||
- name: Require main branch
|
||||
id: snapshot
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
if [[ "${GITHUB_REF}" != "refs/heads/main" ]]; then
|
||||
echo "Nightly releases must run from refs/heads/main (got ${GITHUB_REF})." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
sha="${GITHUB_SHA}"
|
||||
# Docker 镜像仓库名必须全小写;GitHub owner 可能保留大写,先统一规范化。
|
||||
repository_owner="${GITHUB_REPOSITORY%%/*}"
|
||||
repository_owner="${repository_owner,,}"
|
||||
echo "sha=${sha}" >> "${GITHUB_OUTPUT}"
|
||||
echo "short_sha=${sha:0:7}" >> "${GITHUB_OUTPUT}"
|
||||
echo "date=$(date -u +'%Y-%m-%d')" >> "${GITHUB_OUTPUT}"
|
||||
echo "ghcr_image=ghcr.io/${repository_owner}/aether" >> "${GITHUB_OUTPUT}"
|
||||
echo "Building main at ${sha}."
|
||||
|
||||
# Keep the scheduled backend coverage in one place so it cannot drift from PR CI.
|
||||
rust_ci:
|
||||
name: Rust CI
|
||||
needs: source
|
||||
uses: ./.github/workflows/rust-ci.yml
|
||||
|
||||
rust_extended:
|
||||
name: Rust extended checks
|
||||
needs: source
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 90
|
||||
steps:
|
||||
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
|
||||
with:
|
||||
ref: ${{ needs.source.outputs.sha }}
|
||||
|
||||
- name: Install pinned Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable
|
||||
with:
|
||||
toolchain: 1.95.0
|
||||
|
||||
- name: Show Rust toolchain
|
||||
run: rustc -Vv
|
||||
|
||||
- name: Rust cache
|
||||
uses: Swatinem/rust-cache@49a0bdc70d2e1b713ca9e2869b211fcce03d3c1c # v2
|
||||
with:
|
||||
shared-key: nightly-rust-1.95-${{ runner.os }}
|
||||
workspaces: . -> target
|
||||
|
||||
- name: Setup sccache
|
||||
uses: mozilla-actions/sccache-action@7d986dd989559c6ecdb630a3fd2557667be217ad # v0.0.9
|
||||
|
||||
- name: Check all workspace targets
|
||||
env:
|
||||
RUSTC_WRAPPER: sccache
|
||||
SCCACHE_GHA_ENABLED: 'true'
|
||||
run: cargo check --workspace --all-targets --all-features --locked
|
||||
|
||||
- name: Run workspace doctests
|
||||
env:
|
||||
RUSTC_WRAPPER: sccache
|
||||
SCCACHE_GHA_ENABLED: 'true'
|
||||
run: cargo test --workspace --all-features --doc --locked
|
||||
|
||||
- name: Show sccache stats
|
||||
if: always()
|
||||
env:
|
||||
RUSTC_WRAPPER: sccache
|
||||
SCCACHE_GHA_ENABLED: 'true'
|
||||
run: sccache --show-stats
|
||||
|
||||
frontend:
|
||||
name: Frontend checks and build
|
||||
needs: source
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
|
||||
with:
|
||||
ref: ${{ needs.source.outputs.sha }}
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: npm
|
||||
cache-dependency-path: |
|
||||
frontend/package-lock.json
|
||||
aether-vscodex/web/package-lock.json
|
||||
|
||||
# The frontend prebuild synchronizes the embedded VSCodex UI by running
|
||||
# its build from a separate package. Install that package explicitly so
|
||||
# vue-tsc can resolve vite/client, vitest/globals, and node types in a
|
||||
# clean runner.
|
||||
- name: Install VSCodex web dependencies
|
||||
working-directory: aether-vscodex/web
|
||||
run: npm ci
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: frontend
|
||||
run: npm ci
|
||||
|
||||
- name: Lint
|
||||
working-directory: frontend
|
||||
run: npx --no-install eslint .
|
||||
|
||||
- name: Type-check
|
||||
working-directory: frontend
|
||||
run: npm run type-check
|
||||
|
||||
- name: Run unit tests
|
||||
working-directory: frontend
|
||||
run: npm run test:run
|
||||
|
||||
- name: Build nightly frontend
|
||||
working-directory: frontend
|
||||
env:
|
||||
AETHER_BUILD_VERSION: nightly-${{ needs.source.outputs.short_sha }}
|
||||
AETHER_VERSION: nightly
|
||||
run: npm run build
|
||||
|
||||
- name: Upload frontend artifact
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5
|
||||
with:
|
||||
name: nightly-frontend-dist
|
||||
path: frontend/dist/
|
||||
if-no-files-found: error
|
||||
overwrite: true
|
||||
retention-days: 7
|
||||
|
||||
repository_health:
|
||||
name: Repository health checks
|
||||
needs: source
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
|
||||
with:
|
||||
ref: ${{ needs.source.outputs.sha }}
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
- name: Check generated format coverage matrix
|
||||
run: python3 docs/api/generate_format_field_coverage.py --check
|
||||
|
||||
- name: Test pressure report checker
|
||||
run: node --test tools/pressure/check_gateway_stage_report.test.js
|
||||
|
||||
checks:
|
||||
name: Nightly check gate
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ always() }}
|
||||
needs:
|
||||
- source
|
||||
- rust_ci
|
||||
- rust_extended
|
||||
- frontend
|
||||
- repository_health
|
||||
steps:
|
||||
- name: Verify check jobs
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
failed=0
|
||||
echo "source=${{ needs.source.result }}"
|
||||
echo "rust_ci=${{ needs.rust_ci.result }}"
|
||||
echo "rust_extended=${{ needs.rust_extended.result }}"
|
||||
echo "frontend=${{ needs.frontend.result }}"
|
||||
echo "repository_health=${{ needs.repository_health.result }}"
|
||||
|
||||
for result in \
|
||||
"${{ needs.source.result }}" \
|
||||
"${{ needs.rust_ci.result }}" \
|
||||
"${{ needs.rust_extended.result }}" \
|
||||
"${{ needs.frontend.result }}" \
|
||||
"${{ needs.repository_health.result }}"; do
|
||||
if [[ "${result}" != "success" ]]; then
|
||||
failed=1
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ "${failed}" -ne 0 ]]; then
|
||||
echo 'One or more nightly checks failed or were cancelled.' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
build:
|
||||
name: Build ${{ matrix.name }}
|
||||
needs: [source, checks]
|
||||
if: ${{ needs.checks.result == 'success' }}
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 120
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- name: linux-amd64
|
||||
target: x86_64-unknown-linux-musl
|
||||
platform: linux
|
||||
arch: amd64
|
||||
os: ubuntu-latest
|
||||
use_cross: true
|
||||
- name: linux-arm64
|
||||
target: aarch64-unknown-linux-musl
|
||||
platform: linux
|
||||
arch: arm64
|
||||
os: ubuntu-latest
|
||||
use_cross: true
|
||||
- name: macos-amd64
|
||||
target: x86_64-apple-darwin
|
||||
platform: macos
|
||||
arch: amd64
|
||||
os: macos-15-intel
|
||||
use_cross: false
|
||||
- name: macos-arm64
|
||||
target: aarch64-apple-darwin
|
||||
platform: macos
|
||||
arch: arm64
|
||||
os: macos-15
|
||||
use_cross: false
|
||||
steps:
|
||||
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
|
||||
with:
|
||||
ref: ${{ needs.source.outputs.sha }}
|
||||
|
||||
- name: Install pinned Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable
|
||||
with:
|
||||
toolchain: 1.95.0
|
||||
targets: ${{ matrix.target }}
|
||||
|
||||
- name: Rust cache
|
||||
uses: Swatinem/rust-cache@49a0bdc70d2e1b713ca9e2869b211fcce03d3c1c # v2
|
||||
with:
|
||||
shared-key: nightly-release-${{ matrix.target }}
|
||||
workspaces: . -> target
|
||||
|
||||
- name: Install cross
|
||||
if: matrix.use_cross
|
||||
uses: taiki-e/install-action@1ae7257be536a92d9218a6b343dc6e6ba650f7e1 # cross
|
||||
|
||||
- name: Build release binary
|
||||
env:
|
||||
AETHER_BUILD_VERSION: nightly-${{ needs.source.outputs.short_sha }}
|
||||
AETHER_VERSION: nightly
|
||||
AETHER_BUILD_TYPE: release
|
||||
CARGO_TERM_COLOR: always
|
||||
shell: bash
|
||||
run: |
|
||||
if [[ "${{ matrix.use_cross }}" == "true" ]]; then
|
||||
cross build --release --locked -p aether-gateway --target "${{ matrix.target }}"
|
||||
else
|
||||
cargo build --release --locked -p aether-gateway --target "${{ matrix.target }}"
|
||||
fi
|
||||
|
||||
- name: Upload binary artifact
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5
|
||||
with:
|
||||
name: nightly-gateway-${{ matrix.platform }}-${{ matrix.arch }}
|
||||
path: target/${{ matrix.target }}/release/aether-gateway
|
||||
if-no-files-found: error
|
||||
overwrite: true
|
||||
retention-days: 7
|
||||
|
||||
docker:
|
||||
name: Publish nightly GHCR image
|
||||
needs: [source, checks, build]
|
||||
if: ${{ needs.checks.result == 'success' && needs.build.result == 'success' }}
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
GHCR_IMAGE: ${{ needs.source.outputs.ghcr_image }}
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
|
||||
with:
|
||||
ref: ${{ needs.source.outputs.sha }}
|
||||
|
||||
- name: Download Linux binaries and frontend
|
||||
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5
|
||||
with:
|
||||
pattern: nightly-*
|
||||
path: artifacts
|
||||
merge-multiple: false
|
||||
|
||||
- name: Prepare Docker build context
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p dist/frontend
|
||||
cp artifacts/nightly-gateway-linux-amd64/aether-gateway dist/aether-gateway-amd64
|
||||
cp artifacts/nightly-gateway-linux-arm64/aether-gateway dist/aether-gateway-arm64
|
||||
chmod 0755 dist/aether-gateway-amd64 dist/aether-gateway-arm64
|
||||
cp -R artifacts/nightly-frontend-dist/. dist/frontend/
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6 # v3
|
||||
|
||||
- name: Log in to GHCR
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push nightly image
|
||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile.app
|
||||
push: true
|
||||
platforms: linux/amd64,linux/arm64
|
||||
tags: |
|
||||
${{ env.GHCR_IMAGE }}:nightly
|
||||
${{ env.GHCR_IMAGE }}:nightly-${{ needs.source.outputs.sha }}
|
||||
labels: |
|
||||
org.opencontainers.image.title=Aether
|
||||
org.opencontainers.image.version=nightly
|
||||
org.opencontainers.image.revision=${{ needs.source.outputs.sha }}
|
||||
org.opencontainers.image.source=https://github.com/${{ github.repository }}
|
||||
|
||||
package:
|
||||
name: Package nightly archives
|
||||
needs: [source, checks, build]
|
||||
if: ${{ needs.checks.result == 'success' && needs.build.result == 'success' }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
|
||||
with:
|
||||
ref: ${{ needs.source.outputs.sha }}
|
||||
|
||||
- name: Download nightly artifacts
|
||||
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5
|
||||
with:
|
||||
pattern: nightly-*
|
||||
path: artifacts
|
||||
merge-multiple: false
|
||||
|
||||
- name: Build nightly release packages
|
||||
shell: bash
|
||||
env:
|
||||
SOURCE_REF: ${{ needs.source.outputs.sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
VERSION="nightly"
|
||||
|
||||
mkdir -p package release-assets
|
||||
for platform in linux macos; do
|
||||
for arch in amd64 arm64; do
|
||||
bundle="aether-${VERSION}-${platform}-${arch}"
|
||||
root="package/${bundle}"
|
||||
mkdir -p "${root}/bin" "${root}/frontend"
|
||||
|
||||
install -m 0755 \
|
||||
"artifacts/nightly-gateway-${platform}-${arch}/aether-gateway" \
|
||||
"${root}/bin/aether-gateway"
|
||||
cp -R artifacts/nightly-frontend-dist/. "${root}/frontend/"
|
||||
sed \
|
||||
-e "s/^SOURCE_REF=\"\${AETHER_SOURCE_REF:-main}\"/SOURCE_REF=\"\${AETHER_SOURCE_REF:-${SOURCE_REF}}\"/" \
|
||||
-e "s/^VERSION=\"\${AETHER_VERSION:-}\"/VERSION=\"\${AETHER_VERSION:-${VERSION}}\"/" \
|
||||
install.sh > "${root}/install.sh"
|
||||
chmod 0755 "${root}/install.sh"
|
||||
install -m 0755 update.sh "${root}/update.sh"
|
||||
install -m 0644 docker-compose.yml "${root}/docker-compose.yml"
|
||||
install -m 0644 docker-compose.single-node.yml "${root}/docker-compose.single-node.yml"
|
||||
install -m 0644 .env.example "${root}/.env.example"
|
||||
install -m 0755 generate_keys.sh "${root}/generate_keys.sh"
|
||||
install -m 0644 README.md "${root}/README.md"
|
||||
install -m 0644 LICENSE "${root}/LICENSE"
|
||||
|
||||
tar -C package -czf "release-assets/${bundle}.tar.gz" "${bundle}"
|
||||
done
|
||||
done
|
||||
|
||||
sed \
|
||||
-e "s/^SOURCE_REF=\"\${AETHER_SOURCE_REF:-main}\"/SOURCE_REF=\"\${AETHER_SOURCE_REF:-${SOURCE_REF}}\"/" \
|
||||
-e "s/^VERSION=\"\${AETHER_VERSION:-}\"/VERSION=\"\${AETHER_VERSION:-${VERSION}}\"/" \
|
||||
install.sh > release-assets/install.sh
|
||||
chmod 0755 release-assets/install.sh
|
||||
(cd release-assets && sha256sum *.tar.gz > SHA256SUMS)
|
||||
|
||||
test "$(find release-assets -maxdepth 1 -name '*.tar.gz' | wc -l)" -eq 4
|
||||
test "$(wc -l < release-assets/SHA256SUMS)" -eq 4
|
||||
(cd release-assets && sha256sum -c SHA256SUMS)
|
||||
for archive in release-assets/*.tar.gz; do
|
||||
tar -tzf "${archive}" >/dev/null
|
||||
done
|
||||
|
||||
- name: Upload nightly package artifact
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5
|
||||
with:
|
||||
name: nightly-release-assets
|
||||
path: release-assets/*
|
||||
if-no-files-found: error
|
||||
overwrite: true
|
||||
retention-days: 7
|
||||
|
||||
github_release:
|
||||
name: Publish nightly GitHub Release
|
||||
needs: [source, checks, docker, package]
|
||||
if: ${{ needs.checks.result == 'success' && needs.docker.result == 'success' && needs.package.result == 'success' }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: read
|
||||
contents: write
|
||||
steps:
|
||||
- name: Download nightly package artifact
|
||||
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5
|
||||
with:
|
||||
name: nightly-release-assets
|
||||
path: release-assets
|
||||
|
||||
- name: Update rolling nightly release
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
REPOSITORY: ${{ github.repository }}
|
||||
RELEASE_TAG: nightly
|
||||
SOURCE_SHA: ${{ needs.source.outputs.sha }}
|
||||
SOURCE_SHORT_SHA: ${{ needs.source.outputs.short_sha }}
|
||||
RELEASE_DATE: ${{ needs.source.outputs.date }}
|
||||
GHCR_IMAGE: ${{ needs.source.outputs.ghcr_image }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
release_title="Aether Nightly ${RELEASE_DATE} (${SOURCE_SHORT_SHA})"
|
||||
notes_file="${RUNNER_TEMP}/nightly-release-notes.md"
|
||||
cat > "${notes_file}" <<EOF
|
||||
## Aether nightly
|
||||
|
||||
This rolling prerelease was built from [main commit ${SOURCE_SHORT_SHA}](https://github.com/${REPOSITORY}/commit/${SOURCE_SHA}).
|
||||
|
||||
- Source branch: main
|
||||
- Source commit: ${SOURCE_SHA}
|
||||
- Build date (UTC): ${RELEASE_DATE}
|
||||
- Container image: ${GHCR_IMAGE}:nightly
|
||||
- Commit image: ${GHCR_IMAGE}:nightly-${SOURCE_SHA}
|
||||
|
||||
The nightly tag and assets are replaced by the next successful daily build.
|
||||
EOF
|
||||
|
||||
# Create a draft on the first run. Later runs repair the same rolling
|
||||
# release on retry if any upload or metadata update is interrupted.
|
||||
if ! gh release view "${RELEASE_TAG}" --repo "${REPOSITORY}" >/dev/null 2>&1; then
|
||||
gh release create "${RELEASE_TAG}" \
|
||||
--repo "${REPOSITORY}" \
|
||||
--draft \
|
||||
--prerelease \
|
||||
--latest=false \
|
||||
--target "${SOURCE_SHA}" \
|
||||
--title "${release_title}" \
|
||||
--notes-file "${notes_file}"
|
||||
fi
|
||||
|
||||
# Upload archives first, then the checksum/installer metadata. This
|
||||
# keeps a failed upload from leaving a checksum that describes files
|
||||
# which have not reached the Release yet.
|
||||
gh release upload "${RELEASE_TAG}" release-assets/*.tar.gz \
|
||||
--repo "${REPOSITORY}" \
|
||||
--clobber
|
||||
gh release upload "${RELEASE_TAG}" \
|
||||
release-assets/SHA256SUMS \
|
||||
release-assets/install.sh \
|
||||
--repo "${REPOSITORY}" \
|
||||
--clobber
|
||||
|
||||
# target_commitish does not move an existing git tag. Move the ref
|
||||
# only after the complete asset set is available.
|
||||
if gh api "repos/${REPOSITORY}/git/ref/tags/${RELEASE_TAG}" >/dev/null 2>&1; then
|
||||
gh api -X PATCH "repos/${REPOSITORY}/git/refs/tags/${RELEASE_TAG}" \
|
||||
-f "sha=${SOURCE_SHA}" \
|
||||
-F 'force=true' >/dev/null
|
||||
else
|
||||
gh api -X POST "repos/${REPOSITORY}/git/refs" \
|
||||
-f "ref=refs/tags/${RELEASE_TAG}" \
|
||||
-f "sha=${SOURCE_SHA}" >/dev/null
|
||||
fi
|
||||
|
||||
gh release edit "${RELEASE_TAG}" \
|
||||
--repo "${REPOSITORY}" \
|
||||
--draft=false \
|
||||
--prerelease \
|
||||
--latest=false \
|
||||
--target "${SOURCE_SHA}" \
|
||||
--title "${release_title}" \
|
||||
--notes-file "${notes_file}"
|
||||
|
||||
expected_assets=(
|
||||
aether-nightly-linux-amd64.tar.gz
|
||||
aether-nightly-linux-arm64.tar.gz
|
||||
aether-nightly-macos-amd64.tar.gz
|
||||
aether-nightly-macos-arm64.tar.gz
|
||||
SHA256SUMS
|
||||
install.sh
|
||||
)
|
||||
asset_names="$(gh release view "${RELEASE_TAG}" --repo "${REPOSITORY}" --json assets --jq '.assets[].name')"
|
||||
for expected_asset in "${expected_assets[@]}"; do
|
||||
if ! grep -Fxq "${expected_asset}" <<<"${asset_names}"; then
|
||||
echo "Published release is missing asset ${expected_asset}." >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
resolved_sha=""
|
||||
for attempt in {1..10}; do
|
||||
resolved_sha="$(gh api "repos/${REPOSITORY}/commits/${RELEASE_TAG}" --jq '.sha' 2>/dev/null || true)"
|
||||
if [[ "${resolved_sha}" == "${SOURCE_SHA}" ]]; then
|
||||
break
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
if [[ "${resolved_sha}" != "${SOURCE_SHA}" ]]; then
|
||||
echo "nightly tag resolved to ${resolved_sha}, expected ${SOURCE_SHA}." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
release_state="$(gh release view "${RELEASE_TAG}" --repo "${REPOSITORY}" --json isDraft,isPrerelease --jq '[.isDraft, .isPrerelease] | @tsv')"
|
||||
if [[ "${release_state}" != $'false\ttrue' ]]; then
|
||||
echo "nightly release has unexpected state: ${release_state}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Published ${RELEASE_TAG} for ${SOURCE_SHA}."
|
||||
|
||||
summary:
|
||||
name: Nightly summary
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ always() }}
|
||||
needs:
|
||||
- source
|
||||
- rust_ci
|
||||
- rust_extended
|
||||
- frontend
|
||||
- repository_health
|
||||
- checks
|
||||
- build
|
||||
- docker
|
||||
- package
|
||||
- github_release
|
||||
steps:
|
||||
- name: Verify nightly pipeline
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
failed=0
|
||||
for entry in \
|
||||
"source=${{ needs.source.result }}" \
|
||||
"rust_ci=${{ needs.rust_ci.result }}" \
|
||||
"rust_extended=${{ needs.rust_extended.result }}" \
|
||||
"frontend=${{ needs.frontend.result }}" \
|
||||
"repository_health=${{ needs.repository_health.result }}" \
|
||||
"checks=${{ needs.checks.result }}" \
|
||||
"build=${{ needs.build.result }}" \
|
||||
"docker=${{ needs.docker.result }}" \
|
||||
"package=${{ needs.package.result }}" \
|
||||
"github_release=${{ needs.github_release.result }}"; do
|
||||
echo "${entry}"
|
||||
if [[ "${entry#*=}" != "success" ]]; then
|
||||
failed=1
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ "${failed}" -ne 0 ]]; then
|
||||
echo 'Nightly pipeline did not publish a new release.' >&2
|
||||
exit 1
|
||||
fi
|
||||
+153
-25
@@ -6,8 +6,8 @@ on:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
actions: read
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: release-aether-${{ github.ref }}
|
||||
@@ -70,14 +70,22 @@ jobs:
|
||||
needs: preflight
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
cache-dependency-path: |
|
||||
frontend/package-lock.json
|
||||
aether-vscodex/web/package-lock.json
|
||||
|
||||
- name: Build aether-vscodex web
|
||||
working-directory: aether-vscodex/web
|
||||
run: |
|
||||
npm ci
|
||||
npm run build
|
||||
|
||||
- name: Install & build
|
||||
working-directory: frontend
|
||||
@@ -86,13 +94,78 @@ jobs:
|
||||
npm run build
|
||||
|
||||
- name: Upload frontend artifact
|
||||
uses: actions/upload-artifact@v5
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5
|
||||
with:
|
||||
name: frontend-dist
|
||||
path: frontend/dist/
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
vscodex:
|
||||
name: Build VS Code Codex extension
|
||||
needs: preflight
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
cache-dependency-path: |
|
||||
aether-vscodex/package-lock.json
|
||||
aether-vscodex/web/package-lock.json
|
||||
aether-vscodex/vscode-extension/package-lock.json
|
||||
|
||||
- name: Install module test dependencies
|
||||
working-directory: aether-vscodex
|
||||
run: npm ci
|
||||
|
||||
- name: Build the embedded Web UI
|
||||
working-directory: aether-vscodex/web
|
||||
run: |
|
||||
npm ci
|
||||
npm run build
|
||||
|
||||
- name: Install extension dependencies
|
||||
working-directory: aether-vscodex/vscode-extension
|
||||
run: npm ci
|
||||
|
||||
- name: Check and compile the extension
|
||||
working-directory: aether-vscodex/vscode-extension
|
||||
run: |
|
||||
npm run check
|
||||
npm run build
|
||||
|
||||
- name: Run module tests
|
||||
working-directory: aether-vscodex
|
||||
run: npm test
|
||||
|
||||
- name: Run Web UI tests
|
||||
working-directory: aether-vscodex/web
|
||||
run: npm test
|
||||
|
||||
- name: Package VSIX
|
||||
working-directory: aether-vscodex/vscode-extension
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
version="$(node -p "require('./package.json').version")"
|
||||
npx --yes @vscode/vsce package --no-update-package-json --allow-missing-repository
|
||||
source_vsix="codex-remote-collab-${version}.vsix"
|
||||
test -f "${source_vsix}"
|
||||
mv "${source_vsix}" "aether-vscodex-${version}.vsix"
|
||||
unzip -l "aether-vscodex-${version}.vsix" | grep 'extension/node_modules/ws/index.js' >/dev/null
|
||||
|
||||
- name: Upload VSIX artifact
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5
|
||||
with:
|
||||
name: aether-vscodex-vsix
|
||||
path: aether-vscodex/vscode-extension/aether-vscodex-*.vsix
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
|
||||
build:
|
||||
name: Build ${{ matrix.name }}
|
||||
needs: preflight
|
||||
@@ -126,22 +199,22 @@ jobs:
|
||||
os: macos-15
|
||||
use_cross: false
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
|
||||
- name: Rust cache
|
||||
uses: Swatinem/rust-cache@v2
|
||||
uses: Swatinem/rust-cache@49a0bdc70d2e1b713ca9e2869b211fcce03d3c1c # v2
|
||||
with:
|
||||
shared-key: release-${{ matrix.target }}
|
||||
workspaces: . -> target
|
||||
|
||||
- name: Install cross
|
||||
if: matrix.use_cross
|
||||
uses: taiki-e/install-action@cross
|
||||
uses: taiki-e/install-action@1ae7257be536a92d9218a6b343dc6e6ba650f7e1 # cross
|
||||
|
||||
- name: Build
|
||||
env:
|
||||
@@ -157,7 +230,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Upload binary artifact
|
||||
uses: actions/upload-artifact@v5
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5
|
||||
with:
|
||||
name: aether-gateway-${{ matrix.platform }}-${{ matrix.arch }}
|
||||
path: target/${{ matrix.target }}/release/aether-gateway
|
||||
@@ -169,11 +242,17 @@ jobs:
|
||||
needs: [preflight, frontend, build]
|
||||
if: needs.preflight.outputs.publish == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: read
|
||||
attestations: write
|
||||
contents: read
|
||||
id-token: write
|
||||
packages: write
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
|
||||
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@v5
|
||||
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5
|
||||
with:
|
||||
path: artifacts
|
||||
|
||||
@@ -186,27 +265,27 @@ jobs:
|
||||
cp -r artifacts/frontend-dist dist/frontend
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
|
||||
|
||||
- name: Log in to GHCR
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5
|
||||
with:
|
||||
images: |
|
||||
${{ env.REGISTRY }}/${{ env.GHCR_IMAGE }}
|
||||
@@ -222,7 +301,8 @@ jobs:
|
||||
latest=false
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
id: push
|
||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile.app
|
||||
@@ -231,15 +311,36 @@ jobs:
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
platforms: linux/amd64,linux/arm64
|
||||
|
||||
- name: Attest GHCR image provenance
|
||||
uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2
|
||||
with:
|
||||
subject-name: ${{ env.REGISTRY }}/${{ env.GHCR_IMAGE }}
|
||||
subject-digest: ${{ steps.push.outputs.digest }}
|
||||
push-to-registry: true
|
||||
create-storage-record: false
|
||||
|
||||
- name: Attest Docker Hub image provenance
|
||||
uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2
|
||||
with:
|
||||
subject-name: docker.io/${{ env.DOCKERHUB_IMAGE }}
|
||||
subject-digest: ${{ steps.push.outputs.digest }}
|
||||
push-to-registry: true
|
||||
create-storage-record: false
|
||||
|
||||
package:
|
||||
name: Release tarballs
|
||||
needs: [preflight, frontend, build]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: read
|
||||
attestations: write
|
||||
contents: read
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
|
||||
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@v5
|
||||
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5
|
||||
with:
|
||||
path: artifacts
|
||||
|
||||
@@ -289,8 +390,24 @@ jobs:
|
||||
chmod +x release-assets/install.sh
|
||||
(cd release-assets && sha256sum *.tar.gz > SHA256SUMS)
|
||||
|
||||
- name: Attest release package provenance
|
||||
id: attest-release
|
||||
if: needs.preflight.outputs.publish == 'true'
|
||||
uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2
|
||||
with:
|
||||
subject-path: |
|
||||
release-assets/*.tar.gz
|
||||
release-assets/install.sh
|
||||
release-assets/SHA256SUMS
|
||||
|
||||
- name: Bundle release package provenance
|
||||
if: needs.preflight.outputs.publish == 'true'
|
||||
env:
|
||||
ATTESTATION_BUNDLE: ${{ steps.attest-release.outputs.bundle-path }}
|
||||
run: install -m 0644 "${ATTESTATION_BUNDLE}" release-assets/AETHER_RELEASE_PROVENANCE.sigstore.json
|
||||
|
||||
- name: Upload release package artifact
|
||||
uses: actions/upload-artifact@v5
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5
|
||||
with:
|
||||
name: release-assets
|
||||
path: release-assets/*
|
||||
@@ -299,16 +416,25 @@ jobs:
|
||||
|
||||
github-release:
|
||||
name: GitHub Release assets
|
||||
needs: [preflight, docker, package]
|
||||
needs: [preflight, docker, package, vscodex]
|
||||
if: needs.preflight.outputs.publish == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: read
|
||||
contents: write
|
||||
steps:
|
||||
- name: Download release package artifact
|
||||
uses: actions/download-artifact@v5
|
||||
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5
|
||||
with:
|
||||
name: release-assets
|
||||
path: release-assets
|
||||
|
||||
- name: Download VSIX artifact
|
||||
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5
|
||||
with:
|
||||
name: aether-vscodex-vsix
|
||||
path: release-assets
|
||||
|
||||
- name: Delete stale draft releases for tag
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
@@ -331,12 +457,14 @@ jobs:
|
||||
done <<< "${draft_ids}"
|
||||
|
||||
- name: Publish GitHub Release assets
|
||||
uses: softprops/action-gh-release@v2
|
||||
uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2
|
||||
with:
|
||||
generate_release_notes: true
|
||||
prerelease: ${{ needs.preflight.outputs.prerelease }}
|
||||
make_latest: ${{ needs.preflight.outputs.make_latest }}
|
||||
files: |
|
||||
release-assets/*.tar.gz
|
||||
release-assets/AETHER_RELEASE_PROVENANCE.sigstore.json
|
||||
release-assets/SHA256SUMS
|
||||
release-assets/install.sh
|
||||
release-assets/*.vsix
|
||||
|
||||
+133
-202
@@ -1,6 +1,7 @@
|
||||
name: Rust CI
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
@@ -10,19 +11,64 @@ on:
|
||||
- "Cargo.lock"
|
||||
- "crates/**"
|
||||
- "apps/**"
|
||||
- "install.sh"
|
||||
- "deploy.sh"
|
||||
- "update.sh"
|
||||
- "generate_keys.sh"
|
||||
- ".env.example"
|
||||
- "README.md"
|
||||
- "Dockerfile.app"
|
||||
- "docker-compose.yml"
|
||||
- "docker-compose.single-node.yml"
|
||||
- "docker-compose.local.yml"
|
||||
- "docker-compose.release-local.yml"
|
||||
- "tests/compose_database_config_test.py"
|
||||
- "tests/install_*_test.sh"
|
||||
- "tests/deploy_*_test.sh"
|
||||
- "tests/update_*_test.sh"
|
||||
- "tests/release_supply_chain_test.sh"
|
||||
- "tests/tunnel_installer_config_security_test.sh"
|
||||
- ".github/workflows/build-tunnel.yml"
|
||||
- ".github/workflows/deploy-pages.yml"
|
||||
- ".github/workflows/release.yml"
|
||||
- ".github/workflows/rust-ci.yml"
|
||||
- ".github/workflows/nightly.yml"
|
||||
pull_request:
|
||||
paths:
|
||||
- "Cargo.toml"
|
||||
- "Cargo.lock"
|
||||
- "crates/**"
|
||||
- "apps/**"
|
||||
- "install.sh"
|
||||
- "deploy.sh"
|
||||
- "update.sh"
|
||||
- "generate_keys.sh"
|
||||
- ".env.example"
|
||||
- "README.md"
|
||||
- "Dockerfile.app"
|
||||
- "docker-compose.yml"
|
||||
- "docker-compose.single-node.yml"
|
||||
- "docker-compose.local.yml"
|
||||
- "docker-compose.release-local.yml"
|
||||
- "tests/compose_database_config_test.py"
|
||||
- "tests/install_*_test.sh"
|
||||
- "tests/deploy_*_test.sh"
|
||||
- "tests/update_*_test.sh"
|
||||
- "tests/release_supply_chain_test.sh"
|
||||
- "tests/tunnel_installer_config_security_test.sh"
|
||||
- ".github/workflows/build-tunnel.yml"
|
||||
- ".github/workflows/deploy-pages.yml"
|
||||
- ".github/workflows/release.yml"
|
||||
- ".github/workflows/rust-ci.yml"
|
||||
- ".github/workflows/nightly.yml"
|
||||
|
||||
concurrency:
|
||||
group: rust-ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
group: rust-ci-${{ github.event_name }}-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
CARGO_INCREMENTAL: 0
|
||||
CARGO_PROFILE_DEV_DEBUG: 0
|
||||
@@ -30,14 +76,35 @@ env:
|
||||
CARGO_TERM_COLOR: always
|
||||
|
||||
jobs:
|
||||
shell_security:
|
||||
name: Shell security fixtures
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
|
||||
|
||||
- name: Run installer and supply-chain fixtures
|
||||
shell: bash
|
||||
run: |
|
||||
python3 tests/compose_database_config_test.py
|
||||
bash tests/deploy_state_safety_test.sh
|
||||
bash tests/install_archive_safety_test.sh
|
||||
bash tests/install_container_runtime_security_test.sh
|
||||
bash tests/install_current_release_link_test.sh
|
||||
bash tests/install_local_bundle_safety_test.sh
|
||||
bash tests/install_privileged_write_safety_test.sh
|
||||
bash tests/install_source_trust_test.sh
|
||||
bash tests/release_supply_chain_test.sh
|
||||
bash tests/update_compose_safety_test.sh
|
||||
bash tests/tunnel_installer_config_security_test.sh
|
||||
|
||||
fmt:
|
||||
name: Format
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable
|
||||
with:
|
||||
toolchain: 1.95.0
|
||||
components: rustfmt
|
||||
@@ -49,22 +116,22 @@ jobs:
|
||||
name: Clippy (Gateway)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable
|
||||
with:
|
||||
toolchain: 1.95.0
|
||||
components: clippy
|
||||
|
||||
- name: Rust cache
|
||||
uses: Swatinem/rust-cache@v2
|
||||
uses: Swatinem/rust-cache@49a0bdc70d2e1b713ca9e2869b211fcce03d3c1c # v2
|
||||
with:
|
||||
shared-key: rust-ci-${{ runner.os }}
|
||||
workspaces: . -> target
|
||||
|
||||
- name: Setup sccache
|
||||
uses: mozilla-actions/[email protected]
|
||||
uses: mozilla-actions/sccache-action@7d986dd989559c6ecdb630a3fd2557667be217ad # v0.0.9
|
||||
|
||||
- name: Clippy
|
||||
env:
|
||||
@@ -83,22 +150,22 @@ jobs:
|
||||
name: Clippy (Data)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable
|
||||
with:
|
||||
toolchain: 1.95.0
|
||||
components: clippy
|
||||
|
||||
- name: Rust cache
|
||||
uses: Swatinem/rust-cache@v2
|
||||
uses: Swatinem/rust-cache@49a0bdc70d2e1b713ca9e2869b211fcce03d3c1c # v2
|
||||
with:
|
||||
shared-key: rust-ci-${{ runner.os }}
|
||||
workspaces: . -> target
|
||||
|
||||
- name: Setup sccache
|
||||
uses: mozilla-actions/[email protected]
|
||||
uses: mozilla-actions/sccache-action@7d986dd989559c6ecdb630a3fd2557667be217ad # v0.0.9
|
||||
|
||||
- name: Clippy
|
||||
env:
|
||||
@@ -117,22 +184,22 @@ jobs:
|
||||
name: Clippy (Workspace Rest)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable
|
||||
with:
|
||||
toolchain: 1.95.0
|
||||
components: clippy
|
||||
|
||||
- name: Rust cache
|
||||
uses: Swatinem/rust-cache@v2
|
||||
uses: Swatinem/rust-cache@49a0bdc70d2e1b713ca9e2869b211fcce03d3c1c # v2
|
||||
with:
|
||||
shared-key: rust-ci-${{ runner.os }}
|
||||
workspaces: . -> target
|
||||
|
||||
- name: Setup sccache
|
||||
uses: mozilla-actions/[email protected]
|
||||
uses: mozilla-actions/sccache-action@7d986dd989559c6ecdb630a3fd2557667be217ad # v0.0.9
|
||||
|
||||
- name: Clippy
|
||||
env:
|
||||
@@ -169,28 +236,31 @@ jobs:
|
||||
name: Test (Gateway)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable
|
||||
|
||||
- name: Show Rust toolchain
|
||||
run: rustup show active-toolchain
|
||||
|
||||
- name: Rust cache
|
||||
uses: Swatinem/rust-cache@v2
|
||||
uses: Swatinem/rust-cache@49a0bdc70d2e1b713ca9e2869b211fcce03d3c1c # v2
|
||||
with:
|
||||
shared-key: rust-ci-${{ runner.os }}
|
||||
workspaces: . -> target
|
||||
|
||||
- name: Setup sccache
|
||||
uses: mozilla-actions/[email protected]
|
||||
uses: mozilla-actions/sccache-action@7d986dd989559c6ecdb630a3fd2557667be217ad # v0.0.9
|
||||
|
||||
- name: Setup mold
|
||||
uses: rui314/setup-mold@v1
|
||||
uses: rui314/setup-mold@7e4f20ad28a2e8ca6fd0892ccf72e2abb706b9c3 # v1
|
||||
|
||||
- name: Install nextest
|
||||
uses: taiki-e/install-action@nextest
|
||||
uses: taiki-e/install-action@d5f9268ff7620505a81ada10ddf18cdd72240185 # nextest
|
||||
|
||||
- name: Expose PostgreSQL test binaries
|
||||
run: pg_config --bindir >> "$GITHUB_PATH"
|
||||
|
||||
- name: Test lib
|
||||
env:
|
||||
@@ -200,13 +270,13 @@ jobs:
|
||||
RUSTFLAGS: "-C link-arg=-fuse-ld=mold"
|
||||
run: cargo nextest run -p aether-gateway --lib
|
||||
|
||||
- name: Test bin
|
||||
- name: Test bins
|
||||
env:
|
||||
RUSTC_WRAPPER: sccache
|
||||
SCCACHE_GHA_ENABLED: "true"
|
||||
RUST_MIN_STACK: "16777216"
|
||||
RUSTFLAGS: "-C link-arg=-fuse-ld=mold"
|
||||
run: cargo nextest run -p aether-gateway --bin aether-gateway
|
||||
run: cargo nextest run -p aether-gateway --bins
|
||||
|
||||
- name: Show sccache stats
|
||||
if: always()
|
||||
@@ -219,30 +289,34 @@ jobs:
|
||||
name: Test (Data)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable
|
||||
|
||||
- name: Show Rust toolchain
|
||||
run: rustup show active-toolchain
|
||||
|
||||
- name: Rust cache
|
||||
uses: Swatinem/rust-cache@v2
|
||||
uses: Swatinem/rust-cache@49a0bdc70d2e1b713ca9e2869b211fcce03d3c1c # v2
|
||||
with:
|
||||
shared-key: rust-ci-${{ runner.os }}
|
||||
workspaces: . -> target
|
||||
|
||||
- name: Setup sccache
|
||||
uses: mozilla-actions/[email protected]
|
||||
uses: mozilla-actions/sccache-action@7d986dd989559c6ecdb630a3fd2557667be217ad # v0.0.9
|
||||
|
||||
- name: Install nextest
|
||||
uses: taiki-e/install-action@nextest
|
||||
uses: taiki-e/install-action@d5f9268ff7620505a81ada10ddf18cdd72240185 # nextest
|
||||
|
||||
- name: Expose PostgreSQL test binaries
|
||||
run: pg_config --bindir >> "$GITHUB_PATH"
|
||||
|
||||
- name: Test
|
||||
env:
|
||||
RUSTC_WRAPPER: sccache
|
||||
SCCACHE_GHA_ENABLED: "true"
|
||||
AETHER_REQUIRE_LOCAL_POSTGRES_TESTS: "true"
|
||||
run: cargo nextest run -p aether-data
|
||||
|
||||
- name: Show sccache stats
|
||||
@@ -260,23 +334,21 @@ jobs:
|
||||
matrix:
|
||||
feature:
|
||||
- postgres
|
||||
- mysql
|
||||
- sqlite
|
||||
- all-drivers
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable
|
||||
|
||||
- name: Rust cache
|
||||
uses: Swatinem/rust-cache@v2
|
||||
uses: Swatinem/rust-cache@49a0bdc70d2e1b713ca9e2869b211fcce03d3c1c # v2
|
||||
with:
|
||||
shared-key: rust-ci-${{ runner.os }}
|
||||
workspaces: . -> target
|
||||
|
||||
- name: Setup sccache
|
||||
uses: mozilla-actions/[email protected]
|
||||
uses: mozilla-actions/sccache-action@7d986dd989559c6ecdb630a3fd2557667be217ad # v0.0.9
|
||||
|
||||
- name: Check selected data driver
|
||||
env:
|
||||
@@ -295,25 +367,25 @@ jobs:
|
||||
name: Test (Workspace Rest)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable
|
||||
|
||||
- name: Show Rust toolchain
|
||||
run: rustup show active-toolchain
|
||||
|
||||
- name: Rust cache
|
||||
uses: Swatinem/rust-cache@v2
|
||||
uses: Swatinem/rust-cache@49a0bdc70d2e1b713ca9e2869b211fcce03d3c1c # v2
|
||||
with:
|
||||
shared-key: rust-ci-${{ runner.os }}
|
||||
workspaces: . -> target
|
||||
|
||||
- name: Setup sccache
|
||||
uses: mozilla-actions/[email protected]
|
||||
uses: mozilla-actions/sccache-action@7d986dd989559c6ecdb630a3fd2557667be217ad # v0.0.9
|
||||
|
||||
- name: Install nextest
|
||||
uses: taiki-e/install-action@nextest
|
||||
uses: taiki-e/install-action@d5f9268ff7620505a81ada10ddf18cdd72240185 # nextest
|
||||
|
||||
- name: Test
|
||||
env:
|
||||
@@ -336,25 +408,23 @@ jobs:
|
||||
matrix:
|
||||
package:
|
||||
- aether-data-postgres
|
||||
- aether-data-mysql
|
||||
- aether-data-sqlite
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable
|
||||
|
||||
- name: Rust cache
|
||||
uses: Swatinem/rust-cache@v2
|
||||
uses: Swatinem/rust-cache@49a0bdc70d2e1b713ca9e2869b211fcce03d3c1c # v2
|
||||
with:
|
||||
shared-key: rust-ci-${{ runner.os }}
|
||||
workspaces: . -> target
|
||||
|
||||
- name: Setup sccache
|
||||
uses: mozilla-actions/[email protected]
|
||||
uses: mozilla-actions/sccache-action@7d986dd989559c6ecdb630a3fd2557667be217ad # v0.0.9
|
||||
|
||||
- name: Install nextest
|
||||
uses: taiki-e/install-action@nextest
|
||||
uses: taiki-e/install-action@d5f9268ff7620505a81ada10ddf18cdd72240185 # nextest
|
||||
|
||||
- name: Test adapter
|
||||
env:
|
||||
@@ -373,25 +443,28 @@ jobs:
|
||||
name: Test (Integration Scenarios)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable
|
||||
|
||||
- name: Rust cache
|
||||
uses: Swatinem/rust-cache@v2
|
||||
uses: Swatinem/rust-cache@49a0bdc70d2e1b713ca9e2869b211fcce03d3c1c # v2
|
||||
with:
|
||||
shared-key: rust-ci-${{ runner.os }}
|
||||
workspaces: . -> target
|
||||
|
||||
- name: Setup sccache
|
||||
uses: mozilla-actions/[email protected]
|
||||
uses: mozilla-actions/sccache-action@7d986dd989559c6ecdb630a3fd2557667be217ad # v0.0.9
|
||||
|
||||
- name: Test scenario binaries
|
||||
- name: Expose PostgreSQL test binaries
|
||||
run: pg_config --bindir >> "$GITHUB_PATH"
|
||||
|
||||
- name: Test scenario binaries and end-to-end suites
|
||||
env:
|
||||
RUSTC_WRAPPER: sccache
|
||||
SCCACHE_GHA_ENABLED: "true"
|
||||
run: cargo test -p aether-integration-tests --bins
|
||||
run: cargo test -p aether-integration-tests --bins --tests
|
||||
|
||||
- name: Show sccache stats
|
||||
if: always()
|
||||
@@ -424,40 +497,6 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
data_db_smoke_sqlite:
|
||||
name: Data DB Smoke (SQLite)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Show Rust toolchain
|
||||
run: rustup show active-toolchain
|
||||
|
||||
- name: Rust cache
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
shared-key: rust-ci-${{ runner.os }}
|
||||
workspaces: . -> target
|
||||
|
||||
- name: Setup sccache
|
||||
uses: mozilla-actions/[email protected]
|
||||
|
||||
- name: Run SQLite data smoke tests
|
||||
env:
|
||||
RUSTC_WRAPPER: sccache
|
||||
SCCACHE_GHA_ENABLED: "true"
|
||||
run: cargo test -p aether-data --all-features sqlite --lib
|
||||
|
||||
- name: Show sccache stats
|
||||
if: always()
|
||||
env:
|
||||
RUSTC_WRAPPER: sccache
|
||||
SCCACHE_GHA_ENABLED: "true"
|
||||
run: sccache --show-stats
|
||||
|
||||
data_db_smoke_postgres:
|
||||
name: Data DB Smoke (Postgres)
|
||||
runs-on: ubuntu-latest
|
||||
@@ -476,22 +515,22 @@ jobs:
|
||||
--health-timeout=5s
|
||||
--health-retries=20
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable
|
||||
|
||||
- name: Show Rust toolchain
|
||||
run: rustup show active-toolchain
|
||||
|
||||
- name: Rust cache
|
||||
uses: Swatinem/rust-cache@v2
|
||||
uses: Swatinem/rust-cache@49a0bdc70d2e1b713ca9e2869b211fcce03d3c1c # v2
|
||||
with:
|
||||
shared-key: rust-ci-${{ runner.os }}
|
||||
workspaces: . -> target
|
||||
|
||||
- name: Setup sccache
|
||||
uses: mozilla-actions/[email protected]
|
||||
uses: mozilla-actions/sccache-action@7d986dd989559c6ecdb630a3fd2557667be217ad # v0.0.9
|
||||
|
||||
- name: Add PostgreSQL server binaries to PATH
|
||||
run: echo "$(pg_config --bindir)" >> "$GITHUB_PATH"
|
||||
@@ -529,112 +568,6 @@ jobs:
|
||||
AETHER_TEST_POSTGRES_URL: postgres://aether:[email protected]:5432/aether_test
|
||||
run: cargo test -p aether-data --all-features postgres_core_export_reads_migrated_database_rows_when_url_is_set --lib -- --nocapture
|
||||
|
||||
- name: Run SQLite-to-Postgres import smoke test
|
||||
env:
|
||||
RUSTC_WRAPPER: sccache
|
||||
SCCACHE_GHA_ENABLED: "true"
|
||||
AETHER_TEST_POSTGRES_URL: postgres://aether:[email protected]:5432/aether_test
|
||||
run: cargo test -p aether-data --all-features sqlite_core_export_reads_migrated_database_rows --lib -- --nocapture
|
||||
|
||||
- name: Show sccache stats
|
||||
if: always()
|
||||
env:
|
||||
RUSTC_WRAPPER: sccache
|
||||
SCCACHE_GHA_ENABLED: "true"
|
||||
run: sccache --show-stats
|
||||
|
||||
data_db_smoke_mysql:
|
||||
name: Data DB Smoke (MySQL)
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
mysql:
|
||||
image: mysql:8.0
|
||||
env:
|
||||
MYSQL_DATABASE: aether_test
|
||||
MYSQL_USER: aether
|
||||
MYSQL_PASSWORD: aether
|
||||
MYSQL_ROOT_PASSWORD: aether_root
|
||||
ports:
|
||||
- 3306:3306
|
||||
options: >-
|
||||
--health-cmd="mysqladmin ping -h 127.0.0.1 -uaether -paether --silent"
|
||||
--health-interval=5s
|
||||
--health-timeout=5s
|
||||
--health-retries=20
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Show Rust toolchain
|
||||
run: rustup show active-toolchain
|
||||
|
||||
- name: Rust cache
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
shared-key: rust-ci-${{ runner.os }}
|
||||
workspaces: . -> target
|
||||
|
||||
- name: Setup sccache
|
||||
uses: mozilla-actions/[email protected]
|
||||
|
||||
- name: Run MySQL migration smoke test
|
||||
env:
|
||||
RUSTC_WRAPPER: sccache
|
||||
SCCACHE_GHA_ENABLED: "true"
|
||||
AETHER_TEST_MYSQL_URL: mysql://aether:[email protected]:3306/aether_test
|
||||
run: cargo test -p aether-data --all-features mysql_migrations_create_core_config_tables_when_url_is_set --lib -- --nocapture
|
||||
|
||||
- name: Run MySQL usage write smoke test
|
||||
env:
|
||||
RUSTC_WRAPPER: sccache
|
||||
SCCACHE_GHA_ENABLED: "true"
|
||||
AETHER_TEST_MYSQL_URL: mysql://aether:[email protected]:3306/aether_test
|
||||
run: cargo test -p aether-data-mysql mysql_usage_write_repository_upserts_when_url_is_set --lib -- --nocapture
|
||||
|
||||
- name: Run MySQL usage read smoke test
|
||||
env:
|
||||
RUSTC_WRAPPER: sccache
|
||||
SCCACHE_GHA_ENABLED: "true"
|
||||
AETHER_TEST_MYSQL_URL: mysql://aether:[email protected]:3306/aether_test
|
||||
run: cargo test -p aether-data-mysql mysql_usage_read_repository_reads_usage_contract_views_when_url_is_set --lib -- --nocapture
|
||||
|
||||
- name: Run MySQL provider catalog smoke test
|
||||
env:
|
||||
RUSTC_WRAPPER: sccache
|
||||
SCCACHE_GHA_ENABLED: "true"
|
||||
AETHER_TEST_MYSQL_URL: mysql://aether:[email protected]:3306/aether_test
|
||||
run: cargo test -p aether-data-mysql mysql_provider_catalog_repository_round_trips_when_url_is_set --lib -- --nocapture
|
||||
|
||||
- name: Run MySQL core export smoke test
|
||||
env:
|
||||
RUSTC_WRAPPER: sccache
|
||||
SCCACHE_GHA_ENABLED: "true"
|
||||
AETHER_TEST_MYSQL_URL: mysql://aether:[email protected]:3306/aether_test
|
||||
run: cargo test -p aether-data --all-features mysql_core_export_reads_migrated_database_rows_when_url_is_set --lib -- --nocapture
|
||||
|
||||
- name: Run MySQL wallet read smoke test
|
||||
env:
|
||||
RUSTC_WRAPPER: sccache
|
||||
SCCACHE_GHA_ENABLED: "true"
|
||||
AETHER_TEST_MYSQL_URL: mysql://aether:[email protected]:3306/aether_test
|
||||
run: cargo test -p aether-data-mysql mysql_wallet_read_repository_reads_wallet_contract_views --lib -- --nocapture
|
||||
|
||||
- name: Run MySQL wallet daily usage aggregation smoke test
|
||||
env:
|
||||
RUSTC_WRAPPER: sccache
|
||||
SCCACHE_GHA_ENABLED: "true"
|
||||
AETHER_TEST_MYSQL_URL: mysql://aether:[email protected]:3306/aether_test
|
||||
run: cargo test -p aether-data --all-features mysql_wallet_daily_usage_aggregation_uses_settlement_wallets_when_url_is_set --lib -- --nocapture
|
||||
|
||||
- name: Run MySQL stats aggregation smoke test
|
||||
env:
|
||||
RUSTC_WRAPPER: sccache
|
||||
SCCACHE_GHA_ENABLED: "true"
|
||||
AETHER_TEST_MYSQL_URL: mysql://aether:[email protected]:3306/aether_test
|
||||
run: cargo test -p aether-data --all-features mysql_stats_aggregation_runs_after_mysql_migrations_when_url_is_set --lib -- --nocapture
|
||||
|
||||
- name: Show sccache stats
|
||||
if: always()
|
||||
env:
|
||||
@@ -646,16 +579,12 @@ jobs:
|
||||
name: Data DB Smoke
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- data_db_smoke_sqlite
|
||||
- data_db_smoke_postgres
|
||||
- data_db_smoke_mysql
|
||||
if: ${{ always() }}
|
||||
steps:
|
||||
- name: Verify database smoke jobs
|
||||
run: |
|
||||
if [ "${{ needs.data_db_smoke_sqlite.result }}" != "success" ] || \
|
||||
[ "${{ needs.data_db_smoke_postgres.result }}" != "success" ] || \
|
||||
[ "${{ needs.data_db_smoke_mysql.result }}" != "success" ]; then
|
||||
if [ "${{ needs.data_db_smoke_postgres.result }}" != "success" ]; then
|
||||
echo "Data DB smoke failed"
|
||||
exit 1
|
||||
fi
|
||||
@@ -668,6 +597,7 @@ jobs:
|
||||
- clippy
|
||||
- test
|
||||
- data_db_smoke
|
||||
- shell_security
|
||||
if: ${{ always() }}
|
||||
steps:
|
||||
- name: Verify required jobs
|
||||
@@ -675,7 +605,8 @@ jobs:
|
||||
if [ "${{ needs.fmt.result }}" != "success" ] || \
|
||||
[ "${{ needs.clippy.result }}" != "success" ] || \
|
||||
[ "${{ needs.test.result }}" != "success" ] || \
|
||||
[ "${{ needs.data_db_smoke.result }}" != "success" ]; then
|
||||
[ "${{ needs.data_db_smoke.result }}" != "success" ] || \
|
||||
[ "${{ needs.shell_security.result }}" != "success" ]; then
|
||||
echo "Rust CI failed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -13,6 +13,10 @@
|
||||
.plans
|
||||
.playwright-mcp/
|
||||
|
||||
docs/architecture
|
||||
!docs/architecture/architecture-dark.svg
|
||||
!docs/architecture/architecture-light.svg
|
||||
|
||||
### Python ###
|
||||
*.db
|
||||
*.db-*
|
||||
@@ -248,3 +252,5 @@ src/_version.py
|
||||
analysis/
|
||||
new-api/
|
||||
apps/aether-tunnel/aether-tunnel.toml
|
||||
# Generated by frontend/scripts/sync-vscodex.mjs.
|
||||
frontend/public/aether-vscodex/
|
||||
|
||||
Generated
+736
-380
File diff suppressed because it is too large
Load Diff
+7
-6
@@ -11,8 +11,6 @@ members = [
|
||||
"crates/aether-routing-core",
|
||||
"crates/aether-data/contracts",
|
||||
"crates/aether-data/adapters/postgres",
|
||||
"crates/aether-data/adapters/mysql",
|
||||
"crates/aether-data/adapters/sqlite",
|
||||
"crates/aether-data/query",
|
||||
"crates/aether-data/schema",
|
||||
"crates/aether-dispatch-core",
|
||||
@@ -66,8 +64,6 @@ aether-provider-pool = { path = "crates/aether-provider/pool" }
|
||||
aether-routing-core = { path = "crates/aether-routing-core" }
|
||||
aether-data-contracts = { path = "crates/aether-data/contracts" }
|
||||
aether-data-postgres = { path = "crates/aether-data/adapters/postgres" }
|
||||
aether-data-mysql = { path = "crates/aether-data/adapters/mysql" }
|
||||
aether-data-sqlite = { path = "crates/aether-data/adapters/sqlite" }
|
||||
aether-data-query = { path = "crates/aether-data/query" }
|
||||
aether-data-schema = { path = "crates/aether-data/schema" }
|
||||
aether-dispatch-core = { path = "crates/aether-dispatch-core" }
|
||||
@@ -101,21 +97,26 @@ aether-runtime = { path = "crates/aether-runtime/base" }
|
||||
aether-testkit = { path = "crates/aether-testing/testkit" }
|
||||
aes = "0.8"
|
||||
aes-gcm = "0.10"
|
||||
aws-lc-rs = { version = "1.16.2", default-features = false, features = ["alloc", "aws-lc-sys"] }
|
||||
async-stream = "0.3"
|
||||
async-trait = "0.1"
|
||||
axum = "0.8"
|
||||
base64 = "0.22"
|
||||
bcrypt = "0.16"
|
||||
brotli = "8"
|
||||
bytes = "1"
|
||||
cbc = "0.1"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
chrono-tz = "0.10"
|
||||
crypto_box = { version = "0.9", features = ["seal"] }
|
||||
ed25519-dalek = { version = "2.2", features = ["pkcs8"] }
|
||||
flate2 = "1"
|
||||
futures-util = "0.3"
|
||||
hmac = "0.12"
|
||||
http = "1"
|
||||
object_store = { version = "0.12", default-features = false, features = ["aws"] }
|
||||
object_store = { version = "0.14.1", default-features = false, features = ["aws"] }
|
||||
pbkdf2 = { version = "0.12", default-features = false, features = ["hmac"] }
|
||||
percent-encoding = "2"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "rustls-tls", "http2", "socks"] }
|
||||
redis = { version = "0.28", default-features = false, features = ["tokio-comp", "script", "streams", "connection-manager"] }
|
||||
regex = "1"
|
||||
@@ -133,7 +134,7 @@ tokio = { version = "1", features = ["macros", "net", "rt-multi-thread", "signal
|
||||
tokio-util = { version = "0.7", features = ["codec", "io-util"] }
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
|
||||
uuid = { version = "1", features = ["serde", "v4", "v5"] }
|
||||
uuid = { version = "1", features = ["serde", "v4", "v5", "v7"] }
|
||||
webpki-roots = "0.26"
|
||||
wreq = { version = "6.0.0-rc.28", default-features = false, features = ["json", "stream", "socks", "webpki-roots", "ws"] }
|
||||
wreq-util = "3.0.0-rc.10"
|
||||
|
||||
+9
-4
@@ -10,20 +10,24 @@
|
||||
|
||||
# --- layout stage: create /opt/aether directory structure with symlink ---
|
||||
# distroless has no shell, so we use busybox to set up the symlink.
|
||||
FROM busybox:1.37-musl AS layout
|
||||
FROM busybox:1.37.0-musl@sha256:fc6dddc4c44b1bfe37f41cae8e67d1693828e8f42a91862816d7953e2c9d3f23 AS layout
|
||||
|
||||
ARG TARGETARCH
|
||||
|
||||
RUN mkdir -p /opt/aether/releases/image/bin /opt/aether/releases/image/frontend /opt/aether/logs
|
||||
|
||||
COPY dist/aether-gateway-${TARGETARCH} /opt/aether/releases/image/bin/aether-gateway
|
||||
RUN chmod 0755 /opt/aether/releases/image/bin/aether-gateway
|
||||
COPY dist/frontend/ /opt/aether/releases/image/frontend/
|
||||
|
||||
# Keep the immutable release root-owned while guaranteeing that the runtime
|
||||
# identity can traverse and read every packaged asset.
|
||||
RUN chmod -R u=rwX,go=rX /opt/aether/releases/image \
|
||||
&& chmod 0755 /opt/aether/releases/image/bin/aether-gateway
|
||||
|
||||
RUN ln -s /opt/aether/releases/image /opt/aether/current
|
||||
|
||||
# --- final stage: distroless runtime ---
|
||||
FROM gcr.io/distroless/static-debian12
|
||||
FROM gcr.io/distroless/static-debian12@sha256:6447365a6337c3732f412d1b74357b30a633831955b2bc45552b0086be907687
|
||||
|
||||
COPY --from=layout /opt/aether /opt/aether
|
||||
|
||||
@@ -31,6 +35,7 @@ WORKDIR /opt/aether
|
||||
|
||||
ENV RUST_LOG=aether_gateway=info \
|
||||
APP_PORT=8084 \
|
||||
HOME=/tmp/aether-home \
|
||||
AETHER_UPDATE_STRATEGY=docker \
|
||||
AETHER_GATEWAY_STATIC_DIR=/opt/aether/current/frontend
|
||||
|
||||
@@ -39,5 +44,5 @@ EXPOSE 8084
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||
CMD ["/opt/aether/current/bin/aether-gateway", "--healthcheck"]
|
||||
|
||||
USER root
|
||||
USER 65532:65532
|
||||
ENTRYPOINT ["/opt/aether/current/bin/aether-gateway"]
|
||||
|
||||
@@ -11,6 +11,15 @@ FROM ${NODE_BASE_IMAGE} AS frontend-builder
|
||||
ARG AETHER_BUILD_VERSION
|
||||
ENV AETHER_BUILD_VERSION=${AETHER_BUILD_VERSION} \
|
||||
AETHER_VERSION=${AETHER_BUILD_VERSION}
|
||||
WORKDIR /app/aether-vscodex/web
|
||||
COPY aether-vscodex/web/package*.json ./
|
||||
RUN --mount=type=cache,id=aether-vscodex-npm-cache,target=/root/.npm,sharing=locked \
|
||||
npm config set registry https://registry.npmmirror.com && \
|
||||
npm ci --no-audit --no-fund
|
||||
COPY aether-vscodex/public /app/aether-vscodex/public
|
||||
COPY aether-vscodex/web/ ./
|
||||
RUN npm run build
|
||||
|
||||
WORKDIR /app/frontend
|
||||
COPY frontend/package*.json ./
|
||||
RUN --mount=type=cache,id=aether-npm-cache,target=/root/.npm,sharing=locked \
|
||||
|
||||
@@ -11,6 +11,15 @@ FROM ${NODE_BASE_IMAGE} AS frontend-builder
|
||||
ARG AETHER_BUILD_VERSION
|
||||
ENV AETHER_BUILD_VERSION=${AETHER_BUILD_VERSION} \
|
||||
AETHER_VERSION=${AETHER_BUILD_VERSION}
|
||||
WORKDIR /app/aether-vscodex/web
|
||||
COPY aether-vscodex/web/package*.json ./
|
||||
RUN --mount=type=cache,id=aether-vscodex-npm-cache,target=/root/.npm,sharing=locked \
|
||||
npm config set registry https://registry.npmmirror.com && \
|
||||
npm ci --no-audit --no-fund
|
||||
COPY aether-vscodex/public /app/aether-vscodex/public
|
||||
COPY aether-vscodex/web/ ./
|
||||
RUN npm run build
|
||||
|
||||
WORKDIR /app/frontend
|
||||
COPY frontend/package*.json ./
|
||||
RUN --mount=type=cache,id=aether-npm-cache,target=/root/.npm,sharing=locked \
|
||||
|
||||
@@ -6,7 +6,7 @@ DEV_RUST_LOG := $(RUST_LOG)
|
||||
endif
|
||||
export DEV_RUST_LOG
|
||||
|
||||
.PHONY: dev dev-backend dev-frontend migration backfill
|
||||
.PHONY: dev dev-backend dev-frontend db-status db-prepare migration backfill
|
||||
|
||||
define DEV_BACKEND_SCRIPT
|
||||
set -euo pipefail
|
||||
@@ -20,6 +20,13 @@ set -a
|
||||
source .env
|
||||
set +a
|
||||
|
||||
if [[ -n "$${ADMIN_EMAIL:-}" || -n "$${ADMIN_USERNAME:-}" || -n "$${ADMIN_PASSWORD:-}" ]]; then
|
||||
if [[ -z "$${ADMIN_USERNAME:-}" || -z "$${ADMIN_PASSWORD:-}" ]]; then
|
||||
echo "=> 管理员自举配置不完整,请在 .env 中设置 ADMIN_USERNAME 和 ADMIN_PASSWORD"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
dotenv_has_key() {
|
||||
local key="$$1"
|
||||
grep -Eq "^[[:space:]]*$${key}=" .env
|
||||
@@ -29,15 +36,6 @@ lowercase() {
|
||||
printf '%s' "$$1" | tr '[:upper:]' '[:lower:]'
|
||||
}
|
||||
|
||||
dev_uses_sqlite_database() {
|
||||
local driver
|
||||
local url
|
||||
driver="$$(lowercase "$${AETHER_DATABASE_DRIVER:-}")"
|
||||
url="$${AETHER_DATABASE_URL:-$${DATABASE_URL:-}}"
|
||||
|
||||
[[ "$${driver}" == "sqlite" || "$${url}" == sqlite:* ]]
|
||||
}
|
||||
|
||||
dev_uses_postgres_database() {
|
||||
local driver
|
||||
local url
|
||||
@@ -61,9 +59,6 @@ dev_uses_redis_runtime() {
|
||||
if [[ "$${backend}" == "redis" ]]; then
|
||||
return 0
|
||||
fi
|
||||
if dev_uses_sqlite_database; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
return 0
|
||||
}
|
||||
@@ -201,12 +196,17 @@ print_startup_failure_hint() {
|
||||
|
||||
if [ -n "$${log_file}" ] && [ -f "$${log_file}" ]; then
|
||||
if grep -Eq "database schema is behind" "$${log_file}"; then
|
||||
echo "=> 检测到数据库 schema 落后,请执行: make migration"
|
||||
echo "=> 检测到数据库尚未准备完成,请执行: make db-prepare"
|
||||
return
|
||||
fi
|
||||
|
||||
if grep -Eq "database backfills are behind" "$${log_file}"; then
|
||||
echo "=> 检测到待执行 backfills,请执行: make backfill"
|
||||
echo "=> 检测到数据库尚未准备完成,请执行: make db-prepare"
|
||||
return
|
||||
fi
|
||||
|
||||
if grep -Eq "bootstrap admin env is partially configured.*ADMIN_PASSWORD" "$${log_file}"; then
|
||||
echo "=> 首次启动需要管理员密码,请在 .env 中设置 ADMIN_PASSWORD"
|
||||
return
|
||||
fi
|
||||
fi
|
||||
@@ -344,6 +344,9 @@ if ! ensure_dev_infra; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "=> 编译 aether-gateway..."
|
||||
cargo build -p aether-gateway --bin aether-gateway
|
||||
|
||||
GATEWAY_PID=""
|
||||
GATEWAY_LOG_DIR=""
|
||||
GATEWAY_LOG_FILE=""
|
||||
@@ -352,8 +355,8 @@ create_gateway_log_file
|
||||
|
||||
echo "=> 启动 aether-gateway (Rust frontdoor: 0.0.0.0:$${APP_PORT})..."
|
||||
echo "=> 日志过滤: $${RUST_LOG}"
|
||||
echo "=> 执行命令: cargo run -p aether-gateway -- --app-port $${APP_PORT}"
|
||||
cargo run -p aether-gateway -- --app-port "$${APP_PORT}" > >(
|
||||
echo "=> 执行命令: target/debug/aether-gateway --app-port $${APP_PORT}"
|
||||
target/debug/aether-gateway --app-port "$${APP_PORT}" > >(
|
||||
tee -a "$${GATEWAY_LOG_FILE}"
|
||||
) 2>&1 &
|
||||
GATEWAY_PID=$$!
|
||||
@@ -444,7 +447,7 @@ if [ -f .env ]; then
|
||||
fi
|
||||
export APP_PORT="$${APP_PORT:-8084}"
|
||||
|
||||
echo "=> 启动后端: RUST_LOG=$${DEV_RUST_LOG} cargo run -p aether-gateway -- --app-port $${APP_PORT:-8084}"
|
||||
echo "=> 启动后端: 先编译 aether-gateway,再运行 target/debug/aether-gateway --app-port $${APP_PORT:-8084}"
|
||||
/bin/bash -euo pipefail -c "$$DEV_BACKEND_SCRIPT" &
|
||||
backend_pid=$$!
|
||||
|
||||
@@ -494,8 +497,14 @@ export DEV_SCRIPT
|
||||
define DB_TASK_SCRIPT
|
||||
set -euo pipefail
|
||||
|
||||
if [ -z "$${DB_TASK_FLAG:-}" ] || [ -z "$${DB_TASK_LABEL:-}" ]; then
|
||||
echo "=> 内部错误: DB_TASK_FLAG / DB_TASK_LABEL 未设置"
|
||||
if [ -z "$${DB_TASK_COMMAND:-}" ] || [ -z "$${DB_TASK_LABEL:-}" ]; then
|
||||
echo "=> 内部错误: DB_TASK_COMMAND / DB_TASK_LABEL 未设置"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
read -r -a db_task_args <<< "$${DB_TASK_COMMAND}"
|
||||
if [ "$${#db_task_args[@]}" -eq 0 ]; then
|
||||
echo "=> 内部错误: DB_TASK_COMMAND 为空"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -546,8 +555,8 @@ if ! command -v cargo >/dev/null 2>&1; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "=> 执行 $${DB_TASK_LABEL}: cargo run -p aether-gateway -- $${DB_TASK_FLAG}"
|
||||
exec cargo run -p aether-gateway -- "$${DB_TASK_FLAG}"
|
||||
echo "=> 执行 $${DB_TASK_LABEL}: cargo run -p aether-gateway --bin aether-gateway -- $${db_task_args[*]}"
|
||||
exec cargo run -p aether-gateway --bin aether-gateway -- "$${db_task_args[@]}"
|
||||
endef
|
||||
export DB_TASK_SCRIPT
|
||||
|
||||
@@ -560,8 +569,14 @@ dev-backend:
|
||||
dev-frontend:
|
||||
@cd frontend && npm run dev
|
||||
|
||||
db-status:
|
||||
@DB_TASK_COMMAND="db status" DB_TASK_LABEL="数据库状态检查" $(SHELL) -euo pipefail -c "$$DB_TASK_SCRIPT"
|
||||
|
||||
db-prepare:
|
||||
@DB_TASK_COMMAND="db prepare" DB_TASK_LABEL="数据库准备" $(SHELL) -euo pipefail -c "$$DB_TASK_SCRIPT"
|
||||
|
||||
migration:
|
||||
@DB_TASK_FLAG=--migrate DB_TASK_LABEL="数据库迁移" $(SHELL) -euo pipefail -c "$$DB_TASK_SCRIPT"
|
||||
@DB_TASK_COMMAND="--migrate" DB_TASK_LABEL="数据库迁移" $(SHELL) -euo pipefail -c "$$DB_TASK_SCRIPT"
|
||||
|
||||
backfill:
|
||||
@DB_TASK_FLAG=--apply-backfills DB_TASK_LABEL="数据库 backfill" $(SHELL) -euo pipefail -c "$$DB_TASK_SCRIPT"
|
||||
@DB_TASK_COMMAND="--apply-backfills" DB_TASK_LABEL="数据库 backfill" $(SHELL) -euo pipefail -c "$$DB_TASK_SCRIPT"
|
||||
|
||||
@@ -44,17 +44,22 @@ cd Aether
|
||||
|
||||
# 2. 配置环境变量
|
||||
cp .env.example .env
|
||||
# 生成 JWT_SECRET_KEY / ENCRYPTION_KEY, 并填入 .env
|
||||
# .env 包含数据库、JWT 和数据加密密钥,先限制为仅当前用户可读写
|
||||
chmod 600 .env
|
||||
# 生成 JWT / 加密 / Postgres / Redis 独立随机密钥,并填入 .env
|
||||
./generate_keys.sh
|
||||
# 编辑 .env 设置 ADMIN_PASSWORD
|
||||
|
||||
# 3. 首次部署 / 更新 (从以下部署形态任选其一)
|
||||
# Postgres + Redis (适用于企业或多人使用)
|
||||
# Postgres + Redis (推荐)
|
||||
docker compose pull && docker compose up -d
|
||||
# Single Node (适用于个人用户或朋友分享)
|
||||
# Single Node:同样使用 PostgreSQL + Redis,无需挂载本地数据库文件
|
||||
docker compose -f docker-compose.single-node.yml pull && docker compose -f docker-compose.single-node.yml up -d
|
||||
```
|
||||
|
||||
应用镜像默认以固定非 root 身份 `65532:65532` 运行;Compose 移除全部 Linux capabilities、禁止提权、启用只读根文件系统,并提供带 `nosuid,nodev,noexec` 的 `/tmp`。如需使用其他身份,可在 `.env` 中设置非零的 `AETHER_CONTAINER_UID` / `AETHER_CONTAINER_GID`。数据库使用独立 PostgreSQL 容器和 named volume,不再需要调整应用数据库目录的权限。
|
||||
|
||||
|
||||
### 一键更新
|
||||
|
||||
Docker Compose 部署后,可在部署目录直接执行:
|
||||
@@ -69,13 +74,29 @@ Docker Compose 部署后,可在部署目录直接执行:
|
||||
./update.sh --mode single-node
|
||||
```
|
||||
|
||||
仓库自带的 Docker Compose 默认把应用日志输出到容器 `stdout/stderr`,直接用 `docker compose logs -f app` 查看,并由 Docker 轮转日志,避免正式发布镜像切换到非 root 用户后再被宿主机挂载日志目录的权限问题拖垮启动。如果你确实需要文件日志,需要在 compose 里把 `AETHER_LOG_DESTINATION` 改成 `file|both`,并额外挂载一个容器用户可写的目录到 `/opt/aether/logs`。
|
||||
现在仅支持 PostgreSQL。标准和单节点 Docker Compose 均部署 PostgreSQL + Redis;原生 systemd / launchd 安装需要显式提供 PostgreSQL `DATABASE_URL`,例如 `DATABASE_URL=postgresql://user:password@host:5432/aether`。旧数据库不会自动迁移或清空。升级时保留原有 PostgreSQL 密码、`JWT_SECRET_KEY` 和 `ENCRYPTION_KEY`,不要重新生成整个 `.env`。
|
||||
|
||||
仓库自带的 Docker Compose 默认把应用日志输出到容器 `stdout/stderr`,直接用 `docker compose logs -f app` 查看,并由 Docker 轮转日志,避免非 root 用户被宿主机日志目录权限拖垮启动。如果你确实需要文件日志,需要在 compose 里把 `AETHER_LOG_DESTINATION` 改成 `file|both`,额外挂载目录到 `/opt/aether/logs`,并让它归 `.env` 中配置的容器 UID/GID 所有;只读根文件系统不会阻止显式可写挂载。
|
||||
|
||||
管理后台右上角“版本信息”会检测新版本。Docker Compose 部署只提示版本,实际更新继续执行 `./update.sh`;systemd / launchd / 二进制部署才使用后台自更新,流程是下载对应平台的 GitHub Release 包、强制校验 `SHA256SUMS`、解压到 `/opt/aether/releases/<version>`,再切换 `/opt/aether/current` 并退出进程,交给 systemd / launchd 拉起新版本。
|
||||
|
||||
正式 Release 还会发布由 GitHub Actions OIDC / Sigstore 签发的 SLSA build provenance。需要验证发布者身份时,下载目标 tarball 和 `AETHER_RELEASE_PROVENANCE.sigstore.json`,并把 `TAG` 设置为对应 Release tag:
|
||||
|
||||
```bash
|
||||
gh attestation verify "aether-${TAG}-linux-amd64.tar.gz" \
|
||||
--repo fawney19/Aether \
|
||||
--signer-workflow fawney19/Aether/.github/workflows/release.yml \
|
||||
--source-ref "refs/tags/${TAG}" \
|
||||
--bundle AETHER_RELEASE_PROVENANCE.sigstore.json
|
||||
```
|
||||
|
||||
`docker-compose.yml` 中的官方 PostgreSQL 和 Redis 镜像均固定到多架构 OCI index digest。升级这些依赖时应在发布变更中显式更新 digest,避免同名 tag 在无人审查的情况下改变部署内容。
|
||||
|
||||
正式发布到 GHCR 和 Docker Hub 的多架构 Aether 镜像也带有同一 GitHub Actions OIDC / Sigstore provenance;生产 `Dockerfile.app` 的 BusyBox 与 Distroless 基础镜像同样固定到多架构 OCI index digest。
|
||||
|
||||
源码或本地构建版本不会启用后台在线更新,请继续使用源码更新流程。Docker Compose 用户如果希望“容器重建后也保持镜像层面的新版本”,仍建议定期运行 `./update.sh` 拉取并重建 app 镜像。服务器访问 GitHub 需要代理时,可设置 `AETHER_UPDATE_PROXY_URL`,也兼容 `UPDATE_PROXY_URL`、`HTTPS_PROXY`、`ALL_PROXY`、`HTTP_PROXY` 以及 `NO_PROXY`。共享出口触发 GitHub API 限流时,可设置只读 `AETHER_UPDATE_GITHUB_TOKEN`,也兼容 `GITHUB_TOKEN` / `GH_TOKEN`。下载总超时默认 600 秒,连续无响应/无数据默认 30 秒,可通过 `AETHER_UPDATE_DOWNLOAD_TIMEOUT_SECS` 和 `AETHER_UPDATE_DOWNLOAD_IDLE_TIMEOUT_SECS` 调整。
|
||||
|
||||
标准 Docker Compose 使用 Docker named volumes 存放 Postgres/Redis/MySQL 数据;Single Node 使用部署目录下的 `./data` 存放 SQLite 数据。
|
||||
标准和 Single Node Docker Compose 均使用 Docker named volume 存放 PostgreSQL 数据。
|
||||
|
||||
如果是本地源码构建镜像的部署,继续使用:
|
||||
|
||||
@@ -105,17 +126,32 @@ docker compose -f docker-compose.release-local.yml down -v
|
||||
- `AETHER_RELEASE_LOCAL_PORT`:本地联调端口,默认 `18085`
|
||||
- `LOCAL_RELEASE_APP_IMAGE`:本地联调镜像名,默认 `aether-app:release-local`
|
||||
|
||||
### 一键安装(默认 Single Node:Linux systemd / macOS launchd + SQLite)
|
||||
### 一键安装(PostgreSQL + Redis)
|
||||
|
||||
```bash
|
||||
git clone https://github.com/fawney19/Aether.git
|
||||
cd Aether
|
||||
curl -fsSL https://raw.githubusercontent.com/fawney19/Aether/main/install.sh | sudo bash
|
||||
curl -fsSL https://raw.githubusercontent.com/fawney19/Aether/main/install.sh | sudo bash -s -- --mode compose
|
||||
```
|
||||
|
||||
原生 Linux systemd / macOS launchd 安装需先准备 PostgreSQL,将连接串通过 `DATABASE_URL` 传给安装进程,并选择 `--mode single-node`;不再自动创建本地数据库文件。
|
||||
|
||||
### Nightly(每日 main 构建)
|
||||
|
||||
Nightly workflow 每天从 `main` 的固定 commit 构建并发布滚动的 GitHub Release `nightly`,同时推送多架构 GHCR 镜像 `ghcr.io/fawney19/aether:nightly`。Nightly 是预发布版本,适合验证最新代码,不保证与正式版相同的稳定性。滚动 Release 需要仓库保持关闭 GitHub Release immutability。
|
||||
|
||||
安装最新 nightly(PostgreSQL + Redis):
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/fawney19/Aether/main/install.sh | sudo bash -s -- --mode compose --channel nightly
|
||||
```
|
||||
|
||||
Docker Compose 用户可在部署目录的 `.env` 中设置 `APP_IMAGE=ghcr.io/fawney19/aether:nightly`,然后运行 `./update.sh` 获取下一次 nightly。二进制部署请沿用已有 PostgreSQL 环境配置,并使用 `--mode single-node --channel nightly` 重新运行安装脚本升级;当前管理后台的在线更新列表只跟踪正式版/RC/Beta,不会自动提示下一次 nightly。
|
||||
|
||||
## 本地开发
|
||||
|
||||
依赖 Docker、Rust toolchain、Node.js 和 make。
|
||||
首次启动前需要在 `.env` 中设置 `ADMIN_PASSWORD`,用于创建本地管理员。
|
||||
|
||||
```bash
|
||||
make dev
|
||||
@@ -123,6 +159,18 @@ make dev
|
||||
|
||||
`make dev` 会同时启动后端 `aether-gateway` 和前端 `frontend` 的 Vite dev server。需要单独启动时可使用 `make dev-backend` 或 `make dev-frontend`。
|
||||
Postgres / Redis 本地依赖未就绪时,`make dev` 会自动执行 `docker compose up -d postgres redis`。
|
||||
`make dev` 会先完成后端编译,再开始计算服务健康检查超时。数据库 schema 和必要的派生数据准备也会在启动时自动完成;通常不需要手动区分 migration 与 backfill。升级不会主动重写或清除已有业务历史记录,新写入会直接遵循当前的数据持久化策略。排查或部署前预执行时可使用:
|
||||
|
||||
```bash
|
||||
make db-status
|
||||
make db-prepare
|
||||
```
|
||||
|
||||
## Codex 远程协同
|
||||
|
||||
`aether-vscodex/` 是独立的 VS Code Codex 协同模块:同步模式跟随 VS Code 官方 Codex 面板当前会话且不另起进程;异步模式使用独立 app-server,让浏览器自行列出、恢复、新建和切换会话。两种模式都能从本机 URL 或 Aether 云端查看输出、发送消息和处理授权,模块内的 Vue 前端提供中英文界面。
|
||||
|
||||
安装、云端配对和安全边界请参阅 [`aether-vscodex/README.md`](aether-vscodex/README.md)。
|
||||
|
||||
## Aether Tunnel (可选)
|
||||
|
||||
@@ -137,29 +185,52 @@ Aether Tunnel 是配套的正向代理节点,部署在海外 VPS 上,为墙
|
||||
|
||||
- Embeddings: [OpenAI compatible `POST /v1/embeddings`](docs/api/embeddings.md)
|
||||
- Rerank: [OpenAI/Jina compatible `POST /v1/rerank`](docs/api/rerank.md)
|
||||
- Responses WebSocket mode: [protocol and Aether behavior](docs/WebSocket-Mode.md)
|
||||
- WebSocket probes: [Codex](docs/operations/codex-responses-websocket-probe.md) · [OpenAI Responses](docs/operations/openai-responses-websocket-probe.md)
|
||||
|
||||
## 环境变量
|
||||
|
||||
- `APP_PORT`:`aether-gateway` 唯一监听端口,固定绑定 `0.0.0.0:${APP_PORT}`
|
||||
- `DATABASE_URL`:数据库连接串;SQLite 例如 `sqlite:///opt/aether/data/aether.db`,Postgres 例如 `postgresql://postgres:aether@postgres:5432/aether`
|
||||
- `AETHER_GATEWAY_DATA_POSTGRES_MIN_CONNECTIONS` / `AETHER_GATEWAY_DATA_POSTGRES_MAX_CONNECTIONS`:数据库连接池手动覆盖值;未配置时会自动推导,SQLite 固定 `1/1`,Postgres/MySQL 按 CPU 核心数计算并默认封顶 `100`
|
||||
- `DATABASE_URL`:PostgreSQL 连接串,例如 `postgresql://USER:PASSWORD@HOST:5432/aether`
|
||||
- `AETHER_GATEWAY_DATA_POSTGRES_MIN_CONNECTIONS` / `AETHER_GATEWAY_DATA_POSTGRES_MAX_CONNECTIONS`:数据库连接池手动覆盖值;未配置时 PostgreSQL 按每核 `4` 条自动推导,总池范围为 `32-100`。该预算按进程计算,多实例部署应按数据库连接上限显式分配
|
||||
- `AETHER_GATEWAY_MAX_IN_FLIGHT_REQUESTS`:单实例请求并发上限;未配置时按 CPU 自动推导(基础范围 `512-65536`),低文件描述符预算时会进一步下调
|
||||
- `AETHER_GATEWAY_REQUEST_BODY_BUFFER_BUDGET_MB`:单实例同时读取和解压请求体的加权内存预算,默认 `256MB`
|
||||
- `AETHER_GATEWAY_REQUEST_BODY_READ_TIMEOUT_MS`:请求体完整读取超时,默认 `120000ms`
|
||||
- `AETHER_MAX_REQUEST_BODY_MB`:单请求解压后的最大请求体,默认 `64MB`
|
||||
- `AETHER_MAX_INTERNAL_BUFFERED_BODY_MB`:heartbeat、管理探测等内部必须整包读取的响应体上限,默认 `128MB`
|
||||
- `AETHER_GATEWAY_REQUEST_BODY_READ_TIMEOUT_MS`:可选的请求体完整读取超时;默认或显式设为 `0` 时关闭,非零值限制在 `1000-600000ms`
|
||||
- `AETHER_MAX_REQUEST_BODY_MB`:单请求解压后请求体上限,默认 `256MB`;显式设为 `0` 表示不再收紧默认值,但仍受 `256MB` 安全硬上限约束
|
||||
- `AETHER_MAX_INTERNAL_BUFFERED_BODY_MB`:heartbeat、管理探测等内部整包响应体上限,默认 `64MB`;显式设为 `0` 表示不再收紧默认值,但仍受 `256MB` 安全硬上限约束
|
||||
- `AETHER_TUNNEL_NODE_STATUS_QUEUE_CAPACITY`:隧道节点状态上报队列容量,默认 `1024`;满载时拒绝新事件,避免控制面故障导致无界内存增长
|
||||
- `AETHER_TUNNEL_RELAY_ALLOW_PRIVATE_TARGETS`:跨网关 owner relay 解析到私有/保留地址时的显式运维开关,默认关闭;仅当多网关 relay URL 是受控的内网 HTTPS 地址时设置为 `true`。它不改变普通 provider 请求的 DNS/代理策略,也不允许明文 HTTP 非 loopback relay
|
||||
- `AETHER_TUNNEL_RELAY_PRIVATE_HOST_ALLOWLIST`:更窄的 owner relay 私网例外,填写逗号分隔的精确主机名(例如 `gateway-a.internal,gateway-b.internal`,忽略大小写和末尾点);仅这些主机解析出的私有地址会被允许,并且请求仍使用解析后地址 pin。不要填写通配符或 `.internal` 这类后缀
|
||||
- `AETHER_INTERNAL_GATEWAY_AUTH_SECRET`:旧版 `/api/internal/gateway/*` 高权限控制面的独立 HMAC 密钥,至少 `32` 字节;未配置时该控制面返回 `404`。不要复用 JWT、数据加密或 tunnel relay 密钥,多节点必须使用同一值及共享 Redis 防重放
|
||||
- `AETHER_GATEWAY_SECURITY_CACHE_TTL_MS`:IP 黑白名单本地缓存时间,默认 `1000ms`,写操作会主动失效相关缓存
|
||||
- `AETHER_MAX_REDACTED_SYNC_RESPONSE_BODY_MB`:启用 PII 恢复时同步响应允许缓冲的最大大小,默认 `64MB`
|
||||
- `AETHER_MAX_REDACTED_SYNC_RESPONSE_BODY_MB`:PII 恢复同步响应缓冲上限,默认 `64MB`;显式设为 `0` 表示不再收紧默认值,但仍受 `256MB` 安全硬上限约束
|
||||
- `REDIS_URL`:Redis 连接串;仅 Postgres + Redis 的 Docker Compose 部署需要配置
|
||||
- `AETHER_RUNTIME_BACKEND=memory|redis`:运行时缓存/协调后端。SQLite 默认用 `memory`,不会连接 Redis
|
||||
- `AETHER_GATEWAY_AUTO_PREPARE_DATABASE`:常规启动前自动执行挂起的 schema migration 和 backfill;仓库自带的 `docker-compose.yml` 默认开启
|
||||
- `AETHER_RUNTIME_BACKEND=memory|redis`:运行时缓存/协调后端。配置 Redis 时使用 `redis`,否则使用 `memory`;多节点部署和需要跨 gateway 重启恢复 OpenAI Responses continuation history 的部署必须使用共享 Redis
|
||||
- `AETHER_GATEWAY_DATABASE_MODE=auto|verify-only`:数据库启动策略,默认 `auto`,自动完成挂起的 schema migration 和 backfill;`verify-only` 仅检查并在数据库落后时拒绝启动
|
||||
- `AETHER_GATEWAY_AUTO_PREPARE_DATABASE`:旧版兼容开关;新配置请使用 `AETHER_GATEWAY_DATABASE_MODE`
|
||||
- `JWT_SECRET_KEY` / `ENCRYPTION_KEY`:认证和敏感数据加密所需密钥
|
||||
- `AETHER_BACKUP_ENCRYPTION_KEY`:推荐的 S3 备份独立加密密钥;缺省回退到 `ENCRYPTION_KEY`。新备份使用带 key ID 的 AES-256-GCM v2 envelope,轮换前必须保留旧密钥
|
||||
- `API_KEY_PREFIX`:用户和管理员新建 API Key 时使用的前缀,默认 `sk`
|
||||
- `ADMIN_USERNAME` / `ADMIN_PASSWORD` / `ADMIN_EMAIL`:首次启动时自举首个本地管理员;`install.sh` 会提示输入管理员密码
|
||||
- `CORS_ORIGINS` / `CORS_ALLOW_CREDENTIALS`:前端跨域来源控制;如果要跨域带登录 Cookie,`CORS_ORIGINS` 不能写 `*`
|
||||
- `RUST_LOG`:Rust 日志过滤,例如 `aether_gateway=info`、`aether_gateway=debug,sqlx=warn`
|
||||
- Docker Compose 的 `DB_PASSWORD` / `REDIS_PASSWORD` 默认使用 `aether`
|
||||
- `DB_PASSWORD` / `REDIS_PASSWORD`:Docker Compose 后端密码,首次安装时分别随机生成;手工部署必须替换示例占位值,不要互相复用
|
||||
|
||||
### S3 备份离线恢复
|
||||
|
||||
先从 S3 下载完整的 `.json.zst.aes256gcm` 对象,再使用原始的完整 S3 object key 做认证解密。恢复工具只验证并输出本地 JSON,不会直接写数据库;数据库导入仍应在维护窗口通过管理端完成。
|
||||
|
||||
```bash
|
||||
AETHER_BACKUP_ENCRYPTION_KEY='原备份密钥' \
|
||||
cargo run -p aether-gateway --bin aether-backup-restore -- \
|
||||
--input ./backup.json.zst.aes256gcm \
|
||||
--object-key 'aether/backups/aether-data-backup-20260822-010000.json.zst.aes256gcm' \
|
||||
--output ./restored-backup.json
|
||||
```
|
||||
|
||||
工具默认拒绝覆盖,输出采用原子写并在 Unix 上设置为 `0600`;Unix 可用 `--overwrite` 原子替换,Windows 为避免非原子删除窗口会要求选择新输出路径。密钥不能作为命令行参数。可使用 `AETHER_BACKUP_ENCRYPTION_KEY`、兼容用 `AETHER_GATEWAY_DATA_ENCRYPTION_KEY` / `ENCRYPTION_KEY`、受保护的 `--key-file`,或 `AETHER_BACKUP_KEYRING_FILE`。Keyring JSON 格式为 `{"version":1,"keys":["当前或历史 v2 secret"],"legacy_v1":["旧 v1 secret"]}`;条目也可写成 `{"secret":"..."}`(兼容字段名 `key`)。也可由 `AETHER_BACKUP_HISTORICAL_KEYS_JSON` 提供同一结构。密钥文件必须是非符号链接的普通文件,Unix 下权限需为 `0600` 或更严格。
|
||||
|
||||
默认限制密文为 `512MiB`、解压后 JSON 为 `1GiB`,可通过受限的 `--max-encrypted-mib` / `--max-json-mib` 调整。网关最多扫描同一备份前缀下 10,000 个对象,并且不会自动删除 S3 对象:`backup_s3_retention_count` 只用于报告超出保留数量的清理候选。旧明文备份在创建并验证加密副本后仍会保留,必须通过 bucket lifecycle 或支持版本条件的外部清理工具移除;启用 Versioning 时还需清理 noncurrent versions,Object Lock/retention 可能阻止物理删除。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
.git
|
||||
.github
|
||||
node_modules
|
||||
test
|
||||
fixtures
|
||||
vscode-extension
|
||||
*.vsix
|
||||
coverage
|
||||
data
|
||||
.DS_Store
|
||||
@@ -0,0 +1,8 @@
|
||||
node_modules/
|
||||
vscode-extension/node_modules/
|
||||
vscode-extension/dist/
|
||||
data/
|
||||
coverage/
|
||||
*.vsix
|
||||
.DS_Store
|
||||
*.log
|
||||
@@ -0,0 +1,26 @@
|
||||
FROM node:22-alpine
|
||||
|
||||
ENV NODE_ENV=production \
|
||||
HOST=0.0.0.0 \
|
||||
PORT=8788 \
|
||||
AETHER_VSCODEX_DATA_DIR=/var/lib/aether-vscodex
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci --omit=dev && npm cache clean --force
|
||||
|
||||
COPY cloud ./cloud
|
||||
COPY relay ./relay
|
||||
COPY public ./public
|
||||
|
||||
RUN mkdir -p /var/lib/aether-vscodex && chown -R node:node /var/lib/aether-vscodex /app
|
||||
|
||||
USER node
|
||||
|
||||
EXPOSE 8788
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
|
||||
CMD node -e "fetch('http://127.0.0.1:8788/healthz').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))"
|
||||
|
||||
CMD ["node", "cloud/server.js"]
|
||||
@@ -0,0 +1,283 @@
|
||||
# aether-vscodex
|
||||
|
||||
这个项目让浏览器从本机 URL 或 Aether 云端查看、输入并处理 Codex 会话,提供两种
|
||||
可随时切换的控制模式。默认的**同步模式**通过官方扩展使用的本机 IPC socket,严格
|
||||
跟随 VS Code Codex 面板当前会话,不启动另一个 `codex` 进程;**异步模式**由伴随扩展
|
||||
启动独立 app-server,网页可以自行列出、恢复、新建和切换会话。
|
||||
|
||||
同一个伴随扩展可同时连接两个互不替代的通道:本机 loopback 控制台和部署在
|
||||
Aether 中的云端控制台。本机通道默认免密码且只能从本机访问;云端通道使用
|
||||
Aether 登录鉴权、一次性浏览器票据和独立设备凭据;父页面不会通过协议把 Aether JWT
|
||||
传给 iframe 或 Node sidecar。iframe 是随 Aether 一起发布的同源受信代码,不应被视为
|
||||
隔离不受信内容的安全边界。
|
||||
|
||||
`vscode-extension/codex-remote-collab-0.4.0.vsix` 安装到 VS Code 后,会作为官方
|
||||
`openai.chatgpt` Codex 扩展的伴随扩展,并自动托管只监听本机的 relay。开发时仍可
|
||||
单独运行 `relay/server.js`。不要卸载或替换官方 Codex 扩展。
|
||||
|
||||
## 工作方式
|
||||
|
||||
```text
|
||||
官方 VS Code Codex 会话
|
||||
│ 本机私有 IPC(只在 VS Code 所在机器上)
|
||||
▼
|
||||
┌── 本机 relay ── http://127.0.0.1:8787
|
||||
VS Code aether-vscodex 扩展 ────┤
|
||||
└── Aether gateway ── 用户/设备隔离的云端 relay
|
||||
```
|
||||
|
||||
控制模式与传输通道是两个独立维度:切换同步/异步不会重连本地或云端 relay。本机和
|
||||
Aether 控制页连接到同一台 VS Code 主机时,会看到同一个当前模式。
|
||||
|
||||
| 控制模式 | 会话所有者 | 网页会话导航 |
|
||||
| --- | --- | --- |
|
||||
| 同步 | 官方 VS Code Codex 面板 | 禁止网页自行切换;自动跟随 VS Code |
|
||||
| 异步 | 扩展启动的独立 app-server | 可列出、恢复、新建和切换会话 |
|
||||
|
||||
浏览器的 `operator` 可以发送任务、继续/中断当前 turn,并处理 Codex 的审批、
|
||||
用户输入和 MCP elicitation;`viewer` 只能查看事件和输出。远程浏览器不接触
|
||||
VS Code 的 SecretStorage,也不直接连接 IPC socket。
|
||||
|
||||
## 前端结构
|
||||
|
||||
Aether 页面使用仓库既有的 Vue 3、TypeScript、Vite 和 i18n。独立控制台也提供
|
||||
Vue/Vite 源码入口,但当前高保真的会话渲染与协议状态机作为兼容运行时保留,构建到
|
||||
`public/` 后同时供本机 URL 和 Aether 同源 iframe 使用。这样不需要一次性重写并丢失
|
||||
命令展开、滚动锚点、思考状态、Markdown、子代理、模型和权限菜单等已有行为。
|
||||
|
||||
界面支持 `zh-CN` 与 `en-US`。Aether 的语言和深浅色主题会通过经过来源校验的
|
||||
`postMessage` 同步给 iframe;VS Code 命令与设置说明使用 `package.nls` 本地化。
|
||||
|
||||
## Aether 云端部署
|
||||
|
||||
云端模式由 Aether gateway 和独立 Node sidecar 组成。sidecar 只在 Compose 内网暴露
|
||||
8788,公网的 HTTP、配对交换和 WebSocket 都经 Aether gateway:
|
||||
|
||||
```text
|
||||
GET /api/users/me/vscodex/devices
|
||||
POST /api/users/me/vscodex/pairings
|
||||
DELETE /api/users/me/vscodex/devices/:device_id
|
||||
POST /api/users/me/vscodex/ws-tickets
|
||||
POST /api/vscodex/pair
|
||||
WS /api/vscodex/ws
|
||||
```
|
||||
|
||||
生成至少 32 字节的内部令牌,并按 Aether 的公开 HTTPS 地址设置变量:
|
||||
|
||||
```sh
|
||||
export AETHER_VSCODEX_INTERNAL_TOKEN="$(openssl rand -base64 32)"
|
||||
export AETHER_VSCODEX_PUBLIC_WS_URL="wss://aether.example.com/api/vscodex/ws"
|
||||
export AETHER_VSCODEX_ALLOWED_ORIGINS="https://aether.example.com"
|
||||
|
||||
docker compose \
|
||||
-f docker-compose.yml \
|
||||
-f docker-compose.local.yml \
|
||||
-f aether-vscodex/docker-compose.aether.yml \
|
||||
up -d --build
|
||||
```
|
||||
|
||||
源码部署必须包含 `docker-compose.local.yml`,以保证 gateway、前端和 sidecar 来自同一份
|
||||
checkout。使用发布镜像时可以去掉该文件,但 `APP_IMAGE` 必须固定为包含相同
|
||||
`aether-vscodex` 协议版本的 Aether 镜像,不能把当前 sidecar 与旧的 `latest` gateway 混用。
|
||||
|
||||
首次使用源码 Compose 前先构建控制台;正式 Aether 发布流程与 Dockerfile 已自动执行
|
||||
同一步骤:
|
||||
|
||||
```sh
|
||||
npm --prefix aether-vscodex/web ci
|
||||
npm --prefix aether-vscodex/web run build
|
||||
```
|
||||
|
||||
第一阶段 sidecar 是有状态单副本:设备凭据的 scrypt 哈希保存在
|
||||
`vscodex_data`,短期配对码、60 秒一次性浏览器票据和在线房间保存在内存。不要在未引入
|
||||
共享连接目录前横向扩容 sidecar。
|
||||
|
||||
登录 Aether 后打开“远程控制”,生成一次性配对码。然后在 VS Code 命令面板执行
|
||||
**Codex Remote: Pair with Aether**,填写 Aether 地址和配对码。插件会把设备凭据写入
|
||||
VS Code SecretStorage,并同时保持本机控制台连接。
|
||||
|
||||
## 快速开始
|
||||
|
||||
前提:Node.js 20+;官方 `openai.chatgpt` VS Code 扩展已安装并登录;目标会话
|
||||
已经在 VS Code 的 Codex 面板中打开。VS Code 和 relay 必须以同一个操作系统用户
|
||||
运行,因为 IPC socket 是本机文件。
|
||||
|
||||
1. 安装依赖并构建伴随扩展:
|
||||
|
||||
```sh
|
||||
npm --prefix vscode-extension install
|
||||
npm --prefix vscode-extension run build
|
||||
```
|
||||
|
||||
本机 `ws://` 地址会由扩展自动启动 relay;loopback 模式默认不需要 token,且
|
||||
`host` 模式不会启动 `codex app-server`。
|
||||
|
||||
2. 安装 `vscode-extension/codex-remote-collab-0.4.0.vsix`(或在扩展目录先
|
||||
`npm run build` 再用 `npx --yes @vscode/vsce package` 打包),然后在 VS Code
|
||||
执行 **Developer: Reload Window**。
|
||||
|
||||
3. 在 VS Code 设置中填写:
|
||||
|
||||
```json
|
||||
{
|
||||
"codexRemoteCollab.localRelayUrl": "ws://127.0.0.1:8787/v1/connect",
|
||||
"codexRemoteCollab.controlMode": "sync",
|
||||
"codexRemoteCollab.autoDiscoverThread": true,
|
||||
"codexRemoteCollab.autoStart": true
|
||||
}
|
||||
```
|
||||
|
||||
4. 执行一次 **Developer: Reload Window** 后,扩展会自动找到最近的、仍由官方
|
||||
VS Code Codex owner 持有的会话,并把已有输出同步到 relay;如果没有自动启动,
|
||||
无需手动启动或断开。右下角状态项只用于显示状态并打开 Web。需要精确指定会话时,执行
|
||||
**Codex Remote: Set Existing Thread ID**;留空则恢复自动发现。
|
||||
官方 Codex 面板切换会话时,Web 默认会在新会话快照就绪后自动跟随;正在执行或等待
|
||||
授权的旧会话会先保持附着,结束后再安全切换。
|
||||
|
||||
5. 浏览器打开 `http://127.0.0.1:8787`,页面会自动以本机 operator 身份连接,
|
||||
不需要输入密码。
|
||||
|
||||
如果页面显示“等待 VS Code 主机连接”,先确认 relay 地址与扩展设置的端口完全一致,
|
||||
然后在 VS Code 执行一次 **Developer: Reload Window**。同步模式必须在官方 Codex
|
||||
面板已经打开至少一个会话后才能发现 owner;通常不需要手工填写
|
||||
`codexRemoteCollab.threadId`,留空会自动选择最近的可用会话。若之前填写过已经关闭的
|
||||
thread ID,清空该设置后再重载窗口。
|
||||
|
||||
### 发布与下载插件
|
||||
|
||||
正式发布时不需要用户在本地编译。仓库的 `.github/workflows/release.yml` 在推送
|
||||
`vX.Y.Z`、`vX.Y.Z-beta.N` 或 `vX.Y.Z-rc.N` 标签时,会在 GitHub Actions 中完成 Web
|
||||
前端构建、扩展编译和 VSIX 打包,并把
|
||||
`aether-vscodex-<extension-version>.vsix` 附加到对应的 GitHub Release。用户从 Release
|
||||
页面下载该 VSIX,在 VS Code 的扩展视图中选择“从 VSIX 安装...”即可;安装后执行一次
|
||||
**Developer: Reload Window**。
|
||||
|
||||
手动运行该 workflow 时,VSIX 会作为 `aether-vscodex-vsix` Actions artifact 提供下载,
|
||||
但不会创建 GitHub Release。源码目录中的 VSIX 只用于本地开发验证,不是用户发布渠道。
|
||||
|
||||
如果命令面板提示 `command 'codexRemoteCollab.start' not found`,通常是旧版
|
||||
VSIX 激活失败(旧包可能没有包含 `ws` 运行依赖)。请安装当前的
|
||||
`codex-remote-collab-0.4.0.vsix` 并使用 `--force` 覆盖旧版本,然后执行一次
|
||||
**Developer: Reload Window**:
|
||||
|
||||
```sh
|
||||
code --install-extension vscode-extension/codex-remote-collab-0.4.0.vsix --force
|
||||
```
|
||||
|
||||
也可以在 **Output → Codex Remote Collaboration** 中确认没有
|
||||
`Cannot find module 'ws'`;出现该错误时,说明扩展尚未成功激活。
|
||||
|
||||
网页现在按官方 Codex Webview 的会话模型展示:历史和实时输出在中间消息流,用户、
|
||||
助手、reasoning、命令输出分别投影为对应的消息项;助手内容支持安全的 Markdown、
|
||||
代码块和复制操作,reasoning/命令活动可折叠。底部 composer 使用可编辑富文本区域,
|
||||
回车发送、Shift+Enter 换行;审批和用户输入会以内嵌 card 出现在会话流中,支持风险
|
||||
标记、输入控件、授权范围和明确的允许/拒绝动作。附着适配器会额外发送可选的
|
||||
`messages` 角色投影,旧版 host 没有该字段时网页仍回退到纯文本快照。
|
||||
|
||||
页面打开后自动连接并在断线后重连,不再需要手动点击“连接”或“断开”。同步模式下
|
||||
会话列表、返回历史和新建入口会被禁用,所有输入都发送到 VS Code 当前会话。这里复刻的是从本机已安装
|
||||
官方 bundle 审计出的布局、状态和交互;官方 bundle 依赖 VS Code 私有 Webview API,
|
||||
不能安全地直接作为 iframe 嵌入浏览器。
|
||||
|
||||
底部的“同步 / 异步”分段控件发送 `control/mode/set`。当前 turn 正在执行或存在待处理
|
||||
授权、用户输入时,主机拒绝切换;候选适配器启动失败时保留原模式和原会话。切入异步
|
||||
模式后,页面顶部会恢复会话历史、新建和选择入口;`session/list` 映射到
|
||||
`thread/list`,选择会话使用 `thread/resume` 并水合完整历史,新建会话使用
|
||||
`thread/start`。切回同步模式会关闭独立 app-server,并重新以 VS Code 面板为唯一
|
||||
会话导航来源。
|
||||
|
||||
### 认证(可选)
|
||||
|
||||
如果以后需要保护 relay,可显式开启认证;本机流程默认不需要这些变量:
|
||||
|
||||
```sh
|
||||
CODEX_REMOTE_AUTH=required \
|
||||
CODEX_REMOTE_HOST_TOKEN='host-only-secret' \
|
||||
CODEX_REMOTE_TOKEN='browser-operator-secret' \
|
||||
CODEX_REMOTE_VIEW_TOKEN='browser-viewer-secret' \
|
||||
CODEX_REMOTE_MODE=host npm start
|
||||
```
|
||||
|
||||
认证开启后,Host token 填在 VS Code 扩展中,Operator/Viewer token 填在浏览器中。
|
||||
|
||||
## `spawn codex ENOENT` 是什么
|
||||
|
||||
这个错误只表示某处正在尝试启动**独立**的 `codex app-server`,但 VS Code 图形
|
||||
进程的 `PATH` 找不到可执行文件。对于本项目默认的同步模式,不会调用
|
||||
`spawn codex`,因此不需要通过设置 `codexCommand` 来修复它。
|
||||
|
||||
只有切换到异步模式(或仍使用旧版兼容设置)才需要独立可执行文件:
|
||||
|
||||
```json
|
||||
"codexRemoteCollab.controlMode": "async"
|
||||
```
|
||||
|
||||
扩展会优先解析 `codexRemoteCollab.codexCommand`,并可回退到官方 Codex 扩展内置的
|
||||
可执行文件;`codexRemoteCollab.codexArgs` 默认是 `["app-server", "--stdio"]`。
|
||||
旧 `mode=attach/spawn` 会分别迁移为 `sync/async`。
|
||||
|
||||
## Relay 模式
|
||||
|
||||
### `host`(推荐)
|
||||
|
||||
relay 只负责认证、事件缓存和转发;VS Code 扩展通过私有 IPC 附着官方 Codex
|
||||
会话。必须先打开目标会话;本机 loopback 默认不需要 host token,只有显式开启认证时
|
||||
才把 host token 提供给扩展。
|
||||
|
||||
### `embedded`(旧的独立进程模式)
|
||||
|
||||
只有显式设置 `CODEX_REMOTE_MODE=embedded` 时,relay 才会启动自己的
|
||||
`codex app-server --stdio`,适合测试页面和公开 app-server 协议;它与 VS Code
|
||||
当前会话无关:
|
||||
|
||||
```sh
|
||||
CODEX_REMOTE_MODE=embedded CODEX_CWD="$PWD" npm start
|
||||
```
|
||||
|
||||
`CODEX_BIN` 可指定独立进程的可执行文件;`CODEX_ARGS_JSON` 可覆盖其参数。不要
|
||||
把这些设置误认为 attach 模式的必要配置。
|
||||
|
||||
## HTTP API
|
||||
|
||||
认证开启时,除 `/api/health` 外的 `/api/*` 都需要
|
||||
`Authorization: Bearer <operator-or-viewer-token>` 或 `X-Codex-Token`;本机免认证
|
||||
模式下 loopback 请求直接作为 operator 处理。
|
||||
|
||||
```text
|
||||
GET /api/health
|
||||
GET /api/state
|
||||
GET /api/events?fromSeq=0
|
||||
POST /api/command {"commandId":"...","method":"turn/start","params":{...}}
|
||||
POST /api/respond {"requestId":"...","result":{...}}
|
||||
```
|
||||
|
||||
host 模式下,同步控制会拒绝 `thread/start` 和网页会话导航;异步控制会把它们转给
|
||||
独立 app-server。浏览器使用 `threadId` 发送 `turn/start`、`turn/steer` 或
|
||||
`turn/interrupt`。认证开启时写操作和
|
||||
响应请求必须使用 operator token;本机免认证模式下 loopback operator 可直接操作。
|
||||
|
||||
## 私有协议和限制
|
||||
|
||||
- IPC follower 协议是官方 VS Code 扩展的私有、带版本号实现,不是公开 API;官方
|
||||
扩展升级后可能需要同步适配。启用 `codexRemoteCollab.ipcStrictVersions`
|
||||
时,未知 stream 版本会让连接报错而不是猜测执行。
|
||||
- 自动发现只把本地 rollout 元数据当作候选,最终仍通过 IPC owner discovery
|
||||
验证;生产或多会话场景建议设置明确的 `threadId`。
|
||||
- relay 默认只监听 loopback,且 loopback 默认免认证;这意味着同一台机器上能访问
|
||||
loopback 的本地进程都可能控制会话,不要把它反向代理或暴露到外部。如果开启 token
|
||||
认证,token 是 bearer secret。高风险授权默认被 host policy 拒绝,只有显式设置
|
||||
`codexRemoteCollab.allowHighRiskApprovals=true` 才允许。
|
||||
- 输出会做常见 token/密码脱敏,但不能识别所有秘密;不要把凭据发送给 Codex。
|
||||
- 当前 UI 控制一个 host 会话,不提供多人同时编辑或文件同步。
|
||||
|
||||
## 测试
|
||||
|
||||
根目录测试使用假的 stdio app-server,不会向真实 Codex 发送任务:
|
||||
|
||||
```sh
|
||||
npm test
|
||||
cd vscode-extension && npm run check && npm run build
|
||||
```
|
||||
|
||||
要验证真实附着,只读地打开官方 VS Code 会话后启动 bridge;不要在验证脚本中
|
||||
调用 `turn/start`,除非你确实要向该会话发送任务。
|
||||
@@ -0,0 +1,727 @@
|
||||
"use strict";
|
||||
|
||||
const crypto = require("node:crypto");
|
||||
const fs = require("node:fs");
|
||||
const http = require("node:http");
|
||||
const net = require("node:net");
|
||||
const path = require("node:path");
|
||||
const { URL } = require("node:url");
|
||||
const { WebSocket, WebSocketServer } = require("ws");
|
||||
|
||||
const { CodexRelay } = require("../relay/server.js");
|
||||
|
||||
const MAX_JSON_BYTES = 64 * 1024;
|
||||
const MAX_WS_BYTES = 16 * 1024 * 1024;
|
||||
const DEFAULT_PAIRING_TTL_MS = 10 * 60 * 1000;
|
||||
const DEFAULT_TICKET_TTL_MS = 60 * 1000;
|
||||
const DEFAULT_ROOM_IDLE_MS = 30 * 60 * 1000;
|
||||
|
||||
class DeviceStore {
|
||||
constructor(filePath) {
|
||||
this.filePath = filePath;
|
||||
this.data = { version: 1, devices: [] };
|
||||
this.load();
|
||||
}
|
||||
|
||||
load() {
|
||||
try {
|
||||
const parsed = JSON.parse(fs.readFileSync(this.filePath, "utf8"));
|
||||
if (parsed?.version !== 1 || !Array.isArray(parsed.devices)) throw new Error("unsupported device store format");
|
||||
this.data = parsed;
|
||||
} catch (error) {
|
||||
if (error?.code !== "ENOENT") throw error;
|
||||
fs.mkdirSync(path.dirname(this.filePath), { recursive: true, mode: 0o700 });
|
||||
this.persist();
|
||||
}
|
||||
}
|
||||
|
||||
list(userId, connectedDeviceIds = new Set()) {
|
||||
return this.data.devices
|
||||
.filter((device) => device.user_id === userId && !device.revoked_at)
|
||||
.map((device) => publicDevice(device, connectedDeviceIds.has(device.id)));
|
||||
}
|
||||
|
||||
create(userId, name) {
|
||||
const id = crypto.randomUUID();
|
||||
const secret = crypto.randomBytes(32).toString("base64url");
|
||||
const salt = crypto.randomBytes(16).toString("base64url");
|
||||
const now = new Date().toISOString();
|
||||
const device = {
|
||||
id,
|
||||
user_id: userId,
|
||||
name: normalizeName(name),
|
||||
secret_salt: salt,
|
||||
secret_hash: deriveSecret(secret, salt),
|
||||
created_at: now,
|
||||
last_seen_at: null,
|
||||
revoked_at: null,
|
||||
};
|
||||
this.data.devices.push(device);
|
||||
this.persist();
|
||||
return { device: publicDevice(device, false), token: `avx1.${id}.${secret}` };
|
||||
}
|
||||
|
||||
authenticate(token) {
|
||||
const parsed = parseDeviceToken(token);
|
||||
if (!parsed) return null;
|
||||
const device = this.data.devices.find((candidate) => candidate.id === parsed.id && !candidate.revoked_at);
|
||||
if (!device) return null;
|
||||
const actual = Buffer.from(deriveSecret(parsed.secret, device.secret_salt), "base64url");
|
||||
const expected = Buffer.from(device.secret_hash, "base64url");
|
||||
if (actual.length !== expected.length || !crypto.timingSafeEqual(actual, expected)) return null;
|
||||
return device;
|
||||
}
|
||||
|
||||
get(userId, deviceId) {
|
||||
return this.data.devices.find((device) => device.user_id === userId && device.id === deviceId && !device.revoked_at) || null;
|
||||
}
|
||||
|
||||
touch(deviceId) {
|
||||
const device = this.data.devices.find((candidate) => candidate.id === deviceId && !candidate.revoked_at);
|
||||
if (!device) return;
|
||||
device.last_seen_at = new Date().toISOString();
|
||||
this.persist();
|
||||
}
|
||||
|
||||
revoke(userId, deviceId) {
|
||||
const device = this.get(userId, deviceId);
|
||||
if (!device) return false;
|
||||
device.revoked_at = new Date().toISOString();
|
||||
this.persist();
|
||||
return true;
|
||||
}
|
||||
|
||||
persist() {
|
||||
fs.mkdirSync(path.dirname(this.filePath), { recursive: true, mode: 0o700 });
|
||||
const temporary = `${this.filePath}.${process.pid}.${crypto.randomBytes(4).toString("hex")}.tmp`;
|
||||
fs.writeFileSync(temporary, `${JSON.stringify(this.data, null, 2)}\n`, { mode: 0o600 });
|
||||
fs.renameSync(temporary, this.filePath);
|
||||
}
|
||||
}
|
||||
|
||||
class EphemeralCredentials {
|
||||
constructor(options = {}) {
|
||||
this.pairingTtlMs = options.pairingTtlMs || DEFAULT_PAIRING_TTL_MS;
|
||||
this.ticketTtlMs = options.ticketTtlMs || DEFAULT_TICKET_TTL_MS;
|
||||
this.pairings = new Map();
|
||||
this.tickets = new Map();
|
||||
}
|
||||
|
||||
createPairing(userId, requestedName) {
|
||||
const code = pairingCode();
|
||||
const record = {
|
||||
id: crypto.randomUUID(),
|
||||
code,
|
||||
user_id: userId,
|
||||
requested_name: normalizeName(requestedName),
|
||||
expires_at_ms: Date.now() + this.pairingTtlMs,
|
||||
};
|
||||
this.pairings.set(normalizePairingCode(code), record);
|
||||
return record;
|
||||
}
|
||||
|
||||
consumePairing(code) {
|
||||
const key = normalizePairingCode(code);
|
||||
const record = this.pairings.get(key);
|
||||
this.pairings.delete(key);
|
||||
if (!record || record.expires_at_ms <= Date.now()) return null;
|
||||
return record;
|
||||
}
|
||||
|
||||
createTicket(userId, deviceId) {
|
||||
const ticket = `avt1.${crypto.randomBytes(32).toString("base64url")}`;
|
||||
this.tickets.set(ticket, {
|
||||
user_id: userId,
|
||||
device_id: deviceId,
|
||||
expires_at_ms: Date.now() + this.ticketTtlMs,
|
||||
});
|
||||
return ticket;
|
||||
}
|
||||
|
||||
consumeTicket(ticket) {
|
||||
const record = this.tickets.get(ticket);
|
||||
this.tickets.delete(ticket);
|
||||
if (!record || record.expires_at_ms <= Date.now()) return null;
|
||||
return record;
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
const now = Date.now();
|
||||
for (const [key, record] of this.pairings) if (record.expires_at_ms <= now) this.pairings.delete(key);
|
||||
for (const [key, record] of this.tickets) if (record.expires_at_ms <= now) this.tickets.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
class RoomManager {
|
||||
constructor(options = {}) {
|
||||
this.rooms = new Map();
|
||||
this.pendingRooms = new Map();
|
||||
this.revokedRoomKeys = new Set();
|
||||
this.idleMs = options.idleMs || DEFAULT_ROOM_IDLE_MS;
|
||||
}
|
||||
|
||||
key(userId, deviceId) {
|
||||
return `${encodeURIComponent(userId)}:${deviceId}`;
|
||||
}
|
||||
|
||||
async get(userId, deviceId) {
|
||||
const key = this.key(userId, deviceId);
|
||||
if (this.revokedRoomKeys.has(key)) throw httpError(401, "device revoked");
|
||||
let room = this.rooms.get(key);
|
||||
if (!room && this.pendingRooms.has(key)) room = await this.pendingRooms.get(key);
|
||||
if (!room) {
|
||||
const creating = this.createRoom(key, userId, deviceId);
|
||||
this.pendingRooms.set(key, creating);
|
||||
try {
|
||||
room = await creating;
|
||||
} finally {
|
||||
this.pendingRooms.delete(key);
|
||||
}
|
||||
}
|
||||
if (this.revokedRoomKeys.has(key)) throw httpError(401, "device revoked");
|
||||
room.lastActiveMs = Date.now();
|
||||
return room;
|
||||
}
|
||||
|
||||
async createRoom(key, userId, deviceId) {
|
||||
const hostToken = randomToken();
|
||||
const operatorToken = randomToken();
|
||||
const relay = new CodexRelay({
|
||||
host: "127.0.0.1",
|
||||
port: 0,
|
||||
mode: "host",
|
||||
authRequired: true,
|
||||
hostToken,
|
||||
operatorToken,
|
||||
viewerToken: randomToken(),
|
||||
});
|
||||
await relay.start();
|
||||
if (this.revokedRoomKeys.has(key)) {
|
||||
await relay.stop().catch(() => undefined);
|
||||
throw httpError(401, "device revoked");
|
||||
}
|
||||
const address = relay.address();
|
||||
const room = {
|
||||
key,
|
||||
userId,
|
||||
deviceId,
|
||||
relay,
|
||||
hostToken,
|
||||
operatorToken,
|
||||
baseUrl: `ws://127.0.0.1:${address.port}`,
|
||||
connections: 0,
|
||||
lastActiveMs: Date.now(),
|
||||
};
|
||||
this.rooms.set(key, room);
|
||||
return room;
|
||||
}
|
||||
|
||||
connectedDeviceIds(userId) {
|
||||
return new Set([...this.rooms.values()]
|
||||
.filter((room) => room.userId === userId && room.relay.state.hostConnected)
|
||||
.map((room) => room.deviceId));
|
||||
}
|
||||
|
||||
retain(room) {
|
||||
room.connections += 1;
|
||||
room.lastActiveMs = Date.now();
|
||||
}
|
||||
|
||||
release(room) {
|
||||
room.connections = Math.max(0, room.connections - 1);
|
||||
room.lastActiveMs = Date.now();
|
||||
}
|
||||
|
||||
async cleanup() {
|
||||
const now = Date.now();
|
||||
for (const [key, room] of this.rooms) {
|
||||
if (room.connections > 0 || now - room.lastActiveMs < this.idleMs) continue;
|
||||
this.rooms.delete(key);
|
||||
await room.relay.stop();
|
||||
}
|
||||
}
|
||||
|
||||
async revoke(userId, deviceId) {
|
||||
const key = this.key(userId, deviceId);
|
||||
this.revokedRoomKeys.add(key);
|
||||
const pending = this.pendingRooms.get(key);
|
||||
if (pending) await pending.catch(() => undefined);
|
||||
const room = this.rooms.get(key);
|
||||
if (!room) return;
|
||||
this.rooms.delete(key);
|
||||
await room.relay.stop();
|
||||
}
|
||||
|
||||
async stop() {
|
||||
await Promise.allSettled([...this.pendingRooms.values()]);
|
||||
this.pendingRooms.clear();
|
||||
const rooms = [...this.rooms.values()];
|
||||
this.rooms.clear();
|
||||
this.revokedRoomKeys.clear();
|
||||
await Promise.allSettled(rooms.map((room) => room.relay.stop()));
|
||||
}
|
||||
}
|
||||
|
||||
class AetherVscodexCloudServer {
|
||||
constructor(options = {}) {
|
||||
this.host = options.host || process.env.HOST || "127.0.0.1";
|
||||
this.port = parsePort(options.port ?? process.env.PORT, 8788);
|
||||
this.internalToken = options.internalToken || process.env.AETHER_VSCODEX_INTERNAL_TOKEN || "";
|
||||
this.publicWsUrl = options.publicWsUrl || process.env.AETHER_VSCODEX_PUBLIC_WS_URL || "";
|
||||
this.allowedOrigins = normalizeOrigins(options.allowedOrigins ?? process.env.AETHER_VSCODEX_ALLOWED_ORIGINS);
|
||||
const dataDir = options.dataDir || process.env.AETHER_VSCODEX_DATA_DIR || path.join(process.cwd(), "data");
|
||||
this.store = options.store || new DeviceStore(path.join(dataDir, "devices.json"));
|
||||
this.credentials = options.credentials || new EphemeralCredentials(options);
|
||||
this.rooms = options.rooms || new RoomManager(options);
|
||||
this.exchangeAttempts = new Map();
|
||||
this.httpServer = null;
|
||||
this.wsServer = null;
|
||||
this.cleanupTimer = null;
|
||||
}
|
||||
|
||||
async start() {
|
||||
if (!this.internalToken) throw new Error("AETHER_VSCODEX_INTERNAL_TOKEN is required");
|
||||
if (Buffer.byteLength(this.internalToken, "utf8") < 24) throw new Error("AETHER_VSCODEX_INTERNAL_TOKEN must contain at least 24 bytes");
|
||||
if (!this.publicWsUrl) throw new Error("AETHER_VSCODEX_PUBLIC_WS_URL is required");
|
||||
validatePublicWsUrl(this.publicWsUrl);
|
||||
if (!isLoopbackHost(this.host) && this.allowedOrigins.size === 0) {
|
||||
throw new Error("AETHER_VSCODEX_ALLOWED_ORIGINS is required when binding outside loopback");
|
||||
}
|
||||
this.httpServer = http.createServer((request, response) => {
|
||||
void this.handleHttp(request, response).catch((error) => {
|
||||
jsonResponse(response, error.statusCode || 500, { error: error.expose ? error.message : "internal server error" });
|
||||
});
|
||||
});
|
||||
this.wsServer = new WebSocketServer({ noServer: true, maxPayload: MAX_WS_BYTES });
|
||||
this.httpServer.on("upgrade", (request, socket, head) => this.handleUpgrade(request, socket, head));
|
||||
this.cleanupTimer = setInterval(() => {
|
||||
this.credentials.cleanup();
|
||||
this.cleanupExchangeAttempts();
|
||||
void this.rooms.cleanup();
|
||||
}, 30_000);
|
||||
this.cleanupTimer.unref();
|
||||
await new Promise((resolve, reject) => {
|
||||
const onError = (error) => reject(error);
|
||||
this.httpServer.once("error", onError);
|
||||
this.httpServer.listen(this.port, this.host, () => {
|
||||
this.httpServer.off("error", onError);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
return this.address();
|
||||
}
|
||||
|
||||
address() {
|
||||
const address = this.httpServer.address();
|
||||
if (!address || typeof address === "string") return { host: this.host, port: this.port };
|
||||
return { host: address.address, port: address.port };
|
||||
}
|
||||
|
||||
async stop() {
|
||||
if (this.cleanupTimer) clearInterval(this.cleanupTimer);
|
||||
this.cleanupTimer = null;
|
||||
if (this.wsServer) {
|
||||
for (const client of this.wsServer.clients) client.close(1001, "server shutting down");
|
||||
await new Promise((resolve) => this.wsServer.close(() => resolve()));
|
||||
}
|
||||
if (this.httpServer) await new Promise((resolve) => this.httpServer.close(() => resolve()));
|
||||
this.wsServer = null;
|
||||
this.httpServer = null;
|
||||
await this.rooms.stop();
|
||||
}
|
||||
|
||||
async handleHttp(request, response) {
|
||||
const requestUrl = new URL(request.url || "/", "http://sidecar.local");
|
||||
if (request.method === "GET" && requestUrl.pathname === "/healthz") {
|
||||
jsonResponse(response, 200, { ok: true, service: "aether-vscodex", mode: "single-replica" });
|
||||
return;
|
||||
}
|
||||
if (request.method === "POST" && requestUrl.pathname === "/v1/pairings/exchange") {
|
||||
this.enforceExchangeRate(request);
|
||||
const body = await readJson(request);
|
||||
const pairing = this.credentials.consumePairing(body.code);
|
||||
if (!pairing) throw httpError(400, "invalid or expired pairing code");
|
||||
const created = this.store.create(pairing.user_id, body.name || pairing.requested_name);
|
||||
jsonResponse(response, 201, {
|
||||
device_id: created.device.id,
|
||||
device_name: created.device.name,
|
||||
device_token: created.token,
|
||||
ws_url: this.publicWsUrl,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const match = requestUrl.pathname.match(/^\/internal\/v1\/users\/([^/]+)\/(devices|pairings|ws-tickets)(?:\/([^/]+))?$/);
|
||||
if (!match) {
|
||||
jsonResponse(response, 404, { error: "not found" });
|
||||
return;
|
||||
}
|
||||
this.requireInternalAuth(request);
|
||||
const userId = decodeURIComponent(match[1]);
|
||||
const resource = match[2];
|
||||
const resourceId = match[3] ? decodeURIComponent(match[3]) : null;
|
||||
if (!userId || userId.length > 256) throw httpError(400, "invalid user id");
|
||||
|
||||
if (request.method === "GET" && resource === "devices" && !resourceId) {
|
||||
jsonResponse(response, 200, { devices: this.store.list(userId, this.rooms.connectedDeviceIds(userId)) });
|
||||
return;
|
||||
}
|
||||
if (request.method === "POST" && resource === "pairings" && !resourceId) {
|
||||
const body = await readJson(request);
|
||||
const pairing = this.credentials.createPairing(userId, body.name);
|
||||
jsonResponse(response, 201, {
|
||||
pairing_id: pairing.id,
|
||||
code: pairing.code,
|
||||
expires_at: new Date(pairing.expires_at_ms).toISOString(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (request.method === "DELETE" && resource === "devices" && resourceId) {
|
||||
if (!this.store.revoke(userId, resourceId)) throw httpError(404, "device not found");
|
||||
await this.rooms.revoke(userId, resourceId);
|
||||
response.writeHead(204, { "Cache-Control": "no-store" });
|
||||
response.end();
|
||||
return;
|
||||
}
|
||||
if (request.method === "POST" && resource === "ws-tickets" && !resourceId) {
|
||||
const body = await readJson(request);
|
||||
const deviceId = typeof body.device_id === "string" ? body.device_id : "";
|
||||
if (!deviceId || !this.store.get(userId, deviceId)) throw httpError(404, "device not found");
|
||||
jsonResponse(response, 201, {
|
||||
ticket: this.credentials.createTicket(userId, deviceId),
|
||||
ws_url: "/api/vscodex/ws",
|
||||
expires_in: Math.floor(this.credentials.ticketTtlMs / 1000),
|
||||
});
|
||||
return;
|
||||
}
|
||||
jsonResponse(response, 405, { error: "method not allowed" }, { Allow: allowedMethod(resource, resourceId) });
|
||||
}
|
||||
|
||||
requireInternalAuth(request) {
|
||||
if (!this.hasInternalAuth(request)) throw httpError(401, "unauthorized");
|
||||
}
|
||||
|
||||
hasInternalAuth(request) {
|
||||
const authorization = String(request.headers.authorization || "");
|
||||
const token = authorization.startsWith("Bearer ") ? authorization.slice(7) : "";
|
||||
return secureEqual(token, this.internalToken);
|
||||
}
|
||||
|
||||
enforceExchangeRate(request) {
|
||||
const address = this.exchangeRateAddress(request);
|
||||
const now = Date.now();
|
||||
const attempts = (this.exchangeAttempts.get(address) || []).filter((time) => now - time < 60_000);
|
||||
if (attempts.length >= 10) throw httpError(429, "too many pairing attempts");
|
||||
attempts.push(now);
|
||||
this.exchangeAttempts.set(address, attempts);
|
||||
}
|
||||
|
||||
exchangeRateAddress(request) {
|
||||
if (this.hasInternalAuth(request)) {
|
||||
const forwardedAddress = singleHeaderValue(request, "x-aether-client-ip")?.trim();
|
||||
if (forwardedAddress && net.isIP(forwardedAddress)) return forwardedAddress;
|
||||
}
|
||||
return request.socket.remoteAddress || "unknown";
|
||||
}
|
||||
|
||||
cleanupExchangeAttempts() {
|
||||
const now = Date.now();
|
||||
for (const [address, attempts] of this.exchangeAttempts) {
|
||||
const active = attempts.filter((time) => now - time < 60_000);
|
||||
if (active.length) this.exchangeAttempts.set(address, active);
|
||||
else this.exchangeAttempts.delete(address);
|
||||
}
|
||||
}
|
||||
|
||||
handleUpgrade(request, socket, head) {
|
||||
const requestUrl = new URL(request.url || "/", "http://sidecar.local");
|
||||
if (requestUrl.pathname !== "/api/vscodex/ws" && requestUrl.pathname !== "/v1/connect") {
|
||||
rejectUpgrade(socket, 404, "Not Found");
|
||||
return;
|
||||
}
|
||||
const origin = request.headers.origin;
|
||||
if (origin && this.allowedOrigins.size > 0 && !this.allowedOrigins.has(normalizeOrigin(origin))) {
|
||||
rejectUpgrade(socket, 403, "Forbidden");
|
||||
return;
|
||||
}
|
||||
this.wsServer.handleUpgrade(request, socket, head, (webSocket) => {
|
||||
this.wsServer.emit("connection", webSocket, request);
|
||||
this.handleWebSocket(webSocket, request);
|
||||
});
|
||||
}
|
||||
|
||||
handleWebSocket(socket, request) {
|
||||
let hello = null;
|
||||
let token = "";
|
||||
let upstream = null;
|
||||
let authenticating = false;
|
||||
let room = null;
|
||||
const queued = [];
|
||||
const authTimer = setTimeout(() => socket.close(1008, "authentication required"), 10_000);
|
||||
authTimer.unref();
|
||||
|
||||
const connectUpstream = async () => {
|
||||
if (authenticating || upstream || !hello || !token) return;
|
||||
authenticating = true;
|
||||
let identity;
|
||||
let upstreamToken;
|
||||
if (hello.clientType === "host") {
|
||||
const device = this.store.authenticate(token);
|
||||
if (!device) throw httpError(401, "invalid device credential");
|
||||
identity = { userId: device.user_id, deviceId: device.id };
|
||||
room = await this.rooms.get(identity.userId, identity.deviceId);
|
||||
upstreamToken = room.hostToken;
|
||||
this.store.touch(device.id);
|
||||
} else {
|
||||
const ticket = this.credentials.consumeTicket(token);
|
||||
if (!ticket || !this.store.get(ticket.user_id, ticket.device_id)) throw httpError(401, "invalid or expired browser ticket");
|
||||
identity = { userId: ticket.user_id, deviceId: ticket.device_id };
|
||||
room = await this.rooms.get(identity.userId, identity.deviceId);
|
||||
upstreamToken = room.operatorToken;
|
||||
}
|
||||
this.rooms.retain(room);
|
||||
upstream = new WebSocket(`${room.baseUrl}${hello.clientType === "host" ? "/v1/connect" : "/ws"}`, {
|
||||
maxPayload: MAX_WS_BYTES,
|
||||
});
|
||||
upstream.once("open", () => {
|
||||
if (socket.readyState !== WebSocket.OPEN) {
|
||||
upstream.close();
|
||||
return;
|
||||
}
|
||||
upstream.send(JSON.stringify(hello));
|
||||
upstream.send(JSON.stringify(hello.clientType === "host"
|
||||
? { v: 1, kind: "auth", accessToken: upstreamToken }
|
||||
: { type: "auth", token: upstreamToken }));
|
||||
for (const frame of queued.splice(0)) upstream.send(frame);
|
||||
});
|
||||
upstream.on("message", (data, isBinary) => {
|
||||
if (socket.readyState === WebSocket.OPEN) socket.send(data, { binary: isBinary });
|
||||
});
|
||||
upstream.on("close", (code, reason) => {
|
||||
if (socket.readyState === WebSocket.OPEN) socket.close(validCloseCode(code) ? code : 1011, reason.toString().slice(0, 120) || "relay closed");
|
||||
});
|
||||
upstream.on("error", () => {
|
||||
if (socket.readyState === WebSocket.OPEN) socket.close(1011, "relay unavailable");
|
||||
});
|
||||
clearTimeout(authTimer);
|
||||
};
|
||||
|
||||
socket.on("message", (data, isBinary) => {
|
||||
if (isBinary) {
|
||||
socket.close(1003, "JSON text frames only");
|
||||
return;
|
||||
}
|
||||
if (upstream) {
|
||||
const text = data.toString("utf8");
|
||||
if (upstream.readyState === WebSocket.OPEN) upstream.send(text);
|
||||
else queued.push(text);
|
||||
return;
|
||||
}
|
||||
let message;
|
||||
try {
|
||||
message = JSON.parse(data.toString("utf8"));
|
||||
} catch {
|
||||
socket.close(1007, "invalid JSON");
|
||||
return;
|
||||
}
|
||||
if (message?.kind === "hello") {
|
||||
if (Number(message.protocol || 1) !== 1) {
|
||||
socket.close(1002, "unsupported protocol");
|
||||
return;
|
||||
}
|
||||
hello = {
|
||||
v: 1,
|
||||
kind: "hello",
|
||||
clientType: message.clientType === "host" ? "host" : "web",
|
||||
protocol: 1,
|
||||
...(typeof message.sessionId === "string" ? { sessionId: message.sessionId } : {}),
|
||||
...(Number.isFinite(Number(message.lastSeq)) ? { lastSeq: Number(message.lastSeq) } : {}),
|
||||
};
|
||||
} else if (message?.kind === "auth" || message?.type === "auth") {
|
||||
token = typeof message.accessToken === "string" ? message.accessToken : typeof message.token === "string" ? message.token : "";
|
||||
} else {
|
||||
socket.close(1002, "hello and auth required");
|
||||
return;
|
||||
}
|
||||
void connectUpstream().catch(() => socket.close(1008, "authentication failed"));
|
||||
});
|
||||
socket.on("close", () => {
|
||||
clearTimeout(authTimer);
|
||||
if (upstream && upstream.readyState < WebSocket.CLOSING) upstream.close();
|
||||
if (room) this.rooms.release(room);
|
||||
});
|
||||
socket.on("error", () => {});
|
||||
}
|
||||
}
|
||||
|
||||
function parseDeviceToken(token) {
|
||||
const match = /^avx1\.([0-9a-f-]{36})\.([A-Za-z0-9_-]{32,})$/.exec(String(token || ""));
|
||||
return match ? { id: match[1], secret: match[2] } : null;
|
||||
}
|
||||
|
||||
function deriveSecret(secret, salt) {
|
||||
return crypto.scryptSync(secret, Buffer.from(salt, "base64url"), 32).toString("base64url");
|
||||
}
|
||||
|
||||
function publicDevice(device, connected) {
|
||||
return {
|
||||
id: device.id,
|
||||
name: device.name,
|
||||
connected,
|
||||
created_at: device.created_at,
|
||||
last_seen_at: device.last_seen_at,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeName(value) {
|
||||
const name = typeof value === "string" ? value.trim().replace(/\s+/g, " ").slice(0, 80) : "";
|
||||
return name || "VS Code";
|
||||
}
|
||||
|
||||
function pairingCode() {
|
||||
const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
||||
const bytes = crypto.randomBytes(8);
|
||||
let result = "";
|
||||
for (let index = 0; index < 8; index += 1) result += alphabet[bytes[index] % alphabet.length];
|
||||
return `${result.slice(0, 4)}-${result.slice(4)}`;
|
||||
}
|
||||
|
||||
function normalizePairingCode(value) {
|
||||
return String(value || "").toUpperCase().replace(/[^A-Z2-9]/g, "");
|
||||
}
|
||||
|
||||
function randomToken() {
|
||||
return crypto.randomBytes(32).toString("base64url");
|
||||
}
|
||||
|
||||
function secureEqual(left, right) {
|
||||
const a = Buffer.from(String(left || ""));
|
||||
const b = Buffer.from(String(right || ""));
|
||||
return a.length === b.length && crypto.timingSafeEqual(a, b);
|
||||
}
|
||||
|
||||
function singleHeaderValue(request, name) {
|
||||
const distinctValues = request.headersDistinct?.[name];
|
||||
if (Array.isArray(distinctValues)) return distinctValues.length === 1 ? distinctValues[0] : null;
|
||||
const value = request.headers[name];
|
||||
return typeof value === "string" ? value : null;
|
||||
}
|
||||
|
||||
function parsePort(value, fallback) {
|
||||
const parsed = Number(value ?? fallback);
|
||||
if (!Number.isInteger(parsed) || parsed < 0 || parsed > 65535) throw new Error("invalid port");
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function normalizeOrigins(value) {
|
||||
const values = Array.isArray(value) ? value : String(value || "").split(",");
|
||||
return new Set(values.map(normalizeOrigin).filter(Boolean));
|
||||
}
|
||||
|
||||
function normalizeOrigin(value) {
|
||||
try {
|
||||
return new URL(String(value).trim()).origin.toLowerCase();
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function validatePublicWsUrl(value) {
|
||||
let url;
|
||||
try {
|
||||
url = new URL(value);
|
||||
} catch {
|
||||
throw new Error("AETHER_VSCODEX_PUBLIC_WS_URL must be an absolute WebSocket URL");
|
||||
}
|
||||
if (url.protocol !== "wss:" && !(url.protocol === "ws:" && isLoopbackHost(url.hostname))) {
|
||||
throw new Error("AETHER_VSCODEX_PUBLIC_WS_URL must use wss:// outside loopback");
|
||||
}
|
||||
}
|
||||
|
||||
function isLoopbackHost(value) {
|
||||
const host = String(value || "").replace(/^\[|\]$/g, "").toLowerCase();
|
||||
return host === "127.0.0.1" || host === "localhost" || host === "::1";
|
||||
}
|
||||
|
||||
function validCloseCode(code) {
|
||||
return code === 1000 || (code >= 1001 && code <= 1014 && ![1004, 1005, 1006].includes(code)) || (code >= 3000 && code <= 4999);
|
||||
}
|
||||
|
||||
function readJson(request) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let size = 0;
|
||||
const chunks = [];
|
||||
request.on("data", (chunk) => {
|
||||
size += chunk.length;
|
||||
if (size > MAX_JSON_BYTES) {
|
||||
reject(httpError(413, "request body too large"));
|
||||
request.destroy();
|
||||
return;
|
||||
}
|
||||
chunks.push(chunk);
|
||||
});
|
||||
request.on("end", () => {
|
||||
try {
|
||||
const value = JSON.parse(Buffer.concat(chunks).toString("utf8") || "{}");
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error();
|
||||
resolve(value);
|
||||
} catch {
|
||||
reject(httpError(400, "invalid JSON body"));
|
||||
}
|
||||
});
|
||||
request.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
function jsonResponse(response, statusCode, body, extraHeaders = {}) {
|
||||
if (response.headersSent) return;
|
||||
const payload = Buffer.from(JSON.stringify(body));
|
||||
response.writeHead(statusCode, {
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
"Content-Length": payload.length,
|
||||
"Cache-Control": "no-store",
|
||||
...extraHeaders,
|
||||
});
|
||||
response.end(payload);
|
||||
}
|
||||
|
||||
function rejectUpgrade(socket, status, reason) {
|
||||
socket.write(`HTTP/1.1 ${status} ${reason}\r\nConnection: close\r\n\r\n`);
|
||||
socket.destroy();
|
||||
}
|
||||
|
||||
function httpError(statusCode, message) {
|
||||
return Object.assign(new Error(message), { statusCode, expose: statusCode < 500 });
|
||||
}
|
||||
|
||||
function allowedMethod(resource, resourceId) {
|
||||
if (resource === "devices" && resourceId) return "DELETE";
|
||||
if (resource === "devices") return "GET";
|
||||
return "POST";
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const server = new AetherVscodexCloudServer();
|
||||
const address = await server.start();
|
||||
process.stdout.write(`Aether VS Codex sidecar listening on ${address.host}:${address.port}\n`);
|
||||
const shutdown = async () => {
|
||||
await server.stop();
|
||||
process.exit(0);
|
||||
};
|
||||
process.once("SIGINT", shutdown);
|
||||
process.once("SIGTERM", shutdown);
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
main().catch((error) => {
|
||||
process.stderr.write(`${error.stack || error}\n`);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
AetherVscodexCloudServer,
|
||||
DeviceStore,
|
||||
EphemeralCredentials,
|
||||
RoomManager,
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
services:
|
||||
app:
|
||||
environment:
|
||||
AETHER_VSCODEX_ENABLED: "true"
|
||||
AETHER_VSCODEX_INTERNAL_URL: http://vscodex:8788
|
||||
AETHER_VSCODEX_INTERNAL_TOKEN: ${AETHER_VSCODEX_INTERNAL_TOKEN:?set AETHER_VSCODEX_INTERNAL_TOKEN}
|
||||
AETHER_VSCODEX_PUBLIC_WS_URL: ${AETHER_VSCODEX_PUBLIC_WS_URL:?set AETHER_VSCODEX_PUBLIC_WS_URL}
|
||||
depends_on:
|
||||
vscodex:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- ./aether-vscodex/web/dist:/opt/aether/releases/image/frontend/aether-vscodex:ro
|
||||
|
||||
vscodex:
|
||||
build:
|
||||
context: ./aether-vscodex
|
||||
image: ${AETHER_VSCODEX_IMAGE:-aether-vscodex:local}
|
||||
environment:
|
||||
HOST: 0.0.0.0
|
||||
PORT: 8788
|
||||
AETHER_VSCODEX_INTERNAL_TOKEN: ${AETHER_VSCODEX_INTERNAL_TOKEN:?set AETHER_VSCODEX_INTERNAL_TOKEN}
|
||||
AETHER_VSCODEX_PUBLIC_WS_URL: ${AETHER_VSCODEX_PUBLIC_WS_URL:?set AETHER_VSCODEX_PUBLIC_WS_URL}
|
||||
AETHER_VSCODEX_ALLOWED_ORIGINS: ${AETHER_VSCODEX_ALLOWED_ORIGINS:?set AETHER_VSCODEX_ALLOWED_ORIGINS}
|
||||
AETHER_VSCODEX_DATA_DIR: /var/lib/aether-vscodex
|
||||
expose:
|
||||
- "8788"
|
||||
volumes:
|
||||
- vscodex_data:/var/lib/aether-vscodex
|
||||
logging:
|
||||
driver: local
|
||||
options:
|
||||
max-size: "50m"
|
||||
max-file: "3"
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
vscodex_data:
|
||||
@@ -0,0 +1,20 @@
|
||||
# Cloud security model
|
||||
|
||||
## Trust boundaries
|
||||
|
||||
- Aether authenticates browser HTTP requests and resolves the user ID. The client never supplies a trusted user ID.
|
||||
- The Node sidecar never receives an Aether access token or JWT signing key.
|
||||
- A VS Code installation receives one revocable device credential. Only its scrypt hash is persisted.
|
||||
- An iframe receives a random, one-time WebSocket ticket with a 60-second lifetime. Tickets are sent in an auth frame, never in a URL.
|
||||
- The embedded UI is trusted, same-origin Aether code. `allow-same-origin` is required by the current integration, so the iframe is not a sandbox boundary for untrusted content even though the parent does not post its JWT into the frame.
|
||||
- Relay state is isolated by `(user_id, device_id)`. A browser ticket and host credential must resolve to the same room.
|
||||
|
||||
## Network boundary
|
||||
|
||||
Run the sidecar on the private Compose network. Do not publish port 8788. Aether gateway is the only public HTTP and WebSocket entry point and authenticates internal API calls with `AETHER_VSCODEX_INTERNAL_TOKEN`.
|
||||
|
||||
`AETHER_VSCODEX_ALLOWED_ORIGINS` must contain the exact public Aether origin when the sidecar binds outside loopback. Public deployments must use HTTPS/WSS.
|
||||
|
||||
## Current scaling limit
|
||||
|
||||
The first release intentionally runs one sidecar replica. Pairing codes, browser tickets, and the live connection directory are process-local. Before adding replicas, move those records to a shared atomic store and add sticky or distributed WebSocket room routing.
|
||||
@@ -0,0 +1,59 @@
|
||||
"use strict";
|
||||
|
||||
const readline = require("node:readline");
|
||||
|
||||
let threadNumber = 0;
|
||||
let turnNumber = 0;
|
||||
let activeThread = null;
|
||||
let activeTurn = null;
|
||||
|
||||
function send(message) {
|
||||
process.stdout.write(`${JSON.stringify(message)}\n`);
|
||||
}
|
||||
|
||||
const input = readline.createInterface({ input: process.stdin });
|
||||
input.on("line", (line) => {
|
||||
let request;
|
||||
try { request = JSON.parse(line); } catch { return; }
|
||||
if (request.method === "initialize") {
|
||||
send({ id: request.id, result: { userAgent: "fake", codexHome: "/tmp/codex" } });
|
||||
send({ method: "remoteControl/status/changed", params: { status: "disabled" } });
|
||||
return;
|
||||
}
|
||||
if (request.method === "thread/start") {
|
||||
activeThread = `thread-${++threadNumber}`;
|
||||
send({ id: request.id, result: { thread: { id: activeThread }, cwd: request.params?.cwd || "/tmp" } });
|
||||
send({ method: "thread/started", params: { thread: { id: activeThread } } });
|
||||
return;
|
||||
}
|
||||
if (request.method === "turn/start") {
|
||||
activeTurn = `turn-${++turnNumber}`;
|
||||
send({ id: request.id, result: { turn: { id: activeTurn } } });
|
||||
send({ method: "turn/started", params: { threadId: request.params.threadId, turn: { id: activeTurn } } });
|
||||
const text = request.params.input?.[0]?.text || "";
|
||||
send({ method: "item/agentMessage/delta", params: { threadId: request.params.threadId, turnId: activeTurn, itemId: "item-1", delta: `echo: ${text}` } });
|
||||
if (text.includes("approve")) {
|
||||
send({ id: 9001, method: "item/commandExecution/requestApproval", params: { threadId: request.params.threadId, turnId: activeTurn, itemId: "item-2", command: "echo approval" } });
|
||||
} else {
|
||||
send({ method: "turn/completed", params: { threadId: request.params.threadId, turn: { id: activeTurn } } });
|
||||
activeTurn = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (request.method === "turn/steer") {
|
||||
send({ id: request.id, result: { turn: { id: activeTurn } } });
|
||||
send({ method: "item/agentMessage/delta", params: { delta: `steered: ${request.params.input?.[0]?.text || ""}` } });
|
||||
return;
|
||||
}
|
||||
if (request.method === "turn/interrupt") {
|
||||
send({ id: request.id, result: {} });
|
||||
send({ method: "turn/completed", params: { threadId: request.params.threadId, turn: { id: request.params.turnId } } });
|
||||
activeTurn = null;
|
||||
return;
|
||||
}
|
||||
if (request.id === 9001 && (request.result || request.error)) {
|
||||
send({ method: "item/agentMessage/delta", params: { delta: `approval response: ${JSON.stringify(request.result || request.error)}` } });
|
||||
send({ method: "turn/completed", params: { threadId: activeThread, turn: { id: activeTurn } } });
|
||||
activeTurn = null;
|
||||
}
|
||||
});
|
||||
Generated
+39
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"name": "aether-vscodex",
|
||||
"version": "0.4.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "aether-vscodex",
|
||||
"version": "0.4.0",
|
||||
"dependencies": {
|
||||
"ws": "^8.18.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.21.3",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz",
|
||||
"integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bufferutil": "^4.0.1",
|
||||
"utf-8-validate": ">=5.0.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bufferutil": {
|
||||
"optional": true
|
||||
},
|
||||
"utf-8-validate": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "aether-vscodex",
|
||||
"version": "0.4.0",
|
||||
"private": true,
|
||||
"description": "Synchronous VS Code Codex mirroring and asynchronous Codex control for local Web and Aether",
|
||||
"type": "commonjs",
|
||||
"main": "relay/server.js",
|
||||
"scripts": {
|
||||
"start": "node relay/server.js",
|
||||
"start:cloud": "node cloud/server.js",
|
||||
"build:web": "npm --prefix web run build",
|
||||
"build:extension": "npm --prefix vscode-extension run build",
|
||||
"build": "npm run build:web && npm run build:extension",
|
||||
"test": "node --test test/*.test.js",
|
||||
"test:web": "npm --prefix web test"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"dependencies": {
|
||||
"ws": "^8.18.3"
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,112 @@
|
||||
(function (root, factory) {
|
||||
"use strict";
|
||||
|
||||
const api = factory();
|
||||
if (typeof module === "object" && module.exports) module.exports = api;
|
||||
if (!root || !root.document) return;
|
||||
|
||||
const bridge = api.createAetherEmbedBridge(root);
|
||||
root.AetherVscodexEmbed = bridge;
|
||||
if (bridge.active) bridge.start();
|
||||
})(typeof window === "object" ? window : undefined, function () {
|
||||
"use strict";
|
||||
|
||||
const VERSION = 1;
|
||||
const PREFIX = "aether-vscodex/";
|
||||
const INBOUND_TYPES = new Set(["connect", "context", "disconnect", "error"]);
|
||||
|
||||
function isAetherEmbed(locationLike) {
|
||||
try {
|
||||
return new URLSearchParams(locationLike?.search || "").get("embed") === "aether";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeTheme(value) {
|
||||
const theme = String(value || "").trim().toLowerCase();
|
||||
return theme === "dark" || theme === "light" ? theme : "system";
|
||||
}
|
||||
|
||||
function createAetherEmbedBridge(windowLike) {
|
||||
const active = isAetherEmbed(windowLike.location);
|
||||
const listeners = new Map();
|
||||
const pending = new Map();
|
||||
let started = false;
|
||||
|
||||
const emit = (name, payload) => {
|
||||
for (const listener of listeners.get(name) || []) listener(payload);
|
||||
};
|
||||
|
||||
const post = (type, payload = {}) => {
|
||||
if (!active || windowLike.parent === windowLike) return false;
|
||||
windowLike.parent.postMessage({ v: VERSION, type: `${PREFIX}${type}`, ...payload }, windowLike.location.origin);
|
||||
return true;
|
||||
};
|
||||
|
||||
const applyContext = (payload) => {
|
||||
if (payload.locale && windowLike.VscodexI18n?.setLocale) {
|
||||
windowLike.VscodexI18n.setLocale(payload.locale, { persist: false });
|
||||
}
|
||||
const theme = normalizeTheme(payload.theme);
|
||||
const documentElement = windowLike.document?.documentElement;
|
||||
if (documentElement) {
|
||||
if (theme === "system") delete documentElement.dataset.theme;
|
||||
else documentElement.dataset.theme = theme;
|
||||
documentElement.style.colorScheme = theme === "system" ? "" : theme;
|
||||
}
|
||||
};
|
||||
|
||||
const handleMessage = (event) => {
|
||||
if (!active || event.origin !== windowLike.location.origin || event.source !== windowLike.parent) return;
|
||||
const message = event.data;
|
||||
if (!message || typeof message !== "object" || message.v !== VERSION || typeof message.type !== "string") return;
|
||||
if (!message.type.startsWith(PREFIX)) return;
|
||||
const name = message.type.slice(PREFIX.length);
|
||||
if (!INBOUND_TYPES.has(name)) return;
|
||||
if (name === "connect" || name === "context") applyContext(message);
|
||||
if (!(listeners.get(name)?.size)) pending.set(name, message);
|
||||
emit(name, message);
|
||||
};
|
||||
|
||||
return {
|
||||
active,
|
||||
version: VERSION,
|
||||
start() {
|
||||
if (!active || started) return;
|
||||
started = true;
|
||||
windowLike.document.body?.classList.add("embed-aether");
|
||||
windowLike.addEventListener("message", handleMessage);
|
||||
post("ready");
|
||||
},
|
||||
stop() {
|
||||
if (!started) return;
|
||||
started = false;
|
||||
windowLike.removeEventListener("message", handleMessage);
|
||||
listeners.clear();
|
||||
pending.clear();
|
||||
},
|
||||
on(name, listener) {
|
||||
if (!INBOUND_TYPES.has(name) || typeof listener !== "function") return () => undefined;
|
||||
if (!listeners.has(name)) listeners.set(name, new Set());
|
||||
listeners.get(name).add(listener);
|
||||
if (pending.has(name)) {
|
||||
const message = pending.get(name);
|
||||
pending.delete(name);
|
||||
listener(message);
|
||||
}
|
||||
return () => listeners.get(name)?.delete(listener);
|
||||
},
|
||||
post,
|
||||
requestTicket(payload = {}) {
|
||||
return post("request-ticket", payload);
|
||||
},
|
||||
reportState(state, payload = {}) {
|
||||
return post("state", { state, ...payload });
|
||||
},
|
||||
_handleMessage: handleMessage,
|
||||
};
|
||||
}
|
||||
|
||||
return { createAetherEmbedBridge, isAetherEmbed, normalizeTheme };
|
||||
});
|
||||
@@ -0,0 +1,541 @@
|
||||
(function (root, factory) {
|
||||
"use strict";
|
||||
|
||||
const api = factory(root);
|
||||
if (typeof module === "object" && module.exports) module.exports = api;
|
||||
if (root?.document) root.VscodexI18n = api;
|
||||
})(typeof window === "object" ? window : undefined, function (root) {
|
||||
"use strict";
|
||||
|
||||
const STORAGE_KEY = "aether-vscodex.locale";
|
||||
const SUPPORTED = new Set(["zh-CN", "en-US"]);
|
||||
const EN = Object.freeze({
|
||||
"本地模式": "Local mode",
|
||||
"独立模式": "Standalone mode",
|
||||
"云端模式": "Cloud mode",
|
||||
"控制模式": "Control mode",
|
||||
"同步": "Sync",
|
||||
"异步": "Async",
|
||||
"同步模式跟随 VS Code 当前会话": "Sync mode follows the current VS Code conversation",
|
||||
"异步模式可独立管理会话": "Async mode manages conversations independently",
|
||||
"正在切换控制模式": "Switching control mode",
|
||||
"控制模式已切换": "Control mode switched",
|
||||
"控制模式切换失败": "Unable to switch control mode",
|
||||
"当前任务或请求完成后才能切换控制模式": "The control mode can be changed after the current task or request finishes",
|
||||
"同步模式下会话管理由 VS Code 控制": "VS Code controls conversation navigation in sync mode",
|
||||
"当前模式不支持修改会话设置": "The current mode does not support changing conversation settings",
|
||||
"本机连接(无需 token)": "Local connection (no token required)",
|
||||
"本机模式无需填写;认证模式再填写": "No token is needed locally; enter one only for authenticated mode",
|
||||
"访问 token(认证模式)": "Access token (authenticated mode)",
|
||||
"粘贴 relay 启动时打印的 token": "Paste the token printed when the relay started",
|
||||
"本地连接无需 token": "No token is needed for a local connection",
|
||||
"编辑外部文件和联网时始终询问": "Always ask before editing external files or using the network",
|
||||
"不限制联网或文件访问": "Allow unrestricted network and file access",
|
||||
"查看请求数据": "View request data",
|
||||
"查看上下文用量": "View context usage",
|
||||
"创建新会话": "New conversation",
|
||||
"打开会话历史": "Open conversation history",
|
||||
"待处理的 Codex 请求": "Pending Codex requests",
|
||||
"当前会话": "Current conversation",
|
||||
"当前模型": "Current model",
|
||||
"切换模型": "Change model",
|
||||
"等待 VS Code 主机": "Waiting for VS Code host",
|
||||
"等待连接": "Waiting for connection",
|
||||
"对话内容": "Conversation",
|
||||
"发送 JSON": "Send JSON",
|
||||
"发送后续指令": "Send follow-up",
|
||||
"发送消息": "Send message",
|
||||
"返回会话列表": "Back to conversations",
|
||||
"返回模型强度": "Back to model effort",
|
||||
"高级": "Advanced",
|
||||
"简洁": "Simple",
|
||||
"更多操作": "More actions",
|
||||
"更高效": "More efficient",
|
||||
"更智能": "More capable",
|
||||
"工作目录": "Working directory",
|
||||
"工作区": "Workspace",
|
||||
"工作区写入": "Workspace write",
|
||||
"回到最新消息": "Jump to latest message",
|
||||
"正在工作,回到最新消息": "Working, jump to latest message",
|
||||
"会话历史": "Conversation history",
|
||||
"会话设置": "Conversation settings",
|
||||
"仅本次 turn": "This turn only",
|
||||
"仅查看文件,不修改工作区": "View files without changing the workspace",
|
||||
"仅对可能不安全的操作询问": "Ask only for potentially unsafe actions",
|
||||
"拒绝": "Deny",
|
||||
"可用会话": "Available conversations",
|
||||
"连接设置": "Connection settings",
|
||||
"留空使用默认模型": "Leave empty to use the default model",
|
||||
"模式": "Mode",
|
||||
"模型": "Model",
|
||||
"模型与推理强度": "Model and reasoning effort",
|
||||
"默认": "Default",
|
||||
"启动新 thread": "Start new thread",
|
||||
"强度": "Effort",
|
||||
"切换模型与推理强度": "Change model and reasoning effort",
|
||||
"清除搜索": "Clear search",
|
||||
"清空当前输出": "Clear current output",
|
||||
"清空对话": "Clear conversation",
|
||||
"取消": "Cancel",
|
||||
"权限设置": "Permission settings",
|
||||
"确认": "Confirm",
|
||||
"确认完全访问": "Confirm full access",
|
||||
"沙箱": "Sandbox",
|
||||
"上下文用量": "Context usage",
|
||||
"设置": "Settings",
|
||||
"审批策略": "Approval policy",
|
||||
"使用 config.toml 中的权限": "Use permissions from config.toml",
|
||||
"使用左右方向键调整强度": "Use the left and right arrow keys to adjust effort",
|
||||
"授权范围": "Authorization scope",
|
||||
"授权与输入": "Approvals and input",
|
||||
"刷新会话列表": "Refresh conversations",
|
||||
"搜索最近会话": "Search recent conversations",
|
||||
"提交后续变更要求": "Ask for follow-up changes",
|
||||
"添加工作区上下文": "Add workspace context",
|
||||
"添加文件": "Add files",
|
||||
"添加文件及更多内容": "Add files and more",
|
||||
"添加照片": "Add photos",
|
||||
"推理强度": "Reasoning effort",
|
||||
"完全访问": "Full access",
|
||||
"完全访问允许 Codex 执行命令、访问互联网并编辑工作区之外的文件。": "Full access lets Codex run commands, use the internet, and edit files outside the workspace.",
|
||||
"网页搜索": "Web search",
|
||||
"未认证": "Unauthenticated",
|
||||
"显示 Codex": "Show Codex",
|
||||
"修改权限": "Change permissions",
|
||||
"需要时询问": "Ask when needed",
|
||||
"已附着当前会话": "Attached to current conversation",
|
||||
"隐藏面板": "Hide panel",
|
||||
"由 Codex 审批": "Let Codex decide",
|
||||
"允许": "Allow",
|
||||
"允许一次": "Allow once",
|
||||
"暂无待处理请求": "No pending requests",
|
||||
"暂无用量数据": "No usage data",
|
||||
"展开面板": "Expand panel",
|
||||
"正在连接": "Connecting",
|
||||
"只读": "Read only",
|
||||
"中断当前 turn": "Interrupt current turn",
|
||||
"重新同步": "Resync",
|
||||
"子代理": "Subagent",
|
||||
"自定义": "Custom",
|
||||
"最近会话": "Recent conversations",
|
||||
"Codex 消息": "Codex messages",
|
||||
"JSON 响应": "JSON response",
|
||||
"语言": "Language",
|
||||
"中文": "Chinese",
|
||||
"跟随浏览器": "Use browser language",
|
||||
"正在连接云端会话": "Connecting to cloud conversation",
|
||||
"正在等待云端连接": "Waiting for cloud connection",
|
||||
"云端连接已断开": "Cloud connection disconnected",
|
||||
"云端连接配置无效": "Invalid cloud connection configuration",
|
||||
"云端连接地址必须与当前页面同源": "The cloud connection URL must be same-origin",
|
||||
"正在获取新的连接凭证": "Requesting new connection credentials",
|
||||
"父页面已断开连接": "Disconnected by the parent page",
|
||||
"当前 relay 需要 token": "This relay requires a token",
|
||||
"WebSocket 未连接": "WebSocket is not connected",
|
||||
"连接中": "Connecting",
|
||||
"同步中": "Syncing",
|
||||
"已连接": "Connected",
|
||||
"认证失败,准备重连": "Authentication failed; preparing to reconnect",
|
||||
"准备重连": "Preparing to reconnect",
|
||||
"重连中": "Reconnecting",
|
||||
"收到无法解析的 relay 消息": "Received an unreadable relay message",
|
||||
"等待 relay 连接": "Waiting for relay connection",
|
||||
"等待 VS Code 主机连接": "Waiting for VS Code host",
|
||||
"VS Code 主机未连接": "VS Code host is disconnected",
|
||||
"等待 VS Code 伴随扩展连接": "Waiting for the VS Code companion extension",
|
||||
"VS Code 伴随扩展未连接": "VS Code companion extension is disconnected",
|
||||
"等待在 VS Code 中打开 Codex 会话": "Open a Codex conversation in VS Code to continue",
|
||||
"会话已关闭": "Conversation closed",
|
||||
"VS Code 会话已关闭": "VS Code conversation closed",
|
||||
"会话操作失败": "Conversation operation failed",
|
||||
"当前任务结束或请求处理后才能切换": "You can switch after the current task or request finishes",
|
||||
"目标会话没有返回 VS Code 快照,请先在官方 Codex 面板打开它": "The target conversation did not return a VS Code snapshot. Open it in the official Codex panel first.",
|
||||
"当前 relay 版本不支持此会话操作,请重启 relay": "This relay version does not support the conversation action. Restart the relay.",
|
||||
"正在读取会话…": "Loading conversations...",
|
||||
"正在切换会话…": "Switching conversation...",
|
||||
"无法读取会话": "Unable to load conversations",
|
||||
"没有匹配的会话": "No matching conversations",
|
||||
"没有可附加的会话": "No attachable conversations",
|
||||
"没有可控制的会话": "No controllable conversations",
|
||||
"正在切换": "Switching",
|
||||
"未打开": "Not open",
|
||||
"当前": "Current",
|
||||
"可切换": "Available",
|
||||
"会话": "Conversation",
|
||||
"当前角色不能创建会话": "Your current role cannot create conversations",
|
||||
"正在创建新会话": "Creating a new conversation",
|
||||
"无法创建新会话": "Unable to create a new conversation",
|
||||
"当前任务仍在运行或等待授权,暂不能切换": "The current task is running or awaiting approval, so it cannot be switched yet",
|
||||
"会话切换失败": "Conversation switch failed",
|
||||
"正在确认会话": "Confirming conversation",
|
||||
"正在加载会话": "Loading conversation",
|
||||
"会话已切换": "Conversation switched",
|
||||
"正在更新模型设置": "Updating model settings",
|
||||
"模型设置已更新": "Model settings updated",
|
||||
"无法更新模型设置": "Unable to update model settings",
|
||||
"已停止": "Stopped",
|
||||
"成功": "Succeeded",
|
||||
"无输出": "No output",
|
||||
"等待输出…": "Waiting for output...",
|
||||
"执行步骤": "Action",
|
||||
"正在读取文件": "Reading files",
|
||||
"读取完成": "Finished reading",
|
||||
"已读取文件运行了命令": "Read files and ran a command",
|
||||
"已读取文件": "Read files",
|
||||
"编辑了文件": "Edited files",
|
||||
"已完成计划": "Completed plan",
|
||||
"读取文件失败": "Failed to read files",
|
||||
"已停止读取文件": "Stopped reading files",
|
||||
"读取文件": "Read files",
|
||||
"已运行命令": "Ran command",
|
||||
"正在运行命令": "Running command",
|
||||
"正在思考": "Thinking",
|
||||
"正在制定计划": "Creating a plan",
|
||||
"正在编辑文件": "Editing files",
|
||||
"正在处理": "Working",
|
||||
"已完成思考": "Finished thinking",
|
||||
"计划完成": "Plan completed",
|
||||
"文件编辑完成": "Finished editing files",
|
||||
"工作说明": "Progress update",
|
||||
"计划": "Plan",
|
||||
"文件变更": "File changes",
|
||||
"等待授权": "Waiting for approval",
|
||||
"正在生成": "Generating",
|
||||
"已中断": "Interrupted",
|
||||
"失败": "Failed",
|
||||
"已完成": "Completed",
|
||||
"正在工作": "Working",
|
||||
"正在等待你的回答": "Waiting for your answer",
|
||||
"正在搜索网页": "Searching the web",
|
||||
"执行失败": "Action failed",
|
||||
"处理中": "Working",
|
||||
"思考": "Reasoning",
|
||||
"编辑文件": "Edit files",
|
||||
"思考中": "Thinking",
|
||||
"编辑中": "Editing",
|
||||
"进行中": "In progress",
|
||||
"异常": "Error",
|
||||
"未读": "Unread",
|
||||
"本地会话": "Local conversation",
|
||||
"默认拒绝,请明确允许": "Denied by default; allow explicitly",
|
||||
"需要远程确认或输入": "Remote confirmation or input is required",
|
||||
"允许运行命令?": "Allow this command?",
|
||||
"允许修改文件?": "Allow file changes?",
|
||||
"需要扩大权限": "Additional permissions required",
|
||||
"Codex 需要你的回答": "Codex needs your answer",
|
||||
"需要外部服务确认": "External service confirmation required",
|
||||
"Codex 请求确认": "Codex requests confirmation",
|
||||
"高风险": "High risk",
|
||||
"低风险": "Low risk",
|
||||
"需确认": "Confirmation required",
|
||||
"请输入": "Enter a response",
|
||||
"提交回答": "Submit answer",
|
||||
"发送自定义响应": "Send custom response",
|
||||
"自定义响应不是有效 JSON": "The custom response is not valid JSON",
|
||||
"响应不是有效 JSON": "The response is not valid JSON",
|
||||
"远程参与者拒绝": "Denied by remote participant",
|
||||
"状态": "Status",
|
||||
"命令": "Command",
|
||||
"详情": "Details",
|
||||
"复制消息": "Copy message",
|
||||
"复制命令": "Copy command",
|
||||
"复制输出": "Copy output",
|
||||
"未知": "Unknown",
|
||||
"未知错误": "Unknown error",
|
||||
"已附着 VS Code 当前 Codex 会话;输入、输出和授权都回到同一个会话。": "Attached to the current VS Code Codex conversation. Messages, output, and approvals all return to that conversation.",
|
||||
"当前为独立 app-server 模式。": "Currently using standalone app-server mode.",
|
||||
"已附着现有会话": "Attached to existing conversation",
|
||||
"通用 Codex 模型": "General-purpose Codex model",
|
||||
"平衡速度与推理": "Balanced speed and reasoning",
|
||||
"可用模型": "Available model",
|
||||
"极低": "Minimal",
|
||||
"轻度": "Low",
|
||||
"标准": "Medium",
|
||||
"深度": "High",
|
||||
"极高": "Extra high",
|
||||
"最大": "Maximum",
|
||||
"此模型使用默认推理强度": "This model uses its default reasoning effort",
|
||||
"返回简洁模型选择": "Return to simple model selection",
|
||||
"显示高级模型选项": "Show advanced model options",
|
||||
"自定义权限由 config.toml 管理": "Custom permissions are managed by config.toml",
|
||||
"正在等待指示": "Waiting for instructions",
|
||||
"正在工作": "Working",
|
||||
"命令输出": "Command output",
|
||||
"工具输出": "Tool output",
|
||||
"发送 Steer": "Send steer",
|
||||
"会话切换失败,已恢复原会话": "Conversation switch failed; restored the previous conversation",
|
||||
"(空消息)": "(empty message)",
|
||||
"今天": "Today",
|
||||
"昨天": "Yesterday",
|
||||
"未完成": "Not completed",
|
||||
"步骤": "Step",
|
||||
"查看图像": "View image",
|
||||
"等待输入": "Waiting for input",
|
||||
"读取文件运行命令失败": "Failed to read files and run a command",
|
||||
"发送输入": "Send input",
|
||||
"工具": "Tool",
|
||||
"工具失败": "Tool failed",
|
||||
"正在搜索": "Searching",
|
||||
"你停止了工作": "You stopped working",
|
||||
"关闭子代理": "Close subagent",
|
||||
"恢复子代理": "Resume subagent",
|
||||
"启动子代理": "Start subagent",
|
||||
"搜索": "Search",
|
||||
"文件": "File",
|
||||
"新会话已在 VS Code 中打开": "The new conversation opened in VS Code",
|
||||
"事件窗口已过期,请以当前快照为准": "The event window expired; the current snapshot is authoritative",
|
||||
"执行状态未知,请等待主机恢复": "Execution status is unknown; wait for the host to recover",
|
||||
"文件已截断": "File truncated",
|
||||
"已拒绝": "Denied",
|
||||
"已开始工作": "Started working",
|
||||
"已添加工作区上下文": "Added workspace context",
|
||||
"已添加网页搜索": "Added web search",
|
||||
"运行命令": "Run command",
|
||||
"整理上下文": "Compacting context",
|
||||
"正在切换会话": "Switching conversation",
|
||||
"MCP 工具": "MCP tool",
|
||||
" · @ 可标记代理": " · @ to mention agents",
|
||||
});
|
||||
|
||||
const EN_PATTERNS = Object.freeze([
|
||||
[/^用时 1分钟(\d+)秒$/, "Worked for 1m{1}s"],
|
||||
[/^用时 (\d+)分(\d+)秒$/, "Worked for {1}m{2}s"],
|
||||
[/^用时 1分钟$/, "Worked for 1m"],
|
||||
[/^用时 (\d+)分$/, "Worked for {1}m"],
|
||||
[/^用时 (\d+)秒$/, "Worked for {1}s"],
|
||||
[/^用时 (\d+)毫秒$/, "Worked for {1}ms"],
|
||||
[/^用时\s+(.+)$/, "Worked for {1}"],
|
||||
[/^已思考 1分钟(\d+)秒$/, "Thought for 1m{1}s"],
|
||||
[/^已思考 (\d+)分(\d+)秒$/, "Thought for {1}m{2}s"],
|
||||
[/^已思考 (\d+)秒$/, "Thought for {1}s"],
|
||||
[/^已思考\s+(.+)$/, "Thought for {1}"],
|
||||
[/^退出码\s+(.+)$/, "Exit code {1}"],
|
||||
[/^正在读取\s+(.+)$/, "Reading {1}", [1]],
|
||||
[/^已读取\s+(.+)$/, "Read {1}", [1]],
|
||||
[/^读取失败\s*·\s*(.+)$/, "Failed to read {1}", [1]],
|
||||
[/^已停止读取\s+(.+)$/, "Stopped reading {1}", [1]],
|
||||
[/^读取\s+(.+)$/, "Read {1}", [1]],
|
||||
[/^已读取这些内容\s*·\s*(\d+)\s*个文件(.*)$/, "Read these items · {1} files{2}"],
|
||||
[/^已在\s+(.+)\s+内运行\s+(.+)$/, "Ran {2} in {1}", [2]],
|
||||
[/^命令运行失败\s*·\s*(.+?)\s*·\s*((?:\d+毫秒|\d+秒|1分钟(?:\d+秒)?|\d+分(?:\d+秒)?))$/, "Command failed · {1} · {2}", [1]],
|
||||
[/^命令运行失败\s*·\s*(.+)$/, "Command failed · {1}", [1]],
|
||||
// Renderer-owned disclosure labels. Keep the captured command/model text
|
||||
// intact; only the surrounding UI words are localized.
|
||||
[/^命令\s*·\s*(.+)$/, "Command · {1}", [1]],
|
||||
[/^已工具\s*·\s*(.+)$/, "Tool completed · {1}"],
|
||||
[/^当前模型\s+(.+?)\s+(极低|轻度|标准|深度|极高|最大),切换模型$/, "Current model: {1} {2}. Change model", [1]],
|
||||
[/^已停止\s*(.+?)\s*·\s*((?:\d+毫秒|\d+秒|1分钟(?:\d+秒)?|\d+分(?:\d+秒)?))$/, "Stopped {1} · {2}", [1]],
|
||||
[/^已运行\s*(.+)$/, "Ran {1}", [1]],
|
||||
[/^命令运行失败\s*(.*)$/, "Command failed{1}", [1]],
|
||||
[/^命令:\s*(.+?)(执行状态未知,请等待主机恢复)$/, "Command: {1} (execution status unknown; wait for the host to recover)", [1]],
|
||||
[/^命令:\s*(.+)$/, "Command: {1}", [1]],
|
||||
[/^已停止\s*(.+)$/, "Stopped {1}", [1]],
|
||||
[/^正在运行\s+(.+)$/, "Running {1}", [1]],
|
||||
[/^(.+?)\s*·\s*失败$/, "{1} · Failed"],
|
||||
[/^(.+?)\s*·\s*已中断$/, "{1} · Interrupted"],
|
||||
[/^(.+)\s+失败$/, "{1} failed"],
|
||||
[/^编辑了文件\s*·\s*(.+)$/, "Edited files · {1}"],
|
||||
[/^已完成计划\s*·\s*(.+)$/, "Completed plan · {1}"],
|
||||
[/^(\d+)\/(\d+)\s*个会话$/, "{1}/{2} conversations"],
|
||||
[/^(\d+)\s*个会话$/, "{1} conversations"],
|
||||
[/^会话\s+(.+)$/, "Conversation {1}", [1]],
|
||||
[/^工作区\s*·\s*(.+)$/, "Workspace · {1}", [1]],
|
||||
[/^昨天\s+(.+)$/, "Yesterday {1}", [1]],
|
||||
[/^正在切换到「(.+)」…?$/, "Switching to “{1}”...", [1]],
|
||||
[/^你在\s+(.+)\s+后停止了$/, "You stopped after {1}"],
|
||||
[/^执行失败\s*·\s*(.+)$/, "Action failed · {1}"],
|
||||
[/^新会话创建失败:(.+)$/, "Unable to create a new conversation: {1}", [1]],
|
||||
[/^模型设置更新失败:(.+)$/, "Unable to update model settings: {1}", [1]],
|
||||
[/^(.+)(执行状态未知,请等待主机恢复)$/, "{1} (execution status unknown; wait for the host to recover)", [1]],
|
||||
[/^(.+) 完成$/, "{1} completed", [1]],
|
||||
[/^请求 #(.+) 已提交$/, "Request #{1} submitted"],
|
||||
[/^请求 #(.+) 已发送,等待 VS Code 主机确认$/, "Request #{1} sent; waiting for the VS Code host"],
|
||||
[/^无法读取 (.+)$/, "Unable to read {1}", [1]],
|
||||
[/^\[图片附件:(.+)\]$/, "[Image attachment: {1}]", [1]],
|
||||
[/^(.+) 已开始工作$/, "{1} started working", [1]],
|
||||
[/^(.+) 已完成$/, "{1} completed", [1]],
|
||||
[/^(.+) 已中断$/, "{1} interrupted", [1]],
|
||||
[/^…(文件已截断)$/, "... (file truncated)"],
|
||||
[/^当前模型\s+(.+),切换模型$/, "Current model: {1}. Change model", [1]],
|
||||
[/^切换模型(当前\s+(.+?)\s+(极低|轻度|标准|深度|极高|最大))$/, "Change model (current: {1} {2})", [1]],
|
||||
[/^切换模型(当前\s+(.+))$/, "Change model (current: {1})", [1]],
|
||||
[/^修改权限,当前为(.+)$/, "Change permissions. Current: {1}"],
|
||||
[/^修改权限(当前:(.+))$/, "Change permissions (current: {1})"],
|
||||
[/^上下文已使用\s*(\d+)%(剩余\s*(\d+)%)$/, "Context used: {1}% ({2}% remaining)"],
|
||||
[/^(\d+)%\s*已使用$/, "{1}% used"],
|
||||
[/^剩余\s+(.+)\s+tokens$/, "{1} tokens remaining"],
|
||||
[/^当前上下文\s+(.+)\s+tokens$/, "Current context: {1} tokens"],
|
||||
[/^最近请求\s+(.+)\s+tokens$/, "Latest request: {1} tokens"],
|
||||
[/^累计\s+(.+)\s+tokens$/, "Total: {1} tokens"],
|
||||
[/^使用\s+(.+)$/, "Using {1}", [1]],
|
||||
[/^已用时\s+(.+)$/, "Elapsed: {1}"],
|
||||
[/^(\d+)\s*个后台代理(.*)$/, "{1} background agents{2}"],
|
||||
[/^(\d+)毫秒$/, "{1}ms"],
|
||||
[/^(\d+)秒$/, "{1}s"],
|
||||
[/^1分钟(\d+)秒$/, "1m{1}s"],
|
||||
[/^(\d+)分(\d+)秒$/, "{1}m{2}s"],
|
||||
[/^1分钟(\d+秒)?$/, "1m{1}"],
|
||||
[/^(\d+)分(\d+秒)?$/, "{1}m{2}"],
|
||||
]);
|
||||
const ZH = Object.freeze(Object.fromEntries(Object.entries(EN).map(([source, translated]) => [translated, source])));
|
||||
|
||||
let currentLocale = "zh-CN";
|
||||
let observer = null;
|
||||
const textSources = new WeakMap();
|
||||
const textRendered = new WeakMap();
|
||||
const attributeSources = new WeakMap();
|
||||
const attributeRendered = new WeakMap();
|
||||
|
||||
function normalizeLocale(value) {
|
||||
const locale = String(value || "").trim().replace("_", "-").toLowerCase();
|
||||
return locale.startsWith("zh") ? "zh-CN" : "en-US";
|
||||
}
|
||||
|
||||
function embeddedMode() {
|
||||
if (root?.AetherVscodexEmbed?.active) return true;
|
||||
try { return new URLSearchParams(root?.location?.search || "").get("embed") === "aether"; }
|
||||
catch { return false; }
|
||||
}
|
||||
|
||||
function interpolate(template, values) {
|
||||
return String(template).replace(/\{(\d+)\}/g, (_, index) => values[Number(index)] ?? "");
|
||||
}
|
||||
|
||||
function translate(value, locale = currentLocale, depth = 0) {
|
||||
const source = String(value ?? "");
|
||||
if (!source) return source;
|
||||
if (normalizeLocale(locale) === "zh-CN") return ZH[source] || source;
|
||||
if (Object.prototype.hasOwnProperty.call(EN, source)) return EN[source];
|
||||
for (const [pattern, template, rawIndexes] of EN_PATTERNS) {
|
||||
const match = source.match(pattern);
|
||||
if (match) {
|
||||
const translatedMatch = match.map((part, index) => index === 0
|
||||
? part
|
||||
: rawIndexes?.includes(index) ? part
|
||||
: depth < 6 ? translate(part, locale, depth + 1) : (EN[part] || part));
|
||||
return interpolate(template, translatedMatch);
|
||||
}
|
||||
}
|
||||
return source;
|
||||
}
|
||||
|
||||
function shouldSkipTextNode(node) {
|
||||
const parent = node?.parentElement;
|
||||
return Boolean(parent?.closest?.("code, pre, .message-body, .request-summary, .request-questions, .request-json, .request-command, .diff-output, .terminal-output, .session-option-title, .subagent-name, .subagent-summary-label"));
|
||||
}
|
||||
|
||||
function translateTextNode(node) {
|
||||
if (!node || shouldSkipTextNode(node)) return;
|
||||
const current = node.nodeValue;
|
||||
const previousRendered = textRendered.get(node);
|
||||
if (!textSources.has(node) || current !== previousRendered) textSources.set(node, current);
|
||||
const source = textSources.get(node);
|
||||
const leading = source.match(/^\s*/)?.[0] || "";
|
||||
const trailing = source.match(/\s*$/)?.[0] || "";
|
||||
const core = source.slice(leading.length, source.length - trailing.length);
|
||||
if (!core) return;
|
||||
const translated = translate(core);
|
||||
const rendered = `${leading}${translated}${trailing}`;
|
||||
textRendered.set(node, rendered);
|
||||
if (rendered !== current) node.nodeValue = rendered;
|
||||
}
|
||||
|
||||
function translateAttributes(element) {
|
||||
if (!element?.getAttribute || element.closest?.(".message-body, pre, code")) return;
|
||||
let sources = attributeSources.get(element);
|
||||
let renderedValues = attributeRendered.get(element);
|
||||
if (!sources) { sources = new Map(); attributeSources.set(element, sources); }
|
||||
if (!renderedValues) { renderedValues = new Map(); attributeRendered.set(element, renderedValues); }
|
||||
for (const attribute of ["title", "aria-label", "placeholder", "data-placeholder"]) {
|
||||
if (!element.hasAttribute(attribute)) continue;
|
||||
const current = element.getAttribute(attribute);
|
||||
if (!sources.has(attribute) || current !== renderedValues.get(attribute)) sources.set(attribute, current);
|
||||
const source = sources.get(attribute);
|
||||
const translated = translate(source);
|
||||
renderedValues.set(attribute, translated);
|
||||
if (translated !== current) element.setAttribute(attribute, translated);
|
||||
}
|
||||
}
|
||||
|
||||
function translateTree(node) {
|
||||
if (!root?.document || !node) return;
|
||||
if (node.nodeType === 3) {
|
||||
translateTextNode(node);
|
||||
return;
|
||||
}
|
||||
if (node.nodeType !== 1 && node.nodeType !== 9 && node.nodeType !== 11) return;
|
||||
if (node.nodeType === 1) translateAttributes(node);
|
||||
const walker = root.document.createTreeWalker(node, root.NodeFilter.SHOW_ELEMENT | root.NodeFilter.SHOW_TEXT);
|
||||
for (let current = walker.nextNode(); current; current = walker.nextNode()) {
|
||||
if (current.nodeType === 3) translateTextNode(current);
|
||||
else translateAttributes(current);
|
||||
}
|
||||
}
|
||||
|
||||
function applyDocument() {
|
||||
if (!root?.document) return;
|
||||
root.document.documentElement.lang = currentLocale;
|
||||
translateTree(root.document.body);
|
||||
const selector = root.document.getElementById("localeSelect");
|
||||
if (selector && selector.value !== currentLocale) selector.value = currentLocale;
|
||||
}
|
||||
|
||||
function setLocale(value, options = {}) {
|
||||
currentLocale = SUPPORTED.has(value) ? value : normalizeLocale(value);
|
||||
if (options.persist !== false && root?.localStorage && !embeddedMode()) {
|
||||
try { root.localStorage.setItem(STORAGE_KEY, currentLocale); } catch { /* storage may be disabled */ }
|
||||
}
|
||||
applyDocument();
|
||||
if (root?.CustomEvent) root.dispatchEvent?.(new root.CustomEvent("aether-vscodex:locale", { detail: { locale: currentLocale } }));
|
||||
return currentLocale;
|
||||
}
|
||||
|
||||
function initialLocale() {
|
||||
if (embeddedMode()) return normalizeLocale(root?.navigator?.language);
|
||||
try {
|
||||
const saved = root?.localStorage?.getItem(STORAGE_KEY);
|
||||
if (SUPPORTED.has(saved)) return saved;
|
||||
} catch { /* storage may be disabled */ }
|
||||
return normalizeLocale(root?.navigator?.language);
|
||||
}
|
||||
|
||||
function start() {
|
||||
if (!root?.document) return;
|
||||
currentLocale = initialLocale();
|
||||
applyDocument();
|
||||
if (typeof root.MutationObserver === "function" && !observer) {
|
||||
observer = new root.MutationObserver((records) => {
|
||||
if (currentLocale === "zh-CN") return;
|
||||
for (const record of records) {
|
||||
if (record.type === "characterData") translateTextNode(record.target);
|
||||
else if (record.type === "attributes") translateAttributes(record.target);
|
||||
else for (const node of record.addedNodes) translateTree(node);
|
||||
}
|
||||
});
|
||||
observer.observe(root.document.documentElement, {
|
||||
subtree: true,
|
||||
childList: true,
|
||||
characterData: true,
|
||||
attributes: true,
|
||||
attributeFilter: ["title", "aria-label", "placeholder", "data-placeholder"],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const api = {
|
||||
locale: () => currentLocale,
|
||||
normalizeLocale,
|
||||
setLocale,
|
||||
start,
|
||||
t: (value) => translate(value),
|
||||
translate,
|
||||
translateTree,
|
||||
messages: { "zh-CN": Object.freeze({}), "en-US": EN },
|
||||
};
|
||||
|
||||
if (root?.document) {
|
||||
if (root.document.readyState === "loading") root.document.addEventListener("DOMContentLoaded", start, { once: true });
|
||||
else start();
|
||||
}
|
||||
return api;
|
||||
});
|
||||
@@ -0,0 +1,303 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="color-scheme" content="dark light" />
|
||||
<title>Codex</title>
|
||||
<link rel="stylesheet" href="./style.css" />
|
||||
</head>
|
||||
<body class="codex-app local-no-auth">
|
||||
<div class="codex-panel">
|
||||
<div class="connection" aria-live="polite" hidden>
|
||||
<span id="connectionDot" class="dot offline"></span>
|
||||
<span id="connectionText">正在连接</span>
|
||||
<span id="roleBadge" class="badge">未认证</span>
|
||||
</div>
|
||||
<main class="chat-shell">
|
||||
<section class="chat-header" aria-label="当前会话">
|
||||
<div class="thread-heading">
|
||||
<button id="backButton" class="icon-button header-back-button" type="button" data-panel-action="back" title="返回会话列表" aria-label="返回会话列表" hidden>
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M9.75 3.5 5.25 8l4.5 4.5M5.5 8h6.25" /></svg>
|
||||
</button>
|
||||
<button id="sessionPickerButton" class="thread-picker-button" type="button" aria-haspopup="dialog" aria-expanded="false" title="打开会话历史" aria-label="打开会话历史" disabled>
|
||||
<h2 id="threadTitle">Codex</h2>
|
||||
</button>
|
||||
<span id="appState" class="status-text" aria-live="polite">等待 VS Code 主机</span>
|
||||
</div>
|
||||
<div class="thread-actions">
|
||||
<button class="icon-button" type="button" data-panel-action="menu" title="更多操作" aria-label="更多操作">
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true"><circle cx="3" cy="8" r="1" /><circle cx="8" cy="8" r="1" /><circle cx="13" cy="8" r="1" /></svg>
|
||||
</button>
|
||||
<button id="historyButton" class="icon-button header-history-button" type="button" data-panel-action="history" title="会话历史" aria-label="会话历史" hidden>
|
||||
<svg viewBox="0 0 20 20" aria-hidden="true"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" /><path d="M3 3v5h5" /><path d="M12 7v5l4 2" /></svg>
|
||||
</button>
|
||||
<button class="icon-button" type="button" data-panel-action="settings" title="设置" aria-label="设置">
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M6.7 2h2.6l.4 1.6c.4.2.8.4 1.2.7l1.6-.6 1.3 2.2-1.2 1.1a5 5 0 0 1 0 1.4l1.2 1.1-1.3 2.2-1.6-.6c-.4.3-.8.5-1.2.7L9.3 14H6.7l-.4-1.6a5 5 0 0 1-1.2-.7l-1.6.6-1.3-2.2 1.2-1.1a5 5 0 0 1 0-1.4L2.2 6l1.3-2.2 1.6.6c.4-.3.8-.5 1.2-.7L6.7 2Z" /><circle cx="8" cy="8" r="1.7" /></svg>
|
||||
</button>
|
||||
<button id="newSessionButton" class="icon-button new-session-button" type="button" data-panel-action="new-session" title="创建新会话" aria-label="创建新会话" hidden>
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M3.25 3.25h5.5a1.5 1.5 0 0 1 1.5 1.5v2.5" /><path d="M3.25 3.25v9.5h6" /><path d="m8.2 11.35 4.55-4.55 1.25 1.25-4.55 4.55-2 .5Z" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
<div id="panelMenu" class="panel-popover panel-menu" hidden>
|
||||
<button type="button" data-menu-action="sessions" hidden>最近会话</button>
|
||||
<button type="button" data-menu-action="clear">清空当前输出</button>
|
||||
<button type="button" data-menu-action="refresh">重新同步</button>
|
||||
<button type="button" data-menu-action="expand">展开面板</button>
|
||||
<button type="button" data-menu-action="close">隐藏面板</button>
|
||||
</div>
|
||||
<div id="detailsPopover" class="panel-popover details-popover settings-popover" hidden role="dialog" aria-label="设置">
|
||||
<div class="popover-title">设置</div>
|
||||
<div class="settings-shortcuts">
|
||||
<button type="button" data-settings-action="model"><span>模型与推理强度</span><span id="settingsModelValue">默认</span></button>
|
||||
<button type="button" data-settings-action="permission"><span>修改权限</span><span id="settingsPermissionValue">工作区写入</span></button>
|
||||
<label id="localeSetting" class="settings-locale">
|
||||
<span>语言</span>
|
||||
<select id="localeSelect" aria-label="语言">
|
||||
<option value="zh-CN">中文</option>
|
||||
<option value="en-US">English</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div class="settings-divider"></div>
|
||||
<div class="popover-subtitle">当前会话</div>
|
||||
<dl>
|
||||
<dt>工作区</dt><dd id="popoverCwd">-</dd>
|
||||
<dt>模式</dt><dd id="popoverMode">本地模式</dd>
|
||||
<dt>thread</dt><dd id="popoverThread">-</dd>
|
||||
</dl>
|
||||
</div>
|
||||
<div id="sessionPicker" class="panel-popover session-picker" hidden role="dialog" aria-label="最近会话">
|
||||
<div class="session-picker-header">
|
||||
<span class="popover-title">最近会话</span>
|
||||
<button id="sessionPickerRefresh" class="session-picker-refresh" type="button" title="刷新会话列表" aria-label="刷新会话列表">
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M13 5V2m0 0h-3m3 0-2.1 2.1A5 5 0 1 0 13 9" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="session-search">
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true"><circle cx="6.8" cy="6.8" r="3.8" /><path d="m9.7 9.7 3.2 3.2" /></svg>
|
||||
<label class="sr-only" for="sessionSearchInput">搜索最近会话</label>
|
||||
<input id="sessionSearchInput" type="search" autocomplete="off" spellcheck="false" placeholder="搜索最近会话" aria-label="搜索最近会话" aria-controls="sessionList" aria-expanded="false" />
|
||||
<button id="sessionSearchClear" class="session-search-clear" type="button" title="清除搜索" aria-label="清除搜索" hidden>
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="m4.5 4.5 7 7m0-7-7 7" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div id="sessionPickerStatus" class="session-picker-status" role="status" aria-live="polite"></div>
|
||||
<div id="sessionList" class="session-list" role="listbox" aria-label="可用会话" tabindex="0"></div>
|
||||
</div>
|
||||
|
||||
<section class="chat-panel" aria-label="对话内容">
|
||||
<div id="output" class="output chat-scroll" tabindex="0" aria-live="polite" aria-label="Codex 消息"></div>
|
||||
<button id="scrollToBottom" class="scroll-to-bottom" type="button" aria-label="回到最新消息" aria-hidden="true" tabindex="-1">
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M8 3v9M4.5 8.5 8 12l3.5-3.5" /></svg>
|
||||
<span class="scroll-working-dots" aria-hidden="true"><i></i><i></i><i></i></span>
|
||||
</button>
|
||||
<div id="inlineRequests" class="inline-requests" aria-live="polite" aria-label="待处理的 Codex 请求"></div>
|
||||
</section>
|
||||
|
||||
<section id="messageForm" class="composer" aria-label="发送消息">
|
||||
<section id="subagentsPanel" class="subagents-panel" aria-label="子代理" hidden>
|
||||
<button id="subagentsToggle" class="subagents-toggle" type="button" aria-expanded="false">
|
||||
<span class="subagents-title">子代理</span>
|
||||
<span id="subagentsCount" class="subagents-count"></span>
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="m6 3 5 5-5 5" /></svg>
|
||||
</button>
|
||||
<div id="subagentsList" class="subagents-list"></div>
|
||||
</section>
|
||||
<div id="liveActivity" class="live-activity" role="status" aria-live="polite" hidden>
|
||||
<span class="activity-spinner" aria-hidden="true"></span>
|
||||
<span class="activity-label"></span>
|
||||
<span class="activity-dots" aria-hidden="true"><i></i><i></i><i></i></span>
|
||||
<span class="activity-elapsed"></span>
|
||||
</div>
|
||||
<div class="composer-surface">
|
||||
<div
|
||||
id="messageInput"
|
||||
class="composer-editor"
|
||||
contenteditable="true"
|
||||
role="textbox"
|
||||
aria-multiline="true"
|
||||
data-placeholder="提交后续变更要求"
|
||||
spellcheck="true"
|
||||
></div>
|
||||
<div class="composer-footer">
|
||||
<div class="composer-hint">
|
||||
<button id="composerPlusButton" class="composer-icon-button" type="button" aria-haspopup="menu" aria-expanded="false" title="添加文件及更多内容" aria-label="添加文件及更多内容">
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M8 3v10M3 8h10" /></svg>
|
||||
</button>
|
||||
<div id="composerPlusMenu" class="composer-popover composer-plus-menu" role="menu" hidden>
|
||||
<div class="composer-popover-heading">添加文件及更多内容</div>
|
||||
<button type="button" role="menuitem" data-composer-action="attach">添加文件</button>
|
||||
<button type="button" role="menuitem" data-composer-action="photo">添加照片</button>
|
||||
<button type="button" role="menuitem" data-composer-action="workspace">添加工作区上下文</button>
|
||||
<button type="button" role="menuitem" data-composer-action="web-search">网页搜索</button>
|
||||
</div>
|
||||
<input id="attachmentInput" type="file" accept=".txt,.md,.json,.js,.ts,.tsx,.jsx,.css,.html,.yml,.yaml,.xml,.py,.go,.rs,.java,.c,.cpp,.h,image/*" multiple hidden />
|
||||
<button id="permissionChip" class="permission-chip" type="button" aria-haspopup="menu" aria-expanded="false" title="修改权限" aria-label="修改权限">
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M8 1.8 13 4v3.6c0 3-2 5.6-5 6.6-3-1-5-3.6-5-6.6V4l5-2.2Z" /><path d="m5.5 8 1.6 1.6L10.8 6" /></svg>
|
||||
<span id="permissionLabel">工作区写入</span>
|
||||
<svg class="permission-chevron" viewBox="0 0 16 16" aria-hidden="true"><path d="m4.5 6 3.5 3.5L11.5 6" /></svg>
|
||||
</button>
|
||||
<div id="permissionMenu" class="composer-popover permission-menu" role="menu" aria-label="权限设置" hidden>
|
||||
<div class="composer-popover-heading">修改权限</div>
|
||||
<button type="button" role="menuitemradio" data-permission-mode="ask" aria-checked="false"><span>需要时询问</span><small>编辑外部文件和联网时始终询问</small></button>
|
||||
<button type="button" role="menuitemradio" data-permission-mode="auto" aria-checked="false"><span>由 Codex 审批</span><small>仅对可能不安全的操作询问</small></button>
|
||||
<button type="button" role="menuitemradio" data-permission-mode="full" aria-checked="false"><span>完全访问</span><small>不限制联网或文件访问</small></button>
|
||||
<button type="button" role="menuitemradio" data-permission-mode="custom" aria-checked="false"><span>自定义</span><small>使用 config.toml 中的权限</small></button>
|
||||
<button type="button" role="menuitemradio" data-permission-mode="readonly" aria-checked="false"><span>只读</span><small>仅查看文件,不修改工作区</small></button>
|
||||
</div>
|
||||
<div id="permissionConfirm" class="permission-confirm" role="dialog" aria-modal="true" aria-labelledby="permissionConfirmTitle" hidden>
|
||||
<div id="permissionConfirmTitle" class="permission-confirm-title">确认完全访问</div>
|
||||
<p>完全访问允许 Codex 执行命令、访问互联网并编辑工作区之外的文件。</p>
|
||||
<div class="permission-confirm-actions">
|
||||
<button id="permissionConfirmCancel" type="button">取消</button>
|
||||
<button id="permissionConfirmAccept" class="primary" type="button">确认</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="usagePicker" class="usage-picker" hidden>
|
||||
<button id="usageButton" class="usage-button" type="button" aria-haspopup="dialog" aria-expanded="false" title="查看上下文用量" aria-label="查看上下文用量"><span id="usageRing" class="usage-ring" aria-hidden="true"><span id="usageLabel">0%</span></span></button>
|
||||
<div id="usageMenu" class="composer-popover usage-menu" role="dialog" aria-label="上下文用量" hidden>
|
||||
<div class="composer-popover-heading">上下文用量</div>
|
||||
<div id="usageSummary" class="usage-summary">暂无用量数据</div>
|
||||
<div class="usage-meter"><span id="usageMeterBar"></span></div>
|
||||
<div id="usageDetails" class="usage-details"></div>
|
||||
</div>
|
||||
</div>
|
||||
<span id="factApp" class="sr-only">-</span>
|
||||
<span id="factClients" class="sr-only">-</span>
|
||||
<span id="factRequests" class="sr-only">0</span>
|
||||
</div>
|
||||
<div class="composer-actions">
|
||||
<div id="modelPicker" class="model-picker">
|
||||
<button id="modelPickerButton" class="model-picker-button" type="button" aria-haspopup="menu" aria-expanded="false" title="切换模型与推理强度" hidden>
|
||||
<span id="modelLabel" class="model-label"></span>
|
||||
<span id="modelEffortLabel" class="model-effort-label"></span>
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="m4.5 6 3.5 3.5L11.5 6" /></svg>
|
||||
</button>
|
||||
<div id="modelMenu" class="model-menu" role="menu" aria-label="模型与推理强度" hidden>
|
||||
<div id="modelPowerView" class="model-power-view">
|
||||
<div class="model-power-heading">
|
||||
<span>推理强度</span>
|
||||
<button id="modelAdvancedToggle" class="model-advanced-toggle" type="button">高级</button>
|
||||
</div>
|
||||
<div class="model-power-control">
|
||||
<span class="model-power-label">更高效</span>
|
||||
<input id="modelPowerSlider" class="model-power-slider" type="range" min="0" max="3" step="1" value="1" aria-label="强度" aria-describedby="modelPowerInstructions" />
|
||||
<span class="model-power-label">更智能</span>
|
||||
</div>
|
||||
<div id="modelPowerValue" class="model-power-value"></div>
|
||||
<span id="modelPowerInstructions" class="sr-only">使用左右方向键调整强度</span>
|
||||
</div>
|
||||
<div id="modelAdvancedView" class="model-advanced-view" hidden>
|
||||
<div class="model-advanced-toolbar">
|
||||
<button id="modelAdvancedBack" class="model-advanced-back" type="button" aria-label="返回模型强度">‹</button>
|
||||
<span>模型与推理强度</span>
|
||||
</div>
|
||||
<div class="model-menu-heading">模型</div>
|
||||
<div id="modelOptions" class="model-options" role="listbox" aria-label="模型"></div>
|
||||
<div class="model-menu-heading effort-heading">推理强度</div>
|
||||
<div id="effortOptions" class="effort-options" role="listbox" aria-label="推理强度"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button id="interruptButton" class="compact-action interrupt-action" type="button" disabled title="中断当前 turn" aria-label="中断当前 turn">
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true"><rect x="4.5" y="4.5" width="7" height="7" rx="1" /></svg>
|
||||
</button>
|
||||
<button id="steerButton" class="primary compact-action steer-action" type="button" disabled title="发送后续指令" aria-label="发送后续指令">
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M8 12V4M4.5 7.5 8 4l3.5 3.5" /></svg>
|
||||
</button>
|
||||
<button id="startTurnButton" class="primary send-button" type="button" disabled title="发送消息" aria-label="发送消息">
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M8 12V4M4.5 7.5 8 4l3.5 3.5" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mode-row">
|
||||
<span class="connection-mode-label">
|
||||
<svg class="mode-icon" viewBox="0 0 16 16" aria-hidden="true"><rect x="2" y="3" width="12" height="8" rx="1" /><path d="M5 13h6M8 11v2" /></svg>
|
||||
<span id="modeLabel">本地模式</span>
|
||||
</span>
|
||||
<div id="controlModeSwitch" class="control-mode-switch" role="group" aria-label="控制模式" aria-busy="false" data-mode="sync" data-switching="false">
|
||||
<button type="button" data-control-mode="sync" aria-pressed="true" title="同步模式跟随 VS Code 当前会话" disabled>同步</button>
|
||||
<button type="button" data-control-mode="async" aria-pressed="false" title="异步模式可独立管理会话" disabled>异步</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
<button id="restorePanel" class="restore-panel" type="button" hidden>显示 Codex</button>
|
||||
|
||||
<!-- Protocol compatibility state stays out of the visual shell. -->
|
||||
<section class="compatibility-state" aria-hidden="true" hidden inert>
|
||||
<details id="sessionSettings">
|
||||
<summary>会话设置</summary>
|
||||
<div class="settings-grid">
|
||||
<label>工作目录<input id="cwdInput" type="text" /></label>
|
||||
<label>模型<input id="modelInput" type="text" placeholder="留空使用默认模型" /></label>
|
||||
<label>沙箱
|
||||
<select id="sandboxInput">
|
||||
<option value="workspace-write">workspace-write</option>
|
||||
<option value="read-only">read-only</option>
|
||||
<option value="danger-full-access">danger-full-access</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>审批策略
|
||||
<select id="approvalInput">
|
||||
<option value="on-request">on-request</option>
|
||||
<option value="untrusted">untrusted</option>
|
||||
<option value="never">never</option>
|
||||
</select>
|
||||
</label>
|
||||
<button id="startThreadButton" class="secondary" type="button">启动新 thread</button>
|
||||
<div class="ids">
|
||||
<span>thread</span><code id="threadId">-</code>
|
||||
<span>turn</span><code id="turnId">-</code>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
<details id="connectionSettings">
|
||||
<summary>连接设置</summary>
|
||||
<label class="token-field">
|
||||
<span id="tokenLabel">本机连接(无需 token)</span>
|
||||
<input id="tokenInput" type="password" autocomplete="off" placeholder="本机模式无需填写;认证模式再填写" />
|
||||
</label>
|
||||
</details>
|
||||
<span id="sessionMode">已附着当前会话</span>
|
||||
<span id="latestSeq">seq -</span>
|
||||
<span id="outputHint">等待连接</span>
|
||||
<button id="clearOutputButton" type="button">清空对话</button>
|
||||
<span id="lastEvent">-</span>
|
||||
<details id="requestsPanel"><summary><span>授权与输入</span><span id="requestCount" class="badge warning">0</span></summary><div id="requests" class="requests empty">暂无待处理请求</div></details>
|
||||
</section>
|
||||
|
||||
<template id="requestTemplate">
|
||||
<article class="request">
|
||||
<div class="request-title"><span class="request-icon" aria-hidden="true">!</span><strong class="request-method"></strong><span class="request-risk"></span><span class="request-id"></span></div>
|
||||
<p class="request-summary"></p>
|
||||
<pre class="request-command"></pre>
|
||||
<div class="request-questions"></div>
|
||||
<label class="request-scope-wrap" hidden>
|
||||
<span>授权范围</span>
|
||||
<select class="request-scope">
|
||||
<option value="turn">仅本次 turn</option>
|
||||
<option value="session">当前会话</option>
|
||||
</select>
|
||||
</label>
|
||||
<details class="request-details">
|
||||
<summary>查看请求数据</summary>
|
||||
<pre class="request-json"></pre>
|
||||
</details>
|
||||
<textarea class="request-response" rows="4" aria-label="JSON 响应"></textarea>
|
||||
<div class="button-row request-actions">
|
||||
<button class="primary request-allow">允许</button>
|
||||
<button class="secondary request-deny">拒绝</button>
|
||||
<button class="secondary request-send">发送 JSON</button>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
<script src="./embed-bridge.js" defer></script>
|
||||
<script src="./i18n.js" defer></script>
|
||||
<script src="./app.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,745 @@
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const test = require("node:test");
|
||||
|
||||
const { CodexAgentAdapter } = require("../vscode-extension/dist/codexAgentAdapter.js");
|
||||
const { RelayHost } = require("../vscode-extension/dist/relayHost.js");
|
||||
|
||||
class FakeRpc {
|
||||
responses = [];
|
||||
requests = [];
|
||||
notificationListener;
|
||||
requestListener;
|
||||
exitListener;
|
||||
overrides;
|
||||
|
||||
constructor(overrides = {}) {
|
||||
this.overrides = overrides;
|
||||
}
|
||||
|
||||
get running() {
|
||||
return true;
|
||||
}
|
||||
|
||||
async start() {}
|
||||
|
||||
async request(method, params) {
|
||||
this.requests.push({ method, params });
|
||||
if (Object.prototype.hasOwnProperty.call(this.overrides, method)) {
|
||||
const override = this.overrides[method];
|
||||
return typeof override === "function" ? override(params) : override;
|
||||
}
|
||||
if (method === "initialize") return { userAgent: "test", codexHome: "/tmp/codex" };
|
||||
if (method === "thread/start") return { thread: { id: "thread-test" }, cwd: "/tmp" };
|
||||
if (method === "turn/start") return { turn: { id: "turn-test" } };
|
||||
if (method === "turn/steer") return { turn: { id: "turn-test" } };
|
||||
if (method === "turn/interrupt") return {};
|
||||
throw new Error(`unexpected request ${method}`);
|
||||
}
|
||||
|
||||
notify() {}
|
||||
|
||||
respond(id, result) {
|
||||
this.responses.push({ id, result });
|
||||
}
|
||||
|
||||
respondError(id, code, message) {
|
||||
this.responses.push({ id, error: { code, message } });
|
||||
}
|
||||
|
||||
onNotification(listener) {
|
||||
this.notificationListener = listener;
|
||||
return { dispose: () => undefined };
|
||||
}
|
||||
|
||||
onServerRequest(listener) {
|
||||
this.requestListener = listener;
|
||||
return { dispose: () => undefined };
|
||||
}
|
||||
|
||||
onExit(listener) {
|
||||
this.exitListener = listener;
|
||||
return { dispose: () => undefined };
|
||||
}
|
||||
|
||||
close() {}
|
||||
|
||||
emitRequest(request) {
|
||||
this.requestListener(request);
|
||||
}
|
||||
|
||||
emitNotification(notification) {
|
||||
this.notificationListener(notification);
|
||||
}
|
||||
}
|
||||
|
||||
class FakeRelay {
|
||||
frames = [];
|
||||
listeners = new Set();
|
||||
|
||||
async connect() {}
|
||||
|
||||
send(frame) {
|
||||
this.frames.push(frame);
|
||||
}
|
||||
|
||||
onMessage(listener) {
|
||||
this.listeners.add(listener);
|
||||
return { dispose: () => this.listeners.delete(listener) };
|
||||
}
|
||||
|
||||
close() {}
|
||||
}
|
||||
|
||||
test("CodexAgentAdapter keeps numeric and string approval ids distinct", async () => {
|
||||
const rpc = new FakeRpc();
|
||||
const adapter = new CodexAgentAdapter({ approvalTimeoutMs: 0 }, rpc);
|
||||
await adapter.start();
|
||||
|
||||
rpc.emitRequest({
|
||||
id: 1,
|
||||
method: "item/commandExecution/requestApproval",
|
||||
params: { threadId: "t", turnId: "u", itemId: "n", command: "echo number" },
|
||||
});
|
||||
rpc.emitRequest({
|
||||
id: "1",
|
||||
method: "item/commandExecution/requestApproval",
|
||||
params: { threadId: "t", turnId: "u", itemId: "s", command: "echo string" },
|
||||
});
|
||||
|
||||
const snapshot = await adapter.snapshot();
|
||||
assert.deepEqual(snapshot.pendingApprovals.map((entry) => entry.requestId), [1, "1"]);
|
||||
await adapter.respondApproval(1, "deny");
|
||||
await adapter.respondApproval("1", "deny");
|
||||
assert.deepEqual(rpc.responses.map((entry) => entry.id), [1, "1"]);
|
||||
assert.equal((await adapter.snapshot()).pendingApprovals.length, 0);
|
||||
await adapter.dispose();
|
||||
});
|
||||
|
||||
test("commandActions are included in high-risk approval classification", async () => {
|
||||
const rpc = new FakeRpc();
|
||||
const adapter = new CodexAgentAdapter({ approvalTimeoutMs: 0 }, rpc);
|
||||
await adapter.start();
|
||||
rpc.emitRequest({
|
||||
id: 2,
|
||||
method: "item/commandExecution/requestApproval",
|
||||
params: {
|
||||
threadId: "t",
|
||||
turnId: "u",
|
||||
itemId: "actions",
|
||||
command: null,
|
||||
commandActions: [{ type: "unknown", command: "sudo rm -rf /" }],
|
||||
},
|
||||
});
|
||||
const snapshot = await adapter.snapshot();
|
||||
assert.equal(snapshot.pendingApprovals[0].risk, "high");
|
||||
await adapter.respondApproval(2, "deny");
|
||||
await adapter.dispose();
|
||||
});
|
||||
|
||||
test("output snapshots stay redacted and interrupt clears the active turn", async () => {
|
||||
const rpc = new FakeRpc();
|
||||
const adapter = new CodexAgentAdapter({ approvalTimeoutMs: 0 }, rpc);
|
||||
await adapter.start();
|
||||
await adapter.startThread({});
|
||||
await adapter.startTurn({ text: "hello" });
|
||||
assert.equal((await adapter.snapshot()).turnId, "turn-test");
|
||||
|
||||
rpc.emitNotification({
|
||||
method: "item/agentMessage/delta",
|
||||
params: { delta: "credential Bearer abcdefghijklmnop" },
|
||||
});
|
||||
const snapshot = await adapter.snapshot();
|
||||
assert.equal(snapshot.outputTail.includes("Bearer abcdefghijklmnop"), false);
|
||||
assert.match(snapshot.outputTail, /\[REDACTED\]/);
|
||||
|
||||
await adapter.interruptTurn({});
|
||||
const afterInterrupt = await adapter.snapshot();
|
||||
assert.equal(afterInterrupt.turnId, null);
|
||||
assert.equal(afterInterrupt.state, "idle");
|
||||
await adapter.dispose();
|
||||
});
|
||||
|
||||
test("async adapter lists app-server threads and exposes the model catalog", async () => {
|
||||
const rpc = new FakeRpc({
|
||||
"model/list": {
|
||||
data: [{ id: "model-1", model: "gpt-5.6-sol", displayName: "5.6 Sol", hidden: false }],
|
||||
nextCursor: null,
|
||||
},
|
||||
"thread/list": {
|
||||
data: [
|
||||
{
|
||||
id: "thread-recent",
|
||||
name: null,
|
||||
preview: "Inspect the workspace\nwith detail",
|
||||
cwd: "/tmp/workspace",
|
||||
createdAt: 1_700_000_000,
|
||||
updatedAt: 1_700_000_100,
|
||||
status: { type: "idle" },
|
||||
source: "vscode",
|
||||
},
|
||||
],
|
||||
nextCursor: "next-page",
|
||||
backwardsCursor: null,
|
||||
},
|
||||
});
|
||||
const adapter = new CodexAgentAdapter({ approvalTimeoutMs: 0 }, rpc);
|
||||
await adapter.start();
|
||||
|
||||
const result = await adapter.listSessions({ limit: 500, query: "workspace", sortKey: "invalid" });
|
||||
assert.equal(result.sessions[0].threadId, "thread-recent");
|
||||
assert.equal(result.sessions[0].title, "Inspect the workspace with detail");
|
||||
assert.equal(result.sessions[0].updatedAtMs, 1_700_000_100_000);
|
||||
assert.equal(result.nextCursor, "next-page");
|
||||
const listRequest = rpc.requests.find((entry) => entry.method === "thread/list");
|
||||
assert.deepEqual(listRequest.params, {
|
||||
limit: 100,
|
||||
sortKey: "updated_at",
|
||||
sortDirection: "desc",
|
||||
searchTerm: "workspace",
|
||||
});
|
||||
const snapshot = await adapter.snapshot();
|
||||
assert.equal(snapshot.metadata.mode, "async");
|
||||
assert.equal(snapshot.metadata.availableModels[0].model, "gpt-5.6-sol");
|
||||
await adapter.dispose();
|
||||
});
|
||||
|
||||
test("async adapter projects live token usage notifications into metadata and snapshots", async () => {
|
||||
const rpc = new FakeRpc({
|
||||
"model/list": { data: [], nextCursor: null },
|
||||
});
|
||||
const adapter = new CodexAgentAdapter({ approvalTimeoutMs: 0 }, rpc);
|
||||
const events = [];
|
||||
adapter.onEvent((event) => events.push(event));
|
||||
await adapter.start();
|
||||
await adapter.startThread({});
|
||||
|
||||
rpc.emitNotification({
|
||||
method: "thread/tokenUsage/updated",
|
||||
params: {
|
||||
threadId: "thread-test",
|
||||
// A usage update may arrive after the turn has completed. It must not
|
||||
// make the adapter report that historical turn as active again.
|
||||
turnId: "turn-finished",
|
||||
tokenUsage: {
|
||||
total: {
|
||||
totalTokens: 1_200,
|
||||
inputTokens: 800,
|
||||
cachedInputTokens: 100,
|
||||
cacheWriteInputTokens: 20,
|
||||
outputTokens: 300,
|
||||
reasoningOutputTokens: 80,
|
||||
},
|
||||
last: {
|
||||
totalTokens: 450,
|
||||
inputTokens: 300,
|
||||
cachedInputTokens: 40,
|
||||
cacheWriteInputTokens: 10,
|
||||
outputTokens: 100,
|
||||
reasoningOutputTokens: 40,
|
||||
},
|
||||
modelContextWindow: 128_000,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const expected = {
|
||||
total: {
|
||||
totalTokens: 1_200,
|
||||
inputTokens: 800,
|
||||
cachedInputTokens: 100,
|
||||
cacheWriteInputTokens: 20,
|
||||
outputTokens: 300,
|
||||
reasoningOutputTokens: 80,
|
||||
},
|
||||
last: {
|
||||
totalTokens: 450,
|
||||
inputTokens: 300,
|
||||
cachedInputTokens: 40,
|
||||
cacheWriteInputTokens: 10,
|
||||
outputTokens: 100,
|
||||
reasoningOutputTokens: 40,
|
||||
},
|
||||
modelContextWindow: 128_000,
|
||||
};
|
||||
const snapshot = await adapter.snapshot();
|
||||
assert.deepEqual(snapshot.metadata.tokenUsage, expected);
|
||||
assert.deepEqual(snapshot.metadata.latestTokenUsageInfo, expected);
|
||||
assert.equal(snapshot.turnId, null);
|
||||
|
||||
const usageEvent = events.find((event) => event.raw?.method === "thread/tokenUsage/updated");
|
||||
assert.ok(usageEvent);
|
||||
assert.deepEqual(usageEvent.payload.tokenUsage, expected);
|
||||
assert.deepEqual(usageEvent.payload.latestTokenUsageInfo, expected);
|
||||
// Keep the raw diagnostic envelope redacted while exposing only the safe
|
||||
// numeric projection to the browser.
|
||||
assert.equal(usageEvent.raw.params.tokenUsage, "[REDACTED]");
|
||||
|
||||
rpc.emitNotification({
|
||||
method: "thread/tokenUsage/updated",
|
||||
params: {
|
||||
threadId: "thread-test",
|
||||
turnId: "turn-finished",
|
||||
tokenUsage: { total: { inputTokens: -1 } },
|
||||
},
|
||||
});
|
||||
assert.deepEqual((await adapter.snapshot()).metadata.tokenUsage, expected);
|
||||
await adapter.dispose();
|
||||
});
|
||||
|
||||
test("async adapter resumes a thread with structured history and ignores late notifications", async () => {
|
||||
const thread = {
|
||||
id: "thread-selected",
|
||||
name: "Selected thread",
|
||||
preview: "hello",
|
||||
cwd: "/tmp/selected",
|
||||
createdAt: 1_700_000_000,
|
||||
updatedAt: 1_700_000_010,
|
||||
status: { type: "idle" },
|
||||
turns: [{
|
||||
id: "turn-history",
|
||||
status: "completed",
|
||||
startedAt: 1_700_000_001,
|
||||
completedAt: 1_700_000_004,
|
||||
durationMs: 3_000,
|
||||
items: [
|
||||
{ type: "userMessage", id: "user-1", clientId: null, content: [{ type: "text", text: "hello", text_elements: [] }] },
|
||||
{ type: "reasoning", id: "reason-1", summary: ["Checking files"], content: [] },
|
||||
{ type: "commandExecution", id: "command-1", command: "pwd", cwd: "/tmp/selected", status: "completed", aggregatedOutput: "/tmp/selected\n", exitCode: 0, durationMs: 50, commandActions: [] },
|
||||
{ type: "agentMessage", id: "agent-1", text: "Done", phase: "final_answer" },
|
||||
],
|
||||
}],
|
||||
};
|
||||
const rpc = new FakeRpc({
|
||||
"model/list": { data: [{ id: "model-1", model: "gpt-5.6-sol" }], nextCursor: null },
|
||||
"thread/resume": {
|
||||
thread,
|
||||
model: "gpt-5.6-sol",
|
||||
modelProvider: "openai",
|
||||
serviceTier: null,
|
||||
cwd: "/tmp/selected",
|
||||
approvalPolicy: "on-request",
|
||||
approvalsReviewer: "user",
|
||||
sandbox: { type: "workspaceWrite" },
|
||||
reasoningEffort: "high",
|
||||
},
|
||||
});
|
||||
const adapter = new CodexAgentAdapter({ approvalTimeoutMs: 0 }, rpc);
|
||||
const events = [];
|
||||
adapter.onEvent((event) => events.push(event));
|
||||
await adapter.start();
|
||||
|
||||
const result = await adapter.selectSession({ threadId: "thread-selected" });
|
||||
assert.equal(result.threadId, "thread-selected");
|
||||
let snapshot = await adapter.snapshot();
|
||||
assert.equal(snapshot.messages.length, 4);
|
||||
assert.deepEqual(snapshot.messages.map((message) => message.kind), ["user", "reasoning", "tool", "assistant"]);
|
||||
assert.equal(snapshot.messages[2].output, "/tmp/selected\n");
|
||||
assert.equal(snapshot.metadata.title, "Selected thread");
|
||||
assert.equal(snapshot.metadata.threadSettings.effort, "high");
|
||||
assert.equal(snapshot.metadata.historyComplete, true);
|
||||
assert.equal(snapshot.status.turnStatus, "completed");
|
||||
assert.match(snapshot.outputTail, /Done/);
|
||||
assert.ok(events.some((event) => event.type === "output.snapshot" && event.payload.historyComplete === true));
|
||||
const authoritative = events.find((event) => event.type === "session.snapshot");
|
||||
assert.equal(authoritative.payload.threadId, "thread-selected");
|
||||
assert.equal(authoritative.payload.metadata.model, "gpt-5.6-sol");
|
||||
assert.equal(authoritative.payload.messages.length, 4);
|
||||
|
||||
rpc.emitNotification({
|
||||
method: "item/completed",
|
||||
params: {
|
||||
threadId: "thread-old",
|
||||
turnId: "turn-old",
|
||||
completedAtMs: Date.now(),
|
||||
item: { type: "agentMessage", id: "late-old", text: "wrong thread" },
|
||||
},
|
||||
});
|
||||
rpc.emitNotification({
|
||||
method: "item/completed",
|
||||
params: {
|
||||
threadId: "thread-selected",
|
||||
turnId: "turn-live",
|
||||
completedAtMs: Date.now(),
|
||||
item: { type: "agentMessage", id: "current-item", text: "current thread" },
|
||||
},
|
||||
});
|
||||
snapshot = await adapter.snapshot();
|
||||
assert.equal(snapshot.messages.some((message) => message.itemId === "late-old"), false);
|
||||
assert.equal(snapshot.messages.some((message) => message.itemId === "current-item"), true);
|
||||
await adapter.dispose();
|
||||
});
|
||||
|
||||
test("async adapter hydrates paginated turns and items into chronological complete history", async () => {
|
||||
const threadId = "thread-paged-history";
|
||||
const userItem = (id, text) => ({
|
||||
type: "userMessage",
|
||||
id,
|
||||
clientId: null,
|
||||
content: [{ type: "text", text, text_elements: [] }],
|
||||
});
|
||||
const assistantItem = (id, text) => ({
|
||||
type: "agentMessage",
|
||||
id,
|
||||
text,
|
||||
phase: "final_answer",
|
||||
});
|
||||
const earlyUser = userItem("early-user", "first question");
|
||||
const rpc = new FakeRpc({
|
||||
"model/list": { data: [], nextCursor: null },
|
||||
"thread/resume": {
|
||||
thread: {
|
||||
id: threadId,
|
||||
name: "Paged history",
|
||||
preview: "first question",
|
||||
cwd: "/tmp/paged",
|
||||
createdAt: 50,
|
||||
updatedAt: 350,
|
||||
historyMode: "paginated",
|
||||
status: { type: "idle" },
|
||||
turns: [],
|
||||
},
|
||||
model: "gpt-5.6-sol",
|
||||
cwd: "/tmp/paged",
|
||||
initialTurnsPage: {
|
||||
data: [{
|
||||
id: "turn-late",
|
||||
status: "completed",
|
||||
startedAt: 300,
|
||||
completedAt: 310,
|
||||
itemsView: "full",
|
||||
items: [userItem("late-user", "third question"), assistantItem("late-agent", "third answer")],
|
||||
}],
|
||||
nextCursor: "turn-page-2",
|
||||
backwardsCursor: null,
|
||||
},
|
||||
},
|
||||
"thread/turns/list": (params) => {
|
||||
if (params.cursor === "turn-page-2") {
|
||||
return {
|
||||
data: [{
|
||||
id: "turn-early",
|
||||
status: "completed",
|
||||
startedAt: 100,
|
||||
completedAt: 110,
|
||||
itemsView: "summary",
|
||||
items: [earlyUser],
|
||||
}],
|
||||
nextCursor: "turn-page-3",
|
||||
backwardsCursor: null,
|
||||
};
|
||||
}
|
||||
assert.equal(params.cursor, "turn-page-3");
|
||||
return {
|
||||
data: [{
|
||||
id: "turn-middle",
|
||||
status: "completed",
|
||||
startedAt: 200,
|
||||
completedAt: 210,
|
||||
itemsView: "full",
|
||||
items: [userItem("middle-user", "second question"), assistantItem("middle-agent", "second answer")],
|
||||
}],
|
||||
nextCursor: null,
|
||||
backwardsCursor: null,
|
||||
};
|
||||
},
|
||||
"thread/items/list": (params) => {
|
||||
assert.equal(params.turnId, "turn-early");
|
||||
return {
|
||||
data: [
|
||||
// The summary row is repeated by the full item page; hydration must
|
||||
// de-duplicate it while adding the omitted assistant response.
|
||||
{ turnId: "turn-early", item: earlyUser },
|
||||
{ turnId: "turn-early", item: assistantItem("early-agent", "first answer") },
|
||||
],
|
||||
nextCursor: null,
|
||||
backwardsCursor: null,
|
||||
};
|
||||
},
|
||||
});
|
||||
const events = [];
|
||||
const adapter = new CodexAgentAdapter({ approvalTimeoutMs: 0 }, rpc);
|
||||
adapter.onEvent((event) => events.push(event));
|
||||
await adapter.start();
|
||||
await adapter.selectSession({ threadId });
|
||||
|
||||
const resume = rpc.requests.find((entry) => entry.method === "thread/resume");
|
||||
assert.deepEqual(resume.params, {
|
||||
threadId,
|
||||
excludeTurns: true,
|
||||
initialTurnsPage: { limit: 100, sortDirection: "asc", itemsView: "full" },
|
||||
});
|
||||
const turnPages = rpc.requests.filter((entry) => entry.method === "thread/turns/list");
|
||||
assert.deepEqual(turnPages.map((entry) => entry.params.cursor), ["turn-page-2", "turn-page-3"]);
|
||||
assert.ok(turnPages.every((entry) => entry.params.threadId === threadId
|
||||
&& entry.params.limit === 100
|
||||
&& entry.params.sortDirection === "asc"
|
||||
&& entry.params.itemsView === "full"));
|
||||
const itemPages = rpc.requests.filter((entry) => entry.method === "thread/items/list");
|
||||
assert.deepEqual(itemPages.map((entry) => entry.params), [{
|
||||
threadId,
|
||||
turnId: "turn-early",
|
||||
limit: 100,
|
||||
sortDirection: "asc",
|
||||
}]);
|
||||
assert.equal(rpc.requests.some((entry) => entry.method === "thread/read"), false);
|
||||
|
||||
const snapshot = await adapter.snapshot();
|
||||
assert.deepEqual(snapshot.messages.map((message) => [message.turnId, message.text]), [
|
||||
["turn-early", "first question"],
|
||||
["turn-early", "first answer"],
|
||||
["turn-middle", "second question"],
|
||||
["turn-middle", "second answer"],
|
||||
["turn-late", "third question"],
|
||||
["turn-late", "third answer"],
|
||||
]);
|
||||
assert.equal(snapshot.metadata.historyComplete, true);
|
||||
const outputSnapshot = events.find((event) => event.type === "output.snapshot");
|
||||
assert.equal(outputSnapshot.payload.historyComplete, true);
|
||||
assert.deepEqual(outputSnapshot.payload.messages.map((message) => message.text), [
|
||||
"first question",
|
||||
"first answer",
|
||||
"second question",
|
||||
"second answer",
|
||||
"third question",
|
||||
"third answer",
|
||||
]);
|
||||
await adapter.dispose();
|
||||
});
|
||||
|
||||
test("async adapter falls back to thread/read when resume omits existing history", async () => {
|
||||
const metadataThread = {
|
||||
id: "thread-paginated",
|
||||
preview: "existing conversation",
|
||||
cwd: "/tmp/project",
|
||||
createdAt: 1_700_000_000,
|
||||
updatedAt: 1_700_000_100,
|
||||
status: { type: "idle" },
|
||||
turns: [],
|
||||
};
|
||||
const rpc = new FakeRpc({
|
||||
"model/list": { data: [], nextCursor: null },
|
||||
"thread/resume": { thread: metadataThread, model: "gpt-5.6-sol", cwd: "/tmp/project" },
|
||||
"thread/read": {
|
||||
thread: {
|
||||
...metadataThread,
|
||||
turns: [{
|
||||
id: "turn-read",
|
||||
status: "completed",
|
||||
items: [{ type: "agentMessage", id: "read-agent", text: "hydrated history" }],
|
||||
}],
|
||||
},
|
||||
},
|
||||
});
|
||||
const adapter = new CodexAgentAdapter({ approvalTimeoutMs: 0 }, rpc);
|
||||
await adapter.start();
|
||||
await adapter.selectSession({ threadId: "thread-paginated" });
|
||||
const read = rpc.requests.find((entry) => entry.method === "thread/read");
|
||||
assert.deepEqual(read.params, { threadId: "thread-paginated", includeTurns: true });
|
||||
assert.equal((await adapter.snapshot()).messages[0].text, "hydrated history");
|
||||
await adapter.dispose();
|
||||
});
|
||||
|
||||
test("async adapter starts new sessions and sends flat durable thread settings", async () => {
|
||||
const rpc = new FakeRpc({
|
||||
"model/list": { data: [], nextCursor: null },
|
||||
"thread/start": {
|
||||
thread: { id: "thread-new", preview: "", cwd: "/tmp/new", status: { type: "idle" }, turns: [] },
|
||||
model: "gpt-5.6-sol",
|
||||
cwd: "/tmp/new",
|
||||
reasoningEffort: "medium",
|
||||
},
|
||||
"thread/settings/update": { ok: true },
|
||||
});
|
||||
const adapter = new CodexAgentAdapter({ approvalTimeoutMs: 0, defaultCwd: "/tmp/default" }, rpc);
|
||||
await adapter.start();
|
||||
await adapter.newSession({});
|
||||
await adapter.updateThreadSettings({
|
||||
threadSettings: {
|
||||
model: "gpt-5.6-terra",
|
||||
effort: "high",
|
||||
approvalPolicy: "on-request",
|
||||
approvalsReviewer: "user",
|
||||
sandboxPolicy: "workspace-write",
|
||||
permissions: ":workspace",
|
||||
},
|
||||
});
|
||||
const start = rpc.requests.find((entry) => entry.method === "thread/start");
|
||||
assert.equal(start.params.cwd, "/tmp/default");
|
||||
const update = rpc.requests.find((entry) => entry.method === "thread/settings/update");
|
||||
assert.deepEqual(update.params, {
|
||||
threadId: "thread-new",
|
||||
model: "gpt-5.6-terra",
|
||||
effort: "high",
|
||||
approvalPolicy: "on-request",
|
||||
approvalsReviewer: "user",
|
||||
permissions: ":workspace",
|
||||
});
|
||||
assert.equal(Object.prototype.hasOwnProperty.call(update.params, "sandboxPolicy"), false);
|
||||
const snapshot = await adapter.snapshot();
|
||||
assert.equal(snapshot.metadata.model, "gpt-5.6-terra");
|
||||
assert.equal(snapshot.metadata.latestReasoningEffort, "high");
|
||||
assert.equal(snapshot.metadata.sandboxPolicy, "workspace-write");
|
||||
await adapter.dispose();
|
||||
});
|
||||
|
||||
test("thread settings updates require the send_task_input capability", async () => {
|
||||
const rpc = new FakeRpc();
|
||||
const adapter = new CodexAgentAdapter({ approvalTimeoutMs: 0 }, rpc);
|
||||
await adapter.start();
|
||||
const relay = new FakeRelay();
|
||||
const host = new RelayHost({
|
||||
adapter,
|
||||
relay,
|
||||
capabilities: ["read_output"],
|
||||
sessionId: "test-session",
|
||||
});
|
||||
|
||||
await host.handleFrame({
|
||||
kind: "command",
|
||||
type: "thread.settings.update",
|
||||
commandId: "settings-without-capability",
|
||||
actor: { role: "operator" },
|
||||
payload: { threadSettings: { model: "gpt-5.6-sol", effort: "high" } },
|
||||
});
|
||||
|
||||
const result = relay.frames.find((frame) => frame.payload?.commandId === "settings-without-capability");
|
||||
assert.equal(result.type, "command.rejected");
|
||||
assert.match(result.payload.error, /missing capability: send_task_input/);
|
||||
await adapter.dispose();
|
||||
});
|
||||
|
||||
test("RelayHost exposes session list as read-only and protects session selection", async () => {
|
||||
const relay = new FakeRelay();
|
||||
const calls = [];
|
||||
const adapter = {
|
||||
async start() {},
|
||||
async sendInput() { return {}; },
|
||||
async cancel() { return {}; },
|
||||
async respondApproval() { return {}; },
|
||||
async snapshot() { return { threadId: "thread-a", turnId: null, state: "idle", pendingApprovals: [], outputTail: "" }; },
|
||||
onEvent() { return { dispose() {} }; },
|
||||
async dispose() {},
|
||||
async listSessions(params) {
|
||||
calls.push({ method: "listSessions", params });
|
||||
return { sessions: [{ threadId: "thread-a", title: "A", updatedAtMs: null, active: true, available: true }], activeThreadId: "thread-a" };
|
||||
},
|
||||
async selectSession(params) {
|
||||
calls.push({ method: "selectSession", params });
|
||||
return { threadId: params.threadId, previousThreadId: "thread-a", switched: true, available: true };
|
||||
},
|
||||
async newSession(params) {
|
||||
calls.push({ method: "newSession", params });
|
||||
return { opened: true, command: "chatgpt.newCodexPanel" };
|
||||
},
|
||||
getControlMode() { return "sync"; },
|
||||
async setControlMode(params) {
|
||||
calls.push({ method: "setControlMode", params });
|
||||
return { changed: true, controlMode: params.mode, previousControlMode: "sync", modeEpoch: 1 };
|
||||
},
|
||||
};
|
||||
const host = new RelayHost({
|
||||
adapter,
|
||||
relay,
|
||||
capabilities: ["read_output", "send_task_input"],
|
||||
sessionId: "test-session",
|
||||
});
|
||||
|
||||
await host.handleFrame({ kind: "command", type: "session/list", commandId: "list-1", actor: { role: "viewer" }, payload: {} });
|
||||
const listed = relay.frames.find((frame) => frame.payload?.commandId === "list-1");
|
||||
assert.equal(listed.type, "command.accepted");
|
||||
assert.equal(listed.payload.result.activeThreadId, "thread-a");
|
||||
assert.equal(calls[0].method, "listSessions");
|
||||
|
||||
await host.handleFrame({ kind: "command", type: "session/select", commandId: "select-viewer", actor: { role: "viewer" }, payload: { threadId: "thread-b" } });
|
||||
const denied = relay.frames.find((frame) => frame.payload?.commandId === "select-viewer");
|
||||
assert.equal(denied.type, "command.rejected");
|
||||
|
||||
await host.handleFrame({ kind: "command", type: "session/select", commandId: "select-operator", actor: { role: "operator" }, payload: { threadId: "thread-b" } });
|
||||
const selected = relay.frames.find((frame) => frame.payload?.commandId === "select-operator");
|
||||
assert.equal(selected.type, "command.accepted");
|
||||
assert.equal(selected.payload.result.threadId, "thread-b");
|
||||
assert.equal(calls.at(-1).method, "selectSession");
|
||||
|
||||
await host.handleFrame({ kind: "command", type: "session/new", commandId: "new-viewer", actor: { role: "viewer" }, payload: {} });
|
||||
const deniedNew = relay.frames.find((frame) => frame.payload?.commandId === "new-viewer");
|
||||
assert.equal(deniedNew.type, "command.rejected");
|
||||
|
||||
await host.handleFrame({ kind: "command", type: "session/new", commandId: "new-operator", actor: { role: "operator" }, payload: {} });
|
||||
const opened = relay.frames.find((frame) => frame.payload?.commandId === "new-operator");
|
||||
assert.equal(opened.type, "command.accepted");
|
||||
assert.equal(opened.payload.result.command, "chatgpt.newCodexPanel");
|
||||
assert.equal(calls.at(-1).method, "newSession");
|
||||
|
||||
await host.handleFrame({ kind: "command", type: "control/mode/get", commandId: "mode-get-viewer", actor: { role: "viewer" }, payload: {} });
|
||||
const mode = relay.frames.find((frame) => frame.payload?.commandId === "mode-get-viewer");
|
||||
assert.equal(mode.type, "command.accepted");
|
||||
assert.equal(mode.payload.result.mode, "sync");
|
||||
|
||||
await host.handleFrame({ kind: "command", type: "control/mode/set", commandId: "mode-set-viewer", actor: { role: "viewer" }, payload: { mode: "async" } });
|
||||
const deniedMode = relay.frames.find((frame) => frame.payload?.commandId === "mode-set-viewer");
|
||||
assert.equal(deniedMode.type, "command.rejected");
|
||||
|
||||
await host.handleFrame({ kind: "command", type: "control/mode/set", commandId: "mode-set-operator", actor: { role: "operator" }, payload: { mode: "async" } });
|
||||
const changedMode = relay.frames.find((frame) => frame.payload?.commandId === "mode-set-operator");
|
||||
assert.equal(changedMode.type, "command.accepted");
|
||||
assert.equal(changedMode.payload.result.controlMode, "async");
|
||||
assert.equal(calls.at(-1).method, "setControlMode");
|
||||
});
|
||||
|
||||
test("approval decision conflicts and unknown tagged objects fail closed", async () => {
|
||||
const rpc = new FakeRpc();
|
||||
const adapter = new CodexAgentAdapter({ approvalTimeoutMs: 0 }, rpc);
|
||||
await adapter.start();
|
||||
const relay = new FakeRelay();
|
||||
const host = new RelayHost({
|
||||
adapter,
|
||||
relay,
|
||||
capabilities: ["read_output", "send_task_input", "cancel_task", "approve_low_risk"],
|
||||
sessionId: "test-session",
|
||||
});
|
||||
|
||||
rpc.emitRequest({
|
||||
id: 3,
|
||||
method: "execCommandApproval",
|
||||
params: { conversationId: "thread-test", callId: "call-3", command: ["echo", "safe"] },
|
||||
});
|
||||
await host.handleFrame({
|
||||
kind: "command",
|
||||
type: "approval.respond",
|
||||
commandId: "conflicting-response",
|
||||
actor: { role: "operator" },
|
||||
payload: {
|
||||
requestId: 3,
|
||||
decision: "deny",
|
||||
response: { decision: "approved_mcp_policy_amendment" },
|
||||
},
|
||||
});
|
||||
assert.deepEqual(rpc.responses[0], {
|
||||
id: 3,
|
||||
result: { decision: { denied: { rejection: "approval response implies allow, but decision is deny" } } },
|
||||
});
|
||||
|
||||
rpc.emitRequest({
|
||||
id: 4,
|
||||
method: "item/commandExecution/requestApproval",
|
||||
params: { threadId: "thread-test", turnId: "turn-test", itemId: "item-4", command: "echo safe" },
|
||||
});
|
||||
await host.handleFrame({
|
||||
kind: "command",
|
||||
type: "approval.respond",
|
||||
commandId: "unknown-tagged-response",
|
||||
actor: { role: "operator" },
|
||||
payload: {
|
||||
requestId: 4,
|
||||
decision: "allow",
|
||||
response: { decision: { futurePolicyGrant: { scope: "all" } } },
|
||||
},
|
||||
});
|
||||
assert.deepEqual(rpc.responses[1], {
|
||||
id: 4,
|
||||
result: { decision: "decline" },
|
||||
});
|
||||
await adapter.dispose();
|
||||
});
|
||||
@@ -0,0 +1,298 @@
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const test = require("node:test");
|
||||
const { WebSocket } = require("ws");
|
||||
|
||||
const { AetherVscodexCloudServer, RoomManager } = require("../cloud/server.js");
|
||||
|
||||
const internalToken = "test-internal-token-with-enough-entropy";
|
||||
|
||||
function internalFetch(base, pathname, options = {}) {
|
||||
return fetch(`${base}${pathname}`, {
|
||||
...options,
|
||||
headers: {
|
||||
Authorization: `Bearer ${internalToken}`,
|
||||
...(options.body ? { "Content-Type": "application/json" } : {}),
|
||||
...(options.headers || {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function websocketClient(base, clientType, token, sessionId) {
|
||||
const socket = new WebSocket(`${base.replace(/^http/, "ws")}/api/vscodex/ws`);
|
||||
const messages = [];
|
||||
const waiters = [];
|
||||
const wait = (predicate, timeout = 5_000, label = "websocket frame") => new Promise((resolve, reject) => {
|
||||
const existing = messages.find(predicate);
|
||||
if (existing) return resolve(existing);
|
||||
const timer = setTimeout(() => {
|
||||
const index = waiters.findIndex((entry) => entry.resolve === resolve);
|
||||
if (index >= 0) waiters.splice(index, 1);
|
||||
reject(new Error(`timed out waiting for ${label}; received: ${JSON.stringify(messages.map((message) => ({ type: message.type, kind: message.kind, commandId: message.commandId })))}`));
|
||||
}, timeout);
|
||||
waiters.push({
|
||||
predicate,
|
||||
resolve: (message) => {
|
||||
clearTimeout(timer);
|
||||
resolve(message);
|
||||
},
|
||||
});
|
||||
});
|
||||
socket.on("message", (data) => {
|
||||
const message = JSON.parse(data.toString("utf8"));
|
||||
messages.push(message);
|
||||
for (let index = waiters.length - 1; index >= 0; index -= 1) {
|
||||
if (!waiters[index].predicate(message)) continue;
|
||||
const waiter = waiters.splice(index, 1)[0];
|
||||
waiter.resolve(message);
|
||||
}
|
||||
});
|
||||
return new Promise((resolve, reject) => {
|
||||
socket.once("open", () => {
|
||||
socket.send(JSON.stringify({ v: 1, kind: "hello", clientType, protocol: 1, ...(sessionId ? { sessionId } : {}) }));
|
||||
socket.send(JSON.stringify(clientType === "host"
|
||||
? { v: 1, kind: "auth", accessToken: token }
|
||||
: { type: "auth", token }));
|
||||
wait((message) => message.type === "auth.ok").then(() => resolve({ socket, wait, messages }), reject);
|
||||
});
|
||||
socket.once("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
async function pairDevice(base, userId, name) {
|
||||
const pairingResponse = await internalFetch(base, `/internal/v1/users/${encodeURIComponent(userId)}/pairings`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ name }),
|
||||
});
|
||||
assert.equal(pairingResponse.status, 201);
|
||||
const pairing = await pairingResponse.json();
|
||||
const exchangeResponse = await fetch(`${base}/v1/pairings/exchange`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ code: pairing.code, name }),
|
||||
});
|
||||
assert.equal(exchangeResponse.status, 201);
|
||||
return exchangeResponse.json();
|
||||
}
|
||||
|
||||
async function browserTicket(base, userId, deviceId) {
|
||||
const response = await internalFetch(base, `/internal/v1/users/${encodeURIComponent(userId)}/ws-tickets`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ device_id: deviceId }),
|
||||
});
|
||||
assert.equal(response.status, 201);
|
||||
return response.json();
|
||||
}
|
||||
|
||||
function exchangeAttempt(base, headers = {}) {
|
||||
return fetch(`${base}/v1/pairings/exchange`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", ...headers },
|
||||
body: JSON.stringify({ code: "INVALID-CODE" }),
|
||||
});
|
||||
}
|
||||
|
||||
test("pairing exchange trusts a gateway client IP only with valid internal authentication", async (t) => {
|
||||
const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "aether-vscodex-rate-limit-"));
|
||||
const server = new AetherVscodexCloudServer({
|
||||
host: "127.0.0.1",
|
||||
port: 0,
|
||||
internalToken,
|
||||
publicWsUrl: "wss://aether.example/api/vscodex/ws",
|
||||
dataDir,
|
||||
});
|
||||
await server.start();
|
||||
t.after(async () => {
|
||||
await server.stop();
|
||||
fs.rmSync(dataDir, { recursive: true, force: true });
|
||||
});
|
||||
const address = server.address();
|
||||
const base = `http://127.0.0.1:${address.port}`;
|
||||
const trustedHeaders = (clientIp) => ({
|
||||
Authorization: `Bearer ${internalToken}`,
|
||||
"X-Aether-Client-IP": clientIp,
|
||||
});
|
||||
|
||||
for (let attempt = 0; attempt < 10; attempt += 1) {
|
||||
assert.equal((await exchangeAttempt(base, trustedHeaders("198.51.100.10"))).status, 400);
|
||||
}
|
||||
assert.equal((await exchangeAttempt(base, trustedHeaders("198.51.100.10"))).status, 429);
|
||||
assert.equal((await exchangeAttempt(base, trustedHeaders("198.51.100.11"))).status, 400);
|
||||
assert.equal((await exchangeAttempt(base, trustedHeaders("2001:db8::10"))).status, 400);
|
||||
|
||||
server.exchangeAttempts.clear();
|
||||
for (let attempt = 0; attempt < 5; attempt += 1) {
|
||||
assert.equal((await exchangeAttempt(base, { "X-Aether-Client-IP": `198.51.100.${20 + attempt}` })).status, 400);
|
||||
}
|
||||
for (let attempt = 0; attempt < 5; attempt += 1) {
|
||||
assert.equal((await exchangeAttempt(base, {
|
||||
Authorization: "Bearer invalid-internal-token",
|
||||
"X-Aether-Client-IP": `198.51.100.${30 + attempt}`,
|
||||
})).status, 400);
|
||||
}
|
||||
assert.equal((await exchangeAttempt(base, { "X-Aether-Client-IP": "198.51.100.99" })).status, 429);
|
||||
|
||||
server.exchangeAttempts.clear();
|
||||
const invalidForwardedAddresses = ["proxy.internal", "198.51.100.40, 198.51.100.41"];
|
||||
for (let attempt = 0; attempt < 10; attempt += 1) {
|
||||
assert.equal((await exchangeAttempt(base, trustedHeaders(invalidForwardedAddresses[attempt % 2]))).status, 400);
|
||||
}
|
||||
assert.equal((await exchangeAttempt(base, trustedHeaders("198.51.100.42, 198.51.100.43"))).status, 429);
|
||||
});
|
||||
|
||||
test("cloud sidecar pairs a device and isolates host/browser traffic by Aether user and device", async (t) => {
|
||||
const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "aether-vscodex-test-"));
|
||||
const server = new AetherVscodexCloudServer({
|
||||
host: "127.0.0.1",
|
||||
port: 0,
|
||||
internalToken,
|
||||
publicWsUrl: "wss://aether.example/api/vscodex/ws",
|
||||
dataDir,
|
||||
pairingTtlMs: 5_000,
|
||||
ticketTtlMs: 5_000,
|
||||
});
|
||||
await server.start();
|
||||
t.after(async () => {
|
||||
await server.stop();
|
||||
fs.rmSync(dataDir, { recursive: true, force: true });
|
||||
});
|
||||
const address = server.address();
|
||||
const base = `http://127.0.0.1:${address.port}`;
|
||||
|
||||
const unauthorized = await fetch(`${base}/internal/v1/users/user-a/devices`);
|
||||
assert.equal(unauthorized.status, 401);
|
||||
|
||||
const paired = await pairDevice(base, "user-a", "MacBook VS Code");
|
||||
assert.match(paired.device_token, /^avx1\./);
|
||||
const devicesResponse = await internalFetch(base, "/internal/v1/users/user-a/devices");
|
||||
assert.equal(devicesResponse.status, 200);
|
||||
const devices = await devicesResponse.json();
|
||||
assert.deepEqual(devices.devices.map((device) => ({ id: device.id, name: device.name, connected: device.connected })), [
|
||||
{ id: paired.device_id, name: "MacBook VS Code", connected: false },
|
||||
]);
|
||||
|
||||
const ticket = await browserTicket(base, "user-a", paired.device_id);
|
||||
assert.equal(ticket.ws_url, "/api/vscodex/ws");
|
||||
const host = await websocketClient(base, "host", paired.device_token, "host-user-a");
|
||||
const browser = await websocketClient(base, "web", ticket.ticket);
|
||||
t.after(() => host.socket.close());
|
||||
t.after(() => browser.socket.close());
|
||||
browser.socket.send(JSON.stringify({ type: "subscribe", fromSeq: 0 }));
|
||||
|
||||
host.socket.send(JSON.stringify({
|
||||
v: 1,
|
||||
kind: "event",
|
||||
type: "connection.opened",
|
||||
id: "connection-a",
|
||||
sessionId: "host-user-a",
|
||||
seq: 1,
|
||||
ts: new Date().toISOString(),
|
||||
payload: {},
|
||||
}));
|
||||
host.socket.send(JSON.stringify({
|
||||
v: 1,
|
||||
kind: "event",
|
||||
type: "session.snapshot",
|
||||
id: "snapshot-a",
|
||||
sessionId: "host-user-a",
|
||||
seq: 2,
|
||||
ts: new Date().toISOString(),
|
||||
payload: { threadId: "thread-a", state: "idle", messages: [{ kind: "assistant", text: "user-a-only" }] },
|
||||
}));
|
||||
const snapshot = await browser.wait((message) => message.kind === "event" && message.type === "session.snapshot", 5_000, "session snapshot");
|
||||
assert.equal(snapshot.payload.threadId, "thread-a");
|
||||
assert.equal(snapshot.payload.messages[0].text, "user-a-only");
|
||||
|
||||
browser.socket.send(JSON.stringify({ type: "command", commandId: "cmd-a", method: "session/list", params: {} }));
|
||||
const command = await host.wait((message) => message.kind === "command" && message.commandId === "cmd-a", 5_000, "browser command");
|
||||
assert.equal(command.type, "session/list");
|
||||
|
||||
const secondUser = await pairDevice(base, "user-b", "Other VS Code");
|
||||
const secondTicket = await browserTicket(base, "user-b", secondUser.device_id);
|
||||
const secondBrowser = await websocketClient(base, "web", secondTicket.ticket);
|
||||
t.after(() => secondBrowser.socket.close());
|
||||
secondBrowser.socket.send(JSON.stringify({ type: "subscribe", fromSeq: 0 }));
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
assert.equal(secondBrowser.messages.some((message) => message.payload?.threadId === "thread-a"), false);
|
||||
|
||||
const reusedTicket = new WebSocket(`${base.replace(/^http/, "ws")}/api/vscodex/ws`);
|
||||
const closed = new Promise((resolve, reject) => {
|
||||
reusedTicket.once("open", () => {
|
||||
reusedTicket.send(JSON.stringify({ v: 1, kind: "hello", clientType: "web", protocol: 1 }));
|
||||
reusedTicket.send(JSON.stringify({ type: "auth", token: ticket.ticket }));
|
||||
});
|
||||
reusedTicket.once("close", (code) => resolve(code));
|
||||
reusedTicket.once("error", reject);
|
||||
});
|
||||
assert.equal(await closed, 1008, "browser tickets are one-time credentials");
|
||||
});
|
||||
|
||||
test("device revocation closes its room and blocks future host authentication", async (t) => {
|
||||
const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "aether-vscodex-revoke-"));
|
||||
const server = new AetherVscodexCloudServer({
|
||||
host: "127.0.0.1",
|
||||
port: 0,
|
||||
internalToken,
|
||||
publicWsUrl: "wss://aether.example/api/vscodex/ws",
|
||||
dataDir,
|
||||
});
|
||||
await server.start();
|
||||
t.after(async () => {
|
||||
await server.stop();
|
||||
fs.rmSync(dataDir, { recursive: true, force: true });
|
||||
});
|
||||
const address = server.address();
|
||||
const base = `http://127.0.0.1:${address.port}`;
|
||||
const paired = await pairDevice(base, "user-a", "Revoked device");
|
||||
const host = await websocketClient(base, "host", paired.device_token, "revoked-host");
|
||||
|
||||
const response = await internalFetch(base, `/internal/v1/users/user-a/devices/${paired.device_id}`, { method: "DELETE" });
|
||||
assert.equal(response.status, 204);
|
||||
await new Promise((resolve) => host.socket.once("close", resolve));
|
||||
|
||||
const rejected = new WebSocket(`${base.replace(/^http/, "ws")}/api/vscodex/ws`);
|
||||
const closed = new Promise((resolve, reject) => {
|
||||
rejected.once("open", () => {
|
||||
rejected.send(JSON.stringify({ v: 1, kind: "hello", clientType: "host", protocol: 1, sessionId: "retry" }));
|
||||
rejected.send(JSON.stringify({ v: 1, kind: "auth", accessToken: paired.device_token }));
|
||||
});
|
||||
rejected.once("close", (code) => resolve(code));
|
||||
rejected.once("error", reject);
|
||||
});
|
||||
assert.equal(await closed, 1008);
|
||||
});
|
||||
|
||||
test("room revocation wins a concurrent room creation", async () => {
|
||||
const rooms = new RoomManager();
|
||||
let releaseCreation;
|
||||
const creationGate = new Promise((resolve) => { releaseCreation = resolve; });
|
||||
let stopped = false;
|
||||
const room = {
|
||||
key: rooms.key("user-a", "device-a"),
|
||||
userId: "user-a",
|
||||
deviceId: "device-a",
|
||||
relay: { stop: async () => { stopped = true; } },
|
||||
connections: 0,
|
||||
lastActiveMs: Date.now(),
|
||||
};
|
||||
rooms.createRoom = async (key) => {
|
||||
await creationGate;
|
||||
rooms.rooms.set(key, room);
|
||||
return room;
|
||||
};
|
||||
|
||||
const pendingGet = rooms.get("user-a", "device-a");
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
const pendingRevoke = rooms.revoke("user-a", "device-a");
|
||||
releaseCreation();
|
||||
|
||||
await assert.rejects(pendingGet, /device revoked/);
|
||||
await pendingRevoke;
|
||||
assert.equal(stopped, true);
|
||||
assert.equal(rooms.rooms.has(room.key), false);
|
||||
await assert.rejects(rooms.get("user-a", "device-a"), /device revoked/);
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,209 @@
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const net = require("node:net");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { mkdtempSync, rmSync } = require("node:fs");
|
||||
const test = require("node:test");
|
||||
|
||||
const {
|
||||
CODEX_IPC_METHOD_VERSIONS,
|
||||
CodexIpcClient,
|
||||
IpcFrameDecoder,
|
||||
applyIpcPatches,
|
||||
encodeIpcFrame,
|
||||
} = require("../vscode-extension/dist/codexIpc.js");
|
||||
|
||||
function waitFor(predicate, timeoutMs = 2_000) {
|
||||
const started = Date.now();
|
||||
return new Promise((resolve, reject) => {
|
||||
const poll = () => {
|
||||
if (predicate()) return resolve();
|
||||
if (Date.now() - started >= timeoutMs) return reject(new Error("timed out waiting for fixture"));
|
||||
setTimeout(poll, 5);
|
||||
};
|
||||
poll();
|
||||
});
|
||||
}
|
||||
|
||||
test("private IPC framing handles split UTF-8 frames", () => {
|
||||
const message = {
|
||||
type: "broadcast",
|
||||
method: "thread-stream-following-changed",
|
||||
sourceClientId: "client-1",
|
||||
version: 1,
|
||||
params: { conversationId: "thread-1", hostId: "local", following: true, text: "中文" },
|
||||
};
|
||||
const frame = encodeIpcFrame(message);
|
||||
const decoder = new IpcFrameDecoder();
|
||||
const first = decoder.push(frame.subarray(0, 3));
|
||||
assert.deepEqual(first, []);
|
||||
const second = decoder.push(frame.subarray(3, frame.length - 1));
|
||||
assert.deepEqual(second, []);
|
||||
assert.deepEqual(decoder.push(frame.subarray(frame.length - 1)), [message]);
|
||||
});
|
||||
|
||||
test("applyIpcPatches updates a conversation snapshot", () => {
|
||||
const initial = { turns: [{ items: [{ text: "old" }] }], status: "idle" };
|
||||
const next = applyIpcPatches(initial, [
|
||||
{ op: "replace", path: ["turns", 0, "items", 0, "text"], value: "new" },
|
||||
{ op: "add", path: ["turns", 0, "items", 1], value: { text: "second" } },
|
||||
{ op: "replace", path: ["status"], value: "active" },
|
||||
]);
|
||||
assert.deepEqual(next, {
|
||||
turns: [{ items: [{ text: "new" }, { text: "second" }] }],
|
||||
status: "active",
|
||||
});
|
||||
});
|
||||
|
||||
test("fixture owner receives follow/start/steer/interrupt/approval requests", async () => {
|
||||
const temp = mkdtempSync(path.join(os.tmpdir(), "codex-ipc-fixture-"));
|
||||
const socketPath = path.join(temp, "ipc.sock");
|
||||
const threadId = "11111111-1111-4111-8111-111111111111";
|
||||
const ownerId = "owner-client";
|
||||
const requests = [];
|
||||
const followingBroadcasts = [];
|
||||
let fixtureSocket;
|
||||
const server = net.createServer((socket) => {
|
||||
fixtureSocket = socket;
|
||||
const decoder = new IpcFrameDecoder();
|
||||
socket.on("data", (chunk) => {
|
||||
for (const message of decoder.push(chunk)) {
|
||||
if (message.type === "request" && message.method === "initialize") {
|
||||
socket.write(encodeIpcFrame({
|
||||
type: "response",
|
||||
requestId: message.requestId,
|
||||
resultType: "success",
|
||||
method: "initialize",
|
||||
handledByClientId: "fixture-client",
|
||||
result: { clientId: "fixture-client" },
|
||||
}));
|
||||
continue;
|
||||
}
|
||||
if (message.type === "broadcast" && message.method === "thread-stream-following-changed") {
|
||||
followingBroadcasts.push(message);
|
||||
const target = message.sourceClientId;
|
||||
socket.write(encodeIpcFrame({
|
||||
type: "broadcast",
|
||||
method: "thread-stream-state-changed",
|
||||
sourceClientId: ownerId,
|
||||
targetClientIds: [target],
|
||||
version: CODEX_IPC_METHOD_VERSIONS["thread-stream-state-changed"],
|
||||
params: {
|
||||
conversationId: threadId,
|
||||
hostId: "local",
|
||||
change: {
|
||||
type: "snapshot",
|
||||
revision: 1,
|
||||
conversationState: { id: threadId, title: "fixture", turns: [], requests: [] },
|
||||
},
|
||||
},
|
||||
}));
|
||||
continue;
|
||||
}
|
||||
if (message.type === "request") {
|
||||
requests.push(message);
|
||||
socket.write(encodeIpcFrame({
|
||||
type: "response",
|
||||
requestId: message.requestId,
|
||||
resultType: "success",
|
||||
method: message.method,
|
||||
handledByClientId: ownerId,
|
||||
result: { method: message.method, ok: true },
|
||||
}));
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
try {
|
||||
await new Promise((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(socketPath, resolve);
|
||||
});
|
||||
const client = new CodexIpcClient({ socketPath, autoReconnect: false });
|
||||
const streamEvents = [];
|
||||
client.onStreamEvent((event) => streamEvents.push(event));
|
||||
await client.connect();
|
||||
await client.followConversation(threadId);
|
||||
await waitFor(() => streamEvents.some((event) => event.kind === "snapshot"));
|
||||
assert.equal(client.getConversationState(threadId).ownerClientId, ownerId);
|
||||
fixtureSocket.write(encodeIpcFrame({
|
||||
type: "broadcast",
|
||||
method: "thread-stream-following-status-requested",
|
||||
sourceClientId: ownerId,
|
||||
targetClientIds: ["fixture-client"],
|
||||
version: CODEX_IPC_METHOD_VERSIONS["thread-stream-following-status-requested"],
|
||||
params: { conversationId: threadId, hostId: "local" },
|
||||
}));
|
||||
await waitFor(() => followingBroadcasts.length >= 2);
|
||||
assert.deepEqual(followingBroadcasts[1].targetClientIds, [ownerId]);
|
||||
assert.deepEqual(followingBroadcasts[1].params, {
|
||||
conversationId: threadId,
|
||||
hostId: "local",
|
||||
following: true,
|
||||
});
|
||||
|
||||
await client.startTurn(threadId, "hello", { ownerClientId: ownerId });
|
||||
await client.steerTurn(threadId, "follow-up", { ownerClientId: ownerId });
|
||||
await client.updateThreadSettings(threadId, {
|
||||
model: "gpt-5.6-sol",
|
||||
effort: "ultra",
|
||||
multiAgentMode: "explicitRequestOnly",
|
||||
}, { ownerClientId: ownerId });
|
||||
await client.interruptTurn(threadId, { mode: "user-stop", expectedTurnId: "turn-1", ownerClientId: ownerId });
|
||||
await client.respondCommandApproval(threadId, 7, "decline", { ownerClientId: ownerId });
|
||||
await client.respondFileApproval(threadId, "8", "cancel", { ownerClientId: ownerId });
|
||||
await client.respondPermissionsApproval(threadId, 9, { permissions: {}, scope: "turn" }, { ownerClientId: ownerId });
|
||||
await client.respondUserInput(threadId, 10, { answers: {} }, { ownerClientId: ownerId });
|
||||
await client.respondMcpElicitation(threadId, 11, { action: "decline", content: null, _meta: null }, { ownerClientId: ownerId });
|
||||
|
||||
assert.deepEqual(requests.map((request) => request.method), [
|
||||
"thread-follower-start-turn",
|
||||
"thread-follower-steer-turn",
|
||||
"thread-follower-update-thread-settings",
|
||||
"thread-follower-interrupt-turn",
|
||||
"thread-follower-command-approval-decision",
|
||||
"thread-follower-file-approval-decision",
|
||||
"thread-follower-permissions-request-approval-response",
|
||||
"thread-follower-submit-user-input",
|
||||
"thread-follower-submit-mcp-server-elicitation-response",
|
||||
]);
|
||||
assert.deepEqual(requests[0].params, {
|
||||
conversationId: threadId,
|
||||
turnStart: {
|
||||
request: {
|
||||
threadId,
|
||||
input: [{ type: "text", text: "hello", text_elements: [] }],
|
||||
},
|
||||
context: { inheritThreadSettings: true },
|
||||
},
|
||||
});
|
||||
assert.deepEqual(requests[2].params, {
|
||||
conversationId: threadId,
|
||||
threadSettings: {
|
||||
model: "gpt-5.6-sol",
|
||||
effort: "ultra",
|
||||
multiAgentMode: "explicitRequestOnly",
|
||||
},
|
||||
});
|
||||
assert.equal(requests[2].version, 1);
|
||||
assert.deepEqual(requests[3].params, {
|
||||
conversationId: threadId,
|
||||
mode: "user-stop",
|
||||
expectedTurnId: "turn-1",
|
||||
});
|
||||
assert.equal(requests[3].version, 4);
|
||||
assert.deepEqual(requests[4].params, { conversationId: threadId, requestId: 7, decision: "decline" });
|
||||
assert.deepEqual(requests[8].params, {
|
||||
conversationId: threadId,
|
||||
requestId: 11,
|
||||
response: { action: "decline", content: null, _meta: null },
|
||||
});
|
||||
await client.dispose();
|
||||
} finally {
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
rmSync(temp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const { chmodSync, mkdtempSync, mkdirSync, rmSync, writeFileSync } = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const test = require("node:test");
|
||||
|
||||
const { resolveCodexCommand } = require("../vscode-extension/dist/codexPath.js");
|
||||
const { JsonlRpcClient } = require("../vscode-extension/dist/jsonlRpc.js");
|
||||
|
||||
function temporaryDirectory() {
|
||||
return mkdtempSync(path.join(os.tmpdir(), "codex-remote-path-"));
|
||||
}
|
||||
|
||||
test("resolveCodexCommand finds a bare command in PATH", () => {
|
||||
const root = temporaryDirectory();
|
||||
try {
|
||||
const bin = path.join(root, "bin");
|
||||
const executable = path.join(bin, "codex-test");
|
||||
mkdirSync(bin);
|
||||
writeFileSync(executable, "#!/bin/sh\nexit 0\n");
|
||||
chmodSync(executable, 0o755);
|
||||
assert.equal(resolveCodexCommand("codex-test", { env: { PATH: bin }, platform: process.platform }), executable);
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("resolveCodexCommand falls back to a per-user ChatGPT.app install", () => {
|
||||
const root = temporaryDirectory();
|
||||
try {
|
||||
const executable = path.join(root, "Applications", "ChatGPT.app", "Contents", "Resources", "codex");
|
||||
mkdirSync(path.dirname(executable), { recursive: true });
|
||||
writeFileSync(executable, "#!/bin/sh\nexit 0\n");
|
||||
chmodSync(executable, 0o755);
|
||||
assert.equal(
|
||||
resolveCodexCommand("codex", { env: { PATH: "/usr/bin:/bin" }, homeDir: root, platform: "darwin" }),
|
||||
executable,
|
||||
);
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("a missing explicit command reports a full-path setting hint", () => {
|
||||
assert.throws(
|
||||
() => resolveCodexCommand("/definitely/missing/codex", { platform: process.platform }),
|
||||
/Codex executable .* was not found.*codexRemoteCollab\.codexCommand.*full path/,
|
||||
);
|
||||
});
|
||||
|
||||
test("JsonlRpcClient turns spawn ENOENT into an actionable error", async () => {
|
||||
const client = new JsonlRpcClient({ command: "/definitely/missing/codex", args: [] });
|
||||
await assert.rejects(() => client.start(), /Codex executable .* was not found.*codexRemoteCollab\.codexCommand/);
|
||||
client.close();
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const test = require("node:test");
|
||||
|
||||
const { CompositeRelayTransport } = require("../vscode-extension/dist/compositeRelay.js");
|
||||
|
||||
class FakeRelay {
|
||||
constructor({ connectError } = {}) {
|
||||
this.connectError = connectError;
|
||||
this.frames = [];
|
||||
this.closed = false;
|
||||
this.listeners = { message: new Set(), open: new Set(), close: new Set() };
|
||||
}
|
||||
|
||||
async connect() {
|
||||
if (this.connectError) throw this.connectError;
|
||||
for (const listener of this.listeners.open) listener();
|
||||
}
|
||||
|
||||
send(frame) { this.frames.push(frame); }
|
||||
close() { this.closed = true; }
|
||||
onMessage(listener) { return this.add("message", listener); }
|
||||
onOpen(listener) { return this.add("open", listener); }
|
||||
onClose(listener) { return this.add("close", listener); }
|
||||
add(type, listener) {
|
||||
this.listeners[type].add(listener);
|
||||
return { dispose: () => this.listeners[type].delete(listener) };
|
||||
}
|
||||
receive(frame) { for (const listener of this.listeners.message) listener(frame); }
|
||||
disconnect(error) { for (const listener of this.listeners.close) listener(error); }
|
||||
}
|
||||
|
||||
test("CompositeRelayTransport keeps local control available when optional cloud connect fails", async () => {
|
||||
const local = new FakeRelay();
|
||||
const cloud = new FakeRelay({ connectError: new Error("cloud offline") });
|
||||
const relay = new CompositeRelayTransport([
|
||||
{ id: "local", transport: local, required: true },
|
||||
{ id: "cloud", transport: cloud },
|
||||
]);
|
||||
|
||||
await relay.connect();
|
||||
assert.equal(relay.isConnected("local"), true);
|
||||
assert.equal(relay.isConnected("cloud"), false);
|
||||
relay.send({ kind: "event", type: "session.snapshot" });
|
||||
assert.equal(local.frames.length, 1);
|
||||
assert.equal(cloud.frames.length, 1, "optional transport may queue events for reconnect");
|
||||
relay.close();
|
||||
assert.equal(local.closed, true);
|
||||
assert.equal(cloud.closed, true);
|
||||
});
|
||||
|
||||
test("CompositeRelayTransport forwards commands and reports offline only after every relay closes", async () => {
|
||||
const local = new FakeRelay();
|
||||
const cloud = new FakeRelay();
|
||||
const relay = new CompositeRelayTransport([
|
||||
{ id: "local", transport: local, required: true },
|
||||
{ id: "cloud", transport: cloud },
|
||||
]);
|
||||
const messages = [];
|
||||
const closes = [];
|
||||
relay.onMessage((frame) => messages.push(frame));
|
||||
relay.onClose((error) => closes.push(error?.message));
|
||||
|
||||
await relay.connect();
|
||||
assert.equal(relay.isConnected("local"), true);
|
||||
assert.equal(relay.isConnected("cloud"), true);
|
||||
cloud.receive({ kind: "command", type: "turn.start" });
|
||||
assert.equal(messages.length, 1);
|
||||
local.disconnect(new Error("local offline"));
|
||||
assert.equal(relay.isConnected("local"), false);
|
||||
assert.deepEqual(closes, []);
|
||||
cloud.disconnect(new Error("cloud offline"));
|
||||
assert.deepEqual(closes, ["cloud offline"]);
|
||||
relay.close();
|
||||
});
|
||||
|
||||
test("CompositeRelayTransport surfaces each member reconnect for snapshot hydration", async () => {
|
||||
const local = new FakeRelay();
|
||||
const cloud = new FakeRelay();
|
||||
const relay = new CompositeRelayTransport([
|
||||
{ id: "local", transport: local, required: true },
|
||||
{ id: "cloud", transport: cloud },
|
||||
]);
|
||||
let opens = 0;
|
||||
relay.onOpen(() => { opens += 1; });
|
||||
await relay.connect();
|
||||
assert.equal(opens, 2);
|
||||
cloud.disconnect(new Error("cloud offline"));
|
||||
for (const listener of cloud.listeners.open) listener();
|
||||
assert.equal(opens, 3, "cloud recovery must prompt RelayHost to publish a fresh snapshot");
|
||||
relay.close();
|
||||
});
|
||||
|
||||
test("CompositeRelayTransport fails when the required local relay cannot connect", async () => {
|
||||
const local = new FakeRelay({ connectError: new Error("local offline") });
|
||||
const cloud = new FakeRelay();
|
||||
const relay = new CompositeRelayTransport([
|
||||
{ id: "local", transport: local, required: true },
|
||||
{ id: "cloud", transport: cloud },
|
||||
]);
|
||||
await assert.rejects(relay.connect(), /local: local offline/);
|
||||
assert.equal(local.closed, true);
|
||||
assert.equal(cloud.closed, true);
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const test = require("node:test");
|
||||
|
||||
test("VS Code runtime strings have English and Simplified Chinese bundles", () => {
|
||||
const extensionRoot = path.join(__dirname, "..", "vscode-extension");
|
||||
const source = fs.readFileSync(path.join(extensionRoot, "src", "extension.ts"), "utf8");
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(extensionRoot, "package.json"), "utf8"));
|
||||
const english = JSON.parse(fs.readFileSync(path.join(extensionRoot, "l10n", "bundle.l10n.json"), "utf8"));
|
||||
const chinese = JSON.parse(fs.readFileSync(path.join(extensionRoot, "l10n", "bundle.l10n.zh-cn.json"), "utf8"));
|
||||
const keys = [...source.matchAll(/(?<![A-Za-z])t\("([^"]+)"/g)].map((match) => match[1]);
|
||||
|
||||
assert.equal(manifest.l10n, "./l10n");
|
||||
assert.ok(keys.length > 20, "expected runtime-localized extension strings");
|
||||
for (const key of new Set(keys)) {
|
||||
assert.equal(english[key], key, `missing English source string: ${key}`);
|
||||
assert.equal(typeof chinese[key], "string", `missing zh-CN translation: ${key}`);
|
||||
assert.ok(chinese[key].length > 0, `empty zh-CN translation: ${key}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("production copy scripts require the Vue build instead of silently falling back", () => {
|
||||
const projectRoot = path.join(__dirname, "..");
|
||||
const extensionSync = fs.readFileSync(path.join(projectRoot, "vscode-extension", "scripts", "sync-local-relay.cjs"), "utf8");
|
||||
const aetherSync = fs.readFileSync(path.join(projectRoot, "..", "frontend", "scripts", "sync-vscodex.mjs"), "utf8");
|
||||
const extensionManifest = JSON.parse(fs.readFileSync(path.join(projectRoot, "vscode-extension", "package.json"), "utf8"));
|
||||
|
||||
assert.match(extensionManifest.scripts["vscode:prepublish"], /build:web/);
|
||||
assert.doesNotMatch(extensionSync, /projectRoot,\s*"public"/);
|
||||
assert.doesNotMatch(aetherSync, /moduleRoot,\s*'public'/);
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
const assert = require("node:assert/strict");
|
||||
const http = require("node:http");
|
||||
const path = require("node:path");
|
||||
const test = require("node:test");
|
||||
|
||||
const {
|
||||
LocalRelayController,
|
||||
localRelayTarget,
|
||||
relayHealthAvailable,
|
||||
} = require("../vscode-extension/dist/localRelay.js");
|
||||
|
||||
test("local relay target accepts only loopback ws URLs", () => {
|
||||
assert.deepEqual(localRelayTarget("ws://localhost:8898/v1/connect"), {
|
||||
host: "127.0.0.1",
|
||||
port: 8898,
|
||||
healthUrl: "http://127.0.0.1:8898/api/health",
|
||||
webUrl: "http://127.0.0.1:8898/",
|
||||
});
|
||||
assert.equal(localRelayTarget("wss://127.0.0.1:8898/v1/connect"), undefined);
|
||||
assert.equal(localRelayTarget("ws://192.168.1.10:8898/v1/connect"), undefined);
|
||||
assert.equal(localRelayTarget("not a url"), undefined);
|
||||
});
|
||||
|
||||
test("local relay health probe recognizes a responding HTTP service", async (t) => {
|
||||
const server = http.createServer((request, response) => {
|
||||
if (request.url === "/api/health") {
|
||||
response.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify({ ok: true }));
|
||||
} else if (request.url === "/aborted") {
|
||||
response.writeHead(200, { "content-type": "application/json" });
|
||||
response.write('{"ok":');
|
||||
response.destroy();
|
||||
} else if (request.url === "/drip") {
|
||||
response.writeHead(200, { "content-type": "application/json" });
|
||||
const interval = setInterval(() => response.write(" "), 10);
|
||||
response.on("close", () => clearInterval(interval));
|
||||
} else {
|
||||
response.writeHead(404).end();
|
||||
}
|
||||
});
|
||||
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
t.after(() => new Promise((resolve) => server.close(resolve)));
|
||||
const address = server.address();
|
||||
assert.equal(await relayHealthAvailable(`http://127.0.0.1:${address.port}/api/health`), true);
|
||||
assert.equal(await relayHealthAvailable(`http://127.0.0.1:${address.port}/missing`), false);
|
||||
assert.equal(await relayHealthAvailable(`http://127.0.0.1:${address.port}/aborted`, 100), false);
|
||||
const startedAt = Date.now();
|
||||
assert.equal(await relayHealthAvailable(`http://127.0.0.1:${address.port}/drip`, 50), false);
|
||||
assert.ok(Date.now() - startedAt < 500);
|
||||
});
|
||||
|
||||
test("local relay controller starts and stops a bundled loopback relay", async () => {
|
||||
let starts = 0;
|
||||
let stops = 0;
|
||||
class FakeRelay {
|
||||
async start() { starts += 1; return { host: "127.0.0.1", port: 65534 }; }
|
||||
async stop() { stops += 1; }
|
||||
}
|
||||
const controller = new LocalRelayController({
|
||||
extensionPath: path.resolve(__dirname, "../vscode-extension"),
|
||||
probeTimeoutMs: 20,
|
||||
loadRelayModule: () => ({ CodexRelay: FakeRelay }),
|
||||
});
|
||||
assert.equal(await controller.ensureRunning("ws://127.0.0.1:65534/v1/connect"), true);
|
||||
assert.equal(starts, 1);
|
||||
await controller.stop();
|
||||
assert.equal(stops, 1);
|
||||
});
|
||||
|
||||
test("local relay controller does not leak a relay when stopped during startup", async () => {
|
||||
let releaseStart;
|
||||
const startGate = new Promise((resolve) => { releaseStart = resolve; });
|
||||
let startEntered;
|
||||
const entered = new Promise((resolve) => { startEntered = resolve; });
|
||||
let stops = 0;
|
||||
class SlowRelay {
|
||||
async start() {
|
||||
startEntered();
|
||||
await startGate;
|
||||
return { host: "127.0.0.1", port: 65533 };
|
||||
}
|
||||
async stop() { stops += 1; }
|
||||
}
|
||||
const controller = new LocalRelayController({
|
||||
extensionPath: path.resolve(__dirname, "../vscode-extension"),
|
||||
probeTimeoutMs: 20,
|
||||
loadRelayModule: () => ({ CodexRelay: SlowRelay }),
|
||||
});
|
||||
const starting = controller.ensureRunning("ws://127.0.0.1:65533/v1/connect");
|
||||
await entered;
|
||||
const stopping = controller.stop();
|
||||
releaseStart();
|
||||
await Promise.all([starting, stopping]);
|
||||
assert.equal(stops, 1);
|
||||
});
|
||||
|
||||
test("local relay controller does not start after stop wins an in-flight health probe", async () => {
|
||||
let resolveProbe;
|
||||
const probe = new Promise((resolve) => { resolveProbe = resolve; });
|
||||
let probeEntered;
|
||||
const entered = new Promise((resolve) => { probeEntered = resolve; });
|
||||
let starts = 0;
|
||||
class FakeRelay {
|
||||
async start() { starts += 1; return { host: "127.0.0.1", port: 65532 }; }
|
||||
async stop() {}
|
||||
}
|
||||
const controller = new LocalRelayController({
|
||||
extensionPath: path.resolve(__dirname, "../vscode-extension"),
|
||||
loadRelayModule: () => ({ CodexRelay: FakeRelay }),
|
||||
probeRelayHealth: async () => {
|
||||
probeEntered();
|
||||
return probe;
|
||||
},
|
||||
});
|
||||
const ensuring = controller.ensureRunning("ws://127.0.0.1:65532/v1/connect");
|
||||
await entered;
|
||||
await controller.stop();
|
||||
resolveProbe(false);
|
||||
assert.equal(await ensuring, false);
|
||||
assert.equal(starts, 0);
|
||||
});
|
||||
@@ -0,0 +1,194 @@
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const test = require("node:test");
|
||||
|
||||
const { createAetherEmbedBridge, isAetherEmbed } = require("../public/embed-bridge.js");
|
||||
const i18n = require("../public/i18n.js");
|
||||
|
||||
function embeddedWindow() {
|
||||
const listeners = new Map();
|
||||
const posts = [];
|
||||
const parent = { postMessage: (message, origin) => posts.push({ message, origin }) };
|
||||
const bodyClasses = new Set();
|
||||
const documentElement = { dataset: {}, style: {} };
|
||||
const windowLike = {
|
||||
location: { search: "?embed=aether", origin: "https://aether.example" },
|
||||
parent,
|
||||
document: {
|
||||
body: { classList: { add: (value) => bodyClasses.add(value) } },
|
||||
documentElement,
|
||||
},
|
||||
addEventListener: (name, listener) => listeners.set(name, listener),
|
||||
removeEventListener: (name, listener) => {
|
||||
if (listeners.get(name) === listener) listeners.delete(name);
|
||||
},
|
||||
};
|
||||
return { bodyClasses, documentElement, listeners, parent, posts, windowLike };
|
||||
}
|
||||
|
||||
test("Aether embed mode is opt-in and announces readiness only to the same-origin parent", () => {
|
||||
assert.equal(isAetherEmbed({ search: "" }), false);
|
||||
assert.equal(isAetherEmbed({ search: "?embed=other" }), false);
|
||||
assert.equal(isAetherEmbed({ search: "?embed=aether" }), true);
|
||||
|
||||
const fixture = embeddedWindow();
|
||||
const bridge = createAetherEmbedBridge(fixture.windowLike);
|
||||
assert.equal(bridge.active, true);
|
||||
bridge.start();
|
||||
assert.equal(fixture.bodyClasses.has("embed-aether"), true);
|
||||
assert.deepEqual(fixture.posts, [{
|
||||
message: { v: 1, type: "aether-vscodex/ready" },
|
||||
origin: "https://aether.example",
|
||||
}]);
|
||||
});
|
||||
|
||||
test("Aether embed bridge rejects cross-origin and non-parent messages and buffers an early connect", () => {
|
||||
const fixture = embeddedWindow();
|
||||
const bridge = createAetherEmbedBridge(fixture.windowLike);
|
||||
bridge.start();
|
||||
const dispatch = fixture.listeners.get("message");
|
||||
const connect = {
|
||||
v: 1,
|
||||
type: "aether-vscodex/connect",
|
||||
ticket: "one-time-ticket",
|
||||
wsUrl: "/api/vscodex/ws",
|
||||
locale: "en-US",
|
||||
theme: "dark",
|
||||
};
|
||||
|
||||
dispatch({ origin: "https://attacker.example", source: fixture.parent, data: connect });
|
||||
dispatch({ origin: "https://aether.example", source: {}, data: connect });
|
||||
let received = null;
|
||||
bridge.on("connect", (message) => { received = message; });
|
||||
assert.equal(received, null);
|
||||
|
||||
dispatch({ origin: "https://aether.example", source: fixture.parent, data: connect });
|
||||
assert.equal(received.ticket, "one-time-ticket");
|
||||
assert.equal(fixture.documentElement.dataset.theme, "dark");
|
||||
|
||||
const second = embeddedWindow();
|
||||
const bufferedBridge = createAetherEmbedBridge(second.windowLike);
|
||||
bufferedBridge.start();
|
||||
second.listeners.get("message")({ origin: "https://aether.example", source: second.parent, data: connect });
|
||||
let buffered = null;
|
||||
bufferedBridge.on("connect", (message) => { buffered = message; });
|
||||
assert.equal(buffered.ticket, "one-time-ticket");
|
||||
});
|
||||
|
||||
test("bridge ticket requests never place the ticket in a URL", () => {
|
||||
const fixture = embeddedWindow();
|
||||
const bridge = createAetherEmbedBridge(fixture.windowLike);
|
||||
bridge.start();
|
||||
bridge.requestTicket({ reason: "disconnected", deviceId: "device-1" });
|
||||
assert.deepEqual(fixture.posts.at(-1), {
|
||||
message: {
|
||||
v: 1,
|
||||
type: "aether-vscodex/request-ticket",
|
||||
reason: "disconnected",
|
||||
deviceId: "device-1",
|
||||
},
|
||||
origin: "https://aether.example",
|
||||
});
|
||||
});
|
||||
|
||||
test("locale dictionary covers static shell and core dynamic status text", () => {
|
||||
assert.equal(i18n.translate("设置", "en-US"), "Settings");
|
||||
assert.equal(i18n.translate("中文", "en-US"), "Chinese");
|
||||
assert.equal(i18n.translate("正在思考", "en-US"), "Thinking");
|
||||
assert.equal(i18n.translate("已读取这些内容 · 4 个文件", "en-US"), "Read these items · 4 files");
|
||||
assert.equal(i18n.translate("用时 3分45秒", "en-US"), "Worked for 3m45s");
|
||||
assert.equal(i18n.translate("修改权限,当前为需要时询问", "en-US"), "Change permissions. Current: Ask when needed");
|
||||
assert.equal(i18n.translate("模型设置更新失败:timeout", "en-US"), "Unable to update model settings: timeout");
|
||||
assert.equal(i18n.translate("请求 #17 已发送,等待 VS Code 主机确认", "en-US"), "Request #17 sent; waiting for the VS Code host");
|
||||
assert.equal(i18n.translate("无法读取 notes.md", "en-US"), "Unable to read notes.md");
|
||||
assert.equal(i18n.translate("命令: timed out", "en-US"), "Command: timed out");
|
||||
assert.equal(i18n.translate("命令: timed out(执行状态未知,请等待主机恢复)", "en-US"), "Command: timed out (execution status unknown; wait for the host to recover)");
|
||||
assert.equal(i18n.translate("子代理 失败", "en-US"), "Subagent failed");
|
||||
assert.equal(i18n.translate("已在 2秒 内运行 echo hi", "en-US"), "Ran echo hi in 2s");
|
||||
assert.equal(i18n.translate("命令运行失败 · echo hi · 2秒", "en-US"), "Command failed · echo hi · 2s");
|
||||
assert.equal(i18n.translate("命令运行失败 · echo hi", "en-US"), "Command failed · echo hi");
|
||||
assert.equal(i18n.translate("已停止 echo hi · 2秒", "en-US"), "Stopped echo hi · 2s");
|
||||
assert.equal(i18n.translate("文件变更 · 失败", "en-US"), "File changes · Failed");
|
||||
assert.equal(i18n.translate("文件变更 · 已中断", "en-US"), "File changes · Interrupted");
|
||||
assert.equal(i18n.translate("命令 · echo hi", "en-US"), "Command · echo hi");
|
||||
assert.equal(i18n.translate("命令 · 设置", "en-US"), "Command · 设置");
|
||||
assert.equal(i18n.translate("正在读取 设置", "en-US"), "Reading 设置");
|
||||
assert.equal(i18n.translate("已在 2秒 内运行 设置", "en-US"), "Ran 设置 in 2s");
|
||||
assert.equal(i18n.translate("正在切换到「设置」…", "en-US"), "Switching to “设置”...");
|
||||
assert.equal(i18n.translate("你停止了工作", "en-US"), "You stopped working");
|
||||
assert.equal(i18n.translate("工具失败", "en-US"), "Tool failed");
|
||||
assert.equal(i18n.translate("正在搜索", "en-US"), "Searching");
|
||||
assert.equal(i18n.translate("已工具 · 2秒", "en-US"), "Tool completed · 2s");
|
||||
assert.equal(i18n.translate("当前模型 5.6 Sol 标准,切换模型", "en-US"), "Current model: 5.6 Sol Medium. Change model");
|
||||
assert.equal(i18n.translate("编辑了文件", "en-US"), "Edited files");
|
||||
assert.equal(i18n.translate("编辑了文件 · 2秒", "en-US"), "Edited files · 2s");
|
||||
assert.equal(i18n.translate("已完成计划", "en-US"), "Completed plan");
|
||||
assert.equal(i18n.translate("已完成计划 · 2秒", "en-US"), "Completed plan · 2s");
|
||||
assert.equal(i18n.translate("…(文件已截断)", "en-US"), "... (file truncated)");
|
||||
assert.equal(i18n.translate("事件窗口已过期,请以当前快照为准", "en-US"), "The event window expired; the current snapshot is authoritative");
|
||||
assert.equal(i18n.translate("控制模式", "en-US"), "Control mode");
|
||||
assert.equal(i18n.translate("同步模式跟随 VS Code 当前会话", "en-US"), "Sync mode follows the current VS Code conversation");
|
||||
assert.equal(i18n.translate("异步模式可独立管理会话", "en-US"), "Async mode manages conversations independently");
|
||||
assert.equal(i18n.translate("当前任务或请求完成后才能切换控制模式", "en-US"), "The control mode can be changed after the current task or request finishes");
|
||||
assert.equal(i18n.translate("Settings", "zh-CN"), "设置");
|
||||
assert.equal(i18n.normalizeLocale("zh-Hans"), "zh-CN");
|
||||
assert.equal(i18n.normalizeLocale("en-GB"), "en-US");
|
||||
});
|
||||
|
||||
test("renderer-owned dynamic labels have English fallbacks without translating host values", () => {
|
||||
assert.equal(i18n.translate("命令 · echo hi", "en-US"), "Command · echo hi");
|
||||
assert.equal(i18n.translate("你停止了工作", "en-US"), "You stopped working");
|
||||
assert.equal(i18n.translate("工具失败", "en-US"), "Tool failed");
|
||||
assert.equal(i18n.translate("正在搜索", "en-US"), "Searching");
|
||||
assert.equal(i18n.translate("当前模型 5.6 Sol 标准,切换模型", "en-US"), "Current model: 5.6 Sol Medium. Change model");
|
||||
|
||||
const app = fs.readFileSync(path.join(__dirname, "..", "public", "app.js"), "utf8");
|
||||
// Command/path/title values are appended after a locale-specific prefix;
|
||||
// they are never passed through the translator as a whole.
|
||||
assert.match(app, /uiWithRaw\("正在运行 ", "Running ",/);
|
||||
assert.match(app, /uiWithRaw\("已读取 ", "Read ",/);
|
||||
assert.match(app, /uiLocale\(\) === "en-US" \? `Switching to/);
|
||||
});
|
||||
|
||||
test("public shell uses relative assets and embedded startup skips the health probe", () => {
|
||||
const publicRoot = path.join(__dirname, "..", "public");
|
||||
const html = fs.readFileSync(path.join(publicRoot, "index.html"), "utf8");
|
||||
const app = fs.readFileSync(path.join(publicRoot, "app.js"), "utf8");
|
||||
assert.match(html, /href="\.\/style\.css"/);
|
||||
assert.match(html, /src="\.\/embed-bridge\.js"/);
|
||||
assert.match(html, /src="\.\/i18n\.js"/);
|
||||
assert.match(html, /src="\.\/app\.js"/);
|
||||
assert.match(app, /if \(embeddedInAether\)[\s\S]+else \{[\s\S]+fetch\("\.\/api\/health"/);
|
||||
assert.doesNotMatch(app, /ticket=.*state\.embedTicket/);
|
||||
assert.match(app, /empty\.textContent = t\(activity\.status === "inProgress" \? "正在读取文件" : "读取完成"\)/);
|
||||
assert.match(app, /outputContent\.textContent = t\("无输出"\)/);
|
||||
assert.match(app, /button\.title = t\(title\)/);
|
||||
assert.match(app, /activity\.action === "spawnAgent" \? t\("启动子代理"\)/);
|
||||
assert.match(app, /return t\("需要远程确认或输入"\)/);
|
||||
assert.match(app, /questionPrompt === undefined \|\| questionPrompt === null \? t\("请输入"\)/);
|
||||
assert.match(app, /checkbox\.setAttribute\("aria-label", t\(checkbox\.checked \? "已完成" : "未完成"\)\)/);
|
||||
assert.match(app, /window\.addEventListener\("aether-vscodex:locale", \(\) => \{[\s\S]+state\.activities\.values\(\)[\s\S]+renderRequests\(\)/);
|
||||
});
|
||||
|
||||
test("control mode is snapshot-authoritative and gates independent session actions", () => {
|
||||
const publicRoot = path.join(__dirname, "..", "public");
|
||||
const html = fs.readFileSync(path.join(publicRoot, "index.html"), "utf8");
|
||||
const app = fs.readFileSync(path.join(publicRoot, "app.js"), "utf8");
|
||||
|
||||
assert.match(html, /id="controlModeSwitch"[\s\S]+data-control-mode="sync"[\s\S]+data-control-mode="async"/);
|
||||
assert.match(app, /command\("control\/mode\/set", \{ mode \}\)/);
|
||||
assert.match(app, /applyControlModeSnapshot\(payload\.metadata\)/);
|
||||
assert.match(app, /const controlMetadata = \{[\s\S]+snapshot\.metadata[\s\S]+appState\.sessionMetadata[\s\S]+applyControlModeSnapshot\(controlMetadata\)/);
|
||||
assert.match(app, /sessionList: source\.sessionList === true/);
|
||||
assert.match(app, /Boolean\(state\.sessionListCommandId\)/);
|
||||
assert.match(app, /mode_switch_pending.*return "正在切换控制模式"/);
|
||||
assert.match(app, /mode_busy\|cannot switch control mode.*return "当前任务或请求完成后才能切换控制模式"/);
|
||||
assert.match(app, /setConversationStatus\(sessionErrorMessage\(message, "控制模式切换失败"\), "warning"\)/);
|
||||
assert.match(app, /if \(!sessionControlAllowed\("sessionList"\)\) return;/);
|
||||
assert.match(app, /if \(!sessionControlAllowed\("sessionSelect"\)\)/);
|
||||
assert.match(app, /if \(!sessionControlAllowed\("sessionCreate"\)\)/);
|
||||
assert.match(app, /sessionPickerButton\.disabled = !listAllowed/);
|
||||
});
|
||||
@@ -0,0 +1,206 @@
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const { EventEmitter } = require("node:events");
|
||||
const test = require("node:test");
|
||||
|
||||
const { RelayClient } = require("../vscode-extension/dist/relayClient.js");
|
||||
|
||||
class FakeWebSocket extends EventEmitter {
|
||||
static instances = [];
|
||||
|
||||
constructor(url) {
|
||||
super();
|
||||
this.url = url;
|
||||
this.readyState = 0;
|
||||
this.sent = [];
|
||||
FakeWebSocket.instances.push(this);
|
||||
}
|
||||
|
||||
open() {
|
||||
this.readyState = 1;
|
||||
this.emit("open");
|
||||
}
|
||||
|
||||
receive(frame) {
|
||||
this.emit("message", Buffer.from(JSON.stringify(frame)));
|
||||
}
|
||||
|
||||
send(data) {
|
||||
this.sent.push(JSON.parse(data));
|
||||
}
|
||||
|
||||
close() {
|
||||
if (this.readyState === 3) return;
|
||||
this.readyState = 3;
|
||||
this.emit("close");
|
||||
}
|
||||
}
|
||||
|
||||
test("RelayClient queues application frames until auth.ok on initial connect and reconnect", async (t) => {
|
||||
FakeWebSocket.instances.length = 0;
|
||||
const client = new RelayClient({
|
||||
url: "ws://relay.invalid/v1/connect",
|
||||
accessToken: "host-token",
|
||||
reconnect: false,
|
||||
webSocket: FakeWebSocket,
|
||||
});
|
||||
t.after(() => client.close());
|
||||
|
||||
const firstConnect = client.connect();
|
||||
const first = FakeWebSocket.instances[0];
|
||||
first.open();
|
||||
assert.deepEqual(first.sent.map((frame) => frame.kind), ["hello", "auth"]);
|
||||
|
||||
client.send({ v: 1, kind: "event", type: "output.chunk", id: "event-1", sessionId: "session-1", payload: { text: "queued" } });
|
||||
assert.equal(first.sent.length, 2, "application event must not be sent before authentication");
|
||||
first.receive({ type: "auth.ok", role: "host", clientType: "host" });
|
||||
await firstConnect;
|
||||
assert.equal(first.sent.length, 3);
|
||||
assert.equal(first.sent[2].id, "event-1");
|
||||
|
||||
first.close();
|
||||
const secondConnect = client.connect();
|
||||
const second = FakeWebSocket.instances[1];
|
||||
second.open();
|
||||
assert.deepEqual(second.sent.map((frame) => frame.kind), ["hello", "auth"]);
|
||||
|
||||
client.send({ v: 1, kind: "event", type: "output.chunk", id: "event-2", sessionId: "session-1", payload: { text: "queued during reconnect" } });
|
||||
assert.equal(second.sent.length, 2, "reconnect window must remain auth-gated");
|
||||
second.receive({ type: "auth.ok", role: "host", clientType: "host" });
|
||||
await secondConnect;
|
||||
assert.equal(second.sent.length, 3);
|
||||
assert.equal(second.sent[2].id, "event-2");
|
||||
});
|
||||
|
||||
test("RelayClient coalesces queued transcript projections within a byte budget", async (t) => {
|
||||
FakeWebSocket.instances.length = 0;
|
||||
const client = new RelayClient({
|
||||
url: "ws://relay.invalid/v1/connect",
|
||||
accessToken: "host-token",
|
||||
reconnect: false,
|
||||
maxFrameBytes: 4_096,
|
||||
maxQueuedBytes: 4_096,
|
||||
webSocket: FakeWebSocket,
|
||||
});
|
||||
t.after(() => client.close());
|
||||
|
||||
const connecting = client.connect();
|
||||
const socket = FakeWebSocket.instances[0];
|
||||
socket.open();
|
||||
client.send({ v: 1, kind: "event", type: "approval.requested", id: "approval", sessionId: "session-1", payload: { text: "a".repeat(700) } });
|
||||
client.send({ v: 1, kind: "event", type: "output.snapshot", id: "old-projection", sessionId: "session-1", payload: { text: "x".repeat(1_200) } });
|
||||
client.send({ v: 1, kind: "event", type: "output.chunk", id: "new-projection", sessionId: "session-1", payload: { text: "y".repeat(1_200) } });
|
||||
client.send({ v: 1, kind: "event", type: "command.result", id: "command", sessionId: "session-1", payload: { text: "c".repeat(700) } });
|
||||
|
||||
assert.ok(client.queueBytes <= 4_096);
|
||||
socket.receive({ type: "auth.ok", role: "host", clientType: "host" });
|
||||
await connecting;
|
||||
const queuedIds = socket.sent.slice(2).map((frame) => frame.id);
|
||||
assert.deepEqual(queuedIds, ["approval", "new-projection", "command"]);
|
||||
});
|
||||
|
||||
test("RelayClient evicts reconstructible projections before queued control events", async (t) => {
|
||||
FakeWebSocket.instances.length = 0;
|
||||
const client = new RelayClient({
|
||||
url: "ws://relay.invalid/v1/connect",
|
||||
accessToken: "host-token",
|
||||
reconnect: false,
|
||||
maxFrameBytes: 4_096,
|
||||
maxQueuedBytes: 2_500,
|
||||
webSocket: FakeWebSocket,
|
||||
});
|
||||
t.after(() => client.close());
|
||||
|
||||
const connecting = client.connect();
|
||||
const socket = FakeWebSocket.instances[0];
|
||||
socket.open();
|
||||
client.send({ v: 1, kind: "event", type: "approval.requested", id: "approval", sessionId: "session-1", payload: { text: "a".repeat(850) } });
|
||||
client.send({ v: 1, kind: "event", type: "output.chunk", id: "projection", sessionId: "session-1", payload: { text: "x".repeat(900) } });
|
||||
client.send({ v: 1, kind: "event", type: "command.result", id: "command", sessionId: "session-1", payload: { text: "c".repeat(850) } });
|
||||
|
||||
assert.ok(client.queueBytes <= 2_500);
|
||||
socket.receive({ type: "auth.ok", role: "host", clientType: "host" });
|
||||
await connecting;
|
||||
const queuedIds = socket.sent.slice(2).map((frame) => frame.id);
|
||||
assert.deepEqual(queuedIds, ["approval", "command"]);
|
||||
});
|
||||
|
||||
test("RelayClient supports a tokenless local handshake", async (t) => {
|
||||
FakeWebSocket.instances.length = 0;
|
||||
const client = new RelayClient({
|
||||
url: "ws://127.0.0.1:8787/v1/connect",
|
||||
reconnect: false,
|
||||
webSocket: FakeWebSocket,
|
||||
});
|
||||
t.after(() => client.close());
|
||||
|
||||
const connecting = client.connect();
|
||||
const socket = FakeWebSocket.instances[0];
|
||||
socket.open();
|
||||
assert.deepEqual(socket.sent.map((frame) => frame.kind), ["hello"]);
|
||||
socket.receive({ type: "auth.ok", role: "host", clientType: "host", authRequired: false });
|
||||
await connecting;
|
||||
|
||||
client.send({ v: 1, kind: "event", type: "connection.opened", id: "event-local", sessionId: "session-local", payload: {} });
|
||||
assert.equal(socket.sent.length, 2);
|
||||
assert.equal(socket.sent[1].type, "connection.opened");
|
||||
});
|
||||
|
||||
test("RelayClient accepts structured history snapshots larger than the old 256 KiB limit", async (t) => {
|
||||
FakeWebSocket.instances.length = 0;
|
||||
const client = new RelayClient({
|
||||
url: "ws://127.0.0.1:8787/v1/connect",
|
||||
reconnect: false,
|
||||
webSocket: FakeWebSocket,
|
||||
});
|
||||
t.after(() => client.close());
|
||||
|
||||
const connecting = client.connect();
|
||||
const socket = FakeWebSocket.instances[0];
|
||||
socket.open();
|
||||
socket.receive({ type: "auth.ok", role: "host", clientType: "host", authRequired: false });
|
||||
await connecting;
|
||||
|
||||
const historyText = "x".repeat(512 * 1024);
|
||||
assert.doesNotThrow(() => client.send({
|
||||
v: 1,
|
||||
kind: "event",
|
||||
type: "session.snapshot",
|
||||
id: "large-history-snapshot",
|
||||
sessionId: "session-local",
|
||||
payload: { threadId: "large-thread", messages: [{ kind: "assistant", text: historyText }] },
|
||||
}));
|
||||
assert.equal(socket.sent.at(-1).payload.messages[0].text.length, historyText.length);
|
||||
});
|
||||
|
||||
test("RelayClient ignores late events from a replaced socket", async (t) => {
|
||||
FakeWebSocket.instances.length = 0;
|
||||
const client = new RelayClient({
|
||||
url: "ws://relay.invalid/v1/connect",
|
||||
accessToken: "host-token",
|
||||
reconnect: false,
|
||||
webSocket: FakeWebSocket,
|
||||
});
|
||||
t.after(() => client.close());
|
||||
|
||||
const firstConnect = client.connect();
|
||||
const first = FakeWebSocket.instances[0];
|
||||
first.open();
|
||||
client.close();
|
||||
|
||||
const secondConnect = client.connect();
|
||||
const second = FakeWebSocket.instances[1];
|
||||
second.open();
|
||||
|
||||
// Simulate a delayed event from the old socket after the replacement.
|
||||
first.open();
|
||||
first.receive({ type: "auth.ok", role: "host", clientType: "host" });
|
||||
assert.equal(second.sent.length, 2, "late auth must not authenticate or flush the new socket");
|
||||
|
||||
client.send({ v: 1, kind: "event", type: "output.chunk", id: "event-after-replace", sessionId: "session-1", payload: { text: "queued" } });
|
||||
second.receive({ type: "auth.ok", role: "host", clientType: "host" });
|
||||
await secondConnect;
|
||||
assert.equal(second.sent[2].id, "event-after-replace");
|
||||
await assert.rejects(firstConnect);
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,387 @@
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const test = require("node:test");
|
||||
|
||||
const { SwitchableAgentAdapter } = require("../vscode-extension/dist/switchableAgentAdapter.js");
|
||||
|
||||
class FakeAdapter {
|
||||
constructor(name, options = {}) {
|
||||
this.name = name;
|
||||
this.options = options;
|
||||
this.listeners = new Set();
|
||||
this.calls = [];
|
||||
this.disposed = false;
|
||||
this.snapshotValue = options.snapshot ?? idleSnapshot(name);
|
||||
}
|
||||
|
||||
async start() {
|
||||
this.calls.push(["start"]);
|
||||
this.emit({ type: "candidate.starting", payload: { name: this.name } });
|
||||
if (this.options.startGate) await this.options.startGate.promise;
|
||||
if (this.options.startError) throw this.options.startError;
|
||||
this.emit({ type: "connection.opened", payload: { name: this.name } });
|
||||
}
|
||||
|
||||
async startThread(params = {}) { return this.record("startThread", params); }
|
||||
async newSession(params = {}) { return this.record("newSession", params); }
|
||||
async startTurn(params) { return this.record("startTurn", params); }
|
||||
async steerTurn(params) { return this.record("steerTurn", params); }
|
||||
async updateThreadSettings(params) { return this.record("updateThreadSettings", params); }
|
||||
async listSessions(params = {}) { return this.record("listSessions", params); }
|
||||
async selectSession(params) { return this.record("selectSession", params); }
|
||||
async interruptTurn(params) { return this.record("interruptTurn", params); }
|
||||
async sendInput(text, params = {}) { return this.record("sendInput", { text, ...params }); }
|
||||
async cancel(taskId, params = {}) { return this.record("cancel", { taskId, ...params }); }
|
||||
async respondApproval(requestId, decision, reason, response) {
|
||||
return this.record("respondApproval", { requestId, decision, reason, response });
|
||||
}
|
||||
async denyPending(reason) { this.calls.push(["denyPending", reason]); }
|
||||
|
||||
async snapshot() {
|
||||
this.calls.push(["snapshot"]);
|
||||
return structuredClone(this.snapshotValue);
|
||||
}
|
||||
|
||||
onEvent(listener) {
|
||||
this.listeners.add(listener);
|
||||
return { dispose: () => this.listeners.delete(listener) };
|
||||
}
|
||||
|
||||
emit(event) {
|
||||
for (const listener of this.listeners) listener(event);
|
||||
}
|
||||
|
||||
async dispose() {
|
||||
this.calls.push(["dispose"]);
|
||||
this.disposed = true;
|
||||
}
|
||||
|
||||
record(method, params) {
|
||||
this.calls.push([method, params]);
|
||||
return { adapter: this.name, method, params };
|
||||
}
|
||||
}
|
||||
|
||||
function idleSnapshot(name) {
|
||||
return {
|
||||
threadId: `${name}-thread`,
|
||||
turnId: null,
|
||||
state: "idle",
|
||||
pendingApprovals: [],
|
||||
pendingRequests: [],
|
||||
outputTail: "",
|
||||
metadata: { adapter: name },
|
||||
};
|
||||
}
|
||||
|
||||
function deferred() {
|
||||
let resolve;
|
||||
let reject;
|
||||
const promise = new Promise((yes, no) => { resolve = yes; reject = no; });
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
test("sync mode decorates snapshots and enforces VS Code-owned navigation", async () => {
|
||||
const sync = new FakeAdapter("sync");
|
||||
const adapter = new SwitchableAgentAdapter({ initialMode: "sync", createAdapter: () => sync });
|
||||
await adapter.start();
|
||||
|
||||
const snapshot = await adapter.snapshot();
|
||||
assert.equal(snapshot.metadata.adapter, "sync");
|
||||
assert.equal(snapshot.metadata.mode, "sync");
|
||||
assert.equal(snapshot.metadata.controlMode, "sync");
|
||||
assert.equal(snapshot.metadata.modeEpoch, 0);
|
||||
assert.deepEqual(snapshot.metadata.capabilities, {
|
||||
followsVscodeRoute: true,
|
||||
sessionList: false,
|
||||
sessionSelect: false,
|
||||
sessionCreate: false,
|
||||
threadSettings: true,
|
||||
});
|
||||
|
||||
await assert.rejects(adapter.listSessions(), /unavailable in sync mode/);
|
||||
await assert.rejects(adapter.selectSession({ threadId: "other" }), /unavailable in sync mode/);
|
||||
await assert.rejects(adapter.newSession(), /unavailable in sync mode/);
|
||||
await assert.rejects(adapter.startThread(), /unavailable in sync mode/);
|
||||
assert.equal((await adapter.sendInput("hello")).adapter, "sync");
|
||||
assert.equal((await adapter.updateThreadSettings({ model: "codex" })).adapter, "sync");
|
||||
await adapter.dispose();
|
||||
});
|
||||
|
||||
test("async mode proxies the complete AgentAdapter surface", async () => {
|
||||
const independent = new FakeAdapter("async");
|
||||
const adapter = new SwitchableAgentAdapter({ initialMode: "async", createAdapter: () => independent });
|
||||
await adapter.start();
|
||||
|
||||
await adapter.startThread({ cwd: "/workspace" });
|
||||
await adapter.newSession({ model: "codex" });
|
||||
await adapter.startTurn({ text: "start" });
|
||||
await adapter.steerTurn({ text: "steer" });
|
||||
await adapter.updateThreadSettings({ effort: "high" });
|
||||
await adapter.listSessions({ limit: 10 });
|
||||
await adapter.selectSession({ threadId: "thread-2" });
|
||||
await adapter.interruptTurn({ turnId: "turn-1" });
|
||||
await adapter.sendInput("input", { source: "web" });
|
||||
await adapter.cancel("turn-2", { reason: "user" });
|
||||
await adapter.respondApproval(7, "allow", "approved", { decision: "accept" });
|
||||
await adapter.denyPending("offline");
|
||||
|
||||
assert.deepEqual(
|
||||
independent.calls.map(([method]) => method).filter((method) => !["start", "snapshot", "dispose"].includes(method)),
|
||||
[
|
||||
"startThread",
|
||||
"newSession",
|
||||
"startTurn",
|
||||
"steerTurn",
|
||||
"updateThreadSettings",
|
||||
"listSessions",
|
||||
"selectSession",
|
||||
"interruptTurn",
|
||||
"sendInput",
|
||||
"cancel",
|
||||
"respondApproval",
|
||||
"denyPending",
|
||||
],
|
||||
);
|
||||
await adapter.dispose();
|
||||
});
|
||||
|
||||
test("session/new falls back to thread/start for a minimal async adapter", async () => {
|
||||
const independent = new FakeAdapter("async");
|
||||
independent.newSession = undefined;
|
||||
const adapter = new SwitchableAgentAdapter({ initialMode: "async", createAdapter: () => independent });
|
||||
await adapter.start();
|
||||
|
||||
const result = await adapter.newSession({ cwd: "/workspace" });
|
||||
assert.equal(result.method, "startThread");
|
||||
assert.equal((await adapter.snapshot()).metadata.capabilities.sessionCreate, true);
|
||||
await adapter.dispose();
|
||||
});
|
||||
|
||||
test("mode switch commits atomically, buffers candidate events, and isolates the old generation", async () => {
|
||||
const sync = new FakeAdapter("sync");
|
||||
const gate = deferred();
|
||||
const asyncAdapter = new FakeAdapter("async", { startGate: gate });
|
||||
const adapter = new SwitchableAgentAdapter({
|
||||
initialMode: "sync",
|
||||
createAdapter: (mode) => mode === "sync" ? sync : asyncAdapter,
|
||||
});
|
||||
const events = [];
|
||||
adapter.onEvent((event) => events.push(`${event.type}:${event.payload.name ?? event.payload.controlMode ?? ""}`));
|
||||
await adapter.start();
|
||||
events.length = 0;
|
||||
|
||||
const switching = adapter.setControlMode({ mode: "async" });
|
||||
await Promise.resolve();
|
||||
sync.emit({ type: "old.while-current", payload: { name: "sync" } });
|
||||
assert.deepEqual(events, ["old.while-current:sync"]);
|
||||
await assert.rejects(adapter.sendInput("racing input"), /mode is switching/);
|
||||
gate.resolve();
|
||||
|
||||
const result = await switching;
|
||||
assert.deepEqual(result, {
|
||||
changed: true,
|
||||
controlMode: "async",
|
||||
previousControlMode: "sync",
|
||||
modeEpoch: 1,
|
||||
});
|
||||
assert.equal(sync.disposed, true);
|
||||
assert.equal(adapter.getControlMode(), "async");
|
||||
assert.ok(events.indexOf("control.mode.changed:async") < events.indexOf("candidate.starting:async"));
|
||||
assert.ok(events.includes("connection.opened:async"));
|
||||
|
||||
sync.emit({ type: "old.after-commit", payload: { name: "sync" } });
|
||||
asyncAdapter.emit({ type: "new.after-commit", payload: { name: "async" } });
|
||||
assert.equal(events.includes("old.after-commit:sync"), false);
|
||||
assert.equal(events.includes("new.after-commit:async"), true);
|
||||
|
||||
const snapshot = await adapter.snapshot();
|
||||
assert.equal(snapshot.metadata.modeEpoch, 1);
|
||||
assert.deepEqual(snapshot.metadata.capabilities, {
|
||||
followsVscodeRoute: false,
|
||||
sessionList: true,
|
||||
sessionSelect: true,
|
||||
sessionCreate: true,
|
||||
threadSettings: true,
|
||||
});
|
||||
assert.equal((await adapter.listSessions()).adapter, "async");
|
||||
assert.equal((await adapter.newSession()).adapter, "async");
|
||||
await adapter.dispose();
|
||||
});
|
||||
|
||||
test("delegate snapshot events always carry authoritative mode metadata", async () => {
|
||||
const sync = new FakeAdapter("sync");
|
||||
const asyncAdapter = new FakeAdapter("async");
|
||||
const adapter = new SwitchableAgentAdapter({
|
||||
initialMode: "sync",
|
||||
createAdapter: (mode) => mode === "sync" ? sync : asyncAdapter,
|
||||
});
|
||||
const snapshots = [];
|
||||
adapter.onEvent((event) => {
|
||||
if (event.type === "session.snapshot") snapshots.push(event.payload);
|
||||
});
|
||||
await adapter.start();
|
||||
|
||||
sync.emit({
|
||||
type: "session.snapshot",
|
||||
threadId: "sync-thread-2",
|
||||
payload: { threadId: "sync-thread-2", metadata: { adapter: "sync", route: "/thread/2" } },
|
||||
});
|
||||
assert.deepEqual(snapshots.at(-1).metadata, {
|
||||
adapter: "sync",
|
||||
route: "/thread/2",
|
||||
mode: "sync",
|
||||
controlMode: "sync",
|
||||
modeEpoch: 0,
|
||||
capabilities: {
|
||||
followsVscodeRoute: true,
|
||||
sessionList: false,
|
||||
sessionSelect: false,
|
||||
sessionCreate: false,
|
||||
threadSettings: true,
|
||||
},
|
||||
});
|
||||
|
||||
await adapter.setControlMode({ mode: "async" });
|
||||
snapshots.length = 0;
|
||||
asyncAdapter.emit({
|
||||
type: "session.snapshot",
|
||||
threadId: "async-thread-2",
|
||||
payload: { threadId: "async-thread-2", metadata: { adapter: "async", title: "Second" } },
|
||||
});
|
||||
assert.equal(snapshots.length, 1);
|
||||
assert.equal(snapshots[0].metadata.adapter, "async");
|
||||
assert.equal(snapshots[0].metadata.title, "Second");
|
||||
assert.equal(snapshots[0].metadata.controlMode, "async");
|
||||
assert.equal(snapshots[0].metadata.modeEpoch, 1);
|
||||
assert.equal(snapshots[0].metadata.capabilities.followsVscodeRoute, false);
|
||||
assert.equal(snapshots[0].metadata.capabilities.sessionSelect, true);
|
||||
await adapter.dispose();
|
||||
});
|
||||
|
||||
test("active turns and pending requests prevent a mode switch", async (t) => {
|
||||
const cases = [
|
||||
["active turn", { ...idleSnapshot("sync"), turnId: "turn-1", state: "active" }],
|
||||
["active state before a turn id arrives", { ...idleSnapshot("sync"), state: "in_progress" }],
|
||||
["active runtime flag", { ...idleSnapshot("sync"), activeFlags: ["thinking"] }],
|
||||
["pending approval", {
|
||||
...idleSnapshot("sync"),
|
||||
pendingApprovals: [{ requestId: 1, method: "approval", action: "run", risk: "low", summary: "run", createdAt: 1, payload: {} }],
|
||||
}],
|
||||
["pending input", {
|
||||
...idleSnapshot("sync"),
|
||||
pendingRequests: [{ requestId: "input-1", method: "item/tool/requestUserInput" }],
|
||||
}],
|
||||
];
|
||||
|
||||
for (const [name, snapshot] of cases) {
|
||||
await t.test(name, async () => {
|
||||
const sync = new FakeAdapter("sync", { snapshot });
|
||||
let factoryCalls = 0;
|
||||
const adapter = new SwitchableAgentAdapter({
|
||||
initialMode: "sync",
|
||||
createAdapter: (mode) => {
|
||||
factoryCalls += 1;
|
||||
return mode === "sync" ? sync : new FakeAdapter("async");
|
||||
},
|
||||
});
|
||||
await adapter.start();
|
||||
await assert.rejects(adapter.setControlMode({ mode: "async" }), /turn or request is active/);
|
||||
assert.equal(factoryCalls, 1, "busy checks happen before creating a second adapter");
|
||||
assert.equal(adapter.getControlMode(), "sync");
|
||||
await adapter.dispose();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("candidate startup failure leaves the old adapter authoritative", async () => {
|
||||
const sync = new FakeAdapter("sync");
|
||||
const failed = new FakeAdapter("async", { startError: new Error("candidate failed") });
|
||||
const adapter = new SwitchableAgentAdapter({
|
||||
initialMode: "sync",
|
||||
createAdapter: (mode) => mode === "sync" ? sync : failed,
|
||||
});
|
||||
const events = [];
|
||||
adapter.onEvent((event) => events.push(event.type));
|
||||
await adapter.start();
|
||||
events.length = 0;
|
||||
|
||||
await assert.rejects(adapter.setControlMode({ controlMode: "async" }), /candidate failed/);
|
||||
assert.equal(adapter.getControlMode(), "sync");
|
||||
assert.equal(failed.disposed, true);
|
||||
assert.equal(sync.disposed, false);
|
||||
assert.equal(events.includes("candidate.starting"), false, "failed candidate events stay private");
|
||||
assert.equal((await adapter.sendInput("still attached")).adapter, "sync");
|
||||
assert.equal((await adapter.snapshot()).metadata.modeEpoch, 0);
|
||||
await adapter.dispose();
|
||||
});
|
||||
|
||||
test("a mode factory cannot reuse the currently active adapter instance", async () => {
|
||||
const shared = new FakeAdapter("shared");
|
||||
const adapter = new SwitchableAgentAdapter({ initialMode: "sync", createAdapter: () => shared });
|
||||
await adapter.start();
|
||||
|
||||
await assert.rejects(adapter.setControlMode({ mode: "async" }), /must return a distinct adapter/);
|
||||
assert.equal(adapter.getControlMode(), "sync");
|
||||
assert.equal(shared.disposed, false);
|
||||
assert.equal((await adapter.sendInput("still live")).adapter, "shared");
|
||||
await adapter.dispose();
|
||||
});
|
||||
|
||||
test("listener failures cannot turn a committed switch into a rejected command", async () => {
|
||||
const sync = new FakeAdapter("sync");
|
||||
const asyncAdapter = new FakeAdapter("async");
|
||||
const adapter = new SwitchableAgentAdapter({
|
||||
initialMode: "sync",
|
||||
createAdapter: (mode) => mode === "sync" ? sync : asyncAdapter,
|
||||
});
|
||||
adapter.onEvent(() => { throw new Error("consumer failed"); });
|
||||
await adapter.start();
|
||||
|
||||
const result = await adapter.setControlMode({ mode: "async" });
|
||||
assert.equal(result.changed, true);
|
||||
assert.equal(adapter.getControlMode(), "async");
|
||||
assert.equal(sync.disposed, true);
|
||||
await adapter.dispose();
|
||||
});
|
||||
|
||||
test("a turn that appears while the candidate starts aborts before commit", async () => {
|
||||
const sync = new FakeAdapter("sync");
|
||||
const gate = deferred();
|
||||
const candidate = new FakeAdapter("async", { startGate: gate });
|
||||
const adapter = new SwitchableAgentAdapter({
|
||||
initialMode: "sync",
|
||||
createAdapter: (mode) => mode === "sync" ? sync : candidate,
|
||||
});
|
||||
await adapter.start();
|
||||
|
||||
const switching = adapter.setControlMode({ mode: "async" });
|
||||
await Promise.resolve();
|
||||
sync.snapshotValue.turnId = "turn-race";
|
||||
sync.snapshotValue.state = "active";
|
||||
gate.resolve();
|
||||
|
||||
await assert.rejects(switching, /turn or request is active/);
|
||||
assert.equal(adapter.getControlMode(), "sync");
|
||||
assert.equal(candidate.disposed, true);
|
||||
assert.equal(sync.disposed, false);
|
||||
sync.snapshotValue.turnId = null;
|
||||
sync.snapshotValue.state = "idle";
|
||||
await adapter.dispose();
|
||||
});
|
||||
|
||||
test("control mode validation and idempotent switches are explicit", async () => {
|
||||
const sync = new FakeAdapter("sync");
|
||||
const adapter = new SwitchableAgentAdapter({ initialMode: "sync", createAdapter: () => sync });
|
||||
await adapter.start();
|
||||
|
||||
await assert.rejects(adapter.setControlMode({ mode: "attach" }), /must be sync or async/);
|
||||
assert.deepEqual(await adapter.setControlMode({ mode: "sync" }), {
|
||||
changed: false,
|
||||
controlMode: "sync",
|
||||
previousControlMode: "sync",
|
||||
modeEpoch: 0,
|
||||
});
|
||||
await adapter.dispose();
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
dist/
|
||||
*.vsix
|
||||
@@ -0,0 +1,9 @@
|
||||
src/**
|
||||
.gitignore
|
||||
tsconfig.json
|
||||
**/*.map
|
||||
node_modules/@types/**
|
||||
node_modules/typescript/**
|
||||
node_modules/.package-lock.json
|
||||
*.tsbuildinfo
|
||||
*.vsix
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Codex Remote Collaboration contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,237 @@
|
||||
# Codex Remote Collaboration VS Code Bridge
|
||||
|
||||
This extension connects local and Aether relay channels to one switchable Codex
|
||||
control host. **Synchronous mode** follows the conversation currently shown by
|
||||
the official Codex VS Code extension through its private IPC protocol and does
|
||||
not spawn a `codex` process. **Asynchronous mode** starts an independent
|
||||
app-server and lets the Web UI list, resume, create, and select conversations.
|
||||
|
||||
The attached conversation remains visible and usable in the official Codex
|
||||
panel. Remote operators can observe its output, submit a new turn or steer the
|
||||
active turn, interrupt it, and answer supported approval/input requests.
|
||||
|
||||
The mode can be changed from the Web UI without reconnecting either relay.
|
||||
Synchronous mode makes the official panel the only conversation-navigation
|
||||
owner; asynchronous mode restores the browser history and new-conversation
|
||||
actions. A running turn or pending request blocks mode changes.
|
||||
|
||||
## Requirements
|
||||
|
||||
- The official `openai.chatgpt` VS Code extension is installed and signed in.
|
||||
- The target Codex conversation is open and owned by that extension.
|
||||
- The bridge and official extension run as the same OS user. The default Unix
|
||||
socket is `$CODEX_HOME/ipc/ipc.sock`, normally `~/.codex/ipc/ipc.sock`.
|
||||
- For a loopback `ws://` URL, the extension starts and owns its bundled relay
|
||||
automatically. Remote and `wss://` relay URLs remain externally hosted.
|
||||
|
||||
The IPC follower protocol is private and versioned, not a public OpenAI API.
|
||||
An official extension update can require a compatible bridge update. Strict
|
||||
stream-version checks are enabled by default so an unknown protocol fails
|
||||
closed instead of being interpreted optimistically.
|
||||
|
||||
## Build and install
|
||||
|
||||
```sh
|
||||
npm install
|
||||
npm run check
|
||||
npm run build
|
||||
npx --yes @vscode/vsce package
|
||||
code --install-extension codex-remote-collab-0.4.0.vsix --force
|
||||
```
|
||||
|
||||
Run **Developer: Reload Window** after installing or replacing the VSIX.
|
||||
|
||||
## Configure control modes
|
||||
|
||||
For the local default, no separate relay command is required. The extension
|
||||
starts the bundled relay on the host and port from `codexRemoteCollab.localRelayUrl`.
|
||||
To run the development relay manually, disable
|
||||
`codexRemoteCollab.autoStartLocalRelay` and use:
|
||||
|
||||
```sh
|
||||
HOST=127.0.0.1 PORT=8787 CODEX_REMOTE_MODE=host npm start
|
||||
```
|
||||
|
||||
To opt into authentication later, set `CODEX_REMOTE_AUTH=required` and the three
|
||||
token variables before starting the relay.
|
||||
|
||||
Set the extension configuration:
|
||||
|
||||
```json
|
||||
{
|
||||
"codexRemoteCollab.localRelayUrl": "ws://127.0.0.1:8787/v1/connect",
|
||||
"codexRemoteCollab.controlMode": "sync",
|
||||
"codexRemoteCollab.autoDiscoverThread": true,
|
||||
"codexRemoteCollab.autoStart": true
|
||||
}
|
||||
```
|
||||
|
||||
Then:
|
||||
|
||||
1. Open the target conversation in the official Codex panel.
|
||||
2. Reload VS Code once after installing the companion extension. The local relay and bridge start automatically; no token is needed for loopback. The status item opens the Web console and is not a connect/disconnect toggle.
|
||||
3. Open the relay web console; it connects automatically on localhost. The web UI uses a
|
||||
Codex-style conversation stream with a bottom composer; Enter sends and Shift+Enter
|
||||
inserts a newline. There is no separate connect/disconnect step for the local relay.
|
||||
|
||||
If the browser says that it is waiting for the VS Code host or the recent-session list is
|
||||
empty, verify that `codexRemoteCollab.localRelayUrl` uses the same port as the relay and run
|
||||
**Developer: Reload Window**. Keep `codexRemoteCollab.threadId` empty unless a specific
|
||||
conversation must be pinned; an old closed ID can prevent startup until it is cleared.
|
||||
|
||||
When authentication is enabled, run **Codex Remote: Set Relay Token** with the
|
||||
host token. It is stored in `vscode.SecretStorage`, not in settings; the browser
|
||||
uses the operator or viewer token separately.
|
||||
|
||||
With no configured thread ID, the bridge ranks recent VS Code rollout metadata
|
||||
and shows only candidates verified by live IPC owner discovery and a matching
|
||||
follower snapshot. Explicit Codex Desktop tasks, closed, stale, and other
|
||||
non-attachable history entries are omitted.
|
||||
In synchronous mode, switching the conversation in the official Codex panel
|
||||
also switches the Web projection after the new owner snapshot is ready. The
|
||||
Web UI cannot list, select, or create conversations in this mode. Switch to
|
||||
asynchronous mode when the browser should own conversation navigation.
|
||||
To avoid ambiguity when several Codex windows are open, run **Codex Remote: Set Existing Thread ID**.
|
||||
An empty value restores automatic discovery.
|
||||
|
||||
Useful commands:
|
||||
|
||||
- **Codex Remote: Start Bridge** / **Stop Bridge**
|
||||
- **Codex Remote: Set Existing Thread ID**
|
||||
- **Codex Remote: Set Relay Token**
|
||||
- **Codex Remote: Pair with Aether**
|
||||
- **Codex Remote: Configure Aether Cloud Relay**
|
||||
- **Codex Remote: Send Input**
|
||||
- **Codex Remote: Show Snapshot**
|
||||
|
||||
## Settings
|
||||
|
||||
| Setting | Default | Meaning |
|
||||
| --- | --- | --- |
|
||||
| `codexRemoteCollab.controlMode` | `sync` | `sync` follows VS Code; `async` owns an independent app-server. |
|
||||
| `codexRemoteCollab.localRelayUrl` | `ws://127.0.0.1:8787/v1/connect` | Bundled loopback relay used by the local Web control. |
|
||||
| `codexRemoteCollab.aetherUrl` | empty | Aether origin remembered by the pairing command. |
|
||||
| `codexRemoteCollab.cloudRelayUrl` | empty | Aether WebSocket relay URL populated by pairing. |
|
||||
| `codexRemoteCollab.threadId` | empty | Exact existing conversation ID; empty enables discovery. |
|
||||
| `codexRemoteCollab.autoDiscoverThread` | `true` | Discover and owner-check a local VS Code session. |
|
||||
| `codexRemoteCollab.followVscodeSession` | `true` | Legacy compatibility setting; synchronous mode always follows VS Code. |
|
||||
| `codexRemoteCollab.ipcSocketPath` | empty | Override the local IPC socket path. |
|
||||
| `codexRemoteCollab.hostId` | `local` | Owner-discovery host identifier. |
|
||||
| `codexRemoteCollab.ipcStrictVersions` | `true` | Reject unsupported stream protocol versions. |
|
||||
| `codexRemoteCollab.approvalTimeoutMs` | `300000` | Deny an unanswered request locally after this delay. |
|
||||
| `codexRemoteCollab.allowHighRiskApprovals` | `false` | Permit remote high-risk approvals when explicitly enabled. |
|
||||
|
||||
`codexRemoteCollab.codexCommand`, `codexArgs`, and `defaultCwd` apply only to
|
||||
asynchronous mode. The deprecated `mode=attach/spawn` values map to
|
||||
`controlMode=sync/async` when no explicit control mode exists.
|
||||
|
||||
## Pair with Aether
|
||||
|
||||
The local relay stays enabled after cloud pairing. In Aether, open **Codex remote
|
||||
control** and generate a one-time code. Then run **Codex Remote: Pair with Aether**
|
||||
from the VS Code Command Palette, enter the Aether server URL and the code, and
|
||||
the bridge will connect to both relays. The long-lived device credential is stored
|
||||
only in VS Code SecretStorage. Revoke a lost or retired device from the Aether page.
|
||||
|
||||
## Relay behavior
|
||||
|
||||
The bridge sends a `hello` and, when a relay token is configured, a separate
|
||||
bearer-auth frame over an outbound WebSocket. It publishes normalized events including:
|
||||
|
||||
- `connection.opened` / `connection.closed`
|
||||
- `session.snapshot`
|
||||
- `output.snapshot` / `output.chunk`
|
||||
- `task.started` / `task.finished` / `task.cancelled`
|
||||
- `approval.requested` / `approval.resolved` / `approval.expired`
|
||||
- `input.requested` / `input.resolved` / `input.expired`
|
||||
|
||||
Remote commands are mapped to the existing conversation owner:
|
||||
|
||||
- `control/mode/set` atomically switches between `sync` and `async`.
|
||||
- `session/list`, `session/select`, and `session/new` are available only in
|
||||
asynchronous mode and map to `thread/list`, `thread/resume`, and `thread/start`.
|
||||
- `turn/start` starts a turn in the attached thread.
|
||||
- `turn/steer` adds input to the active turn.
|
||||
- `turn/interrupt` interrupts the expected active turn.
|
||||
- `approval.respond`, `input.respond`, and `server.request.respond` preserve the
|
||||
original request ID and use method-specific follower responses.
|
||||
- `thread/start` is deliberately rejected in synchronous mode because VS Code
|
||||
owns conversation navigation there.
|
||||
|
||||
The browser never connects directly to the IPC socket. Relay and host both
|
||||
enforce role/capability checks; high-risk command approval remains disabled
|
||||
unless the local VS Code setting opts in.
|
||||
|
||||
## Supported follower requests
|
||||
|
||||
- `item/commandExecution/requestApproval`
|
||||
- `item/fileChange/requestApproval`
|
||||
- `item/permissions/requestApproval`
|
||||
- `item/tool/requestUserInput`
|
||||
- `mcpServer/elicitation/request`
|
||||
- legacy `applyPatchApproval` and `execCommandApproval`
|
||||
|
||||
Unanswered requests expire with a local deny. JSON-RPC numeric and string IDs
|
||||
remain distinct, and a response can be submitted only once.
|
||||
|
||||
## Legacy mode migration
|
||||
|
||||
The old setting remains accepted:
|
||||
|
||||
```json
|
||||
{
|
||||
"codexRemoteCollab.mode": "spawn",
|
||||
"codexRemoteCollab.codexCommand": "/absolute/path/to/codex",
|
||||
"codexRemoteCollab.codexArgs": ["app-server", "--stdio"]
|
||||
}
|
||||
```
|
||||
|
||||
It maps to `controlMode=async`. Prefer the new setting directly. A
|
||||
`spawn codex ENOENT` error belongs only to asynchronous mode; it is not a
|
||||
synchronous-mode prerequisite or a PATH problem that needs fixing for
|
||||
existing-session control.
|
||||
|
||||
The standalone `npm run start:stdio` entry point and `createBridge()` helper
|
||||
also retain the legacy app-server adapter for compatibility.
|
||||
|
||||
## Embedding the attach adapter
|
||||
|
||||
The reusable exports are in `src/index.ts`:
|
||||
|
||||
```ts
|
||||
import {
|
||||
CodexIpcAgentAdapter,
|
||||
RelayClient,
|
||||
RelayHost,
|
||||
} from "codex-remote-collab";
|
||||
|
||||
const adapter = new CodexIpcAgentAdapter({
|
||||
threadId: process.env.CODEX_THREAD_ID,
|
||||
autoDiscoverThread: true,
|
||||
});
|
||||
const relay = new RelayClient({
|
||||
url: "wss://relay.example.test/v1/connect",
|
||||
accessToken: process.env.CODEX_REMOTE_HOST_TOKEN,
|
||||
});
|
||||
const host = new RelayHost({ adapter, relay });
|
||||
await host.start();
|
||||
```
|
||||
|
||||
`CodexIpcClient` is exported separately for protocol fixtures and diagnostics.
|
||||
Use `followConversation()` before follower mutations, and always target the
|
||||
owner returned by `findThreadOwner()`.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **No existing session found:** open the target official Codex conversation,
|
||||
keep that VS Code window running, then retry or set its exact thread ID.
|
||||
- **Owner not found:** the rollout exists on disk but no live official client
|
||||
currently owns it. Reopen the conversation in the Codex panel.
|
||||
- **IPC version mismatch:** update this bridge for the installed official
|
||||
extension. Disabling strict versions is diagnostic only.
|
||||
- **Relay stays at waiting for host:** confirm host mode, relay URL, and that no
|
||||
second host is already connected. If authentication is enabled, also check the
|
||||
host token.
|
||||
- **Old `spawn codex ENOENT` message:** install version `0.4.0`, reload VS Code,
|
||||
and verify `codexRemoteCollab.controlMode` is `sync` unless independent
|
||||
conversations are intended.
|
||||
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"A non-empty Aether device credential is required.": "A non-empty Aether device credential is required.",
|
||||
"Aether cloud connection removed. Local control remains enabled.": "Aether cloud connection removed. Local control remains enabled.",
|
||||
"Aether cloud connection saved. Restart the Codex Remote bridge to connect; local control remains available.": "Aether cloud connection saved. Restart the Codex Remote bridge to connect; local control remains available.",
|
||||
"Aether cloud relay WebSocket URL": "Aether cloud relay WebSocket URL",
|
||||
"Aether pairing completed. Local and cloud control are both active.": "Aether pairing completed. Local and cloud control are both active.",
|
||||
"Aether pairing was saved, but the cloud connection is currently unavailable. Local control remains active and the cloud connection will retry.": "Aether pairing was saved, but the cloud connection is currently unavailable. Local control remains active and the cloud connection will retry.",
|
||||
"Aether returned an invalid pairing response.": "Aether returned an invalid pairing response.",
|
||||
"Aether server URL": "Aether server URL",
|
||||
"Attached to the existing Codex conversation. Click to open the web control.": "Attached to the existing Codex conversation. Click to open the web control.",
|
||||
"Bridge connected. Click to open the web control.": "Bridge connected. Click to open the web control.",
|
||||
"Bridge paused. Click to open the web control and resume automatically.": "Bridge paused. Click to open the web control and resume automatically.",
|
||||
"Codex Remote Collaboration": "Codex Remote Collaboration",
|
||||
"Codex Remote will attach to {0} after the next bridge start.": "Codex Remote will attach to {0} after the next bridge start.",
|
||||
"Codex Remote will auto-discover the latest VS Code Codex conversation after the next bridge start.": "Codex Remote will auto-discover the latest VS Code Codex conversation after the next bridge start.",
|
||||
"Connecting to the local Codex collaboration service": "Connecting to the local Codex collaboration service",
|
||||
"Device credential from the Aether pairing flow": "Device credential from the Aether pairing flow",
|
||||
"Enter a valid URL.": "Enter a valid URL.",
|
||||
"Enter a valid WebSocket URL.": "Enter a valid WebSocket URL.",
|
||||
"Enter the 8-character pairing code.": "Enter the 8-character pairing code.",
|
||||
"Enter the Aether server URL.": "Enter the Aether server URL.",
|
||||
"Existing Codex conversation ID (leave blank for auto-discovery)": "Existing Codex conversation ID (leave blank for auto-discovery)",
|
||||
"Independent Codex mode is connected. Click to open the web control.": "Independent Codex mode is connected. Click to open the web control.",
|
||||
"One-time pairing code shown in Aether": "One-time pairing code shown in Aether",
|
||||
"Relay access token (leave blank for the local relay)": "Relay access token (leave blank for the local relay)",
|
||||
"Relay token stored in VS Code SecretStorage.": "Relay token stored in VS Code SecretStorage.",
|
||||
"Remote Aether connections must use wss://.": "Remote Aether connections must use wss://.",
|
||||
"Remote Aether servers must use https://.": "Remote Aether servers must use https://.",
|
||||
"Restoring the local collaboration service": "Restoring the local collaboration service",
|
||||
"Send input to the active Codex turn": "Send input to the active Codex turn",
|
||||
"Set codexRemoteCollab.localRelayUrl before starting the bridge.": "Set codexRemoteCollab.localRelayUrl before starting the bridge.",
|
||||
"Start the Codex remote bridge first.": "Start the Codex remote bridge first.",
|
||||
"Starting the independent Codex mode.": "Starting the independent Codex mode.",
|
||||
"Starting {0}": "Starting {0}",
|
||||
"The Codex conversation is not connected": "The Codex conversation is not connected",
|
||||
"The Codex executable is unavailable": "The Codex executable is unavailable",
|
||||
"The Codex remote bridge attached to the existing VS Code Codex conversation.": "The Codex remote bridge attached to the existing VS Code Codex conversation.",
|
||||
"The Codex remote bridge is already running.": "The Codex remote bridge is already running.",
|
||||
"The Codex remote collaboration bridge connected.": "The Codex remote collaboration bridge connected.",
|
||||
"The independent Codex mode is not connected": "The independent Codex mode is not connected",
|
||||
"The independent Codex remote mode connected.": "The independent Codex remote mode connected.",
|
||||
"The bridge is not connected": "The bridge is not connected",
|
||||
"The local collaboration URL is invalid. Check codexRemoteCollab.localRelayUrl.": "The local collaboration URL is invalid. Check codexRemoteCollab.localRelayUrl.",
|
||||
"The local collaboration service at {0} is temporarily unavailable. The extension will keep retrying.": "The local collaboration service at {0} is temporarily unavailable. The extension will keep retrying.",
|
||||
"The official Codex extension new-conversation command was not found. Make sure the VS Code Codex extension is enabled.": "The official Codex extension new-conversation command was not found. Make sure the VS Code Codex extension is enabled.",
|
||||
"Unable to pair with Aether: {0}": "Unable to pair with Aether: {0}",
|
||||
"Unable to restore the local collaboration service": "Unable to restore the local collaboration service",
|
||||
"Unable to send Codex input: {0}": "Unable to send Codex input: {0}",
|
||||
"Unable to start the Codex remote bridge: {0}": "Unable to start the Codex remote bridge: {0}",
|
||||
"Unable to start the local Codex collaboration service: {0}": "Unable to start the local Codex collaboration service: {0}",
|
||||
"Unable to start the local collaboration service: {0}": "Unable to start the local collaboration service: {0}",
|
||||
"Use a ws:// or wss:// URL.": "Use a ws:// or wss:// URL.",
|
||||
"Use the Aether origin without credentials, a query, or a fragment.": "Use the Aether origin without credentials, a query, or a fragment.",
|
||||
"Waiting for a Codex conversation to open in VS Code. It will connect automatically.": "Waiting for a Codex conversation to open in VS Code. It will connect automatically.",
|
||||
"codexRemoteCollab.localRelayUrl must be a loopback ws:// address.": "codexRemoteCollab.localRelayUrl must be a loopback ws:// address."
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"A non-empty Aether device credential is required.": "必须填写 Aether 设备凭据。",
|
||||
"Aether cloud connection removed. Local control remains enabled.": "已移除 Aether 云端连接,本地控制仍然可用。",
|
||||
"Aether cloud connection saved. Restart the Codex Remote bridge to connect; local control remains available.": "已保存 Aether 云端连接。重启 Codex Remote 桥接后即可连接,本地控制仍然可用。",
|
||||
"Aether cloud relay WebSocket URL": "Aether 云端 relay WebSocket 地址",
|
||||
"Aether pairing completed. Local and cloud control are both active.": "Aether 配对完成,本地与云端控制均已启用。",
|
||||
"Aether pairing was saved, but the cloud connection is currently unavailable. Local control remains active and the cloud connection will retry.": "Aether 配对信息已保存,但当前无法连接云端。本地控制仍然可用,云端连接会继续重试。",
|
||||
"Aether returned an invalid pairing response.": "Aether 返回了无效的配对响应。",
|
||||
"Aether server URL": "Aether 服务器地址",
|
||||
"Attached to the existing Codex conversation. Click to open the web control.": "已附加到现有 Codex 会话,点击打开 Web 控制页。",
|
||||
"Bridge connected. Click to open the web control.": "桥接已连接,点击打开 Web 控制页。",
|
||||
"Bridge paused. Click to open the web control and resume automatically.": "桥接已暂停,点击打开 Web 控制页时会自动恢复。",
|
||||
"Codex Remote Collaboration": "Codex 远程协同",
|
||||
"Codex Remote will attach to {0} after the next bridge start.": "Codex Remote 将在下次启动桥接后附加到 {0}。",
|
||||
"Codex Remote will auto-discover the latest VS Code Codex conversation after the next bridge start.": "Codex Remote 将在下次启动桥接后自动发现最新的 VS Code Codex 会话。",
|
||||
"Connecting to the local Codex collaboration service": "正在连接本地 Codex 协同服务",
|
||||
"Device credential from the Aether pairing flow": "Aether 配对流程生成的设备凭据",
|
||||
"Enter a valid URL.": "请输入有效的 URL。",
|
||||
"Enter a valid WebSocket URL.": "请输入有效的 WebSocket URL。",
|
||||
"Enter the 8-character pairing code.": "请输入 8 位配对码。",
|
||||
"Enter the Aether server URL.": "请输入 Aether 服务器地址。",
|
||||
"Existing Codex conversation ID (leave blank for auto-discovery)": "现有 Codex 会话 ID(留空则自动发现)",
|
||||
"Independent Codex mode is connected. Click to open the web control.": "独立 Codex 模式已连接,点击打开 Web 控制页。",
|
||||
"One-time pairing code shown in Aether": "Aether 中显示的一次性配对码",
|
||||
"Relay access token (leave blank for the local relay)": "Relay 访问 token(本地 relay 请留空)",
|
||||
"Relay token stored in VS Code SecretStorage.": "Relay token 已保存到 VS Code SecretStorage。",
|
||||
"Remote Aether connections must use wss://.": "远程 Aether 连接必须使用 wss://。",
|
||||
"Remote Aether servers must use https://.": "远程 Aether 服务器必须使用 https://。",
|
||||
"Restoring the local collaboration service": "正在恢复本地协同服务",
|
||||
"Send input to the active Codex turn": "向当前 Codex turn 发送输入",
|
||||
"Set codexRemoteCollab.localRelayUrl before starting the bridge.": "请先设置 codexRemoteCollab.localRelayUrl,再启动桥接。",
|
||||
"Start the Codex remote bridge first.": "请先启动 Codex 远程桥接。",
|
||||
"Starting the independent Codex mode.": "正在启动独立 Codex 模式。",
|
||||
"Starting {0}": "正在启动 {0}",
|
||||
"The Codex conversation is not connected": "Codex 会话尚未连接",
|
||||
"The Codex executable is unavailable": "Codex 可执行文件不可用",
|
||||
"The Codex remote bridge attached to the existing VS Code Codex conversation.": "Codex 远程桥接已附加到现有 VS Code Codex 会话。",
|
||||
"The Codex remote bridge is already running.": "Codex 远程桥接已在运行。",
|
||||
"The Codex remote collaboration bridge connected.": "Codex 远程协同桥接已连接。",
|
||||
"The independent Codex mode is not connected": "独立 Codex 模式尚未连接",
|
||||
"The independent Codex remote mode connected.": "独立 Codex 远程模式已连接。",
|
||||
"The bridge is not connected": "桥接尚未连接",
|
||||
"The local collaboration URL is invalid. Check codexRemoteCollab.localRelayUrl.": "本地协同地址无效,请检查 codexRemoteCollab.localRelayUrl。",
|
||||
"The local collaboration service at {0} is temporarily unavailable. The extension will keep retrying.": "本地协同服务 {0} 暂时无法连接,扩展会继续重试。",
|
||||
"The official Codex extension new-conversation command was not found. Make sure the VS Code Codex extension is enabled.": "未找到官方 Codex 扩展的新会话命令,请确认 VS Code Codex 扩展已启用。",
|
||||
"Unable to pair with Aether: {0}": "无法与 Aether 配对:{0}",
|
||||
"Unable to restore the local collaboration service": "无法恢复本地协同服务",
|
||||
"Unable to send Codex input: {0}": "无法发送 Codex 输入:{0}",
|
||||
"Unable to start the Codex remote bridge: {0}": "无法启动 Codex 远程桥接:{0}",
|
||||
"Unable to start the local Codex collaboration service: {0}": "无法启动本地 Codex 协同服务:{0}",
|
||||
"Unable to start the local collaboration service: {0}": "无法启动本地协同服务:{0}",
|
||||
"Use a ws:// or wss:// URL.": "请使用 ws:// 或 wss:// URL。",
|
||||
"Use the Aether origin without credentials, a query, or a fragment.": "请填写不含凭据、查询参数或片段的 Aether 源地址。",
|
||||
"Waiting for a Codex conversation to open in VS Code. It will connect automatically.": "正在等待 VS Code 中打开 Codex 会话,检测到后会自动连接。",
|
||||
"codexRemoteCollab.localRelayUrl must be a loopback ws:// address.": "codexRemoteCollab.localRelayUrl 必须是回环地址上的 ws:// URL。"
|
||||
}
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
{
|
||||
"name": "codex-remote-collab",
|
||||
"version": "0.4.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "codex-remote-collab",
|
||||
"version": "0.4.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ws": "^8.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.14.0",
|
||||
"@types/vscode": "^1.85.0",
|
||||
"@types/ws": "^8.5.12",
|
||||
"typescript": "^5.4.5"
|
||||
},
|
||||
"engines": {
|
||||
"vscode": "^1.85.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "20.19.43",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz",
|
||||
"integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/vscode": {
|
||||
"version": "1.134.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.134.0.tgz",
|
||||
"integrity": "sha512-NDEu0hg4sF7+vvFsADsktqUJ6f80LHSZvVK2Ovo1XiQ0/VHck1O3zst+ZZyVA/uvz6vo6LcuoqU2q48YMqOwWw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/ws": {
|
||||
"version": "8.18.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
|
||||
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.21.3",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz",
|
||||
"integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bufferutil": "^4.0.1",
|
||||
"utf-8-validate": ">=5.0.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bufferutil": {
|
||||
"optional": true
|
||||
},
|
||||
"utf-8-validate": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
{
|
||||
"name": "codex-remote-collab",
|
||||
"displayName": "%extension.displayName%",
|
||||
"description": "%extension.description%",
|
||||
"version": "0.4.0",
|
||||
"publisher": "local",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"vscode": "^1.85.0"
|
||||
},
|
||||
"categories": [
|
||||
"Other"
|
||||
],
|
||||
"l10n": "./l10n",
|
||||
"activationEvents": [
|
||||
"onStartupFinished",
|
||||
"onCommand:codexRemoteCollab.openWeb",
|
||||
"onCommand:codexRemoteCollab.start",
|
||||
"onCommand:codexRemoteCollab.stop",
|
||||
"onCommand:codexRemoteCollab.setThreadId",
|
||||
"onCommand:codexRemoteCollab.sendInput",
|
||||
"onCommand:codexRemoteCollab.setRelayToken",
|
||||
"onCommand:codexRemoteCollab.configureCloud",
|
||||
"onCommand:codexRemoteCollab.pairCloud",
|
||||
"onCommand:codexRemoteCollab.snapshot"
|
||||
],
|
||||
"main": "./dist/extension.js",
|
||||
"contributes": {
|
||||
"commands": [
|
||||
{
|
||||
"command": "codexRemoteCollab.openWeb",
|
||||
"title": "%command.openWeb%"
|
||||
},
|
||||
{
|
||||
"command": "codexRemoteCollab.start",
|
||||
"title": "%command.start%"
|
||||
},
|
||||
{
|
||||
"command": "codexRemoteCollab.stop",
|
||||
"title": "%command.stop%"
|
||||
},
|
||||
{
|
||||
"command": "codexRemoteCollab.setThreadId",
|
||||
"title": "%command.setThreadId%"
|
||||
},
|
||||
{
|
||||
"command": "codexRemoteCollab.sendInput",
|
||||
"title": "%command.sendInput%"
|
||||
},
|
||||
{
|
||||
"command": "codexRemoteCollab.setRelayToken",
|
||||
"title": "%command.setRelayToken%"
|
||||
},
|
||||
{
|
||||
"command": "codexRemoteCollab.configureCloud",
|
||||
"title": "%command.configureCloud%"
|
||||
},
|
||||
{
|
||||
"command": "codexRemoteCollab.pairCloud",
|
||||
"title": "%command.pairCloud%"
|
||||
},
|
||||
{
|
||||
"command": "codexRemoteCollab.snapshot",
|
||||
"title": "%command.snapshot%"
|
||||
}
|
||||
],
|
||||
"configuration": {
|
||||
"title": "%configuration.title%",
|
||||
"properties": {
|
||||
"codexRemoteCollab.localRelayUrl": {
|
||||
"type": "string",
|
||||
"default": "ws://127.0.0.1:8787/v1/connect",
|
||||
"description": "%configuration.localRelayUrl%"
|
||||
},
|
||||
"codexRemoteCollab.relayUrl": {
|
||||
"type": "string",
|
||||
"default": "ws://127.0.0.1:8787/v1/connect",
|
||||
"description": "%configuration.relayUrl%",
|
||||
"deprecationMessage": "%configuration.relayUrl.deprecation%"
|
||||
},
|
||||
"codexRemoteCollab.cloudRelayUrl": {
|
||||
"type": "string",
|
||||
"default": "",
|
||||
"description": "%configuration.cloudRelayUrl%"
|
||||
},
|
||||
"codexRemoteCollab.aetherUrl": {
|
||||
"type": "string",
|
||||
"default": "",
|
||||
"description": "%configuration.aetherUrl%"
|
||||
},
|
||||
"codexRemoteCollab.autoStart": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "%configuration.autoStart%"
|
||||
},
|
||||
"codexRemoteCollab.autoStartLocalRelay": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "%configuration.autoStartLocalRelay%"
|
||||
},
|
||||
"codexRemoteCollab.mode": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"attach",
|
||||
"spawn"
|
||||
],
|
||||
"default": "attach",
|
||||
"description": "%configuration.mode%",
|
||||
"deprecationMessage": "%configuration.mode.deprecation%"
|
||||
},
|
||||
"codexRemoteCollab.controlMode": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"sync",
|
||||
"async"
|
||||
],
|
||||
"enumDescriptions": [
|
||||
"%configuration.controlMode.sync%",
|
||||
"%configuration.controlMode.async%"
|
||||
],
|
||||
"default": "sync",
|
||||
"description": "%configuration.controlMode%"
|
||||
},
|
||||
"codexRemoteCollab.threadId": {
|
||||
"type": "string",
|
||||
"default": "",
|
||||
"description": "%configuration.threadId%"
|
||||
},
|
||||
"codexRemoteCollab.autoDiscoverThread": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "%configuration.autoDiscoverThread%"
|
||||
},
|
||||
"codexRemoteCollab.followVscodeSession": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "%configuration.followVscodeSession%"
|
||||
},
|
||||
"codexRemoteCollab.ipcSocketPath": {
|
||||
"type": "string",
|
||||
"default": "",
|
||||
"description": "%configuration.ipcSocketPath%"
|
||||
},
|
||||
"codexRemoteCollab.hostId": {
|
||||
"type": "string",
|
||||
"default": "local",
|
||||
"description": "%configuration.hostId%"
|
||||
},
|
||||
"codexRemoteCollab.ipcStrictVersions": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "%configuration.ipcStrictVersions%"
|
||||
},
|
||||
"codexRemoteCollab.codexCommand": {
|
||||
"type": "string",
|
||||
"default": "codex",
|
||||
"description": "%configuration.codexCommand%"
|
||||
},
|
||||
"codexRemoteCollab.codexArgs": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"default": [
|
||||
"app-server",
|
||||
"--stdio"
|
||||
],
|
||||
"description": "%configuration.codexArgs%"
|
||||
},
|
||||
"codexRemoteCollab.defaultCwd": {
|
||||
"type": "string",
|
||||
"default": "",
|
||||
"description": "%configuration.defaultCwd%"
|
||||
},
|
||||
"codexRemoteCollab.approvalTimeoutMs": {
|
||||
"type": "number",
|
||||
"default": 300000,
|
||||
"minimum": 1000,
|
||||
"description": "%configuration.approvalTimeoutMs%"
|
||||
},
|
||||
"codexRemoteCollab.allowHighRiskApprovals": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "%configuration.allowHighRiskApprovals%"
|
||||
},
|
||||
"codexRemoteCollab.relayReconnect": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "%configuration.relayReconnect%"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"vscode:prepublish": "npm run build:web && npm run build",
|
||||
"build:web": "npm --prefix ../web run build",
|
||||
"build": "tsc -p tsconfig.json && node scripts/sync-local-relay.cjs",
|
||||
"compile": "npm run build",
|
||||
"check": "tsc --noEmit -p tsconfig.json",
|
||||
"start:stdio": "node dist/cli.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"ws": "^8.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.14.0",
|
||||
"@types/vscode": "^1.85.0",
|
||||
"@types/ws": "^8.5.12",
|
||||
"typescript": "^5.4.5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"extension.displayName": "Codex Remote Collaboration",
|
||||
"extension.description": "Synchronize the current VS Code Codex conversation or manage independent Codex conversations from a local browser and Aether cloud.",
|
||||
"command.openWeb": "Codex Remote: Open Local Web Console",
|
||||
"command.start": "Codex Remote: Start Bridge",
|
||||
"command.stop": "Codex Remote: Stop Bridge",
|
||||
"command.setThreadId": "Codex Remote: Set Existing Thread ID",
|
||||
"command.sendInput": "Codex Remote: Send Input",
|
||||
"command.setRelayToken": "Codex Remote: Set Local Relay Token",
|
||||
"command.configureCloud": "Codex Remote: Configure Aether Cloud Manually",
|
||||
"command.pairCloud": "Codex Remote: Pair with Aether",
|
||||
"command.snapshot": "Codex Remote: Show Snapshot",
|
||||
"configuration.title": "Codex Remote Collaboration",
|
||||
"configuration.localRelayUrl": "Loopback relay used by the local browser UI. It remains active when Aether cloud sync is enabled.",
|
||||
"configuration.relayUrl": "Legacy relay setting retained for compatibility. Use localRelayUrl and cloudRelayUrl for new installations.",
|
||||
"configuration.relayUrl.deprecation": "Use codexRemoteCollab.localRelayUrl for local access and codexRemoteCollab.cloudRelayUrl for Aether cloud access.",
|
||||
"configuration.cloudRelayUrl": "Optional Aether cloud relay WebSocket URL. The device credential is stored separately in VS Code SecretStorage.",
|
||||
"configuration.aetherUrl": "Aether server origin used by the one-time pairing flow.",
|
||||
"configuration.autoStart": "Start the bridge when the extension activates.",
|
||||
"configuration.autoStartLocalRelay": "Automatically host the bundled relay for loopback ws:// URLs.",
|
||||
"configuration.mode": "Attach to the existing official VS Code Codex session, or spawn a separate app-server for legacy use.",
|
||||
"configuration.mode.deprecation": "Use codexRemoteCollab.controlMode. attach maps to sync and spawn maps to async.",
|
||||
"configuration.controlMode": "Choose whether the web console follows the current VS Code Codex conversation or manages independent conversations.",
|
||||
"configuration.controlMode.sync": "Synchronize with the conversation currently shown in the official VS Code Codex panel.",
|
||||
"configuration.controlMode.async": "Run an independent Codex app-server and manage its conversations from the web console.",
|
||||
"configuration.threadId": "Existing VS Code Codex conversation ID to follow. Empty uses the most recent locally available session.",
|
||||
"configuration.autoDiscoverThread": "Discover a recent VS Code Codex conversation when no thread ID is configured.",
|
||||
"configuration.followVscodeSession": "Follow conversation changes in the attached official VS Code Codex panel.",
|
||||
"configuration.ipcSocketPath": "Optional official Codex IPC socket path. Empty uses CODEX_HOME/ipc/ipc.sock.",
|
||||
"configuration.hostId": "Codex host identifier used for existing-session discovery.",
|
||||
"configuration.ipcStrictVersions": "Reject unknown private IPC stream versions instead of applying them optimistically.",
|
||||
"configuration.codexCommand": "Asynchronous mode: Codex executable used to launch the independent app-server.",
|
||||
"configuration.codexArgs": "Asynchronous mode: arguments passed to the Codex executable.",
|
||||
"configuration.defaultCwd": "Asynchronous mode: working directory used when starting a conversation.",
|
||||
"configuration.approvalTimeoutMs": "Milliseconds before an unanswered Codex approval or input request is denied locally.",
|
||||
"configuration.allowHighRiskApprovals": "Allow the remote operator to approve high-risk commands. Keep disabled unless the relay and host are tightly controlled.",
|
||||
"configuration.relayReconnect": "Reconnect outbound relay WebSockets after a disconnect."
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"extension.displayName": "Codex 远程协同",
|
||||
"extension.description": "从本地浏览器或 Aether 云端同步 VS Code 当前 Codex 会话,或独立管理 Codex 会话。",
|
||||
"command.openWeb": "Codex 远程:打开本地 Web 控制台",
|
||||
"command.start": "Codex 远程:启动桥接",
|
||||
"command.stop": "Codex 远程:停止桥接",
|
||||
"command.setThreadId": "Codex 远程:设置现有会话 ID",
|
||||
"command.sendInput": "Codex 远程:发送输入",
|
||||
"command.setRelayToken": "Codex 远程:设置本地中继令牌",
|
||||
"command.configureCloud": "Codex 远程:手动配置 Aether 云端",
|
||||
"command.pairCloud": "Codex 远程:与 Aether 配对",
|
||||
"command.snapshot": "Codex 远程:显示会话快照",
|
||||
"configuration.title": "Codex 远程协同",
|
||||
"configuration.localRelayUrl": "本地浏览器控制台使用的回环中继地址。启用 Aether 云同步后仍保持连接。",
|
||||
"configuration.relayUrl": "为兼容旧版本保留的中继设置。新安装请使用 localRelayUrl 和 cloudRelayUrl。",
|
||||
"configuration.relayUrl.deprecation": "本地访问请使用 codexRemoteCollab.localRelayUrl,Aether 云端访问请使用 codexRemoteCollab.cloudRelayUrl。",
|
||||
"configuration.cloudRelayUrl": "可选的 Aether 云端 WebSocket 中继地址。设备凭据单独保存在 VS Code SecretStorage 中。",
|
||||
"configuration.aetherUrl": "一次性配对流程使用的 Aether 服务地址。",
|
||||
"configuration.autoStart": "扩展激活时自动启动桥接。",
|
||||
"configuration.autoStartLocalRelay": "为回环 ws:// 地址自动启动扩展内置的本地中继。",
|
||||
"configuration.mode": "附加到官方 VS Code Codex 现有会话,或为兼容旧版本启动独立 app-server。",
|
||||
"configuration.mode.deprecation": "请改用 codexRemoteCollab.controlMode。attach 对应 sync,spawn 对应 async。",
|
||||
"configuration.controlMode": "选择 Web 控制台是跟随 VS Code 当前 Codex 会话,还是独立管理会话。",
|
||||
"configuration.controlMode.sync": "同步展示官方 VS Code Codex 面板当前打开的会话。",
|
||||
"configuration.controlMode.async": "启动独立 Codex app-server,并从 Web 控制台管理其会话。",
|
||||
"configuration.threadId": "要跟随的现有 VS Code Codex 会话 ID。留空时使用本机最近可附加的会话。",
|
||||
"configuration.autoDiscoverThread": "未设置会话 ID 时自动发现最近的 VS Code Codex 会话。",
|
||||
"configuration.followVscodeSession": "自动跟随官方 VS Code Codex 面板中的会话切换。",
|
||||
"configuration.ipcSocketPath": "可选的官方 Codex IPC socket 路径。留空时使用 CODEX_HOME/ipc/ipc.sock。",
|
||||
"configuration.hostId": "现有会话发现使用的 Codex 主机标识。",
|
||||
"configuration.ipcStrictVersions": "拒绝未知的私有 IPC 流版本,不进行乐观兼容。",
|
||||
"configuration.codexCommand": "异步模式:用于启动独立 app-server 的 Codex 可执行文件。",
|
||||
"configuration.codexArgs": "异步模式:传给 Codex 可执行文件的参数。",
|
||||
"configuration.defaultCwd": "异步模式:启动会话时使用的工作目录。",
|
||||
"configuration.approvalTimeoutMs": "Codex 授权或输入请求无人处理时,在本地拒绝前等待的毫秒数。",
|
||||
"configuration.allowHighRiskApprovals": "允许远程操作员批准高风险命令。仅在中继和主机均受严格控制时启用。",
|
||||
"configuration.relayReconnect": "中继 WebSocket 断开后自动重连。"
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const extensionRoot = path.resolve(__dirname, "..");
|
||||
const projectRoot = path.resolve(extensionRoot, "..");
|
||||
const outputRoot = path.join(extensionRoot, "dist", "local-relay");
|
||||
const publicRoot = path.join(extensionRoot, "dist", "public");
|
||||
const vuePublicRoot = path.join(projectRoot, "web", "dist");
|
||||
|
||||
if (!fs.existsSync(path.join(vuePublicRoot, "index.html"))) {
|
||||
throw new Error("web/dist is missing; run npm run build:web before building the extension");
|
||||
}
|
||||
|
||||
fs.rmSync(outputRoot, { recursive: true, force: true });
|
||||
fs.rmSync(publicRoot, { recursive: true, force: true });
|
||||
fs.mkdirSync(outputRoot, { recursive: true });
|
||||
fs.mkdirSync(publicRoot, { recursive: true });
|
||||
fs.copyFileSync(path.join(projectRoot, "relay", "server.js"), path.join(outputRoot, "server.js"));
|
||||
fs.cpSync(vuePublicRoot, publicRoot, { recursive: true });
|
||||
@@ -0,0 +1,46 @@
|
||||
import { CodexAgentAdapter, CodexAgentAdapterOptions } from "./codexAgentAdapter";
|
||||
import { RelayClient, RelayClientOptions } from "./relayClient";
|
||||
import { RelayHost, RelayHostOptions } from "./relayHost";
|
||||
import { AgentAdapter, Logger, RelayTransport } from "./protocol";
|
||||
|
||||
export interface CodexRemoteBridgeOptions {
|
||||
/** Use a supplied adapter/transport when embedding or testing. */
|
||||
adapter?: AgentAdapter;
|
||||
relay?: RelayTransport;
|
||||
adapterOptions?: CodexAgentAdapterOptions;
|
||||
relayOptions?: RelayClientOptions;
|
||||
sessionId?: string;
|
||||
capabilities?: Iterable<string>;
|
||||
logger?: Logger;
|
||||
}
|
||||
export interface CodexRemoteBridge {
|
||||
adapter: AgentAdapter;
|
||||
relay: RelayTransport;
|
||||
host: RelayHost;
|
||||
start(): Promise<void>;
|
||||
stop(): Promise<void>;
|
||||
}
|
||||
|
||||
/** Construct the default outbound VS Code bridge in one call. */
|
||||
export function createBridge(options: CodexRemoteBridgeOptions): CodexRemoteBridge {
|
||||
const adapter = options.adapter ?? new CodexAgentAdapter(options.adapterOptions);
|
||||
const relay = options.relay ?? (() => {
|
||||
if (!options.relayOptions) throw new Error("relayOptions are required when no relay transport is supplied");
|
||||
return new RelayClient(options.relayOptions);
|
||||
})();
|
||||
const hostOptions: RelayHostOptions = {
|
||||
adapter,
|
||||
relay,
|
||||
...(options.sessionId ? { sessionId: options.sessionId } : {}),
|
||||
...(options.capabilities ? { capabilities: options.capabilities } : {}),
|
||||
...(options.logger ? { logger: options.logger } : {}),
|
||||
};
|
||||
const host = new RelayHost(hostOptions);
|
||||
return {
|
||||
adapter,
|
||||
relay,
|
||||
host,
|
||||
start: () => host.start(),
|
||||
stop: () => host.stop(),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { CodexAgentAdapter } from "./codexAgentAdapter";
|
||||
import { RelayHost } from "./relayHost";
|
||||
import { StdioRelayTransport } from "./relayClient";
|
||||
|
||||
/** Standalone bridge: relay frames in stdin, relay frames out on stdout. */
|
||||
async function main(): Promise<void> {
|
||||
const logger = {
|
||||
debug: (message: string, ...args: unknown[]) => console.error(`[debug] ${message}`, ...args),
|
||||
info: (message: string, ...args: unknown[]) => console.error(`[info] ${message}`, ...args),
|
||||
warn: (message: string, ...args: unknown[]) => console.error(`[warn] ${message}`, ...args),
|
||||
error: (message: string, ...args: unknown[]) => console.error(`[error] ${message}`, ...args),
|
||||
};
|
||||
const command = process.env.CODEX_COMMAND || "codex";
|
||||
const args = process.env.CODEX_APP_SERVER_ARGS ? JSON.parse(process.env.CODEX_APP_SERVER_ARGS) as string[] : ["app-server", "--stdio"];
|
||||
const adapter = new CodexAgentAdapter({ command, args, defaultCwd: process.env.CODEX_WORKSPACE, logger });
|
||||
const relay = new StdioRelayTransport(process.stdin, process.stdout, logger);
|
||||
const host = new RelayHost({ adapter, relay, sendHandshake: true, logger });
|
||||
const shutdown = async (): Promise<void> => {
|
||||
await host.stop();
|
||||
process.exit(0);
|
||||
};
|
||||
process.once("SIGINT", () => void shutdown());
|
||||
process.once("SIGTERM", () => void shutdown());
|
||||
await host.start();
|
||||
}
|
||||
|
||||
void main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.stack ?? error.message : String(error));
|
||||
process.exitCode = 1;
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,947 @@
|
||||
/**
|
||||
* Minimal client for the private Codex desktop/VS Code coordination socket.
|
||||
*
|
||||
* This is intentionally separate from the app-server (JSONL/stdio) adapter.
|
||||
* It attaches to the already running Codex UI through the local IPC router and
|
||||
* therefore does not spawn another `codex` process. The wire protocol is
|
||||
* private and versioned by the official extension; keep this module isolated
|
||||
* so a protocol change can fail without taking down the relay bridge.
|
||||
*/
|
||||
|
||||
import * as crypto from "node:crypto";
|
||||
import * as net from "node:net";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
|
||||
import type { JsonObject, JsonValue } from "./protocol";
|
||||
|
||||
export const INITIALIZING_CLIENT_ID = "initializing-client";
|
||||
export const DEFAULT_IPC_REQUEST_TIMEOUT_MS = 5_000;
|
||||
export const DEFAULT_MAX_IPC_FRAME_BYTES = 256 * 1024 * 1024;
|
||||
|
||||
/** Versions shipped by openai.chatgpt 26.820.71523. */
|
||||
export const CODEX_IPC_METHOD_VERSIONS = Object.freeze({
|
||||
"thread-stream-state-changed": 11,
|
||||
"thread-stream-following-changed": 1,
|
||||
"thread-stream-following-status-requested": 1,
|
||||
"ipc-connection-reset": 1,
|
||||
"thread-read-state-changed": 2,
|
||||
"thread-archived": 2,
|
||||
"thread-unarchived": 1,
|
||||
"thread-owner-discovery": 1,
|
||||
"thread-follower-start-turn": 2,
|
||||
"thread-follower-load-complete-history": 1,
|
||||
"thread-follower-compact-thread": 1,
|
||||
"thread-follower-steer-turn": 1,
|
||||
"thread-follower-interrupt-turn": 4,
|
||||
"thread-follower-update-thread-settings": 1,
|
||||
"thread-follower-edit-last-user-turn": 2,
|
||||
"thread-follower-command-approval-decision": 1,
|
||||
"thread-follower-file-approval-decision": 1,
|
||||
"thread-follower-permissions-request-approval-response": 1,
|
||||
"thread-follower-submit-user-input": 1,
|
||||
"thread-follower-submit-mcp-server-elicitation-response": 1,
|
||||
"thread-follower-set-queued-follow-ups-state": 1,
|
||||
"thread-queued-followups-changed": 1,
|
||||
} as const);
|
||||
|
||||
export type IpcMethod = keyof typeof CODEX_IPC_METHOD_VERSIONS;
|
||||
export type IpcRequestId = string | number;
|
||||
export type IpcPatchPathPart = string | number;
|
||||
|
||||
export interface IpcRequest {
|
||||
type: "request";
|
||||
requestId: IpcRequestId;
|
||||
sourceClientId: string;
|
||||
targetClientId?: string;
|
||||
version: number;
|
||||
method: string;
|
||||
params?: JsonValue;
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
export interface IpcResponse {
|
||||
type: "response";
|
||||
requestId: IpcRequestId;
|
||||
resultType: "success" | "error";
|
||||
method?: string;
|
||||
handledByClientId?: string;
|
||||
result?: JsonValue;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface IpcBroadcast {
|
||||
type: "broadcast";
|
||||
method: string;
|
||||
sourceClientId?: string;
|
||||
targetClientIds?: string[];
|
||||
version: number;
|
||||
params?: JsonValue;
|
||||
}
|
||||
|
||||
export interface IpcClientDiscoveryRequest {
|
||||
type: "client-discovery-request";
|
||||
requestId: IpcRequestId;
|
||||
request: IpcRequest;
|
||||
}
|
||||
|
||||
export interface IpcClientDiscoveryResponse {
|
||||
type: "client-discovery-response";
|
||||
requestId: IpcRequestId;
|
||||
response: { canHandle: boolean };
|
||||
}
|
||||
|
||||
export type IpcMessage =
|
||||
| IpcRequest
|
||||
| IpcResponse
|
||||
| IpcBroadcast
|
||||
| IpcClientDiscoveryRequest
|
||||
| IpcClientDiscoveryResponse;
|
||||
|
||||
export interface IpcJsonPatch {
|
||||
op: "add" | "remove" | "replace";
|
||||
path: IpcPatchPathPart[];
|
||||
value?: JsonValue;
|
||||
}
|
||||
|
||||
export interface ThreadStreamSnapshot {
|
||||
type: "snapshot";
|
||||
revision: number;
|
||||
conversationState: JsonObject;
|
||||
}
|
||||
|
||||
export interface ThreadStreamPatches {
|
||||
type: "patches";
|
||||
baseRevision: number;
|
||||
revision: number;
|
||||
patches: IpcJsonPatch[];
|
||||
}
|
||||
|
||||
export type ThreadStreamChange = ThreadStreamSnapshot | ThreadStreamPatches;
|
||||
|
||||
export interface ConversationStreamState {
|
||||
conversationId: string;
|
||||
hostId: string;
|
||||
ownerClientId: string;
|
||||
revision: number;
|
||||
conversationState: JsonObject;
|
||||
}
|
||||
|
||||
export type ConversationStreamEvent =
|
||||
| (ConversationStreamState & { kind: "snapshot"; raw: IpcBroadcast })
|
||||
| (ConversationStreamState & { kind: "patches"; patches: IpcJsonPatch[]; baseRevision: number; raw: IpcBroadcast })
|
||||
| {
|
||||
kind: "desync";
|
||||
conversationId: string;
|
||||
hostId: string;
|
||||
ownerClientId: string;
|
||||
expectedRevision: number;
|
||||
receivedBaseRevision: number;
|
||||
receivedRevision: number;
|
||||
raw: IpcBroadcast;
|
||||
};
|
||||
|
||||
export interface CodexIpcClientOptions {
|
||||
/** Explicit socket path; otherwise `$CODEX_HOME/ipc/ipc.sock` or `~/.codex`. */
|
||||
socketPath?: string;
|
||||
codexHome?: string;
|
||||
homeDir?: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
platform?: NodeJS.Platform;
|
||||
clientType?: string;
|
||||
requestTimeoutMs?: number;
|
||||
maxFrameBytes?: number;
|
||||
strictVersions?: boolean;
|
||||
/** Reconnect after a socket close and re-send all active following subscriptions. */
|
||||
autoReconnect?: boolean;
|
||||
reconnectDelayMs?: number;
|
||||
/** Optional handler for discovery requests. Default is fail-closed (`false`). */
|
||||
canHandleRequest?: (request: IpcRequest) => boolean | Promise<boolean>;
|
||||
}
|
||||
|
||||
export interface FollowerTurnStartOptions {
|
||||
request?: JsonObject;
|
||||
context?: JsonObject;
|
||||
clientUserMessageId?: string;
|
||||
ownerClientId?: string;
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
export interface FollowerSteerOptions {
|
||||
clientUserMessageId?: string;
|
||||
serviceTier?: string | null;
|
||||
attachments?: JsonValue[];
|
||||
additionalContext?: JsonObject | null;
|
||||
restoreMessage?: JsonValue | null;
|
||||
ownerClientId?: string;
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
export interface FollowerInterruptOptions {
|
||||
mode?: "user-stop" | "system" | "descendant-cleanup" | string;
|
||||
expectedTurnId?: string | null;
|
||||
ownerClientId?: string;
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
export interface FollowOptions {
|
||||
hostId?: string;
|
||||
targetClientIds?: string[];
|
||||
}
|
||||
|
||||
export interface RequestOptions {
|
||||
targetClientId?: string;
|
||||
timeoutMs?: number;
|
||||
version?: number;
|
||||
requestId?: IpcRequestId;
|
||||
}
|
||||
|
||||
export interface IpcErrorOptions {
|
||||
code: string;
|
||||
response?: IpcResponse;
|
||||
}
|
||||
|
||||
export class CodexIpcError extends Error {
|
||||
readonly code: string;
|
||||
readonly response?: IpcResponse;
|
||||
|
||||
constructor(message: string, options: IpcErrorOptions) {
|
||||
super(message);
|
||||
this.name = "CodexIpcError";
|
||||
this.code = options.code;
|
||||
this.response = options.response;
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveCodexIpcSocketPath(options: {
|
||||
socketPath?: string;
|
||||
codexHome?: string;
|
||||
homeDir?: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
platform?: NodeJS.Platform;
|
||||
} = {}): string {
|
||||
if (options.socketPath?.trim()) return options.socketPath.trim();
|
||||
const platform = options.platform ?? process.platform;
|
||||
if (platform === "win32") return "\\\\.\\pipe\\codex-ipc";
|
||||
const env = options.env ?? process.env;
|
||||
const homeDir = options.homeDir ?? os.homedir();
|
||||
const configuredHome = options.codexHome?.trim() || env.CODEX_HOME?.trim() || path.join(homeDir, ".codex");
|
||||
const codexHome = configuredHome === "~"
|
||||
? homeDir
|
||||
: configuredHome.startsWith("~/")
|
||||
? path.join(homeDir, configuredHome.slice(2))
|
||||
: configuredHome;
|
||||
return path.join(codexHome, "ipc", "ipc.sock");
|
||||
}
|
||||
|
||||
/** Encode one private IPC frame: uint32 little-endian byte length + UTF-8 JSON. */
|
||||
export function encodeIpcFrame(message: IpcMessage, maxFrameBytes = DEFAULT_MAX_IPC_FRAME_BYTES): Buffer {
|
||||
const json = JSON.stringify(message);
|
||||
const payload = Buffer.from(json, "utf8");
|
||||
if (payload.length === 0 || payload.length > maxFrameBytes) {
|
||||
throw new RangeError(`IPC frame exceeds ${maxFrameBytes} bytes`);
|
||||
}
|
||||
const frame = Buffer.allocUnsafe(4 + payload.length);
|
||||
frame.writeUInt32LE(payload.length, 0);
|
||||
payload.copy(frame, 4);
|
||||
return frame;
|
||||
}
|
||||
|
||||
/** Incremental decoder that accepts arbitrary TCP/Unix-socket chunk boundaries. */
|
||||
export class IpcFrameDecoder {
|
||||
private buffer = Buffer.alloc(0);
|
||||
|
||||
constructor(private readonly maxFrameBytes = DEFAULT_MAX_IPC_FRAME_BYTES) {}
|
||||
|
||||
push(chunk: Uint8Array): IpcMessage[] {
|
||||
if (chunk.length === 0) return [];
|
||||
this.buffer = this.buffer.length === 0 ? Buffer.from(chunk) : Buffer.concat([this.buffer, chunk]);
|
||||
const messages: IpcMessage[] = [];
|
||||
while (this.buffer.length >= 4) {
|
||||
const payloadLength = this.buffer.readUInt32LE(0);
|
||||
if (payloadLength === 0 || payloadLength > this.maxFrameBytes) {
|
||||
throw new CodexIpcError(`Invalid IPC frame length (${payloadLength} bytes)`, { code: "invalid-frame-length" });
|
||||
}
|
||||
if (this.buffer.length < payloadLength + 4) break;
|
||||
const payload = this.buffer.subarray(4, payloadLength + 4).toString("utf8");
|
||||
this.buffer = this.buffer.subarray(payloadLength + 4);
|
||||
let decoded: unknown;
|
||||
try {
|
||||
decoded = JSON.parse(payload);
|
||||
} catch (error) {
|
||||
throw new CodexIpcError(`Invalid IPC JSON: ${error instanceof Error ? error.message : String(error)}`, {
|
||||
code: "invalid-json",
|
||||
});
|
||||
}
|
||||
if (!isRecord(decoded) || typeof decoded.type !== "string") {
|
||||
throw new CodexIpcError("IPC frame must be an object with a type", { code: "invalid-message" });
|
||||
}
|
||||
messages.push(decoded as unknown as IpcMessage);
|
||||
}
|
||||
return messages;
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.buffer = Buffer.alloc(0);
|
||||
}
|
||||
}
|
||||
|
||||
type Listener<T> = (value: T) => void;
|
||||
export interface IpcSubscription { dispose(): void; }
|
||||
|
||||
function subscribe<T>(set: Set<Listener<T>>, listener: Listener<T>): IpcSubscription {
|
||||
set.add(listener);
|
||||
return { dispose: () => set.delete(listener) };
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function isJsonObject(value: unknown): value is JsonObject {
|
||||
return isRecord(value);
|
||||
}
|
||||
|
||||
function requestIdKey(id: IpcRequestId): string {
|
||||
return `${typeof id}:${String(id)}`;
|
||||
}
|
||||
|
||||
function cloneJson<T extends JsonValue>(value: T): T {
|
||||
return JSON.parse(JSON.stringify(value)) as T;
|
||||
}
|
||||
|
||||
function versionFor(method: string, params?: JsonValue): number {
|
||||
// The official client accepts interrupt v3 when expectedTurnId is absent;
|
||||
// v4 is used when the active-turn precondition is present.
|
||||
if (method === "thread-follower-interrupt-turn"
|
||||
&& (!isRecord(params) || params.expectedTurnId === undefined || params.expectedTurnId === null)) return 3;
|
||||
return CODEX_IPC_METHOD_VERSIONS[method as IpcMethod] ?? 0;
|
||||
}
|
||||
|
||||
function textInput(text: string): JsonObject {
|
||||
return { type: "text", text, text_elements: [] };
|
||||
}
|
||||
|
||||
function normalizeInput(input: string | JsonValue[]): JsonValue[] {
|
||||
return typeof input === "string"
|
||||
? [textInput(input)]
|
||||
: input.map((entry) => typeof entry === "string" ? textInput(entry) : entry);
|
||||
}
|
||||
|
||||
function hasTarget(frame: IpcBroadcast, clientId: string): boolean {
|
||||
return frame.targetClientIds == null || frame.targetClientIds.includes(clientId);
|
||||
}
|
||||
|
||||
/** Apply the JSON patch arrays generated by Immer in the official webview. */
|
||||
export function applyIpcPatches(root: JsonValue, patches: IpcJsonPatch[]): JsonValue {
|
||||
let result = cloneJson(root);
|
||||
for (const patch of patches) {
|
||||
if (!Array.isArray(patch.path)) throw new CodexIpcError("IPC patch path must be an array", { code: "invalid-patch" });
|
||||
if (patch.path.length === 0) {
|
||||
if (patch.op === "remove") throw new CodexIpcError("Removing the conversation root is unsupported", { code: "invalid-patch" });
|
||||
if (patch.value === undefined) throw new CodexIpcError("Patch value is missing", { code: "invalid-patch" });
|
||||
result = cloneJson(patch.value);
|
||||
continue;
|
||||
}
|
||||
|
||||
const parentPath = patch.path.slice(0, -1);
|
||||
const key = patch.path[patch.path.length - 1];
|
||||
assertSafePatchPart(key);
|
||||
const parent = getAtPath(result, parentPath);
|
||||
if (Array.isArray(parent)) {
|
||||
const index = key === "-" ? parent.length : toArrayIndex(key);
|
||||
if (patch.op === "add") {
|
||||
if (patch.value === undefined) throw new CodexIpcError("Patch value is missing", { code: "invalid-patch" });
|
||||
parent.splice(index, 0, cloneJson(patch.value));
|
||||
} else if (patch.op === "replace") {
|
||||
if (patch.value === undefined || index < 0 || index >= parent.length) throw new CodexIpcError("Invalid array replace patch", { code: "invalid-patch" });
|
||||
parent[index] = cloneJson(patch.value);
|
||||
} else {
|
||||
if (index < 0 || index >= parent.length) throw new CodexIpcError("Invalid array remove patch", { code: "invalid-patch" });
|
||||
parent.splice(index, 1);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (!isRecord(parent) || typeof key !== "string") {
|
||||
throw new CodexIpcError("IPC patch parent is not an object or array", { code: "invalid-patch" });
|
||||
}
|
||||
if (patch.op === "remove") {
|
||||
delete parent[key];
|
||||
} else {
|
||||
if (patch.value === undefined) throw new CodexIpcError("Patch value is missing", { code: "invalid-patch" });
|
||||
parent[key] = cloneJson(patch.value);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function getAtPath(root: JsonValue, pathParts: IpcPatchPathPart[]): JsonValue {
|
||||
let current: JsonValue = root;
|
||||
for (const part of pathParts) {
|
||||
if (Array.isArray(current)) {
|
||||
const index = toArrayIndex(part);
|
||||
if (index < 0 || index >= current.length) throw new CodexIpcError("IPC patch path is out of bounds", { code: "invalid-patch" });
|
||||
current = current[index];
|
||||
} else if (isRecord(current) && typeof part === "string" && Object.prototype.hasOwnProperty.call(current, part)) {
|
||||
assertSafePatchPart(part);
|
||||
current = current[part];
|
||||
} else {
|
||||
throw new CodexIpcError("IPC patch path does not exist", { code: "invalid-patch" });
|
||||
}
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
function toArrayIndex(value: IpcPatchPathPart): number {
|
||||
if (typeof value === "number" && Number.isInteger(value)) return value;
|
||||
if (typeof value === "string" && /^\d+$/.test(value)) return Number(value);
|
||||
throw new CodexIpcError(`Invalid array patch index: ${String(value)}`, { code: "invalid-patch" });
|
||||
}
|
||||
|
||||
function assertSafePatchPart(value: IpcPatchPathPart): void {
|
||||
if (value === "__proto__" || value === "prototype" || value === "constructor") {
|
||||
throw new CodexIpcError("Unsafe IPC patch path", { code: "invalid-patch" });
|
||||
}
|
||||
}
|
||||
|
||||
export class CodexIpcClient {
|
||||
readonly socketPath: string;
|
||||
private readonly options: Required<Pick<CodexIpcClientOptions, "clientType" | "requestTimeoutMs" | "maxFrameBytes" | "strictVersions" | "autoReconnect" | "reconnectDelayMs">> & CodexIpcClientOptions;
|
||||
private socket: net.Socket | undefined;
|
||||
private decoder: IpcFrameDecoder;
|
||||
private connectPromise: Promise<string> | undefined;
|
||||
private reconnectTimer: NodeJS.Timeout | undefined;
|
||||
private disposed = false;
|
||||
private clientId = INITIALIZING_CLIENT_ID;
|
||||
private readonly pending = new Map<string, { method: string; resolve: (response: IpcResponse) => void; reject: (error: Error) => void; timer: NodeJS.Timeout }>();
|
||||
private readonly followed = new Map<string, string>();
|
||||
private readonly streams = new Map<string, ConversationStreamState>();
|
||||
private readonly messageListeners = new Set<Listener<IpcMessage>>();
|
||||
private readonly broadcastListeners = new Set<Listener<IpcBroadcast>>();
|
||||
private readonly streamListeners = new Set<Listener<ConversationStreamEvent>>();
|
||||
private readonly errorListeners = new Set<Listener<Error>>();
|
||||
private readonly closeListeners = new Set<Listener<Error | undefined>>();
|
||||
private readonly discoveryHandler?: (request: IpcRequest) => boolean | Promise<boolean>;
|
||||
|
||||
constructor(options: CodexIpcClientOptions = {}) {
|
||||
this.options = {
|
||||
...options,
|
||||
clientType: options.clientType ?? "codex-remote-collab",
|
||||
requestTimeoutMs: options.requestTimeoutMs ?? DEFAULT_IPC_REQUEST_TIMEOUT_MS,
|
||||
maxFrameBytes: options.maxFrameBytes ?? DEFAULT_MAX_IPC_FRAME_BYTES,
|
||||
strictVersions: options.strictVersions ?? true,
|
||||
autoReconnect: options.autoReconnect ?? false,
|
||||
reconnectDelayMs: options.reconnectDelayMs ?? 1_000,
|
||||
};
|
||||
this.socketPath = resolveCodexIpcSocketPath(options);
|
||||
this.decoder = new IpcFrameDecoder(this.options.maxFrameBytes);
|
||||
this.discoveryHandler = options.canHandleRequest;
|
||||
}
|
||||
|
||||
getClientId(): string { return this.clientId; }
|
||||
|
||||
getConversationState(conversationId: string): ConversationStreamState | undefined {
|
||||
const state = this.streams.get(conversationId);
|
||||
return state == null ? undefined : { ...state, conversationState: cloneJson(state.conversationState) };
|
||||
}
|
||||
|
||||
getFollowedConversations(): ReadonlyMap<string, string> { return this.followed; }
|
||||
|
||||
onMessage(listener: Listener<IpcMessage>): IpcSubscription { return subscribe(this.messageListeners, listener); }
|
||||
onBroadcast(listener: Listener<IpcBroadcast>): IpcSubscription { return subscribe(this.broadcastListeners, listener); }
|
||||
onStreamEvent(listener: Listener<ConversationStreamEvent>): IpcSubscription { return subscribe(this.streamListeners, listener); }
|
||||
onError(listener: Listener<Error>): IpcSubscription { return subscribe(this.errorListeners, listener); }
|
||||
onClose(listener: Listener<Error | undefined>): IpcSubscription { return subscribe(this.closeListeners, listener); }
|
||||
|
||||
async connect(): Promise<string> {
|
||||
if (this.disposed) throw new CodexIpcError("IPC client is disposed", { code: "disposed" });
|
||||
if (this.reconnectTimer) {
|
||||
clearTimeout(this.reconnectTimer);
|
||||
this.reconnectTimer = undefined;
|
||||
}
|
||||
if (this.socket?.writable && this.clientId !== INITIALIZING_CLIENT_ID) return this.clientId;
|
||||
if (this.connectPromise) return this.connectPromise;
|
||||
this.connectPromise = new Promise<string>((resolve, reject) => {
|
||||
const socket = net.createConnection(this.socketPath);
|
||||
this.socket = socket;
|
||||
this.decoder.reset();
|
||||
let settled = false;
|
||||
const finishError = (error: Error): void => {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
reject(error);
|
||||
}
|
||||
this.emitError(error);
|
||||
};
|
||||
socket.setNoDelay?.(true);
|
||||
socket.on("connect", () => {
|
||||
const requestId = crypto.randomUUID();
|
||||
const timer = setTimeout(() => {
|
||||
this.pending.delete(requestIdKey(requestId));
|
||||
finishError(new CodexIpcError("IPC initialize timed out", { code: "timeout" }));
|
||||
socket.destroy();
|
||||
}, this.options.requestTimeoutMs);
|
||||
this.pending.set(requestIdKey(requestId), {
|
||||
method: "initialize",
|
||||
resolve: (response) => {
|
||||
clearTimeout(timer);
|
||||
if (response.resultType !== "success" || !isRecord(response.result) || typeof response.result.clientId !== "string") {
|
||||
finishError(new CodexIpcError("IPC initialize returned an invalid response", { code: "initialize-failed", response }));
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
this.clientId = response.result.clientId;
|
||||
settled = true;
|
||||
resolve(this.clientId);
|
||||
this.resubscribeAfterConnect().catch((error) => this.emitError(asError(error)));
|
||||
},
|
||||
reject: (error) => {
|
||||
clearTimeout(timer);
|
||||
finishError(error);
|
||||
socket.destroy();
|
||||
},
|
||||
timer,
|
||||
});
|
||||
this.write({
|
||||
type: "request",
|
||||
requestId,
|
||||
sourceClientId: INITIALIZING_CLIENT_ID,
|
||||
version: 0,
|
||||
method: "initialize",
|
||||
params: { clientType: this.options.clientType },
|
||||
});
|
||||
});
|
||||
socket.on("data", (chunk) => {
|
||||
try {
|
||||
for (const message of this.decoder.push(chunk)) this.handleMessage(message);
|
||||
} catch (error) {
|
||||
const normalized = asError(error);
|
||||
finishError(normalized);
|
||||
socket.destroy(normalized);
|
||||
}
|
||||
});
|
||||
socket.on("error", (error) => {
|
||||
if (!settled) finishError(error);
|
||||
else this.emitError(error);
|
||||
});
|
||||
socket.on("close", () => {
|
||||
this.handleClose();
|
||||
});
|
||||
}).finally(() => {
|
||||
this.connectPromise = undefined;
|
||||
});
|
||||
return this.connectPromise;
|
||||
}
|
||||
|
||||
async followConversation(conversationId: string, following = true, options: FollowOptions = {}): Promise<void> {
|
||||
const hostId = options.hostId ?? "local";
|
||||
await this.connect();
|
||||
if (following) this.followed.set(conversationId, hostId);
|
||||
else {
|
||||
this.followed.delete(conversationId);
|
||||
this.streams.delete(conversationId);
|
||||
}
|
||||
const params: JsonObject = { conversationId, hostId, following };
|
||||
const frame: IpcBroadcast = {
|
||||
type: "broadcast",
|
||||
method: "thread-stream-following-changed",
|
||||
sourceClientId: this.clientId,
|
||||
version: CODEX_IPC_METHOD_VERSIONS["thread-stream-following-changed"],
|
||||
params,
|
||||
};
|
||||
if (options.targetClientIds) frame.targetClientIds = options.targetClientIds;
|
||||
this.write(frame);
|
||||
}
|
||||
|
||||
async findThreadOwner(conversationId: string, hostId = "local", timeoutMs = this.options.requestTimeoutMs): Promise<string | null> {
|
||||
try {
|
||||
const response = await this.request("thread-owner-discovery", { conversationId, hostId }, { timeoutMs });
|
||||
return response.handledByClientId ?? null;
|
||||
} catch (error) {
|
||||
if (error instanceof CodexIpcError
|
||||
&& (error.code === "no-client-found" || error.code.startsWith("no-client-found:"))) return null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async request(method: string, params?: JsonValue, options: RequestOptions = {}): Promise<IpcResponse> {
|
||||
await this.connect();
|
||||
const requestId = options.requestId ?? crypto.randomUUID();
|
||||
const timeoutMs = options.timeoutMs ?? this.options.requestTimeoutMs;
|
||||
const frame: IpcRequest = {
|
||||
type: "request",
|
||||
requestId,
|
||||
sourceClientId: this.clientId,
|
||||
version: options.version ?? versionFor(method, params),
|
||||
method,
|
||||
params,
|
||||
};
|
||||
if (options.targetClientId) frame.targetClientId = options.targetClientId;
|
||||
if (timeoutMs > 0) frame.timeoutMs = timeoutMs;
|
||||
return new Promise<IpcResponse>((resolve, reject) => {
|
||||
const key = requestIdKey(requestId);
|
||||
const timer = setTimeout(() => {
|
||||
this.pending.delete(key);
|
||||
reject(new CodexIpcError(`${method} timed out`, { code: "timeout" }));
|
||||
}, timeoutMs > 0 ? timeoutMs : 2 ** 31 - 1);
|
||||
this.pending.set(key, { method, resolve, reject, timer });
|
||||
try {
|
||||
this.write(frame);
|
||||
} catch (error) {
|
||||
clearTimeout(timer);
|
||||
this.pending.delete(key);
|
||||
reject(asError(error));
|
||||
}
|
||||
}).then((response) => {
|
||||
if (response.resultType === "error") {
|
||||
throw new CodexIpcError(response.error ?? `${method} failed`, { code: response.error ?? "ipc-error", response });
|
||||
}
|
||||
if (response.method != null && response.method !== method) {
|
||||
throw new CodexIpcError(`IPC response method mismatch: expected ${method}, got ${response.method}`, {
|
||||
code: "response-method-mismatch",
|
||||
response,
|
||||
});
|
||||
}
|
||||
return response;
|
||||
});
|
||||
}
|
||||
|
||||
async requestFollower(method: string, conversationId: string, params: JsonObject = {}, options: RequestOptions & { ownerClientId?: string } = {}): Promise<IpcResponse> {
|
||||
const ownerClientId = options.ownerClientId ?? this.streams.get(conversationId)?.ownerClientId;
|
||||
if (!ownerClientId) throw new CodexIpcError(`No owner is known for conversation ${conversationId}`, { code: "owner-unknown" });
|
||||
// Do not allow a caller-provided params object to accidentally retarget a
|
||||
// request after the owner has been selected from the stream snapshot.
|
||||
const body: JsonObject = { ...params, conversationId };
|
||||
const { ownerClientId: _owner, ...requestOptions } = options;
|
||||
return this.request(method, body, { ...requestOptions, targetClientId: ownerClientId });
|
||||
}
|
||||
|
||||
/** Send the exact private `turnStart` envelope expected by the owner. */
|
||||
async startTurn(conversationId: string, input: string | JsonValue[], options: FollowerTurnStartOptions = {}): Promise<JsonValue | undefined> {
|
||||
const request: JsonObject = {
|
||||
...(options.request ?? {}),
|
||||
threadId: conversationId,
|
||||
input: options.request?.input ?? normalizeInput(input),
|
||||
};
|
||||
const context: JsonObject = { inheritThreadSettings: true, ...(options.context ?? {}) };
|
||||
if (options.clientUserMessageId) request.clientUserMessageId = options.clientUserMessageId;
|
||||
const response = await this.requestFollower("thread-follower-start-turn", conversationId, {
|
||||
turnStart: { request, context },
|
||||
}, {
|
||||
ownerClientId: options.ownerClientId,
|
||||
timeoutMs: options.timeoutMs,
|
||||
});
|
||||
return response.result;
|
||||
}
|
||||
|
||||
async steerTurn(conversationId: string, input: string | JsonValue[], options: FollowerSteerOptions = {}): Promise<JsonValue | undefined> {
|
||||
const params: JsonObject = {
|
||||
clientUserMessageId: options.clientUserMessageId ?? crypto.randomUUID(),
|
||||
input: normalizeInput(input),
|
||||
attachments: options.attachments ?? [],
|
||||
};
|
||||
if (options.serviceTier !== undefined) params.serviceTier = options.serviceTier;
|
||||
if (options.additionalContext !== undefined) params.additionalContext = options.additionalContext;
|
||||
if (options.restoreMessage !== undefined) params.restoreMessage = options.restoreMessage;
|
||||
const response = await this.requestFollower("thread-follower-steer-turn", conversationId, params, {
|
||||
ownerClientId: options.ownerClientId,
|
||||
timeoutMs: options.timeoutMs,
|
||||
});
|
||||
return response.result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist settings for the next turn through the official conversation
|
||||
* owner. The owner-side follower handler expects the settings nested under
|
||||
* `threadSettings`; `requestFollower` adds the conversation id to the
|
||||
* outer envelope, yielding:
|
||||
* `{ conversationId, threadSettings }`.
|
||||
*/
|
||||
async updateThreadSettings(
|
||||
conversationId: string,
|
||||
threadSettings: JsonObject,
|
||||
options: RequestOptions & { ownerClientId?: string } = {},
|
||||
): Promise<JsonValue | undefined> {
|
||||
if (!isJsonObject(threadSettings)) {
|
||||
throw new CodexIpcError("thread settings must be a JSON object", { code: "invalid-thread-settings" });
|
||||
}
|
||||
const response = await this.requestFollower(
|
||||
"thread-follower-update-thread-settings",
|
||||
conversationId,
|
||||
{ threadSettings: cloneJson(threadSettings) },
|
||||
options,
|
||||
);
|
||||
return response.result;
|
||||
}
|
||||
|
||||
/** Alias matching the official app-server manager method name. */
|
||||
async updateThreadSettingsForNextTurn(
|
||||
conversationId: string,
|
||||
threadSettings: JsonObject,
|
||||
options: RequestOptions & { ownerClientId?: string } = {},
|
||||
): Promise<JsonValue | undefined> {
|
||||
return this.updateThreadSettings(conversationId, threadSettings, options);
|
||||
}
|
||||
|
||||
async interruptTurn(conversationId: string, options: FollowerInterruptOptions = {}): Promise<JsonValue | undefined> {
|
||||
const params: JsonObject = { mode: options.mode ?? "user-stop" };
|
||||
if (options.expectedTurnId !== undefined && options.expectedTurnId !== null) params.expectedTurnId = options.expectedTurnId;
|
||||
const response = await this.requestFollower("thread-follower-interrupt-turn", conversationId, params, {
|
||||
ownerClientId: options.ownerClientId,
|
||||
timeoutMs: options.timeoutMs,
|
||||
});
|
||||
return response.result;
|
||||
}
|
||||
|
||||
async loadCompleteHistory(conversationId: string, options: RequestOptions & { ownerClientId?: string } = {}): Promise<JsonValue | undefined> {
|
||||
const response = await this.requestFollower("thread-follower-load-complete-history", conversationId, {}, options);
|
||||
return response.result;
|
||||
}
|
||||
|
||||
async respondCommandApproval(conversationId: string, requestId: IpcRequestId, decision: JsonValue, options: RequestOptions & { ownerClientId?: string } = {}): Promise<JsonValue | undefined> {
|
||||
return this.respondFollower("thread-follower-command-approval-decision", conversationId, { requestId, decision }, options);
|
||||
}
|
||||
|
||||
async respondFileApproval(conversationId: string, requestId: IpcRequestId, decision: JsonValue, options: RequestOptions & { ownerClientId?: string } = {}): Promise<JsonValue | undefined> {
|
||||
return this.respondFollower("thread-follower-file-approval-decision", conversationId, { requestId, decision }, options);
|
||||
}
|
||||
|
||||
async respondPermissionsApproval(conversationId: string, requestId: IpcRequestId, response: JsonValue, options: RequestOptions & { ownerClientId?: string } = {}): Promise<JsonValue | undefined> {
|
||||
return this.respondFollower("thread-follower-permissions-request-approval-response", conversationId, { requestId, response }, options);
|
||||
}
|
||||
|
||||
async respondUserInput(conversationId: string, requestId: IpcRequestId, response: JsonValue, options: RequestOptions & { ownerClientId?: string } = {}): Promise<JsonValue | undefined> {
|
||||
return this.respondFollower("thread-follower-submit-user-input", conversationId, { requestId, response }, options);
|
||||
}
|
||||
|
||||
async respondMcpElicitation(conversationId: string, requestId: IpcRequestId, response: JsonValue, options: RequestOptions & { ownerClientId?: string } = {}): Promise<JsonValue | undefined> {
|
||||
return this.respondFollower("thread-follower-submit-mcp-server-elicitation-response", conversationId, { requestId, response }, options);
|
||||
}
|
||||
|
||||
private async respondFollower(method: string, conversationId: string, params: JsonObject, options: RequestOptions & { ownerClientId?: string }): Promise<JsonValue | undefined> {
|
||||
const response = await this.requestFollower(method, conversationId, params, options);
|
||||
return response.result;
|
||||
}
|
||||
|
||||
async dispose(): Promise<void> {
|
||||
this.disposed = true;
|
||||
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
|
||||
this.reconnectTimer = undefined;
|
||||
for (const pending of this.pending.values()) {
|
||||
clearTimeout(pending.timer);
|
||||
pending.reject(new CodexIpcError("IPC client disposed", { code: "disposed" }));
|
||||
}
|
||||
this.pending.clear();
|
||||
this.socket?.destroy();
|
||||
this.socket = undefined;
|
||||
this.clientId = INITIALIZING_CLIENT_ID;
|
||||
}
|
||||
|
||||
private write(message: IpcMessage): void {
|
||||
if (!this.socket?.writable) throw new CodexIpcError("IPC socket is not connected", { code: "not-connected" });
|
||||
this.socket.write(encodeIpcFrame(message, this.options.maxFrameBytes));
|
||||
}
|
||||
|
||||
private handleMessage(message: IpcMessage): void {
|
||||
for (const listener of this.messageListeners) safeCall(listener, message, (error) => this.emitError(error));
|
||||
switch (message.type) {
|
||||
case "response":
|
||||
this.handleResponse(message);
|
||||
return;
|
||||
case "broadcast":
|
||||
this.handleBroadcast(message);
|
||||
return;
|
||||
case "client-discovery-request":
|
||||
this.handleDiscoveryRequest(message).catch((error) => this.emitError(asError(error)));
|
||||
return;
|
||||
case "request":
|
||||
this.handleUnexpectedRequest(message);
|
||||
return;
|
||||
case "client-discovery-response":
|
||||
// Discovery responses are consumed by the router, not by clients.
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private handleResponse(response: IpcResponse): void {
|
||||
const key = requestIdKey(response.requestId);
|
||||
const pending = this.pending.get(key);
|
||||
if (!pending) return;
|
||||
this.pending.delete(key);
|
||||
clearTimeout(pending.timer);
|
||||
pending.resolve(response);
|
||||
}
|
||||
|
||||
private handleBroadcast(frame: IpcBroadcast): void {
|
||||
if (!hasTarget(frame, this.clientId)) return;
|
||||
for (const listener of this.broadcastListeners) safeCall(listener, frame, (error) => this.emitError(error));
|
||||
if (frame.method === "thread-stream-state-changed") {
|
||||
this.handleStreamStateBroadcast(frame);
|
||||
} else if (frame.method === "thread-stream-following-status-requested") {
|
||||
this.handleFollowingStatusRequested(frame);
|
||||
}
|
||||
}
|
||||
|
||||
/** Re-announce active subscriptions when an owner reconnects or hands off. */
|
||||
private handleFollowingStatusRequested(frame: IpcBroadcast): void {
|
||||
if (!isRecord(frame.params)) return;
|
||||
if (this.options.strictVersions
|
||||
&& frame.version !== CODEX_IPC_METHOD_VERSIONS["thread-stream-following-status-requested"]) {
|
||||
this.emitError(new CodexIpcError(`Unsupported thread following status version ${frame.version}`, { code: "version-mismatch" }));
|
||||
return;
|
||||
}
|
||||
const conversationId = typeof frame.params.conversationId === "string"
|
||||
? frame.params.conversationId
|
||||
: undefined;
|
||||
const hostId = typeof frame.params.hostId === "string" ? frame.params.hostId : "local";
|
||||
const requester = frame.sourceClientId;
|
||||
if (!conversationId || !requester || requester === this.clientId) return;
|
||||
if (this.followed.get(conversationId) !== hostId) return;
|
||||
void this.followConversation(conversationId, true, {
|
||||
hostId,
|
||||
targetClientIds: [requester],
|
||||
}).catch((error) => this.emitError(asError(error)));
|
||||
}
|
||||
|
||||
private handleStreamStateBroadcast(frame: IpcBroadcast): void {
|
||||
if (!isRecord(frame.params)) return;
|
||||
const conversationId = typeof frame.params.conversationId === "string" ? frame.params.conversationId : undefined;
|
||||
const hostId = typeof frame.params.hostId === "string" ? frame.params.hostId : "local";
|
||||
const change = frame.params.change;
|
||||
if (!conversationId || !isRecord(change) || typeof change.type !== "string") return;
|
||||
if (this.options.strictVersions && frame.version !== CODEX_IPC_METHOD_VERSIONS["thread-stream-state-changed"]) {
|
||||
this.emitError(new CodexIpcError(`Unsupported thread stream version ${frame.version}`, { code: "version-mismatch" }));
|
||||
return;
|
||||
}
|
||||
const ownerClientId = frame.sourceClientId ?? "";
|
||||
if (change.type === "snapshot") {
|
||||
if (typeof change.revision !== "number" || !isJsonObject(change.conversationState)) return;
|
||||
const state: ConversationStreamState = {
|
||||
conversationId,
|
||||
hostId,
|
||||
ownerClientId,
|
||||
revision: change.revision,
|
||||
conversationState: cloneJson(change.conversationState),
|
||||
};
|
||||
this.streams.set(conversationId, state);
|
||||
this.emitStream({ kind: "snapshot", ...state, raw: frame });
|
||||
return;
|
||||
}
|
||||
if (change.type !== "patches" || typeof change.baseRevision !== "number" || typeof change.revision !== "number" || !Array.isArray(change.patches)) return;
|
||||
const current = this.streams.get(conversationId);
|
||||
if (!current || current.ownerClientId !== ownerClientId || current.revision !== change.baseRevision) {
|
||||
const expectedRevision = current?.revision ?? 0;
|
||||
this.emitStream({
|
||||
kind: "desync",
|
||||
conversationId,
|
||||
hostId,
|
||||
ownerClientId,
|
||||
expectedRevision,
|
||||
receivedBaseRevision: change.baseRevision,
|
||||
receivedRevision: change.revision,
|
||||
raw: frame,
|
||||
});
|
||||
// Re-sending `following:true` is how the official follower asks the
|
||||
// owner for a fresh snapshot when a patch base revision is missed.
|
||||
if (this.followed.has(conversationId)) {
|
||||
this.followConversation(conversationId, true, { hostId }).catch((error) => this.emitError(asError(error)));
|
||||
}
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const patches = change.patches as unknown as IpcJsonPatch[];
|
||||
const nextConversationState = applyIpcPatches(current.conversationState, patches);
|
||||
if (!isJsonObject(nextConversationState)) throw new CodexIpcError("Patched conversation state is not an object", { code: "invalid-patch" });
|
||||
const next: ConversationStreamState = {
|
||||
...current,
|
||||
revision: change.revision,
|
||||
conversationState: nextConversationState,
|
||||
};
|
||||
this.streams.set(conversationId, next);
|
||||
this.emitStream({ kind: "patches", ...next, patches, baseRevision: change.baseRevision, raw: frame });
|
||||
} catch (error) {
|
||||
this.emitError(asError(error));
|
||||
}
|
||||
}
|
||||
|
||||
private async handleDiscoveryRequest(message: IpcClientDiscoveryRequest): Promise<void> {
|
||||
const request = message.request;
|
||||
let canHandle = false;
|
||||
try {
|
||||
canHandle = this.discoveryHandler ? await this.discoveryHandler(request) : false;
|
||||
} catch {
|
||||
canHandle = false;
|
||||
}
|
||||
this.write({
|
||||
type: "client-discovery-response",
|
||||
requestId: message.requestId,
|
||||
response: { canHandle },
|
||||
});
|
||||
}
|
||||
|
||||
private handleUnexpectedRequest(request: IpcRequest): void {
|
||||
try {
|
||||
this.write({
|
||||
type: "response",
|
||||
requestId: request.requestId,
|
||||
resultType: "error",
|
||||
error: "no-handler-for-request",
|
||||
});
|
||||
} catch (error) {
|
||||
this.emitError(asError(error));
|
||||
}
|
||||
}
|
||||
|
||||
private async resubscribeAfterConnect(): Promise<void> {
|
||||
const subscriptions = [...this.followed.entries()];
|
||||
for (const [conversationId, hostId] of subscriptions) {
|
||||
this.write({
|
||||
type: "broadcast",
|
||||
method: "thread-stream-following-changed",
|
||||
sourceClientId: this.clientId,
|
||||
version: CODEX_IPC_METHOD_VERSIONS["thread-stream-following-changed"],
|
||||
params: { conversationId, hostId, following: true },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private handleClose(): void {
|
||||
const socket = this.socket;
|
||||
this.socket = undefined;
|
||||
this.decoder.reset();
|
||||
const closeError = new CodexIpcError("IPC socket closed", { code: "connection-closed" });
|
||||
for (const pending of this.pending.values()) {
|
||||
clearTimeout(pending.timer);
|
||||
pending.reject(closeError);
|
||||
}
|
||||
this.pending.clear();
|
||||
this.clientId = INITIALIZING_CLIENT_ID;
|
||||
for (const listener of this.closeListeners) safeCall(listener, closeError, (error) => this.emitError(error));
|
||||
if (!this.disposed && this.options.autoReconnect && socket) {
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
this.reconnectTimer = undefined;
|
||||
this.connect().catch((error) => this.emitError(asError(error)));
|
||||
}, this.options.reconnectDelayMs);
|
||||
}
|
||||
}
|
||||
|
||||
private emitStream(event: ConversationStreamEvent): void {
|
||||
for (const listener of this.streamListeners) safeCall(listener, event, (error) => this.emitError(error));
|
||||
}
|
||||
|
||||
private emitError(error: Error): void {
|
||||
for (const listener of this.errorListeners) safeCall(listener, error, () => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
function safeCall<T>(listener: Listener<T>, value: T, onError: (error: Error) => void): void {
|
||||
try {
|
||||
listener(value);
|
||||
} catch (error) {
|
||||
onError(asError(error));
|
||||
}
|
||||
}
|
||||
|
||||
function asError(error: unknown): Error {
|
||||
return error instanceof Error ? error : new Error(String(error));
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,152 @@
|
||||
import { accessSync, constants, Dirent, readdirSync, statSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { delimiter, isAbsolute, join, sep } from "node:path";
|
||||
|
||||
export interface CodexPathOptions {
|
||||
/** Environment used for PATH lookup. Defaults to the extension host environment. */
|
||||
env?: NodeJS.ProcessEnv;
|
||||
/** Home directory used when looking for bundled installations. */
|
||||
homeDir?: string;
|
||||
/** Platform override for deterministic tests. */
|
||||
platform?: NodeJS.Platform;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the executable used by the VS Code bridge.
|
||||
*
|
||||
* VS Code launched from Finder/Dock often receives a smaller PATH than a shell.
|
||||
* The default `codex` command therefore gets a few explicit installation
|
||||
* fallbacks, while a user-supplied command remains authoritative.
|
||||
*/
|
||||
export function resolveCodexCommand(configuredCommand = "codex", options: CodexPathOptions = {}): string {
|
||||
const command = configuredCommand.trim() || "codex";
|
||||
const env = options.env ?? process.env;
|
||||
const platform = options.platform ?? process.platform;
|
||||
const home = options.homeDir ?? homedir();
|
||||
|
||||
if (hasPathComponent(command, platform)) {
|
||||
const resolved = executablePath(command, platform);
|
||||
if (resolved) return resolved;
|
||||
throw missingCodexError(command, platform);
|
||||
}
|
||||
|
||||
const fromPath = findOnPath(command, env.PATH, platform, env.PATHEXT);
|
||||
if (fromPath) return fromPath;
|
||||
|
||||
// Only the default command gets installation-specific fallbacks. A custom
|
||||
// bare command should fail loudly instead of silently running another binary.
|
||||
if (!isDefaultCommand(command, platform)) throw missingCodexError(command, platform);
|
||||
|
||||
for (const candidate of bundledCandidates(home, platform)) {
|
||||
const resolved = executablePath(candidate, platform);
|
||||
if (resolved) return resolved;
|
||||
}
|
||||
|
||||
throw missingCodexError(command, platform);
|
||||
}
|
||||
|
||||
export function missingCodexError(command: string, platform: NodeJS.Platform = process.platform): Error {
|
||||
const examples = platform === "darwin"
|
||||
? ' Set "codexRemoteCollab.codexCommand" to the full path, for example "/Applications/ChatGPT.app/Contents/Resources/codex".'
|
||||
: ' Set "codexRemoteCollab.codexCommand" to the full path of the Codex executable.';
|
||||
return new Error(`Codex executable "${command}" was not found.${examples}`);
|
||||
}
|
||||
|
||||
function isDefaultCommand(command: string, platform: NodeJS.Platform): boolean {
|
||||
return platform === "win32" ? command.toLowerCase() === "codex" || command.toLowerCase() === "codex.exe" : command === "codex";
|
||||
}
|
||||
|
||||
function hasPathComponent(command: string, platform: NodeJS.Platform): boolean {
|
||||
return isAbsolute(command) || command.includes(sep) || (platform === "win32" && command.includes("\\"));
|
||||
}
|
||||
|
||||
function executablePath(candidate: string, platform: NodeJS.Platform): string | undefined {
|
||||
try {
|
||||
const info = statSync(candidate);
|
||||
if (!info.isFile()) return undefined;
|
||||
// X_OK is meaningful on POSIX; Windows still benefits from the file check.
|
||||
if (platform !== "win32") accessSync(candidate, constants.X_OK);
|
||||
return candidate;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function findOnPath(command: string, pathValue: string | undefined, platform: NodeJS.Platform, pathextValue?: string): string | undefined {
|
||||
if (!pathValue) return undefined;
|
||||
const extensions = platform === "win32" ? windowsExtensions(command, pathextValue) : [""];
|
||||
for (const directory of pathValue.split(delimiter)) {
|
||||
if (!directory) continue;
|
||||
for (const extension of extensions) {
|
||||
const candidate = join(directory, `${command}${extension}`);
|
||||
const resolved = executablePath(candidate, platform);
|
||||
if (resolved) return resolved;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function windowsExtensions(command: string, pathextValue: string | undefined): string[] {
|
||||
if (/[.][^./\\]+$/.test(command)) return [""];
|
||||
const extensions = (pathextValue ?? ".COM;.EXE;.BAT;.CMD")
|
||||
.split(";")
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean);
|
||||
return ["", ...extensions];
|
||||
}
|
||||
|
||||
function bundledCandidates(home: string, platform: NodeJS.Platform): string[] {
|
||||
if (platform !== "darwin") return [];
|
||||
|
||||
const candidates = [
|
||||
join(home, "Applications", "ChatGPT.app", "Contents", "Resources", "codex"),
|
||||
"/Applications/ChatGPT.app/Contents/Resources/codex",
|
||||
join(home, ".local", "bin", "codex"),
|
||||
join(home, ".npm-global", "bin", "codex"),
|
||||
];
|
||||
|
||||
for (const extensionsRoot of [
|
||||
join(home, ".vscode", "extensions"),
|
||||
join(home, ".vscode-insiders", "extensions"),
|
||||
]) {
|
||||
candidates.push(...officialExtensionCandidates(extensionsRoot));
|
||||
}
|
||||
return candidates;
|
||||
}
|
||||
|
||||
function officialExtensionCandidates(extensionsRoot: string): string[] {
|
||||
let entries: Dirent<string>[];
|
||||
try {
|
||||
entries = readdirSync(extensionsRoot, { withFileTypes: true, encoding: "utf8" });
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
const matches = entries
|
||||
.filter((entry) => entry.isDirectory() && entry.name.startsWith("openai.chatgpt-"))
|
||||
.map((entry) => {
|
||||
const directory = join(extensionsRoot, entry.name);
|
||||
let modified = 0;
|
||||
try {
|
||||
modified = statSync(directory).mtimeMs;
|
||||
} catch {
|
||||
// Keep an unreadable entry at the end of the deterministic sort.
|
||||
}
|
||||
return { directory, modified };
|
||||
})
|
||||
.sort((left, right) => right.modified - left.modified || right.directory.localeCompare(left.directory));
|
||||
|
||||
const candidates: string[] = [];
|
||||
for (const match of matches) {
|
||||
let architectures: Dirent<string>[];
|
||||
try {
|
||||
architectures = readdirSync(join(match.directory, "bin"), { withFileTypes: true, encoding: "utf8" });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const architecture of architectures) {
|
||||
if (architecture.isDirectory()) candidates.push(join(match.directory, "bin", architecture.name, "codex"));
|
||||
}
|
||||
}
|
||||
return candidates;
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { Disposable, RelayFrame, RelayTransport } from "./protocol";
|
||||
|
||||
export interface NamedRelayTransport {
|
||||
id: string;
|
||||
transport: RelayTransport;
|
||||
required?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fans host events out to local and cloud relays while presenting one
|
||||
* transport lifecycle to RelayHost. A temporary cloud outage must not stop
|
||||
* the local bridge (and vice versa).
|
||||
*/
|
||||
export class CompositeRelayTransport implements RelayTransport {
|
||||
readonly handlesHandshake = true;
|
||||
private readonly entries: NamedRelayTransport[];
|
||||
private readonly subscriptions: Disposable[] = [];
|
||||
private readonly openEntries = new Set<string>();
|
||||
private readonly messageListeners = new Set<(frame: RelayFrame) => void>();
|
||||
private readonly openListeners = new Set<() => void>();
|
||||
private readonly closeListeners = new Set<(error?: Error) => void>();
|
||||
private started = false;
|
||||
private sessionId?: string;
|
||||
|
||||
constructor(entries: NamedRelayTransport[]) {
|
||||
if (entries.length === 0) throw new Error("CompositeRelayTransport requires at least one relay");
|
||||
const ids = new Set<string>();
|
||||
for (const entry of entries) {
|
||||
if (!entry.id || ids.has(entry.id)) throw new Error(`duplicate relay id: ${entry.id || "(empty)"}`);
|
||||
ids.add(entry.id);
|
||||
}
|
||||
this.entries = [...entries];
|
||||
}
|
||||
|
||||
setSessionId(sessionId: string): void {
|
||||
this.sessionId = sessionId;
|
||||
for (const { transport } of this.entries) {
|
||||
(transport as RelayTransport & { setSessionId?: (value: string) => void }).setSessionId?.(sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
async connect(): Promise<void> {
|
||||
if (this.started) return;
|
||||
this.started = true;
|
||||
this.bindTransports();
|
||||
if (this.sessionId) this.setSessionId(this.sessionId);
|
||||
|
||||
const results = await Promise.allSettled(this.entries.map(({ transport }) => transport.connect()));
|
||||
const failures = results
|
||||
.map((result, index) => ({ result, entry: this.entries[index] }))
|
||||
.filter((item): item is { result: PromiseRejectedResult; entry: NamedRelayTransport } => item.result.status === "rejected");
|
||||
const requiredFailure = failures.find(({ entry }) => entry.required);
|
||||
const connected = results.length - failures.length;
|
||||
if (requiredFailure || connected === 0) {
|
||||
this.started = false;
|
||||
this.disposeSubscriptions();
|
||||
for (const { transport } of this.entries) transport.close();
|
||||
const detail = failures.map(({ entry, result }) => `${entry.id}: ${errorMessage(result.reason)}`).join("; ");
|
||||
throw new Error(`unable to connect relay${failures.length === 1 ? "" : "s"}: ${detail}`);
|
||||
}
|
||||
}
|
||||
|
||||
send(frame: RelayFrame): void {
|
||||
const failures: string[] = [];
|
||||
for (const { id, transport } of this.entries) {
|
||||
try {
|
||||
transport.send(frame);
|
||||
} catch (error) {
|
||||
failures.push(`${id}: ${errorMessage(error)}`);
|
||||
}
|
||||
}
|
||||
if (failures.length === this.entries.length) {
|
||||
throw new Error(`all relay sends failed: ${failures.join("; ")}`);
|
||||
}
|
||||
}
|
||||
|
||||
onMessage(listener: (frame: RelayFrame) => void): Disposable {
|
||||
this.messageListeners.add(listener);
|
||||
return { dispose: () => this.messageListeners.delete(listener) };
|
||||
}
|
||||
|
||||
onOpen(listener: () => void): Disposable {
|
||||
this.openListeners.add(listener);
|
||||
return { dispose: () => this.openListeners.delete(listener) };
|
||||
}
|
||||
|
||||
onClose(listener: (error?: Error) => void): Disposable {
|
||||
this.closeListeners.add(listener);
|
||||
return { dispose: () => this.closeListeners.delete(listener) };
|
||||
}
|
||||
|
||||
isConnected(id: string): boolean {
|
||||
return this.openEntries.has(id);
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.started = false;
|
||||
this.openEntries.clear();
|
||||
this.disposeSubscriptions();
|
||||
for (const { transport } of this.entries) transport.close();
|
||||
}
|
||||
|
||||
private bindTransports(): void {
|
||||
for (const { id, transport } of this.entries) {
|
||||
this.subscriptions.push(transport.onMessage((frame) => {
|
||||
for (const listener of this.messageListeners) listener(frame);
|
||||
}));
|
||||
if (transport.onOpen) this.subscriptions.push(transport.onOpen(() => {
|
||||
this.openEntries.add(id);
|
||||
// RelayHost publishes an authoritative snapshot after an authenticated
|
||||
// reconnect. Surface every member reconnect so a recovered cloud relay
|
||||
// is hydrated even while the local relay remained online.
|
||||
for (const listener of this.openListeners) listener();
|
||||
}));
|
||||
if (transport.onClose) this.subscriptions.push(transport.onClose((error) => {
|
||||
const wasOpen = this.openEntries.delete(id);
|
||||
if (wasOpen && this.openEntries.size === 0) {
|
||||
for (const listener of this.closeListeners) listener(error);
|
||||
}
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
private disposeSubscriptions(): void {
|
||||
for (const subscription of this.subscriptions.splice(0)) subscription.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
@@ -0,0 +1,574 @@
|
||||
import { hostname } from "node:os";
|
||||
|
||||
import * as vscode from "vscode";
|
||||
|
||||
import { CodexAgentAdapter } from "./codexAgentAdapter";
|
||||
import { resolveCodexCommand } from "./codexPath";
|
||||
import { CodexIpcAgentAdapter } from "./codexIpcAgentAdapter";
|
||||
import { CompositeRelayTransport } from "./compositeRelay";
|
||||
import { LocalRelayController, localRelayTarget } from "./localRelay";
|
||||
import { AgentAdapter, ControlMode, Disposable, JsonObject, Logger } from "./protocol";
|
||||
import { RelayClient } from "./relayClient";
|
||||
import { RelayHost } from "./relayHost";
|
||||
import { SwitchableAgentAdapter } from "./switchableAgentAdapter";
|
||||
|
||||
let activeHost: RelayHost | undefined;
|
||||
let activeAdapter: AgentAdapter | undefined;
|
||||
let activeRelay: CompositeRelayTransport | undefined;
|
||||
let activeAdapterStatusSubscription: Disposable | undefined;
|
||||
let statusItem: vscode.StatusBarItem | undefined;
|
||||
let autoStartRetryTimer: NodeJS.Timeout | undefined;
|
||||
let autoStartRetryMs = 3_000;
|
||||
let localRelayController: LocalRelayController | undefined;
|
||||
const t = (message: string, ...args: Array<string | number | boolean>): string => vscode.l10n.t(message, ...args);
|
||||
|
||||
export async function activate(context: vscode.ExtensionContext): Promise<void> {
|
||||
const output = vscode.window.createOutputChannel(t("Codex Remote Collaboration"));
|
||||
context.subscriptions.push(output);
|
||||
const logger = {
|
||||
debug: (message: string, ...args: unknown[]) => output.appendLine(`[debug] ${message} ${formatArgs(args)}`),
|
||||
info: (message: string, ...args: unknown[]) => output.appendLine(`[info] ${message} ${formatArgs(args)}`),
|
||||
warn: (message: string, ...args: unknown[]) => output.appendLine(`[warn] ${message} ${formatArgs(args)}`),
|
||||
error: (message: string, ...args: unknown[]) => output.appendLine(`[error] ${message} ${formatArgs(args)}`),
|
||||
};
|
||||
localRelayController = new LocalRelayController({ extensionPath: context.extensionPath, logger });
|
||||
|
||||
statusItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, 100);
|
||||
statusItem.command = "codexRemoteCollab.openWeb";
|
||||
statusItem.text = "$(plug) Codex Remote";
|
||||
statusItem.tooltip = t("Connecting to the local Codex collaboration service");
|
||||
statusItem.show();
|
||||
context.subscriptions.push(statusItem);
|
||||
|
||||
const start = async (automatic = false): Promise<void> => {
|
||||
if (!automatic && autoStartRetryTimer) {
|
||||
clearTimeout(autoStartRetryTimer);
|
||||
autoStartRetryTimer = undefined;
|
||||
}
|
||||
if (activeHost) {
|
||||
if (!automatic) vscode.window.showInformationMessage(t("The Codex remote bridge is already running."));
|
||||
return;
|
||||
}
|
||||
const configuration = vscode.workspace.getConfiguration("codexRemoteCollab");
|
||||
const relayConfiguration = resolveRelayConfiguration(configuration);
|
||||
const localRelayUrl = relayConfiguration.localUrl;
|
||||
if (!localRelayUrl) {
|
||||
vscode.window.showWarningMessage(t("Set codexRemoteCollab.localRelayUrl before starting the bridge."));
|
||||
return;
|
||||
}
|
||||
const localTarget = localRelayTarget(localRelayUrl);
|
||||
if (!localTarget) {
|
||||
vscode.window.showErrorMessage(t("codexRemoteCollab.localRelayUrl must be a loopback ws:// address."));
|
||||
return;
|
||||
}
|
||||
if (localTarget && configuration.get<boolean>("autoStartLocalRelay", true)) {
|
||||
setStatus("$(sync~spin) Codex Remote", t("Starting {0}", localTarget.webUrl), "codexRemoteCollab.openWeb");
|
||||
try {
|
||||
await localRelayController?.ensureRunning(localRelayUrl);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
setStatus("$(error) Codex Remote", t("Unable to start the local collaboration service: {0}", message), "codexRemoteCollab.openWeb");
|
||||
if (automatic) {
|
||||
logger.warn(`Automatic local relay start failed; retrying in ${autoStartRetryMs}ms`, error);
|
||||
scheduleAutoStartRetry(start);
|
||||
} else {
|
||||
vscode.window.showErrorMessage(t("Unable to start the local Codex collaboration service: {0}", message));
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
const legacyToken = await context.secrets.get("codexRemoteCollab.relayToken");
|
||||
const localToken = relayConfiguration.legacyRemote ? undefined : legacyToken;
|
||||
if (!localToken) logger.info("Using the loopback-only unauthenticated local relay");
|
||||
const initialControlMode = resolveInitialControlMode(configuration);
|
||||
let currentControlMode: ControlMode = initialControlMode;
|
||||
const createAdapter = (controlMode: ControlMode): AgentAdapter => {
|
||||
if (controlMode === "sync") {
|
||||
const configuredThreadId = configuration.get<string>("threadId", "").trim();
|
||||
const socketPath = configuration.get<string>("ipcSocketPath", "").trim();
|
||||
logger.info(`Synchronous mode enabled; following the VS Code Codex panel${configuredThreadId ? ` (initial conversation ${configuredThreadId})` : ""}`);
|
||||
return new CodexIpcAgentAdapter({
|
||||
threadId: configuredThreadId || undefined,
|
||||
socketPath: socketPath || undefined,
|
||||
hostId: configuration.get<string>("hostId", "local"),
|
||||
autoDiscoverThread: configuration.get<boolean>("autoDiscoverThread", true),
|
||||
// Synchronous mode has one navigation owner: the official panel.
|
||||
followVscodeSession: true,
|
||||
preferredCwds: workspaceRoots(),
|
||||
strictVersions: configuration.get<boolean>("ipcStrictVersions", true),
|
||||
logger,
|
||||
approvalTimeoutMs: configuration.get<number>("approvalTimeoutMs", 300_000),
|
||||
openNewSession: () => openOfficialNewSession(logger),
|
||||
});
|
||||
}
|
||||
|
||||
const configuredCommand = configuration.get<string>("codexCommand", "codex");
|
||||
const command = resolveCodexCommand(configuredCommand);
|
||||
const args = configuration.get<string[]>("codexArgs", ["app-server", "--stdio"]);
|
||||
const defaultCwd = configuration.get<string>("defaultCwd", "") || firstWorkspaceRoot();
|
||||
logger.info(`Asynchronous mode enabled; using independent Codex executable: ${command}`);
|
||||
return new CodexAgentAdapter({
|
||||
command,
|
||||
args,
|
||||
defaultCwd: defaultCwd || undefined,
|
||||
logger,
|
||||
approvalTimeoutMs: configuration.get<number>("approvalTimeoutMs", 300_000),
|
||||
});
|
||||
};
|
||||
const adapter = new SwitchableAgentAdapter({
|
||||
initialMode: initialControlMode,
|
||||
createAdapter,
|
||||
logger,
|
||||
onModeChanged: async (nextMode) => {
|
||||
currentControlMode = nextMode;
|
||||
await configuration.update("controlMode", nextMode, vscode.ConfigurationTarget.Global);
|
||||
setControlModeStatus(nextMode, nextMode === "async" || Boolean((await adapter.snapshot()).threadId));
|
||||
},
|
||||
});
|
||||
const localRelay = new RelayClient({
|
||||
url: localRelayUrl,
|
||||
...(localToken ? { accessToken: localToken } : {}),
|
||||
reconnect: configuration.get<boolean>("relayReconnect", true),
|
||||
logger,
|
||||
});
|
||||
const relayEntries = [{ id: "local", transport: localRelay, required: true }];
|
||||
const cloudRelayUrl = relayConfiguration.cloudUrl;
|
||||
const cloudToken = await context.secrets.get("codexRemoteCollab.cloudRelayToken")
|
||||
?? (relayConfiguration.legacyRemote ? legacyToken : undefined);
|
||||
if (cloudRelayUrl && cloudToken) {
|
||||
relayEntries.push({
|
||||
id: "aether-cloud",
|
||||
transport: new RelayClient({
|
||||
url: cloudRelayUrl,
|
||||
accessToken: cloudToken,
|
||||
reconnect: configuration.get<boolean>("relayReconnect", true),
|
||||
logger,
|
||||
}),
|
||||
required: false,
|
||||
});
|
||||
logger.info(`Aether cloud relay enabled: ${cloudRelayUrl}`);
|
||||
} else if (cloudRelayUrl) {
|
||||
logger.warn("Aether cloud relay URL is configured without a device credential; cloud sync is disabled until pairing is completed");
|
||||
}
|
||||
const relay = new CompositeRelayTransport(relayEntries);
|
||||
const capabilities = ["read_output", "send_task_input", "cancel_task", "approve_low_risk"];
|
||||
if (configuration.get<boolean>("allowHighRiskApprovals", false)) capabilities.push("approve_high_risk");
|
||||
const host = new RelayHost({ adapter, relay, logger, capabilities });
|
||||
let controlReady = initialControlMode === "async";
|
||||
activeAdapterStatusSubscription?.dispose();
|
||||
const adapterStatusSubscription = adapter.onEvent((event) => {
|
||||
if (activeAdapter !== adapter) return;
|
||||
if (event.type === "control.mode.changed") {
|
||||
const changedMode = event.payload.controlMode;
|
||||
if (changedMode === "sync" || changedMode === "async") currentControlMode = changedMode;
|
||||
}
|
||||
if (event.type !== "session.snapshot") return;
|
||||
const metadata = event.payload.metadata;
|
||||
if (metadata !== null && typeof metadata === "object" && !Array.isArray(metadata)) {
|
||||
const snapshotMode = (metadata as JsonObject).controlMode;
|
||||
if (snapshotMode === "sync" || snapshotMode === "async") currentControlMode = snapshotMode;
|
||||
}
|
||||
const waiting = event.payload.state === "waiting_for_host"
|
||||
|| (metadata !== null && typeof metadata === "object" && !Array.isArray(metadata)
|
||||
&& (metadata as JsonObject).waitingForSession === true);
|
||||
const threadId = event.threadId
|
||||
?? (typeof event.payload.threadId === "string" ? event.payload.threadId : undefined);
|
||||
controlReady = currentControlMode === "async" || (Boolean(threadId) && !waiting);
|
||||
setControlModeStatus(currentControlMode, controlReady);
|
||||
});
|
||||
activeAdapterStatusSubscription = adapterStatusSubscription;
|
||||
activeAdapter = adapter;
|
||||
activeRelay = relay;
|
||||
activeHost = host;
|
||||
if (configuration.get<boolean>("autoStartLocalRelay", true)) {
|
||||
localRelay.onClose(() => {
|
||||
if (activeHost !== host) return;
|
||||
setStatus("$(sync~spin) Codex Remote", t("Restoring the local collaboration service"), "codexRemoteCollab.openWeb");
|
||||
void localRelayController?.ensureRunning(localRelayUrl).catch((error) => {
|
||||
logger.warn("Unable to recover bundled local relay", error);
|
||||
setStatus("$(error) Codex Remote", t("Unable to restore the local collaboration service"), "codexRemoteCollab.openWeb");
|
||||
});
|
||||
});
|
||||
localRelay.onOpen(() => {
|
||||
if (activeHost === host) {
|
||||
setControlModeStatus(currentControlMode, controlReady);
|
||||
}
|
||||
});
|
||||
}
|
||||
try {
|
||||
await host.start();
|
||||
autoStartRetryMs = 3_000;
|
||||
const snapshot = await adapter.snapshot();
|
||||
const snapshotMode = snapshot.metadata?.controlMode;
|
||||
if (snapshotMode === "sync" || snapshotMode === "async") currentControlMode = snapshotMode;
|
||||
controlReady = currentControlMode === "async" || Boolean(snapshot.threadId);
|
||||
setControlModeStatus(currentControlMode, controlReady);
|
||||
if (!automatic && (currentControlMode === "async" || controlReady)) {
|
||||
vscode.window.showInformationMessage(currentControlMode === "sync"
|
||||
? t("The Codex remote bridge attached to the existing VS Code Codex conversation.")
|
||||
: t("The independent Codex remote mode connected."));
|
||||
}
|
||||
} catch (error) {
|
||||
activeHost = undefined;
|
||||
activeAdapter = undefined;
|
||||
activeRelay = undefined;
|
||||
if (activeAdapterStatusSubscription === adapterStatusSubscription) {
|
||||
activeAdapterStatusSubscription.dispose();
|
||||
activeAdapterStatusSubscription = undefined;
|
||||
}
|
||||
await host.stop().catch(() => undefined);
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (initialControlMode === "sync" && isAttachSessionUnavailable(message)) {
|
||||
setStatus("$(sync~spin) Codex Remote", t("Waiting for a Codex conversation to open in VS Code. It will connect automatically."), "codexRemoteCollab.openWeb");
|
||||
logger.info(`No attachable VS Code Codex session is available; retrying in ${autoStartRetryMs}ms`);
|
||||
scheduleAutoStartRetry(start);
|
||||
return;
|
||||
}
|
||||
setStatus("$(error) Codex Remote", initialControlMode === "sync" ? t("The Codex conversation is not connected") : t("The independent Codex mode is not connected"), "codexRemoteCollab.openWeb");
|
||||
if (automatic) {
|
||||
logger.warn(`Automatic bridge start failed; retrying in ${autoStartRetryMs}ms`, error);
|
||||
scheduleAutoStartRetry(start);
|
||||
} else {
|
||||
const detail = localTarget && /ECONNREFUSED|connect refused/i.test(message)
|
||||
? t("The local collaboration service at {0} is temporarily unavailable. The extension will keep retrying.", localTarget.webUrl)
|
||||
: t("Unable to start the Codex remote bridge: {0}", message);
|
||||
vscode.window.showErrorMessage(detail);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const stop = async (): Promise<void> => {
|
||||
if (autoStartRetryTimer) {
|
||||
clearTimeout(autoStartRetryTimer);
|
||||
autoStartRetryTimer = undefined;
|
||||
}
|
||||
autoStartRetryMs = 3_000;
|
||||
const host = activeHost;
|
||||
activeHost = undefined;
|
||||
activeAdapter = undefined;
|
||||
activeRelay = undefined;
|
||||
activeAdapterStatusSubscription?.dispose();
|
||||
activeAdapterStatusSubscription = undefined;
|
||||
if (host) await host.stop();
|
||||
setStatus("$(plug) Codex Remote", t("Bridge paused. Click to open the web control and resume automatically."), "codexRemoteCollab.openWeb");
|
||||
};
|
||||
|
||||
context.subscriptions.push(vscode.commands.registerCommand("codexRemoteCollab.openWeb", async () => {
|
||||
const localRelayUrl = resolveRelayConfiguration(vscode.workspace.getConfiguration("codexRemoteCollab")).localUrl;
|
||||
const webUrl = localRelayController?.getWebUrl(localRelayUrl);
|
||||
if (!webUrl) {
|
||||
vscode.window.showErrorMessage(t("The local collaboration URL is invalid. Check codexRemoteCollab.localRelayUrl."));
|
||||
return;
|
||||
}
|
||||
if (!activeHost) await start(false);
|
||||
if (activeHost) await vscode.env.openExternal(vscode.Uri.parse(webUrl));
|
||||
}));
|
||||
context.subscriptions.push(vscode.commands.registerCommand("codexRemoteCollab.start", start));
|
||||
context.subscriptions.push(vscode.commands.registerCommand("codexRemoteCollab.stop", stop));
|
||||
context.subscriptions.push(vscode.commands.registerCommand("codexRemoteCollab.setThreadId", async () => {
|
||||
const configuration = vscode.workspace.getConfiguration("codexRemoteCollab");
|
||||
const current = configuration.get<string>("threadId", "");
|
||||
const value = await vscode.window.showInputBox({
|
||||
prompt: t("Existing Codex conversation ID (leave blank for auto-discovery)"),
|
||||
value: current,
|
||||
ignoreFocusOut: true,
|
||||
});
|
||||
if (value === undefined) return;
|
||||
await configuration.update("threadId", value.trim(), vscode.ConfigurationTarget.Global);
|
||||
vscode.window.showInformationMessage(value.trim()
|
||||
? t("Codex Remote will attach to {0} after the next bridge start.", value.trim())
|
||||
: t("Codex Remote will auto-discover the latest VS Code Codex conversation after the next bridge start."));
|
||||
}));
|
||||
context.subscriptions.push(vscode.commands.registerCommand("codexRemoteCollab.setRelayToken", async () => {
|
||||
const token = await vscode.window.showInputBox({ prompt: t("Relay access token (leave blank for the local relay)"), password: true, ignoreFocusOut: true });
|
||||
if (token === undefined) return;
|
||||
await context.secrets.store("codexRemoteCollab.relayToken", token);
|
||||
vscode.window.showInformationMessage(t("Relay token stored in VS Code SecretStorage."));
|
||||
}));
|
||||
context.subscriptions.push(vscode.commands.registerCommand("codexRemoteCollab.configureCloud", async () => {
|
||||
const configuration = vscode.workspace.getConfiguration("codexRemoteCollab");
|
||||
const currentUrl = configuration.get<string>("cloudRelayUrl", "");
|
||||
const url = await vscode.window.showInputBox({
|
||||
prompt: t("Aether cloud relay WebSocket URL"),
|
||||
value: currentUrl,
|
||||
placeHolder: "wss://aether.example.com/api/vscodex/ws",
|
||||
ignoreFocusOut: true,
|
||||
validateInput: validateCloudRelayUrl,
|
||||
});
|
||||
if (url === undefined) return;
|
||||
if (!url.trim()) {
|
||||
await configuration.update("cloudRelayUrl", "", vscode.ConfigurationTarget.Global);
|
||||
await context.secrets.delete("codexRemoteCollab.cloudRelayToken");
|
||||
vscode.window.showInformationMessage(t("Aether cloud connection removed. Local control remains enabled."));
|
||||
return;
|
||||
}
|
||||
const token = await vscode.window.showInputBox({
|
||||
prompt: t("Device credential from the Aether pairing flow"),
|
||||
password: true,
|
||||
ignoreFocusOut: true,
|
||||
});
|
||||
if (token === undefined) return;
|
||||
if (!token.trim()) {
|
||||
vscode.window.showWarningMessage(t("A non-empty Aether device credential is required."));
|
||||
return;
|
||||
}
|
||||
await configuration.update("cloudRelayUrl", url.trim(), vscode.ConfigurationTarget.Global);
|
||||
await context.secrets.store("codexRemoteCollab.cloudRelayToken", token.trim());
|
||||
vscode.window.showInformationMessage(t("Aether cloud connection saved. Restart the Codex Remote bridge to connect; local control remains available."));
|
||||
}));
|
||||
context.subscriptions.push(vscode.commands.registerCommand("codexRemoteCollab.pairCloud", async () => {
|
||||
const configuration = vscode.workspace.getConfiguration("codexRemoteCollab");
|
||||
const currentBaseUrl = configuration.get<string>("aetherUrl", "");
|
||||
const baseUrl = await vscode.window.showInputBox({
|
||||
prompt: t("Aether server URL"),
|
||||
value: currentBaseUrl,
|
||||
placeHolder: "https://aether.example.com",
|
||||
ignoreFocusOut: true,
|
||||
validateInput: validateAetherBaseUrl,
|
||||
});
|
||||
if (baseUrl === undefined || !baseUrl.trim()) return;
|
||||
const code = await vscode.window.showInputBox({
|
||||
prompt: t("One-time pairing code shown in Aether"),
|
||||
placeHolder: "ABCD-EFGH",
|
||||
ignoreFocusOut: true,
|
||||
validateInput: (value) => normalizePairingCode(value).length === 8 ? undefined : t("Enter the 8-character pairing code."),
|
||||
});
|
||||
if (code === undefined || !code.trim()) return;
|
||||
try {
|
||||
const normalizedBaseUrl = baseUrl.trim().replace(/\/+$/, "");
|
||||
const response = await fetch(`${normalizedBaseUrl}/api/vscodex/pair`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ code: normalizePairingCode(code), name: hostname() || "VS Code" }),
|
||||
});
|
||||
const raw = await response.text();
|
||||
let result: unknown;
|
||||
try {
|
||||
result = JSON.parse(raw);
|
||||
} catch {
|
||||
result = null;
|
||||
}
|
||||
if (!response.ok) {
|
||||
const detail = isJsonRecord(result) && typeof result.error === "string" ? result.error : `HTTP ${response.status}`;
|
||||
throw new Error(detail);
|
||||
}
|
||||
if (!isJsonRecord(result) || typeof result.device_token !== "string" || typeof result.ws_url !== "string") {
|
||||
throw new Error(t("Aether returned an invalid pairing response."));
|
||||
}
|
||||
const wsError = validateCloudRelayUrl(result.ws_url);
|
||||
if (wsError) throw new Error(wsError);
|
||||
await configuration.update("aetherUrl", normalizedBaseUrl, vscode.ConfigurationTarget.Global);
|
||||
await configuration.update("cloudRelayUrl", result.ws_url, vscode.ConfigurationTarget.Global);
|
||||
await context.secrets.store("codexRemoteCollab.cloudRelayToken", result.device_token);
|
||||
if (activeHost) await stop();
|
||||
await start(false);
|
||||
if (!activeHost) return;
|
||||
if (activeRelay?.isConnected("aether-cloud")) {
|
||||
vscode.window.showInformationMessage(t("Aether pairing completed. Local and cloud control are both active."));
|
||||
} else {
|
||||
vscode.window.showWarningMessage(t("Aether pairing was saved, but the cloud connection is currently unavailable. Local control remains active and the cloud connection will retry."));
|
||||
}
|
||||
} catch (error) {
|
||||
vscode.window.showErrorMessage(t("Unable to pair with Aether: {0}", error instanceof Error ? error.message : String(error)));
|
||||
}
|
||||
}));
|
||||
context.subscriptions.push(vscode.commands.registerCommand("codexRemoteCollab.sendInput", async () => {
|
||||
if (!activeAdapter) {
|
||||
vscode.window.showWarningMessage(t("Start the Codex remote bridge first."));
|
||||
return;
|
||||
}
|
||||
const text = await vscode.window.showInputBox({ prompt: t("Send input to the active Codex turn"), ignoreFocusOut: true });
|
||||
if (text === undefined || !text.trim()) return;
|
||||
try {
|
||||
await activeAdapter.sendInput(text);
|
||||
} catch (error) {
|
||||
vscode.window.showErrorMessage(t("Unable to send Codex input: {0}", error instanceof Error ? error.message : String(error)));
|
||||
}
|
||||
}));
|
||||
context.subscriptions.push(vscode.commands.registerCommand("codexRemoteCollab.snapshot", async () => {
|
||||
if (!activeAdapter) return vscode.window.showWarningMessage(t("Start the Codex remote bridge first."));
|
||||
const snapshot = await activeAdapter.snapshot();
|
||||
output.appendLine(JSON.stringify(snapshot));
|
||||
output.show(true);
|
||||
}));
|
||||
|
||||
if (vscode.workspace.getConfiguration("codexRemoteCollab").get<boolean>("autoStart", true)) await start(true);
|
||||
}
|
||||
|
||||
export async function deactivate(): Promise<void> {
|
||||
if (autoStartRetryTimer) {
|
||||
clearTimeout(autoStartRetryTimer);
|
||||
autoStartRetryTimer = undefined;
|
||||
}
|
||||
const host = activeHost;
|
||||
activeHost = undefined;
|
||||
activeAdapter = undefined;
|
||||
activeAdapterStatusSubscription?.dispose();
|
||||
activeAdapterStatusSubscription = undefined;
|
||||
if (host) await host.stop();
|
||||
const localRelay = localRelayController;
|
||||
localRelayController = undefined;
|
||||
if (localRelay) await localRelay.stop();
|
||||
}
|
||||
|
||||
function firstWorkspaceRoot(): string | undefined {
|
||||
return vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
|
||||
}
|
||||
|
||||
function workspaceRoots(): string[] {
|
||||
return (vscode.workspace.workspaceFolders ?? []).map((folder) => folder.uri.fsPath);
|
||||
}
|
||||
|
||||
function setStatus(text: string, tooltip: string, command?: string): void {
|
||||
if (!statusItem) return;
|
||||
statusItem.text = text;
|
||||
statusItem.tooltip = tooltip;
|
||||
statusItem.command = command;
|
||||
}
|
||||
|
||||
function setAttachStatus(ready: boolean): void {
|
||||
setStatus(
|
||||
ready ? "$(check) Codex Remote" : "$(sync~spin) Codex Remote",
|
||||
ready ? t("Attached to the existing Codex conversation. Click to open the web control.") : t("Waiting for a Codex conversation to open in VS Code. It will connect automatically."),
|
||||
"codexRemoteCollab.openWeb",
|
||||
);
|
||||
}
|
||||
|
||||
function setControlModeStatus(mode: ControlMode, ready: boolean): void {
|
||||
if (mode === "sync") {
|
||||
setAttachStatus(ready);
|
||||
return;
|
||||
}
|
||||
setStatus(
|
||||
ready ? "$(check) Codex Remote" : "$(sync~spin) Codex Remote",
|
||||
ready
|
||||
? t("Independent Codex mode is connected. Click to open the web control.")
|
||||
: t("Starting the independent Codex mode."),
|
||||
"codexRemoteCollab.openWeb",
|
||||
);
|
||||
}
|
||||
|
||||
function scheduleAutoStartRetry(start: (automatic?: boolean) => Promise<void>): void {
|
||||
if (autoStartRetryTimer) return;
|
||||
const delay = autoStartRetryMs;
|
||||
autoStartRetryMs = Math.min(autoStartRetryMs * 2, 30_000);
|
||||
autoStartRetryTimer = setTimeout(() => {
|
||||
autoStartRetryTimer = undefined;
|
||||
void start(true);
|
||||
}, delay);
|
||||
}
|
||||
|
||||
function formatArgs(args: unknown[]): string {
|
||||
return args.length ? args.map((arg) => (typeof arg === "string" ? arg : JSON.stringify(arg))).join(" ") : "";
|
||||
}
|
||||
|
||||
function isAttachSessionUnavailable(message: string): boolean {
|
||||
return message.includes("没有找到已打开的 VS Code Codex 会话")
|
||||
|| /找不到会话\s+.+\s+的 VS Code Codex owner/.test(message);
|
||||
}
|
||||
|
||||
function validateCloudRelayUrl(value: string): string | undefined {
|
||||
if (!value.trim()) return undefined;
|
||||
try {
|
||||
const url = new URL(value.trim());
|
||||
if (url.protocol !== "wss:" && url.protocol !== "ws:") return t("Use a ws:// or wss:// URL.");
|
||||
if (url.protocol === "ws:" && !isLoopbackHostname(url.hostname)) {
|
||||
return t("Remote Aether connections must use wss://.");
|
||||
}
|
||||
return undefined;
|
||||
} catch {
|
||||
return t("Enter a valid WebSocket URL.");
|
||||
}
|
||||
}
|
||||
|
||||
function resolveRelayConfiguration(configuration: vscode.WorkspaceConfiguration): {
|
||||
localUrl: string;
|
||||
cloudUrl: string;
|
||||
legacyRemote: boolean;
|
||||
} {
|
||||
const defaultLocalUrl = "ws://127.0.0.1:8787/v1/connect";
|
||||
const explicitLocal = inspectedValue<string>(configuration.inspect<string>("localRelayUrl"));
|
||||
const explicitCloud = inspectedValue<string>(configuration.inspect<string>("cloudRelayUrl"));
|
||||
const explicitLegacy = inspectedValue<string>(configuration.inspect<string>("relayUrl"));
|
||||
const legacyUrl = explicitLegacy?.trim() || "";
|
||||
const legacyRemote = Boolean(legacyUrl && !localRelayTarget(legacyUrl));
|
||||
const localUrl = (explicitLocal?.trim()
|
||||
|| (!legacyRemote ? legacyUrl : "")
|
||||
|| configuration.get<string>("localRelayUrl", defaultLocalUrl).trim()
|
||||
|| defaultLocalUrl);
|
||||
const cloudUrl = explicitCloud?.trim()
|
||||
|| (legacyRemote ? legacyUrl : "")
|
||||
|| configuration.get<string>("cloudRelayUrl", "").trim();
|
||||
return { localUrl, cloudUrl, legacyRemote };
|
||||
}
|
||||
|
||||
function inspectedValue<T>(inspection: ReturnType<vscode.WorkspaceConfiguration["inspect"]> | undefined): T | undefined {
|
||||
if (!inspection) return undefined;
|
||||
const values = inspection as {
|
||||
globalLanguageValue?: T;
|
||||
workspaceFolderLanguageValue?: T;
|
||||
workspaceLanguageValue?: T;
|
||||
workspaceFolderValue?: T;
|
||||
workspaceValue?: T;
|
||||
globalValue?: T;
|
||||
};
|
||||
return values.workspaceFolderLanguageValue
|
||||
?? values.workspaceLanguageValue
|
||||
?? values.globalLanguageValue
|
||||
?? values.workspaceFolderValue
|
||||
?? values.workspaceValue
|
||||
?? values.globalValue;
|
||||
}
|
||||
|
||||
function resolveInitialControlMode(configuration: vscode.WorkspaceConfiguration): ControlMode {
|
||||
const configured = inspectedValue<ControlMode>(configuration.inspect<ControlMode>("controlMode"));
|
||||
if (configured === "sync" || configured === "async") return configured;
|
||||
const legacyMode = inspectedValue<"attach" | "spawn">(configuration.inspect<"attach" | "spawn">("mode"));
|
||||
return legacyMode === "spawn" ? "async" : "sync";
|
||||
}
|
||||
|
||||
function validateAetherBaseUrl(value: string): string | undefined {
|
||||
if (!value.trim()) return t("Enter the Aether server URL.");
|
||||
try {
|
||||
const url = new URL(value.trim());
|
||||
if (url.username || url.password || url.search || url.hash) return t("Use the Aether origin without credentials, a query, or a fragment.");
|
||||
if (url.protocol === "https:") return undefined;
|
||||
if (url.protocol === "http:" && isLoopbackHostname(url.hostname)) return undefined;
|
||||
return t("Remote Aether servers must use https://.");
|
||||
} catch {
|
||||
return t("Enter a valid URL.");
|
||||
}
|
||||
}
|
||||
|
||||
function normalizePairingCode(value: string): string {
|
||||
return value.toUpperCase().replace(/[^A-Z2-9]/g, "");
|
||||
}
|
||||
|
||||
function isLoopbackHostname(value: string): boolean {
|
||||
const hostname = value.replace(/^\[|\]$/g, "").toLowerCase();
|
||||
return hostname === "127.0.0.1" || hostname === "localhost" || hostname === "::1";
|
||||
}
|
||||
|
||||
function isJsonRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reuse the official extension's command registry for the header's new-chat
|
||||
* action. This keeps the remote UI attached to the same VS Code Codex
|
||||
* installation and avoids launching a second app-server process.
|
||||
*/
|
||||
async function openOfficialNewSession(logger: Logger): Promise<JsonObject> {
|
||||
const commands = await vscode.commands.getCommands(true);
|
||||
const command = commands.includes("chatgpt.newCodexPanel")
|
||||
? "chatgpt.newCodexPanel"
|
||||
: commands.includes("chatgpt.newChat")
|
||||
? "chatgpt.newChat"
|
||||
: undefined;
|
||||
if (!command) {
|
||||
throw new Error(t("The official Codex extension new-conversation command was not found. Make sure the VS Code Codex extension is enabled."));
|
||||
}
|
||||
await vscode.commands.executeCommand(command);
|
||||
logger.info?.("Opened a new official Codex conversation with " + command);
|
||||
return { opened: true, command };
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export * from "./protocol";
|
||||
export * from "./jsonlRpc";
|
||||
export * from "./codexAgentAdapter";
|
||||
export * from "./relayClient";
|
||||
export * from "./compositeRelay";
|
||||
export * from "./relayHost";
|
||||
export * from "./bridge";
|
||||
export * from "./codexPath";
|
||||
export * from "./codexIpc";
|
||||
export * from "./codexIpcAgentAdapter";
|
||||
export * from "./switchableAgentAdapter";
|
||||
@@ -0,0 +1,268 @@
|
||||
import { ChildProcessWithoutNullStreams, spawn } from "node:child_process";
|
||||
import { createInterface, Interface as ReadLineInterface } from "node:readline";
|
||||
|
||||
import {
|
||||
asJsonValue,
|
||||
Disposable,
|
||||
isRecord,
|
||||
JsonRpcId,
|
||||
JsonRpcNotification,
|
||||
JsonRpcRequest,
|
||||
JsonValue,
|
||||
Logger,
|
||||
jsonRpcIdKey,
|
||||
} from "./protocol";
|
||||
|
||||
export interface JsonlRpcClientOptions {
|
||||
command?: string;
|
||||
args?: string[];
|
||||
cwd?: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
logger?: Logger;
|
||||
/** Optional request timeout. Zero disables it, which is useful for long turns. */
|
||||
requestTimeoutMs?: number;
|
||||
}
|
||||
|
||||
interface PendingRequest {
|
||||
method: string;
|
||||
resolve: (value: JsonValue) => void;
|
||||
reject: (reason: Error) => void;
|
||||
timer?: NodeJS.Timeout;
|
||||
}
|
||||
|
||||
export class JsonRpcRemoteError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly code: number,
|
||||
readonly data?: JsonValue,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "JsonRpcRemoteError";
|
||||
}
|
||||
}
|
||||
|
||||
/** Minimal newline-delimited JSON-RPC client used by `codex app-server --stdio`. */
|
||||
export class JsonlRpcClient {
|
||||
private readonly options: Required<Pick<JsonlRpcClientOptions, "command" | "args" | "requestTimeoutMs">> &
|
||||
Omit<JsonlRpcClientOptions, "command" | "args" | "requestTimeoutMs">;
|
||||
private child?: ChildProcessWithoutNullStreams;
|
||||
private stdoutLines?: ReadLineInterface;
|
||||
private nextId = 1;
|
||||
private readonly pending = new Map<string, PendingRequest>();
|
||||
private readonly notificationListeners = new Set<(message: JsonRpcNotification) => void>();
|
||||
private readonly requestListeners = new Set<(message: JsonRpcRequest) => void>();
|
||||
private readonly exitListeners = new Set<(error?: Error) => void>();
|
||||
|
||||
constructor(options: JsonlRpcClientOptions = {}) {
|
||||
this.options = {
|
||||
command: options.command ?? "codex",
|
||||
args: options.args ?? ["app-server", "--stdio"],
|
||||
requestTimeoutMs: options.requestTimeoutMs ?? 0,
|
||||
cwd: options.cwd,
|
||||
env: options.env,
|
||||
logger: options.logger,
|
||||
};
|
||||
}
|
||||
|
||||
get running(): boolean {
|
||||
return Boolean(this.child && this.child.exitCode === null && !this.child.killed);
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
if (this.running) return;
|
||||
|
||||
const child = spawn(this.options.command, this.options.args, {
|
||||
cwd: this.options.cwd,
|
||||
env: { ...process.env, ...(this.options.env ?? {}) },
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
windowsHide: true,
|
||||
});
|
||||
this.child = child;
|
||||
|
||||
this.stdoutLines = createInterface({ input: child.stdout, crlfDelay: Infinity });
|
||||
this.stdoutLines.on("line", (line) => this.handleLine(line));
|
||||
child.stderr.on("data", (chunk: Buffer) => {
|
||||
const text = redactDiagnostic(chunk.toString("utf8").trim());
|
||||
if (text) this.options.logger?.debug?.(`[app-server stderr] ${text}`);
|
||||
});
|
||||
child.once("exit", (code, signal) => {
|
||||
const expected = child.killed;
|
||||
const error = expected
|
||||
? undefined
|
||||
: new Error(`codex app-server exited (code=${String(code)}, signal=${String(signal)})`);
|
||||
this.handleExit(error);
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const onSpawn = (): void => {
|
||||
child.off("error", onError);
|
||||
resolve();
|
||||
};
|
||||
const onError = (error: Error): void => {
|
||||
child.off("spawn", onSpawn);
|
||||
const spawnError = error as NodeJS.ErrnoException;
|
||||
if (spawnError.code === "ENOENT") {
|
||||
reject(new Error(`Codex executable "${this.options.command}" was not found. Set codexRemoteCollab.codexCommand to its full path.`));
|
||||
return;
|
||||
}
|
||||
reject(error);
|
||||
};
|
||||
child.once("spawn", onSpawn);
|
||||
child.once("error", onError);
|
||||
});
|
||||
}
|
||||
|
||||
request(method: string, params?: JsonValue): Promise<JsonValue> {
|
||||
if (!this.running) return Promise.reject(new Error("app-server is not running"));
|
||||
const id = this.nextId++;
|
||||
|
||||
return new Promise<JsonValue>((resolve, reject) => {
|
||||
const pending: PendingRequest = { method, resolve, reject };
|
||||
if (this.options.requestTimeoutMs > 0) {
|
||||
pending.timer = setTimeout(() => {
|
||||
this.pending.delete(jsonRpcIdKey(id));
|
||||
reject(new Error(`app-server request timed out: ${method}`));
|
||||
}, this.options.requestTimeoutMs);
|
||||
}
|
||||
this.pending.set(jsonRpcIdKey(id), pending);
|
||||
try {
|
||||
this.write({ id, method, ...(params === undefined ? {} : { params }) });
|
||||
} catch (error) {
|
||||
this.pending.delete(jsonRpcIdKey(id));
|
||||
if (pending.timer) clearTimeout(pending.timer);
|
||||
reject(error instanceof Error ? error : new Error(String(error)));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
notify(method: string, params?: JsonValue): void {
|
||||
this.write({ method, ...(params === undefined ? {} : { params }) });
|
||||
}
|
||||
|
||||
respond(id: JsonRpcId, result: JsonValue): void {
|
||||
this.write({ id, result });
|
||||
}
|
||||
|
||||
respondError(id: JsonRpcId, code: number, message: string, data?: JsonValue): void {
|
||||
this.write({ id, error: { code, message, ...(data === undefined ? {} : { data }) } });
|
||||
}
|
||||
|
||||
onNotification(listener: (message: JsonRpcNotification) => void): Disposable {
|
||||
this.notificationListeners.add(listener);
|
||||
return { dispose: () => this.notificationListeners.delete(listener) };
|
||||
}
|
||||
|
||||
onServerRequest(listener: (message: JsonRpcRequest) => void): Disposable {
|
||||
this.requestListeners.add(listener);
|
||||
return { dispose: () => this.requestListeners.delete(listener) };
|
||||
}
|
||||
|
||||
onExit(listener: (error?: Error) => void): Disposable {
|
||||
this.exitListeners.add(listener);
|
||||
return { dispose: () => this.exitListeners.delete(listener) };
|
||||
}
|
||||
|
||||
close(): void {
|
||||
const child = this.child;
|
||||
this.child = undefined;
|
||||
this.stdoutLines?.close();
|
||||
this.stdoutLines = undefined;
|
||||
if (child && child.exitCode === null && !child.killed) child.kill();
|
||||
this.rejectAll(new Error("app-server client closed"));
|
||||
}
|
||||
|
||||
private write(message: unknown): void {
|
||||
const child = this.child;
|
||||
if (!child || child.exitCode !== null || child.killed || !child.stdin.writable) {
|
||||
throw new Error("app-server is not running");
|
||||
}
|
||||
child.stdin.write(`${JSON.stringify(message)}\n`, "utf8");
|
||||
}
|
||||
|
||||
private handleLine(line: string): void {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) return;
|
||||
|
||||
let message: unknown;
|
||||
try {
|
||||
message = JSON.parse(trimmed);
|
||||
} catch (error) {
|
||||
this.options.logger?.warn?.("Ignoring malformed app-server JSON", error, trimmed.slice(0, 500));
|
||||
return;
|
||||
}
|
||||
if (!isRecord(message)) return;
|
||||
|
||||
const hasId = typeof message.id === "string" || typeof message.id === "number";
|
||||
const hasMethod = typeof message.method === "string";
|
||||
if (hasId && (Object.hasOwn(message, "result") || Object.hasOwn(message, "error")) && !hasMethod) {
|
||||
this.handleResponse(message as Record<string, unknown> & { id: JsonRpcId });
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasMethod && hasId) {
|
||||
const request: JsonRpcRequest = {
|
||||
id: message.id as JsonRpcId,
|
||||
method: message.method as string,
|
||||
...(message.params === undefined ? {} : { params: asJsonValue(message.params) }),
|
||||
};
|
||||
for (const listener of this.requestListeners) listener(request);
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasMethod) {
|
||||
const notification: JsonRpcNotification = {
|
||||
method: message.method as string,
|
||||
...(message.params === undefined ? {} : { params: asJsonValue(message.params) }),
|
||||
};
|
||||
for (const listener of this.notificationListeners) listener(notification);
|
||||
return;
|
||||
}
|
||||
|
||||
this.options.logger?.warn?.("Ignoring unknown app-server message", message);
|
||||
}
|
||||
|
||||
private handleResponse(message: Record<string, unknown> & { id: JsonRpcId }): void {
|
||||
const pending = this.pending.get(jsonRpcIdKey(message.id));
|
||||
if (!pending) {
|
||||
this.options.logger?.warn?.(`Received response for unknown app-server request ${String(message.id)}`);
|
||||
return;
|
||||
}
|
||||
this.pending.delete(jsonRpcIdKey(message.id));
|
||||
if (pending.timer) clearTimeout(pending.timer);
|
||||
|
||||
if (isRecord(message.error)) {
|
||||
pending.reject(
|
||||
new JsonRpcRemoteError(
|
||||
typeof message.error.message === "string" ? message.error.message : `Request failed: ${pending.method}`,
|
||||
typeof message.error.code === "number" ? message.error.code : -32000,
|
||||
message.error.data === undefined ? undefined : asJsonValue(message.error.data),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
pending.resolve(message.result === undefined ? null : asJsonValue(message.result));
|
||||
}
|
||||
|
||||
private handleExit(error?: Error): void {
|
||||
this.child = undefined;
|
||||
this.stdoutLines?.close();
|
||||
this.stdoutLines = undefined;
|
||||
this.rejectAll(error ?? new Error("app-server exited"));
|
||||
for (const listener of this.exitListeners) listener(error);
|
||||
}
|
||||
|
||||
private rejectAll(error: Error): void {
|
||||
for (const request of this.pending.values()) {
|
||||
if (request.timer) clearTimeout(request.timer);
|
||||
request.reject(error);
|
||||
}
|
||||
this.pending.clear();
|
||||
}
|
||||
}
|
||||
|
||||
function redactDiagnostic(text: string): string {
|
||||
return text
|
||||
.replace(/Bearer\s+[A-Za-z0-9._~+\-/]+=*/gi, "Bearer [REDACTED]")
|
||||
.replace(/\b(?:sk-[A-Za-z0-9_-]{12,}|gh[pousr]_[A-Za-z0-9_]{12,})\b/g, "[REDACTED]")
|
||||
.replace(/((?:token|secret|password|api[_-]?key)\s*[:=]\s*)[^\s,;]+/gi, "$1[REDACTED]");
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
import * as http from "node:http";
|
||||
import * as path from "node:path";
|
||||
|
||||
import { Logger } from "./protocol";
|
||||
|
||||
interface BundledRelay {
|
||||
start(): Promise<{ host: string; port: number }>;
|
||||
stop(): Promise<void>;
|
||||
}
|
||||
|
||||
interface BundledRelayModule {
|
||||
CodexRelay: new (options: Record<string, unknown>) => BundledRelay;
|
||||
}
|
||||
|
||||
export interface LocalRelayTarget {
|
||||
host: string;
|
||||
port: number;
|
||||
healthUrl: string;
|
||||
webUrl: string;
|
||||
}
|
||||
|
||||
export interface LocalRelayControllerOptions {
|
||||
extensionPath: string;
|
||||
logger?: Logger;
|
||||
probeTimeoutMs?: number;
|
||||
relayModulePath?: string;
|
||||
loadRelayModule?: (modulePath: string) => BundledRelayModule;
|
||||
probeRelayHealth?: (url: string, timeoutMs?: number) => Promise<boolean>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Owns the loopback relay bundled with the companion extension. Remote and
|
||||
* TLS relay URLs deliberately stay outside this controller.
|
||||
*/
|
||||
export class LocalRelayController {
|
||||
private readonly options: LocalRelayControllerOptions;
|
||||
private relay?: BundledRelay;
|
||||
private target?: LocalRelayTarget;
|
||||
private starting?: Promise<boolean>;
|
||||
private generation = 0;
|
||||
|
||||
constructor(options: LocalRelayControllerOptions) {
|
||||
this.options = options;
|
||||
}
|
||||
|
||||
async ensureRunning(relayUrl: string): Promise<boolean> {
|
||||
const target = localRelayTarget(relayUrl);
|
||||
if (!target) return false;
|
||||
if ((this.relay || this.starting) && this.target?.healthUrl !== target.healthUrl) await this.stop();
|
||||
const generation = this.generation;
|
||||
this.target = target;
|
||||
const available = await this.probeHealth(target.healthUrl);
|
||||
// `stop()` may run while the health request is in flight. Do not let that
|
||||
// completed probe resurrect a relay owned by a deactivated extension.
|
||||
if (generation !== this.generation) return false;
|
||||
if (available) return false;
|
||||
if (this.relay) {
|
||||
await this.relay.stop().catch(() => undefined);
|
||||
this.relay = undefined;
|
||||
}
|
||||
if (this.starting) return this.starting;
|
||||
this.starting = this.startBundledRelay(target).finally(() => {
|
||||
this.starting = undefined;
|
||||
});
|
||||
return this.starting;
|
||||
}
|
||||
|
||||
getWebUrl(relayUrl: string): string | undefined {
|
||||
return localRelayTarget(relayUrl)?.webUrl;
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
this.generation += 1;
|
||||
const starting = this.starting;
|
||||
if (starting) await starting.catch(() => undefined);
|
||||
const relay = this.relay;
|
||||
this.relay = undefined;
|
||||
this.target = undefined;
|
||||
if (relay) await relay.stop();
|
||||
}
|
||||
|
||||
private async startBundledRelay(target: LocalRelayTarget): Promise<boolean> {
|
||||
const modulePath = this.options.relayModulePath
|
||||
?? path.join(this.options.extensionPath, "dist", "local-relay", "server.js");
|
||||
let relay: BundledRelay;
|
||||
try {
|
||||
const load = this.options.loadRelayModule ?? ((value: string) => require(value) as BundledRelayModule);
|
||||
const module = load(modulePath);
|
||||
if (typeof module?.CodexRelay !== "function") throw new Error("bundled relay module is invalid");
|
||||
relay = new module.CodexRelay({
|
||||
host: target.host,
|
||||
port: target.port,
|
||||
mode: "host",
|
||||
spawnCodex: false,
|
||||
authRequired: false,
|
||||
});
|
||||
await relay.start();
|
||||
} catch (error) {
|
||||
// Another VS Code window can win the listen race after our health
|
||||
// probe. Treat that as success only when the expected relay responds.
|
||||
if (await this.probeHealth(target.healthUrl)) {
|
||||
this.options.logger?.info?.(`Using existing local relay at ${target.webUrl}`);
|
||||
return false;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
this.relay = relay;
|
||||
this.options.logger?.info?.(`Started bundled local relay at ${target.webUrl}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
private probeHealth(url: string): Promise<boolean> {
|
||||
const probe = this.options.probeRelayHealth ?? relayHealthAvailable;
|
||||
return probe(url, this.options.probeTimeoutMs);
|
||||
}
|
||||
}
|
||||
|
||||
export function localRelayTarget(relayUrl: string): LocalRelayTarget | undefined {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(relayUrl);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
if (url.protocol !== "ws:" || !isLoopbackHostname(url.hostname)) return undefined;
|
||||
const port = Number(url.port || 80);
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65_535) return undefined;
|
||||
const hostname = normalizeLoopbackHostname(url.hostname);
|
||||
const authorityHost = hostname.includes(":") ? `[${hostname}]` : hostname;
|
||||
return {
|
||||
host: hostname,
|
||||
port,
|
||||
healthUrl: `http://${authorityHost}:${port}/api/health`,
|
||||
webUrl: `http://${authorityHost}:${port}/`,
|
||||
};
|
||||
}
|
||||
|
||||
function isLoopbackHostname(hostname: string): boolean {
|
||||
const normalized = hostname.toLowerCase().replace(/^\[|\]$/g, "");
|
||||
return normalized === "localhost" || normalized === "127.0.0.1" || normalized === "::1";
|
||||
}
|
||||
|
||||
function normalizeLoopbackHostname(hostname: string): string {
|
||||
const normalized = hostname.toLowerCase().replace(/^\[|\]$/g, "");
|
||||
return normalized === "localhost" ? "127.0.0.1" : normalized;
|
||||
}
|
||||
|
||||
export function relayHealthAvailable(url: string, timeoutMs = 700): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
let settled = false;
|
||||
let timer: NodeJS.Timeout | undefined;
|
||||
const finish = (available: boolean): void => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
if (timer) clearTimeout(timer);
|
||||
resolve(available);
|
||||
};
|
||||
const request = http.get(url, (response) => {
|
||||
if (response.statusCode !== 200) {
|
||||
response.resume();
|
||||
finish(false);
|
||||
return;
|
||||
}
|
||||
let body = "";
|
||||
response.setEncoding("utf8");
|
||||
response.on("data", (chunk) => {
|
||||
if (body.length <= 16_384) body += chunk;
|
||||
});
|
||||
response.on("end", () => {
|
||||
try {
|
||||
const payload = JSON.parse(body) as { ok?: unknown };
|
||||
finish(payload.ok === true);
|
||||
} catch {
|
||||
finish(false);
|
||||
}
|
||||
});
|
||||
response.on("aborted", () => finish(false));
|
||||
response.on("error", () => finish(false));
|
||||
response.on("close", () => {
|
||||
if (!response.complete) finish(false);
|
||||
});
|
||||
});
|
||||
request.setTimeout(timeoutMs, () => {
|
||||
request.destroy();
|
||||
finish(false);
|
||||
});
|
||||
request.on("error", () => finish(false));
|
||||
timer = setTimeout(() => {
|
||||
request.destroy();
|
||||
finish(false);
|
||||
}, timeoutMs);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,496 @@
|
||||
/**
|
||||
* Wire types shared by the relay host and the Codex app-server adapter.
|
||||
*
|
||||
* The relay intentionally treats `payload` as JSON. Keeping this boundary
|
||||
* unopinionated lets the bridge continue working when app-server adds a new
|
||||
* notification or request before this extension is updated.
|
||||
*/
|
||||
|
||||
export type JsonPrimitive = string | number | boolean | null;
|
||||
export type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue };
|
||||
export type JsonObject = { [key: string]: JsonValue };
|
||||
export type JsonRpcId = string | number;
|
||||
|
||||
/** Preserve the JSON-RPC id type when using it as a map key. */
|
||||
export function jsonRpcIdKey(id: JsonRpcId): string {
|
||||
return `${typeof id}:${String(id)}`;
|
||||
}
|
||||
|
||||
export function isJsonRpcId(value: unknown): value is JsonRpcId {
|
||||
return typeof value === "string" || typeof value === "number";
|
||||
}
|
||||
|
||||
export type ApprovalDecisionKind = "allow" | "deny" | "cancel";
|
||||
|
||||
const LEGACY_APPROVAL_METHODS = new Set(["applyPatchApproval", "execCommandApproval"]);
|
||||
const V2_APPROVAL_METHODS = new Set([
|
||||
"item/commandExecution/requestApproval",
|
||||
"item/fileChange/requestApproval",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Classify both current and legacy app-server approval decisions without
|
||||
* rewriting the wire value. Unknown tagged objects intentionally return
|
||||
* `undefined` so callers can fail closed instead of accidentally approving a
|
||||
* newly introduced response shape.
|
||||
*/
|
||||
export function approvalDecisionKind(value: unknown): ApprovalDecisionKind | undefined {
|
||||
if (typeof value === "string") {
|
||||
if (new Set([
|
||||
"allow",
|
||||
"accept",
|
||||
"acceptForSession",
|
||||
"approved",
|
||||
"approved_for_session",
|
||||
"approved_mcp_policy_amendment",
|
||||
]).has(value)) return "allow";
|
||||
if (new Set(["deny", "decline", "denied", "timed_out"]).has(value)) return "deny";
|
||||
if (new Set(["cancel", "abort"]).has(value)) return "cancel";
|
||||
return undefined;
|
||||
}
|
||||
if (!isRecord(value)) return undefined;
|
||||
const keys = Object.keys(value);
|
||||
if (keys.length !== 1) return undefined;
|
||||
const key = keys[0];
|
||||
const nested = value[key];
|
||||
if (isExecpolicyAmendmentTag(key, nested) || isNetworkPolicyAmendmentTag(key, nested)) return "allow";
|
||||
if (key === "denied" && isRecord(nested) && typeof nested.rejection === "string") return "deny";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify a decision against the response schema for one app-server method.
|
||||
* The generic classifier above is intentionally useful for relay envelopes;
|
||||
* this method-aware variant prevents a v2 tagged object from being sent to a
|
||||
* legacy callback (or vice versa), while retaining compatibility aliases that
|
||||
* the relay may use in its outer `decision` field.
|
||||
*/
|
||||
export function approvalDecisionKindForMethod(
|
||||
value: unknown,
|
||||
method?: string,
|
||||
): ApprovalDecisionKind | undefined {
|
||||
const generic = approvalDecisionKind(value);
|
||||
if (!generic || !method) return generic;
|
||||
|
||||
if (LEGACY_APPROVAL_METHODS.has(method)) {
|
||||
if (typeof value === "string") {
|
||||
return new Set([
|
||||
"approved",
|
||||
"approved_for_session",
|
||||
"approved_mcp_policy_amendment",
|
||||
"timed_out",
|
||||
"abort",
|
||||
]).has(value) ? generic : undefined;
|
||||
}
|
||||
if (!isRecord(value)) return undefined;
|
||||
const key = Object.keys(value)[0];
|
||||
return key === "approved_execpolicy_amendment"
|
||||
|| key === "network_policy_amendment"
|
||||
|| key === "denied" ? generic : undefined;
|
||||
}
|
||||
|
||||
if (V2_APPROVAL_METHODS.has(method)) {
|
||||
if (typeof value === "string") {
|
||||
return new Set(["accept", "acceptForSession", "decline", "cancel"]).has(value)
|
||||
? generic
|
||||
: undefined;
|
||||
}
|
||||
if (!isRecord(value)) return undefined;
|
||||
const key = Object.keys(value)[0];
|
||||
if (method === "item/fileChange/requestApproval") return undefined;
|
||||
return key === "acceptWithExecpolicyAmendment" || key === "applyNetworkPolicyAmendment"
|
||||
? generic
|
||||
: undefined;
|
||||
}
|
||||
|
||||
if (method === "mcpServer/elicitation/request") {
|
||||
return typeof value === "string" && new Set(["accept", "decline", "cancel"]).has(value)
|
||||
? generic
|
||||
: undefined;
|
||||
}
|
||||
|
||||
return generic;
|
||||
}
|
||||
|
||||
function isExecpolicyAmendmentTag(key: string, nested: unknown): boolean {
|
||||
if (!isRecord(nested)) return false;
|
||||
if (key === "acceptWithExecpolicyAmendment") {
|
||||
return isStringArray(nested.execpolicy_amendment);
|
||||
}
|
||||
if (key === "approved_execpolicy_amendment") {
|
||||
return isStringArray(nested.proposed_execpolicy_amendment);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function isNetworkPolicyAmendmentTag(key: string, nested: unknown): boolean {
|
||||
if (!isRecord(nested)) return false;
|
||||
if (key === "applyNetworkPolicyAmendment") {
|
||||
return isNetworkPolicyAmendment(nested.network_policy_amendment);
|
||||
}
|
||||
if (key === "network_policy_amendment") {
|
||||
return isNetworkPolicyAmendment(nested.network_policy_amendment);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function isStringArray(value: unknown): value is string[] {
|
||||
return Array.isArray(value) && value.every((item) => typeof item === "string");
|
||||
}
|
||||
|
||||
function isNetworkPolicyAmendment(value: unknown): boolean {
|
||||
return isRecord(value)
|
||||
&& typeof value.host === "string"
|
||||
&& (value.action === "allow" || value.action === "deny");
|
||||
}
|
||||
|
||||
/** Whether a response explicitly carries a decision/action field. */
|
||||
export function hasApprovalDecisionField(value: unknown): value is Record<string, unknown> {
|
||||
return isRecord(value) && (Object.prototype.hasOwnProperty.call(value, "decision")
|
||||
|| Object.prototype.hasOwnProperty.call(value, "action"));
|
||||
}
|
||||
|
||||
export interface Disposable {
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
export interface JsonRpcRequest {
|
||||
id: JsonRpcId;
|
||||
method: string;
|
||||
params?: JsonValue;
|
||||
}
|
||||
|
||||
export interface JsonRpcNotification {
|
||||
method: string;
|
||||
params?: JsonValue;
|
||||
}
|
||||
|
||||
export interface JsonRpcResponse {
|
||||
id: JsonRpcId;
|
||||
result?: JsonValue;
|
||||
error?: {
|
||||
code: number;
|
||||
message: string;
|
||||
data?: JsonValue;
|
||||
};
|
||||
}
|
||||
|
||||
export type JsonRpcMessage = JsonRpcRequest | JsonRpcNotification | JsonRpcResponse;
|
||||
|
||||
export type RelayRole = "owner" | "operator" | "approver" | "viewer" | string;
|
||||
|
||||
export interface RelayActor {
|
||||
id?: string;
|
||||
role?: RelayRole;
|
||||
}
|
||||
|
||||
/** A versioned relay event frame. `seq` is normally assigned by the relay. */
|
||||
export interface RelayEventFrame {
|
||||
v: 1;
|
||||
kind: "event";
|
||||
type: string;
|
||||
id: string;
|
||||
sessionId: string;
|
||||
seq?: number;
|
||||
ts: string;
|
||||
actor?: RelayActor;
|
||||
payload: JsonObject;
|
||||
/** Optional typed execution projection attached by a VS Code host. */
|
||||
status?: AgentStatusSnapshot;
|
||||
}
|
||||
|
||||
export interface RelayCommandFrame {
|
||||
v?: 1;
|
||||
kind?: "command";
|
||||
type: string;
|
||||
/** Compact relay compatibility form: `{ type: "command", method, params }`. */
|
||||
method?: string;
|
||||
params?: JsonObject;
|
||||
commandId?: string;
|
||||
id?: string;
|
||||
sessionId?: string;
|
||||
actor?: RelayActor;
|
||||
payload?: JsonObject;
|
||||
/** Some clients put the command body under `command`. */
|
||||
command?: {
|
||||
type?: string;
|
||||
commandId?: string;
|
||||
payload?: JsonObject;
|
||||
[key: string]: JsonValue | undefined;
|
||||
};
|
||||
}
|
||||
|
||||
export interface RelayHelloFrame {
|
||||
v: 1;
|
||||
kind: "hello";
|
||||
clientType: "host" | "web" | string;
|
||||
protocol?: number;
|
||||
accessToken?: string;
|
||||
token?: string;
|
||||
lastSeq?: number;
|
||||
sessionId?: string;
|
||||
payload?: JsonObject;
|
||||
}
|
||||
|
||||
export interface RelayAckFrame {
|
||||
v: 1;
|
||||
kind: "ack";
|
||||
sessionId: string;
|
||||
seq: number;
|
||||
}
|
||||
|
||||
export interface RelayErrorFrame {
|
||||
v: 1;
|
||||
kind: "error";
|
||||
code: string;
|
||||
message: string;
|
||||
retryable?: boolean;
|
||||
commandId?: string;
|
||||
}
|
||||
|
||||
export type RelayFrame =
|
||||
| RelayEventFrame
|
||||
| RelayCommandFrame
|
||||
| RelayHelloFrame
|
||||
| RelayAckFrame
|
||||
| RelayErrorFrame
|
||||
| (JsonObject & { kind?: string; v?: number });
|
||||
|
||||
/**
|
||||
* Live execution information projected from the official Codex conversation
|
||||
* state. The private IPC protocol can add new turn statuses/flags, so the
|
||||
* string fields intentionally remain open-ended for forward compatibility.
|
||||
*/
|
||||
export interface AgentStatusSnapshot {
|
||||
/** Coarse UI activity, for example `thinking`, `editing`, or `running`. */
|
||||
activity: string;
|
||||
/** Raw/normalized turn status (`inProgress`, `completed`, ...). */
|
||||
turnStatus: string;
|
||||
/** Runtime flags such as `waitingOnApproval` or `waitingOnUserInput`. */
|
||||
activeFlags: string[];
|
||||
startedAtMs?: number | null;
|
||||
durationMs?: number | null;
|
||||
/**
|
||||
* Time spent doing work in the official UI. This deliberately differs
|
||||
* from `durationMs`: Codex starts the worked-for clock at the first work
|
||||
* item and stops it when the final assistant response starts.
|
||||
*/
|
||||
workedDurationMs?: number | null;
|
||||
/** Elapsed wall-clock time for an active turn. */
|
||||
elapsedMs?: number | null;
|
||||
firstTurnWorkItemStartedAtMs?: number | null;
|
||||
finalAssistantStartedAtMs?: number | null;
|
||||
error?: JsonValue;
|
||||
}
|
||||
|
||||
/** Official background-agent lifecycle values emitted by Codex v2 items. */
|
||||
export type CollabAgentStatus =
|
||||
| "pendingInit"
|
||||
| "running"
|
||||
| "interrupted"
|
||||
| "completed"
|
||||
| "errored"
|
||||
| "shutdown"
|
||||
| "notFound"
|
||||
| string;
|
||||
|
||||
export type CollabAgentTool =
|
||||
| "spawnAgent"
|
||||
| "sendInput"
|
||||
| "resumeAgent"
|
||||
| "wait"
|
||||
| "closeAgent"
|
||||
| string;
|
||||
|
||||
export type CollabAgentToolCallStatus = "inProgress" | "completed" | "failed" | string;
|
||||
export type SubAgentActivityKind = "started" | "interacted" | "interrupted" | "completed" | string;
|
||||
|
||||
/** Last known state for one receiver in a collabAgentToolCall item. */
|
||||
export interface CollabAgentStateSnapshot {
|
||||
status: CollabAgentStatus;
|
||||
message?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Browser-safe projection of a background Codex subagent. The official
|
||||
* webview currently uses the four coarse statuses below; the string union is
|
||||
* deliberately open so a newer app-server status does not break the relay.
|
||||
*/
|
||||
export interface SubagentSnapshot {
|
||||
threadId: string;
|
||||
displayName: string | null;
|
||||
prompt: string | null;
|
||||
/** Alias used by the subagent side panel for the same prompt text. */
|
||||
objective?: string | null;
|
||||
status: "waiting" | "working" | "done" | "failed" | string;
|
||||
statusMessage: string | null;
|
||||
startedAtMs?: number | null;
|
||||
completedAtMs?: number | null;
|
||||
canInteract?: boolean;
|
||||
model?: string | null;
|
||||
agentPath?: string | null;
|
||||
parentThreadId?: string | null;
|
||||
}
|
||||
|
||||
export interface AgentEvent {
|
||||
/** Normalized relay event name, for example `output.chunk`. */
|
||||
type: string;
|
||||
threadId?: string;
|
||||
turnId?: string;
|
||||
requestId?: JsonRpcId;
|
||||
payload: JsonObject;
|
||||
/** Original app-server notification/request, when available. */
|
||||
raw?: JsonValue;
|
||||
/** Optional typed projection of live Codex turn/runtime status. */
|
||||
status?: AgentStatusSnapshot;
|
||||
}
|
||||
|
||||
export interface PendingApproval {
|
||||
requestId: JsonRpcId;
|
||||
method: string;
|
||||
threadId?: string;
|
||||
turnId?: string;
|
||||
itemId?: string;
|
||||
action: string;
|
||||
risk: "low" | "medium" | "high" | "unknown";
|
||||
summary: string;
|
||||
/** SHA-256 of canonicalized, unredacted app-server request params. */
|
||||
commandHash?: string;
|
||||
createdAt: number;
|
||||
expiresAt?: number;
|
||||
payload: JsonObject;
|
||||
}
|
||||
|
||||
export interface SessionSnapshot {
|
||||
threadId: string | null;
|
||||
turnId: string | null;
|
||||
state: string;
|
||||
pendingApprovals: PendingApproval[];
|
||||
pendingRequests?: Array<{
|
||||
requestId: JsonRpcId;
|
||||
method: string;
|
||||
params?: JsonValue;
|
||||
commandHash?: string;
|
||||
risk?: string;
|
||||
summary?: string;
|
||||
createdAt?: number;
|
||||
expiresAt?: number;
|
||||
}>;
|
||||
outputTail: string;
|
||||
/** Optional role-aware projection used by the browser renderer. */
|
||||
messages?: JsonValue[];
|
||||
/** Background/inline subagents reconstructed from official collab items. */
|
||||
subagents?: SubagentSnapshot[];
|
||||
/** Live execution projection; retained alongside the legacy `state` field. */
|
||||
status?: AgentStatusSnapshot;
|
||||
/** Convenience aliases for clients that do not consume `status` yet. */
|
||||
activity?: string;
|
||||
turnStatus?: string;
|
||||
activeFlags?: string[];
|
||||
startedAtMs?: number | null;
|
||||
durationMs?: number | null;
|
||||
workedDurationMs?: number | null;
|
||||
elapsedMs?: number | null;
|
||||
metadata?: JsonObject;
|
||||
}
|
||||
|
||||
/** A live VS Code Codex conversation that the attach bridge has verified. */
|
||||
export interface SessionListEntry {
|
||||
threadId: string;
|
||||
title: string;
|
||||
updatedAtMs: number | null;
|
||||
cwd?: string | null;
|
||||
active: boolean;
|
||||
/** True for attach-mode results; retained for wire compatibility. */
|
||||
available: boolean;
|
||||
}
|
||||
|
||||
export interface SessionListResult {
|
||||
sessions: SessionListEntry[];
|
||||
activeThreadId: string | null;
|
||||
}
|
||||
|
||||
/** Which owner controls conversation navigation for the remote surface. */
|
||||
export type ControlMode = "sync" | "async";
|
||||
|
||||
export interface AgentAdapter {
|
||||
start(): Promise<void>;
|
||||
/** Switch between following VS Code and independently owned conversations. */
|
||||
setControlMode?(params: JsonObject): Promise<JsonValue>;
|
||||
/** Return the currently committed control mode without taking a snapshot. */
|
||||
getControlMode?(): ControlMode;
|
||||
/** Start a new app-server thread. */
|
||||
startThread?(params?: JsonObject): Promise<JsonValue>;
|
||||
/** Ask the official VS Code Codex extension to open a fresh conversation. */
|
||||
newSession?(params?: JsonObject): Promise<JsonValue>;
|
||||
/** Start a turn; `threadId` may be supplied in params or use the active thread. */
|
||||
startTurn?(params: JsonObject): Promise<JsonValue>;
|
||||
/** Steer the active turn. */
|
||||
steerTurn?(params: JsonObject): Promise<JsonValue>;
|
||||
/** Persist model/effort and other owner-managed settings on the thread. */
|
||||
updateThreadSettings?(params: JsonObject): Promise<JsonValue>;
|
||||
/** List verified, attachable local conversations without starting another Codex process. */
|
||||
listSessions?(params?: JsonObject): Promise<JsonValue>;
|
||||
/** Attach the follower to another already-open conversation. */
|
||||
selectSession?(params: JsonObject): Promise<JsonValue>;
|
||||
/** Interrupt a turn. */
|
||||
interruptTurn?(params: JsonObject): Promise<JsonValue>;
|
||||
/** Convenience MVP aliases. */
|
||||
sendInput(text: string, params?: JsonObject): Promise<JsonValue>;
|
||||
cancel(taskId?: string, params?: JsonObject): Promise<JsonValue>;
|
||||
respondApproval(
|
||||
requestId: JsonRpcId,
|
||||
decision: "allow" | "deny" | "cancel",
|
||||
reason?: string,
|
||||
response?: JsonValue,
|
||||
): Promise<JsonValue>;
|
||||
/** Resolve all pending approvals/inputs with a deny response. */
|
||||
denyPending?(reason?: string): Promise<void>;
|
||||
snapshot(): Promise<SessionSnapshot>;
|
||||
onEvent(listener: (event: AgentEvent) => void): Disposable;
|
||||
dispose(): Promise<void>;
|
||||
}
|
||||
|
||||
export interface RelayTransport {
|
||||
connect(): Promise<void>;
|
||||
send(frame: RelayFrame): void;
|
||||
onMessage(listener: (frame: RelayFrame) => void): Disposable;
|
||||
onOpen?(listener: () => void): Disposable;
|
||||
onClose?(listener: (error?: Error) => void): Disposable;
|
||||
close(): void;
|
||||
}
|
||||
|
||||
export interface Logger {
|
||||
debug?(message: string, ...args: unknown[]): void;
|
||||
info?(message: string, ...args: unknown[]): void;
|
||||
warn?(message: string, ...args: unknown[]): void;
|
||||
error?(message: string, ...args: unknown[]): void;
|
||||
}
|
||||
|
||||
export const noopDisposable = (): Disposable => ({ dispose: () => undefined });
|
||||
|
||||
export function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
export function asJsonObject(value: unknown): JsonObject {
|
||||
return isRecord(value) ? (value as JsonObject) : {};
|
||||
}
|
||||
|
||||
export function asJsonValue(value: unknown): JsonValue {
|
||||
if (value === undefined) return null;
|
||||
if (value === null || typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
|
||||
return value;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(asJsonValue);
|
||||
}
|
||||
if (isRecord(value)) {
|
||||
const output: JsonObject = {};
|
||||
for (const [key, item] of Object.entries(value)) {
|
||||
if (item !== undefined) output[key] = asJsonValue(item);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
@@ -0,0 +1,413 @@
|
||||
import { createInterface, Interface as ReadLineInterface } from "node:readline";
|
||||
import WebSocket from "ws";
|
||||
|
||||
import {
|
||||
Disposable,
|
||||
isRecord,
|
||||
JsonObject,
|
||||
Logger,
|
||||
RelayFrame,
|
||||
RelayHelloFrame,
|
||||
RelayTransport,
|
||||
} from "./protocol";
|
||||
|
||||
export interface RelayClientOptions {
|
||||
url: string;
|
||||
accessToken?: string;
|
||||
sessionId?: string;
|
||||
lastSeq?: number;
|
||||
reconnect?: boolean;
|
||||
reconnectInitialMs?: number;
|
||||
reconnectMaxMs?: number;
|
||||
maxFrameBytes?: number;
|
||||
maxQueuedBytes?: number;
|
||||
logger?: Logger;
|
||||
/** Injectable constructor for tests or a browser-compatible WebSocket. */
|
||||
webSocket?: new (url: string) => unknown;
|
||||
}
|
||||
|
||||
type SocketLike = {
|
||||
readyState?: number;
|
||||
send(data: string): void;
|
||||
close(): void;
|
||||
on?(event: string, listener: (...args: any[]) => void): void;
|
||||
addEventListener?(event: string, listener: (...args: any[]) => void): void;
|
||||
};
|
||||
|
||||
const OPEN = 1;
|
||||
// A structured Codex history snapshot is routinely larger than 256 KiB even
|
||||
// though its plain-text tail is capped. Keep a bounded limit, but leave enough
|
||||
// room for the message/tool projection of a long attached conversation.
|
||||
export const DEFAULT_MAX_RELAY_FRAME_BYTES = 16 * 1024 * 1024;
|
||||
export const DEFAULT_MAX_RELAY_QUEUE_BYTES = DEFAULT_MAX_RELAY_FRAME_BYTES + 2 * 1024 * 1024;
|
||||
|
||||
interface QueuedFrame {
|
||||
serialized: string;
|
||||
bytes: number;
|
||||
projectionKey?: string;
|
||||
}
|
||||
|
||||
const QUEUED_PROJECTION_TYPES = new Set(["session.snapshot", "output.snapshot", "output.chunk"]);
|
||||
|
||||
function queuedProjectionKey(frame: RelayFrame): string | undefined {
|
||||
if (!isRecord(frame) || frame.kind !== "event" || typeof frame.type !== "string"
|
||||
|| !QUEUED_PROJECTION_TYPES.has(frame.type)) return undefined;
|
||||
const sessionId = typeof frame.sessionId === "string" ? frame.sessionId : "default";
|
||||
return `${sessionId}:transcript`;
|
||||
}
|
||||
|
||||
/** WebSocket relay transport with bounded reconnect and frame validation. */
|
||||
export class RelayClient implements RelayTransport {
|
||||
readonly handlesHandshake = true;
|
||||
private readonly options: Required<
|
||||
Pick<RelayClientOptions, "reconnect" | "reconnectInitialMs" | "reconnectMaxMs" | "maxFrameBytes" | "maxQueuedBytes">
|
||||
> &
|
||||
Omit<RelayClientOptions, "reconnect" | "reconnectInitialMs" | "reconnectMaxMs" | "maxFrameBytes" | "maxQueuedBytes">;
|
||||
private socket?: SocketLike;
|
||||
// A WebSocket can report OPEN while its relay authentication handshake is
|
||||
// still in flight. Keep this separate from `socket` so events emitted by
|
||||
// the adapter during reconnect are queued until the relay sends auth.ok.
|
||||
private authenticatedSocket?: SocketLike;
|
||||
private connecting?: Promise<void>;
|
||||
// Incremented whenever a connection attempt is replaced or explicitly
|
||||
// closed. Late events from an older WebSocket must not mutate newer state.
|
||||
private connectionGeneration = 0;
|
||||
private reconnectTimer?: NodeJS.Timeout;
|
||||
private stopped = false;
|
||||
private retryMs: number;
|
||||
private readonly queue: QueuedFrame[] = [];
|
||||
private queueBytes = 0;
|
||||
private readonly listeners = new Set<(frame: RelayFrame) => void>();
|
||||
private readonly openListeners = new Set<() => void>();
|
||||
private readonly closeListeners = new Set<(error?: Error) => void>();
|
||||
|
||||
constructor(options: RelayClientOptions) {
|
||||
const maxFrameBytes = options.maxFrameBytes ?? DEFAULT_MAX_RELAY_FRAME_BYTES;
|
||||
const defaultMaxQueuedBytes = Math.max(
|
||||
maxFrameBytes,
|
||||
Math.min(DEFAULT_MAX_RELAY_QUEUE_BYTES, maxFrameBytes * 2),
|
||||
);
|
||||
this.options = {
|
||||
...options,
|
||||
reconnect: options.reconnect ?? true,
|
||||
reconnectInitialMs: options.reconnectInitialMs ?? 500,
|
||||
reconnectMaxMs: options.reconnectMaxMs ?? 10_000,
|
||||
maxFrameBytes,
|
||||
maxQueuedBytes: Math.max(1, Math.floor(options.maxQueuedBytes ?? defaultMaxQueuedBytes)),
|
||||
};
|
||||
this.retryMs = this.options.reconnectInitialMs;
|
||||
}
|
||||
|
||||
/** Let RelayHost assign its stable session id before the first hello. */
|
||||
setSessionId(sessionId: string): void {
|
||||
this.options.sessionId = sessionId;
|
||||
}
|
||||
|
||||
async connect(): Promise<void> {
|
||||
this.stopped = false;
|
||||
if (this.socket?.readyState === OPEN && this.authenticatedSocket === this.socket) return;
|
||||
if (this.connecting) return this.connecting;
|
||||
|
||||
const generation = ++this.connectionGeneration;
|
||||
let connectionPromise: Promise<void>;
|
||||
connectionPromise = new Promise<void>((resolve, reject) => {
|
||||
let settled = false;
|
||||
let authenticated = false;
|
||||
const SocketCtor = this.options.webSocket ?? WebSocket;
|
||||
let socket: SocketLike;
|
||||
try {
|
||||
socket = new SocketCtor(this.options.url) as SocketLike;
|
||||
} catch (error) {
|
||||
reject(error instanceof Error ? error : new Error(String(error)));
|
||||
return;
|
||||
}
|
||||
this.socket = socket;
|
||||
this.authenticatedSocket = undefined;
|
||||
|
||||
const isCurrent = (): boolean => this.connectionGeneration === generation && this.socket === socket;
|
||||
|
||||
const onOpen = (): void => {
|
||||
if (!isCurrent() || settled || authenticated) return;
|
||||
try {
|
||||
// The TCP/WebSocket open event is only a transport milestone. Do
|
||||
// not release queued commands until the relay has authenticated us.
|
||||
this.sendHello(socket);
|
||||
} catch (error) {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
reject(error instanceof Error ? error : new Error(String(error)));
|
||||
}
|
||||
}
|
||||
};
|
||||
const onMessage = (raw: unknown): void => {
|
||||
if (!isCurrent()) return;
|
||||
const data = extractMessageData(raw);
|
||||
if (Buffer.byteLength(data, "utf8") > this.options.maxFrameBytes) {
|
||||
this.options.logger?.warn?.("Ignoring oversized relay frame");
|
||||
return;
|
||||
}
|
||||
let frame: unknown;
|
||||
try {
|
||||
frame = JSON.parse(data);
|
||||
} catch {
|
||||
this.options.logger?.warn?.("Ignoring malformed relay JSON");
|
||||
return;
|
||||
}
|
||||
if (!isRecord(frame)) return;
|
||||
if (frame.type === "auth.ok" && !authenticated && !settled) {
|
||||
authenticated = true;
|
||||
settled = true;
|
||||
this.authenticatedSocket = socket;
|
||||
this.retryMs = this.options.reconnectInitialMs;
|
||||
try {
|
||||
this.flush(socket);
|
||||
} catch (error) {
|
||||
this.options.logger?.warn?.("Unable to flush relay queue after authentication", error);
|
||||
}
|
||||
for (const listener of this.openListeners) listener();
|
||||
resolve();
|
||||
} else if (frame.type === "error" && !authenticated && !settled) {
|
||||
settled = true;
|
||||
reject(new Error(typeof frame.message === "string" ? frame.message : "relay authentication failed"));
|
||||
}
|
||||
if (frame.kind === "event" && typeof frame.seq === "number") {
|
||||
this.options.lastSeq = Math.max(this.options.lastSeq ?? 0, frame.seq);
|
||||
}
|
||||
for (const listener of this.listeners) listener(frame as RelayFrame);
|
||||
};
|
||||
const onError = (raw: unknown): void => {
|
||||
if (!isCurrent()) return;
|
||||
const error = raw instanceof Error ? raw : new Error("relay websocket error");
|
||||
this.options.logger?.warn?.(error.message);
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
reject(error);
|
||||
}
|
||||
};
|
||||
const onClose = (): void => {
|
||||
const current = isCurrent();
|
||||
if (current) {
|
||||
this.socket = undefined;
|
||||
if (this.authenticatedSocket === socket) this.authenticatedSocket = undefined;
|
||||
}
|
||||
const error = new Error("relay websocket closed");
|
||||
// A stale socket may still need to settle the promise returned to its
|
||||
// caller, but it must never notify the active host or schedule a
|
||||
// second reconnect loop.
|
||||
if (!current) {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
reject(error);
|
||||
}
|
||||
return;
|
||||
}
|
||||
for (const listener of this.closeListeners) listener(error);
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
reject(error);
|
||||
}
|
||||
if (!this.stopped && this.options.reconnect) this.scheduleReconnect();
|
||||
};
|
||||
|
||||
bindSocket(socket, onOpen, onMessage, onError, onClose);
|
||||
// A small number of test/browser WebSocket implementations can already
|
||||
// be OPEN by the time listeners are attached.
|
||||
if (socket.readyState === OPEN) queueMicrotask(onOpen);
|
||||
}).finally(() => {
|
||||
if (this.connectionGeneration === generation && this.connecting === connectionPromise) {
|
||||
this.connecting = undefined;
|
||||
}
|
||||
});
|
||||
this.connecting = connectionPromise;
|
||||
return connectionPromise;
|
||||
}
|
||||
|
||||
send(frame: RelayFrame): void {
|
||||
const serialized = JSON.stringify(frame);
|
||||
const bytes = Buffer.byteLength(serialized, "utf8");
|
||||
if (bytes > this.options.maxFrameBytes) {
|
||||
throw new Error(`relay frame exceeds ${this.options.maxFrameBytes} bytes`);
|
||||
}
|
||||
if (this.socket?.readyState === OPEN && this.authenticatedSocket === this.socket) {
|
||||
this.socket.send(serialized);
|
||||
return;
|
||||
}
|
||||
this.enqueue({ serialized, bytes, projectionKey: queuedProjectionKey(frame) });
|
||||
}
|
||||
|
||||
onMessage(listener: (frame: RelayFrame) => void): Disposable {
|
||||
this.listeners.add(listener);
|
||||
return { dispose: () => this.listeners.delete(listener) };
|
||||
}
|
||||
|
||||
onOpen(listener: () => void): Disposable {
|
||||
this.openListeners.add(listener);
|
||||
return { dispose: () => this.openListeners.delete(listener) };
|
||||
}
|
||||
|
||||
onClose(listener: (error?: Error) => void): Disposable {
|
||||
this.closeListeners.add(listener);
|
||||
return { dispose: () => this.closeListeners.delete(listener) };
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.stopped = true;
|
||||
this.connectionGeneration += 1;
|
||||
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
|
||||
this.reconnectTimer = undefined;
|
||||
this.connecting = undefined;
|
||||
const socket = this.socket;
|
||||
this.socket = undefined;
|
||||
this.authenticatedSocket = undefined;
|
||||
if (socket && socket.readyState !== 3) socket.close();
|
||||
this.queue.length = 0;
|
||||
this.queueBytes = 0;
|
||||
}
|
||||
|
||||
private sendHello(socket: SocketLike): void {
|
||||
const hello: RelayHelloFrame = {
|
||||
v: 1,
|
||||
kind: "hello",
|
||||
clientType: "host",
|
||||
protocol: 1,
|
||||
...(this.options.sessionId ? { sessionId: this.options.sessionId } : {}),
|
||||
...(this.options.lastSeq !== undefined ? { lastSeq: this.options.lastSeq } : {}),
|
||||
};
|
||||
socket.send(JSON.stringify(hello));
|
||||
if (this.options.accessToken) {
|
||||
// Keep authentication separate from hello so a relay can challenge the
|
||||
// host before accepting a bearer token (and so hello remains cacheable).
|
||||
socket.send(JSON.stringify({ v: 1, kind: "auth", accessToken: this.options.accessToken }));
|
||||
}
|
||||
}
|
||||
|
||||
private flush(socket: SocketLike): void {
|
||||
if (socket.readyState !== OPEN || this.authenticatedSocket !== socket || this.socket !== socket) return;
|
||||
while (this.queue.length > 0) {
|
||||
const entry = this.queue.shift() as QueuedFrame;
|
||||
this.queueBytes = Math.max(0, this.queueBytes - entry.bytes);
|
||||
socket.send(entry.serialized);
|
||||
}
|
||||
}
|
||||
|
||||
private enqueue(entry: QueuedFrame): void {
|
||||
// Transcript events are reconstructible: RelayHost publishes a fresh full
|
||||
// session snapshot after every authenticated reconnect. Keep only the
|
||||
// newest projection per session while preserving approval/command events.
|
||||
if (entry.projectionKey) {
|
||||
for (let index = this.queue.length - 1; index >= 0; index -= 1) {
|
||||
if (this.queue[index].projectionKey === entry.projectionKey) this.removeQueuedFrame(index);
|
||||
}
|
||||
}
|
||||
if (entry.bytes > this.options.maxQueuedBytes) {
|
||||
this.options.logger?.warn?.("Dropping relay frame that exceeds the reconnect queue byte limit");
|
||||
return;
|
||||
}
|
||||
while (this.queue.length >= 100 || this.queueBytes + entry.bytes > this.options.maxQueuedBytes) {
|
||||
const projectionIndex = this.queue.findIndex((queued) => Boolean(queued.projectionKey));
|
||||
if (projectionIndex >= 0) {
|
||||
this.removeQueuedFrame(projectionIndex);
|
||||
continue;
|
||||
}
|
||||
// Never evict an approval/command solely to retain a transcript delta;
|
||||
// the authoritative snapshot emitted after auth restores that state.
|
||||
if (entry.projectionKey) {
|
||||
this.options.logger?.debug?.("Dropping supersedable relay projection while reconnect queue is full");
|
||||
return;
|
||||
}
|
||||
this.removeQueuedFrame(0);
|
||||
}
|
||||
this.queue.push(entry);
|
||||
this.queueBytes += entry.bytes;
|
||||
}
|
||||
|
||||
private removeQueuedFrame(index: number): void {
|
||||
const [removed] = this.queue.splice(index, 1);
|
||||
if (removed) this.queueBytes = Math.max(0, this.queueBytes - removed.bytes);
|
||||
}
|
||||
|
||||
private scheduleReconnect(): void {
|
||||
if (this.reconnectTimer || this.stopped) return;
|
||||
const delay = this.retryMs;
|
||||
this.retryMs = Math.min(this.options.reconnectMaxMs, Math.max(this.retryMs * 2, this.options.reconnectInitialMs));
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
this.reconnectTimer = undefined;
|
||||
void this.connect().catch((error) => this.options.logger?.debug?.("relay reconnect failed", error));
|
||||
}, delay);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Line-oriented transport for local development and CI. Pipe it to a relay
|
||||
* process with `node dist/cli.js`; each line is one JSON relay frame.
|
||||
*/
|
||||
export class StdioRelayTransport implements RelayTransport {
|
||||
private readonly listeners = new Set<(frame: RelayFrame) => void>();
|
||||
private readonly lineReader: ReadLineInterface;
|
||||
private closed = false;
|
||||
|
||||
constructor(
|
||||
private readonly input: NodeJS.ReadableStream = process.stdin,
|
||||
private readonly output: NodeJS.WritableStream = process.stdout,
|
||||
private readonly logger?: Logger,
|
||||
) {
|
||||
this.lineReader = createInterface({ input, crlfDelay: Infinity });
|
||||
this.lineReader.on("line", (line) => {
|
||||
if (!line.trim()) return;
|
||||
try {
|
||||
const frame = JSON.parse(line);
|
||||
if (isRecord(frame)) for (const listener of this.listeners) listener(frame as RelayFrame);
|
||||
} catch (error) {
|
||||
this.logger?.warn?.("Ignoring malformed relay stdin frame", error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async connect(): Promise<void> {
|
||||
this.closed = false;
|
||||
}
|
||||
|
||||
send(frame: RelayFrame): void {
|
||||
if (this.closed) throw new Error("stdio relay transport is closed");
|
||||
this.output.write(`${JSON.stringify(frame)}\n`);
|
||||
}
|
||||
|
||||
onMessage(listener: (frame: RelayFrame) => void): Disposable {
|
||||
this.listeners.add(listener);
|
||||
return { dispose: () => this.listeners.delete(listener) };
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.closed = true;
|
||||
this.lineReader.close();
|
||||
}
|
||||
}
|
||||
|
||||
function bindSocket(
|
||||
socket: SocketLike,
|
||||
onOpen: () => void,
|
||||
onMessage: (data: unknown) => void,
|
||||
onError: (error: unknown) => void,
|
||||
onClose: () => void,
|
||||
): void {
|
||||
if (typeof socket.on === "function") {
|
||||
socket.on("open", onOpen);
|
||||
socket.on("message", onMessage);
|
||||
socket.on("error", onError);
|
||||
socket.on("close", onClose);
|
||||
} else if (typeof socket.addEventListener === "function") {
|
||||
socket.addEventListener("open", onOpen);
|
||||
socket.addEventListener("message", onMessage);
|
||||
socket.addEventListener("error", onError);
|
||||
socket.addEventListener("close", onClose);
|
||||
} else {
|
||||
onError(new Error("WebSocket implementation has no event API"));
|
||||
}
|
||||
}
|
||||
|
||||
function extractMessageData(raw: unknown): string {
|
||||
if (typeof raw === "string") return raw;
|
||||
if (Buffer.isBuffer(raw)) return raw.toString("utf8");
|
||||
if (isRecord(raw) && "data" in raw) return extractMessageData(raw.data);
|
||||
return String(raw ?? "");
|
||||
}
|
||||
@@ -0,0 +1,650 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
import {
|
||||
AgentAdapter,
|
||||
AgentEvent,
|
||||
approvalDecisionKind,
|
||||
approvalDecisionKindForMethod,
|
||||
asJsonObject,
|
||||
asJsonValue,
|
||||
Disposable,
|
||||
hasApprovalDecisionField,
|
||||
isRecord,
|
||||
JsonObject,
|
||||
JsonRpcId,
|
||||
Logger,
|
||||
JsonValue,
|
||||
RelayActor,
|
||||
RelayCommandFrame,
|
||||
RelayEventFrame,
|
||||
RelayFrame,
|
||||
RelayTransport,
|
||||
} from "./protocol";
|
||||
|
||||
export interface RelayHostOptions {
|
||||
adapter: AgentAdapter;
|
||||
relay: RelayTransport;
|
||||
sessionId?: string;
|
||||
actor?: RelayActor;
|
||||
/** Capabilities enforced locally even when relay authorization is bypassed. */
|
||||
capabilities?: Iterable<string>;
|
||||
logger?: Logger;
|
||||
/** Emit a handshake on transports that do not implement one themselves. */
|
||||
sendHandshake?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps relay commands to the app-server AgentAdapter and publishes normalized
|
||||
* adapter events. This is the policy boundary for the VS Code host.
|
||||
*/
|
||||
export class RelayHost {
|
||||
private readonly adapter: AgentAdapter;
|
||||
private readonly relay: RelayTransport;
|
||||
private readonly options: RelayHostOptions;
|
||||
private readonly subscriptions: Disposable[] = [];
|
||||
private readonly commandResults = new Map<string, RelayEventFrame>();
|
||||
private readonly inFlightCommands = new Set<string>();
|
||||
private readonly capabilities: Set<string>;
|
||||
private eventSeq = 0;
|
||||
private sessionId: string;
|
||||
private started = false;
|
||||
private adapterReady = false;
|
||||
|
||||
constructor(options: RelayHostOptions);
|
||||
constructor(adapter: AgentAdapter, relay: RelayTransport, options?: Omit<RelayHostOptions, "adapter" | "relay">);
|
||||
constructor(
|
||||
optionsOrAdapter: RelayHostOptions | AgentAdapter,
|
||||
relayArg?: RelayTransport,
|
||||
legacyOptions: Omit<RelayHostOptions, "adapter" | "relay"> = {},
|
||||
) {
|
||||
if (isAgentAdapter(optionsOrAdapter)) {
|
||||
this.adapter = optionsOrAdapter;
|
||||
if (!relayArg) throw new Error("RelayHost requires a relay transport");
|
||||
this.relay = relayArg;
|
||||
this.options = { ...legacyOptions, adapter: this.adapter, relay: this.relay };
|
||||
} else {
|
||||
this.options = optionsOrAdapter;
|
||||
this.adapter = optionsOrAdapter.adapter;
|
||||
this.relay = optionsOrAdapter.relay;
|
||||
}
|
||||
this.capabilities = new Set(this.options.capabilities ?? [
|
||||
"read_output",
|
||||
"send_task_input",
|
||||
"cancel_task",
|
||||
"approve_low_risk",
|
||||
]);
|
||||
this.sessionId = this.options.sessionId ?? `sess_${randomUUID()}`;
|
||||
}
|
||||
|
||||
get id(): string {
|
||||
return this.sessionId;
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
if (this.started) return;
|
||||
this.started = true;
|
||||
this.subscriptions.push(this.adapter.onEvent((event) => {
|
||||
if (event.type === "connection.opened") this.adapterReady = true;
|
||||
if (event.type === "connection.closed") this.adapterReady = false;
|
||||
this.publishAgentEvent(event);
|
||||
}));
|
||||
this.subscriptions.push(this.relay.onMessage((frame) => {
|
||||
void this.handleFrame(frame).catch((error) => {
|
||||
this.options.logger?.warn?.("Invalid relay frame", error);
|
||||
if (isRecord(frame) && typeof frame.commandId === "string") {
|
||||
this.sendCommandResult(frame.commandId, false, undefined, error instanceof Error ? error.message : String(error), typeof frame.method === "string" ? frame.method : typeof frame.type === "string" ? frame.type : undefined);
|
||||
}
|
||||
});
|
||||
}));
|
||||
if (this.relay.onClose) this.subscriptions.push(this.relay.onClose((error) => {
|
||||
// A relay disconnect must not leave an app-server request waiting for a
|
||||
// browser that can no longer answer. The adapter's local deny path is
|
||||
// deliberately fail-closed. Do not publish `connection.closed` here:
|
||||
// that event describes the app-server process, while this callback only
|
||||
// describes the outbound transport and is followed by connection.opened
|
||||
// on a successful reconnect.
|
||||
void this.adapter.denyPending?.("relay disconnected");
|
||||
this.options.logger?.debug?.("Relay transport closed", error?.message ?? "");
|
||||
}));
|
||||
if (this.relay.onOpen) this.subscriptions.push(this.relay.onOpen(() => {
|
||||
// RelayClient fires onOpen only after auth.ok. On reconnect the adapter
|
||||
// is already initialized, so the synthetic event restores relay state;
|
||||
// during initial startup the adapter event below is authoritative.
|
||||
if (this.adapterReady) {
|
||||
this.publishConnectionEvent("connection.opened");
|
||||
void this.publishSnapshot();
|
||||
}
|
||||
}));
|
||||
|
||||
const configurableRelay = this.relay as RelayTransport & { setSessionId?: (sessionId: string) => void };
|
||||
configurableRelay.setSessionId?.(this.sessionId);
|
||||
try {
|
||||
await this.relay.connect();
|
||||
if (this.options.sendHandshake !== false && !transportHandlesHandshake(this.relay)) {
|
||||
this.safeSend({ v: 1, kind: "hello", clientType: "host", protocol: 1, sessionId: this.sessionId });
|
||||
}
|
||||
// Start app-server only after the relay handshake is queued/sent. This
|
||||
// keeps standalone stdout frames protocol-ordered and prevents an early
|
||||
// notification from racing the host hello.
|
||||
await this.adapter.start();
|
||||
if (!this.adapterReady) {
|
||||
this.adapterReady = true;
|
||||
this.publishConnectionEvent("connection.opened");
|
||||
}
|
||||
await this.publishSnapshot();
|
||||
} catch (error) {
|
||||
this.started = false;
|
||||
this.adapterReady = false;
|
||||
for (const subscription of this.subscriptions.splice(0)) subscription.dispose();
|
||||
this.relay.close();
|
||||
await this.adapter.dispose().catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
if (!this.started) return;
|
||||
this.started = false;
|
||||
this.adapterReady = false;
|
||||
this.inFlightCommands.clear();
|
||||
for (const subscription of this.subscriptions.splice(0)) subscription.dispose();
|
||||
this.relay.close();
|
||||
await this.adapter.dispose();
|
||||
}
|
||||
|
||||
/** Public for unit tests and local stdin bridges. */
|
||||
async handleFrame(frame: RelayFrame): Promise<void> {
|
||||
if (!isRecord(frame)) return;
|
||||
if (frame.kind === "command" || isCommandLike(frame)) {
|
||||
await this.handleCommand(frame as unknown as RelayCommandFrame);
|
||||
return;
|
||||
}
|
||||
if (frame.kind === "event" && typeof frame.seq === "number") {
|
||||
this.safeSend({ v: 1, kind: "ack", sessionId: frame.sessionId, seq: frame.seq });
|
||||
}
|
||||
}
|
||||
|
||||
private async handleCommand(frame: RelayCommandFrame): Promise<void> {
|
||||
const command = normalizeCommand(frame);
|
||||
const commandId = command.commandId;
|
||||
if (commandId) {
|
||||
const previous = this.commandResults.get(commandId);
|
||||
if (previous) {
|
||||
this.safeSend(previous);
|
||||
return;
|
||||
}
|
||||
if (this.inFlightCommands.has(commandId)) {
|
||||
this.safeSend({
|
||||
v: 1,
|
||||
kind: "event",
|
||||
// Do not call this `command.accepted`: the relay treats that event
|
||||
// as the terminal result for its pending command. A retry while the
|
||||
// original operation is running is only an informational event.
|
||||
type: "command.pending",
|
||||
id: `evt_${randomUUID()}`,
|
||||
sessionId: this.sessionId,
|
||||
seq: ++this.eventSeq,
|
||||
ts: new Date().toISOString(),
|
||||
actor: this.options.actor ?? { id: "host", role: "host" },
|
||||
payload: { commandId, duplicate: true, pending: true },
|
||||
});
|
||||
return;
|
||||
}
|
||||
this.inFlightCommands.add(commandId);
|
||||
}
|
||||
|
||||
const role = frame.actor?.role ?? "operator";
|
||||
const denied = authorize(command.type, role, this.capabilities);
|
||||
if (denied) {
|
||||
// A viewer may not force a pending approval to deny (that would turn a
|
||||
// read-only role into a denial-of-service primitive). Authorized roles
|
||||
// can still be rejected by local capability/policy checks, in which
|
||||
// case denying the app-server request is the safe terminal action.
|
||||
if (role === "owner" || role === "operator" || role === "approver" || role === "host") {
|
||||
await this.denyApprovalIfNeeded(command.type, command.payload, denied);
|
||||
}
|
||||
this.sendCommandResult(commandId, false, undefined, denied, command.type);
|
||||
if (commandId) this.inFlightCommands.delete(commandId);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await this.executeCommand(command.type, command.payload);
|
||||
this.sendCommandResult(commandId, true, result, undefined, command.type);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
this.options.logger?.warn?.(`Relay command ${command.type} failed`, error);
|
||||
await this.denyApprovalIfNeeded(command.type, command.payload, message);
|
||||
this.sendCommandResult(commandId, false, undefined, message, command.type);
|
||||
} finally {
|
||||
if (commandId) this.inFlightCommands.delete(commandId);
|
||||
}
|
||||
}
|
||||
|
||||
private async denyApprovalIfNeeded(type: string, payload: JsonObject, reason: string): Promise<void> {
|
||||
const command = canonicalCommandType(type);
|
||||
if (command !== "approval.respond" && command !== "input.respond" && command !== "server.request.respond") return;
|
||||
const requestId = payload.requestId;
|
||||
if (requestId === undefined || (typeof requestId !== "string" && typeof requestId !== "number")) return;
|
||||
try {
|
||||
await this.adapter.respondApproval(requestId, "deny", reason);
|
||||
} catch {
|
||||
// The request may already have expired or been resolved. Keep the
|
||||
// original command rejection as the observable result.
|
||||
}
|
||||
}
|
||||
|
||||
private async executeCommand(type: string, payload: JsonObject): Promise<unknown> {
|
||||
switch (canonicalCommandType(type)) {
|
||||
case "control.mode.get": {
|
||||
const mode = this.adapter.getControlMode?.();
|
||||
if (mode) return { mode };
|
||||
const snapshot = await this.adapter.snapshot();
|
||||
const controlMode = snapshot.metadata?.controlMode;
|
||||
if (controlMode !== "sync" && controlMode !== "async") {
|
||||
throw new Error("adapter does not expose a control mode");
|
||||
}
|
||||
return { mode: controlMode };
|
||||
}
|
||||
case "control.mode.set":
|
||||
if (!this.adapter.setControlMode) throw new Error("adapter does not support control mode switching");
|
||||
return this.adapter.setControlMode(payload);
|
||||
case "thread.start":
|
||||
if (!this.adapter.startThread) throw new Error("adapter does not support thread/start");
|
||||
return this.adapter.startThread(payload);
|
||||
case "session.new":
|
||||
if (!this.adapter.newSession) throw new Error("adapter does not support session/new");
|
||||
return this.adapter.newSession(payload);
|
||||
case "thread.settings.update":
|
||||
if (!this.adapter.updateThreadSettings) throw new Error("adapter does not support thread/settings/update");
|
||||
return this.adapter.updateThreadSettings(payload);
|
||||
case "session.list":
|
||||
if (!this.adapter.listSessions) throw new Error("adapter does not support session/list");
|
||||
return this.adapter.listSessions(payload);
|
||||
case "session.select":
|
||||
if (!this.adapter.selectSession) throw new Error("adapter does not support session/select");
|
||||
return this.adapter.selectSession(payload);
|
||||
case "turn.start":
|
||||
if (this.adapter.startTurn) return this.adapter.startTurn(payload);
|
||||
return this.adapter.sendInput(extractCommandText(payload), payload);
|
||||
case "turn.steer":
|
||||
if (this.adapter.steerTurn) return this.adapter.steerTurn(payload);
|
||||
return this.adapter.sendInput(extractCommandText(payload), payload);
|
||||
case "turn.interrupt":
|
||||
if (this.adapter.interruptTurn) return this.adapter.interruptTurn(payload);
|
||||
return this.adapter.cancel(typeof payload.turnId === "string" ? payload.turnId : undefined, payload);
|
||||
case "task.input": {
|
||||
const text = typeof payload.text === "string" ? payload.text : typeof payload.message === "string" ? payload.message : undefined;
|
||||
if (!text) throw new Error("task.input requires payload.text");
|
||||
return this.adapter.sendInput(text, payload);
|
||||
}
|
||||
case "task.cancel": {
|
||||
const taskId = typeof payload.taskId === "string" ? payload.taskId : typeof payload.turnId === "string" ? payload.turnId : undefined;
|
||||
return this.adapter.cancel(taskId, payload);
|
||||
}
|
||||
case "approval.respond": {
|
||||
const requestId = payload.requestId;
|
||||
if (typeof requestId !== "string" && typeof requestId !== "number") throw new Error("approval.respond requires requestId");
|
||||
const requestedValue = payload.decision;
|
||||
const decision = approvalDecisionKind(requestedValue);
|
||||
if (!decision) throw new Error("decision must be a recognized allow, deny, or cancel value");
|
||||
const snapshot = await this.adapter.snapshot();
|
||||
// JSON-RPC distinguishes numeric and string ids. Keep the lookup
|
||||
// type-safe so id `1` cannot accidentally authorize response `"1"`.
|
||||
const approval = snapshot.pendingApprovals.find((item) => item.requestId === requestId);
|
||||
const method = approval?.method ?? (typeof payload.method === "string" ? payload.method : undefined);
|
||||
const response = payload.response ?? implicitApprovalResponse(requestedValue, decision, method, payload.scope);
|
||||
if (decision === "allow") {
|
||||
if (approval && typeof payload.commandHash === "string" && payload.commandHash !== approval.commandHash) {
|
||||
throw new Error("approval commandHash does not match the pending request");
|
||||
}
|
||||
if (approval?.risk === "high" && !this.capabilities.has("approve_high_risk") && !this.capabilities.has("*")) {
|
||||
throw new Error("host policy requires approve_high_risk for this approval");
|
||||
}
|
||||
}
|
||||
validateApprovalResponse(decision, response, method);
|
||||
return this.adapter.respondApproval(
|
||||
requestId,
|
||||
decision,
|
||||
typeof payload.reason === "string" ? payload.reason : undefined,
|
||||
response,
|
||||
);
|
||||
}
|
||||
case "input.respond":
|
||||
case "server.request.respond": {
|
||||
const requestId = payload.requestId;
|
||||
if (typeof requestId !== "string" && typeof requestId !== "number") throw new Error(`${type} requires requestId`);
|
||||
const response = payload.response ?? (payload.answers !== undefined ? payload.answers : undefined);
|
||||
// Tool-input requests do not carry an allow/deny field in their wire
|
||||
// response, while MCP elicitation uses `action`. Prefer an explicit
|
||||
// response action when present; otherwise honor the relay decision and
|
||||
// fail closed when a denial has no custom response.
|
||||
if (response !== undefined && !isRecord(response)) {
|
||||
throw new Error("input response must be a JSON object");
|
||||
}
|
||||
const responseDecision = isRecord(response)
|
||||
? explicitResponseDecision(response)
|
||||
: undefined;
|
||||
const requestedDecision = payload.decision === undefined
|
||||
? undefined
|
||||
: approvalDecisionKind(payload.decision);
|
||||
if (payload.decision !== undefined && !requestedDecision) {
|
||||
throw new Error("decision must be allow, deny, or cancel");
|
||||
}
|
||||
if (responseDecision && requestedDecision
|
||||
&& responseDecision !== requestedDecision
|
||||
// RelayHost uses `decision: "allow"` as a generic envelope for
|
||||
// MCP/input responses; the nested action remains authoritative in
|
||||
// that one compatibility case.
|
||||
&& requestedDecision !== "allow") {
|
||||
throw new Error(`input response implies ${responseDecision}, but decision is ${requestedDecision}`);
|
||||
}
|
||||
// For MCP, `response.action` is the actual app-server decision and is
|
||||
// authoritative even if a relay uses `decision: "allow"` as a generic
|
||||
// input-response envelope. With no custom response, an explicit relay
|
||||
// decision (or the fail-closed deny default) controls the result.
|
||||
const decision = responseDecision ?? requestedDecision ?? (response === undefined ? "deny" : "allow");
|
||||
const responseForAdapter = requestedDecision && requestedDecision !== "allow" && !responseDecision
|
||||
? undefined
|
||||
: response;
|
||||
return this.adapter.respondApproval(
|
||||
requestId,
|
||||
decision,
|
||||
typeof payload.reason === "string" ? payload.reason : undefined,
|
||||
responseForAdapter,
|
||||
);
|
||||
}
|
||||
case "session.snapshot":
|
||||
case "snapshot":
|
||||
return this.adapter.snapshot();
|
||||
case "ping":
|
||||
return { pong: true, ts: new Date().toISOString() };
|
||||
default:
|
||||
throw new Error(`unsupported relay command: ${type}`);
|
||||
}
|
||||
}
|
||||
|
||||
private sendCommandResult(commandId: string | undefined, ok: boolean, result?: unknown, error?: string, method?: string): void {
|
||||
const frame: RelayEventFrame = {
|
||||
v: 1,
|
||||
kind: "event",
|
||||
type: ok ? "command.accepted" : "command.rejected",
|
||||
id: `evt_${randomUUID()}`,
|
||||
sessionId: this.sessionId,
|
||||
seq: ++this.eventSeq,
|
||||
ts: new Date().toISOString(),
|
||||
actor: this.options.actor ?? { id: "host", role: "host" },
|
||||
payload: {
|
||||
...(commandId ? { commandId } : {}),
|
||||
...(method ? { method } : {}),
|
||||
ok,
|
||||
...(ok ? { result: asJsonValue(result) } : { error: error ?? "command rejected" }),
|
||||
},
|
||||
};
|
||||
if (commandId) {
|
||||
this.commandResults.set(commandId, frame);
|
||||
if (this.commandResults.size > 1000) this.commandResults.delete(this.commandResults.keys().next().value as string);
|
||||
}
|
||||
this.safeSend(frame);
|
||||
}
|
||||
|
||||
private publishAgentEvent(event: AgentEvent): void {
|
||||
if (event.threadId && this.sessionId.startsWith("sess_")) {
|
||||
// Keep a stable relay session id while exposing the app-server thread id
|
||||
// in the payload; a relay session may contain more than one thread.
|
||||
}
|
||||
const payload: JsonObject = {
|
||||
...event.payload,
|
||||
...(event.status ? { executionStatus: asJsonValue(event.status) } : {}),
|
||||
...(event.threadId ? { threadId: event.threadId } : {}),
|
||||
...(event.turnId ? { turnId: event.turnId } : {}),
|
||||
...(event.requestId !== undefined ? { requestId: asJsonValue(event.requestId) } : {}),
|
||||
...(event.raw !== undefined ? { raw: event.raw } : {}),
|
||||
};
|
||||
const frame: RelayEventFrame = {
|
||||
v: 1,
|
||||
kind: "event",
|
||||
type: event.type,
|
||||
id: `evt_${randomUUID()}`,
|
||||
sessionId: this.sessionId,
|
||||
seq: ++this.eventSeq,
|
||||
ts: new Date().toISOString(),
|
||||
actor: this.options.actor ?? { id: "host", role: "host" },
|
||||
payload,
|
||||
...(event.status ? { status: { ...event.status, activeFlags: [...event.status.activeFlags] } } : {}),
|
||||
};
|
||||
try {
|
||||
this.safeSend(frame);
|
||||
} catch (error) {
|
||||
this.options.logger?.warn?.("Unable to publish relay event", error);
|
||||
}
|
||||
}
|
||||
|
||||
private safeSend(frame: RelayFrame): void {
|
||||
try {
|
||||
this.relay.send(frame);
|
||||
} catch (error) {
|
||||
this.options.logger?.warn?.("Unable to send relay frame", error);
|
||||
}
|
||||
}
|
||||
|
||||
private publishConnectionEvent(type: string, error?: Error): void {
|
||||
if (!this.started && type === "connection.closed") return;
|
||||
this.publishAgentEvent({ type, payload: error ? { message: error.message } : {} });
|
||||
}
|
||||
|
||||
private async publishSnapshot(): Promise<void> {
|
||||
try {
|
||||
const snapshot = await this.adapter.snapshot();
|
||||
this.publishAgentEvent({
|
||||
type: "session.snapshot",
|
||||
threadId: snapshot.threadId ?? undefined,
|
||||
turnId: snapshot.turnId ?? undefined,
|
||||
payload: asJsonObject(snapshot),
|
||||
status: snapshot.status,
|
||||
});
|
||||
} catch (error) {
|
||||
this.options.logger?.warn?.("Unable to publish adapter snapshot", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface NormalizedCommand {
|
||||
type: string;
|
||||
commandId?: string;
|
||||
payload: JsonObject;
|
||||
}
|
||||
|
||||
function normalizeCommand(frame: RelayCommandFrame): NormalizedCommand {
|
||||
const nested = isRecord(frame.command) ? frame.command : undefined;
|
||||
const type = typeof nested?.type === "string"
|
||||
? nested.type
|
||||
: typeof frame.method === "string"
|
||||
? frame.method
|
||||
: frame.type === "command"
|
||||
? ""
|
||||
: frame.type;
|
||||
const commandId = typeof frame.commandId === "string"
|
||||
? frame.commandId
|
||||
: typeof nested?.commandId === "string"
|
||||
? nested.commandId
|
||||
: typeof frame.id === "string"
|
||||
? frame.id
|
||||
: undefined;
|
||||
if (!type) throw new Error("relay command has no type");
|
||||
|
||||
if (isRecord(nested?.payload)) return { type, commandId, payload: asJsonObject(nested.payload) };
|
||||
if (isRecord(frame.payload)) return { type, commandId, payload: asJsonObject(frame.payload) };
|
||||
if (isRecord(frame.params)) return { type, commandId, payload: asJsonObject(frame.params) };
|
||||
|
||||
const payload: JsonObject = {};
|
||||
for (const [key, value] of Object.entries(frame)) {
|
||||
if (["v", "kind", "type", "method", "params", "commandId", "id", "sessionId", "actor", "command"].includes(key)) continue;
|
||||
if (value !== undefined) payload[key] = asJsonValue(value);
|
||||
}
|
||||
return { type, commandId, payload };
|
||||
}
|
||||
|
||||
function canonicalCommandType(type: string): string {
|
||||
const normalized = type.trim().replace(/\//g, ".").replace(/\s+/g, ".").toLowerCase();
|
||||
if (normalized === "control.mode.get" || normalized === "controlmode.get" || normalized === "controlmodeget" || normalized === "mode.get" || normalized === "modeget") return "control.mode.get";
|
||||
if (normalized === "control.mode.set" || normalized === "controlmode.set" || normalized === "controlmodeset" || normalized === "mode.set" || normalized === "modeset") return "control.mode.set";
|
||||
if (normalized === "thread.start" || normalized === "threadstart") return "thread.start";
|
||||
if (normalized === "session.new" || normalized === "sessionnew" || normalized === "thread.new" || normalized === "threadnew") return "session.new";
|
||||
if (normalized === "thread.settings.update" || normalized === "threadsettings.update" || normalized === "threadsettingsupdate") return "thread.settings.update";
|
||||
if (normalized === "session.list" || normalized === "thread.list" || normalized === "sessionlist" || normalized === "threadlist") return "session.list";
|
||||
if (normalized === "session.select" || normalized === "session.switch" || normalized === "thread.select" || normalized === "thread.attach" || normalized === "sessionswitch" || normalized === "threadselect") return "session.select";
|
||||
if (normalized === "turn.start" || normalized === "turnstart") return "turn.start";
|
||||
if (normalized === "turn.steer" || normalized === "turnsteer") return "turn.steer";
|
||||
if (normalized === "turn.interrupt" || normalized === "turninterrupt") return "turn.interrupt";
|
||||
if (normalized === "approval.respond" || normalized === "approvalrespond") return "approval.respond";
|
||||
if (normalized === "task.input" || normalized === "taskinput") return "task.input";
|
||||
if (normalized === "task.cancel" || normalized === "taskcancel") return "task.cancel";
|
||||
if (normalized === "input.respond" || normalized === "inputrespond") return "input.respond";
|
||||
if (normalized === "server.request.respond" || normalized === "serverrequest.respond") return "server.request.respond";
|
||||
if (normalized === "session.snapshot" || normalized === "snapshot") return "session.snapshot";
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function authorize(type: string, role: string, capabilities: Set<string>): string | undefined {
|
||||
const command = canonicalCommandType(type);
|
||||
const readOnly = command === "session.snapshot" || command === "snapshot" || command === "session.list" || command === "control.mode.get" || command === "ping";
|
||||
if (readOnly) return undefined;
|
||||
if (role === "viewer") return "viewer role cannot issue control commands";
|
||||
if (role !== "owner" && role !== "operator" && role !== "approver" && role !== "host") return `role ${role} is not authorized`;
|
||||
if ((command === "approval.respond" || command === "input.respond" || command === "server.request.respond") && role !== "owner" && role !== "operator" && role !== "approver" && role !== "host") {
|
||||
return "role is not authorized to resolve approvals";
|
||||
}
|
||||
const required = command === "approval.respond" ? "approve_low_risk" : command === "task.cancel" || command === "turn.interrupt" ? "cancel_task" : command === "task.input" || command.startsWith("turn.") || command === "thread.start" || command === "thread.settings.update" || command === "session.select" || command === "session.new" || command === "control.mode.set" ? "send_task_input" : undefined;
|
||||
if (required && !capabilities.has(required) && !capabilities.has("*") && role !== "owner" && role !== "host") return `missing capability: ${required}`;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function isCommandLike(frame: Record<string, unknown>): boolean {
|
||||
if (frame.kind === "command") return true;
|
||||
if (frame.type === "command" && typeof frame.method === "string") return true;
|
||||
if (frame.kind !== undefined) return false;
|
||||
if (typeof frame.method === "string") return true;
|
||||
if (typeof frame.commandId !== "string") return false;
|
||||
return KNOWN_COMMAND_TYPES.has(String(frame.type).trim().replace(/\//g, ".").toLowerCase());
|
||||
}
|
||||
|
||||
const KNOWN_COMMAND_TYPES = new Set([
|
||||
"control.mode.get",
|
||||
"control.mode.set",
|
||||
"thread.start",
|
||||
"session.new",
|
||||
"thread.settings.update",
|
||||
"session.list",
|
||||
"session.select",
|
||||
"turn.start",
|
||||
"turn.steer",
|
||||
"turn.interrupt",
|
||||
"approval.respond",
|
||||
"task.input",
|
||||
"task.cancel",
|
||||
"input.respond",
|
||||
"server.request.respond",
|
||||
"session.snapshot",
|
||||
"snapshot",
|
||||
"ping",
|
||||
]);
|
||||
|
||||
function isAgentAdapter(value: unknown): value is AgentAdapter {
|
||||
return isRecord(value) && typeof value.start === "function" && typeof value.onEvent === "function" && typeof value.sendInput === "function" && typeof value.cancel === "function" && typeof value.respondApproval === "function" && typeof value.snapshot === "function";
|
||||
}
|
||||
|
||||
function extractCommandText(payload: JsonObject): string {
|
||||
if (typeof payload.text === "string") return payload.text;
|
||||
if (typeof payload.message === "string") return payload.message;
|
||||
if (typeof payload.prompt === "string") return payload.prompt;
|
||||
if (Array.isArray(payload.input)) {
|
||||
const first = payload.input[0];
|
||||
if (isRecord(first) && typeof first.text === "string") return first.text;
|
||||
}
|
||||
throw new Error("turn command requires text or input");
|
||||
}
|
||||
|
||||
function validateApprovalResponse(
|
||||
decision: "allow" | "deny" | "cancel",
|
||||
response: JsonValue | undefined,
|
||||
method?: string,
|
||||
): void {
|
||||
if (response === undefined) return;
|
||||
if (!isRecord(response)) throw new Error("approval response must be a JSON object");
|
||||
|
||||
const hasDecision = Object.prototype.hasOwnProperty.call(response, "decision");
|
||||
const hasAction = Object.prototype.hasOwnProperty.call(response, "action");
|
||||
if (hasDecision || hasAction) {
|
||||
const decisionKind = hasDecision ? approvalDecisionKindForMethod(response.decision, method) : undefined;
|
||||
const actionKind = hasAction ? approvalDecisionKindForMethod(response.action, method) : undefined;
|
||||
if (hasDecision && !decisionKind) throw new Error("unsupported approval response decision");
|
||||
if (hasAction && !actionKind) throw new Error("unsupported approval response action");
|
||||
if (decisionKind && actionKind && decisionKind !== actionKind) {
|
||||
throw new Error("approval response decision and action conflict");
|
||||
}
|
||||
const implied = decisionKind ?? actionKind;
|
||||
if (implied && implied !== decision) {
|
||||
throw new Error(`approval response implies ${implied}, but decision is ${decision}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Permissions approvals intentionally carry a profile rather than a
|
||||
// decision field. Keep the profile shape narrow; malformed/unknown objects
|
||||
// must not be interpreted as an approval.
|
||||
if (method === "item/permissions/requestApproval"
|
||||
&& isRecord(response.permissions)
|
||||
&& (response.scope === "turn" || response.scope === "session")
|
||||
&& (response.strictAutoReview === undefined || typeof response.strictAutoReview === "boolean")
|
||||
&& Object.keys(response).every((key) => key === "permissions" || key === "scope" || key === "strictAutoReview")) {
|
||||
return;
|
||||
}
|
||||
throw new Error("approval response has no recognized decision or permission profile");
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a relay's compact outer decision into a wire response only when it
|
||||
* carries a non-canonical app-server value. Canonical `allow`/`deny`/`cancel`
|
||||
* remain undefined so the adapter can choose the method-specific default.
|
||||
*/
|
||||
function implicitApprovalResponse(
|
||||
requestedValue: JsonValue,
|
||||
decision: "allow" | "deny" | "cancel",
|
||||
method?: string,
|
||||
scope?: JsonValue,
|
||||
): JsonValue | undefined {
|
||||
// Permission approvals have a profile response, not a decision wrapper.
|
||||
// Let the adapter construct the requested turn-scoped profile by default;
|
||||
// callers that need session scope must provide the full profile explicitly.
|
||||
if (method === "item/permissions/requestApproval") return undefined;
|
||||
if (requestedValue === "allow" || requestedValue === "deny" || requestedValue === "cancel") {
|
||||
if (requestedValue === "allow" && scope === "session") {
|
||||
if (method === "applyPatchApproval" || method === "execCommandApproval") return { decision: "approved_for_session" };
|
||||
if (method === "item/commandExecution/requestApproval" || method === "item/fileChange/requestApproval") return { decision: "acceptForSession" };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
// The generic classifier has already rejected unknown/conflicting values.
|
||||
// Preserve recognized legacy/v2 tags exactly under the app-server wrapper.
|
||||
if (decision === "allow" || decision === "deny" || decision === "cancel") {
|
||||
return { decision: requestedValue };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function explicitResponseDecision(response: Record<string, unknown>): "allow" | "deny" | "cancel" | undefined {
|
||||
if (!hasApprovalDecisionField(response)) return undefined;
|
||||
const hasDecision = Object.prototype.hasOwnProperty.call(response, "decision");
|
||||
const hasAction = Object.prototype.hasOwnProperty.call(response, "action");
|
||||
const decision = hasDecision ? approvalDecisionKind(response.decision) : undefined;
|
||||
const action = hasAction ? approvalDecisionKind(response.action) : undefined;
|
||||
if (hasDecision && !decision) throw new Error("unsupported input response decision");
|
||||
if (hasAction && !action) throw new Error("unsupported input response action");
|
||||
if (decision && action && decision !== action) throw new Error("input response decision and action conflict");
|
||||
return decision ?? action;
|
||||
}
|
||||
|
||||
function transportHandlesHandshake(transport: RelayTransport): boolean {
|
||||
return Boolean((transport as RelayTransport & { handlesHandshake?: boolean }).handlesHandshake);
|
||||
}
|
||||
@@ -0,0 +1,425 @@
|
||||
import {
|
||||
AgentAdapter,
|
||||
AgentEvent,
|
||||
asJsonObject,
|
||||
ControlMode,
|
||||
Disposable,
|
||||
JsonObject,
|
||||
JsonRpcId,
|
||||
JsonValue,
|
||||
Logger,
|
||||
SessionSnapshot,
|
||||
} from "./protocol";
|
||||
|
||||
export type AgentAdapterFactory = (mode: ControlMode) => AgentAdapter | Promise<AgentAdapter>;
|
||||
|
||||
export interface SwitchableAgentAdapterOptions {
|
||||
initialMode: ControlMode;
|
||||
createAdapter: AgentAdapterFactory;
|
||||
/** Persist the committed mode. Persistence errors do not roll back a live adapter. */
|
||||
onModeChanged?: (mode: ControlMode, previousMode: ControlMode) => void | Promise<void>;
|
||||
logger?: Logger;
|
||||
}
|
||||
|
||||
interface AdapterBinding {
|
||||
adapter: AgentAdapter;
|
||||
mode: ControlMode;
|
||||
generation: number;
|
||||
committed: boolean;
|
||||
bufferedEvents: AgentEvent[];
|
||||
subscription: Disposable;
|
||||
}
|
||||
|
||||
interface ModeCapabilities extends JsonObject {
|
||||
followsVscodeRoute: boolean;
|
||||
sessionList: boolean;
|
||||
sessionSelect: boolean;
|
||||
sessionCreate: boolean;
|
||||
threadSettings: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps RelayHost bound to one stable AgentAdapter while atomically replacing
|
||||
* the implementation behind it when the control owner changes.
|
||||
*/
|
||||
export class SwitchableAgentAdapter implements AgentAdapter {
|
||||
private readonly options: SwitchableAgentAdapterOptions;
|
||||
private readonly listeners = new Set<(event: AgentEvent) => void>();
|
||||
private binding: AdapterBinding | null = null;
|
||||
private controlMode: ControlMode;
|
||||
private modeEpoch = 0;
|
||||
private startPromise: Promise<void> | null = null;
|
||||
private switchPromise: Promise<JsonValue> | null = null;
|
||||
private started = false;
|
||||
private disposed = false;
|
||||
|
||||
constructor(options: SwitchableAgentAdapterOptions) {
|
||||
this.options = options;
|
||||
this.controlMode = validateControlMode(options.initialMode);
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
if (this.started) return;
|
||||
if (this.disposed) throw new Error("switchable adapter has been disposed");
|
||||
if (this.startPromise) return this.startPromise;
|
||||
|
||||
const operation = this.startInitialAdapter();
|
||||
this.startPromise = operation;
|
||||
try {
|
||||
await operation;
|
||||
} finally {
|
||||
if (this.startPromise === operation) this.startPromise = null;
|
||||
}
|
||||
}
|
||||
|
||||
getControlMode(): ControlMode {
|
||||
return this.controlMode;
|
||||
}
|
||||
|
||||
async setControlMode(params: JsonObject): Promise<JsonValue> {
|
||||
const nextMode = controlModeFromParams(params);
|
||||
this.ensureStarted();
|
||||
if (this.switchPromise) throw new Error("a control mode switch is already in progress");
|
||||
if (nextMode === this.controlMode) {
|
||||
return {
|
||||
changed: false,
|
||||
controlMode: this.controlMode,
|
||||
previousControlMode: this.controlMode,
|
||||
modeEpoch: this.modeEpoch,
|
||||
};
|
||||
}
|
||||
|
||||
const operation = this.performModeSwitch(nextMode);
|
||||
this.switchPromise = operation;
|
||||
try {
|
||||
return await operation;
|
||||
} finally {
|
||||
if (this.switchPromise === operation) this.switchPromise = null;
|
||||
}
|
||||
}
|
||||
|
||||
async startThread(params: JsonObject = {}): Promise<JsonValue> {
|
||||
this.assertIndependentNavigation("thread/start");
|
||||
const adapter = this.activeAdapterForMutation();
|
||||
if (!adapter.startThread) throw unsupported("thread/start", this.controlMode);
|
||||
return adapter.startThread(params);
|
||||
}
|
||||
|
||||
async newSession(params: JsonObject = {}): Promise<JsonValue> {
|
||||
this.assertIndependentNavigation("session/new");
|
||||
const adapter = this.activeAdapterForMutation();
|
||||
if (adapter.newSession) return adapter.newSession(params);
|
||||
if (adapter.startThread) return adapter.startThread(params);
|
||||
throw unsupported("session/new", this.controlMode);
|
||||
}
|
||||
|
||||
async startTurn(params: JsonObject): Promise<JsonValue> {
|
||||
const adapter = this.activeAdapterForMutation();
|
||||
if (!adapter.startTurn) throw unsupported("turn/start", this.controlMode);
|
||||
return adapter.startTurn(params);
|
||||
}
|
||||
|
||||
async steerTurn(params: JsonObject): Promise<JsonValue> {
|
||||
const adapter = this.activeAdapterForMutation();
|
||||
if (!adapter.steerTurn) throw unsupported("turn/steer", this.controlMode);
|
||||
return adapter.steerTurn(params);
|
||||
}
|
||||
|
||||
async updateThreadSettings(params: JsonObject): Promise<JsonValue> {
|
||||
const adapter = this.activeAdapterForMutation();
|
||||
if (!adapter.updateThreadSettings) throw unsupported("thread/settings/update", this.controlMode);
|
||||
return adapter.updateThreadSettings(params);
|
||||
}
|
||||
|
||||
async listSessions(params: JsonObject = {}): Promise<JsonValue> {
|
||||
this.assertIndependentNavigation("session/list");
|
||||
const adapter = this.activeAdapter();
|
||||
if (!adapter.listSessions) throw unsupported("session/list", this.controlMode);
|
||||
return adapter.listSessions(params);
|
||||
}
|
||||
|
||||
async selectSession(params: JsonObject): Promise<JsonValue> {
|
||||
this.assertIndependentNavigation("session/select");
|
||||
const adapter = this.activeAdapterForMutation();
|
||||
if (!adapter.selectSession) throw unsupported("session/select", this.controlMode);
|
||||
return adapter.selectSession(params);
|
||||
}
|
||||
|
||||
async interruptTurn(params: JsonObject): Promise<JsonValue> {
|
||||
const adapter = this.activeAdapter();
|
||||
if (!adapter.interruptTurn) throw unsupported("turn/interrupt", this.controlMode);
|
||||
return adapter.interruptTurn(params);
|
||||
}
|
||||
|
||||
async sendInput(text: string, params: JsonObject = {}): Promise<JsonValue> {
|
||||
return this.activeAdapterForMutation().sendInput(text, params);
|
||||
}
|
||||
|
||||
async cancel(taskId?: string, params: JsonObject = {}): Promise<JsonValue> {
|
||||
return this.activeAdapter().cancel(taskId, params);
|
||||
}
|
||||
|
||||
async respondApproval(
|
||||
requestId: JsonRpcId,
|
||||
decision: "allow" | "deny" | "cancel",
|
||||
reason?: string,
|
||||
response?: JsonValue,
|
||||
): Promise<JsonValue> {
|
||||
return this.activeAdapter().respondApproval(requestId, decision, reason, response);
|
||||
}
|
||||
|
||||
async denyPending(reason?: string): Promise<void> {
|
||||
await this.activeAdapter().denyPending?.(reason);
|
||||
}
|
||||
|
||||
async snapshot(): Promise<SessionSnapshot> {
|
||||
const binding = this.activeBinding();
|
||||
const snapshot = await binding.adapter.snapshot();
|
||||
return this.decorateSnapshot(snapshot, binding);
|
||||
}
|
||||
|
||||
onEvent(listener: (event: AgentEvent) => void): Disposable {
|
||||
this.listeners.add(listener);
|
||||
return { dispose: () => this.listeners.delete(listener) };
|
||||
}
|
||||
|
||||
async dispose(): Promise<void> {
|
||||
if (this.disposed) return;
|
||||
this.disposed = true;
|
||||
|
||||
const starting = this.startPromise;
|
||||
const switching = this.switchPromise;
|
||||
await starting?.catch(() => undefined);
|
||||
await switching?.catch(() => undefined);
|
||||
|
||||
const binding = this.binding;
|
||||
this.binding = null;
|
||||
this.started = false;
|
||||
if (!binding) return;
|
||||
binding.committed = false;
|
||||
binding.subscription.dispose();
|
||||
await binding.adapter.dispose();
|
||||
}
|
||||
|
||||
private async startInitialAdapter(): Promise<void> {
|
||||
const binding = await this.createBinding(this.controlMode, this.modeEpoch);
|
||||
try {
|
||||
await binding.adapter.start();
|
||||
if (this.disposed) throw new Error("switchable adapter was disposed while starting");
|
||||
binding.committed = true;
|
||||
this.binding = binding;
|
||||
this.started = true;
|
||||
this.flushBufferedEvents(binding);
|
||||
} catch (error) {
|
||||
await this.releaseBinding(binding);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async performModeSwitch(nextMode: ControlMode): Promise<JsonValue> {
|
||||
const previousBinding = this.activeBinding();
|
||||
const previousMode = this.controlMode;
|
||||
this.assertSnapshotIdle(await previousBinding.adapter.snapshot());
|
||||
|
||||
const nextEpoch = this.modeEpoch + 1;
|
||||
const candidate = await this.createBinding(nextMode, nextEpoch);
|
||||
if (candidate.adapter === previousBinding.adapter) {
|
||||
candidate.subscription.dispose();
|
||||
throw new Error("adapter factory must return a distinct adapter when switching control modes");
|
||||
}
|
||||
try {
|
||||
await candidate.adapter.start();
|
||||
if (this.disposed) throw new Error("switchable adapter was disposed while switching modes");
|
||||
|
||||
// VS Code can start a turn independently while the candidate boots.
|
||||
// Recheck immediately before the synchronous commit point.
|
||||
this.assertSnapshotIdle(await previousBinding.adapter.snapshot());
|
||||
const candidateSnapshot = await candidate.adapter.snapshot();
|
||||
// The candidate snapshot is an await point, so make the old adapter's
|
||||
// liveness check the final operation before committing synchronously.
|
||||
this.assertSnapshotIdle(await previousBinding.adapter.snapshot());
|
||||
|
||||
candidate.committed = true;
|
||||
this.binding = candidate;
|
||||
this.controlMode = nextMode;
|
||||
this.modeEpoch = nextEpoch;
|
||||
previousBinding.committed = false;
|
||||
|
||||
const result: JsonObject = {
|
||||
changed: true,
|
||||
controlMode: nextMode,
|
||||
previousControlMode: previousMode,
|
||||
modeEpoch: nextEpoch,
|
||||
};
|
||||
this.emit({ type: "control.mode.changed", payload: result });
|
||||
this.flushBufferedEvents(candidate);
|
||||
const snapshot = this.decorateSnapshot(candidateSnapshot, candidate);
|
||||
this.emit({
|
||||
type: "session.snapshot",
|
||||
threadId: snapshot.threadId ?? undefined,
|
||||
turnId: snapshot.turnId ?? undefined,
|
||||
payload: asJsonObject(snapshot),
|
||||
status: snapshot.status,
|
||||
});
|
||||
|
||||
previousBinding.subscription.dispose();
|
||||
await previousBinding.adapter.dispose().catch((error) => {
|
||||
this.options.logger?.warn?.("Unable to dispose the previous control mode adapter", error);
|
||||
});
|
||||
await Promise.resolve(this.options.onModeChanged?.(nextMode, previousMode)).catch((error) => {
|
||||
this.options.logger?.warn?.("Unable to persist the committed control mode", error);
|
||||
});
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (this.binding !== candidate) await this.releaseBinding(candidate);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async createBinding(mode: ControlMode, generation: number): Promise<AdapterBinding> {
|
||||
const adapter = await this.options.createAdapter(mode);
|
||||
if (!adapter) throw new Error(`adapter factory returned no adapter for ${mode} mode`);
|
||||
const binding: AdapterBinding = {
|
||||
adapter,
|
||||
mode,
|
||||
generation,
|
||||
committed: false,
|
||||
bufferedEvents: [],
|
||||
subscription: { dispose: () => undefined },
|
||||
};
|
||||
binding.subscription = adapter.onEvent((event) => this.receiveAdapterEvent(binding, event));
|
||||
return binding;
|
||||
}
|
||||
|
||||
private receiveAdapterEvent(binding: AdapterBinding, event: AgentEvent): void {
|
||||
if (!binding.committed) {
|
||||
binding.bufferedEvents.push(event);
|
||||
return;
|
||||
}
|
||||
if (this.binding !== binding || binding.generation !== this.modeEpoch) return;
|
||||
this.emit(this.decorateEvent(event, binding));
|
||||
}
|
||||
|
||||
private flushBufferedEvents(binding: AdapterBinding): void {
|
||||
const events = binding.bufferedEvents.splice(0);
|
||||
for (const event of events) {
|
||||
if (this.binding !== binding || binding.generation !== this.modeEpoch) return;
|
||||
this.emit(this.decorateEvent(event, binding));
|
||||
}
|
||||
}
|
||||
|
||||
private emit(event: AgentEvent): void {
|
||||
for (const listener of this.listeners) {
|
||||
try {
|
||||
listener(event);
|
||||
} catch (error) {
|
||||
this.options.logger?.warn?.("Switchable adapter event listener failed", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private activeBinding(): AdapterBinding {
|
||||
this.ensureStarted();
|
||||
if (!this.binding) throw new Error("switchable adapter has no active adapter");
|
||||
return this.binding;
|
||||
}
|
||||
|
||||
private activeAdapter(): AgentAdapter {
|
||||
return this.activeBinding().adapter;
|
||||
}
|
||||
|
||||
private activeAdapterForMutation(): AgentAdapter {
|
||||
if (this.switchPromise) throw new Error("control mode is switching; retry after it completes");
|
||||
return this.activeAdapter();
|
||||
}
|
||||
|
||||
private ensureStarted(): void {
|
||||
if (this.disposed) throw new Error("switchable adapter has been disposed");
|
||||
if (!this.started || !this.binding) throw new Error("switchable adapter is not started");
|
||||
}
|
||||
|
||||
private assertIndependentNavigation(operation: string): void {
|
||||
if (this.controlMode === "sync") {
|
||||
throw new Error(`${operation} is unavailable in sync mode; conversation navigation follows VS Code`);
|
||||
}
|
||||
}
|
||||
|
||||
private assertSnapshotIdle(snapshot: SessionSnapshot): void {
|
||||
const pendingApprovalCount = snapshot.pendingApprovals.length;
|
||||
const pendingRequestCount = snapshot.pendingRequests?.length ?? 0;
|
||||
const state = normalizeStatus(snapshot.state);
|
||||
const turnStatus = normalizeStatus(snapshot.status?.turnStatus ?? snapshot.turnStatus ?? "");
|
||||
const activeFlags = snapshot.status?.activeFlags ?? snapshot.activeFlags ?? [];
|
||||
const hasActiveState = ACTIVE_STATUSES.has(state) || ACTIVE_STATUSES.has(turnStatus) || activeFlags.length > 0;
|
||||
if (snapshot.turnId || hasActiveState || pendingApprovalCount > 0 || pendingRequestCount > 0) {
|
||||
throw new Error("cannot switch control mode while a turn or request is active");
|
||||
}
|
||||
}
|
||||
|
||||
private decorateSnapshot(snapshot: SessionSnapshot, binding: AdapterBinding): SessionSnapshot {
|
||||
return {
|
||||
...snapshot,
|
||||
metadata: {
|
||||
...(snapshot.metadata ?? {}),
|
||||
mode: binding.mode,
|
||||
controlMode: binding.mode,
|
||||
modeEpoch: binding.generation,
|
||||
capabilities: this.capabilities(binding),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private decorateEvent(event: AgentEvent, binding: AdapterBinding): AgentEvent {
|
||||
if (event.type !== "session.snapshot") return event;
|
||||
return {
|
||||
...event,
|
||||
payload: {
|
||||
...event.payload,
|
||||
metadata: {
|
||||
...asJsonObject(event.payload.metadata),
|
||||
mode: binding.mode,
|
||||
controlMode: binding.mode,
|
||||
modeEpoch: binding.generation,
|
||||
capabilities: this.capabilities(binding),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private capabilities(binding: AdapterBinding): ModeCapabilities {
|
||||
const independent = binding.mode === "async";
|
||||
return {
|
||||
followsVscodeRoute: !independent,
|
||||
sessionList: independent && typeof binding.adapter.listSessions === "function",
|
||||
sessionSelect: independent && typeof binding.adapter.selectSession === "function",
|
||||
sessionCreate: independent && (typeof binding.adapter.newSession === "function"
|
||||
|| typeof binding.adapter.startThread === "function"),
|
||||
threadSettings: typeof binding.adapter.updateThreadSettings === "function",
|
||||
};
|
||||
}
|
||||
|
||||
private async releaseBinding(binding: AdapterBinding): Promise<void> {
|
||||
binding.committed = false;
|
||||
binding.subscription.dispose();
|
||||
await binding.adapter.dispose().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
function controlModeFromParams(params: JsonObject): ControlMode {
|
||||
return validateControlMode(params.mode ?? params.controlMode);
|
||||
}
|
||||
|
||||
function validateControlMode(value: unknown): ControlMode {
|
||||
if (value === "sync" || value === "async") return value;
|
||||
throw new Error("control mode must be sync or async");
|
||||
}
|
||||
|
||||
function unsupported(operation: string, mode: ControlMode): Error {
|
||||
return new Error(`${operation} is not supported by the ${mode} adapter`);
|
||||
}
|
||||
|
||||
const ACTIVE_STATUSES = new Set(["active", "inprogress", "running", "starting", "thinking", "editing", "working"]);
|
||||
|
||||
function normalizeStatus(value: string): string {
|
||||
return value.trim().replace(/[\s_-]+/g, "").toLowerCase();
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "commonjs",
|
||||
"lib": [
|
||||
"ES2022"
|
||||
],
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"moduleResolution": "node",
|
||||
"sourceMap": true,
|
||||
"skipLibCheck": true,
|
||||
"types": [
|
||||
"node",
|
||||
"vscode"
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"dist"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
dist/
|
||||
*.tsbuildinfo
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="color-scheme" content="dark light" />
|
||||
<title>Codex</title>
|
||||
</head>
|
||||
<body class="codex-app local-no-auth">
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+2680
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "@aether/vscodex-web",
|
||||
"version": "0.4.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc -b && vite build",
|
||||
"typecheck": "vue-tsc -b --pretty false",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@vitejs/plugin-vue": "^6.0.1",
|
||||
"vite": "^7.1.3",
|
||||
"vue": "^3.5.20"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.3.0",
|
||||
"@vue/test-utils": "^2.4.6",
|
||||
"jsdom": "^26.1.0",
|
||||
"typescript": "^5.9.2",
|
||||
"vitest": "^3.2.4",
|
||||
"vue-tsc": "^3.0.6"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import CodexSurface from "./components/CodexSurface.vue";
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CodexSurface />
|
||||
</template>
|
||||
@@ -0,0 +1,269 @@
|
||||
<template>
|
||||
<div class="codex-panel">
|
||||
<div class="connection" aria-live="polite" hidden>
|
||||
<span id="connectionDot" class="dot offline"></span>
|
||||
<span id="connectionText">正在连接</span>
|
||||
<span id="roleBadge" class="badge">未认证</span>
|
||||
</div>
|
||||
|
||||
<main class="chat-shell">
|
||||
<section class="chat-header" aria-label="当前会话">
|
||||
<div class="thread-heading">
|
||||
<button id="backButton" class="icon-button header-back-button" type="button" data-panel-action="back" title="返回会话列表" aria-label="返回会话列表" hidden>
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M9.75 3.5 5.25 8l4.5 4.5M5.5 8h6.25" /></svg>
|
||||
</button>
|
||||
<button id="sessionPickerButton" class="thread-picker-button" type="button" aria-haspopup="dialog" aria-expanded="false" title="打开会话历史" aria-label="打开会话历史" disabled>
|
||||
<h2 id="threadTitle">Codex</h2>
|
||||
</button>
|
||||
<span id="appState" class="status-text" aria-live="polite">等待 VS Code 主机</span>
|
||||
</div>
|
||||
<div class="thread-actions">
|
||||
<button class="icon-button" type="button" data-panel-action="menu" title="更多操作" aria-label="更多操作">
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true"><circle cx="3" cy="8" r="1" /><circle cx="8" cy="8" r="1" /><circle cx="13" cy="8" r="1" /></svg>
|
||||
</button>
|
||||
<button id="historyButton" class="icon-button header-history-button" type="button" data-panel-action="history" title="会话历史" aria-label="会话历史" hidden>
|
||||
<svg viewBox="0 0 20 20" aria-hidden="true"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" /><path d="M3 3v5h5" /><path d="M12 7v5l4 2" /></svg>
|
||||
</button>
|
||||
<button class="icon-button" type="button" data-panel-action="settings" title="设置" aria-label="设置">
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M6.7 2h2.6l.4 1.6c.4.2.8.4 1.2.7l1.6-.6 1.3 2.2-1.2 1.1a5 5 0 0 1 0 1.4l1.2 1.1-1.3 2.2-1.6-.6c-.4.3-.8.5-1.2.7L9.3 14H6.7l-.4-1.6a5 5 0 0 1-1.2-.7l-1.6.6-1.3-2.2 1.2-1.1a5 5 0 0 1 0-1.4L2.2 6l1.3-2.2 1.6.6c.4-.3.8-.5 1.2-.7L6.7 2Z" /><circle cx="8" cy="8" r="1.7" /></svg>
|
||||
</button>
|
||||
<button id="newSessionButton" class="icon-button new-session-button" type="button" data-panel-action="new-session" title="创建新会话" aria-label="创建新会话" hidden>
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M3.25 3.25h5.5a1.5 1.5 0 0 1 1.5 1.5v2.5" /><path d="M3.25 3.25v9.5h6" /><path d="m8.2 11.35 4.55-4.55 1.25 1.25-4.55 4.55-2 .5Z" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div id="panelMenu" class="panel-popover panel-menu" hidden>
|
||||
<button type="button" data-menu-action="sessions" hidden>最近会话</button>
|
||||
<button type="button" data-menu-action="clear">清空当前输出</button>
|
||||
<button type="button" data-menu-action="refresh">重新同步</button>
|
||||
<button type="button" data-menu-action="expand">展开面板</button>
|
||||
<button type="button" data-menu-action="close">隐藏面板</button>
|
||||
</div>
|
||||
|
||||
<div id="detailsPopover" class="panel-popover details-popover settings-popover" hidden role="dialog" aria-label="设置">
|
||||
<div class="popover-title">设置</div>
|
||||
<div class="settings-shortcuts">
|
||||
<button type="button" data-settings-action="model"><span>模型与推理强度</span><span id="settingsModelValue">默认</span></button>
|
||||
<button type="button" data-settings-action="permission"><span>修改权限</span><span id="settingsPermissionValue">工作区写入</span></button>
|
||||
<label id="localeSetting" class="settings-locale">
|
||||
<span>语言</span>
|
||||
<select id="localeSelect" aria-label="语言">
|
||||
<option value="zh-CN">中文</option>
|
||||
<option value="en-US">English</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div class="settings-divider"></div>
|
||||
<div class="popover-subtitle">当前会话</div>
|
||||
<dl>
|
||||
<dt>工作区</dt><dd id="popoverCwd">-</dd>
|
||||
<dt>模式</dt><dd id="popoverMode">本地模式</dd>
|
||||
<dt>thread</dt><dd id="popoverThread">-</dd>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div id="sessionPicker" class="panel-popover session-picker" hidden role="dialog" aria-label="最近会话">
|
||||
<div class="session-picker-header">
|
||||
<span class="popover-title">最近会话</span>
|
||||
<button id="sessionPickerRefresh" class="session-picker-refresh" type="button" title="刷新会话列表" aria-label="刷新会话列表">
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M13 5V2m0 0h-3m3 0-2.1 2.1A5 5 0 1 0 13 9" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="session-search">
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true"><circle cx="6.8" cy="6.8" r="3.8" /><path d="m9.7 9.7 3.2 3.2" /></svg>
|
||||
<label class="sr-only" for="sessionSearchInput">搜索最近会话</label>
|
||||
<input id="sessionSearchInput" type="search" autocomplete="off" spellcheck="false" placeholder="搜索最近会话" aria-label="搜索最近会话" aria-controls="sessionList" aria-expanded="false" />
|
||||
<button id="sessionSearchClear" class="session-search-clear" type="button" title="清除搜索" aria-label="清除搜索" hidden>
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="m4.5 4.5 7 7m0-7-7 7" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div id="sessionPickerStatus" class="session-picker-status" role="status" aria-live="polite"></div>
|
||||
<div id="sessionList" class="session-list" role="listbox" aria-label="可用会话" tabindex="0"></div>
|
||||
</div>
|
||||
|
||||
<section class="chat-panel" aria-label="对话内容">
|
||||
<div id="output" class="output chat-scroll" tabindex="0" aria-live="polite" aria-label="Codex 消息"></div>
|
||||
<button id="scrollToBottom" class="scroll-to-bottom" type="button" aria-label="回到最新消息" aria-hidden="true" tabindex="-1">
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M8 3v9M4.5 8.5 8 12l3.5-3.5" /></svg>
|
||||
<span class="scroll-working-dots" aria-hidden="true"><i></i><i></i><i></i></span>
|
||||
</button>
|
||||
<div id="inlineRequests" class="inline-requests" aria-live="polite" aria-label="待处理的 Codex 请求"></div>
|
||||
</section>
|
||||
|
||||
<section id="messageForm" class="composer" aria-label="发送消息">
|
||||
<section id="subagentsPanel" class="subagents-panel" aria-label="子代理" hidden>
|
||||
<button id="subagentsToggle" class="subagents-toggle" type="button" aria-expanded="false">
|
||||
<span class="subagents-title">子代理</span>
|
||||
<span id="subagentsCount" class="subagents-count"></span>
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="m6 3 5 5-5 5" /></svg>
|
||||
</button>
|
||||
<div id="subagentsList" class="subagents-list"></div>
|
||||
</section>
|
||||
|
||||
<div id="liveActivity" class="live-activity" role="status" aria-live="polite" hidden>
|
||||
<span class="activity-spinner" aria-hidden="true"></span>
|
||||
<span class="activity-label"></span>
|
||||
<span class="activity-dots" aria-hidden="true"><i></i><i></i><i></i></span>
|
||||
<span class="activity-elapsed"></span>
|
||||
</div>
|
||||
|
||||
<div class="composer-surface">
|
||||
<div id="messageInput" class="composer-editor" contenteditable="true" role="textbox" aria-multiline="true" data-placeholder="提交后续变更要求" spellcheck="true"></div>
|
||||
<div class="composer-footer">
|
||||
<div class="composer-hint">
|
||||
<button id="composerPlusButton" class="composer-icon-button" type="button" aria-haspopup="menu" aria-expanded="false" title="添加文件及更多内容" aria-label="添加文件及更多内容">
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M8 3v10M3 8h10" /></svg>
|
||||
</button>
|
||||
<div id="composerPlusMenu" class="composer-popover composer-plus-menu" role="menu" hidden>
|
||||
<div class="composer-popover-heading">添加文件及更多内容</div>
|
||||
<button type="button" role="menuitem" data-composer-action="attach">添加文件</button>
|
||||
<button type="button" role="menuitem" data-composer-action="photo">添加照片</button>
|
||||
<button type="button" role="menuitem" data-composer-action="workspace">添加工作区上下文</button>
|
||||
<button type="button" role="menuitem" data-composer-action="web-search">网页搜索</button>
|
||||
</div>
|
||||
<input id="attachmentInput" type="file" accept=".txt,.md,.json,.js,.ts,.tsx,.jsx,.css,.html,.yml,.yaml,.xml,.py,.go,.rs,.java,.c,.cpp,.h,image/*" multiple hidden />
|
||||
|
||||
<button id="permissionChip" class="permission-chip" type="button" aria-haspopup="menu" aria-expanded="false" title="修改权限" aria-label="修改权限">
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M8 1.8 13 4v3.6c0 3-2 5.6-5 6.6-3-1-5-3.6-5-6.6V4l5-2.2Z" /><path d="m5.5 8 1.6 1.6L10.8 6" /></svg>
|
||||
<span id="permissionLabel">工作区写入</span>
|
||||
<svg class="permission-chevron" viewBox="0 0 16 16" aria-hidden="true"><path d="m4.5 6 3.5 3.5L11.5 6" /></svg>
|
||||
</button>
|
||||
<div id="permissionMenu" class="composer-popover permission-menu" role="menu" aria-label="权限设置" hidden>
|
||||
<div class="composer-popover-heading">修改权限</div>
|
||||
<button type="button" role="menuitemradio" data-permission-mode="ask" aria-checked="false"><span>需要时询问</span><small>编辑外部文件和联网时始终询问</small></button>
|
||||
<button type="button" role="menuitemradio" data-permission-mode="auto" aria-checked="false"><span>由 Codex 审批</span><small>仅对可能不安全的操作询问</small></button>
|
||||
<button type="button" role="menuitemradio" data-permission-mode="full" aria-checked="false"><span>完全访问</span><small>不限制联网或文件访问</small></button>
|
||||
<button type="button" role="menuitemradio" data-permission-mode="custom" aria-checked="false"><span>自定义</span><small>使用 config.toml 中的权限</small></button>
|
||||
<button type="button" role="menuitemradio" data-permission-mode="readonly" aria-checked="false"><span>只读</span><small>仅查看文件,不修改工作区</small></button>
|
||||
</div>
|
||||
|
||||
<div id="permissionConfirm" class="permission-confirm" role="dialog" aria-modal="true" aria-labelledby="permissionConfirmTitle" hidden>
|
||||
<div id="permissionConfirmTitle" class="permission-confirm-title">确认完全访问</div>
|
||||
<p>完全访问允许 Codex 执行命令、访问互联网并编辑工作区之外的文件。</p>
|
||||
<div class="permission-confirm-actions">
|
||||
<button id="permissionConfirmCancel" type="button">取消</button>
|
||||
<button id="permissionConfirmAccept" class="primary" type="button">确认</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="usagePicker" class="usage-picker" hidden>
|
||||
<button id="usageButton" class="usage-button" type="button" aria-haspopup="dialog" aria-expanded="false" title="查看上下文用量" aria-label="查看上下文用量"><span id="usageRing" class="usage-ring" aria-hidden="true"><span id="usageLabel">0%</span></span></button>
|
||||
<div id="usageMenu" class="composer-popover usage-menu" role="dialog" aria-label="上下文用量" hidden>
|
||||
<div class="composer-popover-heading">上下文用量</div>
|
||||
<div id="usageSummary" class="usage-summary">暂无用量数据</div>
|
||||
<div class="usage-meter"><span id="usageMeterBar"></span></div>
|
||||
<div id="usageDetails" class="usage-details"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span id="factApp" class="sr-only">-</span>
|
||||
<span id="factClients" class="sr-only">-</span>
|
||||
<span id="factRequests" class="sr-only">0</span>
|
||||
</div>
|
||||
|
||||
<div class="composer-actions">
|
||||
<div id="modelPicker" class="model-picker">
|
||||
<button id="modelPickerButton" class="model-picker-button" type="button" aria-haspopup="menu" aria-expanded="false" title="切换模型与推理强度" hidden>
|
||||
<span id="modelLabel" class="model-label"></span>
|
||||
<span id="modelEffortLabel" class="model-effort-label"></span>
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="m4.5 6 3.5 3.5L11.5 6" /></svg>
|
||||
</button>
|
||||
<div id="modelMenu" class="model-menu" role="menu" aria-label="模型与推理强度" hidden>
|
||||
<div id="modelPowerView" class="model-power-view">
|
||||
<div class="model-power-heading">
|
||||
<span>推理强度</span>
|
||||
<button id="modelAdvancedToggle" class="model-advanced-toggle" type="button">高级</button>
|
||||
</div>
|
||||
<div class="model-power-control">
|
||||
<span class="model-power-label">更高效</span>
|
||||
<input id="modelPowerSlider" class="model-power-slider" type="range" min="0" max="3" step="1" value="1" aria-label="强度" aria-describedby="modelPowerInstructions" />
|
||||
<span class="model-power-label">更智能</span>
|
||||
</div>
|
||||
<div id="modelPowerValue" class="model-power-value"></div>
|
||||
<span id="modelPowerInstructions" class="sr-only">使用左右方向键调整强度</span>
|
||||
</div>
|
||||
<div id="modelAdvancedView" class="model-advanced-view" hidden>
|
||||
<div class="model-advanced-toolbar">
|
||||
<button id="modelAdvancedBack" class="model-advanced-back" type="button" aria-label="返回模型强度">‹</button>
|
||||
<span>模型与推理强度</span>
|
||||
</div>
|
||||
<div class="model-menu-heading">模型</div>
|
||||
<div id="modelOptions" class="model-options" role="listbox" aria-label="模型"></div>
|
||||
<div class="model-menu-heading effort-heading">推理强度</div>
|
||||
<div id="effortOptions" class="effort-options" role="listbox" aria-label="推理强度"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button id="interruptButton" class="compact-action interrupt-action" type="button" disabled title="中断当前 turn" aria-label="中断当前 turn">
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true"><rect x="4.5" y="4.5" width="7" height="7" rx="1" /></svg>
|
||||
</button>
|
||||
<button id="steerButton" class="primary compact-action steer-action" type="button" disabled title="发送后续指令" aria-label="发送后续指令">
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M8 12V4M4.5 7.5 8 4l3.5 3.5" /></svg>
|
||||
</button>
|
||||
<button id="startTurnButton" class="primary send-button" type="button" disabled title="发送消息" aria-label="发送消息">
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M8 12V4M4.5 7.5 8 4l3.5 3.5" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mode-row">
|
||||
<span class="connection-mode-label">
|
||||
<svg class="mode-icon" viewBox="0 0 16 16" aria-hidden="true"><rect x="2" y="3" width="12" height="8" rx="1" /><path d="M5 13h6M8 11v2" /></svg>
|
||||
<span id="modeLabel">本地模式</span>
|
||||
</span>
|
||||
<div id="controlModeSwitch" class="control-mode-switch" role="group" aria-label="控制模式" aria-busy="false" data-mode="sync" data-switching="false">
|
||||
<button type="button" data-control-mode="sync" aria-pressed="true" title="同步模式跟随 VS Code 当前会话" disabled>同步</button>
|
||||
<button type="button" data-control-mode="async" aria-pressed="false" title="异步模式可独立管理会话" disabled>异步</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<button id="restorePanel" class="restore-panel" type="button" hidden>显示 Codex</button>
|
||||
|
||||
<section class="compatibility-state" aria-hidden="true" hidden inert>
|
||||
<details id="sessionSettings">
|
||||
<summary>会话设置</summary>
|
||||
<div class="settings-grid">
|
||||
<label>工作目录<input id="cwdInput" type="text" /></label>
|
||||
<label>模型<input id="modelInput" type="text" placeholder="留空使用默认模型" /></label>
|
||||
<label>沙箱
|
||||
<select id="sandboxInput">
|
||||
<option value="workspace-write">workspace-write</option>
|
||||
<option value="read-only">read-only</option>
|
||||
<option value="danger-full-access">danger-full-access</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>审批策略
|
||||
<select id="approvalInput">
|
||||
<option value="on-request">on-request</option>
|
||||
<option value="untrusted">untrusted</option>
|
||||
<option value="never">never</option>
|
||||
</select>
|
||||
</label>
|
||||
<button id="startThreadButton" class="secondary" type="button">启动新 thread</button>
|
||||
<div class="ids">
|
||||
<span>thread</span><code id="threadId">-</code>
|
||||
<span>turn</span><code id="turnId">-</code>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
<details id="connectionSettings">
|
||||
<summary>连接设置</summary>
|
||||
<label class="token-field">
|
||||
<span id="tokenLabel">本机连接(无需 token)</span>
|
||||
<input id="tokenInput" type="password" autocomplete="off" placeholder="本机模式无需填写;认证模式再填写" />
|
||||
</label>
|
||||
</details>
|
||||
<span id="sessionMode">已附着当前会话</span>
|
||||
<span id="latestSeq">seq -</span>
|
||||
<span id="outputHint">等待连接</span>
|
||||
<button id="clearOutputButton" type="button">清空对话</button>
|
||||
<span id="lastEvent">-</span>
|
||||
<details id="requestsPanel"><summary><span>授权与输入</span><span id="requestCount" class="badge warning">0</span></summary><div id="requests" class="requests empty">暂无待处理请求</div></details>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,52 @@
|
||||
import { createApp } from "vue";
|
||||
|
||||
import appRuntimeUrl from "../../public/app.js?url";
|
||||
import embedBridgeUrl from "../../public/embed-bridge.js?url";
|
||||
import i18nRuntimeUrl from "../../public/i18n.js?url";
|
||||
import "../../public/style.css";
|
||||
import App from "./App.vue";
|
||||
import { installRequestTemplate } from "./runtime/request-template";
|
||||
|
||||
type RuntimeAsset = {
|
||||
id: string;
|
||||
url: string;
|
||||
};
|
||||
|
||||
const runtimeAssets: RuntimeAsset[] = [
|
||||
{ id: "vscodex-i18n-runtime", url: i18nRuntimeUrl },
|
||||
{ id: "vscodex-embed-bridge", url: embedBridgeUrl },
|
||||
{ id: "vscodex-compat-runtime", url: appRuntimeUrl },
|
||||
];
|
||||
|
||||
function loadRuntimeAsset(asset: RuntimeAsset): Promise<void> {
|
||||
const existing = document.getElementById(asset.id) as HTMLScriptElement | null;
|
||||
if (existing?.dataset.loaded === "true") return Promise.resolve();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const script = existing ?? document.createElement("script");
|
||||
script.id = asset.id;
|
||||
script.async = false;
|
||||
script.src = asset.url;
|
||||
script.addEventListener("load", () => {
|
||||
script.dataset.loaded = "true";
|
||||
resolve();
|
||||
}, { once: true });
|
||||
script.addEventListener("error", () => reject(new Error(`Unable to load ${asset.id}`)), { once: true });
|
||||
if (!existing) document.body.append(script);
|
||||
});
|
||||
}
|
||||
|
||||
async function startCompatibilityRuntime(): Promise<void> {
|
||||
for (const asset of runtimeAssets) await loadRuntimeAsset(asset);
|
||||
}
|
||||
|
||||
createApp(App).mount("#app");
|
||||
installRequestTemplate();
|
||||
|
||||
void startCompatibilityRuntime().catch((error: unknown) => {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const status = document.getElementById("appState");
|
||||
if (status) status.textContent = message;
|
||||
document.body.dataset.runtimeError = "true";
|
||||
console.error("Failed to start the Codex compatibility runtime", error);
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
export function installRequestTemplate(): HTMLTemplateElement {
|
||||
const existing = document.getElementById("requestTemplate");
|
||||
if (existing instanceof HTMLTemplateElement) return existing;
|
||||
|
||||
const template = document.createElement("template");
|
||||
template.id = "requestTemplate";
|
||||
template.innerHTML = `
|
||||
<article class="request">
|
||||
<div class="request-title"><span class="request-icon" aria-hidden="true">!</span><strong class="request-method"></strong><span class="request-risk"></span><span class="request-id"></span></div>
|
||||
<p class="request-summary"></p>
|
||||
<pre class="request-command"></pre>
|
||||
<div class="request-questions"></div>
|
||||
<label class="request-scope-wrap" hidden>
|
||||
<span>授权范围</span>
|
||||
<select class="request-scope">
|
||||
<option value="turn">仅本次 turn</option>
|
||||
<option value="session">当前会话</option>
|
||||
</select>
|
||||
</label>
|
||||
<details class="request-details">
|
||||
<summary>查看请求数据</summary>
|
||||
<pre class="request-json"></pre>
|
||||
</details>
|
||||
<textarea class="request-response" rows="4" aria-label="JSON 响应"></textarea>
|
||||
<div class="button-row request-actions">
|
||||
<button class="primary request-allow">允许</button>
|
||||
<button class="secondary request-deny">拒绝</button>
|
||||
<button class="secondary request-send">发送 JSON</button>
|
||||
</div>
|
||||
</article>
|
||||
`;
|
||||
document.body.append(template);
|
||||
return template;
|
||||
}
|
||||
Vendored
+12
@@ -0,0 +1,12 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface Window {
|
||||
AetherVscodexEmbed?: {
|
||||
active: boolean;
|
||||
stop?: () => void;
|
||||
};
|
||||
VscodexI18n?: {
|
||||
locale: () => string;
|
||||
setLocale: (locale: string, options?: { persist?: boolean }) => string;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
import { mount } from "@vue/test-utils";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import CodexSurface from "../src/components/CodexSurface.vue";
|
||||
import { installRequestTemplate } from "../src/runtime/request-template";
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
describe("CodexSurface", () => {
|
||||
it("mounts the compatibility shell expected by the existing runtime", () => {
|
||||
const wrapper = mount(CodexSurface, { attachTo: document.body });
|
||||
|
||||
expect(wrapper.find("#output").exists()).toBe(true);
|
||||
expect(wrapper.find("#messageInput").attributes("contenteditable")).toBe("true");
|
||||
expect(wrapper.find("#sessionPicker").exists()).toBe(true);
|
||||
expect(wrapper.find("#modelMenu").exists()).toBe(true);
|
||||
expect(wrapper.find("#permissionMenu").exists()).toBe(true);
|
||||
expect(wrapper.find("#requests").exists()).toBe(true);
|
||||
expect(wrapper.find("#controlModeSwitch").attributes("data-mode")).toBe("sync");
|
||||
const controlModes = wrapper.findAll("#controlModeSwitch [data-control-mode]");
|
||||
expect(controlModes).toHaveLength(2);
|
||||
expect(controlModes[0].attributes("aria-pressed")).toBe("true");
|
||||
expect(controlModes.every((button) => button.attributes("disabled") !== undefined)).toBe(true);
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it("keeps every compatibility element from the legacy shell", () => {
|
||||
mount(CodexSurface, { attachTo: document.body });
|
||||
installRequestTemplate();
|
||||
|
||||
const legacyHtml = readFileSync(resolve(process.cwd(), "../public/index.html"), "utf8");
|
||||
const legacyDocument = new DOMParser().parseFromString(legacyHtml, "text/html");
|
||||
const expected = [...legacyDocument.querySelectorAll<HTMLElement>("[id]")]
|
||||
.map((element) => ({ id: element.id, tag: element.tagName, className: element.className }))
|
||||
.sort((left, right) => left.id.localeCompare(right.id));
|
||||
const actual = [...document.querySelectorAll<HTMLElement>("[id]")]
|
||||
.filter((element) => element.id !== "app")
|
||||
.map((element) => ({ id: element.id, tag: element.tagName, className: element.className }))
|
||||
.sort((left, right) => left.id.localeCompare(right.id));
|
||||
|
||||
expect(actual).toEqual(expected);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "Bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"strict": true,
|
||||
"jsx": "preserve",
|
||||
"types": ["vite/client", "vitest/globals"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.vue", "tests/**/*.ts"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "ES2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "Bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"strict": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
/// <reference types="vitest/config" />
|
||||
|
||||
import { fileURLToPath, URL } from "node:url";
|
||||
|
||||
import vue from "@vitejs/plugin-vue";
|
||||
import { defineConfig } from "vite";
|
||||
|
||||
const relayTarget = "http://127.0.0.1:8787";
|
||||
|
||||
export default defineConfig({
|
||||
// Relative assets let the same build run at the local relay root and under
|
||||
// Aether's /aether-vscodex/ static subpath.
|
||||
base: "./",
|
||||
plugins: [vue()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": fileURLToPath(new URL("./src", import.meta.url)),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
fs: {
|
||||
allow: [fileURLToPath(new URL("..", import.meta.url))],
|
||||
},
|
||||
proxy: {
|
||||
"/api": {
|
||||
target: relayTarget,
|
||||
changeOrigin: true,
|
||||
},
|
||||
"/ws": {
|
||||
target: relayTarget.replace("http", "ws"),
|
||||
changeOrigin: true,
|
||||
ws: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: "dist",
|
||||
emptyOutDir: true,
|
||||
assetsInlineLimit: 0,
|
||||
},
|
||||
test: {
|
||||
environment: "jsdom",
|
||||
include: ["tests/**/*.test.ts"],
|
||||
},
|
||||
});
|
||||
@@ -52,6 +52,7 @@ async-trait.workspace = true
|
||||
axum = { version = "0.8", features = ["ws"] }
|
||||
base64.workspace = true
|
||||
bcrypt.workspace = true
|
||||
brotli.workspace = true
|
||||
bytes.workspace = true
|
||||
chrono.workspace = true
|
||||
chrono-tz.workspace = true
|
||||
@@ -64,26 +65,28 @@ http.workspace = true
|
||||
http-body-util = "0.1"
|
||||
hyper = { version = "1", features = ["client", "server", "http1", "http2"] }
|
||||
hyper-util = { version = "0.1", features = ["client-legacy", "client-pool", "server-auto", "service", "tokio"] }
|
||||
ldap3 = { version = "0.11", default-features = false, features = ["sync", "tls-rustls"] }
|
||||
ldap3 = { version = "0.12.1", default-features = false, features = ["sync", "tls-rustls-ring"] }
|
||||
libc = "0.2"
|
||||
md-5 = "0.10"
|
||||
object_store.workspace = true
|
||||
parking_lot = "0.12"
|
||||
percent-encoding.workspace = true
|
||||
regex.workspace = true
|
||||
reqwest.workspace = true
|
||||
rsa = "0.9.10"
|
||||
rustls.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
semver.workspace = true
|
||||
sha1 = "0.10"
|
||||
sha2 = { workspace = true, features = ["oid"] }
|
||||
socket2.workspace = true
|
||||
tar.workspace = true
|
||||
sqlx = { workspace = true, features = ["postgres", "mysql", "sqlite", "migrate"] }
|
||||
sqlx = { workspace = true, features = ["postgres", "migrate"] }
|
||||
sysinfo = "0.32"
|
||||
thiserror.workspace = true
|
||||
tokio.workspace = true
|
||||
tokio-util.workspace = true
|
||||
tokio-tungstenite = { version = "0.28", features = ["rustls-tls-webpki-roots"] }
|
||||
tower = { version = "0.5", features = ["util"] }
|
||||
tower-http = { version = "0.6", features = ["fs", "compression-gzip", "set-header"] }
|
||||
tracing.workspace = true
|
||||
@@ -99,5 +102,7 @@ tikv-jemallocator = { version = "0.6", optional = true }
|
||||
tikv-jemalloc-sys = { version = "0.6", optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
aether-testkit = { workspace = true, features = ["postgres"] }
|
||||
aether-test-support.workspace = true
|
||||
aws-lc-rs.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
|
||||
@@ -30,7 +30,7 @@ struct Args {
|
||||
#[arg(
|
||||
long,
|
||||
env = "AETHER_EXECUTION_RUNTIME_UNIX_SOCKET",
|
||||
default_value = "/tmp/aether-execution-runtime.sock"
|
||||
default_value = "/tmp/aether-execution-runtime/aether-execution-runtime.sock"
|
||||
)]
|
||||
unix_socket: PathBuf,
|
||||
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
pub(crate) use crate::handlers::admin::{
|
||||
admin_provider_ops_local_action_response, admin_provider_pool_config,
|
||||
build_internal_control_error_response, create_provider_oauth_catalog_key,
|
||||
find_duplicate_provider_oauth_key, maybe_build_local_admin_pool_response,
|
||||
maybe_build_local_admin_response, persist_provider_quota_refresh_state,
|
||||
provider_oauth_maintenance_endpoint_for_provider, provider_oauth_runtime_endpoint_for_provider,
|
||||
provider_quota_refresh_endpoint_for_provider, provider_type_supports_quota_refresh,
|
||||
reconcile_admin_fixed_provider_template_endpoints,
|
||||
execute_admin_system_import_exclusively, find_duplicate_provider_oauth_key,
|
||||
maybe_build_local_admin_pool_response, maybe_build_local_admin_response,
|
||||
persist_provider_quota_refresh_state, provider_oauth_maintenance_endpoint_for_provider,
|
||||
provider_oauth_runtime_endpoint_for_provider, provider_quota_refresh_endpoint_for_provider,
|
||||
provider_type_supports_quota_refresh, reconcile_admin_fixed_provider_template_endpoints,
|
||||
refresh_provider_oauth_account_state_after_update, refresh_provider_pool_quota_locally,
|
||||
store_admin_provider_ops_balance_cache, update_existing_provider_oauth_catalog_key,
|
||||
release_admin_system_import_lease, store_admin_provider_ops_balance_cache,
|
||||
try_acquire_admin_system_import_lease, update_existing_provider_oauth_catalog_key,
|
||||
AdminAppState, AdminGatewayProviderTransportSnapshot, AdminLocalOAuthRefreshError,
|
||||
AdminRequestContext, AdminRouteRequest, AdminRouteResponse, AdminRouteResult,
|
||||
AdminStatsTimeRange, AdminStatsUsageFilter, OAUTH_ACCOUNT_BLOCK_PREFIX,
|
||||
OAUTH_REQUEST_FAILED_PREFIX,
|
||||
AdminStatsTimeRange, AdminStatsUsageFilter, AdminSystemImportLockError, SystemExportMode,
|
||||
OAUTH_ACCOUNT_BLOCK_PREFIX, OAUTH_REQUEST_FAILED_PREFIX,
|
||||
};
|
||||
|
||||
use crate::handlers::admin::{
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use aether_usage_runtime::decode_internal_report_body_base64;
|
||||
use base64::Engine as _;
|
||||
use serde_json::Value;
|
||||
|
||||
@@ -43,9 +44,8 @@ pub(crate) fn maybe_normalize_provider_private_sync_report_payload(
|
||||
}
|
||||
|
||||
if let Some(body_base64) = payload.body_base64.as_deref() {
|
||||
let body_bytes = base64::engine::general_purpose::STANDARD
|
||||
.decode(body_base64)
|
||||
.map_err(|err| GatewayError::Internal(err.to_string()))?;
|
||||
let body_bytes =
|
||||
decode_internal_report_body_base64(body_base64).map_err(GatewayError::Internal)?;
|
||||
let Some(normalized_bytes) =
|
||||
normalize_provider_private_stream_bytes(report_context, &body_bytes)?
|
||||
else {
|
||||
|
||||
@@ -50,15 +50,16 @@ pub(crate) use aether_ai_formats::api::{
|
||||
resolve_claude_stream_spec, resolve_claude_sync_spec, resolve_gemini_stream_spec,
|
||||
resolve_gemini_sync_spec, resolve_local_image_stream_spec, resolve_local_image_sync_spec,
|
||||
resolve_local_same_format_stream_spec, resolve_local_same_format_sync_spec,
|
||||
resolve_openai_embedding_sync_spec, sanitize_request_path_and_query, AiControlPlanRequest,
|
||||
CanonicalContentPart, CanonicalStreamEvent, CanonicalStreamFrame, ClaudeClientEmitter,
|
||||
ExecutionRuntimeAuthContext, LocalCoreSyncErrorKind, LocalOpenAiImageSpec,
|
||||
LocalSameFormatProviderFamily, LocalSameFormatProviderSpec, LocalStandardSourceFamily,
|
||||
LocalStandardSourceMode, LocalStandardSpec, OpenAIChatClientEmitter,
|
||||
OpenAIResponsesClientEmitter, StreamingStandardTerminalObserver, CLAUDE_CHAT_STREAM_PLAN_KIND,
|
||||
CLAUDE_CLI_STREAM_PLAN_KIND, EXECUTION_RUNTIME_STREAM_DECISION_ACTION,
|
||||
EXECUTION_RUNTIME_SYNC_DECISION_ACTION, GEMINI_CHAT_STREAM_PLAN_KIND,
|
||||
GEMINI_CLI_STREAM_PLAN_KIND, GEMINI_EMBEDDING_SYNC_PLAN_KIND, GEMINI_FILES_DOWNLOAD_PLAN_KIND,
|
||||
resolve_openai_embedding_sync_spec, sanitize_request_path_and_query,
|
||||
sanitize_request_query_string, AiControlPlanRequest, CanonicalContentPart,
|
||||
CanonicalStreamEvent, CanonicalStreamFrame, ClaudeClientEmitter, ExecutionRuntimeAuthContext,
|
||||
LocalCoreSyncErrorKind, LocalOpenAiImageSpec, LocalSameFormatProviderFamily,
|
||||
LocalSameFormatProviderSpec, LocalStandardSourceFamily, LocalStandardSourceMode,
|
||||
LocalStandardSpec, OpenAIChatClientEmitter, OpenAIResponsesClientEmitter,
|
||||
StreamingStandardTerminalObserver, CLAUDE_CHAT_STREAM_PLAN_KIND, CLAUDE_CLI_STREAM_PLAN_KIND,
|
||||
EXECUTION_RUNTIME_STREAM_DECISION_ACTION, EXECUTION_RUNTIME_SYNC_DECISION_ACTION,
|
||||
GEMINI_CHAT_STREAM_PLAN_KIND, GEMINI_CLI_STREAM_PLAN_KIND, GEMINI_EMBEDDING_SYNC_PLAN_KIND,
|
||||
GEMINI_FILES_DOWNLOAD_PLAN_KIND, GEMINI_INTERACTIONS_STREAM_PLAN_KIND,
|
||||
GEMINI_VIDEO_CANCEL_SYNC_PLAN_KIND, OPENAI_CHAT_STREAM_PLAN_KIND,
|
||||
OPENAI_EMBEDDING_SYNC_PLAN_KIND, OPENAI_IMAGE_STREAM_PLAN_KIND,
|
||||
OPENAI_IMAGE_SYNC_FINALIZE_REPORT_KIND, OPENAI_IMAGE_SYNC_PLAN_KIND,
|
||||
@@ -68,7 +69,10 @@ pub(crate) use aether_ai_formats::api::{
|
||||
OPENAI_VIDEO_REMIX_SYNC_PLAN_KIND,
|
||||
};
|
||||
pub(crate) use aether_ai_formats::protocol::stream::CanonicalUsage as StreamingCanonicalUsage;
|
||||
pub(crate) use aether_ai_formats::CODEX_RESPONSES_LITE_HEADER;
|
||||
/// Codex client identity headers re-exported for out-of-crate probe binaries,
|
||||
/// which must reach `aether_ai_formats` through this seam.
|
||||
pub use aether_ai_formats::{CODEX_CLIENT_ORIGINATOR, CODEX_CLIENT_USER_AGENT};
|
||||
pub(crate) use aether_ai_formats::{CODEX_RESPONSES_LITE_HEADER, UPSTREAM_IS_STREAM_KEY};
|
||||
|
||||
pub(crate) fn parse_direct_request_body(
|
||||
parts: &http::request::Parts,
|
||||
@@ -87,28 +91,36 @@ pub(crate) fn resolve_execution_runtime_stream_plan_kind(
|
||||
parts: &http::request::Parts,
|
||||
decision: &GatewayControlDecision,
|
||||
) -> Option<&'static str> {
|
||||
aether_ai_formats::api::resolve_execution_runtime_stream_plan_kind(
|
||||
decision.route_class.as_deref(),
|
||||
decision.route_family.as_deref(),
|
||||
decision.route_kind.as_deref(),
|
||||
decision.request_auth_channel.as_deref(),
|
||||
&parts.method,
|
||||
parts.uri.path(),
|
||||
)
|
||||
let plan_kind =
|
||||
aether_ai_formats::api::resolve_execution_runtime_stream_plan_kind_with_client_surface(
|
||||
decision.route_class.as_deref(),
|
||||
decision.route_family.as_deref(),
|
||||
decision.route_kind.as_deref(),
|
||||
decision.client_surface,
|
||||
decision.request_auth_channel.as_deref(),
|
||||
&parts.method,
|
||||
parts.uri.path(),
|
||||
)?;
|
||||
crate::ai_serving::plan_kind_matches_api_operation(plan_kind, true, decision.api_operation)
|
||||
.then_some(plan_kind)
|
||||
}
|
||||
|
||||
pub(crate) fn resolve_execution_runtime_sync_plan_kind(
|
||||
parts: &http::request::Parts,
|
||||
decision: &GatewayControlDecision,
|
||||
) -> Option<&'static str> {
|
||||
aether_ai_formats::api::resolve_execution_runtime_sync_plan_kind(
|
||||
decision.route_class.as_deref(),
|
||||
decision.route_family.as_deref(),
|
||||
decision.route_kind.as_deref(),
|
||||
decision.request_auth_channel.as_deref(),
|
||||
&parts.method,
|
||||
parts.uri.path(),
|
||||
)
|
||||
let plan_kind =
|
||||
aether_ai_formats::api::resolve_execution_runtime_sync_plan_kind_with_client_surface(
|
||||
decision.route_class.as_deref(),
|
||||
decision.route_family.as_deref(),
|
||||
decision.route_kind.as_deref(),
|
||||
decision.client_surface,
|
||||
decision.request_auth_channel.as_deref(),
|
||||
&parts.method,
|
||||
parts.uri.path(),
|
||||
)?;
|
||||
crate::ai_serving::plan_kind_matches_api_operation(plan_kind, false, decision.api_operation)
|
||||
.then_some(plan_kind)
|
||||
}
|
||||
|
||||
pub(crate) fn is_matching_stream_request(
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
use std::sync::{Arc, OnceLock};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use http::{request::Parts, HeaderMap};
|
||||
use serde_json::Value;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::ai_serving::transport::ProviderOutboundRequestContext;
|
||||
use crate::client_session_affinity::codex_request_signals_from_request;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct CodexFingerprintContextSlot(Arc<OnceLock<ProviderOutboundRequestContext>>);
|
||||
|
||||
impl Default for CodexFingerprintContextSlot {
|
||||
fn default() -> Self {
|
||||
Self(Arc::new(OnceLock::new()))
|
||||
}
|
||||
}
|
||||
|
||||
impl CodexFingerprintContextSlot {
|
||||
fn resolve(&self, headers: &HeaderMap, body_json: &Value) -> ProviderOutboundRequestContext {
|
||||
self.0
|
||||
.get_or_init(|| {
|
||||
build_codex_fingerprint_context(headers, body_json, Uuid::now_v7().to_string())
|
||||
})
|
||||
.clone()
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn resolve_codex_fingerprint_context(
|
||||
parts: &Parts,
|
||||
body_json: &Value,
|
||||
) -> ProviderOutboundRequestContext {
|
||||
if let Some(context) = parts
|
||||
.extensions
|
||||
.get::<ProviderOutboundRequestContext>()
|
||||
.cloned()
|
||||
{
|
||||
return context;
|
||||
}
|
||||
if let Some(slot) = parts.extensions.get::<CodexFingerprintContextSlot>() {
|
||||
return slot.resolve(&parts.headers, body_json);
|
||||
}
|
||||
build_codex_fingerprint_context(&parts.headers, body_json, Uuid::now_v7().to_string())
|
||||
}
|
||||
|
||||
pub(crate) fn install_codex_fingerprint_context_slot(parts: &mut Parts) {
|
||||
if parts
|
||||
.extensions
|
||||
.get::<ProviderOutboundRequestContext>()
|
||||
.is_none()
|
||||
&& parts
|
||||
.extensions
|
||||
.get::<CodexFingerprintContextSlot>()
|
||||
.is_none()
|
||||
{
|
||||
parts
|
||||
.extensions
|
||||
.insert(CodexFingerprintContextSlot::default());
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn ensure_codex_fingerprint_context(
|
||||
parts: &mut Parts,
|
||||
body_json: &Value,
|
||||
) -> ProviderOutboundRequestContext {
|
||||
let context = resolve_codex_fingerprint_context(parts, body_json);
|
||||
if parts
|
||||
.extensions
|
||||
.get::<ProviderOutboundRequestContext>()
|
||||
.is_none()
|
||||
{
|
||||
parts.extensions.remove::<CodexFingerprintContextSlot>();
|
||||
parts.extensions.insert(context.clone());
|
||||
}
|
||||
context
|
||||
}
|
||||
|
||||
pub(crate) fn attach_codex_logical_turn_context(
|
||||
parts: &mut Parts,
|
||||
body_json: &Value,
|
||||
logical_turn_id: &str,
|
||||
) -> ProviderOutboundRequestContext {
|
||||
let context =
|
||||
build_codex_fingerprint_context(&parts.headers, body_json, logical_turn_id.to_string());
|
||||
parts.extensions.remove::<CodexFingerprintContextSlot>();
|
||||
parts.extensions.insert(context.clone());
|
||||
context
|
||||
}
|
||||
|
||||
pub(crate) fn restore_codex_logical_turn_context(
|
||||
parts: &mut Parts,
|
||||
context: &ProviderOutboundRequestContext,
|
||||
) {
|
||||
parts.extensions.remove::<CodexFingerprintContextSlot>();
|
||||
parts.extensions.insert(context.clone());
|
||||
}
|
||||
|
||||
fn build_codex_fingerprint_context(
|
||||
headers: &HeaderMap,
|
||||
body_json: &Value,
|
||||
logical_turn_id: String,
|
||||
) -> ProviderOutboundRequestContext {
|
||||
let signals = codex_request_signals_from_request(headers, Some(body_json));
|
||||
let mut context = ProviderOutboundRequestContext::new(logical_turn_id, current_unix_millis());
|
||||
|
||||
if let Some(turn_id) = signals.turn_id {
|
||||
context = context.with_original_turn_id(turn_id);
|
||||
}
|
||||
if let Some(session_id) = signals.thread_id.or(signals.session_id) {
|
||||
context = context.with_original_client_session_id(session_id);
|
||||
}
|
||||
if let Some(prompt_cache_key) = signals.prompt_cache_key {
|
||||
context = context.with_original_prompt_cache_key(prompt_cache_key);
|
||||
}
|
||||
|
||||
context
|
||||
}
|
||||
|
||||
fn current_unix_millis() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_millis()
|
||||
.try_into()
|
||||
.unwrap_or(u64::MAX)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use http::HeaderValue;
|
||||
use serde_json::json;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn request_signals_are_captured_once_for_the_logical_turn() {
|
||||
let request = http::Request::builder()
|
||||
.header("thread-id", "header-thread")
|
||||
.body(())
|
||||
.expect("request should build");
|
||||
let (mut parts, _) = request.into_parts();
|
||||
let body = json!({
|
||||
"prompt_cache_key": "client-cache",
|
||||
"client_metadata": {
|
||||
"turn_id": "client-turn",
|
||||
"thread_id": "body-thread"
|
||||
}
|
||||
});
|
||||
|
||||
let context = attach_codex_logical_turn_context(&mut parts, &body, "logical-turn");
|
||||
|
||||
assert_eq!(context.logical_turn_id(), "logical-turn");
|
||||
assert_eq!(context.original_turn_id(), Some("client-turn"));
|
||||
assert_eq!(context.original_client_session_id(), Some("header-thread"));
|
||||
assert_eq!(context.original_prompt_cache_key(), Some("client-cache"));
|
||||
assert_eq!(
|
||||
parts.extensions.get::<ProviderOutboundRequestContext>(),
|
||||
Some(&context)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restored_context_wins_over_retry_request_signals() {
|
||||
let original = ProviderOutboundRequestContext::new("logical-turn", 1234)
|
||||
.with_original_turn_id("original-turn")
|
||||
.with_original_client_session_id("original-thread")
|
||||
.with_original_prompt_cache_key("original-cache");
|
||||
let request = http::Request::builder()
|
||||
.body(())
|
||||
.expect("request should build");
|
||||
let (mut parts, _) = request.into_parts();
|
||||
parts
|
||||
.headers
|
||||
.insert("thread-id", HeaderValue::from_static("retry-thread"));
|
||||
restore_codex_logical_turn_context(&mut parts, &original);
|
||||
|
||||
let resolved = resolve_codex_fingerprint_context(
|
||||
&parts,
|
||||
&json!({
|
||||
"prompt_cache_key": "retry-cache",
|
||||
"client_metadata": {"turn_id": "retry-turn"}
|
||||
}),
|
||||
);
|
||||
|
||||
assert_eq!(resolved, original);
|
||||
assert_eq!(resolved.turn_started_at_unix_ms(), 1234);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generated_context_is_persisted_for_http_replanning() {
|
||||
let request = http::Request::builder()
|
||||
.header("session-id", "client-session")
|
||||
.body(())
|
||||
.expect("request should build");
|
||||
let (mut parts, _) = request.into_parts();
|
||||
let body = json!({
|
||||
"prompt_cache_key": "client-cache",
|
||||
"client_metadata": {"turn_id": "client-turn"}
|
||||
});
|
||||
|
||||
let first = ensure_codex_fingerprint_context(&mut parts, &body);
|
||||
let second = resolve_codex_fingerprint_context(
|
||||
&parts,
|
||||
&json!({
|
||||
"prompt_cache_key": "retry-cache",
|
||||
"client_metadata": {"turn_id": "retry-turn"}
|
||||
}),
|
||||
);
|
||||
|
||||
assert_eq!(second, first);
|
||||
assert_eq!(second.original_turn_id(), Some("client-turn"));
|
||||
assert_eq!(second.original_prompt_cache_key(), Some("client-cache"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn installed_slot_reuses_context_across_cloned_parts() {
|
||||
let request = http::Request::builder()
|
||||
.body(())
|
||||
.expect("request should build");
|
||||
let (mut parts, _) = request.into_parts();
|
||||
install_codex_fingerprint_context_slot(&mut parts);
|
||||
let cloned_parts = parts.clone();
|
||||
|
||||
let first = resolve_codex_fingerprint_context(
|
||||
&parts,
|
||||
&json!({
|
||||
"prompt_cache_key": "first-cache",
|
||||
"client_metadata": {"turn_id": "first-turn"}
|
||||
}),
|
||||
);
|
||||
let second = resolve_codex_fingerprint_context(
|
||||
&cloned_parts,
|
||||
&json!({
|
||||
"prompt_cache_key": "second-cache",
|
||||
"client_metadata": {"turn_id": "second-turn"}
|
||||
}),
|
||||
);
|
||||
|
||||
assert_eq!(second, first);
|
||||
assert_eq!(second.original_turn_id(), Some("first-turn"));
|
||||
assert_eq!(second.original_prompt_cache_key(), Some("first-cache"));
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ use serde_json::Value;
|
||||
|
||||
use crate::ai_serving::{
|
||||
maybe_build_ai_surface_stream_rewriter, AiSurfaceFinalizeError, AiSurfaceStreamRewriter,
|
||||
ResponseHistoryRecord,
|
||||
};
|
||||
use crate::GatewayError;
|
||||
|
||||
@@ -24,6 +25,10 @@ impl LocalStreamRewriter<'_> {
|
||||
pub(crate) fn finish(&mut self) -> Result<Vec<u8>, GatewayError> {
|
||||
self.inner.finish().map_err(map_surface_error)
|
||||
}
|
||||
|
||||
pub(crate) fn take_response_history_record(&mut self) -> Option<ResponseHistoryRecord> {
|
||||
self.inner.take_response_history_record()
|
||||
}
|
||||
}
|
||||
|
||||
fn map_surface_error(error: AiSurfaceFinalizeError) -> GatewayError {
|
||||
|
||||
@@ -13,6 +13,7 @@ fn same_format_claude_local_stream_rewriter_sanitizes_read_input_json_delta() {
|
||||
let report_context = json!({
|
||||
"provider_api_format": "claude:messages",
|
||||
"client_api_format": "claude:messages",
|
||||
"anthropic_compatibility_profile": "claude_code_legacy",
|
||||
"needs_conversion": false,
|
||||
});
|
||||
let mut rewriter =
|
||||
|
||||
@@ -11,10 +11,10 @@ use super::{
|
||||
convert_gemini_chat_response_to_openai_chat, convert_gemini_response_to_openai_responses,
|
||||
maybe_build_local_core_sync_finalize_response,
|
||||
};
|
||||
use crate::ai_serving::GatewayControlDecision;
|
||||
use crate::ai_serving::{
|
||||
convert_openai_chat_response_to_openai_responses,
|
||||
convert_openai_responses_response_to_openai_chat,
|
||||
convert_openai_responses_response_to_openai_chat, openai_responses_message_item_id,
|
||||
GatewayControlDecision,
|
||||
};
|
||||
use crate::usage::GatewaySyncReportRequest;
|
||||
|
||||
@@ -25,6 +25,9 @@ fn test_decision() -> GatewayControlDecision {
|
||||
route_class: Some("ai_public".to_string()),
|
||||
route_family: Some("openai".to_string()),
|
||||
route_kind: Some("compact".to_string()),
|
||||
client_surface: None,
|
||||
api_operation: None,
|
||||
gateway_credential_carrier: None,
|
||||
request_auth_channel: None,
|
||||
auth_endpoint_signature: Some("openai:responses:compact".to_string()),
|
||||
execution_runtime_candidate: true,
|
||||
@@ -189,7 +192,7 @@ fn aggregates_openai_responses_stream_completed_event_to_final_response() {
|
||||
"output_text": "Hello",
|
||||
"output": [{
|
||||
"type": "message",
|
||||
"id": "resp_123_msg",
|
||||
"id": openai_responses_message_item_id("resp_123", 0),
|
||||
"role": "assistant",
|
||||
"status": "completed",
|
||||
"content": [{
|
||||
@@ -840,7 +843,7 @@ fn converts_claude_cli_response_to_openai_responses_response() {
|
||||
"output_text": "Hello Claude CLI",
|
||||
"output": [{
|
||||
"type": "message",
|
||||
"id": "msg_cli_123_msg",
|
||||
"id": openai_responses_message_item_id("msg_cli_123", 0),
|
||||
"role": "assistant",
|
||||
"status": "completed",
|
||||
"content": [{
|
||||
@@ -904,7 +907,7 @@ fn converts_claude_cli_tool_use_to_openai_responses_function_call() {
|
||||
"output": [
|
||||
{
|
||||
"type": "message",
|
||||
"id": "msg_cli_tool_123_msg",
|
||||
"id": openai_responses_message_item_id("msg_cli_tool_123", 0),
|
||||
"role": "assistant",
|
||||
"status": "completed",
|
||||
"content": [{
|
||||
@@ -974,7 +977,7 @@ fn converts_gemini_cli_response_to_openai_responses_response() {
|
||||
"output_text": "Hello Gemini CLI",
|
||||
"output": [{
|
||||
"type": "message",
|
||||
"id": "resp_cli_123_msg",
|
||||
"id": openai_responses_message_item_id("resp_cli_123", 0),
|
||||
"role": "assistant",
|
||||
"status": "completed",
|
||||
"content": [{
|
||||
@@ -1043,7 +1046,7 @@ fn converts_gemini_cli_function_call_to_openai_responses_function_call() {
|
||||
"output": [
|
||||
{
|
||||
"type": "message",
|
||||
"id": "resp_cli_tool_123_msg",
|
||||
"id": openai_responses_message_item_id("resp_cli_tool_123", 0),
|
||||
"role": "assistant",
|
||||
"status": "completed",
|
||||
"content": [{
|
||||
@@ -1249,7 +1252,7 @@ fn local_finalize_handles_openai_responses_openai_family_sync_response_even_when
|
||||
"model": "gpt-5",
|
||||
"output": [{
|
||||
"type": "message",
|
||||
"id": "resp_cli_family_123_msg",
|
||||
"id": openai_responses_message_item_id("resp_cli_family_123", 0),
|
||||
"role": "assistant",
|
||||
"status": "completed",
|
||||
"content": [{
|
||||
@@ -1923,6 +1926,9 @@ fn local_finalize_handles_claude_chat_cross_format_sync_response_from_openai_cha
|
||||
route_class: Some("ai_public".to_string()),
|
||||
route_family: Some("claude".to_string()),
|
||||
route_kind: Some("chat".to_string()),
|
||||
client_surface: None,
|
||||
api_operation: None,
|
||||
gateway_credential_carrier: None,
|
||||
request_auth_channel: None,
|
||||
auth_endpoint_signature: Some("claude:messages".to_string()),
|
||||
execution_runtime_candidate: true,
|
||||
@@ -1991,6 +1997,9 @@ fn local_finalize_handles_gemini_cli_cross_format_sync_response_from_claude_cli(
|
||||
route_class: Some("ai_public".to_string()),
|
||||
route_family: Some("gemini".to_string()),
|
||||
route_kind: Some("cli".to_string()),
|
||||
client_surface: None,
|
||||
api_operation: None,
|
||||
gateway_credential_carrier: None,
|
||||
request_auth_channel: None,
|
||||
auth_endpoint_signature: Some("gemini:generate_content".to_string()),
|
||||
execution_runtime_candidate: true,
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
mod adaptation;
|
||||
pub(crate) mod api;
|
||||
pub(crate) mod codex_context;
|
||||
mod finalize;
|
||||
mod planner;
|
||||
mod pure;
|
||||
mod response_history;
|
||||
pub(crate) mod transport;
|
||||
|
||||
use axum::body::Body;
|
||||
@@ -22,6 +24,7 @@ pub(crate) use self::finalize::internal::{
|
||||
maybe_build_sync_finalize_outcome, maybe_compile_sync_finalize_response,
|
||||
SyncToStreamBridgeOutcome,
|
||||
};
|
||||
pub(crate) use self::planner::openai_responses_reasoning_replay_policy;
|
||||
pub(crate) use self::planner::{
|
||||
apply_local_runtime_candidate_terminal_reason, build_gemini_stream_plan_from_decision,
|
||||
build_gemini_sync_plan_from_decision, build_local_gemini_files_stream_attempt_source_for_kind,
|
||||
@@ -51,18 +54,26 @@ pub(crate) use self::planner::{
|
||||
build_standard_family_sync_plan_and_reports, build_standard_stream_plan_from_decision,
|
||||
build_standard_sync_plan_from_decision, candidate_auth_channel_skip_reason,
|
||||
codex_model_capabilities_for_transport, extract_pool_sticky_session_token,
|
||||
maybe_build_stream_decision_payload, maybe_build_stream_plan_payload,
|
||||
maybe_build_sync_decision_payload, maybe_build_sync_plan_payload,
|
||||
planner_is_matching_stream_request, provider_key_pool_score_id, provider_key_pool_score_scope,
|
||||
read_candidate_transport_snapshot, record_local_runtime_candidate_skip_reason,
|
||||
resolve_upstream_is_stream_for_provider, set_local_openai_chat_execution_exhausted_diagnostic,
|
||||
maybe_build_pinned_stream_local_same_format_provider_decision_payload,
|
||||
maybe_build_responses_websocket_decision, maybe_build_stream_decision_payload,
|
||||
maybe_build_stream_plan_payload, maybe_build_sync_decision_payload,
|
||||
maybe_build_sync_plan_payload, planner_is_matching_stream_request, provider_key_pool_score_id,
|
||||
provider_key_pool_score_scope, read_candidate_transport_snapshot,
|
||||
record_local_runtime_candidate_skip_reason, resolve_provider_chat_pii_redaction,
|
||||
resolve_tunnel_scheduler_affinity_context, resolve_upstream_is_stream_for_provider,
|
||||
set_local_openai_chat_execution_exhausted_diagnostic,
|
||||
set_local_openai_image_execution_exhausted_diagnostic, validate_final_openai_provider_request,
|
||||
CandidateFailureDiagnostic, CandidateFailureDiagnosticKind, EligibleLocalExecutionCandidate,
|
||||
GatewayAuthApiKeySnapshot, GatewayProviderTransportSnapshot, LocalExecutionAttemptSource,
|
||||
LocalExecutionCandidateKind, LocalResolvedOAuthRequestAuth, PlannerAppState,
|
||||
SkippedLocalExecutionCandidate,
|
||||
ResponsesWebSocketBodyNormalization, ResponsesWebSocketDecision,
|
||||
ResponsesWebSocketPinnedCandidate, SkippedLocalExecutionCandidate,
|
||||
};
|
||||
pub(crate) use self::pure::*;
|
||||
pub(crate) use self::response_history::{
|
||||
hydrate_openai_response_history, persist_converted_response_history,
|
||||
persist_response_history_record,
|
||||
};
|
||||
pub(crate) use self::transport::{
|
||||
append_transport_diagnostics_to_value, build_request_trace_proxy_value,
|
||||
candidate_common_transport_skip_reason, candidate_transport_pair_skip_reason,
|
||||
@@ -71,7 +82,7 @@ pub(crate) use self::transport::{
|
||||
request_pair_allowed_for_transport, request_pair_direct_auth,
|
||||
request_pair_transport_unsupported_reason, CandidateTransportPolicyFacts,
|
||||
};
|
||||
pub(crate) use crate::control::GatewayControlDecision;
|
||||
pub(crate) use crate::control::{GatewayControlDecision, GatewayCredentialCarrier};
|
||||
pub(crate) use crate::execution_runtime::{ConversionMode, ExecutionStrategy};
|
||||
pub(crate) use crate::headers::RequestOrigin;
|
||||
pub(crate) use aether_ai_serving::{
|
||||
@@ -89,6 +100,7 @@ pub(crate) fn build_provider_transport_request_url(
|
||||
upstream_is_stream: bool,
|
||||
request_query: Option<&str>,
|
||||
kiro_api_region: Option<&str>,
|
||||
api_operation: Option<ApiOperation>,
|
||||
) -> Option<String> {
|
||||
self::transport::build_transport_request_url(
|
||||
transport,
|
||||
@@ -98,6 +110,7 @@ pub(crate) fn build_provider_transport_request_url(
|
||||
upstream_is_stream,
|
||||
request_query,
|
||||
kiro_api_region,
|
||||
api_operation,
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -109,6 +122,7 @@ pub(crate) fn build_provider_transport_request_url_for_request_body(
|
||||
upstream_is_stream: bool,
|
||||
request_query: Option<&str>,
|
||||
kiro_api_region: Option<&str>,
|
||||
api_operation: Option<ApiOperation>,
|
||||
provider_request_body: Option<&serde_json::Value>,
|
||||
) -> Option<String> {
|
||||
self::transport::build_transport_request_url_for_request_body(
|
||||
@@ -119,6 +133,7 @@ pub(crate) fn build_provider_transport_request_url_for_request_body(
|
||||
upstream_is_stream,
|
||||
request_query,
|
||||
kiro_api_region,
|
||||
api_operation,
|
||||
},
|
||||
provider_request_body,
|
||||
)
|
||||
|
||||
@@ -67,6 +67,7 @@ pub(crate) async fn build_antigravity_v1internal_provider_request(
|
||||
input.upstream_is_stream,
|
||||
input.parts.uri.query(),
|
||||
None,
|
||||
None,
|
||||
Some(&payload.body),
|
||||
)
|
||||
.ok_or(AntigravityV1InternalRequestError::UpstreamUrlUnavailable)?;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
use aether_routing_core::ResolvedRoutingPolicy;
|
||||
use aether_scheduler_core::{
|
||||
build_scheduler_affinity_cache_key_for_api_key_id_with_client_session, ClientSessionAffinity,
|
||||
SchedulerAffinityTarget, SchedulerMinimalCandidateSelectionCandidate,
|
||||
build_scheduler_affinity_cache_key_for_api_key_id_with_client_session_and_scope,
|
||||
ClientSessionAffinity, SchedulerAffinityScope, SchedulerAffinityTarget,
|
||||
SchedulerMinimalCandidateSelectionCandidate,
|
||||
};
|
||||
|
||||
use crate::ai_serving::{GatewayAuthApiKeySnapshot, PlannerAppState};
|
||||
@@ -20,6 +22,7 @@ pub(crate) fn read_cached_scheduler_affinity_target(
|
||||
client_session_affinity: Option<&ClientSessionAffinity>,
|
||||
client_api_format: &str,
|
||||
requested_model: Option<&str>,
|
||||
routing_policy: Option<&ResolvedRoutingPolicy>,
|
||||
) -> Option<SchedulerAffinityTarget> {
|
||||
if !has_explicit_session_affinity(client_session_affinity) {
|
||||
return None;
|
||||
@@ -30,12 +33,15 @@ pub(crate) fn read_cached_scheduler_affinity_target(
|
||||
let api_key_id = auth_snapshot
|
||||
.map(|snapshot| snapshot.api_key_id.trim())
|
||||
.filter(|value| !value.is_empty())?;
|
||||
let cache_key = build_scheduler_affinity_cache_key_for_api_key_id_with_client_session(
|
||||
api_key_id,
|
||||
client_api_format,
|
||||
requested_model,
|
||||
client_session_affinity,
|
||||
)?;
|
||||
let affinity_scope = scheduler_affinity_scope_for_routing_policy(routing_policy);
|
||||
let cache_key =
|
||||
build_scheduler_affinity_cache_key_for_api_key_id_with_client_session_and_scope(
|
||||
api_key_id,
|
||||
client_api_format,
|
||||
requested_model,
|
||||
client_session_affinity,
|
||||
affinity_scope.as_ref(),
|
||||
)?;
|
||||
|
||||
state
|
||||
.app()
|
||||
@@ -72,6 +78,53 @@ pub(crate) fn remember_scheduler_affinity_for_candidate_at_epoch(
|
||||
requested_model: &str,
|
||||
candidate: &SchedulerMinimalCandidateSelectionCandidate,
|
||||
expected_epoch: Option<u64>,
|
||||
) {
|
||||
remember_scheduler_affinity_for_candidate_with_scope_at_epoch(
|
||||
state,
|
||||
auth_snapshot,
|
||||
client_session_affinity,
|
||||
client_api_format,
|
||||
requested_model,
|
||||
candidate,
|
||||
None,
|
||||
expected_epoch,
|
||||
);
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) fn remember_scheduler_affinity_for_candidate_with_routing_policy_at_epoch(
|
||||
state: PlannerAppState<'_>,
|
||||
auth_snapshot: Option<&GatewayAuthApiKeySnapshot>,
|
||||
client_session_affinity: Option<&ClientSessionAffinity>,
|
||||
client_api_format: &str,
|
||||
requested_model: &str,
|
||||
candidate: &SchedulerMinimalCandidateSelectionCandidate,
|
||||
routing_policy: Option<&ResolvedRoutingPolicy>,
|
||||
expected_epoch: Option<u64>,
|
||||
) {
|
||||
let affinity_scope = scheduler_affinity_scope_for_routing_policy(routing_policy);
|
||||
remember_scheduler_affinity_for_candidate_with_scope_at_epoch(
|
||||
state,
|
||||
auth_snapshot,
|
||||
client_session_affinity,
|
||||
client_api_format,
|
||||
requested_model,
|
||||
candidate,
|
||||
affinity_scope.as_ref(),
|
||||
expected_epoch,
|
||||
);
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn remember_scheduler_affinity_for_candidate_with_scope_at_epoch(
|
||||
state: PlannerAppState<'_>,
|
||||
auth_snapshot: Option<&GatewayAuthApiKeySnapshot>,
|
||||
client_session_affinity: Option<&ClientSessionAffinity>,
|
||||
client_api_format: &str,
|
||||
requested_model: &str,
|
||||
candidate: &SchedulerMinimalCandidateSelectionCandidate,
|
||||
affinity_scope: Option<&SchedulerAffinityScope>,
|
||||
expected_epoch: Option<u64>,
|
||||
) {
|
||||
if !has_explicit_session_affinity(client_session_affinity) {
|
||||
return;
|
||||
@@ -82,12 +135,15 @@ pub(crate) fn remember_scheduler_affinity_for_candidate_at_epoch(
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let Some(cache_key) = build_scheduler_affinity_cache_key_for_api_key_id_with_client_session(
|
||||
api_key_id,
|
||||
client_api_format,
|
||||
requested_model,
|
||||
client_session_affinity,
|
||||
) else {
|
||||
let Some(cache_key) =
|
||||
build_scheduler_affinity_cache_key_for_api_key_id_with_client_session_and_scope(
|
||||
api_key_id,
|
||||
client_api_format,
|
||||
requested_model,
|
||||
client_session_affinity,
|
||||
affinity_scope,
|
||||
)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
@@ -103,3 +159,15 @@ pub(crate) fn remember_scheduler_affinity_for_candidate_at_epoch(
|
||||
expected_epoch,
|
||||
);
|
||||
}
|
||||
|
||||
fn scheduler_affinity_scope_for_routing_policy(
|
||||
routing_policy: Option<&ResolvedRoutingPolicy>,
|
||||
) -> Option<SchedulerAffinityScope> {
|
||||
let policy = routing_policy?;
|
||||
let group_id = policy
|
||||
.group_id
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|group_id| !group_id.is_empty())?;
|
||||
Some(SchedulerAffinityScope::new(group_id, policy.group_version))
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -278,13 +278,21 @@ mod tests {
|
||||
|
||||
assert_eq!(metadata["transport_diagnostics"]["provider_type"], "codex");
|
||||
assert_eq!(
|
||||
metadata["transport_diagnostics"]["fingerprint"]["transport_profile"]["profile_id"],
|
||||
"chrome_136"
|
||||
metadata["transport_diagnostics"]["key_fingerprint_configured"],
|
||||
Value::Bool(true)
|
||||
);
|
||||
assert_eq!(
|
||||
metadata["transport_diagnostics"]["key_transport_profile_configured"],
|
||||
Value::Bool(true)
|
||||
);
|
||||
assert_eq!(
|
||||
metadata["transport_diagnostics"]["resolved_transport_profile_id"],
|
||||
"chrome_136"
|
||||
);
|
||||
assert_eq!(
|
||||
metadata["transport_diagnostics"]["resolved_transport_profile"]["profile_id"],
|
||||
"chrome_136"
|
||||
);
|
||||
assert_eq!(
|
||||
metadata["transport_diagnostics"]["request_pair"]["conversion_enabled"],
|
||||
Value::Bool(true)
|
||||
|
||||
@@ -3,17 +3,14 @@ use aether_ai_serving::{
|
||||
AiCandidateRankingPort, AiRankableCandidateParts, AiRankingContextConfig,
|
||||
AiRankingSchedulingMode,
|
||||
};
|
||||
use aether_routing_core::{ResolvedRoutingPolicy, RoutingSchedulingMode, RoutingSetPriorityMode};
|
||||
use aether_routing_core::ResolvedRoutingPolicy;
|
||||
use async_trait::async_trait;
|
||||
use tokio::sync::Mutex;
|
||||
use tracing::warn;
|
||||
|
||||
use crate::ai_serving::{GatewayAuthApiKeySnapshot, PlannerAppState};
|
||||
use crate::clock::current_unix_ms;
|
||||
use crate::handlers::shared::provider_pool::admin_provider_pool_config_from_config_value;
|
||||
use crate::scheduler::config::{
|
||||
read_scheduler_ordering_config, SchedulerOrderingConfig, SchedulerSchedulingMode,
|
||||
};
|
||||
use crate::scheduler::config::{SchedulerOrderingConfig, SchedulerSchedulingMode};
|
||||
use aether_scheduler_core::{
|
||||
matches_affinity_target, ClientSessionAffinity, SchedulerAffinityTarget,
|
||||
SchedulerMinimalCandidateSelectionCandidate, SchedulerPriorityMode, SchedulerRankableCandidate,
|
||||
@@ -66,6 +63,7 @@ impl AiCandidateRankingPort for GatewayLocalCandidateRankingPort<'_> {
|
||||
self.client_session_affinity,
|
||||
normalized_client_api_format,
|
||||
affinity_requested_model,
|
||||
self.routing_policy,
|
||||
))
|
||||
}
|
||||
|
||||
@@ -132,7 +130,7 @@ pub(crate) async fn rank_eligible_local_execution_candidates(
|
||||
required_capabilities: Option<&serde_json::Value>,
|
||||
routing_policy: Option<&ResolvedRoutingPolicy>,
|
||||
) -> Vec<EligibleLocalExecutionCandidate> {
|
||||
let ordering_config = scheduler_ordering_config_for_routing_policy(state, routing_policy).await;
|
||||
let ordering_config = scheduler_ordering_config_for_routing_policy(routing_policy);
|
||||
let port = GatewayLocalCandidateRankingPort {
|
||||
state,
|
||||
requested_model,
|
||||
@@ -183,30 +181,24 @@ fn ai_ranking_scheduling_mode(mode: SchedulerSchedulingMode) -> AiRankingSchedul
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn scheduler_ordering_config_for_routing_policy(
|
||||
state: PlannerAppState<'_>,
|
||||
/// Return the immutable scheduler snapshot carried by a resolved routing
|
||||
/// policy. A missing policy is a programming error in production request
|
||||
/// paths; unit tests may use the scheduler default for isolated ranking tests.
|
||||
pub(crate) fn scheduler_ordering_config_for_routing_policy(
|
||||
routing_policy: Option<&ResolvedRoutingPolicy>,
|
||||
) -> SchedulerOrderingConfig {
|
||||
match routing_policy {
|
||||
Some(policy) => scheduler_ordering_config_from_routing_policy(policy),
|
||||
None => read_scheduler_ordering_config_or_default(state).await,
|
||||
}
|
||||
}
|
||||
|
||||
fn scheduler_ordering_config_from_routing_policy(
|
||||
policy: &ResolvedRoutingPolicy,
|
||||
) -> SchedulerOrderingConfig {
|
||||
SchedulerOrderingConfig {
|
||||
priority_mode: match policy.priority_mode {
|
||||
RoutingSetPriorityMode::Provider => SchedulerPriorityMode::Provider,
|
||||
RoutingSetPriorityMode::GlobalKey => SchedulerPriorityMode::GlobalKey,
|
||||
},
|
||||
scheduling_mode: match policy.scheduling_mode {
|
||||
RoutingSchedulingMode::FixedOrder => SchedulerSchedulingMode::FixedOrder,
|
||||
RoutingSchedulingMode::CacheAffinity => SchedulerSchedulingMode::CacheAffinity,
|
||||
RoutingSchedulingMode::LoadBalance => SchedulerSchedulingMode::LoadBalance,
|
||||
},
|
||||
keep_priority_on_conversion: policy.keep_priority_on_conversion,
|
||||
Some(policy) => SchedulerOrderingConfig::from_routing_policy(policy),
|
||||
None => {
|
||||
#[cfg(test)]
|
||||
{
|
||||
SchedulerOrderingConfig::default()
|
||||
}
|
||||
#[cfg(not(test))]
|
||||
{
|
||||
panic!("resolved routing policy is required before candidate scheduling")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -221,35 +213,34 @@ fn routing_overlaid_candidate(
|
||||
let mut overlaid = candidate.clone();
|
||||
overlaid.provider_priority = policy
|
||||
.ranking_overlay
|
||||
.provider_priority_or_unspecified(candidate.provider_id.as_str());
|
||||
.provider_priority(candidate.provider_id.as_str(), candidate.provider_priority);
|
||||
let overlaid_key_priority = match kind {
|
||||
LocalExecutionCandidateKind::SingleKey => policy
|
||||
.ranking_overlay
|
||||
.key_priority_or_unspecified(candidate.key_id.as_str()),
|
||||
.key_priority_override_matching_format(candidate.key_id.as_str(), |format| {
|
||||
crate::ai_serving::api_format_alias_matches(
|
||||
format,
|
||||
candidate.endpoint_api_format.as_str(),
|
||||
)
|
||||
})
|
||||
.or_else(|| {
|
||||
policy
|
||||
.ranking_overlay
|
||||
.key_priority_overrides
|
||||
.get(candidate.key_id.as_str())
|
||||
.copied()
|
||||
}),
|
||||
LocalExecutionCandidateKind::PoolGroup => policy
|
||||
.ranking_overlay
|
||||
.pool_priority_or_unspecified(candidate.provider_id.as_str()),
|
||||
.pool_priority_overrides
|
||||
.get(candidate.provider_id.as_str())
|
||||
.copied(),
|
||||
};
|
||||
overlaid.key_internal_priority = overlaid_key_priority;
|
||||
overlaid.key_global_priority_for_format = Some(overlaid_key_priority);
|
||||
overlaid
|
||||
}
|
||||
|
||||
async fn read_scheduler_ordering_config_or_default(
|
||||
state: PlannerAppState<'_>,
|
||||
) -> SchedulerOrderingConfig {
|
||||
match read_scheduler_ordering_config(state.app()).await {
|
||||
Ok(config) => config,
|
||||
Err(error) => {
|
||||
warn!(
|
||||
event_name = "planner_scheduler_ordering_config_load_failed",
|
||||
log_type = "event",
|
||||
error = ?error,
|
||||
"failed to load scheduler ordering config while ranking local execution candidates"
|
||||
);
|
||||
SchedulerOrderingConfig::default()
|
||||
}
|
||||
if let Some(overlaid_key_priority) = overlaid_key_priority {
|
||||
overlaid.key_internal_priority = overlaid_key_priority;
|
||||
overlaid.key_global_priority_for_format = Some(overlaid_key_priority);
|
||||
}
|
||||
overlaid
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -260,10 +251,17 @@ mod tests {
|
||||
use aether_ai_serving::{
|
||||
ai_ranking_context, build_ai_rankable_candidate, AiRankableCandidateParts,
|
||||
};
|
||||
use aether_data::repository::provider_catalog::InMemoryProviderCatalogReadRepository;
|
||||
use aether_crypto::DEVELOPMENT_ENCRYPTION_KEY;
|
||||
use aether_data::repository::{
|
||||
provider_catalog::InMemoryProviderCatalogReadRepository,
|
||||
routing_profiles::InMemoryRoutingGroupRepository,
|
||||
};
|
||||
use aether_data_contracts::repository::provider_catalog::{
|
||||
StoredProviderCatalogEndpoint, StoredProviderCatalogKey, StoredProviderCatalogProvider,
|
||||
};
|
||||
use aether_data_contracts::repository::routing_profiles::{
|
||||
CreateRoutingGroupRecord, RoutingGroupWriteRepository,
|
||||
};
|
||||
use aether_scheduler_core::{
|
||||
apply_scheduler_candidate_ranking,
|
||||
build_scheduler_affinity_cache_key_for_api_key_id_with_client_session,
|
||||
@@ -293,7 +291,11 @@ mod tests {
|
||||
required_capabilities: Option<&serde_json::Value>,
|
||||
) -> Vec<SchedulerMinimalCandidateSelectionCandidate> {
|
||||
let normalized_client_api_format = client_api_format.trim().to_ascii_lowercase();
|
||||
let ordering_config = super::read_scheduler_ordering_config_or_default(state).await;
|
||||
let ordering_config =
|
||||
crate::scheduler::config::read_system_default_routing_ordering_config(state.app())
|
||||
.await
|
||||
.expect("routing strategy should load")
|
||||
.unwrap_or_default();
|
||||
let mut candidates = candidates;
|
||||
let mut rankables = Vec::with_capacity(candidates.len());
|
||||
let mut ordering_cache = CandidateTransportRankingFactsCache::default();
|
||||
@@ -354,7 +356,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn routing_policy_priorities_do_not_fall_back_to_candidate_priorities() {
|
||||
fn routing_policy_priorities_fall_back_to_candidate_priorities() {
|
||||
let mut candidate = sample_candidate("endpoint-1", "key-1");
|
||||
candidate.provider_priority = 7;
|
||||
candidate.key_internal_priority = 3;
|
||||
@@ -368,6 +370,8 @@ mod tests {
|
||||
priority_mode: aether_routing_core::RoutingSetPriorityMode::Provider,
|
||||
scheduling_mode: aether_routing_core::RoutingSchedulingMode::CacheAffinity,
|
||||
keep_priority_on_conversion: false,
|
||||
sticky_key_attempts: aether_routing_core::DEFAULT_STICKY_KEY_ATTEMPTS,
|
||||
execution_policy: Default::default(),
|
||||
ranking_overlay: aether_routing_core::RankingOverlay::default(),
|
||||
mutation_plan: Default::default(),
|
||||
pool_policy_overrides: BTreeMap::new(),
|
||||
@@ -380,17 +384,46 @@ mod tests {
|
||||
&candidate,
|
||||
);
|
||||
|
||||
assert_eq!(overlaid.provider_priority, 7);
|
||||
assert_eq!(overlaid.key_internal_priority, 3);
|
||||
assert_eq!(overlaid.key_global_priority_for_format, Some(2));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn routing_policy_ignores_legacy_global_conversion_priority_override() {
|
||||
let data_state = GatewayDataState::default().with_system_config_values_for_tests([(
|
||||
"keep_priority_on_conversion".to_string(),
|
||||
json!(true),
|
||||
)]);
|
||||
let state = AppState::new()
|
||||
.expect("state should build")
|
||||
.with_data_state_for_tests(data_state);
|
||||
let policy = aether_routing_core::ResolvedRoutingPolicy {
|
||||
group_id: Some("group-1".to_string()),
|
||||
group_version: Some(1),
|
||||
selection_source: "system_default".to_string(),
|
||||
requested_model: "gpt-5.4-mini".to_string(),
|
||||
resolved_model: "gpt-5.4-mini".to_string(),
|
||||
priority_mode: aether_routing_core::RoutingSetPriorityMode::Provider,
|
||||
scheduling_mode: aether_routing_core::RoutingSchedulingMode::FixedOrder,
|
||||
keep_priority_on_conversion: false,
|
||||
sticky_key_attempts: aether_routing_core::DEFAULT_STICKY_KEY_ATTEMPTS,
|
||||
execution_policy: Default::default(),
|
||||
ranking_overlay: Default::default(),
|
||||
mutation_plan: Default::default(),
|
||||
pool_policy_overrides: Default::default(),
|
||||
matched_rules: Vec::new(),
|
||||
};
|
||||
|
||||
let ordering = super::scheduler_ordering_config_for_routing_policy(Some(&policy));
|
||||
|
||||
assert_eq!(
|
||||
overlaid.provider_priority,
|
||||
aether_routing_core::ROUTING_PRIORITY_UNSPECIFIED
|
||||
ordering.scheduling_mode,
|
||||
crate::scheduler::config::SchedulerSchedulingMode::FixedOrder
|
||||
);
|
||||
assert_eq!(
|
||||
overlaid.key_internal_priority,
|
||||
aether_routing_core::ROUTING_PRIORITY_UNSPECIFIED
|
||||
);
|
||||
assert_eq!(
|
||||
overlaid.key_global_priority_for_format,
|
||||
Some(aether_routing_core::ROUTING_PRIORITY_UNSPECIFIED)
|
||||
assert!(
|
||||
!ordering.keep_priority_on_conversion,
|
||||
"a resolved routing policy must not inherit the legacy system-config flag"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -409,6 +442,8 @@ mod tests {
|
||||
priority_mode: aether_routing_core::RoutingSetPriorityMode::GlobalKey,
|
||||
scheduling_mode: aether_routing_core::RoutingSchedulingMode::CacheAffinity,
|
||||
keep_priority_on_conversion: false,
|
||||
sticky_key_attempts: aether_routing_core::DEFAULT_STICKY_KEY_ATTEMPTS,
|
||||
execution_policy: Default::default(),
|
||||
ranking_overlay: aether_routing_core::RankingOverlay {
|
||||
pool_priority_overrides: BTreeMap::from([("provider-1".to_string(), 4)]),
|
||||
key_priority_overrides: BTreeMap::from([("representative-key".to_string(), 1)]),
|
||||
@@ -532,6 +567,15 @@ mod tests {
|
||||
api_formats: Option<serde_json::Value>,
|
||||
allowed_models: Option<serde_json::Value>,
|
||||
) -> StoredProviderCatalogKey {
|
||||
let credential_state = AppState::new()
|
||||
.expect("credential state should build")
|
||||
.with_data_state_for_tests(
|
||||
GatewayDataState::disabled()
|
||||
.with_encryption_key_for_tests(DEVELOPMENT_ENCRYPTION_KEY),
|
||||
);
|
||||
let encrypted_api_key = credential_state
|
||||
.seal_provider_catalog_key_api_key(provider_id, id, "plain-upstream-key")
|
||||
.expect("api key should encrypt");
|
||||
StoredProviderCatalogKey::new(
|
||||
id.to_string(),
|
||||
provider_id.to_string(),
|
||||
@@ -543,7 +587,7 @@ mod tests {
|
||||
.expect("key should build")
|
||||
.with_transport_fields(
|
||||
api_formats,
|
||||
"plain-upstream-key".to_string(),
|
||||
encrypted_api_key,
|
||||
None,
|
||||
None,
|
||||
Some(json!({"openai:chat": 1})),
|
||||
@@ -657,7 +701,7 @@ mod tests {
|
||||
let observed_at_unix_secs = current_unix_secs();
|
||||
let data_state = GatewayDataState::with_provider_transport_reader_for_tests(
|
||||
std::sync::Arc::new(provider_catalog),
|
||||
"development-key",
|
||||
DEVELOPMENT_ENCRYPTION_KEY,
|
||||
)
|
||||
.with_system_config_values_for_tests(vec![
|
||||
("provider_priority_mode".to_string(), json!("provider")),
|
||||
@@ -666,6 +710,7 @@ mod tests {
|
||||
serde_json::to_value(TunnelAttachmentRecord {
|
||||
gateway_instance_id: "gateway-b".to_string(),
|
||||
relay_base_url: "http://gateway-b:8080".to_string(),
|
||||
tunnel_generation: "test-generation-remote".to_string(),
|
||||
conn_count: 1,
|
||||
observed_at_unix_secs,
|
||||
})
|
||||
@@ -676,6 +721,7 @@ mod tests {
|
||||
serde_json::to_value(TunnelAttachmentRecord {
|
||||
gateway_instance_id: "gateway-a".to_string(),
|
||||
relay_base_url: "http://gateway-a:8080".to_string(),
|
||||
tunnel_generation: "test-generation-local".to_string(),
|
||||
conn_count: 1,
|
||||
observed_at_unix_secs,
|
||||
})
|
||||
@@ -734,7 +780,7 @@ mod tests {
|
||||
);
|
||||
let data_state = GatewayDataState::with_provider_transport_reader_for_tests(
|
||||
std::sync::Arc::new(provider_catalog),
|
||||
"development-key",
|
||||
DEVELOPMENT_ENCRYPTION_KEY,
|
||||
);
|
||||
let state = AppState::new()
|
||||
.expect("state should build")
|
||||
@@ -787,7 +833,7 @@ mod tests {
|
||||
);
|
||||
let data_state = GatewayDataState::with_provider_transport_reader_for_tests(
|
||||
std::sync::Arc::new(provider_catalog),
|
||||
"development-key",
|
||||
DEVELOPMENT_ENCRYPTION_KEY,
|
||||
)
|
||||
.with_system_config_values_for_tests(vec![(
|
||||
"scheduling_mode".to_string(),
|
||||
@@ -844,7 +890,7 @@ mod tests {
|
||||
);
|
||||
let data_state = GatewayDataState::with_provider_transport_reader_for_tests(
|
||||
std::sync::Arc::new(provider_catalog),
|
||||
"development-key",
|
||||
DEVELOPMENT_ENCRYPTION_KEY,
|
||||
);
|
||||
let state = AppState::new()
|
||||
.expect("state should build")
|
||||
@@ -880,7 +926,8 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn local_execution_ranking_keeps_cross_format_priority_when_global_override_is_enabled() {
|
||||
async fn local_execution_ranking_keeps_cross_format_priority_when_strategy_override_is_enabled()
|
||||
{
|
||||
let provider_catalog = InMemoryProviderCatalogReadRepository::seed(
|
||||
vec![
|
||||
sample_provider_with_options("provider-same", false, 10),
|
||||
@@ -895,14 +942,32 @@ mod tests {
|
||||
sample_key_for_provider("provider-cross", "key-cross", ""),
|
||||
],
|
||||
);
|
||||
let routing_repository = std::sync::Arc::new(InMemoryRoutingGroupRepository::default());
|
||||
routing_repository
|
||||
.create_routing_group(CreateRoutingGroupRecord {
|
||||
id: "strategy-default".to_string(),
|
||||
name: "strategy-default".to_string(),
|
||||
description: None,
|
||||
enabled: true,
|
||||
is_system_default: true,
|
||||
sort_order: 0,
|
||||
config_json: json!({
|
||||
"default_policy": {
|
||||
"keep_priority_on_conversion": true
|
||||
}
|
||||
}),
|
||||
version: 1,
|
||||
created_at: 1,
|
||||
updated_at: 1,
|
||||
published_at: None,
|
||||
})
|
||||
.await
|
||||
.expect("routing strategy should be created");
|
||||
let data_state = GatewayDataState::with_provider_transport_reader_for_tests(
|
||||
std::sync::Arc::new(provider_catalog),
|
||||
"development-key",
|
||||
DEVELOPMENT_ENCRYPTION_KEY,
|
||||
)
|
||||
.with_system_config_values_for_tests(vec![(
|
||||
"keep_priority_on_conversion".to_string(),
|
||||
json!(true),
|
||||
)]);
|
||||
.with_routing_group_repository_for_tests(routing_repository);
|
||||
let state = AppState::new()
|
||||
.expect("state should build")
|
||||
.with_data_state_for_tests(data_state);
|
||||
@@ -962,7 +1027,7 @@ mod tests {
|
||||
);
|
||||
let data_state = GatewayDataState::with_provider_transport_reader_for_tests(
|
||||
std::sync::Arc::new(provider_catalog),
|
||||
"development-key",
|
||||
DEVELOPMENT_ENCRYPTION_KEY,
|
||||
)
|
||||
.with_system_config_values_for_tests(vec![(
|
||||
"provider_priority_mode".to_string(),
|
||||
@@ -1028,7 +1093,7 @@ mod tests {
|
||||
);
|
||||
let data_state = GatewayDataState::with_provider_transport_reader_for_tests(
|
||||
std::sync::Arc::new(provider_catalog),
|
||||
"development-key",
|
||||
DEVELOPMENT_ENCRYPTION_KEY,
|
||||
);
|
||||
let state = AppState::new()
|
||||
.expect("state should build")
|
||||
@@ -1081,7 +1146,7 @@ mod tests {
|
||||
);
|
||||
let data_state = GatewayDataState::with_provider_transport_reader_for_tests(
|
||||
std::sync::Arc::new(provider_catalog),
|
||||
"development-key",
|
||||
DEVELOPMENT_ENCRYPTION_KEY,
|
||||
);
|
||||
let state = AppState::new()
|
||||
.expect("state should build")
|
||||
@@ -1155,7 +1220,7 @@ mod tests {
|
||||
);
|
||||
let data_state = GatewayDataState::with_provider_transport_reader_for_tests(
|
||||
std::sync::Arc::new(provider_catalog),
|
||||
"development-key",
|
||||
DEVELOPMENT_ENCRYPTION_KEY,
|
||||
);
|
||||
let state = AppState::new()
|
||||
.expect("state should build")
|
||||
@@ -1235,7 +1300,7 @@ mod tests {
|
||||
);
|
||||
let data_state = GatewayDataState::with_provider_transport_reader_for_tests(
|
||||
std::sync::Arc::new(provider_catalog),
|
||||
"development-key",
|
||||
DEVELOPMENT_ENCRYPTION_KEY,
|
||||
);
|
||||
let state = AppState::new()
|
||||
.expect("state should build")
|
||||
@@ -1311,7 +1376,7 @@ mod tests {
|
||||
);
|
||||
let data_state = GatewayDataState::with_provider_transport_reader_for_tests(
|
||||
std::sync::Arc::new(provider_catalog),
|
||||
"development-key",
|
||||
DEVELOPMENT_ENCRYPTION_KEY,
|
||||
);
|
||||
let state = AppState::new()
|
||||
.expect("state should build")
|
||||
@@ -1378,7 +1443,7 @@ mod tests {
|
||||
);
|
||||
let data_state = GatewayDataState::with_provider_transport_reader_for_tests(
|
||||
std::sync::Arc::new(provider_catalog),
|
||||
"development-key",
|
||||
DEVELOPMENT_ENCRYPTION_KEY,
|
||||
);
|
||||
let state = AppState::new()
|
||||
.expect("state should build")
|
||||
@@ -1461,7 +1526,7 @@ mod tests {
|
||||
);
|
||||
let data_state = GatewayDataState::with_provider_transport_reader_for_tests(
|
||||
std::sync::Arc::new(provider_catalog),
|
||||
"development-key",
|
||||
DEVELOPMENT_ENCRYPTION_KEY,
|
||||
);
|
||||
let state = AppState::new()
|
||||
.expect("state should build")
|
||||
@@ -1526,7 +1591,7 @@ mod tests {
|
||||
);
|
||||
let data_state = GatewayDataState::with_provider_transport_reader_for_tests(
|
||||
std::sync::Arc::new(provider_catalog),
|
||||
"development-key",
|
||||
DEVELOPMENT_ENCRYPTION_KEY,
|
||||
);
|
||||
let state = AppState::new()
|
||||
.expect("state should build")
|
||||
@@ -1615,7 +1680,7 @@ mod tests {
|
||||
);
|
||||
let data_state = GatewayDataState::with_provider_transport_reader_for_tests(
|
||||
std::sync::Arc::new(provider_catalog),
|
||||
"development-key",
|
||||
DEVELOPMENT_ENCRYPTION_KEY,
|
||||
);
|
||||
let state = AppState::new()
|
||||
.expect("state should build")
|
||||
@@ -1701,7 +1766,7 @@ mod tests {
|
||||
);
|
||||
let data_state = GatewayDataState::with_provider_transport_reader_for_tests(
|
||||
std::sync::Arc::new(provider_catalog),
|
||||
"development-key",
|
||||
DEVELOPMENT_ENCRYPTION_KEY,
|
||||
);
|
||||
let state = AppState::new()
|
||||
.expect("state should build")
|
||||
@@ -1798,7 +1863,7 @@ mod tests {
|
||||
);
|
||||
let data_state = GatewayDataState::with_provider_transport_reader_for_tests(
|
||||
std::sync::Arc::new(provider_catalog),
|
||||
"development-key",
|
||||
DEVELOPMENT_ENCRYPTION_KEY,
|
||||
);
|
||||
let state = AppState::new()
|
||||
.expect("state should build")
|
||||
@@ -1903,7 +1968,7 @@ mod tests {
|
||||
);
|
||||
let data_state = GatewayDataState::with_provider_transport_reader_for_tests(
|
||||
std::sync::Arc::new(provider_catalog),
|
||||
"development-key",
|
||||
DEVELOPMENT_ENCRYPTION_KEY,
|
||||
);
|
||||
let state = AppState::new()
|
||||
.expect("state should build")
|
||||
@@ -1997,7 +2062,7 @@ mod tests {
|
||||
);
|
||||
let data_state = GatewayDataState::with_provider_transport_reader_for_tests(
|
||||
std::sync::Arc::new(provider_catalog),
|
||||
"development-key",
|
||||
DEVELOPMENT_ENCRYPTION_KEY,
|
||||
);
|
||||
let state = AppState::new()
|
||||
.expect("state should build")
|
||||
|
||||
@@ -23,7 +23,9 @@ use crate::ai_serving::{
|
||||
use crate::orchestration::LocalExecutionCandidateMetadata;
|
||||
use crate::stage_metrics::observe_gateway_stage_ms;
|
||||
|
||||
use super::candidate_ranking::rank_eligible_local_execution_candidates;
|
||||
use super::candidate_ranking::{
|
||||
rank_eligible_local_execution_candidates, scheduler_ordering_config_for_routing_policy,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub(crate) struct EligibleLocalExecutionCandidate {
|
||||
@@ -378,8 +380,17 @@ async fn resolve_and_rank_local_execution_candidates_with_pool_expansion(
|
||||
"candidate_resolution_core",
|
||||
started_at.elapsed().as_millis() as u64,
|
||||
);
|
||||
let sticky_key_attempts = if outcome.eligible_candidates.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(
|
||||
scheduler_ordering_config_for_routing_policy(routing_policy)
|
||||
.sticky_key_attempts,
|
||||
)
|
||||
};
|
||||
for candidate in &mut outcome.eligible_candidates {
|
||||
candidate.orchestration.scheduler_affinity_epoch = Some(scheduler_affinity_epoch);
|
||||
candidate.orchestration.sticky_key_attempts = sticky_key_attempts;
|
||||
}
|
||||
(outcome.eligible_candidates, outcome.skipped_candidates)
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user