@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.
- package/package.json +21 -4
- package/src/auth/__tests__/login.test.ts +420 -419
- package/src/auth/__tests__/reset.test.ts +296 -296
- package/src/brain/adapters/anthropic-oauth.ts +421 -420
- package/src/brain/adapters/index.ts +2 -1
- package/src/brain/adapters/ollama.ts +1 -1
- package/src/brain/adapters/openai-oauth.ts +534 -533
- package/src/brain/brain.ts +2 -1
- package/src/brain/redactor.ts +196 -196
- package/src/bundler/__tests__/cli-bench-utils.test.ts +149 -149
- package/src/bundler/__tests__/cold-start.test.ts +504 -504
- package/src/bundler/__tests__/fast-refresh.test.ts +607 -606
- package/src/bundler/__tests__/hdr.test.ts +1 -1
- package/src/bundler/analyzer.ts +958 -958
- package/src/bundler/build.ts +104 -14
- package/src/bundler/dev.ts +125 -0
- package/src/bundler/hmr-types.ts +1 -0
- package/src/bundler/plugins/__tests__/react-compiler-lint.test.ts +110 -0
- package/src/bundler/plugins/index.ts +14 -0
- package/src/bundler/plugins/react-compiler-lint.ts +253 -0
- package/src/bundler/plugins/react-compiler.ts +162 -0
- package/src/bundler/types.ts +12 -0
- package/src/change/integrity.ts +2 -1
- package/src/client/index.ts +10 -0
- package/src/client/island.ts +38 -11
- package/src/client/router.ts +6 -1
- package/src/config/mandu.ts +57 -0
- package/src/config/validate.ts +42 -0
- package/src/content/collection.ts +844 -809
- package/src/content/content-layer.ts +316 -314
- package/src/content/content.test.ts +433 -433
- package/src/content/digest.ts +133 -133
- package/src/content/generate-types.ts +168 -168
- package/src/content/index.ts +6 -1
- package/src/content/llms-txt.ts +277 -277
- package/src/contract/define.ts +474 -474
- package/src/contract/route-helpers.ts +2 -1
- package/src/contract/zod-utils.ts +158 -155
- package/src/db/index.ts +513 -513
- package/src/desktop/__tests__/smoke.test.ts +100 -100
- package/src/desktop/webview-fallback.ts +583 -583
- package/src/desktop/window.ts +3 -1
- package/src/dev-error-overlay/overlay-client.ts +300 -300
- package/src/devtools/ai/mcp-connector.ts +499 -498
- package/src/devtools/client/components/kitchen-root.tsx +7 -2
- package/src/email/resend.ts +163 -163
- package/src/guard/__tests__/tsgolint-bridge.test.ts +347 -0
- package/src/guard/ast-analyzer.ts +806 -806
- package/src/guard/graph.ts +898 -898
- package/src/guard/index.ts +16 -0
- package/src/guard/statistics.ts +578 -578
- package/src/guard/tsgolint-bridge.ts +512 -0
- package/src/i18n/locale-resolver.ts +214 -214
- package/src/id/__tests__/id.test.ts +120 -120
- package/src/intent/index.ts +321 -321
- package/src/island/index.ts +39 -23
- package/src/kitchen/api/contract-api.ts +15 -8
- package/src/kitchen/kitchen-ui.ts +2137 -2137
- package/src/lockfile/index.ts +3 -2
- package/src/middleware/oauth/__tests__/oauth.test.ts +575 -574
- package/src/middleware/rate-limit/__tests__/rate-limit.test.ts +642 -642
- package/src/middleware/secure/index.ts +417 -417
- package/src/observability/event-bus.ts +2 -2
- package/src/observability/metrics.ts +334 -334
- package/src/observability/tracing.ts +694 -694
- package/src/openapi/generator.ts +1 -1
- package/src/perf/user-marks.ts +553 -553
- package/src/plugins/registry.ts +387 -387
- package/src/resource/ddl/diff.ts +392 -392
- package/src/resource/ddl/snapshot.ts +448 -447
- package/src/resource/generator-schema.ts +477 -476
- package/src/resource/parser.ts +4 -2
- package/src/resource/schema.ts +1 -1
- package/src/router/fs-patterns.ts +422 -422
- package/src/runtime/fast-refresh-types.ts +126 -128
- package/src/runtime/image-handler.ts +206 -195
- package/src/runtime/router.test.ts +476 -476
- package/src/runtime/security.ts +155 -155
- package/src/runtime/server.ts +36 -19
- package/src/runtime/session-key.ts +328 -328
- package/src/scheduler/__tests__/scheduler.test.ts +514 -514
- package/src/seo/resolve/index.ts +353 -353
- package/src/spec/load.ts +1 -1
- package/src/testing/reporter.ts +676 -676
- package/src/testing/server.ts +196 -196
- package/src/testing/snapshot.ts +444 -444
- package/src/utils/__tests__/lru-cache.test.ts +186 -186
- 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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
import {
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
type
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
/*
|
|
67
|
-
/*
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
export const
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
* the
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
export const
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
/*
|
|
107
|
-
/*
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
scope
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
*
|
|
129
|
-
*
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
*
|
|
135
|
-
* `
|
|
136
|
-
*
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
*
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
/*
|
|
148
|
-
/*
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
private
|
|
154
|
-
private
|
|
155
|
-
private
|
|
156
|
-
private
|
|
157
|
-
private
|
|
158
|
-
private
|
|
159
|
-
private
|
|
160
|
-
private
|
|
161
|
-
private
|
|
162
|
-
private
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
...
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
this.
|
|
173
|
-
this.
|
|
174
|
-
this.
|
|
175
|
-
this.
|
|
176
|
-
this.
|
|
177
|
-
this.
|
|
178
|
-
this.
|
|
179
|
-
this.
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
"
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
*
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
//
|
|
283
|
-
//
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
//
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
const
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
let
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
//
|
|
335
|
-
//
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
//
|
|
363
|
-
//
|
|
364
|
-
//
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
if (
|
|
388
|
-
this.refreshInFlight
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
| { ok:
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
}
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
/*
|
|
482
|
-
/*
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
}
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
*
|
|
494
|
-
*
|
|
495
|
-
*
|
|
496
|
-
*
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
const
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
.
|
|
507
|
-
|
|
508
|
-
}
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
*
|
|
513
|
-
*
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
}
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
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
|
+
}
|