Compare commits

..
Author SHA1 Message Date
fawney 2cb4d554aa feat(routing): consolidate scheduling strategy configuration 2026-09-03 11:05:59 +08:00
fawney e8d9877b79 Merge remote-tracking branch 'origin/main' 2026-09-02 22:08:36 +08:00
fawney19 cae9aa4134 Merge pull request #784 from fawney19/worktree-linear-enchanting-bunny
feat(routing): move sticky-key retries into routing policy with lazy attempts
2026-09-02 20:56:03 +08:00
elky 7323d41fbe feat(routing): move sticky-key retries into routing policy with lazy attempts
Replace the provider/endpoint max_retries fields as the source of same-key
retries with a routing policy setting, sticky_key_attempts (default 2). Only
the first-ranked candidate is retried on the same key; every failover
candidate gets a single attempt so failover keeps advancing instead of
retrying each fallback key.

Materialize exactly one attempt per candidate and derive same-key retries in
the attempt loop after a candidate-scoped failure, so the retry budget no
longer inflates up-front materialization and needs no upper bound. The budget
travels in the report context; retries reuse the plan with a fresh candidate
id and incremented retry index. Pool groups only retry their first key within
the retry-index stride.

Expose the setting in the routing profile editor and the set_scheduling rule
action, and drop the max_retries input from the provider form.
2026-09-02 20:48:40 +08:00
fawney19 e3644c6142 Merge pull request #779 from fawney19/worktree-linear-enchanting-bunny
feat(routing): make routing profiles the sole scheduler policy source
2026-09-02 17:16:57 +08:00
elky 415b2da81b feat(routing): make routing profiles the sole scheduler policy source
Bootstrap an enabled system-default routing group from the legacy
scheduler config keys on startup, resolve the default ordering config
from that group before falling back to the legacy keys, and stop merging
keep_priority_on_conversion with the legacy flag when a policy is
resolved. Thread the policy-derived ordering config into candidate
preselection so it no longer reads system config independently.

Add per-API-format key priority overrides so a key serving several
formats keeps independent ordering, matching the legacy
global_priority_by_format semantics. Expose keep_priority_on_conversion
in the routing profile editor and read the effective policy in the
model routing preview, monitoring metrics and provider page badge.
2026-09-02 17:04:04 +08:00
elky 166236c2ee Merge origin/main into CI dependency fix 2026-09-02 12:30:31 +08:00
ZheFox 0371a961d6 Merge pull request #777 from zhefox/main
Fix detection of DeepSeek custom relay models in gateway
2026-09-02 11:24:55 +08:00
ZheFox 144a28f544 feat(admin-users): add plan entitlement revocation flow 2026-09-02 11:23:24 +08:00
elky 611c29f1f5 ci: install VSCodex web dependencies in nightly build 2026-09-02 11:17:06 +08:00
ZheFox 6540c1e46a Merge branch 'fawney19:main' into main 2026-09-02 10:26:07 +08:00
ZheFox 24bf92a8bf Merge pull request #776 from zhefox/fix/deepseek-reasoning-text-replay
fix(gateway): detect DeepSeek custom relay models
2026-09-01 23:04:51 +08:00
zhefox 7ae984df4b fix(gateway): detect DeepSeek custom relay models 2026-09-01 22:49:35 +08:00
zhefox e2154629ca fix(gateway): detect DeepSeek custom relay models 2026-09-01 22:49:25 +08:00
ZheFox 0bfd48b9db Merge pull request #774 from Kayphoon/codex/fix-openai-responses-ping
fix(formats): ignore Responses ping stream events
2026-09-01 22:17:18 +08:00
Kayphoon 88d2b002be fix(formats): ignore Responses ping stream events 2026-09-01 12:58:21 +00:00
fawney 30a75832f8 feat(vscodex): add remote Codex collaboration module 2026-09-01 20:25:35 +08:00
fawney 5059093d29 feat(vscodex): add remote Codex collaboration module 2026-09-01 20:08:36 +08:00
ZheFox 5a69cfe40d Merge pull request #772 from zhefox/main
fix(gateway): handle pool saturation and malformed Gemini calls
2026-09-01 19:31:44 +08:00
ZheFox 3d87bbf230 style(rust): apply workspace formatting 2026-09-01 19:31:13 +08:00
ZheFox 633363e190 fix(gateway): handle pool saturation and malformed Gemini calls 2026-09-01 19:25:00 +08:00
fawney19 715f2773c3 Merge pull request #773 from fawney19/codex/codex-fingerprint-convergence
refactor(codex): generalize fingerprint convergence
2026-09-01 17:27:04 +08:00
elky d07dc86376 refactor(codex): generalize fingerprint convergence 2026-09-01 17:05:54 +08:00
elky ef7caa40e7 ci: publish nightly builds from main 2026-09-01 16:43:29 +08:00
fawney19 7fb8d5fc0a Merge pull request #771 from fawney19/codex/codex-context-stability
Merge Phase 1 Codex context and fingerprint convergence changes.
2026-09-01 16:07:41 +08:00
elky 3e540ce589 fix(gateway): route Codex context through transport facade 2026-09-01 15:53:47 +08:00
ZheFox 6c71f87589 fix(frontend): align Antigravity quota summaries 2026-09-01 15:38:00 +08:00
elky a39048ecce feat(codex): stabilize identity across retries 2026-09-01 15:33:40 +08:00
ZheFox b538aa2d66 Merge pull request #770 from zhefox/main
fix(pool): show Antigravity quota reset times
2026-09-01 12:09:41 +08:00
ZheFox 57abb20778 fix(pool): show Antigravity quota reset times 2026-09-01 11:29:48 +08:00
ZheFox d117a0cd13 Merge pull request #769 from zhefox/main
fix(pool): restore Antigravity quota progress bars
2026-09-01 10:43:05 +08:00
ZheFox ee55f46962 fix(pool): restore Antigravity quota progress bars 2026-09-01 10:40:37 +08:00
ZheFox 9210502e77 Merge pull request #768 from zhefox/main
fix(pool): isolate model quotas and compact account display
2026-09-01 10:20:09 +08:00
ZheFox 2fe2600021 fix(pool): isolate model quotas and compact account display 2026-09-01 10:15:57 +08:00
ZheFox 9b819169d5 Merge pull request #767 from zhefox/fix/provider-key-concurrency-cache-affinity
fix(gateway): improve provider pool concurrency, quotas, and affinity
2026-09-01 08:11:46 +08:00
ZheFox 9631b229b3 fix(gateway): add provider key concurrency and cache affinity modes 2026-09-01 08:06:58 +08:00
ZheFox 9372d6cfa5 Merge pull request #765 from Brisbanehuang/codex/usage-api-template
feat(provider-ops): 新增通用 API Key 用量查询模板
2026-08-29 23:23:06 +08:00
Brisbanehuang 4dbf98163e feat(provider-ops): add generic usage API template 2026-08-29 09:04:05 -04:00
ZheFox 6ec0771297 Merge pull request #764 from zhefox/main
fix(gateway): route Responses compaction only to Responses providers
2026-08-29 12:56:32 +08:00
zhefox 56395945c0 fix(gateway): route Responses compaction only to Responses providers 2026-08-29 12:28:55 +08:00
ZheFox 8032497045 Merge pull request #763 from zhefox/main
Fix response reasoning summaries for Chat
2026-08-29 11:54:15 +08:00
zhefox b35364d7fd fix(antigravity): normalize private search tool name 2026-08-29 11:09:15 +08:00
ZheFox f085fdf918 Merge pull request #762 from zhefox/main
fix(gemini): normalize mixed tools for same-format providers
2026-08-29 08:48:56 +08:00
ZheFox 36daba7a34 fix(antigravity): align tool schema wire fields 2026-08-29 08:45:19 +08:00
ZheFox 9837ce1197 fix(antigravity): use Gemini schema field for tools 2026-08-29 02:50:07 +08:00
ZheFox 1bc2287baa fix(gemini): normalize mixed tools for same-format providers 2026-08-29 01:08:01 +08:00
ZheFox a519bcf705 Merge pull request #761 from zhefox/main
Fix response reasoning summaries for Chat
2026-08-29 00:03:16 +08:00
ZheFox 9461b1004f Merge branch 'main' of https://github.com/zhefox/Aether 2026-08-29 00:00:13 +08:00
ZheFox 3c15f523be Merge pull request #760 from zhefox/fix/gemini-tool-wire-model-gating
fix(formats): gate mixed Gemini tools by model
2026-08-28 23:36:08 +08:00
ZheFox 5bcdcca784 fix(formats): normalize Responses additional tools for Chat 2026-08-28 23:03:56 +08:00
ZheFox 83098f98b6 fix(formats): gate mixed Gemini tools by model 2026-08-28 20:42:49 +08:00
ZheFox 5ab35ae6ba Merge pull request #759 from zhefox/fix/gemini-tool-schema-compat
fix(formats): enable mixed Gemini tool calls
2026-08-28 17:58:03 +08:00
ZheFox f0b0064f3d fix(formats): enable mixed Gemini tool calls 2026-08-28 16:55:38 +08:00
ZheFox 4879295f23 Merge pull request #758 from zhefox/fix/gemini-tool-schema-compat
fix(formats): sanitize Gemini tool schemas
2026-08-28 16:15:24 +08:00
ZheFox 64e5725331 fix(formats): sanitize Gemini tool schemas 2026-08-28 16:11:16 +08:00
ZheFox 1995198b18 Merge pull request #757 from zhefox/fix/responses-chat-reasoning-summary
fix(formats): degrade Responses reasoning summaries for Chat
2026-08-28 14:09:57 +08:00
ZheFox 5b6fce1a77 fix(formats): degrade Responses reasoning summaries for Chat 2026-08-28 14:05:48 +08:00
ZheFox fa8e443f7b fix(formats): degrade Responses reasoning summaries for Chat 2026-08-28 14:03:43 +08:00
ZheFox 08e7530adb Merge pull request #756 from zhefox/main
fix: preserve Gemini signatures and enforce provider limits
2026-08-28 14:01:43 +08:00
ZheFox 5687dad177 fix(formats): scope signature helper to tests 2026-08-28 13:21:35 +08:00
ZheFox dd2958a458 fix(admin): persist provider settings and enforce quotas 2026-08-28 12:47:54 +08:00
ZheFox c4b4dfa996 fix(formats): preserve Gemini tool thought signatures 2026-08-28 12:47:34 +08:00
ZheFox d88c454a2c Merge pull request #754 from zhefox/main
fix(ai): align Gemini and Responses compatibility
2026-08-28 08:15:35 +08:00
ZheFox 8cdfa338e5 fix(gemini): pair idless tool history 2026-08-28 02:15:00 +08:00
ZheFox 4da8c57fe3 fix(ai): align Gemini and Responses compatibility 2026-08-27 22:13:20 +08:00
ZheFox 7892aa9485 Merge pull request #753 from zhefox/fix/codex-namespace-tool-conversion
fix(formats): preserve Responses namespace tools through Chat
2026-08-26 01:13:53 +08:00
ZheFox 9d9892be6a fix(gateway): finalize cross-format sync JSON responses 2026-08-26 00:43:58 +08:00
ZheFox e2b003af24 fix(formats): preserve Responses namespace tools through Chat 2026-08-26 00:43:50 +08:00
ZheFox ffca7e0402 Merge pull request #752 from zhefox/main
fix(codex): isolate Spark quotas and repair contaminated account state
2026-08-25 22:13:15 +08:00
ZheFox ec6ddb43a7 fix(data): retain applied legacy backfill for upgrades 2026-08-25 21:22:57 +08:00
ZheFox 2f2d444f97 fix(codex): self-heal Spark-contaminated account quotas
Keep model-scoped Spark windows out of account state, preserve authoritative WHAM exhaustion flags, and repair historical cross-family quota generations without weakening stale-response guards.
2026-08-25 19:13:41 +08:00
stabeyandClaude Opus 5 42deab67b3 fix(admin): keep a model-scoped 429 reset out of the account quota slot
`parse_codex_websocket_usage_limit_error` backfills `primary_reset_at` and
`primary_reset_after_seconds` from the error body whenever the embedded headers
did not supply them. Now that a named per-model limit no longer claims the
unprefixed window headers, that absence is exactly what a Spark 429 produces —
and `resets_at` on such an error is the Spark window's reset, so the backfill
put model-scoped timing back on the account's own quota.

Gate the backfill on the same ownership rule the window parsing uses.

Reported by Cursor Bugbot on the fork PR.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-23 15:22:29 +08:00
stabeyandClaude Opus 5 1b1be918a9 fix(admin): keep Codex model-scoped rate-limit headers out of the account quota
`parse_codex_usage_headers` reads the unprefixed `x-codex-primary/secondary-*`
headers as the account's own quota. They are not: they carry whichever limit
governed the request, and `x-codex-active-limit` names it — `premium` for the
plan's own limit, or a metered feature such as `codex_bengalfox` for a named
per-model limit. On a request billed against a named limit the unprefixed
headers repeat that limit's windows verbatim.

So a single request to a model with its own limit writes that model's windows
into the account slots. The paid-window swap then makes it worse: a named
limit's secondary window is active, unlike the plan's disabled one, so the swap
promotes the model's weekly window into the account's weekly slot — the slot
the UI labels and the scheduler reads through `quota_usage_ratio`.

It also sticks. Both weekly windows share `window_minutes`, so
`codex_quota_same_window_identity` treats them as one window, and
`codex_quota_merge_same_window` drops an observation whose deadline is earlier
than the stored one. The two weeks start at different instants, so every later
account observation looks like a stale sample of a window that already rolled
over and is discarded until the model window's own deadline passes.

Observed on a `pro` key running both model families: one `gpt-5.3-codex-spark`
request replaced the account weekly window with the Spark weekly one, and the
~3000 plan-limit responses over the next 100 minutes were all discarded. The
account's real weekly usage never landed, and its reset time was reported nine
hours late.

The header set describes itself — every named limit announces
`x-codex-<feature>-limit-name` and carries its windows under the same prefix —
so parse the named families directly and only claim the unprefixed windows for
the account when no announced limit owns them. Responses without
`x-codex-active-limit` keep the previous behaviour.

This also stops the Spark windows from going stale: they were only ever written
by the `wham/usage` admin probe even though every response carries them.

Refs #746

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-23 15:22:29 +08:00
ZheFox 3f2b67f191 Merge pull request #749 from zhefox/main
test(gateway): fix Codex Realtime route fixture
2026-08-23 12:48:10 +08:00
ZheFox 6a9eea34a0 test(gateway): fix Codex Realtime route fixture 2026-08-23 04:00:16 +08:00
ZheFox 453a0b3ee7 Merge pull request #747 from zhefox/main
fix(gateway): support current Codex Realtime live routes
2026-08-23 03:18:46 +08:00
ZheFox 2cd20da1ec fix(gateway): support current Codex Realtime live routes 2026-08-23 02:51:49 +08:00
ZheFox ea4453321d Merge pull request #743 from zhefox/main
fix(usage): unify OpenAI Live and WebSocket records
2026-08-21 12:47:37 +08:00
ZheFox acde38b8e7 fix(usage): unify OpenAI Live and WebSocket records 2026-08-21 11:53:53 +08:00
ZheFox 9996e75a34 fix(ci): align database snapshot migration cutoff 2026-08-21 11:11:45 +08:00
ZheFox 16f96d73ec Merge pull request #742 from zhefox/main
feat(gateway): add Codex Live and OpenAI Realtime
2026-08-21 08:36:19 +08:00
ZheFox 2c89202001 feat(gateway): add Codex Live and OpenAI Realtime
Implement preflighted Live/Realtime WebSocket transports, protocol-aware authentication, usage auditing, UI filtering, and legacy Codex permission migration.
2026-08-21 04:27:34 +08:00
ZheFox fe38dcd294 Merge pull request #741 from zhefox/main
feat(gateway): add Codex Live transport
2026-08-20 22:22:37 +08:00
ZheFox 4185ad1b1e feat(gateway): add Codex Live transport 2026-08-20 21:59:05 +08:00
ZheFox 6916e9da76 Merge pull request #739 from zhefox/main
fix(ws): secure Responses continuation and opaque reasoning replay
2026-08-20 10:01:46 +08:00
ZheFox 654f798d25 fix(ws): harden Responses continuation state 2026-08-20 08:51:16 +08:00
ZheFox bef282cfee fix(responses): replay DeepSeek opaque reasoning state 2026-08-20 00:40:48 +08:00
ZheFox d21d8ce9f5 fix(codex): avoid replaying static config on websocket continuations 2026-08-20 00:40:40 +08:00
ZheFox 342f8b6a5f Merge pull request #737 from zhefox/main
fix(ws): preserve Codex continuations across turn metadata
2026-08-18 18:20:49 +08:00
ZheFox c50a1c6c46 fix(ws): preserve Codex continuation bindings 2026-08-18 17:17:45 +08:00
elky 535ee098c3 fix(auth): reject unsigned admin identity headers 2026-08-18 11:12:17 +08:00
ZheFox b45df89ce4 Merge pull request #730 from zhefox/fix/responses-websocket-current
feat(gateway): add OpenAI Responses WebSocket mode
2026-08-17 21:14:50 +08:00
ZheFox c8118edf36 fix(ws): harden Responses connection lifecycle
Revalidate control policy per turn, isolate downstream credentials, and make planner/turn ownership cancellation-safe.

Preserve opaque protocol events, align configurable timeout semantics, and extend end-to-end security and settlement coverage.
2026-08-17 18:50:29 +08:00
AAEE86 4a0775c4ea style: apply cargo fmt across gateway and aether-ai crates 2026-08-17 14:53:53 +08:00
AAEE86 6fc02dad3e fix(ws): restore redacted PII in provider frames before client delivery
Responses WebSocket 只实现了脱敏的一半:请求侧 mask 之后,provider 事件帧在推给
客户端之前没有还原,于是 session 映射内的占位符以 <AETHER:EMAIL:...> 的形式直接
透给客户端。这里补齐响应侧,语义与 HTTP 路径对齐。

- 还原点是 relay loop 的最后一跳(send_client_message 之前、capture_client_frame
  之前),对应 HTTP 的 restore_sync_response_body / StreamingResponseRestorer 所在
  位置。审计与终态观测继续消费脱敏态事件,只有发往客户端的那一份拷贝被还原。
- 复用 privacy::restore_json_strings(改为 pub(crate))与
  RedactionSession::restore_text,不复制任何还原逻辑:只还原本 session mask 过的
  映射,未映射的占位符原样保留;type / model / id 等协议字段不可能命中 sentinel,
  因此不受影响。批量 {"chunks":[...]} 帧一并递归还原。
- session 生命周期:mask 仍然是 per-turn(slot 依旧每轮新建),但 session 改由连接
  持有,按有界 FIFO 留最近 8 轮。理由是 WS 的会话历史留在上游,continuation 只发
  增量输入,per-turn 释放会漏还原后续响应里回显的更早轮次占位符;HTTP 不会漏,是
  因为它每次重发整段历史、重新 mask 会派生出同一个 sentinel。被挤出窗口的轮次退回
  「占位符原样透传」,不会错误还原成别的值。
- 未命中还原时不改写字节;连接上没有任何 mask session 时(未启用脱敏)连事件 clone
  都不做。

测试:redaction.rs 新增 8 条单测(还原命中/批量帧/未映射占位符原样/未命中不改写/
无 session 不介入/空 session 不留存/审计侧入参不被改写/跨轮还原/窗口有界);
responses_websocket_e2e 新增一条用例,mock 上游回显收到的 input,断言上游只看到
占位符而客户端拿到真实邮箱。
2026-08-17 14:53:46 +08:00
AAEE86 dbf2809bd6 fix(ws): settle the previous attempt before transparent retry replanning
评审第 2 条。配额透明重试原来的顺序是「detach 旧 attempt → 规划并绑定新
attempt → 把旧 attempt 的结算排进队列」。规划因此读到的是旧 attempt 还没投射的
health / adaptive / pool 状态,而且旧 attempt 仍占着自己的 pool key lease——替代
key 的挑选看到的是一把仍被占用的 key,最坏情况下判成「无可用供应商」而放弃一次
本可以成功的重试。

普通的新 turn 早就挡住了这件事:client.rs 在处理 response.create 前调用
await_pending_turn_finalization,注释写的正是「不要让新 turn 基于陈旧的 health /
adaptive / pool 状态规划」。透明重试是同一个问题的另一条入口,漏了这一步。

现在顺序是:detach → 释放准入 → 结算旧 attempt 并等它落地 → 规划/绑定新 attempt。

新增 lifecycle::settle_turn_finalization:与 queue_turn_finalization 的区别只在于
「等」。后者把 handle 挂在连接上让 relay loop 继续跑,用在结算之后不再读取共享
状态的出口;前者用在必须先看到结算结果才能继续的路径上。

顺序用类型固定,而不是靠注释:settle_turn_finalization 返回
PreviousAttemptSettled,retry_active_turn_after_quota_exhaustion 要求这个参数。
凭证只能由 lifecycle 颁发(结算完成,或明确「没有 attempt 要结算」),所以把顺序
写反连编译都过不了。

重试失败路径随之变化:旧 attempt 已经结算,不再 resume 回去。logical turn 仍停在
Replanning,后续分支的 end() / finalize_active_turn 只清 logical turn、不交出
attempt,因此不存在重复结算。结算 outcome 取值不变(两条路径用的都是
terminal_outcome.unwrap_or_else(upstream_closed),而这条分支里 terminal_outcome
必为 Some——usage_limit_error 成立意味着有一个已解析的 error 终态帧)。

代价(都落在「重试失败」这一侧,且只影响已终态 attempt 的报告注解,不影响计费):
- 那条最终转发给客户端的 429 事件不再进旧 attempt 的 client capture;
  provider 侧 capture 早在 observe_upstream_frame 里就记下了。
- 如果转发 429 给客户端也失败,record_client_delivery_aborted 落在一个已经结算的
  attempt 上,成为 no-op。

测试:
- lifecycle:await_turn_finalization_handle 必须「等到落地」而不是「排进队列」
  (C6 依赖的性质);结算完成后规划才读状态的顺序型断言(计数器替身);结算任务
  panic 也必须放行调用方,不能卡死 relay loop。
