@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 +9 -0
- package/README.md +16 -5
- package/client.js +63 -3
- package/lib/index.d.ts +24 -2
- package/lib/index.js +262 -78
- package/lib/native-adapter.d.ts +2 -0
- package/lib/native-http.js +6 -0
- package/lib/native-websocket.js +7 -0
- package/package.json +7 -6
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.
|
|
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` |
|
|
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` |
|
|
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 ?
|
|
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 ? '
|
|
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
|
|
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
|
-
|
|
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
|
|
135
|
-
const
|
|
136
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
956
|
+
async commitDocument(document, signal) {
|
|
957
|
+
const nextToken = currentCredential(document)?.access;
|
|
904
958
|
const previous = await this.ctx.credentials.resolve(TOKEN_REF);
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
}
|
|
908
|
-
catch (error) {
|
|
909
|
-
let current;
|
|
959
|
+
throwIfCancelled(signal);
|
|
960
|
+
if (previous?.value !== nextToken) {
|
|
910
961
|
try {
|
|
911
|
-
|
|
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
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
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(
|
|
984
|
+
await this.write(document);
|
|
925
985
|
}
|
|
926
986
|
catch (error) {
|
|
927
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
1300
|
-
|
|
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
|
-
|
|
1427
|
+
let parsed;
|
|
1303
1428
|
try {
|
|
1304
|
-
await this.
|
|
1429
|
+
parsed = await readCredentialDocument(this.filename);
|
|
1305
1430
|
}
|
|
1306
1431
|
catch (error) {
|
|
1307
|
-
|
|
1432
|
+
if (accountId !== undefined)
|
|
1433
|
+
throw error;
|
|
1434
|
+
const previous = await this.ctx.credentials.resolve(TOKEN_REF);
|
|
1308
1435
|
try {
|
|
1309
|
-
|
|
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,
|
|
1440
|
+
catch (failure) {
|
|
1441
|
+
return this.failAfterPublicationRollback(previous, undefined, failure);
|
|
1313
1442
|
}
|
|
1314
|
-
|
|
1315
|
-
|
|
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
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
1422
|
-
return writeFileAtomic(this.filename, `${JSON.stringify(
|
|
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
|
}
|
package/lib/native-adapter.d.ts
CHANGED
|
@@ -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>;
|
package/lib/native-http.js
CHANGED
|
@@ -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)) {
|
package/lib/native-websocket.js
CHANGED
|
@@ -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.
|
|
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
|
+
}
|