dsh-codex-oauth 0.2.7 → 0.3.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/client/index.jsx CHANGED
@@ -1,53 +1,101 @@
1
1
  import React, { useCallback, useEffect, useState } from "react";
2
2
 
3
- const STATUS = "/plugins/dsh-codex-oauth/auth/status";
4
- const LOGIN = "/plugins/dsh-codex-oauth/auth/login";
5
- const LOGOUT = "/plugins/dsh-codex-oauth/auth/logout";
3
+ const BASE = "/plugins/dsh-codex-oauth/auth";
6
4
  const h = React.createElement;
7
-
8
5
  async function request(path, method = "GET") {
9
- const response = await fetch(path, { method, credentials: "same-origin", headers: { accept: "application/json" } });
6
+ const response = await fetch(`${BASE}/${path}`, { method, credentials: "same-origin", headers: { accept: "application/json" } });
10
7
  const value = await response.json().catch(() => ({}));
11
- if (!response.ok) throw new Error(value && typeof value === "object" && typeof value.error === "string" ? value.error : `HTTP ${response.status}`);
8
+ if (!response.ok) throw new Error(typeof value.error === "string" ? value.error : `HTTP ${response.status}`);
12
9
  return value;
13
10
  }
14
-
11
+ function QuotaPanel({ quota }) {
12
+ if (!quota?.available) return h("div", { role: "status" }, "Quota unavailable. No current quota data is available.");
13
+ return h("div", { style: { display: "flex", flexDirection: "column", gap: 12 } },
14
+ ...quota.buckets.map((bucket) => h("section", { key: bucket.id },
15
+ h("strong", null, bucket.name ?? bucket.id),
16
+ ...bucket.windows.map((window, index) => {
17
+ const remaining = Math.round(window.remainingPercent);
18
+ const color = window.stale ? "#888" : remaining <= 10 ? "#c44" : remaining <= 30 ? "#b88020" : "#38875c";
19
+ return h("div", { key: index, style: { marginTop: 8 } },
20
+ h("div", null, `${window.label}: ${remaining}% remaining${window.stale ? " (stale)" : ""}`),
21
+ h("div", { role: "progressbar", "aria-label": `${window.label} quota remaining`, "aria-valuemin": 0, "aria-valuemax": 100, "aria-valuenow": window.remainingPercent,
22
+ style: { height: 7, background: "var(--dsw-alias-border-l2)", borderRadius: 4, marginTop: 4 } },
23
+ h("div", { style: { width: `${window.remainingPercent}%`, height: "100%", background: color, borderRadius: 4 } })),
24
+ h("small", null, window.resetsAt ? `Reset: ${new Date(window.resetsAt * 1000).toLocaleString("en-US")}` : "Reset time unavailable"),
25
+ h("small", { style: { display: "block" } }, `Updated: ${new Date(window.checkedAt).toLocaleString("en-US")}`));
26
+ }))),
27
+ h("small", null, "Quota checks do not call a model."));
28
+ }
15
29
  function CodexSettings() {
16
30
  const [state, setState] = useState({ status: "loading" });
17
31
  const [busy, setBusy] = useState(false);
18
- const refresh = useCallback(() => request(STATUS).then(setState).catch((error) => setState({ status: "error", message: error.message })), []);
32
+ const [notice, setNotice] = useState("");
33
+ const refresh = useCallback(() => request("status").then(setState).catch((error) => setState({ status: "error", message: error.message })), []);
19
34
  useEffect(() => { refresh(); }, [refresh]);
20
35
  useEffect(() => {
21
36
  if (state.status !== "signing-in") return undefined;
22
37
  const timer = setInterval(refresh, 1000);
23
38
  return () => clearInterval(timer);
24
39
  }, [refresh, state.status]);
25
- const signIn = async () => {
40
+ const signIn = async (newAccount = false) => {
26
41
  const popup = window.open("about:blank", "_blank");
27
- if (!popup) { setState({ status: "error", message: "Please allow popups to sign in" }); return; }
42
+ if (!popup) { setState({ ...state, status: "error", message: "Allow pop-ups to complete sign-in." }); return; }
28
43
  popup.opener = null;
29
- setBusy(true); setState({ status: "signing-in" });
44
+ setBusy(true); setState({ ...state, status: "signing-in" }); setNotice("");
45
+ try { const challenge = await request(`login${newAccount ? "?new_account=1" : ""}`, "POST"); popup.location.replace(challenge.url); }
46
+ catch (error) { popup.close(); setState({ ...state, status: "error", message: error.message }); }
47
+ finally { setBusy(false); }
48
+ };
49
+ const signOut = async () => {
50
+ setBusy(true);
30
51
  try {
31
- const challenge = await request(LOGIN, "POST");
32
- popup.location.replace(challenge.url);
33
- } catch (error) {
34
- popup?.close();
35
- setState({ status: "error", message: error.message });
36
- } finally { setBusy(false); }
52
+ const result = await request("logout", "POST");
53
+ if (!result.remoteRevoked) setNotice("Signed out locally. Remote revocation was not confirmed; disconnect the app in ChatGPT usage settings.");
54
+ await refresh();
55
+ } catch (error) { setState({ ...state, status: "error", message: error.message }); }
56
+ finally { setBusy(false); }
57
+ };
58
+ const selectAccount = async (clientId) => {
59
+ setBusy(true); setNotice("");
60
+ try { setState(await request(`account?client_id=${encodeURIComponent(clientId)}`, "POST")); }
61
+ catch (error) { setState({ ...state, status: "error", message: error.message }); }
62
+ finally { setBusy(false); }
37
63
  };
38
- const signOut = async () => { setBusy(true); try { await request(LOGOUT, "POST"); setState({ status: "signed-out" }); } catch (error) { setState({ status: "error", message: error.message }); } finally { setBusy(false); } };
39
- const signedIn = state.status === "signed-in";
40
- const label = state.status === "signed-in" ? "已登录" : state.status === "signing-in" ? "等待浏览器完成登录…" : state.status === "loading" ? "检查登录状态…" : state.status === "error" ? "登录失败" : "未登录";
64
+ const refreshModels = async () => {
65
+ setBusy(true);
66
+ try { const { models } = await request("models", "POST"); setNotice(`Refreshed ${models.length} available models.`); }
67
+ catch (error) { setNotice(error.message); }
68
+ finally { setBusy(false); }
69
+ };
70
+ const signedIn = state.authenticated && state.planUsage;
71
+ useEffect(() => {
72
+ if (!signedIn) return undefined;
73
+ const timer = setInterval(refresh, 30_000);
74
+ return () => clearInterval(timer);
75
+ }, [signedIn, refresh]);
76
+ const signingIn = state.status === "signing-in";
77
+ const label = signingIn ? "Waiting for browser sign-in…" : signedIn ? `Signed in${state.label ? `: ${state.label}` : ""}`
78
+ : state.status === "loading" ? "Checking sign-in status…" : state.authenticated ? "Signed in; ChatGPT plan usage is not authorized" : "Signed out";
41
79
  return h("section", { style: { display: "flex", flexDirection: "column", gap: 12, maxWidth: 640 } },
42
80
  h("h2", { style: { margin: 0 } }, "OpenAI Codex"),
43
- h("p", { style: { margin: 0, color: "var(--dsw-alias-label-secondary)" } }, "使用 ChatGPT Plus/Pro 的 Codex 订阅,无需 OpenAI API Key。"),
44
- h("div", { style: { display: "flex", alignItems: "center", justifyContent: "space-between", padding: 16, border: "1px solid var(--dsw-alias-border-l2)", borderRadius: 10 } },
81
+ h("p", { style: { margin: 0 } }, "Use your ChatGPT plan without an OpenAI API key. Models are provided by the active account."),
82
+ h("div", { style: { display: "flex", alignItems: "center", justifyContent: "space-between", gap: 12, padding: 16, border: "1px solid var(--dsw-alias-border-l2)", borderRadius: 10 } },
45
83
  h("span", null, label),
46
- state.status === "loading" ? null : h("button", { type: "button", disabled: busy || state.status === "signing-in", onClick: signedIn ? signOut : signIn }, signedIn ? "退出登录" : "使用 ChatGPT 登录")),
84
+ state.status === "loading" ? null : h("button", { type: "button", disabled: busy, onClick: signedIn || signingIn ? signOut : () => signIn() }, signedIn ? "Sign out" : signingIn ? "Cancel sign-in" : "Continue with ChatGPT")),
85
+ state.accounts?.length ? h("div", { style: { display: "flex", gap: 8 } },
86
+ h("select", { "aria-label": "ChatGPT account", disabled: busy || signingIn, value: state.accounts.find((account) => account.active)?.clientId ?? "", onChange: (event) => selectAccount(event.target.value) },
87
+ ...state.accounts.map((account) => h("option", { key: account.clientId, value: account.clientId }, `${account.label}${account.authenticated ? "" : " (signed out)"}`))),
88
+ h("button", { type: "button", disabled: busy || signingIn, onClick: () => signIn(true) }, "Add account")) : null,
89
+ state.authenticated && !state.planUsage && !signingIn ? h("button", { type: "button", disabled: busy, onClick: signOut }, "Sign out") : null,
90
+ signedIn && !signingIn ? h("div", { style: { display: "flex", alignItems: "center", gap: 12 } },
91
+ h("span", null, "Using ChatGPT plan"),
92
+ h("a", { href: "https://chatgpt.com/settings/usage", target: "_blank", rel: "noopener noreferrer" }, "Manage usage"),
93
+ h("button", { type: "button", disabled: busy, onClick: refreshModels }, "Refresh models")) : null,
94
+ signedIn ? h(QuotaPanel, { quota: state.quota }) : null,
47
95
  state.status === "error" ? h("p", { style: { color: "var(--dsw-alias-state-error-primary)" } }, state.message) : null,
96
+ notice ? h("p", { role: "status" }, notice) : null,
48
97
  );
49
98
  }
