@pure01fx/dsh-openai-codex-auth 0.7.2 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,26 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.8.0
6
+
7
+ ### Multi-account authentication
8
+
9
+ - Adds multiple managed ChatGPT OAuth accounts with a global current-account selector, per-account removal, optional email labels, and automatic activation of newly added accounts.
10
+ - Migrates legacy version-1 single-account credential documents atomically to version 2 while keeping DSH credential publication and rollback guarantees.
11
+ - Pins every request/recovery attempt to its starting account so a concurrent account switch cannot replay an authenticated request under another account.
12
+ - Extends the same-origin settings API and Web UI with an account list, explicit switching, and individual logout controls without exposing access or refresh tokens.
13
+
14
+ ## 0.7.3
15
+
16
+ ### Client model visibility
17
+
18
+ - Connects the settings-panel Codex visibility controls to DSH's shared model directory: opening either model selector now caches the loaded Codex catalog, updates an already-open settings section, and filters hidden models in the current browser.
19
+
20
+ ### WebSocket reliability
21
+
22
+ - Keeps credential-backed WebSocket connection failures inside the bounded reconnect budget and falls back to HTTP/SSE, preventing turns from remaining on “Deep diving...” forever when a server can reach Codex over HTTPS but not WebSocket.
23
+ - Routes WebSocket v2 through the same opt-in `NODE_USE_ENV_PROXY=1`, `HTTPS_PROXY` / `HTTP_PROXY`, and `NO_PROXY` contract as Node fetch, using an HTTP CONNECT tunnel for WSS endpoints.
24
+
5
25
  ## 0.7.2
6
26
 
7
27
  ### Auxiliary-call compatibility
package/README.md CHANGED
@@ -48,6 +48,14 @@ dsh web
48
48
 
49
49
  设备码登录无需 OAuth callback,适合本机、SSH 隧道和 HTTPS 反向代理场景。
50
50
 
51
+ ## 多账号切换
52
+
53
+ - 已登录后继续点击 **用设备码添加账号** 或 **用浏览器添加账号**,可追加 ChatGPT 账号;新登录的账号会自动成为当前账号。
54
+ - 设置页的 **已添加账号** 列表支持设为当前和单独移除。切换是全局的,只影响切换完成后的新 Codex 请求;已开始的请求继续使用启动时绑定的账号。
55
+ - 移除当前账号时,插件按账号列表顺序选择下一个账号;移除最后一个账号后会清除 `DSH_OPENAI_CODEX_TOKEN`。
56
+ - 原有单账号 `version: 1` 凭据会在首次读取时原子迁移为 `version: 2` 多账号文档,无需重新登录。
57
+ - 如果 `DSH_OPENAI_CODEX_TOKEN` 来自只读环境变量或其他外部 authority,插件会拒绝需要改写当前 token 的登录、切换和退出操作,避免界面与实际请求账号不一致。
58
+
51
59
  ## 两种登录方式
52
60
 
53
61
  ### 设备码登录(推荐)
@@ -82,6 +90,7 @@ http://localhost:1455/auth/callback
82
90
  | --- | --- |
83
91
  | 设备码登录 | 无本机 callback,支持远程/headless 使用 |
84
92
  | 浏览器 OAuth fallback | PKCE + state、端到端 1455 探测、手动 code 回填和临时 callback listener |
93
+ | 多账号管理 | 添加多个 OAuth 账号、全局切换当前账号、单独移除,并自动迁移旧单账号凭据 |
85
94
  | Codex 用量面板 | 展示短周期与周用量、剩余额度和重置时间;Native 模式直接接收 Codex 返回的套餐额度,`wham/usage` 仅作初始读取和 fallback |
86
95
  | 当前路由状态 | 设置页显示 `openai-codex` 当前由本插件 Native Adapter、外部 Adapter 或无人持有;Native 模式同时显示 WebSocket v2 或 HTTP/SSE |
87
96
  | 输入框额度圈 | 登录后显示在输入框右下角;悬停或键盘聚焦显示用量,点击圆圈强制刷新,不做常驻额度轮询 |
@@ -99,9 +108,9 @@ http://localhost:1455/auth/callback
99
108
 
100
109
  登录成功后:
101
110
 
102
- 1. 凭据以 owner-only 权限原子写入 `$DSH_HOME/openai-codex-auth.json`。
103
- 2. access token 通过 DSH credentials 注入 `DSH_OPENAI_CODEX_TOKEN`。
104
- 3. `openai-codex` 模型提供方在请求时读取该凭据。
111
+ 1. 多账号凭据以 owner-only 权限原子写入 `$DSH_HOME/openai-codex-auth.json`;文档保存账号列表与全局当前账号指针。
112
+ 2. 当前账号的 access token 通过 DSH credentials 注入 `DSH_OPENAI_CODEX_TOKEN`。
113
+ 3. `openai-codex` 模型提供方在每个请求开始时读取当前凭据,并把该次尝试固定到同一个账号。
105
114
  4. 输入框右下角显示 Codex 额度圈;悬停或键盘聚焦显示当前用量,点击才强制刷新。Native transport 优先消费 WebSocket `codex.rate_limits` 事件或 HTTP `x-codex-*` headers,未收到直接更新时才在 turn 结束后读取 `wham/usage`。
106
115
  5. 设置页与额度圈只读取登录状态、账号 ID、过期时间和用量摘要,不接触 token。
107
116
 
@@ -111,13 +120,15 @@ http://localhost:1455/auth/callback
111
120
 
112
121
  | 方法 | 路径 | 用途 |
113
122
  | --- | --- | --- |
114
- | GET | `/openai-codex/status` | 登录、flow、用量和 CSRF 状态 |
123
+ | GET | `/openai-codex/status` | 登录、账号列表、当前账号、flow、用量和 CSRF 状态 |
124
+ | POST | `/openai-codex/accounts/current` | 以 `{ "accountId": "…" }` 设定全局当前账号 |
125
+ | POST | `/openai-codex/accounts/logout` | 以 `{ "accountId": "…" }` 单独移除账号 |
115
126
  | POST | `/openai-codex/device/start` | 创建或复用设备码 flow |
116
127
  | POST | `/openai-codex/browser/prepare` | 创建浏览器 flow,返回授权 URL 和一次性 1455 探测 URL |
117
128
  | POST | `/openai-codex/browser/complete` | 接收用户粘贴的 callback URL 或 authorization code |
118
129
  | GET | `/openai-codex/browser/start` | 兼容入口:直接启动并跳转浏览器 OAuth |
119
130
  | POST | `/openai-codex/cancel` | 取消当前登录、关闭临时 listener,保留旧凭据 |
120
- | POST | `/openai-codex/logout` | 取消 flow 并删除本地凭据 |
131
+ | POST | `/openai-codex/logout` | 兼容入口:取消 flow 并移除当前账号 |
121
132
 
122
133
  1456 已完全退役。1455 平时关闭,仅浏览器 OAuth pending 时绑定 loopback;设备码登录完全不使用它。
123
134
 
@@ -189,7 +200,7 @@ $DSH_HOME/openai-codex-auth.json
189
200
 
190
201
  上游 Native Codex Responses 请求没有输出 token 上限字段,因此普通会话若显式设置 `maxTokens` 仍会在 Provider I/O 前失败。DSH 的压缩与会话标题辅助调用会固定携带 `maxTokens`,并分别通过 `purpose: compaction` 与 `purpose: session-title` 标识;本适配器允许这两类 hint 通过,但不会把它序列化为 `max_output_tokens`,实际输出长度仍由 Native Codex 决定。
191
202
 