- turn_state:Replanning 状态下 end() 不再交出第二个 attempt(无重复结算)。
- e2e 新增 provider_quota_exhaustion_transparently_retries_onto_another_key:
  mock 上游首轮只回 Codex 的 429 usage_limit_reached,网关换到第二把 key 重放同一个
  response.create;断言客户端看不到 429、上游被连两次、两次用的不是同一把 key、两个
  attempt 各留一条终态行(429 的那条 + 计费的那条)。已验证它在改动前后都通过——
  它覆盖的是整条路径可用,顺序由上面的单测确定性覆盖。
  夹具随之参数化出 ProviderFixture::CodexKeyPair:透明重试只有 Codex adapter 会
  开启,而 codex 候选要求 auth_type = oauth,所以这个夹具用未过期的 oauth 凭证。
2026-08-17 14:53:40 +08:00
AAEE86 1d3051cb89 refactor(ws): structured terminal observation without SSE text round-trips
评审第 5 条:Responses WebSocket 收到的本来就是结构化协议事件,但为了复用面向
SSE 的 push_line,观测路径要先把每个事件序列化成 data: {json}\n\n,解析器再
decode 回 Value——一次纯粹的往返。这个「伪 SSE」形状是随手拼的,一旦拼装函数
以后被加上换行或分块逻辑,观测结果就会和真实事件悄悄分叉。

aether-ai-formats:
- OpenAIResponsesProviderState::push_line 机械拆成 decode + push_event,
  push_line 现在只做解码。协议状态机一行未动,diff 里除函数签名外只有
  &value → value(value 从拥有改成借用,持有结构化事件的传输不必为了调用它
  先克隆一份)。
- StreamingStandardTerminalObserver::push_event 走 TerminalStreamParser::Standard,
  service tier 的记录方式与 push_line 完全相同。openai:image 的终态状态机按 SSE
  行做增量解析、没有结构化入口,返回 AiSurfaceFinalizeError 让调用方
  disable_with_error 标记 parser_error,而不是静默丢事件、把摘要留成「未观察到
  终态」。ProviderStreamParser 的其余三个格式同样返回 Err:机械拆分随时可做,
  但不建无调用方的接口。

WS 侧:
- 新增 responses/observation.rs 的 ResponsesStructuredTerminalObserver,直接消费
  frame.protocol_events() 借出的事件。包一层的意义是让「不再拼 SSE」成为类型层面
  的事实——这个类型没有任何接受字节的方法,改回 push_line 不可能悄悄发生。
  finish() 里的 Ok(None) / Err → disable_with_error 兜底也一并收进来。
- body capture 不动,仍然是 SSE 形状(data: 开头、\n\n 结尾):
  aether_usage_runtime::report 用 line.strip_prefix("data:") 解析被捕获的 body
  判定 StreamCapturedTerminalState,而它是 stream_report_represents_failure 的一个
  OR 项,换成结构化 JSON 会让终态判定恒为 Missing。capture_sse_event /
  capture_client_frame / websocket_event_as_sse_line 全部保留,原因写在模块文档
  注释里。这一层只换观测,不换捕获。

差分测试(8 个,aether-ai-formats):同一组事件序列分别走 push_line 与
push_event,断言 ExecutionStreamTerminalSummary 完全相等——批量 delta 序列、
completed 带 usage、合法 incomplete、error、response.failed、未知事件、
service tier、缺终态;外加 openai:image 拒绝结构化入口。两条入口不可能有
过滤差异:任何 Value 序列化出来都不会命中 decode_json_data_line 的 empty /
":" / "event:" / [DONE] 四个过滤条件。

turn.rs 里三个既有的 WS 观测测试改走结构化入口;SSE 形状的断言留在 capture 一侧。
验收:crates/aether-usage 零 diff。
2026-08-17 14:53:33 +08:00
AAEE86 59e27524da refactor(gateway): extract transport-neutral execution attempt lifecycle
评审第 4 条:responses/turn.rs 实际复制了一整套 HTTP execution lifecycle——
usage 写入、candidate 状态流转、health/adaptive 效果投射、pool key lease 释放、
body capture、账单失败判定,与 HTTP 的顺序和超时语义只能靠人工对齐。

新增 execution_runtime/attempt_lifecycle.rs,把一次 provider attempt 的记账收成
transport 中立的三段:

  ExecutionAttemptLifecycle::begin        pending usage 行 + Pending candidate
  ExecutionAttemptLifecycle::mark_started usage stream_started + Streaming candidate(幂等)
  ExecutionAttemptLifecycle::settle       终态四段,顺序不可重排:
                                            1 usage terminal(detachable,不可丢)
                                            2 candidate terminal
                                            3 provider 效果 + 超时兜底释放 lease
                                            4 execution report(作废账单不提交)

顺序、5s 分段超时常量、detachable 语义、「每个效果分支都释放 lease」「作废账单
一律不提交 report」这些不变量全部保持原样。

一并上移的辅助设施:
- AttemptStageGuard 取代 await_websocket_lifecycle_stage /
  await_detachable_lifecycle_stage,把「等多久」参数化:WS 用 Bounded(5s),
  HTTP 接线时用 Unbounded 即保持它现在的语义。
- AttemptBodyCapture 取代 append_capture / encode_stream_capture,把
  「缓冲 + 截断标志」两个字段收成一个类型(WS 侧四个字段变两个)。捕获内容
  仍然是 SSE 形状:usage runtime 按 data: 行解析被捕获的 body 来判定
  StreamCapturedTerminalState,换成结构化 JSON 会让终态判定恒为 Missing。
- C2/C3 的结算表本来就不含任何 WS 类型,随之上移。效果表分支与注释逐字未改,
  仅按新位置改名为 AttemptProviderEffect / classify_attempt_provider_effect。
  responses/settlement.rs 只保留 WS 专属的一件事:把 relay loop 的结算信号
  ResponsesWebSocketTurnOutcome 翻译成两个正交事实。

ResponsesProviderAttempt 现在只持有 WS 专有状态:lifecycle 句柄、deadline、
终态观测器、两侧 capture、准入、provider/delivery 事实。plan / trace_id /
report_kind / report_context / candidate 起始时间戳都归 lifecycle。

HTTP 侧不接线:execution_runtime/stream/execution.rs 的
DirectPassthroughFinalizerCore(38 字段)与 failover / oauth 重试 / prefetch 深度
纠缠,无法在「行为等价 + 单 commit 可验证」的前提下改动。逐调用点映射表写在
模块文档注释里作为后续 PR 的接线依据。验收:git diff 对
execution_runtime/stream/ 与 crates/aether-usage 均为零 diff。

新增 6 个测试:效果段超时后仍走兜底 lease 释放、Unbounded 会一直等、detachable
写入在调用方停止等待后仍跑完、settle 四段顺序(计数器替身)、body capture 的
SSE 形状与编码状态(并显式记录默认上限是 usize::MAX,截断分支不可达)、
candidate error_type 映射。
2026-08-17 14:53:25 +08:00
AAEE86 247e7105a2 fix(ws): bill a provider-reached terminal even when client delivery fails
评审第 5 条后半:provider 终态已经到达、只是 gateway 写客户端 socket 失败时,
relay loop 用 client_disconnected() 覆盖了结算信号,于是一条供应商已经完成推理
并消耗了 token 的响应被记成 void billing、candidate 记 Cancelled、不投射供应商
效果、也不提交 execution report。上游成本凭空消失。

结算表只改一行:作废账单的条件从
    provider.cancelled_by_provider() || delivery.is_aborted()
收紧为
    provider.cancelled_by_provider() || (delivery.is_aborted() && !provider.is_terminal())

于是 Terminal{cancelled=false} + delivery Aborted 与 delivery Complete 落在同一侧:
Billed、candidate Success 或 Failed、投射供应商效果、提交 execution report。
状态码随之变成纯 provider 事实(不再把 200 改写成 499);作废分支的 provider
状态码本身就是 499,取值不变。

依据:供应商已经完成推理并消耗 token,客户端还能用 previous_response_id 续取
这条响应。供应商没给出终态时(客户端先走了)仍然作废,这一侧未改。

配套改动:
- connection.rs 写客户端失败处改为 record_client_delivery_aborted(reason) +
  settle_signal_for_client_delivery_failure(terminal_outcome):provider 终态已到达
  就用那条终态作结算信号,不再无条件覆盖。投递失败原因也不再谎称
  「客户端在终态前断开」。
- 投递结果记在 attempt 上而非 logical turn 上:结算按 attempt 进行,且配额透明
  重试时各 attempt 的投递结果彼此独立。
- report_context 新增 websocket_client_delivery="aborted" 与
  websocket_client_delivery_reason,只增字段不改既有字段,便于事后区分
  「客户端拿到了」和「客户端没拿到但已计费」。
- candidate error_type 新增 client_delivery_failed(原先这个场景写的是
  websocket_cancelled)。它排在供应商侧分类之前:这条记录之所以特别正是因为
  内容没送到客户端,供应商侧判定仍由 candidate_status 与 error_message 保留。
- finish_summary 改用作废判定而非「投递失败」判定:provider 终态已到达时摘要
  必须保留真实的 finish_reason 与 usage,否则计费记录会被写坏。

e2e 期望值变化:client_disconnect_mid_turn_still_settles_the_usage_row 改名为
client_disconnect_before_any_provider_output_settles_a_void_row,并补上
「不计费 + status=cancelled + status_code=499」的断言。原用例的 mock 行为是
StallAfterCreated(只发 response.created 就静默),provider 从未给出终态,所以
它走的是未改动的作废一侧;原来的文档注释说「must still be billed」与实际语义
不符,一并纠正。真正被修正的那一行无法在 e2e 里确定性触发——它取决于 relay
loop 的 select! 先观察到上游终态帧还是先观察到已关闭的客户端 socket,是构造性
竞态——因此由 relay 级单测确定性覆盖,e2e 里以注释指向这两个单测。

新增 7 个测试:结算表修正行(并与「投递成功」逐字段对照,只有 candidate 错误
分类不同)、无终态时仍作废、供应商声明取消即使送达也不计费、结算信号选择、
已记录的投递失败不被结算信号覆盖、relay 级「终态到达 + 客户端已关闭 ⇒ Billed /
Success / ProviderSuccess / 已提交 report 且 usage 完整保留」及其镜像、
report_context 只增不改。
2026-08-17 14:53:12 +08:00
AAEE86 dc3743aecf refactor(ws): 拆分 LogicalTurn 与 ProviderAttempt,结算改表驱动
评审第 5 条:一个 ResponsesWebSocketTurn 同时代表 logical turn 和 provider
attempt,finalize() 又用 outcome.cancelled() 一个布尔驱动 billing、candidate
状态和供应商效果,于是 provider 终态已经到达、只是最后一跳写客户端失败时,
供应商事实会被 Cancelled 覆盖掉。

- ResponsesWebSocketTurn → ResponsesProviderAttempt,
  ActiveResponsesWebSocketTurn → ActiveProviderAttempt:类型名字明确它只代表
  一次上游执行,logical turn 由 C1 落地的 LogicalTurn 承担。
- 新增 settlement.rs:AttemptProviderOutcome × AttemptClientDelivery 两个正交
  事实,classify_attempt_settlement 一张表推出 status_code / billing /
  candidate 状态 / candidate 错误分类 / 供应商效果 / 是否提交 execution report。
- attempt 观察到 provider 终态即记录 provider_outcome。结算信号
  ResponsesWebSocketTurnOutcome 只回答「为什么现在结算」:ProviderTerminal 与
  Failure 对 provider 是权威的,Cancelled 只描述客户端/连接层面的停止,不再
  覆盖已观察到的 provider 事实。
- candidate 状态与 candidate 错误分类分开输出:现状存在
  「missing_terminal=true 而记账层判 Success」的组合(report kind 不要求观察到
  终态事件时),会写出 status=Success + error_type=stream_missing_terminal_event,
  这个组合必须原样保留。

classify_responses_websocket_turn_effect 的判定表原样搬入 settlement.rs,分支
和顺序均未改动,两个既有不变量测试随之迁移。

行为等价。结算表当前口径与拆分前完全一致:客户端投递失败仍与「供应商声明取消」
落在同一侧(作废账单、candidate 记 Cancelled、只释放 lease、不提交 execution
report),即使 provider 终态已经到达——这一行由
settlement_table_row_client_delivery_failure_currently_voids_a_reached_terminal
锁住现状,修正它是下一步独立的行为修正。

新增 15 个测试:outcome → 双事实映射表逐行(含 stream_timeout 只在 504 失败一族
成立、provider 终态即使 504 也不投射流式超时)、结算表逐行、投递失败时
forced_error 必须为 None、已观察终态不被 Cancelled 覆盖、以及跨整张表的
「每个分支都释放 pool key lease」「作废账单一律不提交 report」不变量。
2026-08-17 14:53:05 +08:00
AAEE86 1c5ee5228c refactor(ws): 用 ResponsesTurnState 收敛连接 turn 状态
评审第 2 条:BoundResponsesConnection 用 response_in_flight、active_turn、
active_response_create 三个可独立变化的字段编码同一件事,8 种组合里只有 3 种
合法,非法组合只能靠调用点的 if 和「记得同时改另外两个字段」来避免。

三字段合并为一个 ResponsesTurnState:

  Idle                                  没有进行中的 logical turn
  Responding { logical, attempt }        logical 与 attempt 必须同时存在
  Replanning { logical }                 attempt 已取走去结算/重绑,logical 仍在

Replanning 不是新概念:配额透明重试期间现状就处于这个状态,只是靠
Option::take 意外得到。转换只能走 begin / detach_attempt / resume / end,
response_in_flight 与「是否接受新 response.create」都由变体推导。

由此消除的运行时不变量(原来全靠调用点自觉):
- 有 attempt 必有 logical turn
- response_in_flight 与 attempt 同生共死(原来 client 写失败后
  active_turn=None 而 response_in_flight 仍为 true)
- logical turn 结束时必须清 attempt:原来 `active_response_create = None`
  在 connection.rs 里手写 13 处,漏一处就残留;现在只有 end() 一个出口
- 上游绑定返回的连接不再自带 response_in_flight=true 的半成品状态

同时删除 update_response_in_flight:Started 帧把已经是 true 的字段再设一次,
Close 帧因为没有解析出的 frame 而根本不触发,是纯冗余写;它在 Idle 态收到
Started 帧时还会把 response_in_flight 置真,从而永久阻塞后续 response.create。

行为等价。ActiveResponsesWebSocketRequest 改名 LogicalTurn 并随状态机移入
新的 turn_state.rs;状态机对 attempt 类型泛型化,测试用轻量替身驱动同一套
转换逻辑,无需 AppState 或真实 socket。
2026-08-17 14:52:57 +08:00
AAEE86 9d80281b53 fix(ws): route WS planning and continuation through PII redaction 2026-08-17 14:52:49 +08:00
AAEE86 3b036299d4 fix(ws): enforce absolute upstream handshake and initial-message deadlines 2026-08-17 14:52:39 +08:00
AAEE86 f70ae68273 fix(ws): treat max_output_tokens incomplete as legitimate terminal 2026-08-17 14:52:33 +08:00
AAEE86 1353d76e07 feat(frontend): Responses WebSocket 配置与用量展示
provider 表单支持开启 Responses WebSocket;用量列表、状态与详情
区分 WebSocket 请求。
2026-08-17 14:52:25 +08:00
AAEE86 621a528083 test(ws): Responses WebSocket 端到端套件接入 CI
补齐 aether-integration-tests 的 responses_websocket_e2e 集成测试,
并把 CI 的 scenario 任务从 --bins 改为 --bins --tests,否则该套件
不会被执行。
2026-08-17 14:51:24 +08:00
AAEE86 a498875591 feat(gateway): Responses WebSocket 连通性探针
新增 aether-codex-ws-probe 与 aether-openai-responses-ws-probe 两个
二进制,用于在不暴露凭据的前提下验证上游 WebSocket 端点可用性:凭据
只从环境变量读取,不写入日志。公共流程放在
bin/support/responses_ws_probe.rs,各 profile 只负责自己的鉴权与
请求头要求。
2026-08-17 14:51:18 +08:00
AAEE86 71b54070e8 feat(gateway): Codex/OpenAI Responses WebSocket 代理模式
在 /v1/responses 上支持 WebSocket 升级,把客户端帧中继到上游 Codex /
OpenAI Responses WebSocket 端点,同时保持既有的路由、鉴权、配额与用量
语义:

- 路由与准入:control/route/ai.rs 识别 WebSocket 升级请求;
  websocket/ingress.rs 复用 API Key 鉴权、IP 规则与并发许可,并引入
  独立的 WebSocket 连接许可