50
-
51
99
  export const name = "dsh-codex-oauth-client";
52
100
  export const inject = ["slots"];
53
101
  export function apply(ctx) {
package/lib/adapter.js CHANGED
@@ -1,44 +1,131 @@
1
- import { createModels } from "@earendil-works/pi-ai";
2
- import { openaiCodexProvider } from "@earendil-works/pi-ai/providers/openai-codex";
3
- import { PiAiAdapter } from "@deepseek-ai/dsh-llm-pi-ai";
4
- import { resolveRetryPolicy } from "@deepseek-ai/dsh-llm";
5
- import { OPENAI_CODEX_PROVIDER } from "./store.js";
1
+ import { LlmAdapter, LlmError, resolveImageAttachmentAccess, resolveRetryPolicy } from "@deepseek-ai/dsh-llm";
2
+ import { getCredential } from "./auth.js";
3
+ import { CodexModelCatalog } from "./models.js";
4
+ import { OPENAI_CODEX_PROVIDER, authPath } from "./store.js";
5
+ import { openaiFetch, OPENAI_API, readJSON, responseError } from "./openai-http.js";
6
+ import { responsesRequest } from "./responses-request.js";
7
+ import { readSSE, responsesStream } from "./responses-stream.js";
8
+ import { QuotaTracker, quotaHeaders, quotaEvent } from "./quota.js";
9
+ import { CodexQuotaLookup } from "./quota-lookup.js";
6
10
 
7
11
  const STREAM_IDLE_TIMEOUT_MS = 300_000;
8
-
9
- function tokenProvider(provider) {
10
- return {
11
- ...provider,
12
- auth: {
13
- ...provider.auth,
14
- apiKey: {
15
- name: "OpenAI Codex OAuth bearer token",
16
- async resolve({ credential }) {
17
- const access = credential?.access ?? credential?.key;
18
- return access
19
- ? { auth: { apiKey: access }, source: "DSH Codex OAuth" }
20
- : undefined;
21
- },
22
- },
23
- },
24
- };
25
- }
26
-
27
- export function createCodexAdapter(credentials, resolveAttachments) {
28
- const provider = openaiCodexProvider();
29
- const profiles = new Map([[OPENAI_CODEX_PROVIDER, {
30
- provider: OPENAI_CODEX_PROVIDER,
31
- displayName: "OpenAI Codex",
32
- streamIdleTimeoutMs: STREAM_IDLE_TIMEOUT_MS,
33
- retryPolicy: resolveRetryPolicy(undefined, "dsh-codex-oauth retryPolicy"),
34
- configuredMaxTokens: new Map(),
35
- piProvider: tokenProvider(provider),
36
- }]]);
37
- const models = createModels({ credentials });
38
- models.setProvider(provider);
39
- return new PiAiAdapter({
40
- profiles: () => profiles,
41
- resolveApiKey: async () => (await models.getAuth(OPENAI_CODEX_PROVIDER))?.auth.apiKey,
42
- resolveAttachments,
43
- });
12
+ export class CodexAdapter extends LlmAdapter {
13
+ #active = new Set();
14
+ #requestSequence = 0;
15
+ constructor(credentials, resolveAttachments, options = {}) {
16
+ super();
17
+ this.credentials = credentials;
18
+ this.resolveAttachments = resolveAttachments;
19
+ this.options = options;
20
+ this.quotas = new QuotaTracker();
21
+ this.quotaLookup = new CodexQuotaLookup(credentials.filename ?? authPath(), { fetch: options.fetch, now: options.now });
22
+ this.catalog = new CodexModelCatalog(credentials, options);
23
+ this.retryPolicy = resolveRetryPolicy(undefined, "dsh-codex-oauth retryPolicy");
24
+ this.unsubscribe = credentials.subscribe((state) => {
25
+ this.quotas.accountChanged(state);
26
+ this.quotaLookup.reset();
27
+ this.catalog.invalidate();
28
+ for (const request of this.#active) if (!state.authenticated || request.clientId !== state.clientId) request.controller.abort(new LlmError("ChatGPT account changed or signed out", "ABORTED"));
29
+ options.onModelsChanged?.();
30
+ });
31
+ }
32
+ providerInfo(provider) { this.assertProvider(provider); return { id: provider, name: "OpenAI Codex (ChatGPT plan)" }; }
33
+ providerRetryPolicy(provider) { this.assertProvider(provider); return this.retryPolicy; }
34
+ assertProvider(provider) { if (provider !== OPENAI_CODEX_PROVIDER) throw new LlmError("Codex adapter does not own this provider", "NO_ADAPTER"); }
35
+ async listModels(provider, options) { this.assertProvider(provider); return this.catalog.list(options); }
36
+ async resolveModel(provider, model, signal) { this.assertProvider(provider); return this.catalog.resolve(model, signal); }
37
+ async quotaSnapshot() {
38
+ const document = await this.credentials.readDocument();
39
+ const account = document?.registrations.find((entry) => entry.clientId === document.activeClientId);
40
+ if (!account?.access) { this.quotas.clear(); this.quotaLookup.reset(); return this.quotas.snapshot(); }
41
+ const binding = { clientId: document.activeClientId, generation: document.generation ?? 0 };
42
+ this.quotas.activate(binding);
43
+ const passive = this.quotas.snapshot();
44
+ if (passive.available && passive.buckets.every((bucket) => bucket.windows.every((window) => !window.stale))) return passive;
45
+ const queried = await this.quotaLookup.snapshot(account, binding);
46
+ const current = await this.credentials.readDocument();
47
+ if (current?.activeClientId !== binding.clientId || (current?.generation ?? 0) !== binding.generation || !current?.registrations.find((entry) => entry.clientId === binding.clientId)?.access) {
48
+ this.quotaLookup.reset(); return { available: false, buckets: [] };
49
+ }
50
+ return queried.available ? queried : this.quotas.snapshot();
51
+ }
52
+ async prepareCall(provider, model, signal) {
53
+ const initial = await this.credentials.readDocument();
54
+ const info = await this.resolveModel(provider, model, signal);
55
+ const document = await this.credentials.readDocument();
56
+ if (document?.activeClientId !== initial?.activeClientId || (document?.generation ?? 0) !== (initial?.generation ?? 0)) throw new LlmError("The selected ChatGPT account changed during call preparation", "AUTH");
57
+ const binding = { clientId: document?.activeClientId, generation: document?.generation ?? 0, model, info };
58
+ return { model: info, stream: (options) => this.streamPrepared(options, binding) };
59
+ }
60
+ async *stream(options) {
61
+ const prepared = await this.prepareCall(options.provider, options.model, options.signal);
62
+ yield* prepared.stream(options);
63
+ }
64
+ async *streamPrepared(options, binding) {
65
+ this.assertProvider(options.provider);
66
+ if (options.model !== binding.model) throw new LlmError("Prepared Codex model does not match the request", "INVALID_REQUEST");
67
+ const controller = new AbortController();
68
+ const signal = AbortSignal.any([...(options.signal ? [options.signal] : []), controller.signal]);
69
+ let watchdog;
70
+ const activity = () => {
71
+ clearTimeout(watchdog);
72
+ watchdog = setTimeout(() => controller.abort(new LlmError("OpenAI stream idle timeout", "TIMEOUT")), this.options.streamIdleTimeoutMs ?? STREAM_IDLE_TIMEOUT_MS);
73
+ watchdog.unref?.();
74
+ };
75
+ const request = { controller, clientId: binding.clientId };
76
+ this.#active.add(request);
77
+ try {
78
+ const document = await this.credentials.readDocument();
79
+ if (document?.activeClientId !== binding.clientId || (document?.generation ?? 0) !== binding.generation) throw new LlmError("The selected ChatGPT account changed after call preparation", "AUTH");
80
+ const credential = await getCredential(this.credentials, { ...this.options, clientId: binding.clientId, signal });
81
+ const attachments = this.resolveAttachments?.();
82
+ const resolveAccess = (ref, hostPath) => {
83
+ if (!hostPath) return attachments?.imageHostPath ? resolveImageAttachmentAccess(attachments, (path) => this.options.mapHostPath?.(path), ref) : undefined;
84
+ const readonlyPath = this.options.mapHostPath?.(hostPath);
85
+ return readonlyPath ? { readonlyPath } : undefined;
86
+ };
87
+ const { body, names } = await responsesRequest({ ...options, signal }, binding, attachments, resolveAccess);
88
+ signal.throwIfAborted();
89
+ const current = await this.credentials.readDocument();
90
+ if (current?.activeClientId !== binding.clientId || (current?.generation ?? 0) !== binding.generation) throw new LlmError("The selected ChatGPT account changed during request preparation", "AUTH");
91
+ // The ChatGPT plan gateway needs stable session headers to reuse its
92
+ // prompt cache, including the summarizer's calls within the same session.
93
+ const sessionHeaders = options.sessionId === undefined ? {} : {
94
+ "session-id": String(options.sessionId), "x-client-request-id": String(options.sessionId),
95
+ };
96
+ this.quotas.activate(binding);
97
+ const sequence = ++this.#requestSequence;
98
+ activity();
99
+ const response = await openaiFetch(`${OPENAI_API}/responses`, {
100
+ method: "POST", signal, headers: { authorization: `Bearer ${credential.access}`, "content-type": "application/json", accept: "text/event-stream", ...sessionHeaders }, body: JSON.stringify(body),
101
+ }, this.options.fetch);
102
+ this.quotas.update(binding, quotaHeaders(response.headers), sequence);
103
+ if (!response.ok) throw responseError(await readJSON(response), response.status, response.headers, [credential.access]);
104
+ // The ChatGPT plan route can omit Content-Type on a valid SSE response.
105
+ const contentType = response.headers.get("content-type");
106
+ if (contentType && !contentType.startsWith("text/event-stream")) throw new LlmError("OpenAI did not return an event stream", "INVALID_RESPONSE");
107
+ const quotas = this.quotas;
108
+ async function* observedEvents() {
109
+ for await (const event of readSSE(response.body, signal, activity)) {
110
+ quotas.update(binding, quotaEvent(event), sequence);
111
+ yield event;
112
+ }
113
+ }
114
+ yield* responsesStream(observedEvents(), binding, names, response.headers);
115
+ } catch (error) {
116
+ if (signal.aborted) throw signal.reason;
117
+ throw error;
118
+ } finally {
119
+ clearTimeout(watchdog);
120
+ this.#active.delete(request);
121
+ controller.abort(new LlmError("OpenAI request finished", "ABORTED"));
122
+ }
123
+ }
124
+ dispose() {
125
+ this.quotaLookup.dispose();
126
+ this.unsubscribe();
127
+ for (const request of this.#active) request.controller.abort(new LlmError("Codex adapter disposed", "ABORTED"));
128
+ this.#active.clear();
129
+ }
44
130
  }
131
+ export function createCodexAdapter(credentials, resolveAttachments, options) { return new CodexAdapter(credentials, resolveAttachments, options); }
package/lib/auth.js CHANGED
@@ -1,21 +1,240 @@
1
- import { createModels } from "@earendil-works/pi-ai";
2
- import { openaiCodexProvider } from "@earendil-works/pi-ai/providers/openai-codex";
3
- import { CodexCredentialStore, OPENAI_CODEX_PROVIDER, authPath } from "./store.js";
1
+ import { createHash, createPublicKey, randomBytes, timingSafeEqual, verify } from "node:crypto";
2
+ import { createServer } from "node:http";
3
+ import { LlmError } from "@deepseek-ai/dsh-llm";
4
+ import { activeRegistration, clearTokens, CodexCredentialStore, OPENAI_CODEX_PROVIDER, authPath } from "./store.js";
5
+ import { fetchJSON, openaiFetch, OPENAI_API, OPENAI_ISSUER, responseError, readJSON } from "./openai-http.js";
4
6
 
5
- function models(store) {
6
- const value = createModels({ credentials: store });
7
- value.setProvider(openaiCodexProvider());
7
+ const SCOPES = "openid profile email offline_access resource.invoke chatgpt.tokens.use.direct";
8
+ const TERMINAL_REFRESH_ERRORS = new Set(["invalid_grant", "invalid_refresh_token", "token_expired", "refresh_token_expired", "refresh_token_invalidated", "refresh_token_reused"]);
9
+ const random = () => randomBytes(32).toString("base64url");
10
+ const same = (left, right) => typeof left === "string" && typeof right === "string" && Buffer.byteLength(left) === Buffer.byteLength(right)
11
+ && timingSafeEqual(Buffer.from(left), Buffer.from(right));
12
+ const nowOf = (options) => options.now?.() ?? Date.now();
13
+
14
+ async function metadata(options) {
15
+ const value = await fetchJSON(`${OPENAI_ISSUER}/.well-known/openid-configuration`, { signal: options.signal }, options.fetch);
16
+ if (value.issuer !== OPENAI_ISSUER) throw new Error("OpenAI discovery returned an unexpected issuer");
17
+ for (const field of ["jwks_uri", "revocation_endpoint"]) {
18
+ const url = new URL(value[field]);
19
+ if (url.origin !== OPENAI_ISSUER) throw new Error("OpenAI discovery returned an unexpected endpoint");
20
+ }
8
21
  return value;
9
22
  }
10
23
 
11
- export async function login(store = new CodexCredentialStore(), interaction) {
12
- await models(store).login(OPENAI_CODEX_PROVIDER, "oauth", interaction);
24
+ async function validateIdentity(token, clientId, options, nonce, subject) {
25
+ if (typeof token !== "string") throw new Error("OpenAI did not return an ID token");
26
+ const parts = token.split(".");
27
+ if (parts.length !== 3) throw new Error("OpenAI returned an invalid ID token");
28
+ let header, claims;
29
+ try {
30
+ header = JSON.parse(Buffer.from(parts[0], "base64url").toString());
31
+ claims = JSON.parse(Buffer.from(parts[1], "base64url").toString());
32
+ } catch { throw new Error("OpenAI returned an invalid ID token"); }
33
+ if (header.alg !== "RS256" || typeof header.kid !== "string" || header.crit !== undefined) throw new Error("Unsupported OpenAI ID-token signature");
34
+ const discovery = await metadata(options);
35
+ const jwks = await fetchJSON(discovery.jwks_uri, { signal: options.signal }, options.fetch);
36
+ const key = jwks.keys?.find((key) => key.kid === header.kid && key.kty === "RSA" && (!key.use || key.use === "sig") && (!key.alg || key.alg === "RS256"));
37
+ if (!key || !verify("RSA-SHA256", Buffer.from(`${parts[0]}.${parts[1]}`), createPublicKey({ key, format: "jwk" }), Buffer.from(parts[2], "base64url"))) {
38
+ throw new Error("OpenAI ID-token signature validation failed");
39
+ }
40
+ const audiences = Array.isArray(claims.aud) ? claims.aud : [claims.aud];
41
+ const now = nowOf(options) / 1000;
42
+ if (claims.iss !== OPENAI_ISSUER || !audiences.includes(clientId) || (audiences.length > 1 && claims.azp !== clientId) || (claims.azp !== undefined && claims.azp !== clientId)
43
+ || !Number.isFinite(claims.exp) || claims.exp <= now || (claims.nbf !== undefined && (!Number.isFinite(claims.nbf) || claims.nbf > now + 30))
44
+ || typeof claims.sub !== "string" || !claims.sub || (nonce !== undefined && !same(claims.nonce, nonce))
45
+ || (subject !== undefined && claims.sub !== subject)) throw new Error("OpenAI ID-token identity validation failed");
46
+ return { subject: claims.sub, issuer: claims.iss, ...(typeof claims.email === "string" ? { email: claims.email } : {}) };
47
+ }
48
+
49
+ function tokenFields(token, options, previous) {
50
+ if (typeof token.access_token !== "string" || !token.access_token || !Number.isFinite(token.expires_in) || token.expires_in <= 0
51
+ || String(token.token_type).toLowerCase() !== "bearer") throw new Error("OpenAI returned an invalid token response");
52
+ if (token.refresh_token !== undefined && (typeof token.refresh_token !== "string" || !token.refresh_token)) throw new Error("OpenAI returned an invalid refresh token");
53
+ if (token.scope !== undefined && typeof token.scope !== "string") throw new Error("OpenAI returned invalid granted scopes");
54
+ const scopes = token.scope === undefined ? previous?.scopes ?? [] : token.scope.split(/\s+/).filter(Boolean);
55
+ if (scopes.includes("offline_access") && !token.refresh_token) throw new Error("OpenAI did not return a rotating refresh token");
56
+ const earliest = typeof token.earliest_refresh_at === "number"
57
+ ? token.earliest_refresh_at * (token.earliest_refresh_at < 1e12 ? 1000 : 1)
58
+ : Date.parse(token.earliest_refresh_at);
59
+ return {
60
+ access: token.access_token,
61
+ ...(token.refresh_token ? { refresh: token.refresh_token } : {}),
62
+ expires: nowOf(options) + token.expires_in * 1000,
63
+ scopes,
64
+ ...(Number.isFinite(earliest) ? { earliestRefreshAt: earliest } : {}),
65
+ };
66
+ }
67
+
68
+ async function callbackListener(state, requestedClientId, signal, port) {
69
+ let resolveCallback, rejectCallback;
70
+ const callback = new Promise((resolve, reject) => { resolveCallback = resolve; rejectCallback = reject; });
71
+ // Attach a handler immediately: cancellation can precede the caller awaiting the callback.
72
+ callback.catch(() => {});
73
+ const server = createServer((req, res) => {
74
+ const reply = (status, text) => { res.writeHead(status, { "content-type": "text/plain; charset=utf-8", "cache-control": "no-store" }); res.end(text); };
75
+ let url;
76
+ try { url = new URL(req.url, "http://127.0.0.1"); }
77
+ catch { return reply(400, "Invalid callback URL"); }
78
+ if (req.method !== "GET" || url.pathname !== "/auth/callback") return reply(404, "Not found");
79
+ if (!same(url.searchParams.get("state"), state)) return reply(400, "Invalid OAuth state");
80
+ if (url.searchParams.has("error")) {
81
+ reply(400, "ChatGPT authorization was not completed. Return to DSH.");
82
+ rejectCallback(new Error("ChatGPT authorization was declined or could not be completed"));
83
+ return;
84
+ }
85
+ const clientId = url.searchParams.get("client_id") ?? requestedClientId;
86
+ if (!clientId || clientId === "dynamic_agent_client" || (requestedClientId && clientId !== requestedClientId) || !url.searchParams.get("code")) {
87
+ reply(400, "Invalid OAuth registration result");
88
+ rejectCallback(new Error("OpenAI returned an incomplete or mismatched registration"));
89
+ return;
90
+ }
91
+ reply(200, "Authorization received. Return to DSH to check that sign-in completed.");
92
+ resolveCallback({ clientId, code: url.searchParams.get("code") });
93
+ });
94
+ server.requestTimeout = 10_000;
95
+ server.headersTimeout = 10_000;
96
+ const abort = () => { rejectCallback(signal.reason); server.close(); server.closeAllConnections(); };
97
+ signal.addEventListener("abort", abort, { once: true });
98
+ try {
99
+ const listen = (listenPort) => new Promise((resolve, reject) => {
100
+ const error = (error) => { server.off("listening", ready); reject(error); };
101
+ const ready = () => { server.off("error", error); resolve(); };
102
+ server.once("error", error); server.once("listening", ready); server.listen(listenPort, "127.0.0.1");
103
+ });
104
+ signal.throwIfAborted();
105
+ try { await listen(port ?? 1455); }
106
+ catch (error) { if (error.code !== "EADDRINUSE" || port !== undefined) throw error; await listen(0); }
107
+ signal.throwIfAborted();
108
+ return {
109
+ redirectUri: `http://127.0.0.1:${server.address().port}/auth/callback`,
110
+ callback,
111
+ close() { signal.removeEventListener("abort", abort); server.close(); server.closeAllConnections(); },
112
+ };
113
+ } catch (error) { signal.removeEventListener("abort", abort); server.close(); throw error; }
114
+ }
115
+
116
+ export async function login(store = new CodexCredentialStore(), interaction = {}, options = {}) {
117
+ const signal = AbortSignal.any([...(interaction.signal ? [interaction.signal] : []), ...(options.signal ? [options.signal] : []), AbortSignal.timeout(10 * 60_000)]);
118
+ options = { ...options, signal };
119
+ const document = await store.initialize();
120
+ const selected = options.newAccount ? undefined : activeRegistration(document);
121
+ const state = random(), nonce = random(), verifier = random();
122
+ const listener = await callbackListener(state, selected?.clientId, signal, options.callbackPort);
123
+ try {
124
+ const url = new URL(`${OPENAI_ISSUER}/api/accounts/authorize`);
125
+ url.search = new URLSearchParams({
126
+ client_id: selected?.clientId ?? "dynamic_agent_client",
127
+ ext_agent_host_id: document.hostId,
128
+ response_type: "code", redirect_uri: listener.redirectUri, scope: SCOPES, resource: OPENAI_API,
129
+ state, nonce, code_challenge_method: "S256", code_challenge: createHash("sha256").update(verifier).digest("base64url"),
130
+ ...(!selected ? { agent_name_hint: "dsh-codex-oauth" } : {}),
131
+ ...(selected?.idToken ? { id_token_hint: selected.idToken } : {}),
132
+ ...(selected?.email ? { login_hint: selected.email } : {}),
133
+ ...(selected?.access && !selected.scopes?.includes("chatgpt.tokens.use.direct") ? { prompt: "consent" } : {}),
134
+ }).toString();
135
+ if (!interaction.notify) throw new Error("OAuth login requires an authorization-URL handler");
136
+ interaction.notify({ type: "auth_url", url: url.href });
137
+ const { clientId, code } = await listener.callback;
138
+ const tokens = await fetchJSON(`${OPENAI_ISSUER}/api/accounts/oauth/token`, {
139
+ method: "POST", signal,
140
+ body: new URLSearchParams({ grant_type: "authorization_code", client_id: clientId, code, code_verifier: verifier, redirect_uri: listener.redirectUri, resource: OPENAI_API }),
141
+ }, options.fetch);
142
+ const identity = await validateIdentity(tokens.id_token, clientId, options, nonce, selected?.subject);
143
+ const registration = { ...identity, clientId, idToken: tokens.id_token, ...tokenFields(tokens, options) };
144
+ await store.transaction((current) => {
145
+ signal.throwIfAborted();
146
+ if ((current.generation ?? 0) !== (document.generation ?? 0)) throw new Error("The selected account changed during sign-in; please retry");
147
+ const existing = current.registrations.find((account) => account.clientId === clientId);
148
+ if (existing && existing.subject !== identity.subject) throw new Error("OAuth registration identity changed");
149
+ if (existing) { clearTokens(existing); Object.assign(existing, registration); }
150
+ else current.registrations.push(registration);
151
+ current.activeClientId = clientId;
152
+ current.generation = (current.generation ?? 0) + 1;
153
+ });
154
+ return status(store);
155
+ } finally { listener.close(); }
156
+ }
157
+
158
+ export async function getCredential(store, options = {}) {
159
+ const initial = await store.read(OPENAI_CODEX_PROVIDER);
160
+ if (!initial || (options.clientId && initial.clientId !== options.clientId)) throw new LlmError("Sign in to the selected ChatGPT account with dsh-openai-codex login", "AUTH");
161
+ if (!initial.scopes.includes("chatgpt.tokens.use.direct")) throw new LlmError("ChatGPT plan usage was not authorized; sign in again to enable it", "AUTH");
162
+ const now = nowOf(options);
163
+ if (initial.expires > now + 60_000 || (initial.expires > now && initial.earliestRefreshAt > now)) return initial;
164
+ let terminalError;
165
+ const credential = await store.transaction(async (document) => {
166
+ const current = activeRegistration(document);
167
+ if (!current?.access || current.clientId !== initial.clientId) throw new LlmError("The selected ChatGPT account changed", "AUTH");
168
+ const now = nowOf(options);
169
+ if (current.expires > now + 60_000 || (current.expires > now && current.earliestRefreshAt > now)) return current;
170
+ if (!current.refresh) throw new LlmError("ChatGPT sign-in has expired; sign in again", "AUTH");
171
+ try {
172
+ const tokens = await fetchJSON(`${OPENAI_ISSUER}/api/accounts/oauth/token`, {
173
+ method: "POST", signal: options.signal,
174
+ body: new URLSearchParams({ grant_type: "refresh_token", client_id: current.clientId, refresh_token: current.refresh, resource: OPENAI_API }),
175
+ }, options.fetch);
176
+ if (tokens.id_token) await validateIdentity(tokens.id_token, current.clientId, options, undefined, current.subject);
177
+ const fields = tokenFields(tokens, options, current);
178
+ delete current.earliestRefreshAt;
179
+ Object.assign(current, fields, ...(tokens.id_token ? [{ idToken: tokens.id_token }] : []));
180
+ return current;
181
+ } catch (error) {
182
+ if (!TERMINAL_REFRESH_ERRORS.has(error.providerCode)) throw error;
183
+ clearTokens(current);
184
+ document.generation = (document.generation ?? 0) + 1;
185
+ terminalError = new LlmError("ChatGPT sign-in is no longer valid; sign in again", "AUTH");
186
+ return undefined;
187
+ }
188
+ });
189
+ if (terminalError) throw terminalError;
190
+ if (!credential?.scopes.includes("chatgpt.tokens.use.direct")) throw new LlmError("ChatGPT plan usage is no longer authorized", "AUTH");
191
+ return credential;
13
192
  }
14
- export async function logout(store = new CodexCredentialStore()) {
15
- await store.delete(OPENAI_CODEX_PROVIDER);
193
+
194
+ export async function logout(store = new CodexCredentialStore(), options = {}) {
195
+ let remoteRevoked = true;
196
+ await store.transaction(async (document) => {
197
+ const registration = activeRegistration(document);
198
+ if (!registration?.access) return;
199
+ if (registration.refresh) {
200
+ remoteRevoked = false;
201
+ for (let attempt = 0; attempt < 2; attempt++) {
202
+ try {
203
+ const discovery = await metadata(options);
204
+ const response = await openaiFetch(discovery.revocation_endpoint, {
205
+ method: "POST", signal: AbortSignal.any([...(options.signal ? [options.signal] : []), AbortSignal.timeout(15_000)]),
206
+ body: new URLSearchParams({ token: registration.refresh, token_type_hint: "refresh_token", client_id: registration.clientId }),
207
+ }, options.fetch);
208
+ if (!response.ok) throw responseError(await readJSON(response), response.status, response.headers, [registration.refresh]);
209
+ remoteRevoked = true;
210
+ break;
211
+ } catch (error) {
212
+ if (attempt || (error.failure?.status && error.failure.status < 500)) break;
213
+ await new Promise((resolve) => setTimeout(resolve, 200));
214
+ }
215
+ }
216
+ }
217
+ clearTokens(registration);
218
+ document.generation = (document.generation ?? 0) + 1;
219
+ });
220
+ return { remoteRevoked };
221
+ }
222
+
223
+ export async function accounts(store = new CodexCredentialStore()) {
224
+ const document = await store.readDocument();
225
+ return document?.registrations.map((registration) => ({
226
+ clientId: registration.clientId,
227
+ label: registration.email ? `${registration.email} (${registration.clientId.slice(-8)})` : `ChatGPT (${registration.clientId.slice(-8)})`,
228
+ active: registration.clientId === document.activeClientId,
229
+ authenticated: !!registration.access,
230
+ })) ?? [];
16
231
  }
17
232
  export async function status(store = new CodexCredentialStore()) {
18
233
  const credential = await store.read(OPENAI_CODEX_PROVIDER);
19
- return credential?.type === "oauth" ? { authenticated: true, expiresAt: new Date(credential.expires) } : { authenticated: false };
234
+ return credential ? {
235
+ authenticated: true, planUsage: credential.scopes.includes("chatgpt.tokens.use.direct"),
236
+ expiresAt: new Date(credential.expires), clientId: credential.clientId,
237
+ label: (await accounts(store)).find((account) => account.active)?.label,
238
+ } : { authenticated: false };
20
239
  }
21
240
  export { authPath };