@young1lin/dsh-gpt-sub 0.1.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.
Files changed (56) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/LICENSE +21 -0
  3. package/README.md +268 -0
  4. package/client.js +908 -0
  5. package/cordis.patch.yml +44 -0
  6. package/lib/index.d.ts +52 -0
  7. package/lib/index.d.ts.map +1 -0
  8. package/lib/index.js +581 -0
  9. package/lib/index.js.map +1 -0
  10. package/lib/jwt.d.ts +15 -0
  11. package/lib/jwt.d.ts.map +1 -0
  12. package/lib/jwt.js +29 -0
  13. package/lib/jwt.js.map +1 -0
  14. package/lib/proxy-config.d.ts +91 -0
  15. package/lib/proxy-config.d.ts.map +1 -0
  16. package/lib/proxy-config.js +213 -0
  17. package/lib/proxy-config.js.map +1 -0
  18. package/lib/proxy-probe.d.ts +47 -0
  19. package/lib/proxy-probe.d.ts.map +1 -0
  20. package/lib/proxy-probe.js +51 -0
  21. package/lib/proxy-probe.js.map +1 -0
  22. package/lib/proxy-routing.d.ts +111 -0
  23. package/lib/proxy-routing.d.ts.map +1 -0
  24. package/lib/proxy-routing.js +171 -0
  25. package/lib/proxy-routing.js.map +1 -0
  26. package/lib/quota-route.d.ts +85 -0
  27. package/lib/quota-route.d.ts.map +1 -0
  28. package/lib/quota-route.js +105 -0
  29. package/lib/quota-route.js.map +1 -0
  30. package/lib/reset-credits.d.ts +64 -0
  31. package/lib/reset-credits.d.ts.map +1 -0
  32. package/lib/reset-credits.js +84 -0
  33. package/lib/reset-credits.js.map +1 -0
  34. package/lib/token-store.d.ts +107 -0
  35. package/lib/token-store.d.ts.map +1 -0
  36. package/lib/token-store.js +228 -0
  37. package/lib/token-store.js.map +1 -0
  38. package/lib/types.d.ts +18 -0
  39. package/lib/types.d.ts.map +1 -0
  40. package/lib/types.js +2 -0
  41. package/lib/types.js.map +1 -0
  42. package/lib/usage.d.ts +92 -0
  43. package/lib/usage.d.ts.map +1 -0
  44. package/lib/usage.js +106 -0
  45. package/lib/usage.js.map +1 -0
  46. package/package.json +82 -0
  47. package/src/index.ts +685 -0
  48. package/src/jwt.ts +26 -0
  49. package/src/proxy-config.ts +217 -0
  50. package/src/proxy-probe.ts +81 -0
  51. package/src/proxy-routing.ts +205 -0
  52. package/src/quota-route.ts +163 -0
  53. package/src/reset-credits.ts +128 -0
  54. package/src/token-store.ts +309 -0
  55. package/src/types.ts +17 -0
  56. package/src/usage.ts +154 -0
