@mandujs/core 0.41.2 → 0.43.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 (88) hide show
  1. package/package.json +21 -4
  2. package/src/auth/__tests__/login.test.ts +420 -419
  3. package/src/auth/__tests__/reset.test.ts +296 -296
  4. package/src/brain/adapters/anthropic-oauth.ts +421 -420
  5. package/src/brain/adapters/index.ts +2 -1
  6. package/src/brain/adapters/ollama.ts +1 -1
  7. package/src/brain/adapters/openai-oauth.ts +534 -533
  8. package/src/brain/brain.ts +2 -1
  9. package/src/brain/redactor.ts +196 -196
  10. package/src/bundler/__tests__/cli-bench-utils.test.ts +149 -149
  11. package/src/bundler/__tests__/cold-start.test.ts +504 -504
  12. package/src/bundler/__tests__/fast-refresh.test.ts +607 -606
  13. package/src/bundler/__tests__/hdr.test.ts +1 -1
  14. package/src/bundler/analyzer.ts +958 -958
  15. package/src/bundler/build.ts +104 -14
  16. package/src/bundler/dev.ts +125 -0
  17. package/src/bundler/hmr-types.ts +1 -0
  18. package/src/bundler/plugins/__tests__/react-compiler-lint.test.ts +110 -0
  19. package/src/bundler/plugins/index.ts +14 -0
  20. package/src/bundler/plugins/react-compiler-lint.ts +253 -0
  21. package/src/bundler/plugins/react-compiler.ts +162 -0
  22. package/src/bundler/types.ts +12 -0
  23. package/src/change/integrity.ts +2 -1
  24. package/src/client/index.ts +10 -0
  25. package/src/client/island.ts +38 -11
  26. package/src/client/router.ts +6 -1
  27. package/src/config/mandu.ts +57 -0
  28. package/src/config/validate.ts +42 -0
  29. package/src/content/collection.ts +844 -809
  30. package/src/content/content-layer.ts +316 -314
  31. package/src/content/content.test.ts +433 -433
  32. package/src/content/digest.ts +133 -133
  33. package/src/content/generate-types.ts +168 -168
  34. package/src/content/index.ts +6 -1
  35. package/src/content/llms-txt.ts +277 -277
  36. package/src/contract/define.ts +474 -474
  37. package/src/contract/route-helpers.ts +2 -1
  38. package/src/contract/zod-utils.ts +158 -155
  39. package/src/db/index.ts +513 -513
  40. package/src/desktop/__tests__/smoke.test.ts +100 -100
  41. package/src/desktop/webview-fallback.ts +583 -583
  42. package/src/desktop/window.ts +3 -1
  43. package/src/dev-error-overlay/overlay-client.ts +300 -300
  44. package/src/devtools/ai/mcp-connector.ts +499 -498
  45. package/src/devtools/client/components/kitchen-root.tsx +7 -2
  46. package/src/email/resend.ts +163 -163
  47. package/src/guard/__tests__/tsgolint-bridge.test.ts +347 -0
  48. package/src/guard/ast-analyzer.ts +806 -806
  49. package/src/guard/graph.ts +898 -898
  50. package/src/guard/index.ts +16 -0
  51. package/src/guard/statistics.ts +578 -578
  52. package/src/guard/tsgolint-bridge.ts +512 -0
  53. package/src/i18n/locale-resolver.ts +214 -214
  54. package/src/id/__tests__/id.test.ts +120 -120
  55. package/src/intent/index.ts +321 -321
  56. package/src/island/index.ts +39 -23
  57. package/src/kitchen/api/contract-api.ts +15 -8
  58. package/src/kitchen/kitchen-ui.ts +2137 -2137
  59. package/src/lockfile/index.ts +3 -2
  60. package/src/middleware/oauth/__tests__/oauth.test.ts +575 -574
  61. package/src/middleware/rate-limit/__tests__/rate-limit.test.ts +642 -642
  62. package/src/middleware/secure/index.ts +417 -417
  63. package/src/observability/event-bus.ts +2 -2
  64. package/src/observability/metrics.ts +334 -334
  65. package/src/observability/tracing.ts +694 -694
  66. package/src/openapi/generator.ts +1 -1
  67. package/src/perf/user-marks.ts +553 -553
  68. package/src/plugins/registry.ts +387 -387
  69. package/src/resource/ddl/diff.ts +392 -392
  70. package/src/resource/ddl/snapshot.ts +448 -447
  71. package/src/resource/generator-schema.ts +477 -476
  72. package/src/resource/parser.ts +4 -2
  73. package/src/resource/schema.ts +1 -1
  74. package/src/router/fs-patterns.ts +422 -422
  75. package/src/runtime/fast-refresh-types.ts +126 -128
  76. package/src/runtime/image-handler.ts +206 -195
  77. package/src/runtime/router.test.ts +476 -476
  78. package/src/runtime/security.ts +155 -155
  79. package/src/runtime/server.ts +36 -19
  80. package/src/runtime/session-key.ts +328 -328
  81. package/src/scheduler/__tests__/scheduler.test.ts +514 -514
  82. package/src/seo/resolve/index.ts +353 -353
  83. package/src/spec/load.ts +1 -1
  84. package/src/testing/reporter.ts +676 -676
  85. package/src/testing/server.ts +196 -196
  86. package/src/testing/snapshot.ts +444 -444
  87. package/src/utils/__tests__/lru-cache.test.ts +186 -186
  88. package/src/utils/bun.ts +8 -8