- 中继:websocket/responses/* 按 connection / session / turn 分层,
  帧解析归一化、socket 写入有界、continuation 保持调度亲和性
- 配额:orchestration/codex_quota_breaker.rs 在账号配额耗尽时熔断并
  自动恢复,不再直接断开客户端连接
- 用量:每个 turn 的终态用量落库,request_metadata 记录
  websocket_mode / websocket_transport,管理端与 usage 视图暴露
  is_websocket
- 管理端:provider 可配置 Responses WebSocket 开关
2026-08-17 14:50:33 +08:00
ZheFox 9a0d346ff3 Merge pull request #728 from zhefox/main
fix(gateway): stop candidate persistence retry storms
2026-08-17 14:28:11 +08:00
ZheFox 32944538e9 fix(gateway): stop candidate persistence retry storms 2026-08-17 13:49:12 +08:00
ZheFox 0b17026eab Merge pull request #726 from zhefox/main
fix(codex): restore upstream model discovery
2026-08-15 20:16:12 +08:00
ZheFox b13d9b9b40 fix(codex): restore upstream model discovery 2026-08-15 19:36:28 +08:00
ZheFox b7fca851b8 Merge pull request #724 from zhefox/main
fix(codex): serve versioned dynamic model catalogs
2026-08-14 19:08:53 +08:00
zhefox 810c3dfe2b fix(codex): serve versioned dynamic model catalogs 2026-08-14 18:41:44 +08:00
elky a1d64e5239 fix(routing): preserve allowlist edits and save state 2026-08-14 11:43:24 +08:00
elky fb33ea57b0 Merge PR #715: decouple routing model overrides 2026-08-14 11:10:21 +08:00
elky 5b0c763086 fix(codex): fence concurrent quota updates 2026-08-14 09:28:07 +08:00
elky f3a12c1008 fix(ai): preserve Codex image edit validation 2026-08-13 11:31:17 +08:00
elky ca35e09eaa Merge pull request #718 from zjm54321/fix/custom-image-edit-json 2026-08-13 11:00:15 +08:00
elky 8cf381b0c3 feat(codex): add OAuth fingerprint convergence 2026-08-13 09:57:17 +08:00
elky 654c4f6978 fix(auth): preserve turnstile while typing email 2026-08-12 16:56:18 +08:00
elky edb8362adc fix(provider): omit default model test temperature 2026-08-12 16:56:18 +08:00
elky 29fa4aed19 perf(gateway): raise default server pool floor 2026-08-12 16:56:18 +08:00
zjm54321 41e93858e1 refactor(ai): simplify image edit serialization 2026-08-11 00:36:33 +08:00
zjm54321 8d918d0459 fix(ai): serialize image edits with images array 2026-08-11 00:30:00 +08:00
ZheFox 3a759fae89 Merge pull request #712 from zhefox/fix/claude-code-conversion-api-key-toggle
fix: restore Claude Code conversion and user API key toggles
2026-08-05 14:53:03 +08:00
zhefox 985ff3c36a test(gateway): align claude_code endpoint reconciliation 2026-08-05 14:34:20 +08:00
zhefox 908d4f2603 style(provider): apply rustfmt to claude_code tests 2026-08-05 13:58:46 +08:00
zhefox 4d67569873 fix(gateway): support claude_code cross-format Claude messages 2026-08-05 13:57:20 +08:00
ZheFox 1aab31a148 Merge pull request #710 from zhefox/main
fix: align Responses compatibility, routing, and model permissions
2026-08-03 19:31:04 +08:00
zhefox aedff9a704 fix(provider): validate mapped model reasoning effort 2026-08-03 19:22:51 +08:00
zhefox 669f4bddc5 fix: align Responses routing and model permissions 2026-08-03 18:48:01 +08:00
zbs 1a4eede34d fix(routing): decouple model overrides from allowed scope 2026-08-03 07:52:49 +08:00
elky 0318808db9 fix(providers): allow transfer limits on creation 2026-07-31 13:35:08 +08:00
elky 06f5d3c8c0 fix(gateway): complete worker registration cleanup 2026-07-31 11:32:07 +08:00
elky 082407fa51 Merge PR #697: prevent duplicate worker registrations 2026-07-31 11:11:14 +08:00
fawney19 6688ee26db Merge pull request #702 from MMEXA/fix/reconcile-auth-channel-mismatch-formats
fix(gateway): 修复批量更新 API 格式时的认证通道状态冲突
2026-07-31 10:28:37 +08:00
elky beb003b7ad feat(models): add external catalog proxy selection 2026-07-31 09:32:25 +08:00
MMEXA 6ecfe0f0a1 fix(gateway): reconcile auth mismatch formats on key update 2026-07-30 22:14:08 +08:00
ZheFox 12057db476 Merge pull request #701 from zhefox/main
Persist OpenAI Responses continuation history across instances
2026-07-30 21:08:34 +08:00
ZheFox ff47d8d48a fix(gateway): route response history through ai seam 2026-07-30 20:34:41 +08:00
ZheFox ef5f36cc2b fix(ai): satisfy response history clippy checks 2026-07-30 20:13:36 +08:00
ZheFox 84022c4d48 Merge upstream/main into main 2026-07-30 19:40:39 +08:00
ZheFox 118f441029 feat(gateway): persist OpenAI Responses continuation history 2026-07-30 19:26:52 +08:00
elky 20399b004d Merge PR #700: fix admin pool batch update body buffering
Preserve main's failover and usage metadata fixes, restore default tunnel regression coverage, and satisfy current Clippy.
2026-07-30 17:56:37 +08:00
elky 050eb77508 fix(ai): harden responses replay and failure diagnostics 2026-07-30 17:19:54 +08:00
elky 1ab4f079c9 fix(gateway): restore failover and usage diagnostics 2026-07-30 09:12:11 +08:00
MMEXA 6c733f7590 fix(usage): preserve request diagnostics in event seeds 2026-07-30 06:44:59 +08:00
MMEXA d7d8db45ba test(gateway): align tunnel error fixture with failover policy 2026-07-30 06:44:59 +08:00
MMEXA 8cf9af79da fix(ci): remove redundant usage policy update 2026-07-30 05:45:38 +08:00
MMEXA e55793c765 fix(ci): satisfy gateway clippy on upstream baseline 2026-07-30 05:14:34 +08:00
MMEXA d8902ea612 fix(gateway): buffer admin pool batch update bodies 2026-07-30 05:14:34 +08:00
elky a04673a90d feat(gateway): harden failover and payload handling
Retry pre-response transport failures across candidates with an explicit stop policy, and propagate end-to-end timing into usage records and UI diagnostics.

Remove legacy body, import, cookie, PII, and tunnel replay caps while preserving optional operator-configured gateway limits.
2026-07-30 01:03:27 +08:00
ZheFox a97acc07fc Merge pull request #698 from zhefox/main
fix(ci): stabilize cross-platform workflow checks
2026-07-29 22:16:17 +08:00
zhefox f8000012f7 fix(ci): stabilize cross-platform workflow checks 2026-07-29 21:55:43 +08:00
worker-2 6080f8cc88 fix(gateway): stabilize worker task records
Key worker boot records by task so process restarts update the existing
row instead of registering another row for each gateway instance.

Closes #693
Confidence: high
Scope-risk: narrow
2026-07-29 17:27:51 +08:00
ZheFox 37df5b93b1 Merge pull request #696 from zhefox/main
Fix client metadata handling across formats
2026-07-28 18:12:59 +08:00
ZheFox e53abdaec2 Merge branch 'fawney19:main' into main 2026-07-28 18:12:28 +08:00
ZheFox 2db32ea97e Merge branch 'main' of https://github.com/zhefox/Aether 2026-07-28 17:40:46 +08:00
ZheFox 581897ee74 fix(formats): ignore responses client metadata across targets 2026-07-28 17:40:41 +08:00
ZheFox 9a88f966d8 Merge pull request #695 from zhefox/main
Enhance provider capabilities and clean up OAuth keys
2026-07-28 13:58:33 +08:00
ZheFox 9d9316e434 Merge branch 'fawney19:main' into main 2026-07-28 13:56:34 +08:00
ZheFox 1b697b1111 feat(providers): support FedRAMP Codex agent identity registration 2026-07-28 13:29:30 +08:00
ZheFox 3043982486 fix(providers): derive Codex primary quota label from window 2026-07-28 12:57:45 +08:00
ZheFox 0bf92ffffc feat(providers): advertise responses API agent capability 2026-07-28 12:02:03 +08:00
ZheFox f0f87b56a3 feat(providers): add credential-fenced OAuth key cleanup 2026-07-28 11:32:11 +08:00
elky 4148ab1931 fix(routing): harden routed pool scheduling 2026-07-27 22:06:28 +08:00
elky 550cc36760 feat(providers): expand OAuth account management
Add Claude Code manual and cookie authorization, including redacted batch tasks. Harden OAuth imports, duplicate replacement, provider dialogs, and related account-management tests.
2026-07-27 15:53:28 +08:00
elky 531cf11025 feat(gateway): harden provider request execution
Preserve exact request payloads and model client surface and API operation explicitly.

Add Anthropic compatibility profiles, bounded stream commitment, and scoped OAuth retry behavior across provider transports.
2026-07-27 09:36:31 +08:00
elky 79b70f7b5c fix(frontend): align sidebar collapse button 2026-07-26 15:07:51 +08:00
elky 10d369f59c feat(providers): add provider transfer limits 2026-07-26 15:06:56 +08:00
elky 2ef7ac79bc feat(frontend): add collapsible navigation sidebar
Persist the desktop sidebar state, provide accessible compact navigation tooltips, and cover the collapsed navigation markup with a focused component test.
2026-07-25 21:28:51 +08:00
elky 778cfb1a5c feat(data): complete portable SQL backend parity
Align MySQL and SQLite schemas, migrations, usage, stats, export, and backfill behavior with the shared data contracts. Extend gateway startup and maintenance support across all SQL drivers.
2026-07-25 21:28:21 +08:00
elky 764e9fd131 feat(frontend): improve provider detail drawer and pool actions 2026-07-25 11:16:59 +08:00
elky 387134ca87 fix(models): correct fast pricing and online sync 2026-07-24 01:45:38 +08:00
elky a0767d957c fix(frontend): synchronize pool account state 2026-07-23 16:20:18 +08:00
ZheFox b94ef91d07 Merge pull request #692 from zhefox/main
Sync global model prices and track online pricing sources
2026-07-23 16:02:17 +08:00
ZheFox e7910751d9 Merge branch 'fawney19:main' into main 2026-07-23 15:19:48 +08:00
ZheFox 1d2655432d feat(models): track online pricing sources and unsupported fields 2026-07-23 15:18:08 +08:00
ZheFox 323273ff30 feat(models): sync global model prices from online catalog 2026-07-23 13:29:28 +08:00
ZheFox fb2009c65b Merge pull request #691 from zhefox/main
fix(formats): ignore Responses client transport metadata
2026-07-23 12:18:40 +08:00
ZheFox e186cc6848 Merge branch 'main' of https://github.com/zhefox/Aether 2026-07-23 12:17:46 +08:00
ZheFox 615ac99ad7 fix(formats): ignore Responses client transport metadata 2026-07-23 12:16:44 +08:00
ZheFox ec36cfbf75 Merge pull request #690 from zhefox/main
fix(provider): classify deleted Codex agent runtime as invalid
2026-07-23 11:22:30 +08:00
ZheFox 7bf228a33c fix(provider): classify deleted Codex agent runtime as invalid 2026-07-23 11:21:55 +08:00
elky 3606290ac8 fix(provider): harden Agent Identity OAuth lifecycle 2026-07-23 09:33:00 +08:00
elky e49024d33b fix(frontend): shorten Agent Identity tab label 2026-07-22 20:26:49 +08:00
elky fdbc2607ec feat(provider): add dedicated Codex Agent Identity flow 2026-07-22 20:19:29 +08:00
elky c7cc8fd7db test(provider): simplify agent identity assertions 2026-07-22 14:19:58 +08:00
elky 07efcb5146 fix(data): repair legacy active flag synchronization 2026-07-22 14:19:34 +08:00
elky 856605defa fix(model-directives): harden suffix configuration 2026-07-22 14:19:09 +08:00
elky 713010fa0a fix(gateway): restore auth role refresh and Rust checks
Refresh the resolved user role without bypassing owner group and key policies. Resolve Rust 1.95 Clippy failures and make the pending persistence bound test scheduler-independent.
2026-07-22 11:25:24 +08:00
ZheFox cd2fbeeead Merge pull request #689 from AAEE86/feat/agent-identity-support
feat(codex): enroll agent identity from session token
2026-07-22 10:32:06 +08:00
AAEE86 a4350a482a feat(codex): enroll agent identity from session token 2026-07-22 10:21:11 +08:00
ZheFox c825375367 Merge pull request #688 from AAEE86/feat/agent-identity-support
feat(codex): support agent identity accounts
2026-07-22 09:20:11 +08:00
elky fc92c4f431 perf(gateway): scale request hot paths for 20k streams
Shard and singleflight hot-path caches, batch and prioritize candidate and usage lifecycle persistence, and extend database and pressure-test instrumentation for 20k concurrent streams.
2026-07-22 02:11:08 +08:00
AAEE86 b61c590bdb feat(codex): support agent identity accounts 2026-07-21 20:58:49 +08:00
ZheFox 7756c0913f Merge pull request #685 from zhefox/main
fix(gateway): apply group policy to admin-owned keys
2026-07-20 15:52:06 +08:00
ZheFox c34ec7c1ee fix(gateway): apply group policy to admin-owned keys 2026-07-20 15:51:01 +08:00
elky f8778c4a23 feat(gateway): configure cyber policy failover 2026-07-19 23:27:19 +08:00
elky e0dbb233f7 fix(frontend): avoid misleading cache TTL fallback label 2026-07-19 22:21:46 +08:00
elky 9725f9abae fix(frontend): clarify processing tier pricing 2026-07-19 22:00:12 +08:00
elky 5d575f1590 test(stats): treat bulk API key snapshots as authoritative 2026-07-19 19:07:04 +08:00
elky d562c594c3 fix(frontend): preserve compact scope and detail badge 2026-07-19 16:42:32 +08:00
MMEXA ce226a3010 Merge 0c3f51bcec into 644ae9c1bf 2026-07-19 16:12:37 +08:00
elky 644ae9c1bf feat(pool): add table-driven account batch actions 2026-07-19 16:09:20 +08:00
elky 95053f9502 Merge PR #672: 支持账号批量配置与可用模型管理 2026-07-18 22:06:32 +08:00
elky 8fbda84acb fix(data): preserve API key history end to end 2026-07-18 21:58:21 +08:00
elky 03b7d573e0 Merge PR #683: decouple API key historical identity 2026-07-18 21:20:35 +08:00
MMEXA 0c3f51bcec merge(main): 解决 usage 模型展示契约冲突 2026-07-18 19:26:14 +08:00
elky e3d97b573b fix(usage): align fast-tier pricing and model metadata 2026-07-18 16:57:04 +08:00
MMEXA ac3796af84 fix(gateway): 恢复响应边界并统一格式入口 2026-07-18 06:45:37 +08:00
MMEXA f9c343eb07 fix(gateway): 适配 Rust 1.95 整除检查 2026-07-18 05:55:06 +08:00
MMEXA e31df5989a merge(main): 解决 usage 展示与生命周期同步冲突 2026-07-18 05:38:38 +08:00
MMEXA 98fbf029fc fix(data): 解耦 API Key 历史统计身份 2026-07-18 04:42:45 +08:00
MMEXA 4d9a648202 test(gateway): 统一流错误测试的格式层入口 2026-07-18 03:37:31 +08:00
MMEXA 405ca3e66a fix(ci): 恢复非流式错误体边界并适配新版 Clippy 2026-07-18 03:26:53 +08:00
MMEXA 0355c28683 fix(data): 解耦候选记录的 API Key 历史身份 2026-07-18 02:40:34 +08:00
fawney19 6c33b8d8fb Merge pull request #682 from MMEXA/codex/codex-prompt-cache-identity-20260717
fix(codex): 统一通用缓存键与原生会话身份
2026-07-18 00:33:20 +08:00
elky a6c6f14b09 style(frontend): align pool cycle stats values 2026-07-18 00:13:35 +08:00
elky e558f55cd9 style(frontend): refine badges and cycle stats 2026-07-18 00:05:22 +08:00
elky 88a057b8d9 fix(usage): force fast badge background transparent 2026-07-17 22:56:54 +08:00
elky ed27d404ac style(usage): make fast badge background transparent 2026-07-17 22:52:47 +08:00
elky 5dda34c66e style(usage): give fast tier an amber accent 2026-07-17 22:36:55 +08:00
elky 373ebf26d6 fix(pricing): default zero tier ratios to one 2026-07-17 21:33:21 +08:00
elky f65ed2795c fix(codex): support dynamic quota windows 2026-07-17 20:18:04 +08:00
elky 664c063a06 feat(usage): enrich audit metadata and detail views 2026-07-17 19:20:16 +08:00
MMEXA 75795c6fbc test(codex): 对齐 Compact 确定性缓存身份 2026-07-17 08:49:35 +08:00
MMEXA 5b332da7d7 fix(codex): 补齐缓存身份终态请求头 2026-07-17 08:11:16 +08:00
MMEXA d9796d502b fix(codex): 统一通用缓存键与原生会话身份 2026-07-17 06:13:09 +08:00
MMEXA 3b0d87b0fd Merge remote-tracking branch 'origin/main' into codex/pool-key-bulk-management-20260714 2026-07-17 00:17:02 +08:00
MMEXA 3c348dff3a Merge remote-tracking branch 'origin/main' into codex/usage-pending-reasoning-reset-expiry-20260712
# Conflicts:
#	frontend/src/features/usage/components/__tests__/UsageRecordsTable.spec.ts
2026-07-17 00:16:58 +08:00
MMEXA ec1783a35c Merge remote-tracking branch 'origin/main' into codex/pool-key-bulk-management-20260714
# Conflicts:
#	apps/aether-gateway/src/handlers/admin/request/provider/tasks.rs
#	frontend/src/api/endpoints/pool.ts
2026-07-16 23:43:04 +08:00
MMEXA 427030c5de Merge remote-tracking branch 'origin/main' into codex/usage-pending-reasoning-reset-expiry-20260712
# Conflicts:
#	crates/aether-ai-formats/src/formats/openai/responses/mod.rs
#	crates/aether-usage/runtime/src/runtime.rs
#	frontend/src/features/usage/components/UsageRecordsTable.vue
#	frontend/src/features/usage/components/__tests__/UsageRecordsTable.spec.ts
2026-07-16 23:41:58 +08:00
elky 0be380243b feat(pricing): support processing tier multipliers 2026-07-16 23:30:42 +08:00
fawney19 312583f055 Merge pull request #680 from Kayphoon/codex/s3-backup-user-agent
feat(admin): configure S3 backup User-Agent
2026-07-16 23:30:30 +08:00
fawney19 33f49ea9b0 Merge pull request #678 from AAEE86/fix
fix: map Developer role to "system" in OpenAI Chat Completions output
2026-07-16 23:29:55 +08:00
fawney19 470cef17cf Merge pull request #676 from MMEXA/codex/sync-capture-envelope-finalize-20260716
修复同步 finalize 的 Responses 流聚合与转换
2026-07-16 23:29:38 +08:00
ZheFox 3f5f65eb9a Merge pull request #681 from zhefox/main
Codex 重置功能和显示缓存修复以及批量key的导入和管理功能
2026-07-16 19:48:33 +08:00
ZheFox 6664c2dbb8 feat(pool): 支持批量导入 Key 和选择性更新设置 2026-07-16 19:31:07 +08:00
ZheFox 0099167a6d fix(codex): 避免重置机会缺失触发配额刷新 2026-07-16 19:13:09 +08:00
ZheFox f009fb73c3 缓存问题修复 2026-07-16 18:55:28 +08:00
ZheFox 715a5ed626 修复重置次数缓存问题 2026-07-16 18:09:10 +08:00
ZheFox 5cf38d1b35 Codex 重置功能和显示修复 2026-07-16 17:23:41 +08:00
Kayphoon 6b707f29a2 feat(admin): configure S3 backup User-Agent 2026-07-16 08:56:56 +00:00
elky 9ea84f9748 fix(frontend): show service tier transitions 2026-07-16 16:38:30 +08:00
elky c32d043afb fix(frontend): preserve fetched model preset pricing 2026-07-16 16:38:30 +08:00
elky 8fe4d24408 fix(usage): canonicalize cached token totals 2026-07-16 16:38:30 +08:00
elky e369e4aab1 fix(formats): preserve chat-backed Responses metadata 2026-07-16 16:38:30 +08:00
ZheFox 7dc919e8e3 Merge pull request #679 from zhefox/main
fix(frontend): 优化移动端弹窗并完善提供商配额刷新
2026-07-16 15:48:49 +08:00
ZheFox 1333efdad5 fix(frontend): 优化移动端弹窗并完善提供商配额刷新 2026-07-16 15:21:11 +08:00
AAEE86 cd8de1aa13 fix: map Developer role to "system" in OpenAI Chat Completions output 2026-07-16 14:29:08 +08:00
elky d6215d9dec ci(tunnel): reduce artifact retention 2026-07-16 13:12:30 +08:00
elky 9a47267545 fix(usage): bound terminal event persistence
Add end-to-end terminal admission, bounded database fallback, and observable overload handling. Preserve first-byte lifecycle state across asynchronous runtime and frontend updates.
2026-07-16 13:12:30 +08:00
MMEXA 7851503fbc fix(finalize): 严格聚合并投影同步 Responses 流 2026-07-16 12:50:00 +08:00
ZheFox c6d373e6aa Merge pull request #677 from zhefox/main
fix(codex): 移除 Responses Lite 请求中的 context_management
2026-07-16 12:28:34 +08:00
ZheFox 71fcb9c168 fix(codex): 服务端压缩使用标准 Responses 合约 2026-07-16 12:02:33 +08:00
ZheFox 3976652942 fix(codex): 移除 Responses Lite 请求中的 context_management 2026-07-16 11:25:11 +08:00
MMEXA 7b56546e21 fix(finalize): 聚合同步流捕获包装 2026-07-16 10:24:32 +08:00
fawney19 85854e4476 Merge pull request #675 from fawney19/fix/pr-669-tail
feat(codex): complete PR #669 protocol follow-up
2026-07-16 08:54:29 +08:00
elky b50242ab9f fix(test): handle absent empty testkit bin directory 2026-07-16 01:29:51 +08:00
MMEXA 20b27a13b2 feat(codex): 按操作语义路由 Responses V2 压缩
(cherry picked from commit 2fc604e047)
2026-07-16 00:34:42 +08:00
MMEXA 598b2fb374 fix(auth): 授权 Responses Compact 伴随端点
(cherry picked from commit e8afa03e45)
2026-07-16 00:32:14 +08:00
MMEXA ff7988430d fix(openai): encode tool errors in Responses output
(cherry picked from commit f127b67e73)
2026-07-16 00:31:23 +08:00
MMEXA 25da99fac2 fix(codex): preserve reset consume request body
(cherry picked from commit fc2dfb82d2)
2026-07-16 00:27:28 +08:00
elky 8616fe6ee2 refactor(workspace): enforce layered crate boundaries 2026-07-15 23:47:19 +08:00
MMEXA 9b8724453b test(pool): 使用正式 Gemini API 格式 2026-07-14 08:39:41 +08:00
MMEXA 01e104d86a fix(pool): 对齐账号批量配置语义 2026-07-14 08:07:28 +08:00
MMEXA 0acd1de29c fix(gateway): 保持密钥更新模块显式所有权 2026-07-14 05:19:04 +08:00
MMEXA a25fab371a feat(pool): add bulk key configuration management 2026-07-14 04:57:05 +08:00
MMEXA 93e2f95c47 fix(frontend): 按端点能力约束会话压缩映射 2026-07-14 02:05:10 +08:00
MMEXA f10d631a9c feat(frontend): 澄清模型映射适用范围 2026-07-14 00:30:39 +08:00
MMEXA cfc4894dab fix(usage): 保留最新进行态生命周期事件 2026-07-14 00:30:24 +08:00
MMEXA 3f86fdd6bc feat(usage): 展示压缩操作与进行态请求语义 2026-07-13 22:03:44 +08:00
MMEXA b09d1f1c33 fix(usage): expose pending reasoning and exact reset expiry 2026-07-13 22:03:44 +08:00
MMEXA 2fc604e047 feat(codex): 按操作语义路由 Responses V2 压缩 2026-07-13 22:03:34 +08:00
MMEXA e8afa03e45 fix(auth): 授权 Responses Compact 伴随端点 2026-07-13 06:07:54 +08:00
MMEXA fc2dfb82d2 fix(codex): preserve reset consume request body 2026-07-12 23:04:33 +08:00
elky a728c090a9 fix(gateway): scope concurrency helper to tests 2026-07-12 22:36:48 +08:00
elky e58621a735 Merge PR #669: align GPT-5.6 and Codex request protocols 2026-07-12 21:50:20 +08:00
MMEXA b1be370b2e fix(gateway): scope concurrency test helper to tests 2026-07-12 21:08:30 +08:00
MMEXA cf0d957ac7 Merge f127b67e73 into 7f61bb43c7 2026-07-12 20:34:39 +08:00
MMEXA f127b67e73 fix(openai): encode tool errors in Responses output 2026-07-12 20:34:31 +08:00
elky 7f61bb43c7 feat(security): harden gateway request and runtime controls 2026-07-12 14:10:54 +08:00
MMEXA 25c49dd804 fix(data): keep terminal usage state monotonic 2026-07-12 05:45:37 +08:00
MMEXA 72222d935c test(gateway): use valid tunnel relay envelopes 2026-07-12 05:45:32 +08:00
MMEXA 063d517306 test(gateway): compare timeout response numerically 2026-07-12 04:43:13 +08:00
MMEXA 02495ce28e fix(admin): preserve inactive endpoint key counts 2026-07-12 04:19:06 +08:00
MMEXA 63936aa110 fix(gateway): route format rules through serving facade 2026-07-12 03:56:53 +08:00
MMEXA 8d4d42a887 fix(auth): resolve group policy before key intersection 2026-07-12 03:35:42 +08:00
MMEXA 2316df5c9a feat(codex): align Search and execution protocol 2026-07-12 03:04:15 +08:00
MMEXA 59d37ae1dd fix(frontend): import structured models.dev pricing 2026-07-11 18:12:55 +08:00
MMEXA 3014fd50c6 fix(billing): preserve effective cache and tier facts 2026-07-11 18:12:55 +08:00
MMEXA 14c4e3a04e fix(codex): enforce provider request identity 2026-07-11 18:09:14 +08:00
MMEXA 8f1070a451 feat(frontend): expose processing tier pricing 2026-07-11 12:27:09 +08:00
MMEXA 0b30cc6b0f feat(openai): unify tier authorization and settlement 2026-07-11 12:27:05 +08:00
MMEXA b2f596b8f0 fix(gateway): route Codex header through serving facade 2026-07-11 09:43:12 +08:00
MMEXA 01a96fed74 fix(codex): simplify summary normalization 2026-07-11 09:20:07 +08:00
MMEXA 46a903aada fix(codex): align current reasoning request semantics 2026-07-11 09:15:08 +08:00
MMEXA dfa121dd5b feat(openai): align GPT-5.6 and Codex request contracts 2026-07-11 07:40:12 +08:00
elky bc1da3bf3f feat(security): harden client IP and admin controls 2026-07-10 15:13:12 +08:00
elky 6e0dc3b59e feat(frontend): refine global model pricing dialog 2026-07-10 15:13:12 +08:00
elky 4bf5d4c044 Fix cache token accounting and tiered pricing 2026-07-10 15:13:12 +08:00
fawney19 736fc76345 Merge pull request #668 from MMEXA/codex/antigravity-empty-output-retry-20260709
修复 Gemini 空输出按候选重试处理
2026-07-10 09:16:56 +08:00
MMEXA b6b2ca38f4 触发 CI 重跑 2026-07-10 00:41:22 +08:00
MMEXA f07eb25cfc 修复 usage 详情 body 引用解包 2026-07-10 00:23:31 +08:00
MMEXA d2ea437c1c 修复 Gemini 空输出按候选重试处理 2026-07-09 23:10:30 +08:00
fawney19 7bc7d0f8d8 Merge pull request #666 from xixiknow/main
Fix provider key response time counter overflow
2026-07-09 18:04:11 +08:00
fawney19 14cf639aba Merge pull request #667 from MMEXA/codex/antigravity-v1internal-query-20260709
修复 Antigravity v1internal 查询参数透传
2026-07-09 17:50:38 +08:00
yangrs 55cdab592c Remove redundant response time conversion 2026-07-09 16:26:09 +08:00
MMEXA ee0ec18283 修复 Antigravity v1internal 查询参数透传 2026-07-09 16:03:29 +08:00
Start f31c9e03e2 Merge branch 'fawney19:main' into main 2026-07-09 15:11:44 +08:00
fawney19 e50db10439 Merge pull request #663 from MMEXA/codex/gemini-interactions-antigravity-20260705
完善 Gemini Interactions 与 Antigravity 全链路兼容
2026-07-09 14:55:51 +08:00
yangrs 192dc6c20d Fix provider key response time overflow 2026-07-09 14:40:48 +08:00
elky 5e1d14f19b Fix timeline duration display from latency 2026-07-09 11:46:45 +08:00
MMEXA b8b89d21b7 fix: 同步提交本地 sync 错误上报 2026-07-08 23:49:18 +08:00
MMEXA 5eddf4f9ee 细化 Antigravity 测试模型项目元数据补全 2026-07-08 22:45:30 +08:00
MMEXA c7186e1720 完善 Antigravity 配额展示与 CI 断言 2026-07-08 22:34:29 +08:00
MMEXA 4866509938 移除 Antigravity 未知重置时间噪音 2026-07-08 22:34:29 +08:00
MMEXA 2122660a5c 对齐原生 Antigravity 控制面与显示模型 2026-07-08 22:34:29 +08:00
MMEXA 5c68ab896a 恢复历史 backfill 兼容 live 账本 2026-07-08 22:34:29 +08:00
MMEXA f9c8ec41f4 完善 Antigravity 与 Gemini 跨格式兼容 2026-07-08 22:34:29 +08:00
MMEXA b1ed6b24b0 触发 CI 复跑 2026-07-08 22:34:29 +08:00
MMEXA c17c78ad4b 修正 Antigravity Gemini 3.5 Flash 档位展示 2026-07-08 22:34:29 +08:00
MMEXA 9ec48ab6b9 优化 Antigravity 配额展示顺序 2026-07-08 22:34:29 +08:00
MMEXA accd250226 修正 Antigravity 配额模型标签 2026-07-08 22:34:29 +08:00
MMEXA 80a6579766 支持 Gemini Interactions 与 Antigravity 配额精细化 2026-07-08 22:34:29 +08:00
fawney19 1ca83ca3fb Merge pull request #664 from MMEXA/codex/wallet-auth-cache-delay-20260706
修复钱包余额变更后的鉴权缓存延迟
2026-07-07 01:59:44 +08:00
fawney19 a931da0764 Merge pull request #662 from MMEXA/codex/reset-credit-20260704
增加 Codex 重置次数功能
2026-07-07 01:58:44 +08:00
fawney19 a61374c595 Merge pull request #661 from MMEXA/codex/frontend-debug-20260704
修复前端调试与基础交互问题
2026-07-07 01:57:41 +08:00
MMEXA c3136126e5 修复钱包余额变更后的鉴权缓存延迟 2026-07-06 06:15:31 +08:00
MMEXA b23d299533 重跑 Codex 重置次数 CI 2026-07-05 01:37:31 +08:00
MMEXA b03aae18c3 修复 Codex 重置次数 CI 检查 2026-07-04 15:47:01 +08:00
MMEXA 99b6fe468f 简化 Codex 重置机会展示标签 2026-07-04 15:16:38 +08:00
MMEXA ef77ec04ca 增加 Codex 重置次数功能 2026-07-04 06:10:53 +08:00
MMEXA 242081433e 修复前端调试与基础交互问题 2026-07-04 05:24:40 +08:00
ZheFox b86d4e1f0c Merge pull request #660 from zhefox/main
refactor(frontend): unify mobile menu background styles
2026-07-03 13:17:59 +08:00
ZheFox a151f37d63 refactor(frontend): unify mobile menu background styles 2026-07-03 13:17:18 +08:00
ZheFox 42f7907740 Merge pull request #659 from zhefox/main
refactor(frontend): improve mobile overflow handling
2026-07-03 12:54:01 +08:00
ZheFox e72e25c59c refactor(frontend): improve mobile overflow handling 2026-07-03 12:53:22 +08:00
ZheFox 1b0440481b Merge pull request #658 from zhefox/main
修复管理端额度显示、节点表格显示与移动端滚动问题
2026-07-03 12:49:15 +08:00
ZheFox 26d85681f0 refactor(frontend): improve mobile overflow and proxy node table 2026-07-03 12:25:53 +08:00
ZheFox 1dcee77055 refactor(frontend): improve dialog and mobile overflow handling 2026-07-03 11:26:40 +08:00
elky 1ac16005f9 Stabilize usage worker autoscale tests 2026-07-02 17:28:25 +08:00
elky 2f1cdb6a0b Record exhausted usage failures synchronously 2026-07-02 16:08:04 +08:00
elky ac93851b2a Stabilize Gateway h2c transport test 2026-07-02 14:02:26 +08:00
elky 400b3125a4 Preserve terminal request candidate state 2026-07-02 01:40:57 +08:00
elky 2e5ff32e1a perf(frontend): 收敛导航预取并去重首屏请求
- 导航预取仅保留 pointerdown 触发,移除 mouseenter/focus,避免鼠标划过误触发
- 后台预取只做组件懒加载,不再预取各页业务数据,减少首屏资源争抢
- 版本状态检查增加 sessionStorage 缓存(正常 20 分钟 / 错误 5 分钟 TTL)
- fetchModules、必读公告拉取增加请求去重,避免并发重复请求
- 更新检查改用可清理的定时器,组件卸载时清理
- UsageRecordsTable 搜索防抖改为自定义实现,卸载时取消挂起 emit 并补充测试
2026-07-01 20:42:52 +08:00
elky a0f7074e59 chore: disable Redis persistence by default, document triage and policy 2026-07-01 14:15:16 +08:00
elky 7c32be46ca Mark sync usage active earlier 2026-07-01 02:21:20 +08:00
Entropy.Xu 6ed2f9bd0a fix: apply actual billing cost to wallet settlement 2026-07-01 01:12:40 +08:00
elky 778b106023 test: stabilize gateway nextest timing 2026-06-30 18:42:57 +08:00
elky f179ee72f9 chore: update gateway pressure observability 2026-06-30 17:01:39 +08:00
elky 974def5fef refactor(frontend): extract provider key identity block 2026-06-30 17:01:39 +08:00
elky e5351b7d9d refactor(frontend): extract provider key actions 2026-06-30 17:01:39 +08:00
elky ed83184d55 refactor(frontend): extract provider quota display components 2026-06-30 17:01:39 +08:00
elky 15b6606c82 refactor(frontend): extract pool key display panels 2026-06-30 17:01:39 +08:00
elky d7411a3104 refactor(frontend): extract pool header and theme toggle 2026-06-30 17:01:39 +08:00
elky 9f138d09e6 refactor(frontend): modularize i18n architecture 2026-06-30 17:01:39 +08:00
ZheFox bf29129a4b Merge pull request #654 from zhefox/main
Cancel upstream streams on client disconnect and void cancelled usage billing
2026-06-29 01:14:11 +08:00
zhefox f6293b6812 fix(usage): void cancelled usage and cancel dropped streams 2026-06-29 00:30:41 +08:00
elky 7e9424008f Add usage queue worker autoscaling 2026-06-26 14:02:57 +08:00
elky 6c5e70ccb1 fix monitoring error totals and counter health 2026-06-26 10:48:45 +08:00
elky 063834e95b Split admin operations dashboard route 2026-06-26 01:48:37 +08:00
elky c76d6b6396 Add admin operations dashboard and usage state fixes 2026-06-26 01:32:45 +08:00
elky 6f00e9fc67 Improve gateway transport and usage runtime 2026-06-25 22:36:27 +08:00
elky d336d1a7fa Improve gateway scheduling and runtime admission 2026-06-24 01:53:45 +08:00
ZheFox cf0af8fa1e Merge pull request #652 from zhefox/main
fix(usage): preserve token counts in body redaction
2026-06-23 14:40:59 +08:00
zhefox fd220b6c42 fix(usage): preserve token counts in body redaction 2026-06-23 14:38:39 +08:00
zhefox 3472bb75e7 ci: combine gateway clippy and nextest jobs 2026-06-23 14:06:43 +08:00
zhefox ba65c96c74 Merge branch 'main' of https://github.com/zhefox/Aether 2026-06-23 13:36:00 +08:00
zhefox c54b214657 ci: shard gateway tests and disable debug info in rust ci 2026-06-23 13:35:56 +08:00
ZheFox 4fcc17114f Merge pull request #651 from zhefox/main
fix(ai-formats): accept Claude context_management in responses conversion
2026-06-23 10:43:26 +08:00
zhefox deb5f55786 fix(ai-formats): clean up cross-format safety rules for Gemini requests 2026-06-23 10:30:13 +08:00
zhefox 1836c2b652 fix(ai-formats): accept Claude context_management in responses conversion 2026-06-23 10:03:55 +08:00
elky 5b7805181b perf: queue request candidate persistence 2026-06-22 02:49:17 +08:00
elky f75894acbb perf: reduce gateway db pressure under load 2026-06-22 00:08:48 +08:00
elky 541cc197c4 fix: preserve in-memory user export fallback 2026-06-22 00:08:48 +08:00
fawney19 363d1aba9a Merge pull request #615 from AAEE86/main
feat: 健康监控仪表盘与关联下钻优化,完善使用记录展示
2026-06-21 12:48:13 +08:00
fawney19 eb2cf662b7 Merge pull request #650 from stabey/pr/claude-system-responses-20260620
fix(ai-formats): preserve Claude in-message system guidance in Responses
2026-06-21 12:47:26 +08:00
elky 900f8a7163 fix(pool): allow zero cooldown settings 2026-06-21 12:20:08 +08:00
elky 61bdd304b7 Handle inactive PAT owner as invalid OAuth token 2026-06-21 11:39:20 +08:00
elky 279735ae7f Auto-size SQL pool defaults 2026-06-21 11:15:40 +08:00
elky cc2830f6ec Merge branch 'review/pr-639' 2026-06-21 10:48:49 +08:00
elky 8dbd730568 fix: respect imported oauth authorization headers 2026-06-21 02:27:06 +08:00
stabey bb6aa03485 fix(ai-formats): strip Claude billing headers from preserved guidance 2026-06-21 00:22:57 +08:00
stabey 6a22488698 fix(ai-formats): preserve Claude in-message system guidance in responses 2026-06-21 00:07:57 +08:00
elky f1c30439ff fix: preserve provider auth metadata 2026-06-20 22:11:42 +08:00
fawney19 1123095bb7 Merge pull request #624 from MMEXA/codex/fix-antigravity-oauth-quota
修复 Antigravity OAuth 导入后配额复检缺 project
2026-06-19 23:11:38 +08:00
MMEXA 938f11981d fix(ai-serving): route Antigravity auth enum through facade 2026-06-19 22:25:52 +08:00
MMEXA 6c4e730e60 修复 Antigravity OAuth 配额复检缺 project 2026-06-19 22:21:22 +08:00
elky 16584067d7 Add route-backed routing profile views 2026-06-18 02:06:34 +08:00
fawney19 6de0fe75a4 Merge pull request #641 from Kayphoon/codex/usage-cleanup-break-condition
fix(usage): align cleanup loop break conditions with candidate row count
2026-06-17 11:04:55 +08:00
fawney19 34f0913ed0 Merge pull request #645 from zhefox/main
修复 OpenAI Chat/Responses/Messages 转换兼容性并透传 Codex cyber_policy 错误
2026-06-17 11:03:29 +08:00
zhefox 5b305c64e1 fix(ai-formats): omit request tool call ids in OpenAI Responses input 2026-06-17 09:15:38 +08:00
zhefox 8ad97761e8 fix(ai-formats): preserve OpenAI Responses tool call item ids 2026-06-17 08:29:25 +08:00
zhefox 0f92ef664d fix(ai-formats): support OpenAI Responses custom tool/raw passthrough 2026-06-17 04:24:56 +08:00
zhefox 16a4fd3687 Merge branch 'main' of https://github.com/zhefox/Aether 2026-06-17 04:07:53 +08:00
zhefox 3a3fcbe46a fix(ai-formats): preserve OpenAI tool call item ids 2026-06-17 04:05:09 +08:00
zhefox 18d8ea2052 fix(ai-formats): preserve OpenAI tool call item ids 2026-06-17 04:03:40 +08:00
zhefox 6ab08f4014 fix(ai-formats): preserve Claude raw blocks, reasoning tokens, and test stack safety 2026-06-17 03:45:23 +08:00
zhefox 628a3a0d8d fix(ai-formats): support cyber policy failover and custom tool/audio passthrough 2026-06-17 02:33:14 +08:00
elky f52628e00b Handle OpenAI Responses keepalive stream events 2026-06-16 22:52:06 +08:00
zhefox f9d97ececb fix(ai-formats): ignore OpenAI Responses metadata events 2026-06-16 22:34:38 +08:00
fawney19 803e555022 Merge pull request #635 from zhefox/main
fix(gateway): 支持 OpenAI 图片编辑端点请求
2026-06-16 22:31:52 +08:00
zhefox b1bd727978 将 JSON 提示注入为 developer 输入 2026-06-16 21:40:15 +08:00
zhefox c2748dc868 忽略 OpenAI Responses keepalive 事件 2026-06-16 20:00:46 +08:00
ZheFox 302620cb94 Merge branch 'fawney19:main' into main 2026-06-16 12:17:28 +08:00
AAEE86 c255f29e98 Merge remote-tracking branch 'upstream/main' 2026-06-16 10:53:47 +08:00
Kayphoon 6d1b818414 fix(usage): align cleanup loop break conditions with candidate row count
The cleanup loop break condition used rows_affected() from the UPDATE
statement, but for rows that only had blob/audit refs (no inline
compressed body data), the UPDATE reported 0 affected rows. This caused
the loop to exit after the first batch, skipping the majority of
candidates.

Change the break condition in all 4 cleanup functions from:
  if cleaned == 0 || cleaned < batch_size
to:
  if rows.len() < batch_size

This ensures the loop continues as long as SELECT returns a full batch,
regardless of how many rows the UPDATE actually modified.

Affected functions:
- cleanup_usage_raw_body_fields
- cleanup_usage_compressed_body_fields
- cleanup_usage_header_fields
- cleanup_usage_stale_body_fields
2026-06-16 04:19:58 +08:00
ndllz 5249660e07 fix: respect oauth module disabled state 2026-06-09 18:13:07 +08:00
ndllz 84b99a641a fix: speed up usage activity heatmap render 2026-06-09 16:52:58 +08:00
AAEE86 4824e4a487 fix(frontend): 移除账号导入重复处理中提示 2026-06-09 16:40:45 +08:00
zhefox ba723ebe48 fix(usage): always use truncated body placeholder when limit exceeded 2026-06-09 09:59:10 +08:00
zhefox 04ba8cbe9e fix(gateway): support openai image accept negotiation 2026-06-08 20:40:30 +08:00
zhefox 82040bfc21 fix(gateway): support OpenAI image edit requests 2026-06-08 13:22:08 +08:00
AAEE86 85573d7980 Merge remote-tracking branch 'upstream/main' 2026-06-05 08:28:43 +08:00
AAEE86 7835840ebd feat(dashboard): 增加全站实时指标和自动刷新
- 管理员仪表盘新增全站 RPM/TPM 与在线/启用用户指标
- 合并今日请求/费用、全站 RPM/TPM、在线/启用用户卡片展示
- 在线用户按最近 5 分钟活跃请求去重统计
- 全站 RPM/TPM 按最近 60 秒请求与 Token 统计
- 新增仪表盘自动刷新按钮,开启后每 10 秒静默刷新数据
- 同步前端类型、空态占位和仪表盘测试
2026-06-02 18:16:24 +08:00
AAEE86 86f72da3d9 feat(health): 增加历史状态条指标 Tooltip
- 为健康监控时间轴返回 timeline_details 分段指标
- Hover 历史状态柱时展示总请求/成功/失败/可用率/状态
- 展示平均耗时/TTFB/速度和完整时间范围
- 修复历史状态柱 Tooltip 触发区域不可用的问题
- 补齐前端类型、详情抽屉透传和 mock 数据
2026-06-02 10:36:54 +08:00
AAEE86 d5d3f09846 refactor(health): add dashboard overview and related drill-down
- Replace health monitor tabs with a dashboard layout
- Add related health drill-down for endpoint, model, and provider cards
- Render provider health as cards and hide empty monitors
2026-06-02 00:10:59 +08:00
AAEE86 0e6fc96eb1 test(gateway): run wallet usage settlement test on larger stack
Wrap the wallet settlement usage test with the large-stack async test helper to
avoid stack overflow in the default test thread.
2026-06-01 22:40:22 +08:00
AAEE86 b052f40ffb test(gateway): run base usage body capture test on larger stack
Wrap the request_record_level=base local gateway usage test with the existing
large-stack async test helper to avoid stack overflow in the default test thread.
2026-06-01 22:23:54 +08:00
AAEE86 2aef9d2478 Refine mobile usage record metadata layout 2026-06-01 21:59:45 +08:00
AAEE86 8627a18f2e test(gateway): run local usage report test on large stack
Wrap the local OpenAI chat sync usage-reporting test in the existing
large-stack harness to avoid stack overflows under nextest suite load.
2026-06-01 21:45:44 +08:00
AAEE86 21c478be22 fix(health): hide empty endpoint monitors
- Remove raw API format label from endpoint health cards
- Hide endpoint health cards with no requests
2026-06-01 21:24:20 +08:00
AAEE86 9d8f7d158b Refine mobile usage record details
- Move mobile usage actions into the card header
- Add compact user/provider metadata line on mobile
- Preserve hidden unknown toggle and auto refresh controls
2026-06-01 21:13:10 +08:00
AAEE86 c1649fe837 refactor(health): consolidate monitor components 2026-06-01 20:49:38 +08:00
AAEE86 d3c8317939 fix(health): align model health card layout 2026-06-01 18:54:56 +08:00
AAEE86 7ffe33f867 feat(health): refine health monitor metrics
- add TPS to model and provider health payloads

- exclude user-cancelled 499 requests from health statistics

- update model/provider health cards with average latency, average TTFB, TPS, and availability
2026-06-01 18:34:51 +08:00
2091 changed files with 432158 additions and 78367 deletions
+4
View File
@@ -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/
+33 -57
View File
@@ -51,70 +51,46 @@ JWT_SECRET_KEY=change-this-to-a-secure-random-string
ENCRYPTION_KEY=change-this-to-another-secure-random-string
# 启动自举管理员(仅在当前库里还没有活动管理员时生效)
# 手动部署时取消注释并设置;install.sh 首次生成配置时会提示输入。
# 首次启动前必须设置 ADMIN_PASSWORD;install.sh 首次生成配置时会提示输入。
ADMIN_EMAIL=[email protected]
ADMIN_USERNAME=admin123456
# ADMIN_PASSWORD=
# ==================== 可选配置(有默认值) ====================
# docker compose 下 app 启动前自动执行 pending migration/backfill(默认 true)
# AETHER_GATEWAY_AUTO_PREPARE_DATABASE=true
# 可信反向代理 IP/CIDR,只有这些来源发送的 X-Real-IP / X-Forwarded-For 会被采用。
# 默认仅信任本机回环代理:127.0.0.0/8,::1/128。
# Docker/Nginx 位于独立容器时,请按实际容器网络设置,例如:172.16.0.0/12。
# AETHER_TRUSTED_PROXY_CIDRS=127.0.0.0/8,::1/128,172.16.0.0/12
# 管理后台更新策略:
# - systemd/二进制部署使用 self:下载 GitHub Release 包,校验 SHA256 后切换 current 并重启。
# - Docker Compose 使用 docker:后台只提示版本,实际更新请在 compose 目录执行 ./update.sh。
# - 源码/本地构建使用 manual:手动拉取源码或下载 release。
# Compose 默认把持久化文件放在 ./datas/{postgres,mysql,sqlite,redis},日志放在 ./logs。
# 分布式/多节点部署不要使用 ./datas 作为共享数据目录;应使用外部共享 Postgres/MySQL 和 Redis。
# 多节点不要从管理后台一键更新单个节点,应使用镜像滚动更新、systemd 分批发布或外部编排。
# AETHER_BASE_DIR=/opt/aether
# AETHER_UPDATE_STRATEGY=docker
# AETHER_DOCKER_UPDATE_COMMAND=./update.sh
# AETHER_GATEWAY_DEPLOYMENT_TOPOLOGY=single-node
# AETHER_GATEWAY_NODE_ROLE=all
# Docker Compose 默认强制把应用日志输出到 stdout/stderr,并由 Docker 轮转日志。
# 如需文件日志,需要在 compose 里把 AETHER_LOG_DESTINATION 改成 file 或 both,
# 并把容器用户可写目录挂载到 /opt/aether/logs。
# AETHER_LOG_DESTINATION=stdout
# AETHER_LOG_FORMAT=pretty
# AETHER_LOG_DIR=/opt/aether/logs
# 服务器访问 GitHub 需要代理时可配置;也兼容 UPDATE_PROXY_URL / HTTPS_PROXY / ALL_PROXY / HTTP_PROXY。
# 如果 Aether 跑在 Docker 容器里,想走宿主机代理时请写 host.docker.internal,不要写 127.0.0.1。
# AETHER_UPDATE_PROXY_URL=http://host.docker.internal:7890
# 共享出口触发 GitHub API 限流时可配置只读 token;也兼容 GITHUB_TOKEN / GH_TOKEN。
# AETHER_UPDATE_GITHUB_TOKEN=
# 下载超时控制:总超时默认 600 秒;连续无响应/无数据默认 30 秒。
# AETHER_UPDATE_DOWNLOAD_TIMEOUT_SECS=600
# AETHER_UPDATE_DOWNLOAD_IDLE_TIMEOUT_SECS=30
# 本地联调后台在线更新(配合 docker-compose.release-local.yml):
# 会用当前源码构建 release-layout 测试镜像,并伪装成较旧版本以触发升级入口。
# AETHER_RELEASE_LOCAL_VERSION=v0.7.0
# AETHER_RELEASE_LOCAL_PORT=18085
# LOCAL_RELEASE_APP_IMAGE=aether-app:release-local
# 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 连接池配置(默认适合单实例/小型部署;高并发可按需调大)
# 推荐计算方式(单实例):
# MAX = CPU 核数 × 10(AI 网关偏 IO 等待,可激进些;纯 OLTP 用 × 4)
# MIN = MAX × 0.2(保留常驻连接应对突发流量,避免冷启动握手开销)
# 多实例部署时请按 实例数 × MAX 控制总和,PG 端 max_connections 至少为该总和 + 20 余量
# AETHER_GATEWAY_DATA_POSTGRES_MIN_CONNECTIONS=4
# AETHER_GATEWAY_DATA_POSTGRES_MAX_CONNECTIONS=20
# AETHER_GATEWAY_DATA_POSTGRES_STATEMENT_CACHE_CAPACITY=100
# AETHER_GATEWAY_DATA_POSTGRES_ACQUIRE_TIMEOUT_MS=3000
# 启动时的数据库准备策略:auto(默认)或 verify-only
# AETHER_GATEWAY_DATABASE_MODE=auto
# PostgreSQL 性能调优(默认值适合 2核4GB 机器,按实际配置覆盖)
# 参考:shared_buffers ≈ 可用内存 25%,effective_cache_size ≈ 可用内存 50-75%
# POSTGRES_SHM_SIZE 控制 Docker 容器 /dev/shm;仪表盘统计等并行查询会使用它。
# work_mem 是每个连接每个排序操作的内存,不要设太大(并发数 × work_mem 是实际占用)
# | 系统内存 | shared_buffers | effective_cache_size | work_mem |
# | 2GB | 256MB | 768MB | 4MB |
# | 4GB | 1GB | 3GB | 16MB |
# | 8GB | 2GB | 6GB | 16MB |
# | 16GB | 4GB | 12GB | 32MB |
# | 32GB+ | 8GB | 24GB | 32MB |
# POSTGRES_SHARED_BUFFERS=1GB
# POSTGRES_EFFECTIVE_CACHE_SIZE=3GB
# POSTGRES_SHM_SIZE=512mb
# 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
# 可选的 Payload 上限(MiB);默认及 0 均表示不限制。
# AETHER_MAX_REQUEST_BODY_MB=0
# AETHER_GATEWAY_SECURITY_CACHE_TTL_MS=1000
# AETHER_MAX_REDACTED_SYNC_RESPONSE_BODY_MB=0
# AETHER_MAX_INTERNAL_BUFFERED_BODY_MB=0
# AETHER_TUNNEL_NODE_STATUS_QUEUE_CAPACITY=1024
# PostgreSQL 容器调优:docker-compose.yml 已内置通用默认值,通常不用配置。
# 只有在 Postgres 独占大内存、或压测显示 DB 缓存/排序/维护任务成为瓶颈时再覆盖。
# 内置默认:shared_buffers=1GB, effective_cache_size=3GB, shm_size=512mb,
# work_mem=16MB, maintenance_work_mem=256MB。
# POSTGRES_SHARED_BUFFERS=8GB
# POSTGRES_EFFECTIVE_CACHE_SIZE=24GB
# POSTGRES_SHM_SIZE=2gb
# POSTGRES_WORK_MEM=16MB
# POSTGRES_MAINTENANCE_WORK_MEM=256MB
# POSTGRES_MAINTENANCE_WORK_MEM=1GB
+1
View File
@@ -131,6 +131,7 @@ jobs:
aether-tunnel-*.tar.gz
aether-tunnel-*.zip
if-no-files-found: error
retention-days: 1
release:
needs: build
+9 -1
View File
@@ -53,7 +53,15 @@ jobs:
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
+613
View File
@@ -0,0 +1,613 @@
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'
GHCR_IMAGE: ghcr.io/fawney19/aether
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 }}
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}"
echo "sha=${sha}" >> "${GITHUB_OUTPUT}"
echo "short_sha=${sha:0:7}" >> "${GITHUB_OUTPUT}"
echo "date=$(date -u +'%Y-%m-%d')" >> "${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@v5
with:
ref: ${{ needs.source.outputs.sha }}
- name: Install pinned Rust toolchain
uses: dtolnay/rust-toolchain@stable
with:
toolchain: 1.95.0
- name: Show Rust toolchain
run: rustc -Vv
- name: Rust cache
uses: Swatinem/rust-cache@v2
with:
shared-key: nightly-rust-1.95-${{ runner.os }}
workspaces: . -> target
- name: Setup sccache
uses: mozilla-actions/[email protected]
- 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@v5
with:
ref: ${{ needs.source.outputs.sha }}
- name: Setup Node.js
uses: actions/setup-node@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@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@v5
with:
ref: ${{ needs.source.outputs.sha }}
- name: Setup Node.js
uses: actions/setup-node@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@v5
with:
ref: ${{ needs.source.outputs.sha }}
- name: Install pinned Rust toolchain
uses: dtolnay/rust-toolchain@stable
with:
toolchain: 1.95.0
targets: ${{ matrix.target }}
- name: Rust cache
uses: Swatinem/rust-cache@v2
with:
shared-key: nightly-release-${{ matrix.target }}
workspaces: . -> target
- name: Install cross
if: matrix.use_cross
uses: taiki-e/install-action@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@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
permissions:
actions: read
contents: read
packages: write
steps:
- uses: actions/checkout@v5
with:
ref: ${{ needs.source.outputs.sha }}
- name: Download Linux binaries and frontend
uses: actions/download-artifact@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@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push nightly image
uses: docker/build-push-action@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@v5
with:
ref: ${{ needs.source.outputs.sha }}
- name: Download nightly artifacts
uses: actions/download-artifact@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@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@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 }}
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
+82 -2
View File
@@ -77,7 +77,15 @@ jobs:
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
@@ -93,6 +101,71 @@ jobs:
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@v5
- name: Setup Node.js
uses: actions/setup-node@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@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
@@ -299,7 +372,7 @@ 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
steps:
@@ -309,6 +382,12 @@ jobs:
name: release-assets
path: release-assets
- name: Download VSIX artifact
uses: actions/download-artifact@v5
with:
name: aether-vscodex-vsix
path: release-assets
- name: Delete stale draft releases for tag
env:
GH_TOKEN: ${{ github.token }}
@@ -340,3 +419,4 @@ jobs:
release-assets/*.tar.gz
release-assets/SHA256SUMS
release-assets/install.sh
release-assets/*.vsix
+178 -19
View File
@@ -1,6 +1,7 @@
name: Rust CI
on:
workflow_call:
push:
branches:
- master
@@ -11,6 +12,7 @@ on:
- "crates/**"
- "apps/**"
- ".github/workflows/rust-ci.yml"
- ".github/workflows/nightly.yml"
pull_request:
paths:
- "Cargo.toml"
@@ -18,13 +20,19 @@ on:
- "crates/**"
- "apps/**"
- ".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
CARGO_PROFILE_TEST_DEBUG: 0
CARGO_TERM_COLOR: always
jobs:
@@ -68,7 +76,7 @@ jobs:
env:
RUSTC_WRAPPER: sccache
SCCACHE_GHA_ENABLED: "true"
run: cargo clippy -p aether-gateway --all-targets -- -D warnings
run: cargo clippy -p aether-gateway --lib --bins --examples -- -D warnings
- name: Show sccache stats
if: always()
@@ -136,7 +144,7 @@ jobs:
env:
RUSTC_WRAPPER: sccache
SCCACHE_GHA_ENABLED: "true"
run: cargo clippy --workspace --exclude aether-gateway --exclude aether-data --all-targets -- -D warnings
run: cargo clippy --workspace --exclude aether-gateway --exclude aether-data --exclude aether-integration-tests --all-targets -- -D warnings
- name: Show sccache stats
if: always()
@@ -184,15 +192,27 @@ jobs:
- name: Setup sccache
uses: mozilla-actions/[email protected]
- name: Setup mold
uses: rui314/setup-mold@v1
- name: Install nextest
uses: taiki-e/install-action@nextest
- name: Test
- name: Test lib
env:
RUSTC_WRAPPER: sccache
SCCACHE_GHA_ENABLED: "true"
RUST_MIN_STACK: "16777216"
run: cargo nextest run -p aether-gateway
RUSTFLAGS: "-C link-arg=-fuse-ld=mold"
run: cargo nextest run -p aether-gateway --lib
- 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 --bins
- name: Show sccache stats
if: always()
@@ -238,6 +258,45 @@ jobs:
SCCACHE_GHA_ENABLED: "true"
run: sccache --show-stats
check_data_features:
name: Check (Data Feature - ${{ matrix.feature }})
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
feature:
- postgres
- mysql
- sqlite
- all-drivers
steps:
- uses: actions/checkout@v5
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
- 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: Check selected data driver
env:
RUSTC_WRAPPER: sccache
SCCACHE_GHA_ENABLED: "true"
run: cargo check -p aether-data --no-default-features --features ${{ matrix.feature }}
- name: Show sccache stats
if: always()
env:
RUSTC_WRAPPER: sccache
SCCACHE_GHA_ENABLED: "true"
run: sccache --show-stats
test_rest:
name: Test (Workspace Rest)
runs-on: ubuntu-latest
@@ -266,7 +325,79 @@ jobs:
env:
RUSTC_WRAPPER: sccache
SCCACHE_GHA_ENABLED: "true"
run: cargo nextest run --workspace --exclude aether-gateway --exclude aether-data
run: cargo nextest run --workspace --exclude aether-gateway --exclude aether-data --exclude aether-integration-tests
- name: Show sccache stats
if: always()
env:
RUSTC_WRAPPER: sccache
SCCACHE_GHA_ENABLED: "true"
run: sccache --show-stats
test_data_adapters:
name: Test (Data Adapter - ${{ matrix.package }})
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
package:
- aether-data-postgres
- aether-data-mysql
- aether-data-sqlite
steps:
- uses: actions/checkout@v5
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
- 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: Install nextest
uses: taiki-e/install-action@nextest
- name: Test adapter
env:
RUSTC_WRAPPER: sccache
SCCACHE_GHA_ENABLED: "true"
run: cargo nextest run -p ${{ matrix.package }}
- name: Show sccache stats
if: always()
env:
RUSTC_WRAPPER: sccache
SCCACHE_GHA_ENABLED: "true"
run: sccache --show-stats
check_integration_scenarios:
name: Test (Integration Scenarios)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
- 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: Test scenario binaries and end-to-end suites
env:
RUSTC_WRAPPER: sccache
SCCACHE_GHA_ENABLED: "true"
run: cargo test -p aether-integration-tests --bins --tests
- name: Show sccache stats
if: always()
@@ -281,14 +412,20 @@ jobs:
needs:
- test_gateway
- test_data
- check_data_features
- test_rest
- test_data_adapters
- check_integration_scenarios
if: ${{ always() }}
steps:
- name: Verify test jobs
run: |
if [ "${{ needs.test_gateway.result }}" != "success" ] || \
[ "${{ needs.test_data.result }}" != "success" ] || \
[ "${{ needs.test_rest.result }}" != "success" ]; then
[ "${{ needs.check_data_features.result }}" != "success" ] || \
[ "${{ needs.test_rest.result }}" != "success" ] || \
[ "${{ needs.test_data_adapters.result }}" != "success" ] || \
[ "${{ needs.check_integration_scenarios.result }}" != "success" ]; then
echo "Tests failed"
exit 1
fi
@@ -318,7 +455,7 @@ jobs:
env:
RUSTC_WRAPPER: sccache
SCCACHE_GHA_ENABLED: "true"
run: cargo test -p aether-data sqlite --lib
run: cargo test -p aether-data --all-features sqlite --lib
- name: Show sccache stats
if: always()
@@ -362,26 +499,48 @@ jobs:
- name: Setup sccache
uses: mozilla-actions/[email protected]
- name: Add PostgreSQL server binaries to PATH
run: echo "$(pg_config --bindir)" >> "$GITHUB_PATH"
- name: Run Postgres migration 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 postgres_migrations_create_core_config_tables_when_url_is_set --lib -- --nocapture
run: cargo test -p aether-data --all-features postgres_migrations_create_core_config_tables_when_url_is_set --lib -- --nocapture
- name: Run Postgres provider metadata migration 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 postgres_provider_upstream_metadata_migration_preserves_json_when_url_is_set --lib -- --nocapture
- name: Run Postgres API key lifecycle tests
env:
RUSTC_WRAPPER: sccache
SCCACHE_GHA_ENABLED: "true"
AETHER_REQUIRE_LOCAL_POSTGRES_TESTS: "true"
run: |
cargo test -p aether-data --all-features lifecycle::migrate::tests::postgres_request_candidates_preserve_deleted_api_key_identity --lib -- --exact --nocapture
cargo test -p aether-data --all-features lifecycle::migrate::tests::postgres_request_candidate_migration_decouples_legacy_api_key_foreign_key --lib -- --exact --nocapture
cargo test -p aether-data --all-features lifecycle::migrate::tests::postgres_stats_daily_api_key_migration_decouples_legacy_foreign_key --lib -- --exact --nocapture
cargo test -p aether-data --all-features lifecycle::migrate::tests::postgres_expired_api_key_cleanup_preserves_historical_identity --lib -- --exact --nocapture
cargo test -p aether-data --all-features lifecycle::migrate::tests::postgres_api_key_leaderboard_user_filter_preserves_aggregate_history --lib -- --exact --nocapture
- name: Run Postgres core export 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 postgres_core_export_reads_migrated_database_rows_when_url_is_set --lib -- --nocapture
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 sqlite_core_export_reads_migrated_database_rows --lib -- --nocapture
run: cargo test -p aether-data --all-features sqlite_core_export_reads_migrated_database_rows --lib -- --nocapture
- name: Show sccache stats
if: always()
@@ -431,56 +590,56 @@ jobs:
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_migrations_create_core_config_tables_when_url_is_set --lib -- --nocapture
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_usage_write_repository_upserts_when_url_is_set --lib -- --nocapture
run: cargo test -p aether-data-mysql mysql_usage_write_repository_upserts_and_flushes_counters_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_usage_read_repository_reads_usage_contract_views_when_url_is_set --lib -- --nocapture
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_provider_catalog_repository_round_trips_when_url_is_set --lib -- --nocapture
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 mysql_core_export_reads_migrated_database_rows_when_url_is_set --lib -- --nocapture
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_wallet_read_repository_reads_wallet_contract_views --lib -- --nocapture
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 mysql_wallet_daily_usage_aggregation_uses_settlement_wallets_when_url_is_set --lib -- --nocapture
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 mysql_stats_aggregation_runs_after_mysql_migrations_when_url_is_set --lib -- --nocapture
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()
+2
View File
@@ -248,3 +248,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
+364 -15
View File
@@ -69,6 +69,14 @@ dependencies = [
"uuid",
]
[[package]]
name = "aether-admission-core"
version = "0.1.0"
dependencies = [
"serde",
"thiserror 2.0.18",
]
[[package]]
name = "aether-ai-formats"
version = "0.1.0"
@@ -95,6 +103,7 @@ dependencies = [
"aether-pool-core",
"aether-scheduler-core",
"async-trait",
"base64 0.22.1",
"http",
"serde",
"serde_json",
@@ -155,7 +164,9 @@ dependencies = [
"aether-ai-formats",
"aether-cache",
"aether-data-contracts",
"aether-data-query",
"aether-data-mysql",
"aether-data-postgres",
"aether-data-sqlite",
"aether-wallet",
"async-trait",
"chrono",
@@ -183,7 +194,48 @@ dependencies = [
"chrono",
"serde",
"serde_json",
"sha2",
"thiserror 2.0.18",
"tokio",
]
[[package]]
name = "aether-data-mysql"
version = "0.1.0"
dependencies = [
"aether-ai-formats",
"aether-data-contracts",
"aether-data-query",
"async-trait",
"chrono",
"chrono-tz",
"flate2",
"serde_json",
"sha2",
"sqlx",
"tokio",
"tracing",
"uuid",
]
[[package]]
name = "aether-data-postgres"
version = "0.1.0"
dependencies = [
"aether-ai-formats",
"aether-data-contracts",
"aether-data-query",
"async-trait",
"chrono",
"chrono-tz",
"flate2",
"futures-util",
"serde_json",
"sha2",
"sqlx",
"tokio",
"tracing",
"uuid",
]
[[package]]
@@ -203,6 +255,25 @@ dependencies = [
"toml",
]
[[package]]
name = "aether-data-sqlite"
version = "0.1.0"
dependencies = [
"aether-ai-formats",
"aether-data-contracts",
"aether-data-query",
"async-trait",
"chrono",
"chrono-tz",
"flate2",
"serde_json",
"sha2",
"sqlx",
"tokio",
"tracing",
"uuid",
]
[[package]]
name = "aether-dispatch-core"
version = "0.1.0"
@@ -229,6 +300,11 @@ dependencies = [
"aether-data",
"aether-data-contracts",
"aether-dispatch-core",
"aether-gateway-control",
"aether-gateway-execution",
"aether-gateway-frontdoor",
"aether-gateway-tunnel",
"aether-gateway-workers",
"aether-http",
"aether-model-fetch",
"aether-oauth",
@@ -240,7 +316,7 @@ dependencies = [
"aether-runtime-state",
"aether-scheduler-core",
"aether-task-runtime",
"aether-testkit",
"aether-test-support",
"aether-usage-runtime",
"aether-video-tasks-core",
"aether-wallet",
@@ -249,6 +325,7 @@ dependencies = [
"axum",
"base64 0.22.1",
"bcrypt",
"brotli",
"bytes",
"chrono",
"chrono-tz",
@@ -258,7 +335,11 @@ dependencies = [
"futures-util",
"hmac",
"http",
"http-body-util",
"hyper",
"hyper-util",
"ldap3",
"libc",
"md-5",
"object_store",
"parking_lot",
@@ -266,15 +347,20 @@ dependencies = [
"reqwest",
"rsa",
"rustls 0.23.37",
"semver",
"serde",
"serde_json",
"sha1",
"sha2",
"socket2 0.6.3",
"sqlx",
"sysinfo",
"tar",
"thiserror 2.0.18",
"tikv-jemalloc-sys",
"tikv-jemallocator",
"tokio",
"tokio-tungstenite 0.28.0",
"tokio-util",
"tower",
"tower-http",
@@ -288,6 +374,63 @@ dependencies = [
"zstd",
]
[[package]]
name = "aether-gateway-control"
version = "0.1.0"
dependencies = [
"http",
]
[[package]]
name = "aether-gateway-execution"
version = "0.1.0"
dependencies = [
"aether-contracts",
"bytes",
"serde_json",
]
[[package]]
name = "aether-gateway-frontdoor"
version = "0.1.0"
dependencies = [
"aether-ai-formats",
"axum",
"bytes",
"futures-util",
"http",
"serde_json",
"tokio",
"tower",
"tracing",
"tracing-subscriber",
"uuid",
]
[[package]]
name = "aether-gateway-tunnel"
version = "0.1.0"
dependencies = [
"aether-admission-core",
"aether-contracts",
"base64 0.22.1",
"bytes",
"http",
"serde",
"serde_json",
]
[[package]]
name = "aether-gateway-workers"
version = "0.1.0"
dependencies = [
"aether-runtime-state",
"aether-task-runtime",
"aether-test-support",
"tokio",
"tracing",
]
[[package]]
name = "aether-http"
version = "0.1.0"
@@ -296,6 +439,50 @@ dependencies = [
"serde",
]
[[package]]
name = "aether-integration-tests"
version = "0.1.0"
dependencies = [
"aether-contracts",
"aether-crypto",
"aether-data",
"aether-data-contracts",
"aether-gateway",
"aether-runtime-state",
"aether-testkit",
"async-stream",
"axum",
"futures-util",
"http",
"reqwest",
"serde",
"serde_json",
"sha2",
"sqlx",
"tokio",
"tokio-tungstenite 0.28.0",
"uuid",
]
[[package]]
name = "aether-loadtools"
version = "0.1.0"
dependencies = [
"aether-http",
"aether-runtime",
"aether-runtime-state",
"aether-test-support",
"bytes",
"futures-util",
"http",
"libc",
"reqwest",
"serde",
"serde_json",
"sysinfo",
"tokio",
]
[[package]]
name = "aether-model-fetch"
version = "0.1.0"
@@ -341,12 +528,21 @@ dependencies = [
"serde_json",
]
[[package]]
name = "aether-provider-core"
version = "0.1.0"
dependencies = [
"serde",
"thiserror 2.0.18",
]
[[package]]
name = "aether-provider-pool"
version = "0.1.0"
dependencies = [
"aether-data-contracts",
"aether-pool-core",
"aether-provider-transport",
"serde_json",
"url",
"uuid",
@@ -366,6 +562,9 @@ dependencies = [
"async-trait",
"axum",
"base64 0.22.1",
"chrono",
"crypto_box",
"ed25519-dalek",
"http",
"regex",
"reqwest",
@@ -440,11 +639,20 @@ dependencies = [
"sha2",
]
[[package]]
name = "aether-task-core"
version = "0.1.0"
dependencies = [
"serde",
"thiserror 2.0.18",
]
[[package]]
name = "aether-task-runtime"
version = "0.1.0"
dependencies = [
"aether-runtime",
"aether-task-core",
"serde",
"serde_json",
"tokio",
@@ -452,30 +660,25 @@ dependencies = [
"tracing",
]
[[package]]
name = "aether-test-support"
version = "0.1.0"
dependencies = [
"tokio",
]
[[package]]
name = "aether-testkit"
version = "0.1.0"
dependencies = [
"aether-contracts",
"aether-data",
"aether-data-contracts",
"aether-gateway",
"aether-http",
"aether-loadtools",
"aether-runtime",
"aether-runtime-state",
"async-stream",
"axum",
"bytes",
"futures-util",
"http",
"libc",
"reqwest",
"serde",
"serde_json",
"sqlx",
"sysinfo",
"tokio",
"tokio-tungstenite 0.28.0",
]
[[package]]
@@ -484,6 +687,7 @@ version = "0.3.16"
dependencies = [
"aether-contracts",
"aether-gateway",
"aether-gateway-tunnel",
"aether-http",
"aether-runtime",
"aether-runtime-state",
@@ -522,6 +726,14 @@ dependencies = [
"webpki-roots 0.26.11",
]
[[package]]
name = "aether-usage-core"
version = "0.1.0"
dependencies = [
"serde",
"thiserror 2.0.18",
]
[[package]]
name = "aether-usage-runtime"
version = "0.1.0"
@@ -533,6 +745,7 @@ dependencies = [
"aether-runtime-state",
"async-trait",
"base64 0.22.1",
"futures-util",
"serde",
"serde_json",
"tokio",
@@ -906,6 +1119,19 @@ dependencies = [
"zeroize",
]
[[package]]
name = "bigdecimal"
version = "0.4.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4d6867f1565b3aad85681f1015055b087fcfd840d6aeee6eee7f2da317603695"
dependencies = [
"autocfg",
"libm",
"num-bigint",
"num-integer",
"num-traits",
]
[[package]]
name = "bindgen"
version = "0.72.1"
@@ -954,6 +1180,15 @@ dependencies = [
"serde_core",
]
[[package]]
name = "blake2"
version = "0.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
dependencies = [
"digest",
]
[[package]]
name = "block-buffer"
version = "0.10.4"
@@ -1135,6 +1370,7 @@ checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
dependencies = [
"crypto-common",
"inout",
"zeroize",
]
[[package]]
@@ -1419,6 +1655,36 @@ dependencies = [
"typenum",
]
[[package]]
name = "crypto_box"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "16182b4f39a82ec8a6851155cc4c0cda3065bb1db33651726a29e1951de0f009"
dependencies = [
"aead",
"blake2",
"crypto_secretbox",
"curve25519-dalek",
"salsa20",
"subtle",
"zeroize",
]
[[package]]
name = "crypto_secretbox"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9d6cf87adf719ddf43a805e92c6870a531aedda35ff640442cbaf8674e141e1"
dependencies = [
"aead",
"cipher",
"generic-array",
"poly1305",
"salsa20",
"subtle",
"zeroize",
]
[[package]]
name = "csscolorparser"
version = "0.6.2"
@@ -1438,6 +1704,33 @@ dependencies = [
"cipher",
]
[[package]]
name = "curve25519-dalek"
version = "4.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be"
dependencies = [
"cfg-if",
"cpufeatures",
"curve25519-dalek-derive",
"digest",
"fiat-crypto",
"rustc_version",
"subtle",
"zeroize",
]
[[package]]
name = "curve25519-dalek-derive"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "darling"
version = "0.23.0"
@@ -1598,6 +1891,30 @@ version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
[[package]]
name = "ed25519"
version = "2.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53"
dependencies = [
"pkcs8",
"signature",
]
[[package]]
name = "ed25519-dalek"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9"
dependencies = [
"curve25519-dalek",
"ed25519",
"serde",
"sha2",
"subtle",
"zeroize",
]
[[package]]
name = "either"
version = "1.15.0"
@@ -1670,6 +1987,12 @@ version = "2.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
[[package]]
name = "fiat-crypto"
version = "0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
[[package]]
name = "filedescriptor"
version = "0.8.3"
@@ -1908,6 +2231,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
dependencies = [
"typenum",
"version_check",
"zeroize",
]
[[package]]
@@ -2204,6 +2528,7 @@ dependencies = [
"pin-project-lite",
"socket2 0.6.3",
"tokio",
"tower-layer",
"tower-service",
"tracing",
]
@@ -3162,6 +3487,17 @@ version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
[[package]]
name = "poly1305"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf"
dependencies = [
"cpufeatures",
"opaque-debug",
"universal-hash",
]
[[package]]
name = "polyval"
version = "0.6.2"
@@ -3790,6 +4126,15 @@ version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
[[package]]
name = "salsa20"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97a22f5af31f73a954c10289c93e8a50cc23d971e80ee446f1f6f7137a088213"
dependencies = [
"cipher",
]
[[package]]
name = "schannel"
version = "0.1.29"
@@ -4120,6 +4465,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6"
dependencies = [
"base64 0.22.1",
"bigdecimal",
"bytes",
"chrono",
"crc",
@@ -4196,6 +4542,7 @@ checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526"
dependencies = [
"atoi",
"base64 0.22.1",
"bigdecimal",
"bitflags 2.11.0",
"byteorder",
"bytes",
@@ -4239,6 +4586,7 @@ checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46"
dependencies = [
"atoi",
"base64 0.22.1",
"bigdecimal",
"bitflags 2.11.0",
"byteorder",
"chrono",
@@ -4256,6 +4604,7 @@ dependencies = [
"log",
"md-5",
"memchr",
"num-bigint",
"once_cell",
"rand 0.8.5",
"serde",
+65 -28
View File
@@ -1,34 +1,52 @@
[workspace]
members = [
"apps/aether-tunnel",
"crates/aether-ai-formats",
"crates/aether-ai/formats",
"crates/aether-admin",
"crates/aether-ai-serving",
"crates/aether-admission-core",
"crates/aether-ai/serving",
"crates/aether-pool-core",
"crates/aether-provider-pool",
"crates/aether-provider/core",
"crates/aether-provider/pool",
"crates/aether-routing-core",
"crates/aether-data-contracts",
"crates/aether-data-query",
"crates/aether-data-schema",
"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",
"crates/aether-cache",
"crates/aether-billing",
"crates/aether-wallet",
"crates/aether-crypto",
"crates/aether-contracts",
"crates/aether-data",
"crates/aether-data/runtime",
"crates/aether-model-fetch",
"crates/aether-oauth",
"crates/aether-provider-transport",
"crates/aether-provider/transport",
"crates/aether-scheduler-core",
"crates/aether-runtime-state",
"crates/aether-task-runtime",
"crates/aether-usage-runtime",
"crates/aether-runtime/state",
"crates/aether-task/runtime",
"crates/aether-task/core",
"crates/aether-gateway/frontdoor",
"crates/aether-gateway/control",
"crates/aether-gateway/execution",
"crates/aether-gateway/workers",
"crates/aether-gateway/tunnel",
"crates/aether-testing/loadtools",
"crates/aether-testing/integration",
"crates/aether-usage/core",
"crates/aether-testing/support",
"crates/aether-usage/runtime",
"crates/aether-video-tasks-core",
"apps/aether-gateway",
"crates/aether-http",
"crates/aether-runtime",
"crates/aether-testkit",
"crates/aether-runtime/base",
"crates/aether-testing/testkit",
]
default-members = [
"apps/aether-gateway",
]
resolver = "2"
@@ -39,33 +57,48 @@ repository = "https://github.com/fawney19/Aether.git"
[workspace.dependencies]
aether-admin = { path = "crates/aether-admin" }
aether-ai-formats = { path = "crates/aether-ai-formats" }
aether-ai-serving = { path = "crates/aether-ai-serving" }
aether-admission-core = { path = "crates/aether-admission-core" }
aether-ai-formats = { path = "crates/aether-ai/formats" }
aether-ai-serving = { path = "crates/aether-ai/serving" }
aether-pool-core = { path = "crates/aether-pool-core" }
aether-provider-pool = { path = "crates/aether-provider-pool" }
aether-provider-core = { path = "crates/aether-provider/core" }
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-query = { path = "crates/aether-data-query" }
aether-data-schema = { path = "crates/aether-data-schema" }
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" }
aether-cache = { path = "crates/aether-cache" }
aether-billing = { path = "crates/aether-billing" }
aether-wallet = { path = "crates/aether-wallet" }
aether-crypto = { path = "crates/aether-crypto" }
aether-contracts = { path = "crates/aether-contracts" }
aether-data = { path = "crates/aether-data" }
aether-data = { path = "crates/aether-data/runtime" }
aether-model-fetch = { path = "crates/aether-model-fetch" }
aether-oauth = { path = "crates/aether-oauth" }
aether-provider-transport = { path = "crates/aether-provider-transport" }
aether-provider-transport = { path = "crates/aether-provider/transport" }
aether-scheduler-core = { path = "crates/aether-scheduler-core" }
aether-runtime-state = { path = "crates/aether-runtime-state" }
aether-task-runtime = { path = "crates/aether-task-runtime" }
aether-usage-runtime = { path = "crates/aether-usage-runtime" }
aether-runtime-state = { path = "crates/aether-runtime/state" }
aether-task-runtime = { path = "crates/aether-task/runtime" }
aether-task-core = { path = "crates/aether-task/core" }
aether-gateway-frontdoor = { path = "crates/aether-gateway/frontdoor" }
aether-gateway-control = { path = "crates/aether-gateway/control" }
aether-gateway-execution = { path = "crates/aether-gateway/execution" }
aether-gateway-workers = { path = "crates/aether-gateway/workers" }
aether-gateway-tunnel = { path = "crates/aether-gateway/tunnel" }
aether-loadtools = { path = "crates/aether-testing/loadtools" }
aether-integration-tests = { path = "crates/aether-testing/integration" }
aether-test-support = { path = "crates/aether-testing/support" }
aether-usage-core = { path = "crates/aether-usage/core" }
aether-usage-runtime = { path = "crates/aether-usage/runtime" }
aether-video-tasks-core = { path = "crates/aether-video-tasks-core" }
aether-gateway = { path = "apps/aether-gateway" }
aether-http = { path = "crates/aether-http" }
aether-runtime = { path = "crates/aether-runtime" }
aether-testkit = { path = "crates/aether-testkit" }
aether-runtime = { path = "crates/aether-runtime/base" }
aether-testkit = { path = "crates/aether-testing/testkit" }
aes = "0.8"
aes-gcm = "0.10"
async-stream = "0.3"
@@ -73,10 +106,13 @@ 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"
@@ -92,14 +128,15 @@ serde = { version = "1", features = ["derive"] }
serde_json = { version = "1", features = ["preserve_order"] }
serde_path_to_error = "0.1"
sha2 = "0.10"
socket2 = "0.6"
tar = "0.4"
sqlx = { version = "0.8", default-features = false, features = ["postgres", "mysql", "sqlite", "runtime-tokio-rustls", "chrono"] }
sqlx = { version = "0.8", default-features = false, features = ["runtime-tokio-rustls", "chrono"] }
thiserror = "2"
tokio = { version = "1", features = ["macros", "net", "rt-multi-thread", "signal", "sync", "time"] }
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"
+11 -2
View File
@@ -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 \
@@ -62,7 +71,7 @@ COPY --from=gateway-planner /build/recipe.json ./recipe.json
RUN --mount=type=cache,id=aether-cargo-registry,target=/usr/local/cargo/registry,sharing=locked \
--mount=type=cache,id=aether-cargo-git,target=/usr/local/cargo/git,sharing=locked \
--mount=type=cache,id=aether-cargo-target-local,target=/build/target,sharing=locked \
cargo chef cook --release --locked --package aether-gateway --bin aether-gateway --recipe-path recipe.json
cargo chef cook --release --locked --package aether-gateway --bin aether-gateway --features jemalloc --recipe-path recipe.json
COPY Cargo.toml Cargo.lock ./
COPY apps/ ./apps/
@@ -71,7 +80,7 @@ RUN --mount=type=cache,id=aether-cargo-registry,target=/usr/local/cargo/registry
--mount=type=cache,id=aether-cargo-git,target=/usr/local/cargo/git,sharing=locked \
--mount=type=cache,id=aether-cargo-target-local,target=/build/target,sharing=locked \
set -eux; \
cargo build --release --locked -p aether-gateway --bin aether-gateway; \
cargo build --release --locked -p aether-gateway --bin aether-gateway --features jemalloc; \
cp target/release/aether-gateway /tmp/aether-gateway
# ==================== 最小运行时打包 ====================
+11 -2
View File
@@ -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 \
@@ -60,7 +69,7 @@ COPY --from=gateway-planner /build/recipe.json ./recipe.json
RUN --mount=type=cache,id=aether-cargo-registry,target=/usr/local/cargo/registry,sharing=locked \
--mount=type=cache,id=aether-cargo-git,target=/usr/local/cargo/git,sharing=locked \
--mount=type=cache,id=aether-cargo-target-release-local,target=/build/target,sharing=locked \
cargo chef cook --release --locked --package aether-gateway --bin aether-gateway --recipe-path recipe.json
cargo chef cook --release --locked --package aether-gateway --bin aether-gateway --features jemalloc --recipe-path recipe.json
COPY Cargo.toml Cargo.lock ./
COPY apps/ ./apps/
@@ -68,7 +77,7 @@ COPY crates/ ./crates/
RUN --mount=type=cache,id=aether-cargo-registry,target=/usr/local/cargo/registry,sharing=locked \
--mount=type=cache,id=aether-cargo-git,target=/usr/local/cargo/git,sharing=locked \
--mount=type=cache,id=aether-cargo-target-release-local,target=/build/target,sharing=locked \
cargo build --release --locked -p aether-gateway && \
cargo build --release --locked -p aether-gateway --features jemalloc && \
cp target/release/aether-gateway /tmp/aether-gateway
# ==================== 最小运行时打包 ====================
+36 -12
View File
@@ -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
@@ -201,12 +208,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
@@ -352,8 +364,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 "=> 执行命令: cargo run -p aether-gateway --bin aether-gateway -- --app-port $${APP_PORT}"
cargo run -p aether-gateway --bin aether-gateway -- --app-port "$${APP_PORT}" > >(
tee -a "$${GATEWAY_LOG_FILE}"
) 2>&1 &
GATEWAY_PID=$$!
@@ -444,7 +456,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 "=> 启动后端: RUST_LOG=$${DEV_RUST_LOG} cargo run -p aether-gateway --bin aether-gateway -- --app-port $${APP_PORT:-8084}"
/bin/bash -euo pipefail -c "$$DEV_BACKEND_SCRIPT" &
backend_pid=$$!
@@ -494,8 +506,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 +564,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 +578,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"
+40 -3
View File
@@ -113,9 +113,22 @@ cd Aether
curl -fsSL https://raw.githubusercontent.com/fawney19/Aether/main/install.sh | sudo bash
```
### Nightly(每日 main 构建)
Nightly workflow 每天从 `main` 的固定 commit 构建并发布滚动的 GitHub Release `nightly`,同时推送多架构 GHCR 镜像 `ghcr.io/fawney19/aether:nightly`。Nightly 是预发布版本,适合验证最新代码,不保证与正式版相同的稳定性。滚动 Release 需要仓库保持关闭 GitHub Release immutability。
安装最新 nightly(Linux systemd / macOS launchd + SQLite):
```bash
curl -fsSL https://raw.githubusercontent.com/fawney19/Aether/main/install.sh | sudo bash -s -- --channel nightly
```
Docker Compose 用户可在部署目录的 `.env` 中设置 `APP_IMAGE=ghcr.io/fawney19/aether:nightly`,然后运行 `./update.sh` 获取下一次 nightly。二进制方式可重新执行上述安装命令升级;当前管理后台的在线更新列表只跟踪正式版/RC/Beta,不会自动提示下一次 nightly。
## 本地开发
依赖 Docker、Rust toolchain、Node.js 和 make。
首次启动前需要在 `.env` 中设置 `ADMIN_PASSWORD`,用于创建本地管理员。
```bash
make dev
@@ -123,6 +136,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`。
数据库 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,14 +162,26 @@ 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 按每核 `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`:可选的单请求解压后请求体上限;未配置或设为 `0` 时不限制
- `AETHER_MAX_INTERNAL_BUFFERED_BODY_MB`:可选的 heartbeat、管理探测等内部整包响应体上限;未配置或设为 `0` 时不限制
- `AETHER_TUNNEL_NODE_STATUS_QUEUE_CAPACITY`:隧道节点状态上报队列容量,默认 `1024`;满载时拒绝新事件,避免控制面故障导致无界内存增长
- `AETHER_GATEWAY_SECURITY_CACHE_TTL_MS`:IP 黑白名单本地缓存时间,默认 `1000ms`,写操作会主动失效相关缓存
- `AETHER_MAX_REDACTED_SYNC_RESPONSE_BODY_MB`:可选的 PII 恢复同步响应缓冲上限;未配置或设为 `0` 时不限制
- `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`:运行时缓存/协调后端。SQLite 默认用 `memory`,不会连接 Redis;多节点部署和需要跨 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`:认证和敏感数据加密所需密钥
- `API_KEY_PREFIX`:用户和管理员新建 API Key 时使用的前缀,默认 `sk`
- `ADMIN_USERNAME` / `ADMIN_PASSWORD` / `ADMIN_EMAIL`:首次启动时自举首个本地管理员;`install.sh` 会提示输入管理员密码
@@ -168,4 +205,4 @@ Aether Tunnel 是配套的正向代理节点,部署在海外 VPS 上,为墙
## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=fawney19/Aether&type=Date)](https://star-history.com/#fawney19/Aether&Date)
[![Star History Chart](https://api.star-history.com/svg?repos=fawney19/Aether&type=date&legend=top-left)](https://www.star-history.com/?repos=fawney19%2FAether&type=date&legend=top-left)
+10
View File
@@ -0,0 +1,10 @@
.git
.github
node_modules
test
fixtures
vscode-extension
*.vsix
coverage
data
.DS_Store
+8
View File
@@ -0,0 +1,8 @@
node_modules/
vscode-extension/node_modules/
vscode-extension/dist/
data/
coverage/
*.vsix
.DS_Store
*.log
+26
View File
@@ -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"]
+283
View File
@@ -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 后打开“Codex 远程控制”,生成一次性配对码。然后在 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`,除非你确实要向该会话发送任务。
+727
View File
@@ -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,
};
+37
View File
@@ -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:
+20
View File
@@ -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;
}
});
+39
View File
@@ -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
}
}
}
}
}
+23
View File
@@ -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
+112
View File
@@ -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 };
});
+541
View File
@@ -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;
});
+303
View File
@@ -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
+745
View File
@@ -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();
});
+298
View File
@@ -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
+209
View File
@@ -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 });
}
});
+57
View File
@@ -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();
});
+105
View File
@@ -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'/);
});
+120
View File
@@ -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/);
});
+206
View File
@@ -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
+21
View File
@@ -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.
+237
View File
@@ -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
View File
@@ -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"
]
}
+3
View File
@@ -0,0 +1,3 @@
node_modules/
dist/
*.tsbuildinfo
+13
View File
@@ -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>
File diff suppressed because it is too large Load Diff
+28
View File
@@ -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"
}
}
+7
View File
@@ -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>
+52
View File
@@ -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;
}
+12
View File
@@ -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);
});
});
+19
View File
@@ -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"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+17
View File
@@ -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"]
}
+45
View File
@@ -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"],
},
});
+29 -4
View File
@@ -6,6 +6,16 @@ license.workspace = true
repository.workspace = true
description = "Rust ingress gateway for Aether phase 3a transparent proxy"
[features]
default = []
jemalloc = [
"dep:tikv-jemallocator",
"dep:tikv-jemalloc-sys",
"tikv-jemallocator/stats",
"tikv-jemalloc-sys/stats",
]
testkit = []
[dependencies]
aether-admin.workspace = true
aether-ai-formats.workspace = true
@@ -14,10 +24,15 @@ aether-billing.workspace = true
aether-cache.workspace = true
aether-contracts.workspace = true
aether-crypto.workspace = true
aether-data.workspace = true
aether-data = { workspace = true, features = ["all-drivers"] }
aether-data-contracts.workspace = true
aether-dispatch-core.workspace = true
aether-gateway-frontdoor.workspace = true
aether-gateway-control.workspace = true
aether-gateway-execution.workspace = true
aether-http.workspace = true
aether-gateway-workers.workspace = true
aether-gateway-tunnel.workspace = true
aether-model-fetch.workspace = true
aether-oauth.workspace = true
aether-pool-core.workspace = true
@@ -37,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
@@ -46,7 +62,11 @@ flate2.workspace = true
futures-util.workspace = true
hmac.workspace = true
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"] }
libc = "0.2"
md-5 = "0.10"
object_store.workspace = true
parking_lot = "0.12"
@@ -56,13 +76,17 @@ 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
sqlx = { workspace = true, features = ["postgres", "mysql", "sqlite", "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
@@ -74,8 +98,9 @@ wreq-util.workspace = true
zstd.workspace = true
[target.'cfg(not(target_env = "msvc"))'.dependencies]
tikv-jemallocator = "0.6"
tikv-jemallocator = { version = "0.6", optional = true }
tikv-jemalloc-sys = { version = "0.6", optional = true }
[dev-dependencies]
aether-testkit.workspace = true
aether-test-support.workspace = true
tracing-subscriber.workspace = true
+42 -26
View File
@@ -50,21 +50,29 @@ 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,
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_EMBEDDING_SYNC_PLAN_KIND, GEMINI_FILES_DOWNLOAD_PLAN_KIND,
GEMINI_VIDEO_CANCEL_SYNC_PLAN_KIND, OPENAI_EMBEDDING_SYNC_PLAN_KIND,
OPENAI_IMAGE_STREAM_PLAN_KIND, OPENAI_IMAGE_SYNC_FINALIZE_REPORT_KIND,
OPENAI_IMAGE_SYNC_PLAN_KIND, OPENAI_RERANK_SYNC_PLAN_KIND, OPENAI_VIDEO_CANCEL_SYNC_PLAN_KIND,
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,
OPENAI_RERANK_SYNC_PLAN_KIND, OPENAI_RESPONSES_COMPACT_STREAM_PLAN_KIND,
OPENAI_RESPONSES_STREAM_PLAN_KIND, OPENAI_VIDEO_CANCEL_SYNC_PLAN_KIND,
OPENAI_VIDEO_CONTENT_PLAN_KIND, OPENAI_VIDEO_DELETE_SYNC_PLAN_KIND,
OPENAI_VIDEO_REMIX_SYNC_PLAN_KIND,
};
pub(crate) use aether_ai_formats::protocol::stream::CanonicalUsage as StreamingCanonicalUsage;
/// 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,
@@ -83,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,249 @@
use std::sync::{Arc, OnceLock};
use std::time::{SystemTime, UNIX_EPOCH};
use aether_provider_transport::CodexFingerprintConvergenceContext;
use http::{request::Parts, HeaderMap};
use serde_json::Value;
use uuid::Uuid;
use crate::client_session_affinity::codex_request_signals_from_request;
#[derive(Debug, Clone)]
pub(crate) struct CodexFingerprintContextSlot(Arc<OnceLock<CodexFingerprintConvergenceContext>>);
impl Default for CodexFingerprintContextSlot {
fn default() -> Self {
Self(Arc::new(OnceLock::new()))
}
}
impl CodexFingerprintContextSlot {
fn resolve(
&self,
headers: &HeaderMap,
body_json: &Value,
) -> CodexFingerprintConvergenceContext {
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,
) -> CodexFingerprintConvergenceContext {
if let Some(context) = parts
.extensions
.get::<CodexFingerprintConvergenceContext>()
.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::<CodexFingerprintConvergenceContext>()
.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,
) -> CodexFingerprintConvergenceContext {
let context = resolve_codex_fingerprint_context(parts, body_json);
if parts
.extensions
.get::<CodexFingerprintConvergenceContext>()
.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,
) -> CodexFingerprintConvergenceContext {
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: &CodexFingerprintConvergenceContext,
) {
parts.extensions.remove::<CodexFingerprintContextSlot>();
parts.extensions.insert(context.clone());
}
fn build_codex_fingerprint_context(
headers: &HeaderMap,
body_json: &Value,
logical_turn_id: String,
) -> CodexFingerprintConvergenceContext {
let signals = codex_request_signals_from_request(headers, Some(body_json));
let mut context =
CodexFingerprintConvergenceContext::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::<CodexFingerprintConvergenceContext>(),
Some(&context)
);
}
#[test]
fn restored_context_wins_over_retry_request_signals() {
let original = CodexFingerprintConvergenceContext::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 =
@@ -25,12 +25,16 @@ 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,
auth_context: None,
admin_principal: None,
local_auth_rejection: None,
model_directive_policy: Default::default(),
}
}
@@ -1166,7 +1170,7 @@ fn local_finalize_handles_openai_responses_compact_cross_format_sync_response()
assert_eq!(report.report_kind, "openai_responses_compact_sync_success");
assert_eq!(
report.client_body_json.expect("client body should exist")["object"],
"response"
"response.compaction"
);
}
@@ -1222,7 +1226,7 @@ fn local_finalize_handles_openai_responses_compact_cross_format_function_call_re
.background_report
.expect("compact tool-call should downgrade to success report");
let client_body = report.client_body_json.expect("client body should exist");
assert_eq!(client_body["object"], "response");
assert_eq!(client_body["object"], "response.compaction");
assert_eq!(client_body["output"][1]["type"], "function_call");
}
@@ -1439,6 +1443,57 @@ fn local_finalize_handles_openai_responses_cross_format_stream_response_from_gem
);
}
#[test]
fn local_finalize_rejects_antigravity_usage_only_gemini_wrapper() {
let payload = GatewaySyncReportRequest {
trace_id: "trace-antigravity-empty-gemini-wrapper".to_string(),
report_kind: "gemini_chat_sync_finalize".to_string(),
report_context: Some(json!({
"client_api_format": "gemini:generate_content",
"provider_api_format": "gemini:generate_content",
"model": "gemini-3.5-flash",
"mapped_model": "gemini-3-flash-agent",
"needs_conversion": false,
"has_envelope": true,
"envelope_name": "antigravity:v1internal",
"upstream_is_stream": true,
})),
status_code: 200,
headers: BTreeMap::from([("content-type".to_string(), "application/json".to_string())]),
body_json: Some(json!({
"chunks": [{
"response": {
"responseId": "resp-usage-only",
"modelVersion": "gemini-3-flash-agent",
"usageMetadata": {
"promptTokenCount": 5528,
"totalTokenCount": 5528
}
},
"metadata": {},
"traceId": "trace-antigravity-empty-gemini-wrapper"
}],
"metadata": {
"stream": true,
"stored_chunks": 1,
"total_chunks": 1
}
})),
client_body_json: None,
body_base64: None,
telemetry: None,
};
let outcome = maybe_build_local_core_sync_finalize_response(
"trace-antigravity-empty-gemini-wrapper",
&test_decision(),
&payload,
)
.expect("local finalize should evaluate payload");
assert!(outcome.is_none());
}
#[test]
fn local_finalize_handles_openai_responses_compact_openai_family_stream_response_even_when_conversion_flagged(
) {
@@ -1738,6 +1793,93 @@ fn local_finalize_handles_openai_chat_cross_format_sync_response_from_openai_res
assert_eq!(client_body["usage"]["total_tokens"], 5);
}
#[test]
fn local_finalize_aggregates_openai_responses_capture_envelope_before_chat_conversion() {
let payload = GatewaySyncReportRequest {
trace_id: "trace-openai-chat-capture-envelope-sync-123".to_string(),
report_kind: "openai_chat_sync_finalize".to_string(),
report_context: Some(json!({
"client_api_format": "openai:chat",
"provider_api_format": "openai:responses",
"model": "gpt-5.6-luna",
"mapped_model": "gpt-5.6-luna",
"needs_conversion": true,
"has_envelope": false,
})),
status_code: 200,
headers: BTreeMap::from([("content-type".to_string(), "application/json".to_string())]),
body_json: Some(json!({
"chunks": [
{
"type": "response.output_text.delta",
"response_id": "resp_capture_gateway_123",
"output_index": 0,
"content_index": 0,
"delta": "Gateway "
},
{
"type": "response.output_text.done",
"response_id": "resp_capture_gateway_123",
"output_index": 0,
"content_index": 0,
"text": "Gateway capture"
},
{
"type": "response.completed",
"response": {
"id": "resp_capture_gateway_123",
"object": "response",
"status": "completed",
"model": "gpt-5.6-luna",
"output": [],
"usage": {
"input_tokens": 2,
"output_tokens": 3,
"total_tokens": 5
}
}
}
],
"metadata": {}
})),
client_body_json: None,
body_base64: None,
telemetry: None,
};
let outcome = maybe_build_local_core_sync_finalize_response(
"trace-openai-chat-capture-envelope-sync-123",
&test_decision(),
&payload,
)
.expect("capture envelope finalize should succeed")
.expect("capture envelope finalize should match");
let report = outcome
.background_report
.expect("capture envelope conversion should produce a success report");
assert_eq!(report.report_kind, "openai_chat_sync_success");
let provider_body = report
.body_json
.expect("aggregated provider body should exist");
assert_eq!(provider_body["id"], "resp_capture_gateway_123");
assert_eq!(
provider_body["output"][0]["content"][0]["text"],
"Gateway capture"
);
assert!(provider_body.get("chunks").is_none());
let client_body = report
.client_body_json
.expect("converted client body should exist");
assert_eq!(
client_body["choices"][0]["message"]["content"],
"Gateway capture"
);
assert_eq!(client_body["usage"]["prompt_tokens"], 2);
assert_eq!(client_body["usage"]["completion_tokens"], 3);
assert_eq!(client_body["usage"]["total_tokens"], 5);
}
#[test]
fn local_finalize_handles_claude_chat_cross_format_sync_response_from_openai_chat() {
let payload = GatewaySyncReportRequest {
@@ -1784,12 +1926,16 @@ 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,
auth_context: None,
admin_principal: None,
local_auth_rejection: None,
model_directive_policy: Default::default(),
},
&payload,
)
@@ -1851,12 +1997,16 @@ 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,
auth_context: None,
admin_principal: None,
local_auth_rejection: None,
model_directive_policy: Default::default(),
},
&payload,
)
+26 -8
View File
@@ -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;
@@ -13,13 +15,16 @@ use crate::{usage::GatewaySyncReportRequest, AppState, GatewayError};
pub(crate) use self::adaptation::{
maybe_build_provider_private_stream_normalizer, ProviderPrivateStreamNormalizer,
};
pub(crate) use self::api::gemini_generate_content_response_has_visible_output;
pub(crate) use self::api::{
gemini_generate_content_response_has_visible_output, CODEX_RESPONSES_LITE_HEADER,
};
pub(crate) use self::finalize::common::LocalCoreSyncFinalizeOutcome;
pub(crate) use self::finalize::internal::{
maybe_bridge_standard_sync_json_to_stream, maybe_build_stream_response_rewriter,
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,
@@ -48,18 +53,27 @@ pub(crate) use self::planner::{
build_standard_family_stream_plan_and_reports, build_standard_family_sync_attempt_source,
build_standard_family_sync_plan_and_reports, build_standard_stream_plan_from_decision,
build_standard_sync_plan_from_decision, candidate_auth_channel_skip_reason,
extract_pool_sticky_session_token, maybe_build_stream_decision_payload,
codex_model_capabilities_for_transport, extract_pool_sticky_session_token,
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,
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, CandidateFailureDiagnostic,
CandidateFailureDiagnosticKind, EligibleLocalExecutionCandidate, GatewayAuthApiKeySnapshot,
GatewayProviderTransportSnapshot, LocalExecutionAttemptSource, LocalExecutionCandidateKind,
LocalResolvedOAuthRequestAuth, PlannerAppState, SkippedLocalExecutionCandidate,
set_local_openai_image_execution_exhausted_diagnostic, validate_final_openai_provider_request,
CandidateFailureDiagnostic, CandidateFailureDiagnosticKind, EligibleLocalExecutionCandidate,
GatewayAuthApiKeySnapshot, GatewayProviderTransportSnapshot, LocalExecutionAttemptSource,
LocalExecutionCandidateKind, LocalResolvedOAuthRequestAuth, PlannerAppState,
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,
@@ -68,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::{
@@ -86,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,
@@ -95,6 +110,7 @@ pub(crate) fn build_provider_transport_request_url(
upstream_is_stream,
request_query,
kiro_api_region,
api_operation,
},
)
}
@@ -106,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(
@@ -116,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,
)
@@ -0,0 +1,170 @@
use std::collections::BTreeMap;
use std::sync::Arc;
use serde_json::Value;
use crate::ai_serving::transport::antigravity::{
build_antigravity_safe_v1internal_request, build_antigravity_static_identity_headers,
classify_local_antigravity_request_support, AntigravityEnvelopeRequestType,
AntigravityRequestAuth, AntigravityRequestAuthUnsupportedReason,
AntigravityRequestEnvelopeSupport, AntigravityRequestSideSupport,
AntigravityRequestSideUnsupportedReason,
};
use crate::ai_serving::transport::{
build_standard_provider_request_headers, GatewayProviderTransportSnapshot,
StandardProviderRequestHeaders, StandardProviderRequestHeadersInput,
};
use crate::AppState;
pub(crate) const ANTIGRAVITY_V1INTERNAL_ENVELOPE_NAME: &str = "antigravity:v1internal";
pub(crate) enum AntigravityV1InternalRequestError {
TransportUnsupported,
EnvelopeUnsupported,
UpstreamUrlUnavailable,
HeaderRulesApplyFailed,
}
pub(crate) struct AntigravityV1InternalRequestInput<'a> {
pub(crate) state: &'a AppState,
pub(crate) parts: &'a http::request::Parts,
pub(crate) transport: &'a Arc<GatewayProviderTransportSnapshot>,
pub(crate) trace_id: &'a str,
pub(crate) mapped_model: &'a str,
pub(crate) provider_api_format: &'a str,
pub(crate) auth_header: &'a str,
pub(crate) auth_value: &'a str,
pub(crate) request_headers: &'a http::HeaderMap,
pub(crate) original_request_body: &'a Value,
pub(crate) gemini_request_body: &'a Value,
pub(crate) upstream_is_stream: bool,
pub(crate) same_format: bool,
}
pub(crate) struct AntigravityV1InternalRequest {
pub(crate) transport: Arc<GatewayProviderTransportSnapshot>,
pub(crate) body: Value,
pub(crate) headers: StandardProviderRequestHeaders,
pub(crate) upstream_url: String,
}
pub(crate) async fn build_antigravity_v1internal_provider_request(
input: AntigravityV1InternalRequestInput<'_>,
) -> Result<AntigravityV1InternalRequest, AntigravityV1InternalRequestError> {
let payload = build_antigravity_v1internal_payload(
input.state,
input.transport,
input.trace_id,
input.mapped_model,
input.gemini_request_body,
)
.await?;
let upstream_url = crate::ai_serving::build_provider_transport_request_url_for_request_body(
&payload.transport,
input.provider_api_format,
Some(input.mapped_model),
input.upstream_is_stream,
input.parts.uri.query(),
None,
None,
Some(&payload.body),
)
.ok_or(AntigravityV1InternalRequestError::UpstreamUrlUnavailable)?;
let extra_headers: BTreeMap<String, String> =
build_antigravity_static_identity_headers(&payload.auth);
let mut headers =
build_standard_provider_request_headers(StandardProviderRequestHeadersInput {
transport: &payload.transport,
provider_api_format: input.provider_api_format,
same_format: input.same_format,
headers: input.request_headers,
auth_header: input.auth_header,
auth_value: input.auth_value,
extra_headers: &extra_headers,
header_rules: payload.transport.endpoint.header_rules.as_ref(),
provider_request_body: &payload.body,
original_request_body: input.original_request_body,
upstream_is_stream: input.upstream_is_stream,
})
.ok_or(AntigravityV1InternalRequestError::HeaderRulesApplyFailed)?;
headers
.headers
.insert("accept".to_string(), "text/event-stream".to_string());
Ok(AntigravityV1InternalRequest {
transport: payload.transport,
body: payload.body,
headers,
upstream_url,
})
}
struct AntigravityV1InternalPayload {
transport: Arc<GatewayProviderTransportSnapshot>,
auth: AntigravityRequestAuth,
body: Value,
}
async fn build_antigravity_v1internal_payload(
state: &AppState,
transport: &Arc<GatewayProviderTransportSnapshot>,
trace_id: &str,
mapped_model: &str,
gemini_request_body: &Value,
) -> Result<AntigravityV1InternalPayload, AntigravityV1InternalRequestError> {
let mut resolved_transport = Arc::clone(transport);
let mut antigravity_support = classify_local_antigravity_request_support(
&resolved_transport,
gemini_request_body,
AntigravityEnvelopeRequestType::Agent,
);
if matches!(
antigravity_support,
AntigravityRequestSideSupport::Unsupported(
AntigravityRequestSideUnsupportedReason::UnsupportedAuth(
AntigravityRequestAuthUnsupportedReason::MissingProjectId
)
)
) {
if let Some(hydrated) = state
.hydrate_antigravity_project_metadata_for_transport(&resolved_transport)
.await
{
resolved_transport = Arc::new(hydrated);
antigravity_support = classify_local_antigravity_request_support(
&resolved_transport,
gemini_request_body,
AntigravityEnvelopeRequestType::Agent,
);
}
}
let auth = match antigravity_support {
AntigravityRequestSideSupport::Supported(spec) => spec.auth,
AntigravityRequestSideSupport::Unsupported(_) => {
return Err(AntigravityV1InternalRequestError::TransportUnsupported);
}
};
let body = match build_antigravity_safe_v1internal_request(
&auth,
trace_id,
mapped_model,
gemini_request_body,
AntigravityEnvelopeRequestType::Agent,
) {
AntigravityRequestEnvelopeSupport::Supported(envelope) => envelope,
AntigravityRequestEnvelopeSupport::Unsupported(_) => {
return Err(AntigravityV1InternalRequestError::EnvelopeUnsupported);
}
};
Ok(AntigravityV1InternalPayload {
transport: resolved_transport,
auth,
body,
})
}
@@ -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};
@@ -8,25 +10,38 @@ use crate::scheduler::affinity::SCHEDULER_AFFINITY_TTL;
const PLANNER_SCHEDULER_AFFINITY_MAX_ENTRIES: usize = 10_000;
pub(crate) fn has_explicit_session_affinity(
client_session_affinity: Option<&ClientSessionAffinity>,
) -> bool {
client_session_affinity.is_some_and(ClientSessionAffinity::has_session_key)
}
pub(crate) fn read_cached_scheduler_affinity_target(
state: PlannerAppState<'_>,
auth_snapshot: Option<&GatewayAuthApiKeySnapshot>,
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;
}
let requested_model = requested_model
.map(str::trim)
.filter(|value| !value.is_empty())?;
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()
@@ -41,6 +56,9 @@ pub(crate) fn remember_scheduler_affinity_for_candidate(
requested_model: &str,
candidate: &SchedulerMinimalCandidateSelectionCandidate,
) {
if !has_explicit_session_affinity(client_session_affinity) {
return;
}
remember_scheduler_affinity_for_candidate_at_epoch(
state,
auth_snapshot,
@@ -61,18 +79,71 @@ pub(crate) fn remember_scheduler_affinity_for_candidate_at_epoch(
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;
}
let Some(api_key_id) = auth_snapshot
.map(|snapshot| snapshot.api_key_id.trim())
.filter(|value| !value.is_empty())
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;
};
@@ -88,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
@@ -134,6 +134,7 @@ mod tests {
global_model_id: "global-1".to_string(),
global_model_name: "gpt-5.4".to_string(),
selected_provider_model_name: "gpt-5.4".to_string(),
supports_streaming: true,
mapping_matched_model: None,
}
}
@@ -169,6 +169,7 @@ mod tests {
global_model_id: "global-model-1".to_string(),
global_model_name: "gpt-test".to_string(),
selected_provider_model_name: "gpt-test-upstream".to_string(),
supports_streaming: true,
mapping_matched_model: None,
}
}
@@ -1,20 +1,16 @@
use std::collections::BTreeMap;
use aether_ai_serving::{
ai_ranking_context, build_ai_rankable_candidate, run_ai_candidate_ranking,
AiCandidateRankingPort, AiRankableCandidateParts, AiRankingContextConfig,
AiRankingSchedulingMode,
};
use aether_routing_core::{ResolvedRoutingPolicy, RoutingSchedulingMode, RoutingSetPriorityMode};
use aether_routing_core::ResolvedRoutingPolicy;
use async_trait::async_trait;
use tracing::warn;
use tokio::sync::Mutex;
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,
@@ -24,7 +20,7 @@ use aether_scheduler_core::{
use super::candidate_affinity_cache::read_cached_scheduler_affinity_target;
use super::candidate_resolution::{EligibleLocalExecutionCandidate, LocalExecutionCandidateKind};
use super::candidate_transport_ranking_facts::{
resolve_cached_transport_ranking_facts, CandidateTransportRankingFacts,
resolve_cached_transport_ranking_facts, CandidateTransportRankingFactsCache,
};
struct GatewayLocalCandidateRankingPort<'a> {
@@ -35,6 +31,7 @@ struct GatewayLocalCandidateRankingPort<'a> {
required_capabilities: Option<&'a serde_json::Value>,
ordering_config: SchedulerOrderingConfig,
routing_policy: Option<&'a ResolvedRoutingPolicy>,
transport_ranking_facts_cache: Mutex<CandidateTransportRankingFactsCache>,
}
#[async_trait]
@@ -66,6 +63,7 @@ impl AiCandidateRankingPort for GatewayLocalCandidateRankingPort<'_> {
self.client_session_affinity,
normalized_client_api_format,
affinity_requested_model,
self.routing_policy,
))
}
@@ -84,13 +82,17 @@ impl AiCandidateRankingPort for GatewayLocalCandidateRankingPort<'_> {
normalized_client_api_format: &str,
cached_affinity_match: bool,
) -> Result<SchedulerRankableCandidate, Self::Error> {
let ranking_facts = resolve_transport_ranking_facts_for_candidate(
self.state,
&candidate.candidate,
candidate.transport.as_ref(),
self.ordering_config,
)
.await;
let ranking_facts = {
let mut cache = self.transport_ranking_facts_cache.lock().await;
resolve_cached_transport_ranking_facts(
self.state,
&mut cache,
&candidate.candidate,
candidate.transport.as_ref(),
self.ordering_config,
)
.await
};
let routing_overlaid_candidate =
routing_overlaid_candidate(self.routing_policy, candidate.kind, &candidate.candidate);
Ok(build_ai_rankable_candidate(AiRankableCandidateParts {
@@ -128,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,
@@ -137,6 +139,7 @@ pub(crate) async fn rank_eligible_local_execution_candidates(
required_capabilities,
ordering_config,
routing_policy,
transport_ranking_facts_cache: Mutex::new(CandidateTransportRankingFactsCache::default()),
};
match run_ai_candidate_ranking(&port, candidates, normalized_client_api_format).await {
@@ -145,23 +148,6 @@ pub(crate) async fn rank_eligible_local_execution_candidates(
}
}
async fn resolve_transport_ranking_facts_for_candidate(
state: PlannerAppState<'_>,
candidate: &SchedulerMinimalCandidateSelectionCandidate,
transport: &crate::ai_serving::GatewayProviderTransportSnapshot,
ordering_config: SchedulerOrderingConfig,
) -> CandidateTransportRankingFacts {
let mut ordering_cache = BTreeMap::new();
resolve_cached_transport_ranking_facts(
state,
&mut ordering_cache,
candidate,
transport,
ordering_config,
)
.await
}
fn cached_affinity_matches_local_execution_scope(
eligible: &EligibleLocalExecutionCandidate,
target: &SchedulerAffinityTarget,
@@ -195,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")
}
}
}
}
@@ -233,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)]
@@ -272,10 +251,16 @@ mod tests {
use aether_ai_serving::{
ai_ranking_context, build_ai_rankable_candidate, AiRankableCandidateParts,
};
use aether_data::repository::provider_catalog::InMemoryProviderCatalogReadRepository;
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,
@@ -284,7 +269,9 @@ mod tests {
use serde_json::json;
use super::super::candidate_affinity_cache::remember_scheduler_affinity_for_candidate;
use super::super::candidate_transport_ranking_facts::resolve_cached_candidate_transport_ranking_facts;
use super::super::candidate_transport_ranking_facts::{
resolve_cached_candidate_transport_ranking_facts, CandidateTransportRankingFactsCache,
};
use super::{PlannerAppState, SchedulerMinimalCandidateSelectionCandidate};
use crate::ai_serving::planner::candidate_resolution::{
resolve_and_rank_local_execution_candidates,
@@ -303,10 +290,14 @@ 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 = BTreeMap::new();
let mut ordering_cache = CandidateTransportRankingFactsCache::default();
for (original_index, candidate) in candidates.iter().enumerate() {
let ranking_facts = resolve_cached_candidate_transport_ranking_facts(
@@ -358,12 +349,13 @@ mod tests {
global_model_id: "global-model-1".to_string(),
global_model_name: "gpt-4.1".to_string(),
selected_provider_model_name: "gpt-4.1".to_string(),
supports_streaming: true,
mapping_matched_model: None,
}
}
#[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;
@@ -377,6 +369,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(),
@@ -389,17 +383,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"
);
}
@@ -418,6 +441,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)]),
@@ -634,6 +659,7 @@ mod tests {
global_model_id: "global-model-1".to_string(),
global_model_name: "gpt-4.1".to_string(),
selected_provider_model_name: "gpt-4.1".to_string(),
supports_streaming: true,
mapping_matched_model: None,
}
}
@@ -888,7 +914,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),
@@ -903,14 +930,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",
)
.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);
@@ -1540,6 +1585,7 @@ mod tests {
.expect("state should build")
.with_data_state_for_tests(data_state);
let auth_snapshot = sample_auth_snapshot();
let client_session_affinity = ClientSessionAffinity::from_session_key("session-1");
let cached_candidate = sample_priority_candidate(
"provider-cached",
"endpoint-cached",
@@ -1551,7 +1597,7 @@ mod tests {
remember_scheduler_affinity_for_candidate(
PlannerAppState::new(&state),
Some(&auth_snapshot),
None,
Some(&client_session_affinity),
"openai:chat",
"gpt-4.1",
&cached_candidate,
@@ -1573,7 +1619,7 @@ mod tests {
"openai:chat",
"gpt-4.1",
Some(&auth_snapshot),
None,
Some(&client_session_affinity),
None,
None,
None,
@@ -1714,6 +1760,7 @@ mod tests {
.expect("state should build")
.with_data_state_for_tests(data_state);
let auth_snapshot = sample_auth_snapshot();
let client_session_affinity = ClientSessionAffinity::from_session_key("session-1");
let cached_cross_format = sample_priority_candidate(
"provider-shared",
"endpoint-openai",
@@ -1725,7 +1772,7 @@ mod tests {
remember_scheduler_affinity_for_candidate(
PlannerAppState::new(&state),
Some(&auth_snapshot),
None,
Some(&client_session_affinity),
"claude:messages",
"gpt-4.1",
&cached_cross_format,
@@ -1747,7 +1794,7 @@ mod tests {
"claude:messages",
"gpt-4.1",
Some(&auth_snapshot),
None,
Some(&client_session_affinity),
None,
None,
None,
@@ -1915,6 +1962,7 @@ mod tests {
.expect("state should build")
.with_data_state_for_tests(data_state);
let auth_snapshot = sample_auth_snapshot();
let client_session_affinity = ClientSessionAffinity::from_session_key("session-1");
let cached_candidate = sample_priority_candidate(
"provider-pool",
"endpoint-pool",
@@ -1926,7 +1974,7 @@ mod tests {
remember_scheduler_affinity_for_candidate(
PlannerAppState::new(&state),
Some(&auth_snapshot),
None,
Some(&client_session_affinity),
"openai:chat",
"gpt-4.1",
&cached_candidate,
@@ -1948,7 +1996,7 @@ mod tests {
"openai:chat",
Some("gpt-4.1"),
Some(&auth_snapshot),
None,
Some(&client_session_affinity),
None,
None,
None,
@@ -2008,6 +2056,7 @@ mod tests {
.expect("state should build")
.with_data_state_for_tests(data_state);
let auth_snapshot = sample_auth_snapshot();
let client_session_affinity = ClientSessionAffinity::from_session_key("session-1");
let cached_candidate = sample_priority_candidate(
"provider-pool",
"endpoint-pool",
@@ -2019,7 +2068,7 @@ mod tests {
remember_scheduler_affinity_for_candidate(
PlannerAppState::new(&state),
Some(&auth_snapshot),
None,
Some(&client_session_affinity),
"openai:chat",
"gpt-4.1",
&cached_candidate,
@@ -2041,7 +2090,7 @@ mod tests {
"openai:chat",
Some("gpt-4.1"),
Some(&auth_snapshot),
None,
Some(&client_session_affinity),
None,
None,
None,
@@ -2064,7 +2113,7 @@ mod tests {
}
#[tokio::test]
async fn remembers_scheduler_affinity_for_candidate_using_requested_model_key() {
async fn ignores_scheduler_affinity_without_client_session_scope() {
let state = AppState::new().expect("state should build");
let auth_snapshot = sample_auth_snapshot();
let candidate = sample_candidate("endpoint-1", "key-1");
@@ -2078,15 +2127,12 @@ mod tests {
&candidate,
);
let remembered = state
assert!(state
.read_scheduler_affinity_target(
"scheduler_affinity:api-key-1:openai:chat:gpt-5",
SCHEDULER_AFFINITY_TTL,
)
.expect("affinity target should be cached");
assert_eq!(remembered.provider_id, "provider-1");
assert_eq!(remembered.endpoint_id, "endpoint-1");
assert_eq!(remembered.key_id, "key-1");
.is_none());
}
#[tokio::test]
@@ -7,6 +7,7 @@ use aether_ai_serving::{
use aether_routing_core::ResolvedRoutingPolicy;
use async_trait::async_trait;
use std::convert::Infallible;
use std::time::Instant;
use tracing::warn;
use aether_scheduler_core::{
@@ -20,8 +21,11 @@ use crate::ai_serving::{
PlannerAppState,
};
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 {
@@ -68,7 +72,7 @@ struct GatewayLocalCandidateResolutionPort<'a> {
#[async_trait]
impl AiCandidateResolutionPort for GatewayLocalCandidateResolutionPort<'_> {
type Candidate = SchedulerMinimalCandidateSelectionCandidate;
type Transport = GatewayProviderTransportSnapshot;
type Transport = Arc<GatewayProviderTransportSnapshot>;
type Eligible = EligibleLocalExecutionCandidate;
type Skipped = SkippedLocalExecutionCandidate;
type Error = Infallible;
@@ -77,7 +81,12 @@ impl AiCandidateResolutionPort for GatewayLocalCandidateResolutionPort<'_> {
&self,
candidate: &Self::Candidate,
) -> Result<Option<Self::Transport>, Self::Error> {
Ok(read_candidate_transport_snapshot(self.state, candidate).await)
let started_at = Instant::now();
let transport = read_candidate_transport_snapshot_arc(self.state, candidate).await;
let elapsed_ms = started_at.elapsed().as_millis() as u64;
observe_gateway_stage_ms("candidate_transport_snapshot", elapsed_ms);
observe_gateway_stage_ms("candidate_resolution_transport_read", elapsed_ms);
Ok(transport)
}
fn build_missing_transport_skipped_candidate(
@@ -139,7 +148,7 @@ impl AiCandidateResolutionPort for GatewayLocalCandidateResolutionPort<'_> {
SkippedLocalExecutionCandidate {
candidate,
skip_reason,
transport: Some(Arc::new(transport)),
transport: Some(transport),
ranking: None,
extra_data: None,
}
@@ -159,7 +168,7 @@ impl AiCandidateResolutionPort for GatewayLocalCandidateResolutionPort<'_> {
EligibleLocalExecutionCandidate {
kind,
candidate,
transport: Arc::new(transport),
transport,
provider_api_format,
orchestration: LocalExecutionCandidateMetadata::default(),
ranking: None,
@@ -171,7 +180,8 @@ impl AiCandidateResolutionPort for GatewayLocalCandidateResolutionPort<'_> {
candidates: Vec<Self::Eligible>,
normalized_client_api_format: &str,
) -> Result<Vec<Self::Eligible>, Self::Error> {
Ok(rank_eligible_local_execution_candidates(
let started_at = Instant::now();
let ranked = rank_eligible_local_execution_candidates(
self.state,
candidates,
normalized_client_api_format,
@@ -181,7 +191,12 @@ impl AiCandidateResolutionPort for GatewayLocalCandidateResolutionPort<'_> {
self.required_capabilities,
self.routing_policy,
)
.await)
.await;
observe_gateway_stage_ms(
"candidate_resolution_rank",
started_at.elapsed().as_millis() as u64,
);
Ok(ranked)
}
async fn apply_pool_scheduler(
@@ -358,10 +373,24 @@ async fn resolve_and_rank_local_execution_candidates_with_pool_expansion(
expand_pool_groups,
};
let started_at = Instant::now();
match run_ai_candidate_resolution(&port, candidates, request).await {
Ok(mut outcome) => {
observe_gateway_stage_ms(
"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)
}
@@ -506,8 +535,17 @@ pub(crate) async fn read_candidate_transport_snapshot(
state: PlannerAppState<'_>,
candidate: &SchedulerMinimalCandidateSelectionCandidate,
) -> Option<GatewayProviderTransportSnapshot> {
read_candidate_transport_snapshot_arc(state, candidate)
.await
.map(|transport| (*transport).clone())
}
pub(crate) async fn read_candidate_transport_snapshot_arc(
state: PlannerAppState<'_>,
candidate: &SchedulerMinimalCandidateSelectionCandidate,
) -> Option<Arc<GatewayProviderTransportSnapshot>> {
match state
.read_provider_transport_snapshot(
.read_provider_transport_snapshot_arc(
&candidate.provider_id,
&candidate.endpoint_id,
&candidate.key_id,
@@ -616,6 +654,7 @@ mod tests {
global_model_id: "global-model-1".to_string(),
global_model_name: "claude-sonnet".to_string(),
selected_provider_model_name: "claude-sonnet".to_string(),
supports_streaming: true,
mapping_matched_model: None,
}
}
File diff suppressed because it is too large Load Diff
@@ -1,8 +1,10 @@
use std::collections::BTreeMap;
use aether_contracts::ProxySnapshot;
use aether_scheduler_core::{
SchedulerMinimalCandidateSelectionCandidate, SchedulerTunnelAffinityBucket,
};
use serde_json::Value;
use tracing::warn;
use crate::ai_serving::{GatewayProviderTransportSnapshot, PlannerAppState};
@@ -10,7 +12,9 @@ use crate::scheduler::config::SchedulerOrderingConfig;
use super::candidate_resolution::read_candidate_transport_snapshot;
pub(super) type CandidateTransportIdentity<'a> = (&'a str, &'a str, &'a str);
const TUNNEL_OWNER_INSTANCE_ID_EXTRA_KEY: &str = "tunnel_owner_instance_id";
pub(super) type CandidateTransportIdentity = (String, String, String);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) struct CandidateTransportRankingFacts {
@@ -18,38 +22,51 @@ pub(super) struct CandidateTransportRankingFacts {
pub(super) keep_priority_on_conversion: bool,
}
pub(super) async fn resolve_cached_candidate_transport_ranking_facts<'a>(
state: PlannerAppState<'_>,
cache: &mut BTreeMap<CandidateTransportIdentity<'a>, CandidateTransportRankingFacts>,
candidate: &'a SchedulerMinimalCandidateSelectionCandidate,
ordering_config: SchedulerOrderingConfig,
) -> CandidateTransportRankingFacts {
let identity = candidate_transport_identity(candidate);
if let Some(facts) = cache.get(&identity).copied() {
return facts;
}
let facts = resolve_candidate_transport_ranking_facts(state, candidate, ordering_config).await;
cache.insert(identity, facts);
facts
#[derive(Debug, Default)]
pub(super) struct CandidateTransportRankingFactsCache {
candidate_facts: BTreeMap<CandidateTransportIdentity, CandidateTransportRankingFacts>,
configured_proxy_snapshots: BTreeMap<String, Option<ProxySnapshot>>,
system_proxy_snapshot: Option<Option<ProxySnapshot>>,
tunnel_buckets_by_node_id: BTreeMap<String, SchedulerTunnelAffinityBucket>,
}
pub(super) async fn resolve_cached_transport_ranking_facts<'a>(
pub(super) async fn resolve_cached_candidate_transport_ranking_facts(
state: PlannerAppState<'_>,
cache: &mut BTreeMap<CandidateTransportIdentity<'a>, CandidateTransportRankingFacts>,
candidate: &'a SchedulerMinimalCandidateSelectionCandidate,
transport: &GatewayProviderTransportSnapshot,
cache: &mut CandidateTransportRankingFactsCache,
candidate: &SchedulerMinimalCandidateSelectionCandidate,
ordering_config: SchedulerOrderingConfig,
) -> CandidateTransportRankingFacts {
let identity = candidate_transport_identity(candidate);
if let Some(facts) = cache.get(&identity).copied() {
if let Some(facts) = cache.candidate_facts.get(&identity).copied() {
return facts;
}
let facts =
resolve_candidate_transport_ranking_facts_from_transport(state, transport, ordering_config)
.await;
cache.insert(identity, facts);
resolve_candidate_transport_ranking_facts(state, cache, candidate, ordering_config).await;
cache.candidate_facts.insert(identity, facts);
facts
}
pub(super) async fn resolve_cached_transport_ranking_facts(
state: PlannerAppState<'_>,
cache: &mut CandidateTransportRankingFactsCache,
candidate: &SchedulerMinimalCandidateSelectionCandidate,
transport: &GatewayProviderTransportSnapshot,
ordering_config: SchedulerOrderingConfig,
) -> CandidateTransportRankingFacts {
let identity = candidate_transport_identity(candidate);
if let Some(facts) = cache.candidate_facts.get(&identity).copied() {
return facts;
}
let facts = resolve_candidate_transport_ranking_facts_from_transport(
state,
cache,
transport,
ordering_config,
)
.await;
cache.candidate_facts.insert(identity, facts);
facts
}
@@ -58,13 +75,15 @@ pub(super) async fn candidate_keeps_priority_on_conversion(
candidate: &SchedulerMinimalCandidateSelectionCandidate,
ordering_config: SchedulerOrderingConfig,
) -> bool {
resolve_candidate_transport_ranking_facts(state, candidate, ordering_config)
let mut cache = CandidateTransportRankingFactsCache::default();
resolve_candidate_transport_ranking_facts(state, &mut cache, candidate, ordering_config)
.await
.keep_priority_on_conversion
}
async fn resolve_candidate_transport_ranking_facts(
state: PlannerAppState<'_>,
cache: &mut CandidateTransportRankingFactsCache,
candidate: &SchedulerMinimalCandidateSelectionCandidate,
ordering_config: SchedulerOrderingConfig,
) -> CandidateTransportRankingFacts {
@@ -75,17 +94,23 @@ async fn resolve_candidate_transport_ranking_facts(
};
};
resolve_candidate_transport_ranking_facts_from_transport(state, &transport, ordering_config)
.await
resolve_candidate_transport_ranking_facts_from_transport(
state,
cache,
&transport,
ordering_config,
)
.await
}
async fn resolve_candidate_transport_ranking_facts_from_transport(
state: PlannerAppState<'_>,
cache: &mut CandidateTransportRankingFactsCache,
transport: &GatewayProviderTransportSnapshot,
ordering_config: SchedulerOrderingConfig,
) -> CandidateTransportRankingFacts {
CandidateTransportRankingFacts {
tunnel_bucket: resolve_tunnel_owner_affinity_from_transport(state, transport).await,
tunnel_bucket: resolve_tunnel_owner_affinity_from_transport(state, cache, transport).await,
keep_priority_on_conversion: ordering_config.keep_priority_on_conversion
|| transport.provider.keep_priority_on_conversion,
}
@@ -93,12 +118,11 @@ async fn resolve_candidate_transport_ranking_facts_from_transport(
async fn resolve_tunnel_owner_affinity_from_transport(
state: PlannerAppState<'_>,
cache: &mut CandidateTransportRankingFactsCache,
transport: &GatewayProviderTransportSnapshot,
) -> SchedulerTunnelAffinityBucket {
let Some(proxy) = state
.app()
.resolve_transport_proxy_snapshot_with_tunnel_affinity(transport)
.await
let Some(proxy) =
resolve_transport_proxy_snapshot_with_tunnel_affinity_cached(state, cache, transport).await
else {
return SchedulerTunnelAffinityBucket::Neutral;
};
@@ -114,10 +138,74 @@ async fn resolve_tunnel_owner_affinity_from_transport(
return SchedulerTunnelAffinityBucket::Neutral;
};
if let Some(bucket) = cache.tunnel_buckets_by_node_id.get(node_id).copied() {
return bucket;
}
let bucket = resolve_tunnel_owner_affinity_from_proxy(state, &proxy, node_id).await;
cache
.tunnel_buckets_by_node_id
.insert(node_id.to_string(), bucket);
bucket
}
async fn resolve_transport_proxy_snapshot_with_tunnel_affinity_cached(
state: PlannerAppState<'_>,
cache: &mut CandidateTransportRankingFactsCache,
transport: &GatewayProviderTransportSnapshot,
) -> Option<ProxySnapshot> {
for raw in [
transport.key.proxy.as_ref(),
transport.endpoint.proxy.as_ref(),
transport.provider.proxy.as_ref(),
]
.into_iter()
.flatten()
{
let cache_key = proxy_config_cache_key(raw);
if let Some(snapshot) = cache.configured_proxy_snapshots.get(&cache_key) {
if snapshot.is_some() {
return snapshot.clone();
}
continue;
}
let snapshot = state
.app()
.resolve_configured_proxy_snapshot_with_tunnel_affinity(Some(raw))
.await;
cache
.configured_proxy_snapshots
.insert(cache_key, snapshot.clone());
if snapshot.is_some() {
return snapshot;
}
}
if let Some(snapshot) = cache.system_proxy_snapshot.as_ref() {
return snapshot.clone();
}
let snapshot = state.app().resolve_system_proxy_snapshot().await;
cache.system_proxy_snapshot = Some(snapshot.clone());
snapshot
}
async fn resolve_tunnel_owner_affinity_from_proxy(
state: PlannerAppState<'_>,
proxy: &ProxySnapshot,
node_id: &str,
) -> SchedulerTunnelAffinityBucket {
if state.app().tunnel.has_local_proxy(node_id) {
return SchedulerTunnelAffinityBucket::LocalTunnel;
}
if let Some(owner_instance_id) = proxy_tunnel_owner_instance_id(proxy) {
return if owner_instance_id == state.app().tunnel.local_instance_id() {
SchedulerTunnelAffinityBucket::LocalTunnel
} else {
SchedulerTunnelAffinityBucket::RemoteTunnel
};
}
match state
.app()
.tunnel
@@ -144,10 +232,25 @@ async fn resolve_tunnel_owner_affinity_from_transport(
fn candidate_transport_identity(
candidate: &SchedulerMinimalCandidateSelectionCandidate,
) -> CandidateTransportIdentity<'_> {
) -> CandidateTransportIdentity {
(
candidate.provider_id.as_str(),
candidate.endpoint_id.as_str(),
candidate.key_id.as_str(),
candidate.provider_id.clone(),
candidate.endpoint_id.clone(),
candidate.key_id.clone(),
)
}
fn proxy_config_cache_key(raw: &Value) -> String {
serde_json::to_string(raw).unwrap_or_else(|_| raw.to_string())
}
fn proxy_tunnel_owner_instance_id(proxy: &ProxySnapshot) -> Option<&str> {
proxy
.extra
.as_ref()
.and_then(Value::as_object)
.and_then(|extra| extra.get(TUNNEL_OWNER_INSTANCE_ID_EXTRA_KEY))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
}
@@ -1,15 +1,16 @@
use axum::body::Bytes;
use crate::ai_serving::is_json_request;
use crate::ai_serving::{
endpoint_config_forces_upstream_stream_policy as endpoint_config_forces_upstream_stream_policy_impl,
enforce_request_body_stream_field as enforce_request_body_stream_field_impl,
force_upstream_streaming_for_provider as force_upstream_streaming_for_provider_impl,
is_json_request, parse_direct_request_body as parse_direct_request_body_impl,
resolve_upstream_is_stream_from_endpoint_config as resolve_upstream_is_stream_from_endpoint_config_impl,
parse_direct_request_body as parse_direct_request_body_impl,
resolve_format_upstream_is_stream_for_provider as resolve_upstream_is_stream_for_provider_impl,
};
pub(crate) use crate::ai_serving::{
CLAUDE_CHAT_STREAM_PLAN_KIND, CLAUDE_CHAT_SYNC_PLAN_KIND, CLAUDE_CLI_STREAM_PLAN_KIND,
CLAUDE_CLI_SYNC_PLAN_KIND, EXECUTION_RUNTIME_STREAM_ACTION,
CLAUDE_CLI_SYNC_PLAN_KIND, CLAUDE_COUNT_TOKENS_SYNC_PLAN_KIND, EXECUTION_RUNTIME_STREAM_ACTION,
EXECUTION_RUNTIME_STREAM_DECISION_ACTION, EXECUTION_RUNTIME_SYNC_ACTION,
EXECUTION_RUNTIME_SYNC_DECISION_ACTION, GEMINI_CHAT_STREAM_PLAN_KIND,
GEMINI_CHAT_SYNC_PLAN_KIND, GEMINI_CLI_STREAM_PLAN_KIND, GEMINI_CLI_SYNC_PLAN_KIND,
@@ -20,9 +21,10 @@ pub(crate) use crate::ai_serving::{
OPENAI_EMBEDDING_SYNC_PLAN_KIND, OPENAI_IMAGE_STREAM_PLAN_KIND, OPENAI_IMAGE_SYNC_PLAN_KIND,
OPENAI_RERANK_SYNC_PLAN_KIND, OPENAI_RESPONSES_COMPACT_STREAM_PLAN_KIND,
OPENAI_RESPONSES_COMPACT_SYNC_PLAN_KIND, OPENAI_RESPONSES_STREAM_PLAN_KIND,
OPENAI_RESPONSES_SYNC_PLAN_KIND, OPENAI_VIDEO_CANCEL_SYNC_PLAN_KIND,
OPENAI_VIDEO_CONTENT_PLAN_KIND, OPENAI_VIDEO_CREATE_SYNC_PLAN_KIND,
OPENAI_VIDEO_DELETE_SYNC_PLAN_KIND, OPENAI_VIDEO_REMIX_SYNC_PLAN_KIND,
OPENAI_RESPONSES_SYNC_PLAN_KIND, OPENAI_SEARCH_SYNC_PLAN_KIND,
OPENAI_VIDEO_CANCEL_SYNC_PLAN_KIND, OPENAI_VIDEO_CONTENT_PLAN_KIND,
OPENAI_VIDEO_CREATE_SYNC_PLAN_KIND, OPENAI_VIDEO_DELETE_SYNC_PLAN_KIND,
OPENAI_VIDEO_REMIX_SYNC_PLAN_KIND,
};
pub(crate) use aether_ai_serving::AiRequestedModelFamily as RequestedModelFamily;
@@ -54,10 +56,10 @@ pub(crate) fn resolve_upstream_is_stream_for_provider(
client_is_stream: bool,
hard_requires_streaming: bool,
) -> bool {
let hard_requires_streaming = hard_requires_streaming
|| force_upstream_streaming_for_provider(provider_type, provider_api_format);
resolve_upstream_is_stream_from_endpoint_config_impl(
resolve_upstream_is_stream_for_provider_impl(
endpoint_config,
provider_type,
provider_api_format,
client_is_stream,
hard_requires_streaming,
)
@@ -178,6 +180,27 @@ mod tests {
true,
false,
));
assert!(!resolve_upstream_is_stream_for_provider(
Some(&json!({"upstream_stream_policy": "force_stream"})),
"codex",
"openai:image",
true,
true,
));
assert!(!resolve_upstream_is_stream_for_provider(
Some(&json!({"upstream_stream_policy": "force_stream"})),
"codex",
"openai:responses:compact",
true,
true,
));
assert!(!resolve_upstream_is_stream_for_provider(
Some(&json!({"upstream_stream_policy": "force_stream"})),
"custom",
"openai:responses:compact",
true,
true,
));
}
#[test]

Some files were not shown because too many files have changed in this diff Show More