192
- 原生 route 默认使用 Responses WebSocket v2,在会话首个请求上执行 `generate: false` prewarm,并仅在请求历史严格延伸时发送 `previous_response_id` 与增量后缀。连接重建会清除增量链;纯 WebSocket/HTTP 连接建立失败不消耗普通 stream retry 预算,而是按 Codex 的网络恢复策略从 5 秒指数退避到 60 秒并持续等待成功或取消,因此可跨越半分钟断网。连接已经建立后的首个 DSH chunk 前错误仍使用默认 5 次 stream retry 与 200ms 起步的有界指数退避,安全重试耗尽或握手返回 HTTP 426 后,该 DSH 会话会确定性地回退到 HTTP/SSE。transport 层在任何 DSH chunk 已输出后仍不会直接重放原始 stream;它会把瞬态中断重新分类为只允许 durable failed-step 恢复的错误,标准 Agent Profile 中的 `dsh-llm-retry` 再按默认 5 次策略重建请求。该外层策略不会再次匹配 transport 已在首个 chunk 前耗尽的有限 stream 错误,避免两层预算相乘;失败 attempt 的原始事件可以留作非 surface 诊断,但不会组装成模型可见的持久 assistant message。未加载该恢复插件的直接 `ctx.llm.stream()` 调用在首个 chunk 后仍保持 single-attempt,避免重复输出。WebSocket `codex.rate_limits` 事件、握手/错误 metadata 以及 HTTP/SSE response headers 中的 `x-codex-*` quota 会被边界校验后直接写入 Host 用量缓存;额外 metered limit 不会覆盖默认 `codex` 套餐卡片。`response.completed.usage_metadata.amount` 会按原始高精度字符串保留,并作为当前账号的最近响应观测出现在 status JSON 中。
203
+ 原生 route 默认使用 Responses WebSocket v2,在会话首个请求上执行 `generate: false` prewarm,并仅在请求历史严格延伸时发送 `previous_response_id` 与增量后缀。连接重建会清除增量链;凭据解析完成前的网络失败不消耗普通 stream retry 预算,而是按 Codex 的网络恢复策略从 5 秒指数退避到 60 秒并持续等待成功或取消。凭据可用后,WebSocket 连接建立失败与连接已经建立后的首个 DSH chunk 前错误都使用默认 5 次 stream retry 与 200ms 起步的有界指数退避;安全重试耗尽或握手返回 HTTP 426 后,该 DSH 会话会确定性地回退到 HTTP/SSE,因此仅 WebSocket 不可达不会让界面无限停在 Deep diving。transport 层在任何 DSH chunk 已输出后仍不会直接重放原始 stream;它会把瞬态中断重新分类为只允许 durable failed-step 恢复的错误,标准 Agent Profile 中的 `dsh-llm-retry` 再按默认 5 次策略重建请求。该外层策略不会再次匹配 transport 已在首个 chunk 前耗尽的有限 stream 错误,避免两层预算相乘;失败 attempt 的原始事件可以留作非 surface 诊断,但不会组装成模型可见的持久 assistant message。未加载该恢复插件的直接 `ctx.llm.stream()` 调用在首个 chunk 后仍保持 single-attempt,避免重复输出。WebSocket `codex.rate_limits` 事件、握手/错误 metadata 以及 HTTP/SSE response headers 中的 `x-codex-*` quota 会被边界校验后直接写入 Host 用量缓存;额外 metered limit 不会覆盖默认 `codex` 套餐卡片。`response.completed.usage_metadata.amount` 会按原始高精度字符串保留,并作为当前账号的最近响应观测出现在 status JSON 中。
193
204
 
194
205
  `<base>-fast` 只是公开选择别名:wire model 仍是 `<base>`,请求携带 `service_tier: priority`。若账号目录不声明 priority 能力,或请求前账号 authority 已改变,Fast 会直接失败,不会静默降级。Reasoning 选择在 adapter 边界按上游规则转换:`persistent` 在线上请求中写为 `disabled`;`ultra` 优先使用 Catalog 的 `multi_agent_reasoning_effort`,否则回退到 `max`、最高非 Ultra 档或 `medium`。
195
206
 
@@ -221,7 +232,7 @@ $DSH_HOME/openai-codex-auth.json
221
232
 
222
233
  ## 代理
223
234
 
224
- 插件使用 Node.js 原生 `fetch`。若访问 OpenAI 需要 HTTP 代理,启动 DSH 前设置:
235
+ 插件的 HTTP/SSE 请求使用 Node.js 原生 `fetch`,WebSocket v2 使用 `ws`。设置 `NODE_USE_ENV_PROXY=1` 后,两条链路都会按 `HTTPS_PROXY` / `HTTP_PROXY` 选择 HTTP CONNECT 代理并遵守 `NO_PROXY`;若访问 OpenAI 需要代理,请在启动 DSH 前设置:
225
236
 
226
237
  ```sh
227
238
  NODE_USE_ENV_PROXY=1 \
@@ -230,7 +241,7 @@ HTTPS_PROXY=http://127.0.0.1:7890 \
230
241
  dsh web
231
242
  ```
232
243
 
233
- 可按环境补充 `NO_PROXY=localhost,127.0.0.1,::1`。
244
+ 可按环境补充 `NO_PROXY=localhost,127.0.0.1,::1`。对于 `https://` / `wss://` Codex 端点应设置 `HTTPS_PROXY`;代理地址本身可以是 `http://`,WebSocket 会通过该代理向 `chatgpt.com:443` 建立 CONNECT 隧道。
234
245
 
235
246
  ## 常见问题
236
247
 
package/client.js CHANGED
@@ -49,6 +49,121 @@ window.__ModuleLoader__.load({
49
49
  }
50
50
  }
51
51
 
