@sunshaobei/dsh-codex-auth 0.0.0-stage → 3.0.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/README.md +43 -2
- package/cordis.patch.yml +12 -0
- package/lib/client.js +391 -0
- package/lib/index.js +394 -0
- package/lib/vendor/cosmokit.js +325 -0
- package/lib/vendor/schemastery.mjs +596 -0
- package/package.json +54 -4
- package/src/client.ts +416 -0
- package/src/index.ts +397 -0
- package/src/vendor/cosmokit.js +325 -0
- package/src/vendor/schemastery.d.mts +8 -0
- package/src/vendor/schemastery.mjs +596 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-codex-auth — Codex 订阅授权管理 for the dsh web surface.
|
|
3
|
+
*
|
|
4
|
+
* Dual-face plugin:
|
|
5
|
+
*
|
|
6
|
+
* - Host half (lib/index.js, zero runtime deps — schemastery vendored):
|
|
7
|
+
* - `status` → reads the `llm-pi-ai/openai-codex` grant record from
|
|
8
|
+
* ctx.credentials and reports {configured, expired, expires,
|
|
9
|
+
* accountId, plan, email} (plan/email decoded from the access JWT).
|
|
10
|
+
* - `login` → runs the full OpenAI OAuth browser flow host-side:
|
|
11
|
+
* PKCE S256 → local callback server on 127.0.0.1:1455 (same port the
|
|
12
|
+
* Codex CLI uses) → opens the system browser → exchanges the code →
|
|
13
|
+
* writes the grant record via ctx.credentials.modifyRecord (which
|
|
14
|
+
* also fires credentials/record-updated so the model catalog
|
|
15
|
+
* refreshes without a restart).
|
|
16
|
+
* - `logout` → deletes the grant record.
|
|
17
|
+
*
|
|
18
|
+
* - Client half (lib/client.js, served at /plugins/dsh-codex-auth/client.js):
|
|
19
|
+
* - a `settings.section` page "Codex 订阅": authorization status,
|
|
20
|
+
* validity/expiry display, authorize/re-authorize/logout buttons.
|
|
21
|
+
* - a `shell.overlay` modal: when the active session's selected model
|
|
22
|
+
* routes to openai-codex and the credential is expired, an
|
|
23
|
+
* authorization prompt pops up (dismissible, snoozes 10 minutes).
|
|
24
|
+
*
|
|
25
|
+
* OAuth constants mirror @earendil-works/pi-ai's openai-codex adapter
|
|
26
|
+
* (CLIENT_ID app_EMoamEEZ73f0CkXaXp7hrann, PKCE S256, scope
|
|
27
|
+
* openid profile email offline_access, codex_cli_simplified_flow=true).
|
|
28
|
+
*/
|
|
29
|
+
import http from 'node:http';
|
|
30
|
+
import crypto from 'node:crypto';
|
|
31
|
+
import { execFile } from 'node:child_process';
|
|
32
|
+
import z from './vendor/schemastery.mjs';
|
|
33
|
+
const RPC_CHANNEL = '/dsh-codex-auth';
|
|
34
|
+
const CRED_KEY = 'llm-pi-ai/openai-codex';
|
|
35
|
+
const CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann';
|
|
36
|
+
const AUTH_BASE = 'https://auth.openai.com';
|
|
37
|
+
const AUTHORIZE_URL = `${AUTH_BASE}/oauth/authorize`;
|
|
38
|
+
const TOKEN_URL = `${AUTH_BASE}/oauth/token`;
|
|
39
|
+
const REDIRECT_URI = 'http://localhost:1455/auth/callback';
|
|
40
|
+
const SCOPE = 'openid profile email offline_access';
|
|
41
|
+
const LOGIN_TIMEOUT_MS = 5 * 60 * 1000;
|
|
42
|
+
export const name = 'codex-auth';
|
|
43
|
+
export const inject = ['connection', 'credentials', 'webServer', 'llm', 'settings'];
|
|
44
|
+
export const Config = z.object({}).i18n({});
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
// helpers
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
function b64urlToJson(segment) {
|
|
49
|
+
try {
|
|
50
|
+
const json = Buffer.from(segment.replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8');
|
|
51
|
+
return JSON.parse(json);
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
return undefined;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
function decodeJwtPayload(accessToken) {
|
|
58
|
+
const parts = String(accessToken || '').split('.');
|
|
59
|
+
return parts.length >= 2 ? b64urlToJson(parts[1]) : undefined;
|
|
60
|
+
}
|
|
61
|
+
/** Best-effort status read; never throws for the UI. */
|
|
62
|
+
async function readStatus(credentials) {
|
|
63
|
+
try {
|
|
64
|
+
const record = await credentials.readRecord(CRED_KEY);
|
|
65
|
+
const payload = record && record.kind === 'grant' ? record.payload : undefined;
|
|
66
|
+
if (!payload || payload.type !== 'oauth' || !payload.access) {
|
|
67
|
+
return { configured: false };
|
|
68
|
+
}
|
|
69
|
+
const expires = typeof payload.expires === 'number' ? payload.expires : 0;
|
|
70
|
+
const claims = decodeJwtPayload(payload.access) ?? {};
|
|
71
|
+
const auth = claims['https://api.openai.com/auth'] ?? {};
|
|
72
|
+
const profile = claims['https://api.openai.com/profile'] ?? {};
|
|
73
|
+
return {
|
|
74
|
+
configured: true,
|
|
75
|
+
expires,
|
|
76
|
+
expired: expires > 0 && expires <= Date.now(),
|
|
77
|
+
msLeft: expires > 0 ? expires - Date.now() : 0,
|
|
78
|
+
accountId: typeof payload.accountId === 'string' ? payload.accountId : (auth.chatgpt_account_id ?? undefined),
|
|
79
|
+
plan: auth.chatgpt_plan_type ?? undefined,
|
|
80
|
+
email: profile.email ?? undefined,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
catch (error) {
|
|
84
|
+
return { configured: false, error: String(error) };
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
function pkcePair() {
|
|
88
|
+
const verifier = crypto.randomBytes(32).toString('base64url');
|
|
89
|
+
const challenge = crypto.createHash('sha256').update(verifier).digest('base64url');
|
|
90
|
+
return { verifier, challenge };
|
|
91
|
+
}
|
|
92
|
+
function openBrowser(url) {
|
|
93
|
+
return new Promise((resolve, reject) => {
|
|
94
|
+
const command = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'rundll32' : 'xdg-open';
|
|
95
|
+
const args = process.platform === 'win32' ? ['url.dll,FileProtocolHandler', url] : [url];
|
|
96
|
+
execFile(command, args, (error) => error ? reject(error) : resolve());
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
function exchangeCode(code, verifier) {
|
|
100
|
+
return fetch(TOKEN_URL, {
|
|
101
|
+
method: 'POST',
|
|
102
|
+
signal: AbortSignal.timeout(30000),
|
|
103
|
+
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
104
|
+
body: new URLSearchParams({
|
|
105
|
+
grant_type: 'authorization_code',
|
|
106
|
+
client_id: CLIENT_ID,
|
|
107
|
+
code,
|
|
108
|
+
code_verifier: verifier,
|
|
109
|
+
redirect_uri: REDIRECT_URI,
|
|
110
|
+
}),
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
function callbackPage(error) {
|
|
114
|
+
const escape = (text) => text.replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
|
115
|
+
return `<!doctype html>
|
|
116
|
+
<html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="color-scheme" content="light dark"><title>${error ? '授权未完成' : '授权成功'} · DeepSeek Harness</title>
|
|
117
|
+
<style>
|
|
118
|
+
:root{color-scheme:light dark;--bg:#f7f8fa;--surface:#fff;--text:#1b1b1f;--muted:#737780;--line:#e9ebef;--blue:#4d6bfe;--soft:#eef2ff;--error:#c34343;--error-bg:#fff1f0}
|
|
119
|
+
*{box-sizing:border-box}body{margin:0;min-height:100svh;background:var(--bg);color:var(--text);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC",sans-serif;-webkit-font-smoothing:antialiased;display:flex;flex-direction:column}
|
|
120
|
+
header{padding:30px 40px;display:flex;align-items:center;gap:12px}.brand{font-size:25px;font-weight:700;letter-spacing:-1px;color:var(--blue)}.brand-tag{font-size:10px;font-weight:650;letter-spacing:1.6px;border-left:1px solid var(--line);padding-left:12px;color:var(--muted)}
|
|
121
|
+
main{flex:1;display:grid;place-items:center;padding:32px 24px 90px}.card{width:100%;max-width:460px;padding:42px 36px 30px;background:var(--surface);border:1px solid var(--line);border-radius:24px;text-align:center;box-shadow:0 8px 40px #18265505}
|
|
122
|
+
.status-icon{width:64px;height:64px;border-radius:22px;background:var(--soft);color:var(--blue);display:grid;place-items:center;margin:0 auto 24px}.status-icon.error{background:var(--error-bg);color:var(--error)}
|
|
123
|
+
.eyebrow{font-size:12px;color:var(--muted);letter-spacing:.5px;margin:0 0 10px}h1{font-size:26px;line-height:1.4;font-weight:600;letter-spacing:-.6px;margin:0 0 12px}.description{font-size:14px;line-height:1.8;color:var(--muted);margin:0 auto 28px;overflow-wrap:anywhere}
|
|
124
|
+
.button{display:flex;align-items:center;justify-content:center;gap:10px;width:100%;min-height:46px;padding:12px 20px;border-radius:12px;background:var(--blue);color:white;text-decoration:none;font-size:14px;font-weight:500;transition:background .15s,transform .15s}.button:hover{background:#3b58ed}.button:active{transform:translateY(1px)}.button:focus-visible{outline:3px solid #a9b7ff;outline-offset:4px}
|
|
125
|
+
.hint{margin:16px 0 0;color:var(--muted);font-size:12px;line-height:1.7}.footnote{border-top:1px solid var(--line);margin:28px 0 0;padding-top:20px;color:var(--muted);font-size:12px;line-height:1.7}footer{text-align:center;padding:0 24px 24px;color:var(--muted);font-size:11px}
|
|
126
|
+
@media(prefers-color-scheme:dark){:root{--bg:#191a1d;--surface:#232428;--text:#ececf0;--muted:#a0a2aa;--line:#34353a;--soft:#2b3356;--error:#ff9993;--error-bg:#422b2c}.card{box-shadow:none}}
|
|
127
|
+
@media(max-width:480px){header{padding:24px}.card{padding:32px 24px;border-radius:20px}main{padding:16px 20px 48px}h1{font-size:24px}}
|
|
128
|
+
</style></head><body>
|
|
129
|
+
<header aria-label="DeepSeek Harness"><span class="brand">deepseek</span><span class="brand-tag">HARNESS</span></header>
|
|
130
|
+
<main><section class="card" aria-labelledby="title"><div class="status-icon ${error ? 'error' : ''}"><svg aria-hidden="true" width="30" height="30" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">${error ? '<circle cx="12" cy="12" r="9"/><path d="M12 7v6m0 4h.01"/>' : '<path d="m5 12 4 4L19 6"/>'}</svg></div>
|
|
131
|
+
<p class="eyebrow">Codex 订阅 · ChatGPT 授权</p><h1 id="title">${error ? '授权未完成' : '授权成功,准备就绪'}</h1>
|
|
132
|
+
<p class="description">${error ? escape(error) : '凭证已保存。<br>返回 DeepSeek Harness,继续你的工作。'}</p>
|
|
133
|
+
<a class="button" href="dsh://open">返回 DeepSeek Harness <span aria-hidden="true">↗</span></a>
|
|
134
|
+
<p class="hint">${error ? '返回应用后,可查看状态并重新授权。' : '若浏览器询问是否打开应用,请选择「打开」。'}</p>
|
|
135
|
+
<p class="footnote">${error ? '本次授权未完成,请在应用内重试。' : '授权凭证仅保存在本机。<br>返回应用后,可以关闭此页面。'}</p></section></main><footer>DeepSeek Harness · Codex 订阅连接</footer>
|
|
136
|
+
<script>history.replaceState(null,'','/auth/callback');${error ? '' : 'setTimeout(()=>{location.href="dsh://open"},500);'}</script></body></html>`;
|
|
137
|
+
}
|
|
138
|
+
// ---------------------------------------------------------------------------
|
|
139
|
+
// OAuth browser flow (one at a time, owned by the host process)
|
|
140
|
+
// ---------------------------------------------------------------------------
|
|
141
|
+
function runBrowserLogin(credentials, phase) {
|
|
142
|
+
if (phase.phase === 'waiting' || phase.phase === 'exchanging') {
|
|
143
|
+
return Promise.resolve({ phase: phase.phase });
|
|
144
|
+
}
|
|
145
|
+
phase.phase = 'waiting';
|
|
146
|
+
phase.error = undefined;
|
|
147
|
+
phase.startedAt = Date.now();
|
|
148
|
+
return new Promise((resolve) => {
|
|
149
|
+
const { verifier, challenge } = pkcePair();
|
|
150
|
+
const state = crypto.randomBytes(16).toString('hex');
|
|
151
|
+
const url = new URL(AUTHORIZE_URL);
|
|
152
|
+
url.searchParams.set('response_type', 'code');
|
|
153
|
+
url.searchParams.set('client_id', CLIENT_ID);
|
|
154
|
+
url.searchParams.set('redirect_uri', REDIRECT_URI);
|
|
155
|
+
url.searchParams.set('scope', SCOPE);
|
|
156
|
+
url.searchParams.set('code_challenge', challenge);
|
|
157
|
+
url.searchParams.set('code_challenge_method', 'S256');
|
|
158
|
+
url.searchParams.set('state', state);
|
|
159
|
+
url.searchParams.set('id_token_add_organizations', 'true');
|
|
160
|
+
url.searchParams.set('codex_cli_simplified_flow', 'true');
|
|
161
|
+
url.searchParams.set('originator', 'pi');
|
|
162
|
+
let server;
|
|
163
|
+
let settled = false;
|
|
164
|
+
const finish = async (fn) => {
|
|
165
|
+
if (settled)
|
|
166
|
+
return;
|
|
167
|
+
settled = true;
|
|
168
|
+
phase.cancel = undefined;
|
|
169
|
+
clearTimeout(timer);
|
|
170
|
+
try {
|
|
171
|
+
server?.close();
|
|
172
|
+
}
|
|
173
|
+
catch { }
|
|
174
|
+
try {
|
|
175
|
+
const result = await fn();
|
|
176
|
+
resolve(result);
|
|
177
|
+
}
|
|
178
|
+
catch (error) {
|
|
179
|
+
phase.phase = 'error';
|
|
180
|
+
phase.error = error instanceof Error ? error.message : String(error);
|
|
181
|
+
resolve({ phase: 'error', error: phase.error });
|
|
182
|
+
}
|
|
183
|
+
};
|
|
184
|
+
phase.cancel = () => finish(async () => {
|
|
185
|
+
phase.phase = 'idle';
|
|
186
|
+
phase.error = undefined;
|
|
187
|
+
return { phase: 'idle' };
|
|
188
|
+
});
|
|
189
|
+
const timer = setTimeout(() => {
|
|
190
|
+
finish(async () => { throw new Error('授权超时:5 分钟内未收到浏览器回调'); });
|
|
191
|
+
}, LOGIN_TIMEOUT_MS);
|
|
192
|
+
server = http.createServer((req, res) => {
|
|
193
|
+
const parsed = new URL(req.url ?? '/', 'http://localhost:1455');
|
|
194
|
+
if (parsed.pathname !== '/auth/callback') {
|
|
195
|
+
res.writeHead(404).end('not found');
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
const error = parsed.searchParams.get('error');
|
|
199
|
+
const code = parsed.searchParams.get('code');
|
|
200
|
+
const returnedState = parsed.searchParams.get('state');
|
|
201
|
+
if (settled) {
|
|
202
|
+
res.writeHead(409).end('Authorization already processing');
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
const reply = (error) => res.writeHead(error ? 400 : 200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store', 'referrer-policy': 'no-referrer' }).end(callbackPage(error));
|
|
206
|
+
if (returnedState !== state) {
|
|
207
|
+
reply('回调参数无效(state 不匹配),请返回原授权页面重试。');
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
if (error) {
|
|
211
|
+
const message = `授权被拒绝: ${error}`;
|
|
212
|
+
reply(message);
|
|
213
|
+
finish(async () => { throw new Error(message); });
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
if (!code || returnedState !== state) {
|
|
217
|
+
reply('回调参数无效(code/state 不匹配),请返回原授权页面重试。');
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
phase.phase = 'exchanging';
|
|
221
|
+
finish(async () => {
|
|
222
|
+
try {
|
|
223
|
+
const response = await exchangeCode(code, verifier);
|
|
224
|
+
const json = await response.json().catch(() => ({}));
|
|
225
|
+
if (!response.ok || !json.access_token || !json.refresh_token || !Number.isFinite(Number(json.expires_in)) || Number(json.expires_in) <= 0) {
|
|
226
|
+
throw new Error(`token 交换失败 (${response.status}): ${typeof json.error === 'string' ? json.error : '响应缺少有效凭证'}`);
|
|
227
|
+
}
|
|
228
|
+
const access = json.access_token;
|
|
229
|
+
const claims = decodeJwtPayload(access) ?? {};
|
|
230
|
+
const accountId = claims?.['https://api.openai.com/auth']?.chatgpt_account_id;
|
|
231
|
+
if (typeof accountId !== 'string' || accountId.length === 0) {
|
|
232
|
+
throw new Error('无法从 access token 中解析 accountId');
|
|
233
|
+
}
|
|
234
|
+
const payload = {
|
|
235
|
+
type: 'oauth',
|
|
236
|
+
access,
|
|
237
|
+
refresh: json.refresh_token,
|
|
238
|
+
expires: Date.now() + Number(json.expires_in ?? 0) * 1000,
|
|
239
|
+
accountId,
|
|
240
|
+
};
|
|
241
|
+
await credentials.modifyRecord(CRED_KEY, async () => ({ kind: 'grant', payload }));
|
|
242
|
+
const status = await readStatus(credentials);
|
|
243
|
+
if (!status.configured || status.expired)
|
|
244
|
+
throw new Error('凭证写入后校验失败');
|
|
245
|
+
phase.phase = 'done';
|
|
246
|
+
reply();
|
|
247
|
+
return { phase: 'done', status };
|
|
248
|
+
}
|
|
249
|
+
catch (error) {
|
|
250
|
+
reply(error instanceof Error ? error.message : String(error));
|
|
251
|
+
throw error;
|
|
252
|
+
}
|
|
253
|
+
});
|
|
254
|
+
});
|
|
255
|
+
server.on('error', (error) => {
|
|
256
|
+
finish(async () => {
|
|
257
|
+
throw new Error(error.code === 'EADDRINUSE'
|
|
258
|
+
? '本地回调端口 1455 被占用(可能是 Codex CLI 正在登录)。请先结束占用进程后重试。'
|
|
259
|
+
: `回调服务启动失败: ${error.message}`);
|
|
260
|
+
});
|
|
261
|
+
});
|
|
262
|
+
server.listen(1455, '127.0.0.1', () => {
|
|
263
|
+
openBrowser(url.toString()).catch((error) => {
|
|
264
|
+
finish(async () => { throw new Error(`无法打开浏览器: ${String(error)}`); });
|
|
265
|
+
});
|
|
266
|
+
});
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
// ---------------------------------------------------------------------------
|
|
270
|
+
// loopback RPC channel (same shape as dsh-balance-panel)
|
|
271
|
+
// ---------------------------------------------------------------------------
|
|
272
|
+
function rpcFailure(rpcId, code, message) {
|
|
273
|
+
return { type: 'server-response', rpcId, result: { ok: false, error: { code, message, details: {} } } };
|
|
274
|
+
}
|
|
275
|
+
// Activate the installed catalog route through the host settings service.
|
|
276
|
+
// Never replace existing provider options or hardcode a model catalog.
|
|
277
|
+
async function ensureCodexModels(ctx) {
|
|
278
|
+
const provider = 'openai-codex';
|
|
279
|
+
try {
|
|
280
|
+
if (!ctx.llm || !ctx.settings)
|
|
281
|
+
throw new Error('模型或设置服务尚未就绪,请稍后刷新');
|
|
282
|
+
const entry = (await ctx.llm.listConfigurableProviders()).find((row) => row.provider === provider);
|
|
283
|
+
if (!entry)
|
|
284
|
+
throw new Error('当前宿主未安装 Codex 模型目录,请检查 llm-pi-ai 插件');
|
|
285
|
+
const document = (await ctx.settings.describe()).find((row) => row.ns === entry.settingsNs);
|
|
286
|
+
if (!document)
|
|
287
|
+
throw new Error('无法读取 Codex 提供商配置');
|
|
288
|
+
const current = entry.settingsPath.reduce((value, key) => value?.[key], document.value);
|
|
289
|
+
if (current === undefined) {
|
|
290
|
+
await ctx.settings.mutate(entry.settingsNs, [{ op: 'set', path: entry.settingsPath, value: { displayName: 'Codex (ChatGPT)' } }], document.revision);
|
|
291
|
+
}
|
|
292
|
+
const models = await ctx.llm.listModels(provider);
|
|
293
|
+
if (!models.length)
|
|
294
|
+
throw new Error('Codex 模型目录为空,请检查提供商配置后刷新');
|
|
295
|
+
return { ready: true, count: models.length };
|
|
296
|
+
}
|
|
297
|
+
catch (error) {
|
|
298
|
+
return { ready: false, error: error instanceof Error ? error.message : String(error) };
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
function registerCodexAuthRpc(ctx, credentials, phase) {
|
|
302
|
+
let modelSync;
|
|
303
|
+
const statusWithModels = async () => {
|
|
304
|
+
const status = await readStatus(credentials);
|
|
305
|
+
if (!status.configured || status.expired)
|
|
306
|
+
return status;
|
|
307
|
+
modelSync ?? (modelSync = ensureCodexModels(ctx).finally(() => { modelSync = undefined; }));
|
|
308
|
+
return { ...status, models: await modelSync };
|
|
309
|
+
};
|
|
310
|
+
const route = {
|
|
311
|
+
kind: 'prefix',
|
|
312
|
+
path: RPC_CHANNEL,
|
|
313
|
+
handler: async (req, res) => {
|
|
314
|
+
const rejection = typeof ctx.connection?.requestRejection === 'function' ? ctx.connection.requestRejection(req) : undefined;
|
|
315
|
+
if (rejection !== undefined) {
|
|
316
|
+
res.writeHead(rejection);
|
|
317
|
+
res.end(rejection === 401 ? 'unauthorized' : 'forbidden');
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
const pathname = new URL(req.url ?? '/', 'http://dsh.internal').pathname;
|
|
321
|
+
if (!pathname.startsWith(`${RPC_CHANNEL}/`)) {
|
|
322
|
+
res.writeHead(404).end('not found');
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
const endpoint = pathname.slice(RPC_CHANNEL.length + 1);
|
|
326
|
+
if (req.method !== 'POST' || !/^[A-Za-z0-9_$.-]+$/.test(endpoint)) {
|
|
327
|
+
res.writeHead(404).end('not found');
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
let message = null;
|
|
331
|
+
try {
|
|
332
|
+
const chunks = [];
|
|
333
|
+
for await (const chunk of req)
|
|
334
|
+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
335
|
+
const text = Buffer.concat(chunks).toString('utf8');
|
|
336
|
+
message = text ? JSON.parse(text) : null;
|
|
337
|
+
}
|
|
338
|
+
catch (error) {
|
|
339
|
+
res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(rpcFailure('invalid-request', 'gateway/bad-request', String(error))));
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
const rpcId = typeof message?.rpcId === 'string' ? message.rpcId : 'invalid-request';
|
|
343
|
+
if (message?.type !== 'client-request' || typeof message.method !== 'string' || message.method !== endpoint) {
|
|
344
|
+
res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(rpcFailure(rpcId, 'gateway/bad-request', 'invalid client-request message')));
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
try {
|
|
348
|
+
let result;
|
|
349
|
+
if (endpoint === 'status') {
|
|
350
|
+
result = { status: await statusWithModels(), login: { phase: phase.phase, error: phase.error } };
|
|
351
|
+
}
|
|
352
|
+
else if (endpoint === 'login') {
|
|
353
|
+
void runBrowserLogin(credentials, phase);
|
|
354
|
+
result = { phase: phase.phase };
|
|
355
|
+
if (result.phase === 'done')
|
|
356
|
+
result.status = await readStatus(credentials);
|
|
357
|
+
result.login = { phase: phase.phase, error: phase.error };
|
|
358
|
+
}
|
|
359
|
+
else if (endpoint === 'cancel') {
|
|
360
|
+
if (phase.phase === 'exchanging')
|
|
361
|
+
throw new Error('正在保存授权,请稍候');
|
|
362
|
+
await phase.cancel?.();
|
|
363
|
+
result = { status: await readStatus(credentials), login: { phase: phase.phase, error: phase.error } };
|
|
364
|
+
}
|
|
365
|
+
else if (endpoint === 'logout') {
|
|
366
|
+
if (phase.phase === 'waiting' || phase.phase === 'exchanging')
|
|
367
|
+
throw new Error('授权进行中,请完成后再取消授权');
|
|
368
|
+
await credentials.deleteRecord(CRED_KEY);
|
|
369
|
+
phase.phase = 'idle';
|
|
370
|
+
phase.error = undefined;
|
|
371
|
+
result = { status: await statusWithModels(), login: { phase: phase.phase } };
|
|
372
|
+
}
|
|
373
|
+
else {
|
|
374
|
+
res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(rpcFailure(rpcId, 'gateway/no-endpoint', `unknown endpoint ${endpoint}`)));
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
378
|
+
res.end(JSON.stringify({ type: 'server-response', rpcId, result: { ok: true, value: result } }));
|
|
379
|
+
}
|
|
380
|
+
catch (error) {
|
|
381
|
+
res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(rpcFailure(rpcId, 'gateway/handler', error instanceof Error ? error.message : String(error))));
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
};
|
|
385
|
+
ctx.effect(() => ctx.webServer.register(route), 'dsh-codex-auth: rpc channel');
|
|
386
|
+
}
|
|
387
|
+
// ---------------------------------------------------------------------------
|
|
388
|
+
// plugin entry
|
|
389
|
+
// ---------------------------------------------------------------------------
|
|
390
|
+
export function apply(ctx, _config = {}) {
|
|
391
|
+
const credentials = ctx.credentials;
|
|
392
|
+
const phase = { phase: 'idle' };
|
|
393
|
+
registerCodexAuthRpc(ctx, credentials, phase);
|
|
394
|
+
}
|