@pure01fx/dsh-openai-codex-auth 0.7.3 → 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,15 @@
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
+
5
14
  ## 0.7.3
6
15
 
7
16
  ### Client model visibility
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
 
package/client.js CHANGED
@@ -665,7 +665,38 @@ window.__ModuleLoader__.load({
665
665
  }
666
666
  }
667
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
+
668
698
  const logout = async () => {
699
+ if (busy) return
669
700
  setBusy('logout')
670
701
  probeGeneration.current += 1
671
702
  try {
@@ -701,6 +732,10 @@ window.__ModuleLoader__.load({
701
732
  const usage = status && status.usage
702
733
  const loading = status === null && !error
703
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)
704
739
  const pending = Boolean(status && status.loginPending) || watchLogin
705
740
  const devicePending = Boolean(status && status.loginMethod === 'device' && status.device)
706
741
  const browserPending = Boolean(status && status.loginMethod === 'browser' && status.browser)
@@ -731,7 +766,7 @@ window.__ModuleLoader__.load({
731
766
  h('div', { className: 'codexLogo', 'aria-hidden': true }, 'OA'),
732
767
  h('div', null,
733
768
  h('h3', { className: 'codexName' }, 'OpenAI Codex 订阅'),
734
- 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 账号'),
735
770
  ),
736
771
  ),
737
772
  h('span', { className: 'codexBadge ' + (loading || pending ? 'pending' : connected ? 'connected' : '') },
@@ -751,6 +786,31 @@ window.__ModuleLoader__.load({
751
786
  h('p', { className: 'codexRouteDetail' }, route.detail),
752
787
  )
753
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,
754
814
  loading
755
815
  ? h('div', { 'aria-label': '加载中' }, h('div', { className: 'codexSkeleton' }), h('div', { className: 'codexSkeleton', style: { width: '72%' } }))
756
816
  : connected
@@ -848,11 +908,11 @@ window.__ModuleLoader__.load({
848
908
  : null,
849
909
  h('div', { className: 'codexActions' },
850
910
  h('button', { type: 'button', className: 'codexButton primary', disabled: Boolean(busy) || pending || loading, onClick: () => { void startDevice() } },
851
- busy === 'device' ? '正在申请设备码…' : connected ? '用设备码重新登录' : '使用设备码登录'),
911
+ busy === 'device' ? '正在申请设备码…' : connected ? '用设备码添加账号' : '使用设备码登录'),
852
912
  h('button', {
853
913
  type: 'button', className: 'codexButton', disabled: Boolean(busy) || pending || loading || !browserAvailable,
854
914
  title: browserAvailable ? '展开 callback 检测,不会自动弹出窗口' : '仅支持 HTTP 127.0.0.1/localhost 入口', onClick: () => { void startBrowser() },
855
- }, busy === 'browser' ? '正在准备浏览器登录…' : connected ? '用浏览器重新登录' : '本机浏览器 OAuth'),
915
+ }, busy === 'browser' ? '正在准备浏览器登录…' : connected ? '用浏览器添加账号' : '本机浏览器 OAuth'),
856
916
  pending && !devicePending && !browserPending ? h('button', { type: 'button', className: 'codexButton danger', disabled: Boolean(busy), onClick: () => { void cancel() } }, busy === 'cancel' ? '取消中…' : '取消登录') : null,
857
917
  connected ? h('button', { type: 'button', className: 'codexButton', disabled: Boolean(busy), onClick: refresh }, busy === 'refresh' ? '刷新中…' : '刷新用量') : null,
858
918
  connected || (status && status.credentialError) ? h('button', { type: 'button', className: 'codexButton danger', disabled: Boolean(busy), onClick: () => { void logout() } }, busy === 'logout' ? '退出中…' : '退出登录') : null,
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)) {
@@ -500,10 +500,12 @@ export class NativeCodexWebSocketTransport {
500
500
  if (entry.turnState === undefined)
501
501
  entry.turnState = state;
502
502
  },
503
+ ...(pinnedAccountId === undefined ? {} : { pinnedAccountId }),
503
504
  });
504
505
  let reconnects = 0;
505
506
  let connectionRetryDelayMs = INITIAL_CONNECTION_RETRY_DELAY_MS;
506
507
  let recovered = false;
508
+ let pinnedAccountId = mode.pinnedAccountId;
507
509
  let requestCompleted = false;
508
510
  try {
509
511
  if (entry.disabled) {
@@ -518,6 +520,11 @@ export class NativeCodexWebSocketTransport {
518
520
  try {
519
521
  const credential = await this.options.resolveCredential(signal);
520
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
+ }
521
528
  this.assertFastAuthority(credential, mode);
522
529
  for await (const chunk of this.attempt(entry, prepared, credential, signal)) {
523
530
  emitted = true;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pure01fx/dsh-openai-codex-auth",
3
- "version": "0.7.3",
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": {
@@ -69,6 +69,11 @@
69
69
  "CHANGELOG.md",
70
70
  "LICENSE"
71
71
  ],
72
+ "scripts": {
73
+ "build": "tsc",
74
+ "test": "vitest run",
75
+ "prepack": "pnpm build"
76
+ },
72
77
  "dsh": {
73
78
  "engines": {
74
79
  "dsh": "0.1.1-rc.2"
@@ -111,9 +116,5 @@
111
116
  "@types/proxy-from-env": "1.0.4",
112
117
  "typescript": "^6.0.3",
113
118
  "vitest": "^4.1.8"
114
- },
115
- "scripts": {
116
- "build": "tsc",
117
- "test": "vitest run"
118
119
  }
119
- }
120
+ }