@moxxy/plugin-provider-openai-codex 0.21.1

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 (67) hide show
  1. package/LICENSE +21 -0
  2. package/dist/codex/headers.d.ts +5 -0
  3. package/dist/codex/headers.d.ts.map +1 -0
  4. package/dist/codex/headers.js +39 -0
  5. package/dist/codex/headers.js.map +1 -0
  6. package/dist/codex/sse-event-handler.d.ts +16 -0
  7. package/dist/codex/sse-event-handler.d.ts.map +1 -0
  8. package/dist/codex/sse-event-handler.js +155 -0
  9. package/dist/codex/sse-event-handler.js.map +1 -0
  10. package/dist/codex/stream-consumer.d.ts +4 -0
  11. package/dist/codex/stream-consumer.d.ts.map +1 -0
  12. package/dist/codex/stream-consumer.js +176 -0
  13. package/dist/codex/stream-consumer.js.map +1 -0
  14. package/dist/codex/stream-types.d.ts +61 -0
  15. package/dist/codex/stream-types.d.ts.map +1 -0
  16. package/dist/codex/stream-types.js +19 -0
  17. package/dist/codex/stream-types.js.map +1 -0
  18. package/dist/index.d.ts +11 -0
  19. package/dist/index.d.ts.map +1 -0
  20. package/dist/index.js +31 -0
  21. package/dist/index.js.map +1 -0
  22. package/dist/login.d.ts +33 -0
  23. package/dist/login.d.ts.map +1 -0
  24. package/dist/login.js +94 -0
  25. package/dist/login.js.map +1 -0
  26. package/dist/models.d.ts +10 -0
  27. package/dist/models.d.ts.map +1 -0
  28. package/dist/models.js +19 -0
  29. package/dist/models.js.map +1 -0
  30. package/dist/oauth.d.ts +74 -0
  31. package/dist/oauth.d.ts.map +1 -0
  32. package/dist/oauth.js +170 -0
  33. package/dist/oauth.js.map +1 -0
  34. package/dist/profile.d.ts +13 -0
  35. package/dist/profile.d.ts.map +1 -0
  36. package/dist/profile.js +39 -0
  37. package/dist/profile.js.map +1 -0
  38. package/dist/provider.d.ts +79 -0
  39. package/dist/provider.d.ts.map +1 -0
  40. package/dist/provider.js +306 -0
  41. package/dist/provider.js.map +1 -0
  42. package/dist/translate.d.ts +77 -0
  43. package/dist/translate.d.ts.map +1 -0
  44. package/dist/translate.js +172 -0
  45. package/dist/translate.js.map +1 -0
  46. package/dist/types.d.ts +27 -0
  47. package/dist/types.d.ts.map +1 -0
  48. package/dist/types.js +2 -0
  49. package/dist/types.js.map +1 -0
  50. package/package.json +66 -0
  51. package/src/codex/headers.ts +41 -0
  52. package/src/codex/sse-event-handler.test.ts +85 -0
  53. package/src/codex/sse-event-handler.ts +173 -0
  54. package/src/codex/stream-consumer.test.ts +280 -0
  55. package/src/codex/stream-consumer.ts +181 -0
  56. package/src/codex/stream-types.ts +61 -0
  57. package/src/index.ts +65 -0
  58. package/src/login.ts +121 -0
  59. package/src/models.ts +21 -0
  60. package/src/oauth.test.ts +223 -0
  61. package/src/oauth.ts +200 -0
  62. package/src/profile.ts +52 -0
  63. package/src/provider.test.ts +507 -0
  64. package/src/provider.ts +360 -0
  65. package/src/translate.test.ts +95 -0
  66. package/src/translate.ts +224 -0
  67. package/src/types.ts +28 -0