52
+ function writeAvailableModels(models) {
53
+ try {
54
+ const next = JSON.stringify(models)
55
+ if (window.localStorage.getItem(AVAILABLE_MODELS_KEY) === next) return
56
+ window.localStorage.setItem(AVAILABLE_MODELS_KEY, next)
57
+ window.dispatchEvent(new CustomEvent(MODEL_CATALOG_EVENT, { detail: models }))
58
+ } catch {
59
+ // Browser-local catalog persistence is advisory and must never break model loading.
60
+ }
61
+ }
62
+
63
+ function codexModels(groups) {
64
+ const models = []
65
+ const seen = new Set()
66
+ for (const group of Array.isArray(groups) ? groups : []) {
67
+ if (!group || (group.id !== 'openai-codex' && group.id !== 'openai-codex-native')) continue
68
+ for (const model of Array.isArray(group.models) ? group.models : []) {
69
+ if (!model || typeof model.id !== 'string' || model.id.length === 0
70
+ || typeof model.name !== 'string' || model.name.length === 0 || seen.has(model.id)) continue
71
+ seen.add(model.id)
72
+ models.push({ id: model.id, name: model.name })
73
+ if (models.length >= 512) return models
74
+ }
75
+ }
76
+ return models
77
+ }
78
+
79
+ function visibleModelGroups(groups) {
80
+ if (!Array.isArray(groups)) return []
81
+ const hidden = new Set(readModelVisibility().hidden)
82
+ if (hidden.size === 0) return groups
83
+ return groups.flatMap((group) => {
84
+ if (!group || (group.id !== 'openai-codex' && group.id !== 'openai-codex-native')) return [group]
85
+ const models = Array.isArray(group.models)
86
+ ? group.models.filter((model) => model && !hidden.has(model.id)) : []
87
+ return [{ ...group, models }]
88
+ })
89
+ }
90
+
91
+ function installModelVisibilityBridge(ctx) {
92
+ const resolver = ctx.modelDirectories
93
+ if (!resolver || typeof resolver.directoryFor !== 'function') return () => {}
94
+ const originalDirectoryFor = resolver.directoryFor
95
+ const tracked = new Map()
96
+
97
+ const render = (directory, rawGroups) => {
98
+ const available = codexModels(rawGroups)
99
+ if (available.length > 0) writeAvailableModels(available)
100
+ const groups = visibleModelGroups(rawGroups)
101
+ if (directory.store && typeof directory.store.update === 'function') {
102
+ directory.store.update((state) => { state.groups = groups })
103
+ }
104
+ return groups
105
+ }
106
+
107
+ const track = (directory) => {
108
+ if (!directory || tracked.has(directory)
109
+ || typeof directory.load !== 'function' || typeof directory.dispose !== 'function') return directory
110
+ const record = {
111
+ load: directory.load,
112
+ dispose: directory.dispose,
113
+ wrappedLoad: undefined,
114
+ wrappedDispose: undefined,
115
+ generation: 0,
116
+ rawGroups: directory.store && typeof directory.store.getSnapshot === 'function'
117
+ ? directory.store.getSnapshot().groups : [],
118
+ }
119
+ record.wrappedLoad = async function (...args) {
120
+ const generation = ++record.generation
121
+ const value = await record.load.apply(this, args)
122
+ const rawGroups = Array.isArray(value && value.groups) ? value.groups : []
123
+ if (generation !== record.generation) {
124
+ const groups = visibleModelGroups(rawGroups)
125
+ return value && typeof value === 'object' ? { ...value, groups } : value
126
+ }
127
+ record.rawGroups = rawGroups
128
+ const groups = render(directory, record.rawGroups)
129
+ return value && typeof value === 'object' ? { ...value, groups } : value
130
+ }
131
+ record.wrappedDispose = function (...args) {
132
+ ++record.generation
133
+ tracked.delete(directory)
134
+ return record.dispose.apply(this, args)
135
+ }
136
+ directory.load = record.wrappedLoad
137
+ directory.dispose = record.wrappedDispose
138
+ tracked.set(directory, record)
139
+ if (record.rawGroups.length > 0) render(directory, record.rawGroups)
140
+ return directory
141
+ }
142
+
143
+ const bridgedDirectoryFor = function (...args) {
144
+ return track(originalDirectoryFor.apply(this, args))
145
+ }
146
+ resolver.directoryFor = bridgedDirectoryFor
147
+ if (resolver.live && resolver.live.directories && typeof resolver.live.directories.values === 'function') {
148
+ for (const directory of resolver.live.directories.values()) track(directory)
149
+ }
150
+ const refresh = () => {
151
+ for (const [directory, record] of tracked) render(directory, record.rawGroups)
152
+ }
153
+ window.addEventListener(MODEL_VISIBILITY_EVENT, refresh)
154
+
155
+ return () => {
156
+ window.removeEventListener(MODEL_VISIBILITY_EVENT, refresh)
157
+ if (resolver.directoryFor === bridgedDirectoryFor) resolver.directoryFor = originalDirectoryFor
158
+ for (const [directory, record] of tracked) {
159
+ ++record.generation
160
+ if (directory.load === record.wrappedLoad) directory.load = record.load
161
+ if (directory.dispose === record.wrappedDispose) directory.dispose = record.dispose
162
+ }
163
+ tracked.clear()
164
+ }
165
+ }
166
+
52
167
  const css = `
53
168
  .codexSection{box-sizing:border-box;max-width:720px;color:var(--dsw-alias-label-primary);display:flex;flex-direction:column;gap:12px}
54
169
  .codexSection *{box-sizing:border-box}.codexTitle{color:var(--dsw-alias-label-primary);margin:0;font-size:16px;font-weight:500;line-height:24px}
@@ -550,7 +665,38 @@ window.__ModuleLoader__.load({
550
665
  }
551
666
  }
552
667
 
668
+ const mutateAccount = async (path, accountId, action) => {
669
+ if (busy) return
670
+ setBusy(action + ':' + accountId)
671
+ setError('')
672
+ try {
673
+ await post(path, { accountId })
674
+ await load(false)
675
+ } catch (mutationError) {
676
+ setError(messageOf(mutationError))
677
+ } finally {
678
+ setBusy('')
679
+ }
680
+ }
681
+
682
+ const setCurrentAccount = async (accountId) => {
683
+ await mutateAccount('/accounts/current', accountId, 'current')
684
+ }
685
+
686
+ const removeAccount = async (accountId) => {
687
+ const account = accounts.find((candidate) => candidate.accountId === accountId)
688
+ const label = account && account.email ? account.email : shortAccount(accountId)
689
+ const isCurrent = account && (account.current === true || account.accountId === currentAccountId)
690
+ const isLast = accounts.length === 1
691
+ const consequence = isLast
692
+ ? '移除后将退出 Codex,重新使用需要再次完成 OAuth 登录。'
693
+ : isCurrent ? '移除后会自动切换到列表中的下一个账号。' : '该账号的本地 OAuth 凭据会被删除。'
694
+ if (!window.confirm('确定移除 ' + label + '?\n\n' + consequence)) return
695
+ await mutateAccount('/accounts/logout', accountId, 'remove')
696
+ }
697
+
553
698
  const logout = async () => {
699
+ if (busy) return
554
700
  setBusy('logout')
555
701
  probeGeneration.current += 1
556
702
  try {
@@ -586,6 +732,10 @@ window.__ModuleLoader__.load({
586
732
  const usage = status && status.usage
587
733
  const loading = status === null && !error
588
734
  const connected = Boolean(status && status.loggedIn)
735
+ const accounts = status && Array.isArray(status.accounts) ? status.accounts : []
736
+ const currentAccountId = status && status.currentAccountId
737
+ const currentAccount = accounts.find((account) => account.current === true || account.accountId === currentAccountId)
738
+ const currentAccountLabel = currentAccount && currentAccount.email ? currentAccount.email : shortAccount(status && status.accountId)
589
739
  const pending = Boolean(status && status.loginPending) || watchLogin
590
740
  const devicePending = Boolean(status && status.loginMethod === 'device' && status.device)
591
741
  const browserPending = Boolean(status && status.loginMethod === 'browser' && status.browser)
@@ -616,7 +766,7 @@ window.__ModuleLoader__.load({
616
766
  h('div', { className: 'codexLogo', 'aria-hidden': true }, 'OA'),
617
767
  h('div', null,
618
768
  h('h3', { className: 'codexName' }, 'OpenAI Codex 订阅'),
619
- h('p', { className: 'codexMeta', title: connected ? status.accountId : '' }, loading ? '正在读取 OpenAI 登录状态' : connected ? shortAccount(status.accountId) : '尚未连接 ChatGPT 账号'),
769
+ h('p', { className: 'codexMeta', title: connected ? status.accountId : '' }, loading ? '正在读取 OpenAI 登录状态' : connected ? currentAccountLabel : '尚未连接 ChatGPT 账号'),
620
770
  ),
621
771
  ),
622
772
  h('span', { className: 'codexBadge ' + (loading || pending ? 'pending' : connected ? 'connected' : '') },
@@ -636,6 +786,31 @@ window.__ModuleLoader__.load({
636
786
  h('p', { className: 'codexRouteDetail' }, route.detail),
637
787
  )
638
788
  : null,
789
+ !loading && accounts.length > 0
790
+ ? h('div', { className: 'codexModelVisibility' },
791
+ h('div', { className: 'codexModelVisibilityHead' },
792
+ h('strong', null, '已添加账号'),
793
+ h('span', { className: 'codexMeta' }, accounts.length + ' 个'),
794
+ ),
795
+ h('p', { className: 'codexModelVisibilityHint' }, '当前账号用于新的 Codex 请求;你可以切换或单独移除任意账号。'),
796
+ h('div', { className: 'codexModelVisibilityList' }, accounts.map((account) => {
797
+ const isCurrent = account.current === true || account.accountId === currentAccountId
798
+ const accountBusy = busy === 'current:' + account.accountId || busy === 'remove:' + account.accountId
799
+ return h('div', { className: 'codexModelVisibilityOption', key: account.accountId },
800
+ h('span', { title: account.accountId },
801
+ h('strong', null, account.email || shortAccount(account.accountId)),
802
+ account.email ? h('span', null, ' · ' + shortAccount(account.accountId)) : null,
803
+ h('span', null, isCurrent ? ' · 当前账号' : ''),
804
+ account.expiresAt ? h('span', null, ' · 到期 ' + new Date(account.expiresAt).toLocaleString()) : null,
805
+ ),
806
+ h('div', { className: 'codexActions' },
807
+ !isCurrent ? h('button', { type: 'button', className: 'codexButton', disabled: Boolean(busy), onClick: () => { void setCurrentAccount(account.accountId) } }, accountBusy && busy.startsWith('current:') ? '切换中…' : '设为当前') : null,
808
+ h('button', { type: 'button', className: 'codexButton danger', disabled: Boolean(busy), onClick: () => { void removeAccount(account.accountId) } }, accountBusy && busy.startsWith('remove:') ? '移除中…' : '移除'),
809
+ ),
810
+ )
811
+ })),
812
+ )
813
+ : null,
639
814
  loading
640
815
  ? h('div', { 'aria-label': '加载中' }, h('div', { className: 'codexSkeleton' }), h('div', { className: 'codexSkeleton', style: { width: '72%' } }))
641
816
  : connected
@@ -733,11 +908,11 @@ window.__ModuleLoader__.load({
733
908
  : null,
734
909
  h('div', { className: 'codexActions' },
735
910
  h('button', { type: 'button', className: 'codexButton primary', disabled: Boolean(busy) || pending || loading, onClick: () => { void startDevice() } },
736
- busy === 'device' ? '正在申请设备码…' : connected ? '用设备码重新登录' : '使用设备码登录'),
911
+ busy === 'device' ? '正在申请设备码…' : connected ? '用设备码添加账号' : '使用设备码登录'),
737
912
  h('button', {
738
913
  type: 'button', className: 'codexButton', disabled: Boolean(busy) || pending || loading || !browserAvailable,
739
914
  title: browserAvailable ? '展开 callback 检测,不会自动弹出窗口' : '仅支持 HTTP 127.0.0.1/localhost 入口', onClick: () => { void startBrowser() },
740
- }, busy === 'browser' ? '正在准备浏览器登录…' : connected ? '用浏览器重新登录' : '本机浏览器 OAuth'),
915
+ }, busy === 'browser' ? '正在准备浏览器登录…' : connected ? '用浏览器添加账号' : '本机浏览器 OAuth'),
741
916
  pending && !devicePending && !browserPending ? h('button', { type: 'button', className: 'codexButton danger', disabled: Boolean(busy), onClick: () => { void cancel() } }, busy === 'cancel' ? '取消中…' : '取消登录') : null,
742
917
  connected ? h('button', { type: 'button', className: 'codexButton', disabled: Boolean(busy), onClick: refresh }, busy === 'refresh' ? '刷新中…' : '刷新用量') : null,
743
918
  connected || (status && status.credentialError) ? h('button', { type: 'button', className: 'codexButton danger', disabled: Boolean(busy), onClick: () => { void logout() } }, busy === 'logout' ? '退出中…' : '退出登录') : null,
@@ -748,8 +923,11 @@ window.__ModuleLoader__.load({
748
923
  )
749
924
  }
750
925
 
751
- const inject = ['slots']
926
+ const inject = ['slots', 'modelDirectories']
752
927
  function apply(ctx) {
928
+ ctx.inject(['modelDirectories'], (scope) => {
929
+ scope.effect(() => installModelVisibilityBridge(scope), 'openai-codex-auth: model visibility bridge')
930
+ })
753
931
  ctx.slots.inject('settings.section', () => ctx.slots.register({
754
932
  name: 'settings.section',
755
933
  id: 'openai-codex',
package/lib/index.d.ts CHANGED
@@ -7,12 +7,23 @@ import { CODEX_PROVIDER } from './native-adapter.js';
7
7
  export { CODEX_PROVIDER, NATIVE_CODEX_PROVIDER } from './native-adapter.js';
8
8
  export { normalizeUsage } from './usage.js';
9
9
  export { CODEX_CLIENT_VERSION, TRACKED_CODEX_COMMIT, TRACKED_CODEX_RELEASE, TRACKED_CODEX_REPOSITORY, } from './upstream.js';
10
- /** Persisted OAuth credential. */
10
+ /** Persisted OAuth credential for one ChatGPT account. */
11
11
  export interface OpenAICodexCredential {
12
12
  access: string;
13
13
  refresh: string;
14
14
  expires: number;
15
15
  accountId: string;
16
+ email?: string;
17
+ }
18
+ /** Current multi-account credential document. */
19
+ export interface OpenAICodexCredentialDocument {
20
+ version: 2;
21
+ currentAccountId: string | null;
22
+ accounts: OpenAICodexCredential[];
23
+ }
24
+ interface ParsedCredentialDocument {
25
+ document: OpenAICodexCredentialDocument;
26
+ migrated: boolean;
16
27
  }
17
28
  /** Plugin configuration. */
18
29
  export interface Config {
@@ -73,6 +84,8 @@ interface TokenResponse {
73
84
  id_token?: unknown;
74
85
  }
75
86
  declare function parseTokenResponse(value: TokenResponse | null, previous?: OpenAICodexCredential): OpenAICodexCredential;
87
+ declare function parseCredentialDocument(text: string, filename: string): ParsedCredentialDocument;
88
+ declare function currentCredential(document: OpenAICodexCredentialDocument | undefined): OpenAICodexCredential | undefined;
76
89
  declare function parseAuthority(authority: string | undefined): URL | undefined;
77
90
  declare function isLoopbackHostname(hostname: string): boolean;
78
91
  declare function isTrustedHost(authority: string | undefined, trustedHosts: readonly string[]): boolean;
@@ -85,6 +98,8 @@ declare function pollDeviceAuthorization(device: DeviceAuthorization, signal?: A
85
98
  declare function resolveCodexRouteStatus(config: NativeRouteConfig, llm: RouteRuntime | undefined): CodexRouteStatus;
86
99
  export declare const internals: {
87
100
  parseTokenResponse: typeof parseTokenResponse;
101
+ parseCredentialDocument: typeof parseCredentialDocument;
102
+ currentCredential: typeof currentCredential;
88
103
  parseAuthority: typeof parseAuthority;
89
104
  isLoopbackHostname: typeof isLoopbackHostname;
90
105
  isTrustedHost: typeof isTrustedHost;
@@ -138,9 +153,11 @@ export declare class OpenAICodexAuth extends Service {
138
153
  private restorePublishedCredential;
139
154
  private publicationChangedError;
140
155
  private failAfterPublicationRollback;
156
+ private commitDocument;
157
+ private upsertCurrentCredential;
141
158
  private commitCredential;
142
159
  private resolveManagedCredentialLocked;
143
- /** Return a valid managed bearer token, refreshing and persisting it when near expiry. */
160
+ /** Return the current managed bearer token, refreshing and migrating it when needed. */
144
161
  bearerToken(signal?: AbortSignal): Promise<string | undefined>;
145
162
  private externalNativeCredential;
146
163
  private resolveNativeCredential;
@@ -154,6 +171,8 @@ export declare class OpenAICodexAuth extends Service {
154
171
  private beginBrowserLogin;
155
172
  private createBrowserLogin;
156
173
  private cancelLogin;
174
+ private resetCurrentAccountState;
175
+ private setCurrentAccount;
157
176
  private logout;
158
177
  private status;
159
178
  private fetchUsage;
@@ -169,6 +188,9 @@ export declare class OpenAICodexAuth extends Service {
169
188
  private handleBrowserComplete;
170
189
  private handleCallback;
171
190
  private handleCancel;
191
+ private accountIdBody;
192
+ private handleSetCurrentAccount;
193
+ private handleAccountLogout;
172
194
  private handleLogout;
173
195
  }
174
196
  export default OpenAICodexAuth;
package/lib/index.js CHANGED
@@ -46,6 +46,8 @@ class LoginConflictError extends Error {
46
46
  }
47
47
  class CredentialNotWritableError extends Error {
48
48
  }
49
+ class AccountNotFoundError extends Error {
50
+ }
49
51
  const PERMANENT_REFRESH_CODES = new Set([
50
52
  'refresh_token_expired',
51
53
  'refresh_token_reused',
@@ -124,28 +126,70 @@ function parseTokenResponse(value, previous) {
124
126
  : chatGptAccountId(idToken, 'ID token');
125
127
  if (resolvedAccountId === undefined)
126
128
  throw new Error('OpenAI token response has no ID token');
127
- return { access: value.access_token, refresh, expires, accountId: resolvedAccountId };
129
+ const emailClaim = idToken === undefined ? undefined : jwtPayload(idToken, 'ID token').email;
130
+ const email = typeof emailClaim === 'string' && emailClaim.trim() !== ''
131
+ ? emailClaim.trim()
132
+ : previous?.email;
133
+ return {
134
+ access: value.access_token,
135
+ refresh,
136
+ expires,
137
+ accountId: resolvedAccountId,
138
+ ...email === undefined ? {} : { email },
139
+ };
128
140
  }
129
141
  function isPermanentRefreshError(error) {
130
142
  return error instanceof OAuthEndpointError
131
143
  && (error.status === 401
132
144
  || (error.oauthCode !== undefined && PERMANENT_REFRESH_CODES.has(error.oauthCode.toLowerCase())));
133
145
  }
134
- function parseCredential(text, filename) {
135
- const value = JSON.parse(text);
136
- const credential = value.credential;
137
- if (value.version !== 1 || credential === undefined
146
+ function validateCredential(value, filename) {
147
+ const credential = value;
148
+ if (credential === null || typeof credential !== 'object'
138
149
  || typeof credential.access !== 'string' || credential.access.length === 0
139
150
  || typeof credential.refresh !== 'string' || credential.refresh.length === 0
140
151
  || typeof credential.expires !== 'number' || !Number.isFinite(credential.expires)
141
- || typeof credential.accountId !== 'string' || credential.accountId.length === 0) {
152
+ || typeof credential.accountId !== 'string' || credential.accountId.length === 0
153
+ || credential.accountId.length > 512
154
+ || (credential.email !== undefined && (typeof credential.email !== 'string' || credential.email.length > 512))) {
142
155
  throw new Error(`openai-codex-auth: invalid credential document ${filename}`);
143
156
  }
144
- return credential;
157
+ return {
158
+ access: credential.access,
159
+ refresh: credential.refresh,
160
+ expires: credential.expires,
161
+ accountId: credential.accountId,
162
+ ...credential.email === undefined ? {} : { email: credential.email },
163
+ };
145
164
  }
146
- async function readCredential(filename) {
165
+ function parseCredentialDocument(text, filename) {
166
+ const value = JSON.parse(text);
167
+ if (value.version === 1) {
168
+ const credential = validateCredential(value.credential, filename);
169
+ return {
170
+ document: { version: 2, currentAccountId: credential.accountId, accounts: [credential] },
171
+ migrated: true,
172
+ };
173
+ }
174
+ if (value.version !== 2 || !Array.isArray(value.accounts)
175
+ || (value.currentAccountId !== null && typeof value.currentAccountId !== 'string')) {
176
+ throw new Error(`openai-codex-auth: invalid credential document ${filename}`);
177
+ }
178
+ const accounts = value.accounts.map(account => validateCredential(account, filename));
179
+ const ids = new Set(accounts.map(account => account.accountId));
180
+ if (ids.size !== accounts.length
181
+ || (accounts.length === 0 && value.currentAccountId !== null)
182
+ || (accounts.length > 0 && (value.currentAccountId === null || !ids.has(value.currentAccountId)))) {
183
+ throw new Error(`openai-codex-auth: invalid credential document ${filename}`);
184
+ }
185
+ return {
186
+ document: { version: 2, currentAccountId: value.currentAccountId, accounts },
187
+ migrated: false,
188
+ };
189
+ }
190
+ async function readCredentialDocument(filename) {
147
191
  try {
148
- return parseCredential(await readFile(filename, 'utf8'), filename);
192
+ return parseCredentialDocument(await readFile(filename, 'utf8'), filename);
149
193
  }
150
194
  catch (error) {
151
195
  if (error.code === 'ENOENT')
@@ -153,6 +197,11 @@ async function readCredential(filename) {
153
197
  throw error;
154
198
  }
155
199
  }
200
+ function currentCredential(document) {
201
+ if (document?.currentAccountId === null || document === undefined)
202
+ return undefined;
203
+ return document.accounts.find(account => account.accountId === document.currentAccountId);
204
+ }
156
205
  async function responseText(response) {
157
206
  return (await response.text().catch(() => '')).slice(0, MAX_ERROR_BODY_LENGTH);
158
207
  }
@@ -573,6 +622,8 @@ function resolveCodexRouteStatus(config, llm) {
573
622
  }
574
623
  export const internals = {
575
624
  parseTokenResponse,
625
+ parseCredentialDocument,
626
+ currentCredential,
576
627
  parseAuthority,
577
628
  isLoopbackHostname,
578
629
  isTrustedHost,
@@ -703,6 +754,8 @@ export class OpenAICodexAuth extends Service {
703
754
  register('/openai-codex/browser/prepare', (req, res) => this.handleBrowserPrepare(req, res));
704
755
  register('/openai-codex/browser/complete', (req, res) => this.handleBrowserComplete(req, res));
705
756
  register('/openai-codex/cancel', (req, res) => this.handleCancel(req, res));
757
+ register('/openai-codex/accounts/current', (req, res) => this.handleSetCurrentAccount(req, res));
758
+ register('/openai-codex/accounts/logout', (req, res) => this.handleAccountLogout(req, res));
706
759
  register('/openai-codex/logout', (req, res) => this.handleLogout(req, res));
707
760
  }
708
761
  catch (error) {
@@ -802,7 +855,7 @@ export class OpenAICodexAuth extends Service {
802
855
  }
803
856
  async performUsageRefresh(generation) {
804
857
  try {
805
- const credential = await readCredential(this.filename);
858
+ const credential = await withFileLock(this.filename, () => this.resolveManagedCredentialLocked());
806
859
  if (credential === undefined) {
807
860
  if (this.usageGeneration === generation) {
808
861
  this.usageCache = undefined;
@@ -900,43 +953,72 @@ export class OpenAICodexAuth extends Service {
900
953
  }
901
954
  throw failure;
902
955
  }
903
- async commitCredential(credential) {
956
+ async commitDocument(document, signal) {
957
+ const nextToken = currentCredential(document)?.access;
904
958
  const previous = await this.ctx.credentials.resolve(TOKEN_REF);
905
- try {
906
- await this.ctx.credentials.set(TOKEN_REF, credential.access);
907
- }
908
- catch (error) {
909
- let current;
959
+ throwIfCancelled(signal);
960
+ if (previous?.value !== nextToken) {
910
961
  try {
911
- current = await this.ctx.credentials.resolve(TOKEN_REF);
962
+ if (nextToken === undefined)
963
+ await this.ctx.credentials.unset(TOKEN_REF);
964
+ else
965
+ await this.ctx.credentials.set(TOKEN_REF, nextToken);
912
966
  }
913
- catch {
914
- return this.failAfterPublicationRollback(previous, credential.access, error);
915
- }
916
- if (current?.value === credential.access) {
917
- return this.failAfterPublicationRollback(previous, credential.access, error);
967
+ catch (error) {
968
+ let current;
969
+ try {
970
+ current = await this.ctx.credentials.resolve(TOKEN_REF);
971
+ }
972
+ catch {
973
+ return this.failAfterPublicationRollback(previous, nextToken, error);
974
+ }
975
+ if (current?.value === nextToken) {
976
+ return this.failAfterPublicationRollback(previous, nextToken, error);
977
+ }
978
+ if (current?.value !== previous?.value)
979
+ throw this.publicationChangedError(error);
980
+ throw error;
918
981
  }
919
- if (current?.value !== previous?.value)
920
- throw this.publicationChangedError(error);
921
- throw error;
922
982
  }
923
983
  try {
924
- await this.write(credential);
984
+ await this.write(document);
925
985
  }
926
986
  catch (error) {
927
- return this.failAfterPublicationRollback(previous, credential.access, error);
987
+ if (previous?.value === nextToken)
988
+ throw error;
989
+ return this.failAfterPublicationRollback(previous, nextToken, error);
928
990
  }
929
991
  }
992
+ upsertCurrentCredential(document, credential, replacedAccountId = credential.accountId) {
993
+ const existingIndex = document.accounts.findIndex(account => account.accountId === replacedAccountId);
994
+ const accounts = document.accounts.filter(account => account.accountId !== credential.accountId);
995
+ const filteredIndex = accounts.findIndex(account => account.accountId === replacedAccountId);
996
+ if (filteredIndex >= 0)
997
+ accounts.splice(filteredIndex, 1);
998
+ const insertion = existingIndex < 0 ? accounts.length : Math.min(existingIndex, accounts.length);
999
+ accounts.splice(insertion, 0, credential);
1000
+ return { version: 2, currentAccountId: credential.accountId, accounts };
1001
+ }
1002
+ async commitCredential(credential) {
1003
+ const parsed = await readCredentialDocument(this.filename);
1004
+ const document = parsed?.document ?? { version: 2, currentAccountId: null, accounts: [] };
1005
+ await this.commitDocument(this.upsertCurrentCredential(document, credential));
1006
+ }
930
1007
  async resolveManagedCredentialLocked(signal) {
931
1008
  throwIfCancelled(signal);
932
- const current = await readCredential(this.filename);
1009
+ const parsed = await readCredentialDocument(this.filename);
1010
+ const document = parsed?.document;
1011
+ const current = currentCredential(document);
933
1012
  throwIfCancelled(signal);
934
1013
  if (current === undefined) {
935
1014
  this.setCredentialAccount(undefined);
936
1015
  return undefined;
937
1016
  }
938
1017
  if (current.expires > Date.now() + TOKEN_REFRESH_PREEMPT_MS) {
939
- await this.publishCredentialToken(current.access, signal);
1018
+ if (parsed?.migrated)
1019
+ await this.commitDocument(document, signal);
1020
+ else
1021
+ await this.publishCredentialToken(current.access, signal);
940
1022
  this.setCredentialAccount(current.accountId);
941
1023
  return current;
942
1024
  }
@@ -948,18 +1030,24 @@ export class OpenAICodexAuth extends Service {
948
1030
  catch (error) {
949
1031
  throwIfCancelled(signal);
950
1032
  if (!isPermanentRefreshError(error) && current.expires > Date.now()) {
951
- await this.publishCredentialToken(current.access, signal);
1033
+ if (parsed?.migrated)
1034
+ await this.commitDocument(document, signal);
1035
+ else
1036
+ await this.publishCredentialToken(current.access, signal);
952
1037
  this.setCredentialAccount(current.accountId);
953
1038
  return current;
954
1039
  }
955
1040
  throw error;
956
1041
  }
957
1042
  throwIfCancelled(signal);
958
- await this.commitCredential(next);
1043
+ if (next.accountId !== current.accountId) {
1044
+ throw new Error('OpenAI refresh changed the ChatGPT account identity');
1045
+ }
1046
+ await this.commitDocument(this.upsertCurrentCredential(document, next, current.accountId), signal);
959
1047
  this.setCredentialAccount(next.accountId);
960
1048
  return next;
961
1049
  }
962
- /** Return a valid managed bearer token, refreshing and persisting it when near expiry. */
1050
+ /** Return the current managed bearer token, refreshing and migrating it when needed. */
963
1051
  async bearerToken(signal) {
964
1052
  throwIfCancelled(signal);
965
1053
  return withFileLock(this.filename, async () => {
@@ -1027,7 +1115,9 @@ export class OpenAICodexAuth extends Service {
1027
1115
  throwIfCancelled(signal);
1028
1116
  return await withFileLock(this.filename, async () => {
1029
1117
  throwIfCancelled(signal);
1030
- const current = await readCredential(this.filename);
1118
+ const parsed = await readCredentialDocument(this.filename);
1119
+ const document = parsed?.document;
1120
+ const current = currentCredential(document);
1031
1121
  throwIfCancelled(signal);
1032
1122
  if (current === undefined) {
1033
1123
  const external = await this.ctx.credentials.resolve(TOKEN_REF);
@@ -1036,11 +1126,15 @@ export class OpenAICodexAuth extends Service {
1036
1126
  return false;
1037
1127
  const credential = this.externalNativeCredential(external.value);
1038
1128
  throwIfCancelled(signal);
1129
+ if (credential.accountId !== previous.accountId)
1130
+ return false;
1039
1131
  this.setCredentialAccount(credential.accountId);
1040
- return credential.accessToken !== previous.accessToken
1041
- || credential.accountId !== previous.accountId;
1132
+ return credential.accessToken !== previous.accessToken;
1042
1133
  }
1043
- if (current.access !== previous.accessToken || current.accountId !== previous.accountId) {
1134
+ // A request that started under A must never recover by replaying under B.
1135
+ if (current.accountId !== previous.accountId)
1136
+ return false;
1137
+ if (current.access !== previous.accessToken) {
1044
1138
  await this.publishCredentialToken(current.access, signal);
1045
1139
  throwIfCancelled(signal);
1046
1140
  this.setCredentialAccount(current.accountId);
@@ -1050,10 +1144,13 @@ export class OpenAICodexAuth extends Service {
1050
1144
  throwIfCancelled(signal);
1051
1145
  const next = await refreshToken(current, signal);
1052
1146
  throwIfCancelled(signal);
1053
- await this.commitCredential(next);
1147
+ if (next.accountId !== previous.accountId) {
1148
+ throw new Error('OpenAI refresh changed the ChatGPT account identity');
1149
+ }
1150
+ await this.commitDocument(this.upsertCurrentCredential(document, next, current.accountId), signal);
1054
1151
  throwIfCancelled(signal);
1055
1152
  this.setCredentialAccount(next.accountId);
1056
- return next.access !== previous.accessToken || next.accountId !== previous.accountId;
1153
+ return next.access !== previous.accessToken;
1057
1154
  });
1058
1155
  }
1059
1156
  catch (error) {
@@ -1296,53 +1393,86 @@ export class OpenAICodexAuth extends Service {
1296
1393
  if (clearError)
1297
1394
  this.lastLoginError = undefined;
1298
1395
  }
1299
- async logout() {
1300
- await this.cancelLogin(true);
1396
+ resetCurrentAccountState(accountId) {
1397
+ this.setCredentialAccount(accountId);
1398
+ this.usageGeneration += 1;
1399
+ if (this.usageRefresh !== undefined)
1400
+ this.usageRefresh.queued = accountId !== undefined;
1401
+ this.usageCache = undefined;
1402
+ this.usageAccountId = undefined;
1403
+ this.directUsageAccountId = undefined;
1404
+ this.usageHasDirectDefault = false;
1405
+ this.usageError = undefined;
1406
+ this.lastLoginError = undefined;
1407
+ }
1408
+ async setCurrentAccount(accountId) {
1409
+ await withFileLock(this.filename, async () => {
1410
+ const document = (await readCredentialDocument(this.filename))?.document;
1411
+ if (document === undefined || !document.accounts.some(account => account.accountId === accountId)) {
1412
+ throw new AccountNotFoundError('OpenAI Codex account was not found');
1413
+ }
1414
+ if (document.currentAccountId !== accountId) {
1415
+ await this.assertCredentialWritable();
1416
+ await this.commitDocument({ ...document, currentAccountId: accountId });
1417
+ }
1418
+ this.resetCurrentAccountState(accountId);
1419
+ });
1420
+ }
1421
+ async logout(accountId) {
1422
+ if (accountId === undefined)
1423
+ await this.cancelLogin(true);
1424
+ let nextAccountId;
1425
+ let removedCurrent = false;
1301
1426
  await withFileLock(this.filename, async () => {
1302
- const previous = await this.ctx.credentials.resolve(TOKEN_REF);
1427
+ let parsed;
1303
1428
  try {
1304
- await this.ctx.credentials.unset(TOKEN_REF);
1429
+ parsed = await readCredentialDocument(this.filename);
1305
1430
  }
1306
1431
  catch (error) {
1307
- let current;
1432
+ if (accountId !== undefined)
1433
+ throw error;
1434
+ const previous = await this.ctx.credentials.resolve(TOKEN_REF);
1308
1435
  try {
1309
- current = await this.ctx.credentials.resolve(TOKEN_REF);
1436
+ if (previous !== undefined)
1437
+ await this.ctx.credentials.unset(TOKEN_REF);
1438
+ await unlink(this.filename);
1310
1439
  }
1311
- catch {
1312
- return this.failAfterPublicationRollback(previous, undefined, error);
1440
+ catch (failure) {
1441
+ return this.failAfterPublicationRollback(previous, undefined, failure);
1313
1442
  }
1314
- if (current === undefined) {
1315
- return this.failAfterPublicationRollback(previous, undefined, error);
1316
- }
1317
- if (current.value !== previous?.value)
1318
- throw this.publicationChangedError(error);
1319
- throw error;
1320
- }
1321
- try {
1322
- await unlink(this.filename);
1443
+ removedCurrent = true;
1444
+ this.resetCurrentAccountState(undefined);
1445
+ return;
1323
1446
  }
1324
- catch (error) {
1325
- if (error.code !== 'ENOENT') {
1326
- return this.failAfterPublicationRollback(previous, undefined, error);
1327
- }
1447
+ const document = parsed?.document;
1448
+ const target = accountId ?? document?.currentAccountId ?? undefined;
1449
+ if (document === undefined || target === undefined
1450
+ || !document.accounts.some(account => account.accountId === target)) {
1451
+ if (accountId !== undefined)
1452
+ throw new AccountNotFoundError('OpenAI Codex account was not found');
1453
+ return;
1328
1454
  }
1455
+ removedCurrent = document.currentAccountId === target;
1456
+ const targetIndex = document.accounts.findIndex(account => account.accountId === target);
1457
+ const accounts = document.accounts.filter(account => account.accountId !== target);
1458
+ const currentAccountId = removedCurrent
1459
+ ? accounts[targetIndex]?.accountId ?? accounts[0]?.accountId ?? null
1460
+ : document.currentAccountId;
1461
+ if (removedCurrent)
1462
+ await this.assertCredentialWritable();
1463
+ await this.commitDocument({ version: 2, currentAccountId, accounts });
1464
+ nextAccountId = currentAccountId ?? undefined;
1465
+ if (removedCurrent)
1466
+ this.resetCurrentAccountState(nextAccountId);
1329
1467
  });
1330
- this.setCredentialAccount(undefined);
1331
- this.usageGeneration += 1;
1332
- if (this.usageRefresh !== undefined)
1333
- this.usageRefresh.queued = false;
1334
- this.usageCache = undefined;
1335
- this.usageAccountId = undefined;
1336
- this.directUsageAccountId = undefined;
1337
- this.usageHasDirectDefault = false;
1338
- this.usageError = undefined;
1339
- this.lastLoginError = undefined;
1340
1468
  }
1341
1469
  async status(refresh, callbackUrl) {
1470
+ let document;
1342
1471
  let credential;
1343
1472
  let credentialError;
1344
1473
  try {
1345
- credential = await readCredential(this.filename);
1474
+ document = (await readCredentialDocument(this.filename))?.document;
1475
+ credential = currentCredential(document);
1346
1476
  }
1347
1477
  catch (error) {
1348
1478
  credentialError = messageOf(error);
@@ -1350,7 +1480,8 @@ export class OpenAICodexAuth extends Service {
1350
1480
  if (credential !== undefined) {
1351
1481
  try {
1352
1482
  await this.bearerToken();
1353
- credential = await readCredential(this.filename) ?? credential;
1483
+ document = (await readCredentialDocument(this.filename))?.document ?? document;
1484
+ credential = currentCredential(document) ?? credential;
1354
1485
  }
1355
1486
  catch (error) {
1356
1487
  this.usageError = messageOf(error);
@@ -1379,6 +1510,13 @@ export class OpenAICodexAuth extends Service {
1379
1510
  return {
1380
1511
  route: resolveCodexRouteStatus(this.routeConfig, this.ctx.get('llm')),
1381
1512
  loggedIn: credential !== undefined,
1513
+ currentAccountId: document?.currentAccountId ?? null,
1514
+ accounts: (document?.accounts ?? []).map(account => ({
1515
+ accountId: account.accountId,
1516
+ ...account.email === undefined ? {} : { email: account.email },
1517
+ expiresAt: account.expires,
1518
+ current: account.accountId === document?.currentAccountId,
1519
+ })),
1382
1520
  loginPending: startingMethod !== undefined || flow !== undefined,
1383
1521
  ...startingMethod !== undefined ? { loginMethod: startingMethod } : flow === undefined ? {} : { loginMethod: flow.kind },
1384
1522
  ...this.lastLoginError === undefined ? {} : { loginError: this.lastLoginError },
@@ -1388,6 +1526,7 @@ export class OpenAICodexAuth extends Service {
1388
1526
  ...browser === undefined ? {} : { browser },
1389
1527
  ...credential === undefined ? {} : {
1390
1528
  accountId: credential.accountId,
1529
+ ...credential.email === undefined ? {} : { email: credential.email },
1391
1530
  expiresAt: credential.expires,
1392
1531
  ...this.usageCache === undefined || this.usageAccountId !== credential.accountId
1393
1532
  ? {} : { usage: this.usageCache },
@@ -1402,14 +1541,11 @@ export class OpenAICodexAuth extends Service {
1402
1541
  };
1403
1542
  }
1404
1543
  async fetchUsage(credential) {
1405
- const access = await this.bearerToken();
1406
- if (access === undefined)
1407
- throw new Error('OpenAI login is missing');
1408
1544
  const response = await fetch(USAGE_URL, {
1409
1545
  redirect: 'error',
1410
1546
  headers: {
1411
1547
  accept: 'application/json',
1412
- authorization: `Bearer ${access}`,
1548
+ authorization: `Bearer ${credential.access}`,
1413
1549
  'chatgpt-account-id': credential.accountId,
1414
1550
  'user-agent': 'dsh-openai-codex-auth/0.5.0',
1415
1551
  },
@@ -1418,8 +1554,8 @@ export class OpenAICodexAuth extends Service {
1418
1554
  throw new Error(`Codex usage request failed (HTTP ${response.status})`);
1419
1555
  return normalizeUsage(await response.json());
1420
1556
  }
1421
- write(credential) {
1422
- return writeFileAtomic(this.filename, `${JSON.stringify({ version: 1, credential }, null, 2)}\n`, {
1557
+ write(document) {
1558
+ return writeFileAtomic(this.filename, `${JSON.stringify(document, null, 2)}\n`, {
1423
1559
  mode: 0o600, dirMode: 0o700,
1424
1560
  });
1425
1561
  }
@@ -1642,6 +1778,54 @@ export class OpenAICodexAuth extends Service {
1642
1778
  this.sendJson(res, 500, { error: messageOf(error) });
1643
1779
  }
1644
1780
  }
1781
+ async accountIdBody(req) {
1782
+ const body = await readJsonBody(req);
1783
+ if (Object.keys(body).length !== 1 || typeof body.accountId !== 'string'
1784
+ || body.accountId.length === 0 || body.accountId.length > 512) {
1785
+ throw new Error('The accountId field must identify one OpenAI Codex account.');
1786
+ }
1787
+ return body.accountId;
1788
+ }
1789
+ async handleSetCurrentAccount(req, res) {
1790
+ if (!this.trustedManagementRequest(req, res))
1791
+ return;
1792
+ if (req.method !== 'POST') {
1793
+ this.sendJson(res, 405, { error: 'POST only' }, { allow: 'POST' });
1794
+ return;
1795
+ }
1796
+ if (!this.requireCsrf(req, res))
1797
+ return;
1798
+ try {
1799
+ await this.setCurrentAccount(await this.accountIdBody(req));
1800
+ this.sendJson(res, 200, { ok: true });
1801
+ }
1802
+ catch (error) {
1803
+ const status = error instanceof AccountNotFoundError
1804
+ ? 404
1805
+ : error instanceof CredentialNotWritableError ? 409 : 400;
1806
+ this.sendJson(res, status, { error: messageOf(error) });
1807
+ }
1808
+ }
1809
+ async handleAccountLogout(req, res) {
1810
+ if (!this.trustedManagementRequest(req, res))
1811
+ return;
1812
+ if (req.method !== 'POST') {
1813
+ this.sendJson(res, 405, { error: 'POST only' }, { allow: 'POST' });
1814
+ return;
1815
+ }
1816
+ if (!this.requireCsrf(req, res))
1817
+ return;
1818
+ try {
1819
+ await this.logout(await this.accountIdBody(req));
1820
+ this.sendJson(res, 200, { ok: true });
1821
+ }
1822
+ catch (error) {
1823
+ const status = error instanceof AccountNotFoundError
1824
+ ? 404
1825
+ : error instanceof CredentialNotWritableError ? 409 : 400;
1826
+ this.sendJson(res, status, { error: messageOf(error) });
1827
+ }
1828
+ }
1645
1829
  async handleLogout(req, res) {
1646
1830
  if (!this.trustedManagementRequest(req, res))
1647
1831
  return;
@@ -1656,7 +1840,7 @@ export class OpenAICodexAuth extends Service {
1656
1840
  this.sendJson(res, 200, { ok: true });
1657
1841
  }
1658
1842
  catch (error) {
1659
- this.sendJson(res, 500, { error: messageOf(error) });
1843
+ this.sendJson(res, error instanceof CredentialNotWritableError ? 409 : 500, { error: messageOf(error) });
1660
1844
  }
1661
1845
  }
1662
1846
  }
@@ -24,6 +24,8 @@ export interface NativeCodexTransportMode {
24
24
  turnState?: string;
25
25
  /** @internal Receives a newly observed bounded turn-state token. */
26
26
  captureTurnState?: (state: string) => void;
27
+ /** @internal Pins WebSocket reconnects and HTTP fallback to one account. */
28
+ pinnedAccountId?: string;
27
29
  }
28
30
  export interface NativeCodexTransport {
29
31
  stream(options: GenerateOptions, mode?: NativeCodexTransportMode): AsyncIterable<StreamChunk>;
@@ -436,6 +436,7 @@ export class NativeCodexHttpTransport {
436
436
  let transientRetries = 0;
437
437
  let connectionRetryDelayMs = INITIAL_CONNECTION_RETRY_DELAY_MS;
438
438
  let recovered = false;
439
+ let pinnedAccountId = mode.pinnedAccountId;
439
440
  while (true) {
440
441
  throwIfAborted(generation.signal);
441
442
  const watchdog = attemptWatchdog(generation.signal, this.requestTimeoutMs, this.idleTimeoutMs);
@@ -445,6 +446,11 @@ export class NativeCodexHttpTransport {
445
446
  try {
446
447
  credential = await this.options.resolveCredential(watchdog.signal);
447
448
  throwIfAborted(watchdog.signal);
449
+ if (pinnedAccountId === undefined)
450
+ pinnedAccountId = credential.accountId;
451
+ else if (credential.accountId !== pinnedAccountId) {
452
+ throw fixedFailure('native Codex account changed during request', 'AUTH');
453
+ }
448
454
  if (mode.serviceTier !== undefined
449
455
  && (mode.authorityHash === undefined
450
456
  || nativeCodexAuthorityHash(credential.accountId) !== mode.authorityHash)) {
@@ -22,6 +22,8 @@ export interface NativeCodexWebSocketConnectOptions {
22
22
  export interface NativeCodexWebSocketFactory {
23
23
  connect(options: NativeCodexWebSocketConnectOptions): Promise<NativeCodexWebSocket>;
24
24
  }
25
+ /** Resolve the same opt-in environment proxy contract used by Node fetch. */
26
+ export declare function nativeCodexWebSocketProxy(endpoint: string): string | undefined;
25
27
  export declare function nativeCodexWebSocketUrl(endpoint: string): string;
26
28
  export declare class NodeNativeCodexWebSocketFactory implements NativeCodexWebSocketFactory {
27
29
  connect(options: NativeCodexWebSocketConnectOptions): Promise<NativeCodexWebSocket>;
@@ -1,5 +1,7 @@
1
1
  /** Bounded Node WebSocket client seam with injectable deterministic factories. */
2
2
  import { LlmError, ProviderRequestId } from '@deepseek-ai/dsh-llm';
3
+ import { HttpsProxyAgent } from 'https-proxy-agent';
4
+ import { getProxyForUrl } from 'proxy-from-env';
3
5
  import { nativeCodexEndpoint } from './endpoint.js';
4
6
  import { NATIVE_CODEX_CONNECTION_FAILED_CODE, isNativeCodexConnectionFailure, } from './native-adapter.js';
5
7
  import WebSocket from 'ws';
@@ -34,6 +36,13 @@ function positive(value, fallback, label) {
34
36
  throw failure(`native Codex ${label} is invalid`, 'INVALID_ARGS');
35
37
  return value;
36
38
  }
39
+ /** Resolve the same opt-in environment proxy contract used by Node fetch. */
40
+ export function nativeCodexWebSocketProxy(endpoint) {
41
+ if (process.env.NODE_USE_ENV_PROXY !== '1')
42
+ return undefined;
43
+ const proxy = getProxyForUrl(nativeCodexEndpoint(endpoint, true).toString());
44
+ return proxy.length === 0 ? undefined : proxy;
45
+ }
37
46
  export function nativeCodexWebSocketUrl(endpoint) {
38
47
  const url = nativeCodexEndpoint(endpoint, true);
39
48
  if (url.protocol === 'https:')
@@ -165,8 +174,11 @@ export class NodeNativeCodexWebSocketFactory {
165
174
  throw abortFailure(options.signal);
166
175
  return new Promise((resolve, reject) => {
167
176
  const responseHeaders = {};
177
+ const proxy = nativeCodexWebSocketProxy(options.url);
178
+ const agent = proxy === undefined ? undefined : new HttpsProxyAgent(proxy);
168
179
  const socket = new WebSocket(nativeCodexWebSocketUrl(options.url), {
169
180
  headers: options.headers,
181
+ ...(agent === undefined ? {} : { agent }),
170
182
  handshakeTimeout: timeout,
171
183
  maxPayload: maximum,
172
184
  perMessageDeflate: true,
@@ -29,6 +29,7 @@ function reconnectable(code) {
29
29
  return [
30
30
  'WS_RETRYABLE', 'WS_RETRYABLE_RESET', 'WS_PROTOCOL_ERROR',
31
31
  'WS_FRAME_TOO_LARGE', 'WS_RESPONSE_TOO_LARGE', 'TIMEOUT',
32
+ NATIVE_CODEX_CONNECTION_FAILED_CODE,
32
33
  ].includes(code);
33
34
  }
34
35
  function failedStepRetryable(code) {
@@ -499,10 +500,12 @@ export class NativeCodexWebSocketTransport {
499
500
  if (entry.turnState === undefined)
500
501
  entry.turnState = state;
501
502
  },
503
+ ...(pinnedAccountId === undefined ? {} : { pinnedAccountId }),
502
504
  });
503
505
  let reconnects = 0;
504
506
  let connectionRetryDelayMs = INITIAL_CONNECTION_RETRY_DELAY_MS;
505
507
  let recovered = false;
508
+ let pinnedAccountId = mode.pinnedAccountId;
506
509
  let requestCompleted = false;
507
510
  try {
508
511
  if (entry.disabled) {
@@ -517,6 +520,11 @@ export class NativeCodexWebSocketTransport {
517
520
  try {
518
521
  const credential = await this.options.resolveCredential(signal);
519
522
  attemptedCredential = credential;
523
+ if (pinnedAccountId === undefined)
524
+ pinnedAccountId = credential.accountId;
525
+ else if (credential.accountId !== pinnedAccountId) {
526
+ throw failure('native Codex account changed during request', 'AUTH');
527
+ }
520
528
  this.assertFastAuthority(credential, mode);
521
529
  for await (const chunk of this.attempt(entry, prepared, credential, signal)) {
522
530
  emitted = true;
@@ -538,9 +546,12 @@ export class NativeCodexWebSocketTransport {
538
546
  throw failedStepRetry(failureValue);
539
547
  throw failureValue;
540
548
  }
541
- if (failureValue.code === NATIVE_CODEX_CONNECTION_FAILED_CODE
542
- || (attemptedCredential === undefined
543
- && isNativeCodexConnectionFailure(failureValue))) {
549
+ // Credential resolution may need the network itself, so wait until it recovers.
550
+ // Once a credential is available, however, a WebSocket-only outage must consume
551
+ // the bounded reconnect budget and then fall back to HTTP/SSE instead of leaving
552
+ // the DSH turn in an invisible infinite reconnect loop.
553
+ if (attemptedCredential === undefined
554
+ && isNativeCodexConnectionFailure(failureValue)) {
544
555
  await this.waitForConnection(connectionRetryDelayMs, signal);
545
556
  connectionRetryDelayMs = Math.min(connectionRetryDelayMs * 2, MAX_CONNECTION_RETRY_DELAY_MS);
546
557
  continue;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pure01fx/dsh-openai-codex-auth",
3
- "version": "0.7.2",
3
+ "version": "0.8.0",
4
4
  "description": "Native ChatGPT Codex provider, device-code-first login, and same-origin Web integration for DeepSeek Harness",
5
5
  "license": "MIT",
6
6
  "publishConfig": {
@@ -84,6 +84,7 @@
84
84
  "client": {
85
85
  "inject": [
86
86
  "@deepseek-ai/dsh-client-runtime",
87
+ "@deepseek-ai/dsh-client-ui-model-selection",
87
88
  "@deepseek-ai/dsh-client-ui-conversation",
88
89
  "@deepseek-ai/dsh-client-ui-settings"
89
90
  ],
@@ -100,6 +101,8 @@
100
101
  "@deepseek-ai/dsh-atomic-write": "0.1.1-rc.2",
101
102
  "@deepseek-ai/dsh-home-paths": "0.1.1-rc.2",
102
103
  "@deepseek-ai/schemastery": "^3.18.1",
104
+ "https-proxy-agent": "7.0.6",
105
+ "proxy-from-env": "1.1.0",
103
106
  "ws": "8.21.3"
104
107
  },
105
108
  "devDependencies": {
@@ -110,6 +113,7 @@
110
113
  "@deepseek-ai/dsh-llm": "0.1.1-rc.2",
111
114
  "@deepseek-ai/dsh-session": "0.1.1-rc.2",
112
115
  "@types/node": "^22.20.0",
116
+ "@types/proxy-from-env": "1.0.4",
113
117
  "typescript": "^6.0.3",
114
118
  "vitest": "^4.1.8"
115
119
  }