package/src/index.ts ADDED
@@ -0,0 +1,685 @@
1
+ /**
2
+ * Direct ChatGPT/Codex subscription access for DeepSeek Harness.
3
+ *
4
+ * This plugin owns no transport. pi-ai already ships an `openai-codex`
5
+ * provider that knows the Codex wire format -- it sets `store`, derives
6
+ * `chatgpt-account-id` from the access token's own JWT claim, and speaks the
7
+ * codex-responses protocol -- but it authenticates only through OAuth, and
8
+ * `dsh-llm-pi-ai` runs no login flow and holds no OAuth store. Naming a
9
+ * credential on the route grafts an api-key method beside the provider's own,
10
+ * which is the seam this plugin fills: it keeps a live Codex access token in
11
+ * the harness credential store, refreshing it from `~/.codex/auth.json` before
12
+ * it expires.
13
+ *
14
+ * The result needs no loopback port, no shared local key, and no external
15
+ * proxy binary. The `codex` CLI still owns the login.
16
+ *
17
+ * @module dsh-gpt-sub
18
+ */
19
+
20
+ import type { Context } from '@deepseek-ai/cordis'
21
+ import { credentialRef } from '@deepseek-ai/dsh-credentials'
22
+ import z from '@deepseek-ai/schemastery'
23
+ // Type-only: loads the module augmentation that puts `webServer` on Context.
24
+ import type {} from '@deepseek-ai/dsh-host-webserver'
25
+ import type { Dispatcher } from 'undici'
26
+ import { readdir, stat } from 'node:fs/promises'
27
+ import { homedir } from 'node:os'
28
+ import { randomUUID } from 'node:crypto'
29
+ import { dirname, join } from 'node:path'
30
+ import {
31
+ crossOrigin,
32
+ loadStateOverride,
33
+ readJsonObject,
34
+ redactProxyUrl,
35
+ saveStateOverride,
36
+ validateProxyUrl,
37
+ type StateOverride,
38
+ } from './proxy-config.ts'
39
+ import { probeProxy } from './proxy-probe.ts'
40
+ import { consumeResetCredit, listResetCredits } from './reset-credits.ts'
41
+ import { installProxyRouting, type ProxyRouting } from './proxy-routing.ts'
42
+ import { QuotaSource } from './quota-route.ts'
43
+ import { TokenStore } from './token-store.ts'
44
+ import type { Config as ConfigShape } from './types.ts'
45
+ import { fetchUsage, reportedWindows } from './usage.ts'
46
+
47
+ // A type-only re-export of the same name as the `Config` value below would
48
+ // collide (TS2323); a local alias merges with the value export instead.
49
+ export type Config = ConfigShape
50
+
51
+ /** Cordis plugin name. */
52
+ export const name = 'gpt-sub'
53
+
54
+ /**
55
+ * Required services. Without this declaration cordis's context proxy throws
56
+ * `cannot get property "credentials" without inject` the moment the plugin
57
+ * reads `ctx.credentials`, and the plugin sits dead with no retry.
58
+ */
59
+ export const inject = ['credentials', 'webServer']
60
+
61
+ /** Where the browser half polls for the quota reading. */
62
+ const QUOTA_ROUTE = '/gpt-sub/quota'
63
+
64
+ /** Where the browser half reads auth/proxy status the panel renders. */
65
+ const STATUS_ROUTE = '/gpt-sub/status'
66
+
67
+ /** Where the browser half switches the live proxy at runtime. */
68
+ const PROXY_ROUTE = '/gpt-sub/proxy'
69
+
70
+ /** Where the browser half probes a candidate proxy before saving it. */
71
+ const PROXY_TEST_ROUTE = '/gpt-sub/proxy/test'
72
+
73
+ /** Where the browser half switches the credential file at runtime. */
74
+ const AUTH_ROUTE = '/gpt-sub/auth'
75
+
76
+ /** Where the browser half lists a directory to pick a credential file. */
77
+ const AUTH_BROWSE_ROUTE = '/gpt-sub/auth/browse'
78
+
79
+ /** Ceiling on one directory listing, so a huge folder cannot flood the page. */
80
+ const BROWSE_LIMIT = 500
81
+
82
+ /** Ceiling on one connectivity probe. */
83
+ const PROBE_TIMEOUT_MS = 15_000
84
+
85
+ /** Where the browser half lists rate-limit reset credits. */
86
+ const RESET_CREDITS_ROUTE = '/gpt-sub/reset-credits'
87
+
88
+ /** Where the browser half consumes one rate-limit reset credit. */
89
+ const RESET_CREDITS_CONSUME_ROUTE = '/gpt-sub/reset-credits/consume'
90
+
91
+ /** Ceiling on one reset-credit upstream call, matching the codex CLI's 10s. */
92
+ const RESET_CREDITS_TIMEOUT_MS = 10_000
93
+
94
+ /**
95
+ * How a proxy URL appears in logs: credentials masked, direct named as such.
96
+ *
97
+ * @param url - the proxy URL as configured or saved.
98
+ * @returns the log-safe rendering.
99
+ */
100
+ const displayProxy = (url: string): string => (url === '' ? 'direct' : redactProxyUrl(url))
101
+
102
+ /**
103
+ * Plugin config schema.
104
+ *
105
+ * Typed input-then-output: every field has a default, so a config document may
106
+ * supply any subset, and what comes back out is a fully resolved `ConfigShape`.
107
+ */
108
+ export const Config: z<Partial<ConfigShape>, ConfigShape> = z.object({
109
+ // Empty means a direct connection. The token endpoint is as unreachable as
110
+ // the model endpoint on a machine that needs a proxy, so this covers both.
111
+ proxyUrl: z.string().default(''),
112
+ authFile: z.string().default('~/.codex/auth.json'),
113
+ refreshMarginMinutes: z.number().default(30),
114
+ // The credential the provider route names in settings.yaml. Both sides must
115
+ // agree, so it is configurable rather than hard-coded.
116
+ tokenRef: z.string().role('credential-ref').default('CODEX_NATIVE_TOKEN'),
117
+ // How often to re-read auth.json and republish. Well under the refresh
118
+ // margin, so a token nearing expiry is always renewed before a request needs
119
+ // it. Each tick is a file read; it reaches the network only to refresh.
120
+ syncIntervalMinutes: z.number().default(10),
121
+ // How many times a single request may be re-attempted after the connection
122
+ // drops. The link to the proxy fails at random, and each failure otherwise
123
+ // reaches the user as a bare `fetch failed` mid-conversation.
124
+ bootstrapRetries: z.number().default(3),
125
+ // Where the settings panel's proxy edits are persisted. When this file
126
+ // exists, its proxyUrl wins over the config's -- a page edit survives
127
+ // restarts without touching cordis config layers.
128
+ stateFile: z.string().default('~/.dsh/gpt-sub.json'),
129
+ })
130
+
131
+ /** Expand a leading `~` against the current user's home directory. */
132
+ function expandHome(path: string): string {
133
+ return path.startsWith('~/') ? join(homedir(), path.slice(2)) : path
134
+ }
135
+
136
+ /**
137
+ * Publish the current Codex access token into the credential store, and keep
138
+ * publishing it for as long as the plugin is loaded.
139
+ *
140
+ * Async on purpose: cordis awaits a thenable returned from plugin startup and
141
+ * collects the function it resolves to as the fiber's disposer. A missing or
142
+ * unreadable `auth.json` therefore fails the fiber visibly at start instead of
143
+ * leaving a route configured against a credential nothing maintains.
144
+ *
145
+ * @param ctx - the cordis context.
146
+ * @param config - validated plugin configuration.
147
+ * @returns the teardown that stops the sync timer and closes the dispatcher.
148
+ */
149
+ export async function apply(ctx: Context, config: ConfigShape): Promise<() => Promise<void>> {
150
+ const stateFile = expandHome(config.stateFile)
151
+
152
+ // A panel-saved override wins over the config value; a corrupt file falls
153
+ // back to the config rather than failing the plugin over a status file.
154
+ // One state file carries both page-side choices; a field present in it wins
155
+ // over the config value, and a differing choice is logged at start.
156
+ const savedState = await loadStateOverride(stateFile)
157
+ const savedProxy = savedState?.proxyUrl
158
+ if (savedProxy !== undefined && savedProxy !== config.proxyUrl) {
159
+ ctx.logger.info(
160
+ 'gpt-sub: proxy override %s from %s replaces config proxyUrl %s',
161
+ displayProxy(savedProxy),
162
+ stateFile,
163
+ displayProxy(config.proxyUrl),
164
+ )
165
+ }
166
+ let proxyUrl = savedProxy ?? config.proxyUrl
167
+ let overridden = savedProxy !== undefined
168
+
169
+ const savedAuthFile = savedState?.authFile
170
+ if (savedAuthFile !== undefined && savedAuthFile !== config.authFile) {
171
+ ctx.logger.info(
172
+ 'gpt-sub: credential file %s from %s replaces config authFile %s',
173
+ savedAuthFile,
174
+ stateFile,
175
+ config.authFile,
176
+ )
177
+ }
178
+ let authFileDisplay = savedAuthFile ?? config.authFile
179
+ let authOverridden = savedAuthFile !== undefined
180
+
181
+ // Mirror of what the state file currently holds. Every save rewrites the
182
+ // whole object, so recording one choice never erases the other.
183
+ const persisted: StateOverride = {}
184
+ if (overridden) persisted.proxyUrl = proxyUrl
185
+ if (authOverridden) persisted.authFile = authFileDisplay
186
+
187
+ // pi-ai issues model calls through the global fetch, so the proxy has to be
188
+ // installed there rather than handed to a component. Scoped by hostname, so
189
+ // no other provider's traffic changes route.
190
+ let routing: ProxyRouting | undefined =
191
+ proxyUrl === ''
192
+ ? undefined
193
+ : installProxyRouting(proxyUrl, { maxRetries: config.bootstrapRetries })
194
+ let dispatcher: Dispatcher | undefined = routing?.dispatcher
195
+ if (routing !== undefined) {
196
+ ctx.logger.info(
197
+ 'gpt-sub: routing chatgpt.com and auth.openai.com through %s, retrying dropped connections %d times',
198
+ displayProxy(proxyUrl),
199
+ config.bootstrapRetries,
200
+ )
201
+ }
202
+
203
+ const tokens = new TokenStore({
204
+ authFile: expandHome(authFileDisplay),
205
+ refreshMarginMs: config.refreshMarginMinutes * 60_000,
206
+ ...(dispatcher === undefined ? {} : { dispatcher }),
207
+ })
208
+
209
+ const ref = credentialRef(config.tokenRef)
210
+
211
+ /** When the last successful sync finished; 0 before the first. */
212
+ let lastSyncAt = 0
213
+
214
+ /**
215
+ * Read the current token -- refreshing first when it is inside the margin --
216
+ * and write it to the credential the provider route reads.
217
+ *
218
+ * @returns nothing; failures propagate to the caller.
219
+ */
220
+ const sync = async (): Promise<void> => {
221
+ const current = await tokens.getTokens()
222
+ await ctx.credentials.set(ref, current.accessToken)
223
+ lastSyncAt = Date.now()
224
+ }
225
+
226
+ // Fail here, naming the path, rather than leaving the route pointed at a
227
+ // credential that will never be populated. A rejected apply() means cordis
228
+ // never receives the disposer below, so the global routing installed above
229
+ // has to be undone here too: a failed plugin must not leave the process
230
+ // dispatching through its still-open proxy agent.
231
+ try {
232
+ await tokens.verify()
233
+ await sync()
234
+ } catch (error) {
235
+ await routing?.uninstall()
236
+ throw error
237
+ }
238
+ ctx.logger.info('gpt-sub: published Codex access token to %s', config.tokenRef)
239
+
240
+ // Reachability probe. An unproxied egress answers 403 with a Cloudflare
241
+ // block page, and that failure is otherwise invisible until the first model
242
+ // call, where it surfaces as unreadable HTML. Log it loudly here instead.
243
+ // A probe failure must not fail the plugin: the network may simply be down,
244
+ // and the credential is published either way.
245
+ try {
246
+ const current = await tokens.getTokens()
247
+ // Bounded like the panel's own probe: a black-holed route must delay
248
+ // startup by seconds, not by the operating system's connect timeout.
249
+ const usage = await fetchUsage(current.accessToken, dispatcher, undefined, AbortSignal.timeout(PROBE_TIMEOUT_MS))
250
+ const windows = reportedWindows(usage)
251
+ if (windows.length === 0) {
252
+ ctx.logger.info('gpt-sub: reachable; plan %s, no rate-limit window reported', usage.plan_type ?? 'unknown')
253
+ } else {
254
+ ctx.logger.info(
255
+ 'gpt-sub: reachable; plan %s, %s',
256
+ usage.plan_type ?? 'unknown',
257
+ windows
258
+ .map((window) => window.used_percent + '% of the ' + Math.round(window.limit_window_seconds / 3600) + 'h window')
259
+ .join(', '),
260
+ )
261
+ }
262
+ } catch (error) {
263
+ ctx.logger.warn('gpt-sub: reachability probe failed: %s', String(error))
264
+ }
265
+
266
+ // The quota panel's data source. Reads the same token the model calls use,
267
+ // through the same proxy routing, and throttles so one open settings page
268
+ // cannot turn into a stream of upstream requests.
269
+ const quota = new QuotaSource({
270
+ accessToken: async () => (await tokens.getTokens()).accessToken,
271
+ ...(dispatcher === undefined ? {} : { dispatcher }),
272
+ })
273
+
274
+ /**
275
+ * Swap the live proxy routing, serializing concurrent switches.
276
+ *
277
+ * Restores the pre-plugin global dispatcher before installing the next
278
+ * routing, so no request ever dispatches into a closing agent, and points
279
+ * the token store and quota source at the new dispatcher.
280
+ *
281
+ * @param nextUrl - the proxy URL to route through; empty string for direct.
282
+ * @returns nothing; failures propagate to the caller.
283
+ */
284
+ let switching: Promise<void> = Promise.resolve()
285
+ const switchProxy = (nextUrl: string): Promise<void> => {
286
+ const run = async (): Promise<void> => {
287
+ const previous = routing
288
+ routing = undefined
289
+ await previous?.uninstall()
290
+ routing =
291
+ nextUrl === '' ? undefined : installProxyRouting(nextUrl, { maxRetries: config.bootstrapRetries })
292
+ dispatcher = routing?.dispatcher
293
+ tokens.setDispatcher(dispatcher)
294
+ quota.setDispatcher(dispatcher)
295
+ proxyUrl = nextUrl
296
+ }
297
+ switching = switching.then(run, run)
298
+ return switching
299
+ }
300
+
301
+ /**
302
+ * Swap the live credential file, serializing concurrent switches the way
303
+ * {@link switchProxy} does.
304
+ *
305
+ * The caller validates a non-empty candidate before calling; this persists,
306
+ * points the token store at the new file, and republishes immediately, so a
307
+ * model call cannot race the next sync tick with the previous file's token.
308
+ *
309
+ * @param rawPath - the path as typed on the page; empty restores the config.
310
+ * @returns the display path now in force, and whether the token republished.
311
+ */
312
+ let switchingAuth: Promise<{ display: string; published: boolean }> = Promise.resolve({
313
+ display: '',
314
+ published: true,
315
+ })
316
+ const switchAuthFile = (rawPath: string): Promise<{ display: string; published: boolean }> => {
317
+ const run = async (): Promise<{ display: string; published: boolean }> => {
318
+ if (rawPath === '') {
319
+ delete persisted.authFile
320
+ await saveStateOverride(stateFile, persisted)
321
+ authOverridden = false
322
+ authFileDisplay = config.authFile
323
+ } else {
324
+ persisted.authFile = rawPath
325
+ await saveStateOverride(stateFile, persisted)
326
+ authOverridden = true
327
+ authFileDisplay = rawPath
328
+ }
329
+ tokens.setAuthFile(expandHome(authFileDisplay))
330
+ try {
331
+ await sync()
332
+ return { display: authFileDisplay, published: true }
333
+ } catch (error) {
334
+ // The switch itself succeeded; only the publish failed, and the sync
335
+ // timer retries it. Say so rather than failing a choice already made.
336
+ ctx.logger.warn(
337
+ 'gpt-sub: token sync failed after switching to %s, retrying next tick: %s',
338
+ authFileDisplay,
339
+ String(error),
340
+ )
341
+ return { display: authFileDisplay, published: false }
342
+ }
343
+ }
344
+ switchingAuth = switchingAuth.then(run, run)
345
+ return switchingAuth
346
+ }
347
+
348
+ /** Reply with JSON, never cached. */
349
+ const replyJson = (response: import('node:http').ServerResponse, status: number, body: unknown): void => {
350
+ response.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' })
351
+ response.end(JSON.stringify(body))
352
+ }
353
+
354
+ ctx.effect(
355
+ () =>
356
+ ctx.webServer.register({
357
+ kind: 'exact',
358
+ path: QUOTA_ROUTE,
359
+ handler: async (request, response) => {
360
+ const force = new URL(request.url ?? '/', 'http://x').searchParams.get('refresh') === '1'
361
+ const state = await quota.read(force)
362
+ replyJson(response, 200, state)
363
+ },
364
+ }),
365
+ `gpt-sub: GET ${QUOTA_ROUTE}`,
366
+ )
367
+
368
+ ctx.effect(
369
+ () =>
370
+ ctx.webServer.register({
371
+ kind: 'exact',
372
+ path: STATUS_ROUTE,
373
+ handler: async (request, response) => {
374
+ if (request.method !== 'GET') {
375
+ replyJson(response, 405, { message: 'method not allowed' })
376
+ return
377
+ }
378
+ // inspect() is file-only, so a status poll never refreshes a token
379
+ // and never reaches the network.
380
+ const inspection = await tokens.inspect().then(
381
+ (value) => value,
382
+ () => undefined,
383
+ )
384
+ const expiry = inspection?.accessTokenExpiresAt
385
+ const syncIntervalMs = config.syncIntervalMinutes * 60_000
386
+ replyJson(response, 200, {
387
+ authFile: authFileDisplay,
388
+ configAuthFile: config.authFile,
389
+ authOverridden,
390
+ proxyUrl,
391
+ configProxyUrl: config.proxyUrl,
392
+ overridden,
393
+ refreshMarginMinutes: config.refreshMarginMinutes,
394
+ syncIntervalMinutes: config.syncIntervalMinutes,
395
+ ...(lastSyncAt === 0 ? {} : { lastSyncAt, nextSyncAt: lastSyncAt + syncIntervalMs }),
396
+ ...(expiry === undefined
397
+ ? {}
398
+ : { tokenExpiresAt: expiry, refreshAt: expiry - config.refreshMarginMinutes * 60_000 }),
399
+ })
400
+ },
401
+ }),
402
+ `gpt-sub: GET ${STATUS_ROUTE}`,
403
+ )
404
+
405
+ ctx.effect(
406
+ () =>
407
+ ctx.webServer.register({
408
+ kind: 'exact',
409
+ path: PROXY_ROUTE,
410
+ handler: async (request, response) => {
411
+ if (request.method !== 'POST') {
412
+ replyJson(response, 405, { message: 'method not allowed' })
413
+ return
414
+ }
415
+ if (crossOrigin(request)) {
416
+ replyJson(response, 403, { ok: false, message: 'cross-origin request refused' })
417
+ return
418
+ }
419
+ let nextUrl: string
420
+ try {
421
+ nextUrl = validateProxyUrl((await readJsonObject(request))['proxyUrl'])
422
+ } catch (error) {
423
+ replyJson(response, 400, { ok: false, message: error instanceof Error ? error.message : String(error) })
424
+ return
425
+ }
426
+ try {
427
+ persisted.proxyUrl = nextUrl
428
+ await saveStateOverride(stateFile, persisted)
429
+ overridden = true
430
+ await switchProxy(nextUrl)
431
+ ctx.logger.info('gpt-sub: proxy switched to %s', displayProxy(nextUrl))
432
+ replyJson(response, 200, { ok: true, proxyUrl: nextUrl })
433
+ } catch (error) {
434
+ replyJson(response, 500, { ok: false, message: error instanceof Error ? error.message : String(error) })
435
+ }
436
+ },
437
+ }),
438
+ `gpt-sub: POST ${PROXY_ROUTE}`,
439
+ )
440
+
441
+ ctx.effect(
442
+ () =>
443
+ ctx.webServer.register({
444
+ kind: 'exact',
445
+ path: PROXY_TEST_ROUTE,
446
+ handler: async (request, response) => {
447
+ if (request.method !== 'POST') {
448
+ replyJson(response, 405, { message: 'method not allowed' })
449
+ return
450
+ }
451
+ if (crossOrigin(request)) {
452
+ replyJson(response, 403, { ok: false, message: 'cross-origin request refused' })
453
+ return
454
+ }
455
+ let candidate = proxyUrl
456
+ try {
457
+ const body = await readJsonObject(request)
458
+ if (body['proxyUrl'] !== undefined) candidate = validateProxyUrl(body['proxyUrl'])
459
+ } catch (error) {
460
+ replyJson(response, 400, { ok: false, message: error instanceof Error ? error.message : String(error) })
461
+ return
462
+ }
463
+ try {
464
+ const accessToken = (await tokens.getTokens()).accessToken
465
+ const result = await probeProxy({
466
+ proxyUrl: candidate,
467
+ accessToken,
468
+ timeoutMs: PROBE_TIMEOUT_MS,
469
+ })
470
+ replyJson(response, 200, result)
471
+ } catch (error) {
472
+ replyJson(response, 500, { ok: false, message: error instanceof Error ? error.message : String(error) })
473
+ }
474
+ },
475
+ }),
476
+ `gpt-sub: POST ${PROXY_TEST_ROUTE}`,
477
+ )
478
+
479
+ ctx.effect(
480
+ () =>
481
+ ctx.webServer.register({
482
+ kind: 'exact',
483
+ path: AUTH_ROUTE,
484
+ handler: async (request, response) => {
485
+ if (request.method !== 'POST') {
486
+ replyJson(response, 405, { message: 'method not allowed' })
487
+ return
488
+ }
489
+ if (crossOrigin(request)) {
490
+ replyJson(response, 403, { ok: false, message: 'cross-origin request refused' })
491
+ return
492
+ }
493
+ // Absent or empty means "back to the configured default"; otherwise
494
+ // the trimmed string is the candidate path.
495
+ let candidate: string | undefined
496
+ try {
497
+ const body = await readJsonObject(request)
498
+ const value = body['authFile']
499
+ if (value !== undefined && value !== null) {
500
+ if (typeof value !== 'string') throw new Error('authFile must be a string')
501
+ candidate = value.trim()
502
+ }
503
+ } catch (error) {
504
+ replyJson(response, 400, { ok: false, message: error instanceof Error ? error.message : String(error) })
505
+ return
506
+ }
507
+ // Validate BEFORE persisting or switching: a path that cannot serve
508
+ // credentials must never become the live source, here or after a
509
+ // restart. verify() is file-only -- the check spends no refresh.
510
+ if (candidate !== undefined && candidate !== '') {
511
+ try {
512
+ await new TokenStore({
513
+ authFile: expandHome(candidate),
514
+ refreshMarginMs: config.refreshMarginMinutes * 60_000,
515
+ }).verify()
516
+ } catch (error) {
517
+ replyJson(response, 400, { ok: false, message: error instanceof Error ? error.message : String(error) })
518
+ return
519
+ }
520
+ }
521
+ try {
522
+ const result = await switchAuthFile(candidate ?? '')
523
+ ctx.logger.info(
524
+ 'gpt-sub: credential file %s',
525
+ candidate === undefined || candidate === ''
526
+ ? `restored to configured ${result.display}`
527
+ : `switched to ${result.display}`,
528
+ )
529
+ replyJson(response, 200, { ok: true, authFile: result.display, published: result.published })
530
+ } catch (error) {
531
+ replyJson(response, 500, { ok: false, message: error instanceof Error ? error.message : String(error) })
532
+ }
533
+ },
534
+ }),
535
+ `gpt-sub: POST ${AUTH_ROUTE}`,
536
+ )
537
+
538
+ ctx.effect(
539
+ () =>
540
+ ctx.webServer.register({
541
+ kind: 'exact',
542
+ path: AUTH_BROWSE_ROUTE,
543
+ handler: async (request, response) => {
544
+ if (request.method !== 'GET') {
545
+ replyJson(response, 405, { message: 'method not allowed' })
546
+ return
547
+ }
548
+ const requested = new URL(request.url ?? '/', 'http://x').searchParams.get('path') ?? ''
549
+ // Empty means start from the home directory, where ~/.codex lives.
550
+ // A listing carries names and paths only -- never file contents.
551
+ const target = requested.trim() === '' ? homedir() : expandHome(requested.trim())
552
+ try {
553
+ if (!(await stat(target)).isDirectory()) throw new Error('not a directory')
554
+ } catch {
555
+ replyJson(response, 400, { ok: false, message: `not a readable directory: ${target}` })
556
+ return
557
+ }
558
+ try {
559
+ const dirents = await readdir(target, { withFileTypes: true })
560
+ const entries = dirents
561
+ .slice(0, BROWSE_LIMIT)
562
+ .map((entry) => ({
563
+ name: entry.name,
564
+ path: join(target, entry.name),
565
+ dir: entry.isDirectory(),
566
+ }))
567
+ .sort((a, b) => (a.dir === b.dir ? a.name.localeCompare(b.name) : a.dir ? -1 : 1))
568
+ // The root's parent is itself, so the panel hides the up-row
569
+ // rather than offering a dead link.
570
+ const parent = dirname(target)
571
+ replyJson(response, 200, {
572
+ ok: true,
573
+ dir: target,
574
+ ...(parent !== target ? { parent } : {}),
575
+ truncated: dirents.length > BROWSE_LIMIT,
576
+ entries,
577
+ })
578
+ } catch (error) {
579
+ replyJson(response, 400, { ok: false, message: error instanceof Error ? error.message : String(error) })
580
+ }
581
+ },
582
+ }),
583
+ `gpt-sub: GET ${AUTH_BROWSE_ROUTE}`,
584
+ )
585
+
586
+ ctx.effect(
587
+ () =>
588
+ ctx.webServer.register({
589
+ kind: 'exact',
590
+ path: RESET_CREDITS_ROUTE,
591
+ handler: async (request, response) => {
592
+ if (request.method !== 'GET') {
593
+ replyJson(response, 405, { ok: false, message: 'method not allowed' })
594
+ return
595
+ }
596
+ try {
597
+ const accessToken = (await tokens.getTokens()).accessToken
598
+ const details = await listResetCredits(
599
+ accessToken,
600
+ dispatcher,
601
+ undefined,
602
+ AbortSignal.timeout(RESET_CREDITS_TIMEOUT_MS),
603
+ )
604
+ replyJson(response, 200, { ok: true, ...details })
605
+ } catch (error) {
606
+ replyJson(response, 200, { ok: false, message: error instanceof Error ? error.message : String(error) })
607
+ }
608
+ },
609
+ }),
610
+ `gpt-sub: GET ${RESET_CREDITS_ROUTE}`,
611
+ )
612
+
613
+ // Redeeming is destructive and idempotent only per key, so the host mints a
614
+ // fresh key per click; the page sends the credit id it means to spend.
615
+ ctx.effect(
616
+ () =>
617
+ ctx.webServer.register({
618
+ kind: 'exact',
619
+ path: RESET_CREDITS_CONSUME_ROUTE,
620
+ handler: async (request, response) => {
621
+ if (request.method !== 'POST') {
622
+ replyJson(response, 405, { ok: false, message: 'method not allowed' })
623
+ return
624
+ }
625
+ if (crossOrigin(request)) {
626
+ replyJson(response, 403, { ok: false, message: 'cross-origin request refused' })
627
+ return
628
+ }
629
+ let creditId: string | undefined
630
+ try {
631
+ const body = await readJsonObject(request)
632
+ const value = body['creditId']
633
+ if (value === undefined || value === null) creditId = undefined
634
+ else if (typeof value !== 'string' || value.trim() === '') {
635
+ throw new Error('creditId must be a non-empty string when present')
636
+ } else creditId = value.trim()
637
+ } catch (error) {
638
+ replyJson(response, 400, { ok: false, message: error instanceof Error ? error.message : String(error) })
639
+ return
640
+ }
641
+ try {
642
+ const accessToken = (await tokens.getTokens()).accessToken
643
+ const result = await consumeResetCredit(accessToken, randomUUID(), {
644
+ ...(creditId === undefined ? {} : { creditId }),
645
+ ...(dispatcher === undefined ? {} : { dispatcher }),
646
+ signal: AbortSignal.timeout(RESET_CREDITS_TIMEOUT_MS),
647
+ })
648
+ ctx.logger.info(
649
+ 'gpt-sub: rate-limit reset credit consumed (code=%s, windows_reset=%d, credit=%s)',
650
+ result.code,
651
+ result.windows_reset ?? 0,
652
+ creditId ?? 'any',
653
+ )
654
+ // The window just changed, so serve the next quota poll from a
655
+ // fresh upstream read instead of the throttle's cache.
656
+ await quota.read(true).catch(() => undefined)
657
+ replyJson(response, 200, { ok: true, ...result })
658
+ } catch (error) {
659
+ replyJson(response, 500, { ok: false, message: error instanceof Error ? error.message : String(error) })
660
+ }
661
+ },
662
+ }),
663
+ `gpt-sub: POST ${RESET_CREDITS_CONSUME_ROUTE}`,
664
+ )
665
+
666
+ const timer = setInterval(() => {
667
+ // A rejected timer callback would be an unhandled rejection, and DSH's
668
+ // handler exits the process. A transient refresh failure must cost a log
669
+ // line, not the harness: the next tick tries again, and the token in the
670
+ // store stays valid until its own expiry regardless.
671
+ void sync().catch((error: unknown) => {
672
+ ctx.logger.warn('gpt-sub: token sync failed, retrying next tick: %s', String(error))
673
+ })
674
+ }, config.syncIntervalMinutes * 60_000)
675
+ // Never let this timer be the reason the process stays alive.
676
+ timer.unref?.()
677
+
678
+ return async () => {
679
+ clearInterval(timer)
680
+ // Restores the previous global dispatcher before closing the agent, so a
681
+ // fiber restart never leaves the host dispatching into a closing proxy.
682
+ await switching.catch(() => undefined)
683
+ await routing?.uninstall()
684
+ }
685
+ }