@@ -0,0 +1,181 @@
1
+ import { toFriendlyError, type ProviderEvent, type StopReason } from '@moxxy/sdk';
2
+ import { handleSseEvent } from './sse-event-handler.js';
3
+ import { CODEX_RESPONSES_URL } from '../oauth.js';
4
+ import { parseToolArgs, type PendingFunctionCall, type ResponsesSseEvent } from './stream-types.js';
5
+
6
+ export function toErrorEvent(err: unknown): ProviderEvent {
7
+ return {
8
+ type: 'error',
9
+ ...toFriendlyError(err, { provider: 'openai-codex', url: CODEX_RESPONSES_URL }),
10
+ };
11
+ }
12
+
13
+ // Hard cap on the unparsed reassembly buffer. A misbehaving/MITM'd endpoint (or
14
+ // a wedged proxy emitting a continuous body with no blank-line frame separator)
15
+ // would otherwise grow `buffer` without bound until OOM, since it's only ever
16
+ // truncated when a separator is found. 8 MiB is far larger than any legitimate
17
+ // single SSE frame; exceeding it with no separator is treated as a hostile stream.
18
+ const MAX_SSE_BUFFER_BYTES = 8 * 1024 * 1024;
19
+
20
+ export async function* consumeResponsesSse(
21
+ body: ReadableStream<Uint8Array>,
22
+ signal: AbortSignal | undefined,
23
+ emitReasoning = false,
24
+ // Called on every successful body read so the caller can reset an idle
25
+ // watchdog (a stalled stream that stops sending bytes must not hang forever).
26
+ onActivity?: () => void,
27
+ ): AsyncIterable<ProviderEvent> {
28
+ const reader = body.getReader();
29
+ const decoder = new TextDecoder('utf-8');
30
+ let buffer = '';
31
+ const pending = new Map<string, PendingFunctionCall>();
32
+ let stopReason: StopReason = 'end_turn';
33
+ let usageIn = 0;
34
+ let usageOut = 0;
35
+ let usageCacheRead = 0;
36
+ // Tracks whether ANY tool_use_end was yielded during the stream.
37
+ // The Responses API's `response.completed` event doesn't differentiate
38
+ // text-only vs tool-use turns, so without this we'd report end_turn
39
+ // even when tools were requested — the upstream tool-use loop would
40
+ // then drop the calls without executing them.
41
+ let sawToolCall = false;
42
+ // Set when an error frame (response.failed/error) surfaced the failure, so
43
+ // we don't also flush tool calls / emit a normal message_end on top of it.
44
+ let errored = false;
45
+
46
+ try {
47
+ try {
48
+ outer: while (true) {
49
+ if (signal?.aborted) {
50
+ yield { type: 'error', message: 'aborted', retryable: false };
51
+ return;
52
+ }
53
+ const { done, value } = await reader.read();
54
+ if (done) break;
55
+ onActivity?.();
56
+ buffer += decoder.decode(value, { stream: true });
57
+
58
+ // Bound the unparsed buffer: if it grows past the cap without yielding a
59
+ // single complete frame, the peer is feeding us an unframed/oversized
60
+ // body. Bail rather than accumulate to OOM. (The `finally` cancels the
61
+ // reader so the socket is released.)
62
+ if (buffer.length > MAX_SSE_BUFFER_BYTES) {
63
+ yield {
64
+ type: 'error',
65
+ message: 'Codex stream frame exceeded size limit',
66
+ retryable: false,
67
+ };
68
+ return;
69
+ }
70
+
71
+ // SSE frames are separated by blank lines. Some servers emit \r\n\r\n;
72
+ // match either form at the separator and line boundaries rather than
73
+ // rescanning the whole accumulated buffer with a global CRLF replace on
74
+ // every chunk. A `\r` split across two reads is handled because the
75
+ // boundary regexes tolerate the optional `\r`.
76
+ let m: RegExpExecArray | null;
77
+ const sepRe = /\r?\n\r?\n/g;
78
+ while ((m = sepRe.exec(buffer)) !== null) {
79
+ const frame = buffer.slice(0, m.index);
80
+ buffer = buffer.slice(m.index + m[0].length);
81
+ sepRe.lastIndex = 0;
82
+ for (const line of frame.split(/\r?\n/)) {
83
+ if (!line.startsWith('data:')) continue;
84
+ const payload = line.slice(5).trimStart();
85
+ if (!payload || payload === '[DONE]') continue;
86
+
87
+ let json: ResponsesSseEvent;
88
+ try {
89
+ json = JSON.parse(payload) as ResponsesSseEvent;
90
+ } catch {
91
+ continue;
92
+ }
93
+ const result = handleSseEvent(json, pending, emitReasoning);
94
+ if (result.events) {
95
+ for (const ev of result.events) {
96
+ if (ev.type === 'tool_use_end') sawToolCall = true;
97
+ if (ev.type === 'error') errored = true;
98
+ yield ev;
99
+ }
100
+ }
101
+ if (result.stopReason) stopReason = result.stopReason;
102
+ if (result.usage) {
103
+ usageIn = result.usage.input ?? usageIn;
104
+ usageOut = result.usage.output ?? usageOut;
105
+ usageCacheRead = result.usage.cacheRead ?? usageCacheRead;
106
+ }
107
+ // Terminal frame (response.completed / failed / error): stop
108
+ // consuming. Honoring this prevents a failed turn from emitting
109
+ // both an `error` AND a trailing `message_end`, and ignores any
110
+ // stray frames after completion.
111
+ if (result.terminal) break outer;
112
+ }
113
+ }
114
+ }
115
+ } catch (err) {
116
+ yield toErrorEvent(err);
117
+ return;
118
+ }
119
+
120
+ // The error frame already surfaced the failure; nothing more to emit. Drop
121
+ // any pending function calls so a later code path can't flush a phantom
122
+ // tool_use on top of a failed turn — the error supersedes them.
123
+ if (errored) {
124
+ pending.clear();
125
+ return;
126
+ }
127
+
128
+ // Flush any tool_call_end events that didn't have a matching .done frame
129
+ // (defensive — the server normally sends function_call.done, but a
130
+ // truncated stream shouldn't drop the entire tool-use sequence).
131
+ for (const entry of pending.values()) {
132
+ const outId = entry.callId || entry.id;
133
+ // If we have a name but never emitted the start (server sent args.delta
134
+ // for an item whose .added carried no name, then truncated before .done),
135
+ // emit the start first — mirrors the .done branch — so the call isn't
136
+ // dropped just because its start frame never landed.
137
+ if (!entry.emittedStart && entry.name) {
138
+ entry.emittedStart = true;
139
+ yield { type: 'tool_use_start', id: outId, name: entry.name };
140
+ }
141
+ if (entry.emittedStart) {
142
+ sawToolCall = true;
143
+ yield { type: 'tool_use_end', id: outId, input: parseToolArgs(entry.args) };
144
+ } else if (process.env.MOXXY_DEBUG) {
145
+ // A pending function call that never carried a name and never started:
146
+ // we cannot synthesize a valid tool_use, so it is dropped — but make
147
+ // it observable rather than silently swallowed.
148
+ console.error(
149
+ `[openai-codex] dropping a truncated function_call with no name (id=${outId}, args=${entry.args.length}B)`,
150
+ );
151
+ }
152
+ }
153
+
154
+ // If we yielded any tool_use_end this stream, the turn IS a tool-use
155
+ // turn regardless of what `response.completed` said. The Responses API
156
+ // sends `completed` with no stop_reason field, so we infer from the
157
+ // events we actually emitted. Without this upgrade, codex turns with
158
+ // tool calls were reported as end_turn and the loop dropped them.
159
+ if (stopReason === 'end_turn' && sawToolCall) {
160
+ stopReason = 'tool_use';
161
+ }
162
+
163
+ yield {
164
+ type: 'message_end',
165
+ stopReason,
166
+ ...(usageIn > 0 || usageOut > 0
167
+ ? {
168
+ usage: {
169
+ inputTokens: usageIn,
170
+ outputTokens: usageOut,
171
+ ...(usageCacheRead > 0 ? { cacheReadTokens: usageCacheRead } : {}),
172
+ },
173
+ }
174
+ : {}),
175
+ };
176
+ } finally {
177
+ // Always release the HTTP body — on normal completion, error, abort, or
178
+ // the consumer abandoning the stream early — so the socket isn't leaked.
179
+ reader.cancel().catch(() => {});
180
+ }
181
+ }
@@ -0,0 +1,61 @@
1
+ import type { ProviderEvent, StopReason } from '@moxxy/sdk';
2
+
3
+ export interface PendingFunctionCall {
4
+ id: string;
5
+ callId: string;
6
+ name: string;
7
+ args: string;
8
+ emittedStart: boolean;
9
+ }
10
+
11
+ export interface ResponsesSseEvent {
12
+ type?: string;
13
+ delta?: string;
14
+ item?: {
15
+ type?: string;
16
+ id?: string;
17
+ call_id?: string;
18
+ name?: string;
19
+ arguments?: string;
20
+ /** On a `reasoning` output item: the opaque blob to replay (round-trip). */
21
+ encrypted_content?: string;
22
+ };
23
+ item_id?: string;
24
+ call_id?: string;
25
+ name?: string;
26
+ arguments?: string;
27
+ response?: {
28
+ status?: string;
29
+ incomplete_details?: { reason?: string };
30
+ usage?: {
31
+ input_tokens?: number;
32
+ output_tokens?: number;
33
+ input_tokens_details?: { cached_tokens?: number };
34
+ };
35
+ };
36
+ error?: { message?: string };
37
+ }
38
+
39
+ export interface SseStepResult {
40
+ events?: ProviderEvent[];
41
+ stopReason?: StopReason;
42
+ usage?: { input?: number; output?: number; cacheRead?: number };
43
+ terminal?: boolean;
44
+ }
45
+
46
+ /**
47
+ * Parse accumulated function-call argument text into the tool input. The
48
+ * server normally sends well-formed JSON, but a truncated stream can leave
49
+ * partial JSON; rather than drop the call we surface the raw text under
50
+ * `_rawPartial` so the upstream loop still sees the tool request. Shared by
51
+ * the per-event handler (`function_call_arguments.done`) and the consumer's
52
+ * truncated-stream flush so the two stay in lockstep.
53
+ */
54
+ export function parseToolArgs(args: string): unknown {
55
+ if (!args) return {};
56
+ try {
57
+ return JSON.parse(args);
58
+ } catch {
59
+ return { _rawPartial: args };
60
+ }
61
+ }
package/src/index.ts ADDED
@@ -0,0 +1,65 @@
1
+ import { defineProvider, definePlugin } from '@moxxy/sdk';
2
+ import { CodexProvider, type CodexProviderConfig } from './provider.js';
3
+ import { codexModels } from './models.js';
4
+ import { codexLogin, codexLogout, codexStatus } from './login.js';
5
+ import { PLUGIN_VERSION } from './codex/headers.js';
6
+
7
+ export const openaiCodexProviderDef = defineProvider({
8
+ name: 'openai-codex',
9
+ models: [...codexModels],
10
+ createClient: (config) => new CodexProvider(config as CodexProviderConfig),
11
+ // No validateKey: OAuth credentials are validated by the OAuth token
12
+ // exchange itself, not by a synchronous key check.
13
+ auth: {
14
+ kind: 'oauth',
15
+ serviceName: 'ChatGPT Pro/Plus',
16
+ login: codexLogin,
17
+ logout: codexLogout,
18
+ status: codexStatus,
19
+ },
20
+ });
21
+
22
+ export const openaiCodexPlugin = definePlugin({
23
+ name: '@moxxy/plugin-provider-openai-codex',
24
+ version: PLUGIN_VERSION,
25
+ providers: [openaiCodexProviderDef],
26
+ });
27
+
28
+ export default openaiCodexPlugin;
29
+
30
+ export { CodexProvider } from './provider.js';
31
+ export { codexModels, DEFAULT_CODEX_MODEL } from './models.js';
32
+ export {
33
+ CLIENT_ID,
34
+ ISSUER,
35
+ AUTHORIZE_URL,
36
+ TOKEN_URL,
37
+ CODEX_RESPONSES_URL,
38
+ DEFAULT_CALLBACK_PORT,
39
+ DEFAULT_REDIRECT_PATH,
40
+ DEFAULT_REDIRECT_URI,
41
+ SCOPES,
42
+ ORIGINATOR,
43
+ generatePKCE,
44
+ generateState,
45
+ buildAuthorizeUrl,
46
+ parseJwtClaims,
47
+ extractAccountId,
48
+ exchangeCodeForTokens,
49
+ refreshTokens,
50
+ } from './oauth.js';
51
+ export {
52
+ CODEX_PROVIDER_ID,
53
+ codexOauthProfile,
54
+ } from './profile.js';
55
+ export {
56
+ codexLogin,
57
+ codexLogout,
58
+ codexStatus,
59
+ ensureFreshCodexTokens,
60
+ persistCodexTokens,
61
+ readStoredTokens,
62
+ readStoredTokens as readCodexStoredTokens,
63
+ } from './login.js';
64
+ export type { CodexProviderConfig } from './provider.js';
65
+ export type { CodexTokens, PkceCodes, OAuthTokenResponse } from './types.js';
package/src/login.ts ADDED
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Codex OAuth login — thin wrapper over `@moxxy/plugin-oauth`'s provider
3
+ * framework. Everything generic (loopback callback server, browser opener,
4
+ * device-flow polling, vault persistence, refresh) lives in plugin-oauth;
5
+ * this file only carries the codex-shaped mapping into `CodexTokens` that
6
+ * the provider class consumes.
7
+ */
8
+
9
+ import {
10
+ clearStoredCreds,
11
+ ensureFreshTokens,
12
+ readStoredCreds,
13
+ runOauthLogin,
14
+ storeTokenSet,
15
+ type OAuthVault,
16
+ type StoredCreds,
17
+ } from '@moxxy/plugin-oauth';
18
+ import type {
19
+ ProviderAuthContext,
20
+ ProviderOAuthResult,
21
+ ProviderOAuthStatus,
22
+ } from '@moxxy/sdk';
23
+ import { CODEX_PROVIDER_ID, codexOauthProfile } from './profile.js';
24
+ import type { CodexTokens } from './types.js';
25
+
26
+ export async function codexLogin(ctx: ProviderAuthContext): Promise<ProviderOAuthResult> {
27
+ const result = await runOauthLogin(codexOauthProfile, {
28
+ vault: ctx.vault,
29
+ headless: ctx.headless,
30
+ write: ctx.write,
31
+ });
32
+ return result.accountId
33
+ ? { accountId: result.accountId, expiresAt: result.tokens.expiresAt ?? 0 }
34
+ : { expiresAt: result.tokens.expiresAt ?? 0 };
35
+ }
36
+
37
+ export async function codexLogout(ctx: ProviderAuthContext): Promise<boolean> {
38
+ try {
39
+ const removed = await clearStoredCreds(ctx.vault, CODEX_PROVIDER_ID);
40
+ return removed > 0;
41
+ } catch {
42
+ return false;
43
+ }
44
+ }
45
+
46
+ export async function codexStatus(ctx: ProviderAuthContext): Promise<ProviderOAuthStatus | null> {
47
+ const stored = await readStoredCreds(ctx.vault, CODEX_PROVIDER_ID);
48
+ if (!stored) return null;
49
+ return {
50
+ accountId: stored.extras.account_id ?? null,
51
+ expiresAt: stored.tokenSet.expiresAt ?? 0,
52
+ vaultKey: `oauth/${CODEX_PROVIDER_ID}/*`,
53
+ };
54
+ }
55
+
56
+ /**
57
+ * Read the stored token set in the legacy `CodexTokens` shape the provider
58
+ * class expects. Returns `null` if nothing is stored — the provider then
59
+ * reports the "run `moxxy login openai-codex`" error to the caller.
60
+ */
61
+ export async function readStoredTokens(vault: OAuthVault): Promise<CodexTokens | null> {
62
+ const stored = await readStoredCreds(vault, CODEX_PROVIDER_ID);
63
+ return stored ? toCodexTokens(stored) : null;
64
+ }
65
+
66
+ /**
67
+ * Pre-request freshness gate consumed by `CodexProvider.ensureFresh`.
68
+ * Reads the stored creds, refreshes if near expiry, persists the rotated
69
+ * tokens BEFORE returning so a crash here can't strand a single-use
70
+ * refresh_token in memory.
71
+ */
72
+ export async function ensureFreshCodexTokens(vault: OAuthVault): Promise<CodexTokens> {
73
+ const { tokens, extras } = await ensureFreshTokens(codexOauthProfile, vault);
74
+ if (!tokens.refreshToken) {
75
+ throw new Error('refreshed token set missing refresh_token');
76
+ }
77
+ return {
78
+ access: tokens.accessToken,
79
+ refresh: tokens.refreshToken,
80
+ expires: tokens.expiresAt ?? 0,
81
+ ...(extras.account_id ? { accountId: extras.account_id } : {}),
82
+ };
83
+ }
84
+
85
+ /**
86
+ * Persist the provider's in-memory `CodexTokens` back into the vault after
87
+ * an in-flight refresh. Wires the `CodexProvider.onTokensRefreshed`
88
+ * callback to the same storage layout `runOauthLogin` writes.
89
+ */
90
+ export async function persistCodexTokens(
91
+ vault: OAuthVault,
92
+ tokens: CodexTokens,
93
+ ): Promise<void> {
94
+ await storeTokenSet(
95
+ vault,
96
+ CODEX_PROVIDER_ID,
97
+ {
98
+ accessToken: tokens.access,
99
+ refreshToken: tokens.refresh,
100
+ expiresAt: tokens.expires,
101
+ tokenType: 'Bearer',
102
+ },
103
+ {
104
+ clientId: codexOauthProfile.clientId,
105
+ tokenUrl: codexOauthProfile.tokenUrl,
106
+ extras: tokens.accountId ? { account_id: tokens.accountId } : {},
107
+ },
108
+ );
109
+ }
110
+
111
+ function toCodexTokens(stored: StoredCreds): CodexTokens {
112
+ if (!stored.tokenSet.refreshToken) {
113
+ throw new Error(`Stored codex creds missing refresh_token under oauth/${CODEX_PROVIDER_ID}/`);
114
+ }
115
+ return {
116
+ access: stored.tokenSet.accessToken,
117
+ refresh: stored.tokenSet.refreshToken,
118
+ expires: stored.tokenSet.expiresAt ?? 0,
119
+ ...(stored.extras.account_id ? { accountId: stored.extras.account_id } : {}),
120
+ };
121
+ }
package/src/models.ts ADDED
@@ -0,0 +1,21 @@
1
+ import type { ModelDescriptor } from '@moxxy/sdk';
2
+
3
+ /**
4
+ * Models the ChatGPT-plan backend will serve. Mirrors opencode's ALLOWED_MODELS
5
+ * set (`packages/opencode/src/plugin/codex.ts`). The API-key OpenAI provider
6
+ * still exposes the full catalog; this list is the subset the Codex backend
7
+ * routes to ChatGPT-Pro/Plus subscribers without per-token billing.
8
+ */
9
+ // Every Codex-served model is a gpt-5-family reasoning model, so all advertise
10
+ // `supportsReasoning` — the request already sends `reasoning.summary: 'auto'`;
11
+ // the per-provider toggle decides whether the summary is surfaced.
12
+ export const codexModels: ReadonlyArray<ModelDescriptor> = [
13
+ { id: 'gpt-5.5', contextWindow: 1_000_000, maxOutputTokens: 128_000, supportsTools: true, supportsStreaming: true, supportsImages: true, supportsDocuments: true, supportsReasoning: true },
14
+ { id: 'gpt-5.4', contextWindow: 1_000_000, maxOutputTokens: 128_000, supportsTools: true, supportsStreaming: true, supportsImages: true, supportsDocuments: true, supportsReasoning: true },
15
+ { id: 'gpt-5.4-mini', contextWindow: 400_000, maxOutputTokens: 128_000, supportsTools: true, supportsStreaming: true, supportsImages: true, supportsDocuments: true, supportsReasoning: true },
16
+ { id: 'gpt-5.3-codex', contextWindow: 400_000, maxOutputTokens: 128_000, supportsTools: true, supportsStreaming: true, supportsImages: true, supportsDocuments: true, supportsReasoning: true },
17
+ { id: 'gpt-5.3-codex-spark', contextWindow: 400_000, maxOutputTokens: 128_000, supportsTools: true, supportsStreaming: true, supportsImages: true, supportsDocuments: true, supportsReasoning: true },
18
+ { id: 'gpt-5.2', contextWindow: 400_000, maxOutputTokens: 128_000, supportsTools: true, supportsStreaming: true, supportsImages: true, supportsDocuments: true, supportsReasoning: true },
19
+ ];
20
+
21
+ export const DEFAULT_CODEX_MODEL = 'gpt-5.3-codex';
@@ -0,0 +1,223 @@
1
+ import { Buffer } from 'node:buffer';
2
+ import { describe, expect, it, vi } from 'vitest';
3
+ import {
4
+ AUTHORIZE_URL,
5
+ CLIENT_ID,
6
+ buildAuthorizeUrl,
7
+ exchangeCodeForTokens,
8
+ extractAccountId,
9
+ generatePKCE,
10
+ generateState,
11
+ parseJwtClaims,
12
+ refreshTokens,
13
+ SCOPES,
14
+ TOKEN_URL,
15
+ } from './oauth.js';
16
+
17
+ function makeJwt(claims: Record<string, unknown>): string {
18
+ const header = Buffer.from(JSON.stringify({ alg: 'none' })).toString('base64url');
19
+ const payload = Buffer.from(JSON.stringify(claims)).toString('base64url');
20
+ return `${header}.${payload}.sig`;
21
+ }
22
+
23
+ describe('generatePKCE', () => {
24
+ it('produces a spec-length verifier and a 43-char SHA-256 base64url challenge', async () => {
25
+ const { verifier, challenge } = await generatePKCE();
26
+ // 96 bytes of entropy -> 128 base64url chars (the @moxxy/plugin-oauth
27
+ // shared verifier; spec allows 43–128).
28
+ expect(verifier).toHaveLength(128);
29
+ expect(verifier).toMatch(/^[A-Za-z0-9\-._~]+$/);
30
+ expect(challenge).toHaveLength(43);
31
+ expect(challenge).toMatch(/^[A-Za-z0-9_-]+$/);
32
+ expect(challenge).not.toContain('=');
33
+ });
34
+ });
35
+
36
+ describe('generateState', () => {
37
+ it('returns distinct, url-safe base64 strings', () => {
38
+ const a = generateState();
39
+ const b = generateState();
40
+ expect(a).not.toBe(b);
41
+ expect(a).toMatch(/^[A-Za-z0-9_-]+$/);
42
+ });
43
+ });
44
+
45
+ describe('buildAuthorizeUrl', () => {
46
+ it('includes all codex-required query params', () => {
47
+ const url = buildAuthorizeUrl('http://localhost:1455/auth/callback', {
48
+ verifier: 'v',
49
+ challenge: 'ch',
50
+ }, 'STATE');
51
+ expect(url.startsWith(`${AUTHORIZE_URL}?`)).toBe(true);
52
+ const params = new URL(url).searchParams;
53
+ expect(params.get('response_type')).toBe('code');
54
+ expect(params.get('client_id')).toBe(CLIENT_ID);
55
+ expect(params.get('redirect_uri')).toBe('http://localhost:1455/auth/callback');
56
+ expect(params.get('scope')).toBe(SCOPES);
57
+ expect(params.get('code_challenge')).toBe('ch');
58
+ expect(params.get('code_challenge_method')).toBe('S256');
59
+ expect(params.get('id_token_add_organizations')).toBe('true');
60
+ expect(params.get('codex_cli_simplified_flow')).toBe('true');
61
+ expect(params.get('state')).toBe('STATE');
62
+ expect(params.get('originator')).toBe('moxxy');
63
+ });
64
+ });
65
+
66
+ describe('parseJwtClaims', () => {
67
+ it('round-trips a manually-encoded JWT', () => {
68
+ const jwt = makeJwt({ sub: 'abc', email: 'me@example.com' });
69
+ expect(parseJwtClaims(jwt)).toEqual({ sub: 'abc', email: 'me@example.com' });
70
+ });
71
+
72
+ it('returns undefined for malformed JWTs', () => {
73
+ expect(parseJwtClaims('only.two')).toBeUndefined();
74
+ expect(parseJwtClaims('a.notbase64payload.c')).toBeUndefined();
75
+ expect(parseJwtClaims('')).toBeUndefined();
76
+ });
77
+
78
+ it('rejects a hostile JWT with an oversized payload segment instead of decoding it', () => {
79
+ // A MITM'd/hostile token endpoint (or a tampered vault entry replayed back)
80
+ // hands us a multi-megabyte base64url payload. We must NOT base64-decode and
81
+ // JSON.parse the whole thing — bound the segment and degrade to undefined.
82
+ const hugePayload = 'A'.repeat(2 * 1024 * 1024); // ~2 MiB, well over the 64 KiB cap
83
+ const jwt = `aGVhZGVy.${hugePayload}.sig`;
84
+ expect(parseJwtClaims(jwt)).toBeUndefined();
85
+ });
86
+
87
+ it('rejects a JWT whose payload parses to a non-object (string/array/null)', () => {
88
+ // The payload is valid base64url JSON but not a claim bag. Downstream code
89
+ // indexes the result as an object, so a scalar/array/null must be rejected
90
+ // rather than returned as a surprising value.
91
+ const scalar = `h.${Buffer.from('"just a string"').toString('base64url')}.s`;
92
+ const arr = `h.${Buffer.from('[1,2,3]').toString('base64url')}.s`;
93
+ const nul = `h.${Buffer.from('null').toString('base64url')}.s`;
94
+ expect(parseJwtClaims(scalar)).toBeUndefined();
95
+ expect(parseJwtClaims(arr)).toBeUndefined();
96
+ expect(parseJwtClaims(nul)).toBeUndefined();
97
+ });
98
+ });
99
+
100
+ describe('extractAccountId', () => {
101
+ it('prefers the top-level chatgpt_account_id claim', () => {
102
+ const id_token = makeJwt({
103
+ chatgpt_account_id: 'acct_top',
104
+ 'https://api.openai.com/auth': { chatgpt_account_id: 'acct_namespaced' },
105
+ organizations: [{ id: 'org_first' }],
106
+ });
107
+ expect(extractAccountId({ id_token })).toBe('acct_top');
108
+ });
109
+
110
+ it('falls back to the namespaced auth-bag claim', () => {
111
+ const id_token = makeJwt({
112
+ 'https://api.openai.com/auth': { chatgpt_account_id: 'acct_namespaced' },
113
+ organizations: [{ id: 'org_first' }],
114
+ });
115
+ expect(extractAccountId({ id_token })).toBe('acct_namespaced');
116
+ });
117
+
118
+ it('falls back to the first organization id', () => {
119
+ const id_token = makeJwt({ organizations: [{ id: 'org_first' }, { id: 'org_second' }] });
120
+ expect(extractAccountId({ id_token })).toBe('org_first');
121
+ });
122
+
123
+ it('returns undefined when no claim matches', () => {
124
+ const id_token = makeJwt({ sub: 'noone' });
125
+ expect(extractAccountId({ id_token })).toBeUndefined();
126
+ });
127
+
128
+ it('falls through from id_token to access_token', () => {
129
+ const access_token = makeJwt({ chatgpt_account_id: 'acct_from_access' });
130
+ expect(extractAccountId({ access_token })).toBe('acct_from_access');
131
+ });
132
+
133
+ it('degrades to undefined on hostile claim shapes instead of throwing', () => {
134
+ // organizations present but its first element is a scalar / null, the
135
+ // auth-bag is null, and chatgpt_account_id is the wrong type. None of these
136
+ // should crash extraction — it must return undefined.
137
+ const id_token = makeJwt({
138
+ chatgpt_account_id: 12345, // wrong type
139
+ 'https://api.openai.com/auth': null,
140
+ organizations: ['not-an-object', null, { id: 42 }],
141
+ });
142
+ expect(() => extractAccountId({ id_token })).not.toThrow();
143
+ expect(extractAccountId({ id_token })).toBeUndefined();
144
+ });
145
+
146
+ it('returns undefined when handed empty/blank token strings', () => {
147
+ expect(extractAccountId({})).toBeUndefined();
148
+ expect(extractAccountId({ id_token: '', access_token: '' })).toBeUndefined();
149
+ });
150
+ });
151
+
152
+ describe('exchangeCodeForTokens', () => {
153
+ it('posts form-urlencoded body to the token endpoint and normalizes the response', async () => {
154
+ const id_token = makeJwt({ chatgpt_account_id: 'acct_123' });
155
+ const fakeFetch = vi.fn(async (_url: RequestInfo | URL, init?: RequestInit) => {
156
+ expect(init?.method).toBe('POST');
157
+ expect(init?.headers).toMatchObject({ 'Content-Type': 'application/x-www-form-urlencoded' });
158
+ const body = new URLSearchParams(String(init?.body ?? ''));
159
+ expect(body.get('grant_type')).toBe('authorization_code');
160
+ expect(body.get('code')).toBe('CODE');
161
+ expect(body.get('redirect_uri')).toBe('http://localhost:1455/auth/callback');
162
+ expect(body.get('client_id')).toBe(CLIENT_ID);
163
+ expect(body.get('code_verifier')).toBe('VERIFIER');
164
+ return new Response(
165
+ JSON.stringify({
166
+ access_token: 'AT',
167
+ refresh_token: 'RT',
168
+ id_token,
169
+ expires_in: 7200,
170
+ }),
171
+ { status: 200, headers: { 'Content-Type': 'application/json' } },
172
+ );
173
+ });
174
+
175
+ const before = Date.now();
176
+ const result = await exchangeCodeForTokens(
177
+ 'CODE',
178
+ 'http://localhost:1455/auth/callback',
179
+ { verifier: 'VERIFIER', challenge: 'CH' },
180
+ fakeFetch as unknown as typeof fetch,
181
+ );
182
+ const after = Date.now();
183
+
184
+ expect(result.access).toBe('AT');
185
+ expect(result.refresh).toBe('RT');
186
+ expect(result.accountId).toBe('acct_123');
187
+ expect(result.expires).toBeGreaterThanOrEqual(before + 7200 * 1000);
188
+ expect(result.expires).toBeLessThanOrEqual(after + 7200 * 1000);
189
+ expect(fakeFetch).toHaveBeenCalledTimes(1);
190
+ expect(String(fakeFetch.mock.calls[0]![0])).toBe(TOKEN_URL);
191
+ });
192
+
193
+ it('throws when the token endpoint returns non-2xx', async () => {
194
+ const fakeFetch = vi.fn(async () => new Response('bad', { status: 400 }));
195
+ await expect(
196
+ exchangeCodeForTokens(
197
+ 'CODE',
198
+ 'http://localhost:1455/auth/callback',
199
+ { verifier: 'V', challenge: 'C' },
200
+ fakeFetch as unknown as typeof fetch,
201
+ ),
202
+ ).rejects.toThrow(/400/);
203
+ });
204
+ });
205
+
206
+ describe('refreshTokens', () => {
207
+ it('posts refresh_token grant and returns normalized tokens', async () => {
208
+ const fakeFetch = vi.fn(async (_url, init: RequestInit) => {
209
+ const body = new URLSearchParams(String(init.body ?? ''));
210
+ expect(body.get('grant_type')).toBe('refresh_token');
211
+ expect(body.get('refresh_token')).toBe('OLD_RT');
212
+ expect(body.get('client_id')).toBe(CLIENT_ID);
213
+ return new Response(
214
+ JSON.stringify({ access_token: 'NEW_AT', refresh_token: 'NEW_RT', expires_in: 1800 }),
215
+ { status: 200, headers: { 'Content-Type': 'application/json' } },
216
+ );
217
+ });
218
+ const result = await refreshTokens('OLD_RT', fakeFetch as unknown as typeof fetch);
219
+ expect(result.access).toBe('NEW_AT');
220
+ expect(result.refresh).toBe('NEW_RT');
221
+ expect(result.accountId).toBeUndefined();
222
+ });
223
+ });