@mandujs/core 0.39.3 → 0.40.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.
- package/package.json +1 -1
- package/src/brain/__tests__/redactor.test.ts +94 -0
- package/src/brain/adapters/__tests__/_helpers.ts +64 -0
- package/src/brain/adapters/__tests__/anthropic-oauth.test.ts +196 -0
- package/src/brain/adapters/__tests__/openai-oauth.test.ts +202 -0
- package/src/brain/adapters/__tests__/resolver.test.ts +122 -0
- package/src/brain/adapters/anthropic-oauth.ts +420 -0
- package/src/brain/adapters/index.ts +290 -2
- package/src/brain/adapters/oauth-flow.ts +439 -0
- package/src/brain/adapters/openai-oauth.ts +463 -0
- package/src/brain/consent.ts +240 -0
- package/src/brain/credentials.ts +396 -0
- package/src/brain/index.ts +39 -1
- package/src/brain/redactor.ts +196 -0
- package/src/config/mandu.ts +39 -0
- package/src/config/validate.ts +49 -0
|
@@ -0,0 +1,463 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Brain — OpenAI OAuth adapter (Issue #235).
|
|
3
|
+
*
|
|
4
|
+
* Connects to the OpenAI Chat Completions API using a token obtained
|
|
5
|
+
* via OAuth authorization code + PKCE. Mandu never owns an OpenAI API
|
|
6
|
+
* key — the user's OAuth credentials are loaded from the OS keychain
|
|
7
|
+
* (`packages/core/src/brain/credentials.ts`) and forwarded on each
|
|
8
|
+
* request.
|
|
9
|
+
*
|
|
10
|
+
* Failure modes handled here:
|
|
11
|
+
* - Missing token → adapter reports `available: false`, the
|
|
12
|
+
* resolver falls to the next tier.
|
|
13
|
+
* - 401 on complete() → one silent refresh attempt. On repeat
|
|
14
|
+
* failure the token is deleted and the
|
|
15
|
+
* adapter returns an empty completion,
|
|
16
|
+
* letting Brain fall back to template.
|
|
17
|
+
* - Network / 5xx → surfaced as an Error (isolated by Brain
|
|
18
|
+
* via `isolatedBrainExecution`).
|
|
19
|
+
*
|
|
20
|
+
* Redaction is applied to every prompt BEFORE it hits `fetch`. Redacted
|
|
21
|
+
* hits are appended to `<projectRoot>/.mandu/brain-redactions.jsonl`
|
|
22
|
+
* for user audit. A consent prompt runs on the first cloud call per
|
|
23
|
+
* (provider, project).
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { promises as fs } from "node:fs";
|
|
27
|
+
import path from "node:path";
|
|
28
|
+
|
|
29
|
+
import { BaseLLMAdapter } from "./base";
|
|
30
|
+
import type {
|
|
31
|
+
AdapterConfig,
|
|
32
|
+
AdapterStatus,
|
|
33
|
+
ChatMessage,
|
|
34
|
+
CompletionOptions,
|
|
35
|
+
CompletionResult,
|
|
36
|
+
} from "../types";
|
|
37
|
+
import {
|
|
38
|
+
CredentialStore,
|
|
39
|
+
getCredentialStore,
|
|
40
|
+
type StoredToken,
|
|
41
|
+
} from "../credentials";
|
|
42
|
+
import {
|
|
43
|
+
ensureConsent,
|
|
44
|
+
type ConsentPromptDeps,
|
|
45
|
+
} from "../consent";
|
|
46
|
+
import { redactSecrets } from "../redactor";
|
|
47
|
+
import {
|
|
48
|
+
refreshAccessToken,
|
|
49
|
+
runAuthorizationCodeFlow,
|
|
50
|
+
type HttpClient,
|
|
51
|
+
type OAuthEndpoints,
|
|
52
|
+
} from "./oauth-flow";
|
|
53
|
+
|
|
54
|
+
/* -------------------------------------------------------------------- */
|
|
55
|
+
/* Defaults */
|
|
56
|
+
/* -------------------------------------------------------------------- */
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* OpenAI OAuth endpoints. These mirror the ChatGPT developer OAuth
|
|
60
|
+
* surface documented at platform.openai.com/oauth. Tests + CI override
|
|
61
|
+
* via `options.endpoints` so this module never dials the real host in
|
|
62
|
+
* unit tests.
|
|
63
|
+
*/
|
|
64
|
+
export const OPENAI_OAUTH_ENDPOINTS: OAuthEndpoints = {
|
|
65
|
+
authorizationUrl: "https://platform.openai.com/oauth/authorize",
|
|
66
|
+
tokenUrl: "https://platform.openai.com/oauth/token",
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* OpenAI OAuth public client id. Registered by Mandu; no secret is
|
|
71
|
+
* required (PKCE covers the exchange). The client id is not sensitive;
|
|
72
|
+
* it is already embedded in every authorization URL the user clicks.
|
|
73
|
+
*/
|
|
74
|
+
export const OPENAI_OAUTH_CLIENT_ID = "mandu-brain-cli";
|
|
75
|
+
export const OPENAI_OAUTH_SCOPE = "openai.chat";
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Default model — GPT-5.4 (current-generation OpenAI flagship as of
|
|
79
|
+
* 2026-04). Gives brain doctor triage the quality it needs to produce
|
|
80
|
+
* actionable patches, which was the whole motivation for moving off
|
|
81
|
+
* the local ministral-3:3b adapter. Override via
|
|
82
|
+
* `ManduConfig.brain.openai.model` (e.g. set to a cheaper tier for
|
|
83
|
+
* low-stakes automated runs).
|
|
84
|
+
*/
|
|
85
|
+
export const OPENAI_DEFAULT_MODEL = "gpt-5.4";
|
|
86
|
+
export const OPENAI_API_BASE = "https://api.openai.com/v1";
|
|
87
|
+
|
|
88
|
+
export const DEFAULT_OPENAI_CONFIG: AdapterConfig = {
|
|
89
|
+
baseUrl: OPENAI_API_BASE,
|
|
90
|
+
model: OPENAI_DEFAULT_MODEL,
|
|
91
|
+
timeout: 60_000,
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
/* -------------------------------------------------------------------- */
|
|
95
|
+
/* Options */
|
|
96
|
+
/* -------------------------------------------------------------------- */
|
|
97
|
+
|
|
98
|
+
export interface OpenAIOAuthAdapterOptions extends Partial<AdapterConfig> {
|
|
99
|
+
/** Injection point — tests supply an in-memory fetch stub. */
|
|
100
|
+
httpClient?: HttpClient;
|
|
101
|
+
/** Injection point — tests swap in a canned endpoint pair. */
|
|
102
|
+
endpoints?: OAuthEndpoints;
|
|
103
|
+
/** OAuth client id override (for enterprise OpenAI proxies). */
|
|
104
|
+
clientId?: string;
|
|
105
|
+
/** OAuth scope override. */
|
|
106
|
+
scope?: string;
|
|
107
|
+
/** Credential store — default singleton; tests inject an in-memory one. */
|
|
108
|
+
credentialStore?: CredentialStore;
|
|
109
|
+
/** Project root — consent prompts are scoped per-project. */
|
|
110
|
+
projectRoot?: string;
|
|
111
|
+
/** Force-disable the consent prompt (telemetryOptOut path). */
|
|
112
|
+
skipConsent?: boolean;
|
|
113
|
+
/** Consent-prompt deps (stdout, readline) — test injection. */
|
|
114
|
+
consentDeps?: ConsentPromptDeps;
|
|
115
|
+
/**
|
|
116
|
+
* When true, attempted adapter use without a token throws instead of
|
|
117
|
+
* silently returning `available: false`. Used by the CLI `brain login`
|
|
118
|
+
* command to loudly fail if the flow never wrote a token.
|
|
119
|
+
*/
|
|
120
|
+
strict?: boolean;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/* -------------------------------------------------------------------- */
|
|
124
|
+
/* Adapter */
|
|
125
|
+
/* -------------------------------------------------------------------- */
|
|
126
|
+
|
|
127
|
+
export class OpenAIOAuthAdapter extends BaseLLMAdapter {
|
|
128
|
+
readonly name = "openai-oauth";
|
|
129
|
+
private httpClient: HttpClient;
|
|
130
|
+
private endpoints: OAuthEndpoints;
|
|
131
|
+
private clientId: string;
|
|
132
|
+
private scope: string;
|
|
133
|
+
private credentialStore: CredentialStore;
|
|
134
|
+
private projectRoot: string;
|
|
135
|
+
private skipConsent: boolean;
|
|
136
|
+
private consentDeps?: ConsentPromptDeps;
|
|
137
|
+
private strict: boolean;
|
|
138
|
+
private refreshInFlight: Promise<StoredToken | null> | null = null;
|
|
139
|
+
|
|
140
|
+
constructor(options: OpenAIOAuthAdapterOptions = {}) {
|
|
141
|
+
super({
|
|
142
|
+
...DEFAULT_OPENAI_CONFIG,
|
|
143
|
+
...options,
|
|
144
|
+
});
|
|
145
|
+
this.httpClient =
|
|
146
|
+
options.httpClient ?? globalThis.fetch.bind(globalThis);
|
|
147
|
+
this.endpoints = options.endpoints ?? OPENAI_OAUTH_ENDPOINTS;
|
|
148
|
+
this.clientId = options.clientId ?? OPENAI_OAUTH_CLIENT_ID;
|
|
149
|
+
this.scope = options.scope ?? OPENAI_OAUTH_SCOPE;
|
|
150
|
+
this.credentialStore = options.credentialStore ?? getCredentialStore();
|
|
151
|
+
this.projectRoot = options.projectRoot ?? process.cwd();
|
|
152
|
+
this.skipConsent = options.skipConsent ?? false;
|
|
153
|
+
this.consentDeps = options.consentDeps;
|
|
154
|
+
this.strict = options.strict ?? false;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/* ----------------------- Status / login ---------------------------- */
|
|
158
|
+
|
|
159
|
+
async checkStatus(): Promise<AdapterStatus> {
|
|
160
|
+
const token = await this.credentialStore.load("openai");
|
|
161
|
+
if (!token) {
|
|
162
|
+
return {
|
|
163
|
+
available: false,
|
|
164
|
+
model: null,
|
|
165
|
+
error:
|
|
166
|
+
"No OpenAI OAuth token stored. Run `mandu brain login --provider=openai` first.",
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
return {
|
|
170
|
+
available: true,
|
|
171
|
+
model: this.config.model,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Run the authorization-code + PKCE flow and persist the token.
|
|
177
|
+
* Returns the stored token shape on success.
|
|
178
|
+
*/
|
|
179
|
+
async login(
|
|
180
|
+
opts: {
|
|
181
|
+
onAuthUrl?: (url: string) => void;
|
|
182
|
+
openBrowser?: (url: string) => Promise<void> | void;
|
|
183
|
+
timeoutMs?: number;
|
|
184
|
+
} = {},
|
|
185
|
+
): Promise<StoredToken> {
|
|
186
|
+
const tokenResponse = await runAuthorizationCodeFlow({
|
|
187
|
+
endpoints: this.endpoints,
|
|
188
|
+
client: { clientId: this.clientId, scope: this.scope },
|
|
189
|
+
httpClient: this.httpClient,
|
|
190
|
+
onAuthUrl: opts.onAuthUrl,
|
|
191
|
+
openBrowser: opts.openBrowser,
|
|
192
|
+
timeoutMs: opts.timeoutMs,
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
const stored: StoredToken = {
|
|
196
|
+
access_token: tokenResponse.access_token,
|
|
197
|
+
refresh_token: tokenResponse.refresh_token,
|
|
198
|
+
expires_at:
|
|
199
|
+
typeof tokenResponse.expires_in === "number"
|
|
200
|
+
? Math.floor(Date.now() / 1000) + tokenResponse.expires_in
|
|
201
|
+
: undefined,
|
|
202
|
+
scope: tokenResponse.scope ?? this.scope,
|
|
203
|
+
default_model: this.config.model,
|
|
204
|
+
provider: "openai",
|
|
205
|
+
last_used_at: new Date().toISOString(),
|
|
206
|
+
};
|
|
207
|
+
await this.credentialStore.save("openai", stored);
|
|
208
|
+
return stored;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Delete the stored token. Idempotent. */
|
|
212
|
+
async logout(): Promise<void> {
|
|
213
|
+
await this.credentialStore.delete("openai");
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/* ------------------------ Completion ------------------------------- */
|
|
217
|
+
|
|
218
|
+
async complete(
|
|
219
|
+
messages: ChatMessage[],
|
|
220
|
+
options: CompletionOptions = {},
|
|
221
|
+
): Promise<CompletionResult> {
|
|
222
|
+
const token = await this.loadTokenOrReject();
|
|
223
|
+
if (!token) {
|
|
224
|
+
if (this.strict) {
|
|
225
|
+
throw new Error(
|
|
226
|
+
"OpenAIOAuthAdapter.complete() called without a stored token",
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
return emptyCompletion();
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Consent prompt — skipped when telemetryOptOut is active (the
|
|
233
|
+
// resolver never constructs us in that case) OR when the caller
|
|
234
|
+
// has already vetted consent out of band.
|
|
235
|
+
if (!this.skipConsent) {
|
|
236
|
+
const ok = await ensureConsent(
|
|
237
|
+
{
|
|
238
|
+
projectRoot: this.projectRoot,
|
|
239
|
+
provider: "openai",
|
|
240
|
+
model: this.config.model,
|
|
241
|
+
payloadDescription: describeChatPayload(messages),
|
|
242
|
+
},
|
|
243
|
+
this.consentDeps,
|
|
244
|
+
);
|
|
245
|
+
if (!ok) {
|
|
246
|
+
// User declined — Brain must fall back. Return empty so the
|
|
247
|
+
// template path takes over.
|
|
248
|
+
return emptyCompletion();
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Redact every message before the prompt leaves the machine.
|
|
253
|
+
const redactedMessages: ChatMessage[] = [];
|
|
254
|
+
const audit: string[] = [];
|
|
255
|
+
for (const m of messages) {
|
|
256
|
+
const { redacted, hits } = redactSecrets(m.content);
|
|
257
|
+
redactedMessages.push({ role: m.role, content: redacted });
|
|
258
|
+
for (const hit of hits) {
|
|
259
|
+
audit.push(
|
|
260
|
+
JSON.stringify({
|
|
261
|
+
ts: new Date().toISOString(),
|
|
262
|
+
provider: "openai",
|
|
263
|
+
model: this.config.model,
|
|
264
|
+
role: m.role,
|
|
265
|
+
kind: hit.kind,
|
|
266
|
+
sample: hit.sample,
|
|
267
|
+
}),
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
if (audit.length > 0) {
|
|
272
|
+
await appendRedactionLog(this.projectRoot, audit);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// First attempt — fresh token.
|
|
276
|
+
let attemptToken = token.access_token;
|
|
277
|
+
let result = await this.callChatApi(
|
|
278
|
+
attemptToken,
|
|
279
|
+
redactedMessages,
|
|
280
|
+
options,
|
|
281
|
+
);
|
|
282
|
+
if (result.status === 401 && token.refresh_token) {
|
|
283
|
+
const refreshed = await this.trySilentRefresh(token);
|
|
284
|
+
if (refreshed) {
|
|
285
|
+
attemptToken = refreshed.access_token;
|
|
286
|
+
result = await this.callChatApi(
|
|
287
|
+
attemptToken,
|
|
288
|
+
redactedMessages,
|
|
289
|
+
options,
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
if (result.status === 401) {
|
|
294
|
+
// Persistent auth failure — scrub the token so subsequent runs
|
|
295
|
+
// skip straight to the next resolver tier.
|
|
296
|
+
await this.credentialStore.delete("openai");
|
|
297
|
+
return emptyCompletion();
|
|
298
|
+
}
|
|
299
|
+
if (!result.ok) {
|
|
300
|
+
throw new Error(
|
|
301
|
+
`OpenAI request failed (${result.status}): ${result.bodySnippet}`,
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
await this.credentialStore.touch("openai");
|
|
305
|
+
return result.completion;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
private async loadTokenOrReject(): Promise<StoredToken | null> {
|
|
309
|
+
const token = await this.credentialStore.load("openai");
|
|
310
|
+
return token ?? null;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
private async trySilentRefresh(
|
|
314
|
+
existing: StoredToken,
|
|
315
|
+
): Promise<StoredToken | null> {
|
|
316
|
+
if (!existing.refresh_token) return null;
|
|
317
|
+
if (this.refreshInFlight) return this.refreshInFlight;
|
|
318
|
+
this.refreshInFlight = (async () => {
|
|
319
|
+
try {
|
|
320
|
+
const refreshed = await refreshAccessToken({
|
|
321
|
+
endpoints: this.endpoints,
|
|
322
|
+
clientId: this.clientId,
|
|
323
|
+
refreshToken: existing.refresh_token!,
|
|
324
|
+
httpClient: this.httpClient,
|
|
325
|
+
scope: existing.scope ?? this.scope,
|
|
326
|
+
});
|
|
327
|
+
const stored: StoredToken = {
|
|
328
|
+
access_token: refreshed.access_token,
|
|
329
|
+
refresh_token: refreshed.refresh_token ?? existing.refresh_token,
|
|
330
|
+
expires_at:
|
|
331
|
+
typeof refreshed.expires_in === "number"
|
|
332
|
+
? Math.floor(Date.now() / 1000) + refreshed.expires_in
|
|
333
|
+
: undefined,
|
|
334
|
+
scope: refreshed.scope ?? existing.scope,
|
|
335
|
+
default_model: existing.default_model,
|
|
336
|
+
provider: "openai",
|
|
337
|
+
last_used_at: new Date().toISOString(),
|
|
338
|
+
};
|
|
339
|
+
await this.credentialStore.save("openai", stored);
|
|
340
|
+
return stored;
|
|
341
|
+
} catch {
|
|
342
|
+
return null;
|
|
343
|
+
} finally {
|
|
344
|
+
this.refreshInFlight = null;
|
|
345
|
+
}
|
|
346
|
+
})();
|
|
347
|
+
return this.refreshInFlight;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
private async callChatApi(
|
|
351
|
+
accessToken: string,
|
|
352
|
+
messages: ChatMessage[],
|
|
353
|
+
options: CompletionOptions,
|
|
354
|
+
): Promise<
|
|
355
|
+
| { ok: true; status: number; completion: CompletionResult }
|
|
356
|
+
| { ok: false; status: number; bodySnippet: string; completion: CompletionResult }
|
|
357
|
+
> {
|
|
358
|
+
const body = {
|
|
359
|
+
model: this.config.model,
|
|
360
|
+
messages: messages.map((m) => ({ role: m.role, content: m.content })),
|
|
361
|
+
temperature: options.temperature ?? 0.2,
|
|
362
|
+
max_tokens: options.maxTokens ?? 2048,
|
|
363
|
+
stop: options.stop,
|
|
364
|
+
};
|
|
365
|
+
|
|
366
|
+
const res = await this.httpClient(`${this.baseUrl}/chat/completions`, {
|
|
367
|
+
method: "POST",
|
|
368
|
+
headers: {
|
|
369
|
+
authorization: `Bearer ${accessToken}`,
|
|
370
|
+
"content-type": "application/json",
|
|
371
|
+
accept: "application/json",
|
|
372
|
+
},
|
|
373
|
+
body: JSON.stringify(body),
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
if (!res.ok) {
|
|
377
|
+
const txt = await res.text().catch(() => "");
|
|
378
|
+
return {
|
|
379
|
+
ok: false,
|
|
380
|
+
status: res.status,
|
|
381
|
+
bodySnippet: txt.slice(0, 256),
|
|
382
|
+
completion: emptyCompletion(),
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
const json = (await res.json()) as {
|
|
387
|
+
choices?: Array<{ message?: { content?: string } }>;
|
|
388
|
+
usage?: {
|
|
389
|
+
prompt_tokens?: number;
|
|
390
|
+
completion_tokens?: number;
|
|
391
|
+
total_tokens?: number;
|
|
392
|
+
};
|
|
393
|
+
};
|
|
394
|
+
const content = json.choices?.[0]?.message?.content ?? "";
|
|
395
|
+
return {
|
|
396
|
+
ok: true,
|
|
397
|
+
status: res.status,
|
|
398
|
+
completion: {
|
|
399
|
+
content,
|
|
400
|
+
usage: {
|
|
401
|
+
promptTokens: json.usage?.prompt_tokens ?? 0,
|
|
402
|
+
completionTokens: json.usage?.completion_tokens ?? 0,
|
|
403
|
+
totalTokens: json.usage?.total_tokens ?? 0,
|
|
404
|
+
},
|
|
405
|
+
},
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/* -------------------------------------------------------------------- */
|
|
411
|
+
/* Helpers */
|
|
412
|
+
/* -------------------------------------------------------------------- */
|
|
413
|
+
|
|
414
|
+
function emptyCompletion(): CompletionResult {
|
|
415
|
+
return {
|
|
416
|
+
content: "",
|
|
417
|
+
usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* One-line human summary of the chat payload for the consent prompt.
|
|
423
|
+
*
|
|
424
|
+
* Not exported — Anthropic has its own copy to avoid a cross-module
|
|
425
|
+
* name collision in the adapters barrel. Keep implementations in sync
|
|
426
|
+
* if the format changes.
|
|
427
|
+
*/
|
|
428
|
+
function describeChatPayload(messages: ChatMessage[]): string {
|
|
429
|
+
const totalChars = messages.reduce((a, m) => a + m.content.length, 0);
|
|
430
|
+
const roleCounts = new Map<string, number>();
|
|
431
|
+
for (const m of messages) {
|
|
432
|
+
roleCounts.set(m.role, (roleCounts.get(m.role) ?? 0) + 1);
|
|
433
|
+
}
|
|
434
|
+
const roleSummary = [...roleCounts.entries()]
|
|
435
|
+
.map(([r, n]) => `${n} ${r}`)
|
|
436
|
+
.join(", ");
|
|
437
|
+
return `${messages.length} messages (${roleSummary}), ~${totalChars} chars`;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* Append redaction audit entries as JSON-lines to
|
|
442
|
+
* `<projectRoot>/.mandu/brain-redactions.jsonl`. Best-effort — an IO
|
|
443
|
+
* error here must not block the actual adapter request.
|
|
444
|
+
*/
|
|
445
|
+
async function appendRedactionLog(
|
|
446
|
+
projectRoot: string,
|
|
447
|
+
entries: string[],
|
|
448
|
+
): Promise<void> {
|
|
449
|
+
try {
|
|
450
|
+
const dir = path.join(projectRoot, ".mandu");
|
|
451
|
+
await fs.mkdir(dir, { recursive: true });
|
|
452
|
+
const file = path.join(dir, "brain-redactions.jsonl");
|
|
453
|
+
await fs.appendFile(file, `${entries.join("\n")}\n`, { mode: 0o600 });
|
|
454
|
+
} catch {
|
|
455
|
+
/* best-effort */
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
export function createOpenAIOAuthAdapter(
|
|
460
|
+
options: OpenAIOAuthAdapterOptions = {},
|
|
461
|
+
): OpenAIOAuthAdapter {
|
|
462
|
+
return new OpenAIOAuthAdapter(options);
|
|
463
|
+
}
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Brain — first-use consent prompt + cache (Issue #235).
|
|
3
|
+
*
|
|
4
|
+
* On the first `brain_doctor --useLLM` call that would dispatch to a
|
|
5
|
+
* cloud adapter, Mandu prints a one-line summary of what will be
|
|
6
|
+
* transmitted (diff excerpt, violation report shape, target model) and
|
|
7
|
+
* prompts `y/N`. Consent is cached per-provider, per-project at
|
|
8
|
+
* `~/.mandu/brain-consent.json` so the prompt only appears once.
|
|
9
|
+
*
|
|
10
|
+
* The CI escape hatch is `MANDU_BRAIN_AUTO_CONSENT=1` — used by
|
|
11
|
+
* non-interactive pipelines that have already reviewed the data policy
|
|
12
|
+
* out of band.
|
|
13
|
+
*
|
|
14
|
+
* Privacy invariants:
|
|
15
|
+
* - If consent is not granted, the adapter MUST fall through to the
|
|
16
|
+
* next tier in the resolver (ollama → template).
|
|
17
|
+
* - `telemetryOptOut: true` in config bypasses this module entirely —
|
|
18
|
+
* cloud adapters are never constructed in that case.
|
|
19
|
+
* - The consent cache only stores `{ providerId, projectFingerprint,
|
|
20
|
+
* grantedAt }`; no prompt content ever reaches this file.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { promises as fs } from "node:fs";
|
|
24
|
+
import os from "node:os";
|
|
25
|
+
import path from "node:path";
|
|
26
|
+
import { createHash } from "node:crypto";
|
|
27
|
+
|
|
28
|
+
const CONSENT_DIR = path.join(os.homedir(), ".mandu");
|
|
29
|
+
const CONSENT_FILE = path.join(CONSENT_DIR, "brain-consent.json");
|
|
30
|
+
|
|
31
|
+
export type ConsentProvider = "openai" | "anthropic";
|
|
32
|
+
|
|
33
|
+
export interface ConsentEntry {
|
|
34
|
+
provider: ConsentProvider;
|
|
35
|
+
/** SHA-256 of the absolute project root. Lets us re-prompt per-project. */
|
|
36
|
+
project: string;
|
|
37
|
+
/** ISO timestamp of the grant. */
|
|
38
|
+
grantedAt: string;
|
|
39
|
+
/** Model the user was informed about at grant time. */
|
|
40
|
+
modelAtGrant: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface ConsentContext {
|
|
44
|
+
/** Absolute path of the project root — identifies the consent scope. */
|
|
45
|
+
projectRoot: string;
|
|
46
|
+
provider: ConsentProvider;
|
|
47
|
+
/** Model the adapter will transmit to — printed in the prompt. */
|
|
48
|
+
model: string;
|
|
49
|
+
/**
|
|
50
|
+
* One-line human summary of the payload shape that will be sent.
|
|
51
|
+
* Example: "Guard violation report (3 entries) + 120-line diff excerpt".
|
|
52
|
+
* Adapters compose this from their prompt shape.
|
|
53
|
+
*/
|
|
54
|
+
payloadDescription: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface ConsentPromptDeps {
|
|
58
|
+
/** stdout writer — default process.stdout.write. Tests inject a stub. */
|
|
59
|
+
write?: (msg: string) => void;
|
|
60
|
+
/** Read one line of input. Tests inject a stub; production wraps readline. */
|
|
61
|
+
ask?: (prompt: string) => Promise<string>;
|
|
62
|
+
/** Environment — tests can force `MANDU_BRAIN_AUTO_CONSENT`. */
|
|
63
|
+
env?: NodeJS.ProcessEnv;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Fingerprint a project root so the cache key is collision-resistant
|
|
68
|
+
* AND does not leak the absolute path to the on-disk file. We normalize
|
|
69
|
+
* the path (lowercase on win32) before hashing so a case-variant path
|
|
70
|
+
* resolves to the same entry.
|
|
71
|
+
*/
|
|
72
|
+
export function fingerprintProject(projectRoot: string): string {
|
|
73
|
+
const normalized =
|
|
74
|
+
process.platform === "win32" ? projectRoot.toLowerCase() : projectRoot;
|
|
75
|
+
return createHash("sha256")
|
|
76
|
+
.update(normalized)
|
|
77
|
+
.digest("hex")
|
|
78
|
+
.slice(0, 32);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function readAllConsent(): Promise<ConsentEntry[]> {
|
|
82
|
+
try {
|
|
83
|
+
const raw = await fs.readFile(CONSENT_FILE, "utf8");
|
|
84
|
+
const parsed = JSON.parse(raw);
|
|
85
|
+
if (Array.isArray(parsed)) return parsed as ConsentEntry[];
|
|
86
|
+
return [];
|
|
87
|
+
} catch (err) {
|
|
88
|
+
if (
|
|
89
|
+
err instanceof Error &&
|
|
90
|
+
(err as NodeJS.ErrnoException).code === "ENOENT"
|
|
91
|
+
) {
|
|
92
|
+
return [];
|
|
93
|
+
}
|
|
94
|
+
return [];
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function writeAllConsent(all: ConsentEntry[]): Promise<void> {
|
|
99
|
+
await fs.mkdir(CONSENT_DIR, { recursive: true, mode: 0o700 });
|
|
100
|
+
const tmp = `${CONSENT_FILE}.${process.pid}.tmp`;
|
|
101
|
+
await fs.writeFile(tmp, JSON.stringify(all, null, 2), { mode: 0o600 });
|
|
102
|
+
await fs.rename(tmp, CONSENT_FILE);
|
|
103
|
+
try {
|
|
104
|
+
await fs.chmod(CONSENT_FILE, 0o600);
|
|
105
|
+
} catch {
|
|
106
|
+
/* best-effort on Windows */
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Check whether a (provider, project) pair already has stored consent.
|
|
112
|
+
*/
|
|
113
|
+
export async function hasConsent(
|
|
114
|
+
provider: ConsentProvider,
|
|
115
|
+
projectRoot: string,
|
|
116
|
+
): Promise<boolean> {
|
|
117
|
+
const fp = fingerprintProject(projectRoot);
|
|
118
|
+
const all = await readAllConsent();
|
|
119
|
+
return all.some((e) => e.provider === provider && e.project === fp);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Record consent for a (provider, project) pair. Idempotent.
|
|
124
|
+
*/
|
|
125
|
+
export async function grantConsent(
|
|
126
|
+
ctx: ConsentContext,
|
|
127
|
+
): Promise<void> {
|
|
128
|
+
const fp = fingerprintProject(ctx.projectRoot);
|
|
129
|
+
const all = await readAllConsent();
|
|
130
|
+
const existing = all.find(
|
|
131
|
+
(e) => e.provider === ctx.provider && e.project === fp,
|
|
132
|
+
);
|
|
133
|
+
if (existing) return;
|
|
134
|
+
all.push({
|
|
135
|
+
provider: ctx.provider,
|
|
136
|
+
project: fp,
|
|
137
|
+
grantedAt: new Date().toISOString(),
|
|
138
|
+
modelAtGrant: ctx.model,
|
|
139
|
+
});
|
|
140
|
+
await writeAllConsent(all);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Revoke consent — used by `mandu brain logout --provider=...`.
|
|
145
|
+
*/
|
|
146
|
+
export async function revokeConsent(
|
|
147
|
+
provider: ConsentProvider,
|
|
148
|
+
projectRoot?: string,
|
|
149
|
+
): Promise<void> {
|
|
150
|
+
const all = await readAllConsent();
|
|
151
|
+
const fp = projectRoot ? fingerprintProject(projectRoot) : null;
|
|
152
|
+
const next = all.filter((e) => {
|
|
153
|
+
if (e.provider !== provider) return true;
|
|
154
|
+
if (fp === null) return false; // revoke all projects for this provider
|
|
155
|
+
return e.project !== fp;
|
|
156
|
+
});
|
|
157
|
+
await writeAllConsent(next);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Ensure consent exists for the given context. Prompts the user if
|
|
162
|
+
* necessary. Returns `true` when consent is granted (fresh or cached),
|
|
163
|
+
* `false` when the user declined or non-interactive stdin is closed.
|
|
164
|
+
*
|
|
165
|
+
* Never throws — Brain is isolated from the Core execution path.
|
|
166
|
+
*/
|
|
167
|
+
export async function ensureConsent(
|
|
168
|
+
ctx: ConsentContext,
|
|
169
|
+
deps: ConsentPromptDeps = {},
|
|
170
|
+
): Promise<boolean> {
|
|
171
|
+
const env = deps.env ?? process.env;
|
|
172
|
+
|
|
173
|
+
// Already consented — fast path.
|
|
174
|
+
if (await hasConsent(ctx.provider, ctx.projectRoot)) {
|
|
175
|
+
return true;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// CI / pipeline opt-in.
|
|
179
|
+
if (env.MANDU_BRAIN_AUTO_CONSENT === "1") {
|
|
180
|
+
await grantConsent(ctx);
|
|
181
|
+
return true;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const write = deps.write ?? ((m: string) => process.stdout.write(m));
|
|
185
|
+
const ask = deps.ask ?? defaultReadLine;
|
|
186
|
+
|
|
187
|
+
write(
|
|
188
|
+
[
|
|
189
|
+
"",
|
|
190
|
+
"Mandu Brain — cloud adapter consent",
|
|
191
|
+
"-----------------------------------",
|
|
192
|
+
` Provider : ${ctx.provider}`,
|
|
193
|
+
` Model : ${ctx.model}`,
|
|
194
|
+
` Payload : ${ctx.payloadDescription}`,
|
|
195
|
+
"",
|
|
196
|
+
"Secrets detected in source code are scrubbed before transmission",
|
|
197
|
+
"(audit log: .mandu/brain-redactions.jsonl).",
|
|
198
|
+
"",
|
|
199
|
+
"Consent is cached per-project at ~/.mandu/brain-consent.json.",
|
|
200
|
+
"Set MANDU_BRAIN_AUTO_CONSENT=1 in CI to skip this prompt.",
|
|
201
|
+
"",
|
|
202
|
+
].join("\n"),
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
const answer = (await ask("Proceed? [y/N]: ")).trim().toLowerCase();
|
|
206
|
+
if (answer === "y" || answer === "yes") {
|
|
207
|
+
await grantConsent(ctx);
|
|
208
|
+
return true;
|
|
209
|
+
}
|
|
210
|
+
return false;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Default interactive reader — wraps node:readline. Returns an empty
|
|
215
|
+
* string when stdin is non-TTY so non-interactive shells treat the
|
|
216
|
+
* prompt as declined (safer default than auto-accepting).
|
|
217
|
+
*/
|
|
218
|
+
async function defaultReadLine(prompt: string): Promise<string> {
|
|
219
|
+
if (!process.stdin.isTTY) return "";
|
|
220
|
+
process.stdout.write(prompt);
|
|
221
|
+
// Inline readline to avoid a top-level import — keeps the Brain
|
|
222
|
+
// module graph small and avoids pulling readline into the SSR bundle.
|
|
223
|
+
const readline = await import("node:readline");
|
|
224
|
+
const rl = readline.createInterface({
|
|
225
|
+
input: process.stdin,
|
|
226
|
+
output: process.stdout,
|
|
227
|
+
terminal: true,
|
|
228
|
+
});
|
|
229
|
+
return new Promise<string>((resolve) => {
|
|
230
|
+
rl.question("", (answer) => {
|
|
231
|
+
rl.close();
|
|
232
|
+
resolve(answer);
|
|
233
|
+
});
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Consent file path — surfaced to the user in `mandu brain status`. */
|
|
238
|
+
export function consentFilePath(): string {
|
|
239
|
+
return CONSENT_FILE;
|
|
240
|
+
}
|