@@ -1,533 +1,534 @@
1
- /**
2
- * Brain — OpenAI OAuth adapter (Issue #235).
3
- *
4
- * Connects to the OpenAI Chat Completions API using a ChatGPT session
5
- * token. Mandu NEVER owns an OpenAI OAuth app — we reuse the OpenAI
6
- * official `@openai/codex` CLI's login flow. First-time login:
7
- *
8
- * npx @openai/codex login # (or `mandu brain login`)
9
- *
10
- * OpenAI handles the browser OAuth handshake and writes the token to
11
- * `~/.codex/auth.json`. This adapter reads that file via
12
- * `ChatGPTAuth`, auto-refreshes the access token on expiry, and
13
- * forwards the resulting Bearer to `api.openai.com/v1/chat/completions`.
14
- *
15
- * Legacy code in this file (`runAuthorizationCodeFlow` + keychain
16
- * `CredentialStore`) remains available for `mandu brain login` flows
17
- * that opt into a custom OAuth app (e.g. enterprise proxies), but the
18
- * default path is now the ChatGPT session token.
19
- *
20
- * Failure modes handled here:
21
- * - Missing token → adapter reports `available: false`, the
22
- * resolver falls to the next tier.
23
- * - 401 on complete() → one silent refresh attempt. On repeat
24
- * failure the token is deleted and the
25
- * adapter returns an empty completion,
26
- * letting Brain fall back to template.
27
- * - Network / 5xx → surfaced as an Error (isolated by Brain
28
- * via `isolatedBrainExecution`).
29
- *
30
- * Redaction is applied to every prompt BEFORE it hits `fetch`. Redacted
31
- * hits are appended to `<projectRoot>/.mandu/brain-redactions.jsonl`
32
- * for user audit. A consent prompt runs on the first cloud call per
33
- * (provider, project).
34
- */
35
-
36
- import { promises as fs } from "node:fs";
37
- import path from "node:path";
38
-
39
- import { BaseLLMAdapter } from "./base";
40
- import type {
41
- AdapterConfig,
42
- AdapterStatus,
43
- ChatMessage,
44
- CompletionOptions,
45
- CompletionResult,
46
- } from "../types";
47
- import {
48
- CredentialStore,
49
- getCredentialStore,
50
- type StoredToken,
51
- } from "../credentials";
52
- import {
53
- ensureConsent,
54
- type ConsentPromptDeps,
55
- } from "../consent";
56
- import { redactSecrets } from "../redactor";
57
- import {
58
- refreshAccessToken,
59
- runAuthorizationCodeFlow,
60
- type HttpClient,
61
- type OAuthEndpoints,
62
- } from "./oauth-flow";
63
- import { ChatGPTAuth, type EffectiveAuth } from "./chatgpt-auth";
64
-
65
- /* -------------------------------------------------------------------- */
66
- /* Defaults */
67
- /* -------------------------------------------------------------------- */
68
-
69
- /**
70
- * OpenAI OAuth endpoints. These mirror the ChatGPT developer OAuth
71
- * surface documented at platform.openai.com/oauth. Tests + CI override
72
- * via `options.endpoints` so this module never dials the real host in
73
- * unit tests.
74
- */
75
- export const OPENAI_OAUTH_ENDPOINTS: OAuthEndpoints = {
76
- authorizationUrl: "https://platform.openai.com/oauth/authorize",
77
- tokenUrl: "https://platform.openai.com/oauth/token",
78
- };
79
-
80
- /**
81
- * OpenAI OAuth public client id. Registered by Mandu; no secret is
82
- * required (PKCE covers the exchange). The client id is not sensitive;
83
- * it is already embedded in every authorization URL the user clicks.
84
- */
85
- export const OPENAI_OAUTH_CLIENT_ID = "mandu-brain-cli";
86
- export const OPENAI_OAUTH_SCOPE = "openai.chat";
87
-
88
- /**
89
- * Default model — GPT-5.4 (current-generation OpenAI flagship as of
90
- * 2026-04). Gives brain doctor triage the quality it needs to produce
91
- * actionable patches, which was the whole motivation for moving off
92
- * the local ministral-3:3b adapter. Override via
93
- * `ManduConfig.brain.openai.model` (e.g. set to a cheaper tier for
94
- * low-stakes automated runs).
95
- */
96
- export const OPENAI_DEFAULT_MODEL = "gpt-5.4";
97
- export const OPENAI_API_BASE = "https://api.openai.com/v1";
98
-
99
- export const DEFAULT_OPENAI_CONFIG: AdapterConfig = {
100
- baseUrl: OPENAI_API_BASE,
101
- model: OPENAI_DEFAULT_MODEL,
102
- timeout: 60_000,
103
- };
104
-
105
- /* -------------------------------------------------------------------- */
106
- /* Options */
107
- /* -------------------------------------------------------------------- */
108
-
109
- export interface OpenAIOAuthAdapterOptions extends Partial<AdapterConfig> {
110
- /** Injection point tests supply an in-memory fetch stub. */
111
- httpClient?: HttpClient;
112
- /** Injection point — tests swap in a canned endpoint pair (legacy flow only). */
113
- endpoints?: OAuthEndpoints;
114
- /** OAuth client id override (for enterprise OpenAI proxies). */
115
- clientId?: string;
116
- /** OAuth scope override. */
117
- scope?: string;
118
- /** Credential store — default singleton; tests inject an in-memory one. */
119
- credentialStore?: CredentialStore;
120
- /** Project root — consent prompts are scoped per-project. */
121
- projectRoot?: string;
122
- /** Force-disable the consent prompt (telemetryOptOut path). */
123
- skipConsent?: boolean;
124
- /** Consent-prompt deps (stdout, readline) — test injection. */
125
- consentDeps?: ConsentPromptDeps;
126
- /**
127
- * When true, attempted adapter use without a token throws instead of
128
- * silently returning `available: false`. Used by the CLI `brain login`
129
- * command to loudly fail if the flow never wrote a token.
130
- */
131
- strict?: boolean;
132
- /**
133
- * ChatGPT session-token auth helper. Default: reads
134
- * `~/.codex/auth.json` / `~/.chatgpt-local/auth.json` produced by
135
- * `npx @openai/codex login`. Tests inject a helper pointed at a
136
- * throwaway fixture path.
137
- */
138
- chatgptAuth?: ChatGPTAuth;
139
- /**
140
- * Override: explicit path to the ChatGPT auth.json. Only used when
141
- * `chatgptAuth` is not supplied.
142
- */
143
- chatgptAuthFilePath?: string;
144
- }
145
-
146
- /* -------------------------------------------------------------------- */
147
- /* Adapter */
148
- /* -------------------------------------------------------------------- */
149
-
150
- export class OpenAIOAuthAdapter extends BaseLLMAdapter {
151
- readonly name = "openai-oauth";
152
- private httpClient: HttpClient;
153
- private endpoints: OAuthEndpoints;
154
- private clientId: string;
155
- private scope: string;
156
- private credentialStore: CredentialStore;
157
- private projectRoot: string;
158
- private skipConsent: boolean;
159
- private consentDeps?: ConsentPromptDeps;
160
- private strict: boolean;
161
- private refreshInFlight: Promise<StoredToken | null> | null = null;
162
- private chatgptAuth: ChatGPTAuth;
163
-
164
- constructor(options: OpenAIOAuthAdapterOptions = {}) {
165
- super({
166
- ...DEFAULT_OPENAI_CONFIG,
167
- ...options,
168
- });
169
- this.httpClient =
170
- options.httpClient ?? globalThis.fetch.bind(globalThis);
171
- this.endpoints = options.endpoints ?? OPENAI_OAUTH_ENDPOINTS;
172
- this.clientId = options.clientId ?? OPENAI_OAUTH_CLIENT_ID;
173
- this.scope = options.scope ?? OPENAI_OAUTH_SCOPE;
174
- this.credentialStore = options.credentialStore ?? getCredentialStore();
175
- this.projectRoot = options.projectRoot ?? process.cwd();
176
- this.skipConsent = options.skipConsent ?? false;
177
- this.consentDeps = options.consentDeps;
178
- this.strict = options.strict ?? false;
179
- this.chatgptAuth =
180
- options.chatgptAuth ??
181
- new ChatGPTAuth({
182
- authFilePath: options.chatgptAuthFilePath,
183
- httpClient: this.httpClient,
184
- });
185
- }
186
-
187
- /* ----------------------- Status / login ---------------------------- */
188
-
189
- async checkStatus(): Promise<AdapterStatus> {
190
- // Primary path — ChatGPT session token from `@openai/codex login`.
191
- if (this.chatgptAuth.isAuthenticated()) {
192
- return {
193
- available: true,
194
- model: this.config.model,
195
- };
196
- }
197
- // Legacy fallback — custom Mandu OAuth app token stored in keychain.
198
- const token = await this.credentialStore.load("openai");
199
- if (token) {
200
- return {
201
- available: true,
202
- model: this.config.model,
203
- };
204
- }
205
- return {
206
- available: false,
207
- model: null,
208
- error:
209
- "No OpenAI OAuth token found. Run `mandu brain login --provider=openai` " +
210
- "(which wraps `npx @openai/codex login`) first.",
211
- };
212
- }
213
-
214
- /**
215
- * Run the authorization-code + PKCE flow and persist the token.
216
- * Returns the stored token shape on success.
217
- */
218
- async login(
219
- opts: {
220
- onAuthUrl?: (url: string) => void;
221
- openBrowser?: (url: string) => Promise<void> | void;
222
- timeoutMs?: number;
223
- } = {},
224
- ): Promise<StoredToken> {
225
- const tokenResponse = await runAuthorizationCodeFlow({
226
- endpoints: this.endpoints,
227
- client: { clientId: this.clientId, scope: this.scope },
228
- httpClient: this.httpClient,
229
- onAuthUrl: opts.onAuthUrl,
230
- openBrowser: opts.openBrowser,
231
- timeoutMs: opts.timeoutMs,
232
- });
233
-
234
- const stored: StoredToken = {
235
- access_token: tokenResponse.access_token,
236
- refresh_token: tokenResponse.refresh_token,
237
- expires_at:
238
- typeof tokenResponse.expires_in === "number"
239
- ? Math.floor(Date.now() / 1000) + tokenResponse.expires_in
240
- : undefined,
241
- scope: tokenResponse.scope ?? this.scope,
242
- default_model: this.config.model,
243
- provider: "openai",
244
- last_used_at: new Date().toISOString(),
245
- };
246
- await this.credentialStore.save("openai", stored);
247
- return stored;
248
- }
249
-
250
- /** Delete the stored token. Idempotent. */
251
- async logout(): Promise<void> {
252
- await this.credentialStore.delete("openai");
253
- }
254
-
255
- /* ------------------------ Completion ------------------------------- */
256
-
257
- async complete(
258
- messages: ChatMessage[],
259
- options: CompletionOptions = {},
260
- ): Promise<CompletionResult> {
261
- // Primary: ChatGPT session token (managed by `@openai/codex login`).
262
- let chatgpt: EffectiveAuth | null = null;
263
- if (this.chatgptAuth.isAuthenticated()) {
264
- try {
265
- chatgpt = await this.chatgptAuth.getAuth();
266
- } catch {
267
- chatgpt = null;
268
- }
269
- }
270
- // Legacy keychain fallback only if ChatGPT auth unavailable.
271
- const token = chatgpt ? null : await this.loadTokenOrReject();
272
- if (!chatgpt && !token) {
273
- if (this.strict) {
274
- throw new Error(
275
- "OpenAIOAuthAdapter.complete() called without a stored token",
276
- );
277
- }
278
- return emptyCompletion();
279
- }
280
-
281
- // Consent prompt — skipped when telemetryOptOut is active (the
282
- // resolver never constructs us in that case) OR when the caller
283
- // has already vetted consent out of band.
284
- if (!this.skipConsent) {
285
- const ok = await ensureConsent(
286
- {
287
- projectRoot: this.projectRoot,
288
- provider: "openai",
289
- model: this.config.model,
290
- payloadDescription: describeChatPayload(messages),
291
- },
292
- this.consentDeps,
293
- );
294
- if (!ok) {
295
- // User declined — Brain must fall back. Return empty so the
296
- // template path takes over.
297
- return emptyCompletion();
298
- }
299
- }
300
-
301
- // Redact every message before the prompt leaves the machine.
302
- const redactedMessages: ChatMessage[] = [];
303
- const audit: string[] = [];
304
- for (const m of messages) {
305
- const { redacted, hits } = redactSecrets(m.content);
306
- redactedMessages.push({ role: m.role, content: redacted });
307
- for (const hit of hits) {
308
- audit.push(
309
- JSON.stringify({
310
- ts: new Date().toISOString(),
311
- provider: "openai",
312
- model: this.config.model,
313
- role: m.role,
314
- kind: hit.kind,
315
- sample: hit.sample,
316
- }),
317
- );
318
- }
319
- }
320
- if (audit.length > 0) {
321
- await appendRedactionLog(this.projectRoot, audit);
322
- }
323
-
324
- // First attempt — fresh token.
325
- let attemptToken = chatgpt ? chatgpt.accessToken : token!.access_token;
326
- let result = await this.callChatApi(
327
- attemptToken,
328
- redactedMessages,
329
- options,
330
- );
331
- if (result.status === 401) {
332
- if (chatgpt) {
333
- // ChatGPTAuth auto-refreshes via JWT exp; a 401 here means even
334
- // the refreshed token was rejected. Force a re-read (which will
335
- // trigger another refresh if needed) and retry once.
336
- try {
337
- const refreshed = await this.chatgptAuth.getAuth();
338
- attemptToken = refreshed.accessToken;
339
- result = await this.callChatApi(
340
- attemptToken,
341
- redactedMessages,
342
- options,
343
- );
344
- } catch {
345
- /* fall through to 401 handling */
346
- }
347
- } else if (token!.refresh_token) {
348
- const refreshed = await this.trySilentRefresh(token!);
349
- if (refreshed) {
350
- attemptToken = refreshed.access_token;
351
- result = await this.callChatApi(
352
- attemptToken,
353
- redactedMessages,
354
- options,
355
- );
356
- }
357
- }
358
- }
359
- if (result.status === 401) {
360
- if (!chatgpt) {
361
- // Persistent auth failure on the legacy keychain path — scrub so
362
- // subsequent runs skip to the next resolver tier. For the
363
- // ChatGPTAuth path we leave auth.json alone (the user re-runs
364
- // `codex login`; we must not race their session).
365
- await this.credentialStore.delete("openai");
366
- }
367
- return emptyCompletion();
368
- }
369
- if (!result.ok) {
370
- throw new Error(
371
- `OpenAI request failed (${result.status}): ${result.bodySnippet}`,
372
- );
373
- }
374
- if (!chatgpt) await this.credentialStore.touch("openai");
375
- return result.completion;
376
- }
377
-
378
- private async loadTokenOrReject(): Promise<StoredToken | null> {
379
- const token = await this.credentialStore.load("openai");
380
- return token ?? null;
381
- }
382
-
383
- private async trySilentRefresh(
384
- existing: StoredToken,
385
- ): Promise<StoredToken | null> {
386
- if (!existing.refresh_token) return null;
387
- if (this.refreshInFlight) return this.refreshInFlight;
388
- this.refreshInFlight = (async () => {
389
- try {
390
- const refreshed = await refreshAccessToken({
391
- endpoints: this.endpoints,
392
- clientId: this.clientId,
393
- refreshToken: existing.refresh_token!,
394
- httpClient: this.httpClient,
395
- scope: existing.scope ?? this.scope,
396
- });
397
- const stored: StoredToken = {
398
- access_token: refreshed.access_token,
399
- refresh_token: refreshed.refresh_token ?? existing.refresh_token,
400
- expires_at:
401
- typeof refreshed.expires_in === "number"
402
- ? Math.floor(Date.now() / 1000) + refreshed.expires_in
403
- : undefined,
404
- scope: refreshed.scope ?? existing.scope,
405
- default_model: existing.default_model,
406
- provider: "openai",
407
- last_used_at: new Date().toISOString(),
408
- };
409
- await this.credentialStore.save("openai", stored);
410
- return stored;
411
- } catch {
412
- return null;
413
- } finally {
414
- this.refreshInFlight = null;
415
- }
416
- })();
417
- return this.refreshInFlight;
418
- }
419
-
420
- private async callChatApi(
421
- accessToken: string,
422
- messages: ChatMessage[],
423
- options: CompletionOptions,
424
- ): Promise<
425
- | { ok: true; status: number; completion: CompletionResult }
426
- | { ok: false; status: number; bodySnippet: string; completion: CompletionResult }
427
- > {
428
- const body = {
429
- model: this.config.model,
430
- messages: messages.map((m) => ({ role: m.role, content: m.content })),
431
- temperature: options.temperature ?? 0.2,
432
- max_tokens: options.maxTokens ?? 2048,
433
- stop: options.stop,
434
- };
435
-
436
- const res = await this.httpClient(`${this.baseUrl}/chat/completions`, {
437
- method: "POST",
438
- headers: {
439
- authorization: `Bearer ${accessToken}`,
440
- "content-type": "application/json",
441
- accept: "application/json",
442
- },
443
- body: JSON.stringify(body),
444
- });
445
-
446
- if (!res.ok) {
447
- const txt = await res.text().catch(() => "");
448
- return {
449
- ok: false,
450
- status: res.status,
451
- bodySnippet: txt.slice(0, 256),
452
- completion: emptyCompletion(),
453
- };
454
- }
455
-
456
- const json = (await res.json()) as {
457
- choices?: Array<{ message?: { content?: string } }>;
458
- usage?: {
459
- prompt_tokens?: number;
460
- completion_tokens?: number;
461
- total_tokens?: number;
462
- };
463
- };
464
- const content = json.choices?.[0]?.message?.content ?? "";
465
- return {
466
- ok: true,
467
- status: res.status,
468
- completion: {
469
- content,
470
- usage: {
471
- promptTokens: json.usage?.prompt_tokens ?? 0,
472
- completionTokens: json.usage?.completion_tokens ?? 0,
473
- totalTokens: json.usage?.total_tokens ?? 0,
474
- },
475
- },
476
- };
477
- }
478
- }
479
-
480
- /* -------------------------------------------------------------------- */
481
- /* Helpers */
482
- /* -------------------------------------------------------------------- */
483
-
484
- function emptyCompletion(): CompletionResult {
485
- return {
486
- content: "",
487
- usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
488
- };
489
- }
490
-
491
- /**
492
- * One-line human summary of the chat payload for the consent prompt.
493
- *
494
- * Not exported — Anthropic has its own copy to avoid a cross-module
495
- * name collision in the adapters barrel. Keep implementations in sync
496
- * if the format changes.
497
- */
498
- function describeChatPayload(messages: ChatMessage[]): string {
499
- const totalChars = messages.reduce((a, m) => a + m.content.length, 0);
500
- const roleCounts = new Map<string, number>();
501
- for (const m of messages) {
502
- roleCounts.set(m.role, (roleCounts.get(m.role) ?? 0) + 1);
503
- }
504
- const roleSummary = [...roleCounts.entries()]
505
- .map(([r, n]) => `${n} ${r}`)
506
- .join(", ");
507
- return `${messages.length} messages (${roleSummary}), ~${totalChars} chars`;
508
- }
509
-
510
- /**
511
- * Append redaction audit entries as JSON-lines to
512
- * `<projectRoot>/.mandu/brain-redactions.jsonl`. Best-effort an IO
513
- * error here must not block the actual adapter request.
514
- */
515
- async function appendRedactionLog(
516
- projectRoot: string,
517
- entries: string[],
518
- ): Promise<void> {
519
- try {
520
- const dir = path.join(projectRoot, ".mandu");
521
- await fs.mkdir(dir, { recursive: true });
522
- const file = path.join(dir, "brain-redactions.jsonl");
523
- await fs.appendFile(file, `${entries.join("\n")}\n`, { mode: 0o600 });
524
- } catch {
525
- /* best-effort */
526
- }
527
- }
528
-
529
- export function createOpenAIOAuthAdapter(
530
- options: OpenAIOAuthAdapterOptions = {},
531
- ): OpenAIOAuthAdapter {
532
- return new OpenAIOAuthAdapter(options);
533
- }
1
+ /**
2
+ * Brain — OpenAI OAuth adapter (Issue #235).
3
+ *
4
+ * Connects to the OpenAI Chat Completions API using a ChatGPT session
5
+ * token. Mandu NEVER owns an OpenAI OAuth app — we reuse the OpenAI
6
+ * official `@openai/codex` CLI's login flow. First-time login:
7
+ *
8
+ * npx @openai/codex login # (or `mandu brain login`)
9
+ *
10
+ * OpenAI handles the browser OAuth handshake and writes the token to
11
+ * `~/.codex/auth.json`. This adapter reads that file via
12
+ * `ChatGPTAuth`, auto-refreshes the access token on expiry, and
13
+ * forwards the resulting Bearer to `api.openai.com/v1/chat/completions`.
14
+ *
15
+ * Legacy code in this file (`runAuthorizationCodeFlow` + keychain
16
+ * `CredentialStore`) remains available for `mandu brain login` flows
17
+ * that opt into a custom OAuth app (e.g. enterprise proxies), but the
18
+ * default path is now the ChatGPT session token.
19
+ *
20
+ * Failure modes handled here:
21
+ * - Missing token → adapter reports `available: false`, the
22
+ * resolver falls to the next tier.
23
+ * - 401 on complete() → one silent refresh attempt. On repeat
24
+ * failure the token is deleted and the
25
+ * adapter returns an empty completion,
26
+ * letting Brain fall back to template.
27
+ * - Network / 5xx → surfaced as an Error (isolated by Brain
28
+ * via `isolatedBrainExecution`).
29
+ *
30
+ * Redaction is applied to every prompt BEFORE it hits `fetch`. Redacted
31
+ * hits are appended to `<projectRoot>/.mandu/brain-redactions.jsonl`
32
+ * for user audit. A consent prompt runs on the first cloud call per
33
+ * (provider, project).
34
+ */
35
+
36
+ import { promises as fs } from "node:fs";
37
+ import path from "node:path";
38
+
39
+ import { BaseLLMAdapter } from "./base";
40
+ import type {
41
+ AdapterConfig,
42
+ AdapterStatus,
43
+ ChatMessage,
44
+ CompletionOptions,
45
+ CompletionResult,
46
+ } from "../types";
47
+ import type {
48
+ CredentialStore} from "../credentials";
49
+ import {
50
+ getCredentialStore,
51
+ type StoredToken,
52
+ } from "../credentials";
53
+ import {
54
+ ensureConsent,
55
+ type ConsentPromptDeps,
56
+ } from "../consent";
57
+ import { redactSecrets } from "../redactor";
58
+ import {
59
+ refreshAccessToken,
60
+ runAuthorizationCodeFlow,
61
+ type HttpClient,
62
+ type OAuthEndpoints,
63
+ } from "./oauth-flow";
64
+ import { ChatGPTAuth, type EffectiveAuth } from "./chatgpt-auth";
65
+
66
+ /* -------------------------------------------------------------------- */
67
+ /* Defaults */
68
+ /* -------------------------------------------------------------------- */
69
+
70
+ /**
71
+ * OpenAI OAuth endpoints. These mirror the ChatGPT developer OAuth
72
+ * surface documented at platform.openai.com/oauth. Tests + CI override
73
+ * via `options.endpoints` so this module never dials the real host in
74
+ * unit tests.
75
+ */
76
+ export const OPENAI_OAUTH_ENDPOINTS: OAuthEndpoints = {
77
+ authorizationUrl: "https://platform.openai.com/oauth/authorize",
78
+ tokenUrl: "https://platform.openai.com/oauth/token",
79
+ };
80
+
81
+ /**
82
+ * OpenAI OAuth public client id. Registered by Mandu; no secret is
83
+ * required (PKCE covers the exchange). The client id is not sensitive;
84
+ * it is already embedded in every authorization URL the user clicks.
85
+ */
86
+ export const OPENAI_OAUTH_CLIENT_ID = "mandu-brain-cli";
87
+ export const OPENAI_OAUTH_SCOPE = "openai.chat";
88
+
89
+ /**
90
+ * Default model GPT-5.4 (current-generation OpenAI flagship as of
91
+ * 2026-04). Gives brain doctor triage the quality it needs to produce
92
+ * actionable patches, which was the whole motivation for moving off
93
+ * the local ministral-3:3b adapter. Override via
94
+ * `ManduConfig.brain.openai.model` (e.g. set to a cheaper tier for
95
+ * low-stakes automated runs).
96
+ */
97
+ export const OPENAI_DEFAULT_MODEL = "gpt-5.4";
98
+ export const OPENAI_API_BASE = "https://api.openai.com/v1";
99
+
100
+ export const DEFAULT_OPENAI_CONFIG: AdapterConfig = {
101
+ baseUrl: OPENAI_API_BASE,
102
+ model: OPENAI_DEFAULT_MODEL,
103
+ timeout: 60_000,
104
+ };
105
+
106
+ /* -------------------------------------------------------------------- */
107
+ /* Options */
108
+ /* -------------------------------------------------------------------- */
109
+
110
+ export interface OpenAIOAuthAdapterOptions extends Partial<AdapterConfig> {
111
+ /** Injection point — tests supply an in-memory fetch stub. */
112
+ httpClient?: HttpClient;
113
+ /** Injection point — tests swap in a canned endpoint pair (legacy flow only). */
114
+ endpoints?: OAuthEndpoints;
115
+ /** OAuth client id override (for enterprise OpenAI proxies). */
116
+ clientId?: string;
117
+ /** OAuth scope override. */
118
+ scope?: string;
119
+ /** Credential store — default singleton; tests inject an in-memory one. */
120
+ credentialStore?: CredentialStore;
121
+ /** Project root — consent prompts are scoped per-project. */
122
+ projectRoot?: string;
123
+ /** Force-disable the consent prompt (telemetryOptOut path). */
124
+ skipConsent?: boolean;
125
+ /** Consent-prompt deps (stdout, readline) — test injection. */
126
+ consentDeps?: ConsentPromptDeps;
127
+ /**
128
+ * When true, attempted adapter use without a token throws instead of
129
+ * silently returning `available: false`. Used by the CLI `brain login`
130
+ * command to loudly fail if the flow never wrote a token.
131
+ */
132
+ strict?: boolean;
133
+ /**
134
+ * ChatGPT session-token auth helper. Default: reads
135
+ * `~/.codex/auth.json` / `~/.chatgpt-local/auth.json` produced by
136
+ * `npx @openai/codex login`. Tests inject a helper pointed at a
137
+ * throwaway fixture path.
138
+ */
139
+ chatgptAuth?: ChatGPTAuth;
140
+ /**
141
+ * Override: explicit path to the ChatGPT auth.json. Only used when
142
+ * `chatgptAuth` is not supplied.
143
+ */
144
+ chatgptAuthFilePath?: string;
145
+ }
146
+
147
+ /* -------------------------------------------------------------------- */
148
+ /* Adapter */
149
+ /* -------------------------------------------------------------------- */
150
+
151
+ export class OpenAIOAuthAdapter extends BaseLLMAdapter {
152
+ readonly name = "openai-oauth";
153
+ private httpClient: HttpClient;
154
+ private endpoints: OAuthEndpoints;
155
+ private clientId: string;
156
+ private scope: string;
157
+ private credentialStore: CredentialStore;
158
+ private projectRoot: string;
159
+ private skipConsent: boolean;
160
+ private consentDeps?: ConsentPromptDeps;
161
+ private strict: boolean;
162
+ private refreshInFlight: Promise<StoredToken | null> | null = null;
163
+ private chatgptAuth: ChatGPTAuth;
164
+
165
+ constructor(options: OpenAIOAuthAdapterOptions = {}) {
166
+ super({
167
+ ...DEFAULT_OPENAI_CONFIG,
168
+ ...options,
169
+ });
170
+ this.httpClient =
171
+ options.httpClient ?? globalThis.fetch.bind(globalThis);
172
+ this.endpoints = options.endpoints ?? OPENAI_OAUTH_ENDPOINTS;
173
+ this.clientId = options.clientId ?? OPENAI_OAUTH_CLIENT_ID;
174
+ this.scope = options.scope ?? OPENAI_OAUTH_SCOPE;
175
+ this.credentialStore = options.credentialStore ?? getCredentialStore();
176
+ this.projectRoot = options.projectRoot ?? process.cwd();
177
+ this.skipConsent = options.skipConsent ?? false;
178
+ this.consentDeps = options.consentDeps;
179
+ this.strict = options.strict ?? false;
180
+ this.chatgptAuth =
181
+ options.chatgptAuth ??
182
+ new ChatGPTAuth({
183
+ authFilePath: options.chatgptAuthFilePath,
184
+ httpClient: this.httpClient,
185
+ });
186
+ }
187
+
188
+ /* ----------------------- Status / login ---------------------------- */
189
+
190
+ async checkStatus(): Promise<AdapterStatus> {
191
+ // Primary path — ChatGPT session token from `@openai/codex login`.
192
+ if (this.chatgptAuth.isAuthenticated()) {
193
+ return {
194
+ available: true,
195
+ model: this.config.model,
196
+ };
197
+ }
198
+ // Legacy fallback — custom Mandu OAuth app token stored in keychain.
199
+ const token = await this.credentialStore.load("openai");
200
+ if (token) {
201
+ return {
202
+ available: true,
203
+ model: this.config.model,
204
+ };
205
+ }
206
+ return {
207
+ available: false,
208
+ model: null,
209
+ error:
210
+ "No OpenAI OAuth token found. Run `mandu brain login --provider=openai` " +
211
+ "(which wraps `npx @openai/codex login`) first.",
212
+ };
213
+ }
214
+
215
+ /**
216
+ * Run the authorization-code + PKCE flow and persist the token.
217
+ * Returns the stored token shape on success.
218
+ */
219
+ async login(
220
+ opts: {
221
+ onAuthUrl?: (url: string) => void;
222
+ openBrowser?: (url: string) => Promise<void> | void;
223
+ timeoutMs?: number;
224
+ } = {},
225
+ ): Promise<StoredToken> {
226
+ const tokenResponse = await runAuthorizationCodeFlow({
227
+ endpoints: this.endpoints,
228
+ client: { clientId: this.clientId, scope: this.scope },
229
+ httpClient: this.httpClient,
230
+ onAuthUrl: opts.onAuthUrl,
231
+ openBrowser: opts.openBrowser,
232
+ timeoutMs: opts.timeoutMs,
233
+ });
234
+
235
+ const stored: StoredToken = {
236
+ access_token: tokenResponse.access_token,
237
+ refresh_token: tokenResponse.refresh_token,
238
+ expires_at:
239
+ typeof tokenResponse.expires_in === "number"
240
+ ? Math.floor(Date.now() / 1000) + tokenResponse.expires_in
241
+ : undefined,
242
+ scope: tokenResponse.scope ?? this.scope,
243
+ default_model: this.config.model,
244
+ provider: "openai",
245
+ last_used_at: new Date().toISOString(),
246
+ };
247
+ await this.credentialStore.save("openai", stored);
248
+ return stored;
249
+ }
250
+
251
+ /** Delete the stored token. Idempotent. */
252
+ async logout(): Promise<void> {
253
+ await this.credentialStore.delete("openai");
254
+ }
255
+
256
+ /* ------------------------ Completion ------------------------------- */
257
+
258
+ async complete(
259
+ messages: ChatMessage[],
260
+ options: CompletionOptions = {},
261
+ ): Promise<CompletionResult> {
262
+ // Primary: ChatGPT session token (managed by `@openai/codex login`).
263
+ let chatgpt: EffectiveAuth | null = null;
264
+ if (this.chatgptAuth.isAuthenticated()) {
265
+ try {
266
+ chatgpt = await this.chatgptAuth.getAuth();
267
+ } catch {
268
+ chatgpt = null;
269
+ }
270
+ }
271
+ // Legacy keychain fallback only if ChatGPT auth unavailable.
272
+ const token = chatgpt ? null : await this.loadTokenOrReject();
273
+ if (!chatgpt && !token) {
274
+ if (this.strict) {
275
+ throw new Error(
276
+ "OpenAIOAuthAdapter.complete() called without a stored token",
277
+ );
278
+ }
279
+ return emptyCompletion();
280
+ }
281
+
282
+ // Consent prompt skipped when telemetryOptOut is active (the
283
+ // resolver never constructs us in that case) OR when the caller
284
+ // has already vetted consent out of band.
285
+ if (!this.skipConsent) {
286
+ const ok = await ensureConsent(
287
+ {
288
+ projectRoot: this.projectRoot,
289
+ provider: "openai",
290
+ model: this.config.model,
291
+ payloadDescription: describeChatPayload(messages),
292
+ },
293
+ this.consentDeps,
294
+ );
295
+ if (!ok) {
296
+ // User declined Brain must fall back. Return empty so the
297
+ // template path takes over.
298
+ return emptyCompletion();
299
+ }
300
+ }
301
+
302
+ // Redact every message before the prompt leaves the machine.
303
+ const redactedMessages: ChatMessage[] = [];
304
+ const audit: string[] = [];
305
+ for (const m of messages) {
306
+ const { redacted, hits } = redactSecrets(m.content);
307
+ redactedMessages.push({ role: m.role, content: redacted });
308
+ for (const hit of hits) {
309
+ audit.push(
310
+ JSON.stringify({
311
+ ts: new Date().toISOString(),
312
+ provider: "openai",
313
+ model: this.config.model,
314
+ role: m.role,
315
+ kind: hit.kind,
316
+ sample: hit.sample,
317
+ }),
318
+ );
319
+ }
320
+ }
321
+ if (audit.length > 0) {
322
+ await appendRedactionLog(this.projectRoot, audit);
323
+ }
324
+
325
+ // First attempt fresh token.
326
+ let attemptToken = chatgpt ? chatgpt.accessToken : token!.access_token;
327
+ let result = await this.callChatApi(
328
+ attemptToken,
329
+ redactedMessages,
330
+ options,
331
+ );
332
+ if (result.status === 401) {
333
+ if (chatgpt) {
334
+ // ChatGPTAuth auto-refreshes via JWT exp; a 401 here means even
335
+ // the refreshed token was rejected. Force a re-read (which will
336
+ // trigger another refresh if needed) and retry once.
337
+ try {
338
+ const refreshed = await this.chatgptAuth.getAuth();
339
+ attemptToken = refreshed.accessToken;
340
+ result = await this.callChatApi(
341
+ attemptToken,
342
+ redactedMessages,
343
+ options,
344
+ );
345
+ } catch {
346
+ /* fall through to 401 handling */
347
+ }
348
+ } else if (token!.refresh_token) {
349
+ const refreshed = await this.trySilentRefresh(token!);
350
+ if (refreshed) {
351
+ attemptToken = refreshed.access_token;
352
+ result = await this.callChatApi(
353
+ attemptToken,
354
+ redactedMessages,
355
+ options,
356
+ );
357
+ }
358
+ }
359
+ }
360
+ if (result.status === 401) {
361
+ if (!chatgpt) {
362
+ // Persistent auth failure on the legacy keychain path scrub so
363
+ // subsequent runs skip to the next resolver tier. For the
364
+ // ChatGPTAuth path we leave auth.json alone (the user re-runs
365
+ // `codex login`; we must not race their session).
366
+ await this.credentialStore.delete("openai");
367
+ }
368
+ return emptyCompletion();
369
+ }
370
+ if (!result.ok) {
371
+ throw new Error(
372
+ `OpenAI request failed (${result.status}): ${result.bodySnippet}`,
373
+ );
374
+ }
375
+ if (!chatgpt) await this.credentialStore.touch("openai");
376
+ return result.completion;
377
+ }
378
+
379
+ private async loadTokenOrReject(): Promise<StoredToken | null> {
380
+ const token = await this.credentialStore.load("openai");
381
+ return token ?? null;
382
+ }
383
+
384
+ private async trySilentRefresh(
385
+ existing: StoredToken,
386
+ ): Promise<StoredToken | null> {
387
+ if (!existing.refresh_token) return null;
388
+ if (this.refreshInFlight) return this.refreshInFlight;
389
+ this.refreshInFlight = (async () => {
390
+ try {
391
+ const refreshed = await refreshAccessToken({
392
+ endpoints: this.endpoints,
393
+ clientId: this.clientId,
394
+ refreshToken: existing.refresh_token!,
395
+ httpClient: this.httpClient,
396
+ scope: existing.scope ?? this.scope,
397
+ });
398
+ const stored: StoredToken = {
399
+ access_token: refreshed.access_token,
400
+ refresh_token: refreshed.refresh_token ?? existing.refresh_token,
401
+ expires_at:
402
+ typeof refreshed.expires_in === "number"
403
+ ? Math.floor(Date.now() / 1000) + refreshed.expires_in
404
+ : undefined,
405
+ scope: refreshed.scope ?? existing.scope,
406
+ default_model: existing.default_model,
407
+ provider: "openai",
408
+ last_used_at: new Date().toISOString(),
409
+ };
410
+ await this.credentialStore.save("openai", stored);
411
+ return stored;
412
+ } catch {
413
+ return null;
414
+ } finally {
415
+ this.refreshInFlight = null;
416
+ }
417
+ })();
418
+ return this.refreshInFlight;
419
+ }
420
+
421
+ private async callChatApi(
422
+ accessToken: string,
423
+ messages: ChatMessage[],
424
+ options: CompletionOptions,
425
+ ): Promise<
426
+ | { ok: true; status: number; completion: CompletionResult }
427
+ | { ok: false; status: number; bodySnippet: string; completion: CompletionResult }
428
+ > {
429
+ const body = {
430
+ model: this.config.model,
431
+ messages: messages.map((m) => ({ role: m.role, content: m.content })),
432
+ temperature: options.temperature ?? 0.2,
433
+ max_tokens: options.maxTokens ?? 2048,
434
+ stop: options.stop,
435
+ };
436
+
437
+ const res = await this.httpClient(`${this.baseUrl}/chat/completions`, {
438
+ method: "POST",
439
+ headers: {
440
+ authorization: `Bearer ${accessToken}`,
441
+ "content-type": "application/json",
442
+ accept: "application/json",
443
+ },
444
+ body: JSON.stringify(body),
445
+ });
446
+
447
+ if (!res.ok) {
448
+ const txt = await res.text().catch(() => "");
449
+ return {
450
+ ok: false,
451
+ status: res.status,
452
+ bodySnippet: txt.slice(0, 256),
453
+ completion: emptyCompletion(),
454
+ };
455
+ }
456
+
457
+ const json = (await res.json()) as {
458
+ choices?: Array<{ message?: { content?: string } }>;
459
+ usage?: {
460
+ prompt_tokens?: number;
461
+ completion_tokens?: number;
462
+ total_tokens?: number;
463
+ };
464
+ };
465
+ const content = json.choices?.[0]?.message?.content ?? "";
466
+ return {
467
+ ok: true,
468
+ status: res.status,
469
+ completion: {
470
+ content,
471
+ usage: {
472
+ promptTokens: json.usage?.prompt_tokens ?? 0,
473
+ completionTokens: json.usage?.completion_tokens ?? 0,
474
+ totalTokens: json.usage?.total_tokens ?? 0,
475
+ },
476
+ },
477
+ };
478
+ }
479
+ }
480
+
481
+ /* -------------------------------------------------------------------- */
482
+ /* Helpers */
483
+ /* -------------------------------------------------------------------- */
484
+
485
+ function emptyCompletion(): CompletionResult {
486
+ return {
487
+ content: "",
488
+ usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
489
+ };
490
+ }
491
+
492
+ /**
493
+ * One-line human summary of the chat payload for the consent prompt.
494
+ *
495
+ * Not exported Anthropic has its own copy to avoid a cross-module
496
+ * name collision in the adapters barrel. Keep implementations in sync
497
+ * if the format changes.
498
+ */
499
+ function describeChatPayload(messages: ChatMessage[]): string {
500
+ const totalChars = messages.reduce((a, m) => a + m.content.length, 0);
501
+ const roleCounts = new Map<string, number>();
502
+ for (const m of messages) {
503
+ roleCounts.set(m.role, (roleCounts.get(m.role) ?? 0) + 1);
504
+ }
505
+ const roleSummary = [...roleCounts.entries()]
506
+ .map(([r, n]) => `${n} ${r}`)
507
+ .join(", ");
508
+ return `${messages.length} messages (${roleSummary}), ~${totalChars} chars`;
509
+ }
510
+
511
+ /**
512
+ * Append redaction audit entries as JSON-lines to
513
+ * `<projectRoot>/.mandu/brain-redactions.jsonl`. Best-effort an IO
514
+ * error here must not block the actual adapter request.
515
+ */
516
+ async function appendRedactionLog(
517
+ projectRoot: string,
518
+ entries: string[],
519
+ ): Promise<void> {
520
+ try {
521
+ const dir = path.join(projectRoot, ".mandu");
522
+ await fs.mkdir(dir, { recursive: true });
523
+ const file = path.join(dir, "brain-redactions.jsonl");
524
+ await fs.appendFile(file, `${entries.join("\n")}\n`, { mode: 0o600 });
525
+ } catch {
526
+ /* best-effort */
527
+ }
528
+ }
529
+
530
+ export function createOpenAIOAuthAdapter(
531
+ options: OpenAIOAuthAdapterOptions = {},
532
+ ): OpenAIOAuthAdapter {
533
+ return new OpenAIOAuthAdapter(options);
534
+ }