@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
package/package.json
CHANGED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for `packages/core/src/brain/redactor.ts`.
|
|
3
|
+
*
|
|
4
|
+
* Each test asserts BOTH invariants we care about:
|
|
5
|
+
* 1. The secret no longer appears verbatim in the redacted output.
|
|
6
|
+
* 2. A correctly-kinded `RedactionHit` shows up in the audit list.
|
|
7
|
+
*
|
|
8
|
+
* Regression guard — if someone relaxes a pattern to fix a false
|
|
9
|
+
* positive, the matching test here will fail before a key leaks.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { describe, it, expect } from "bun:test";
|
|
13
|
+
import { redactSecrets, redact } from "../redactor";
|
|
14
|
+
|
|
15
|
+
describe("redactSecrets — OpenAI-style keys", () => {
|
|
16
|
+
it("redacts sk-... keys and audits the hit", () => {
|
|
17
|
+
const secret = "sk-proj-ABCDEFGHIJKLMNOPQRSTUVWX";
|
|
18
|
+
const input = `Use key ${secret} to call.`;
|
|
19
|
+
const { redacted, hits } = redactSecrets(input);
|
|
20
|
+
expect(redacted).not.toContain(secret);
|
|
21
|
+
expect(redacted).toContain("[[REDACTED:openai-key]]");
|
|
22
|
+
expect(hits.length).toBeGreaterThanOrEqual(1);
|
|
23
|
+
const sampleContainsFullKey = hits.some((h) => h.sample.includes(secret));
|
|
24
|
+
expect(sampleContainsFullKey).toBe(false);
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
describe("redactSecrets — Bearer tokens", () => {
|
|
29
|
+
it("redacts Authorization: Bearer headers", () => {
|
|
30
|
+
const bearer = "Bearer eyJhbGciOiJIUzI1NiJ9abcdefghij.signature-blob";
|
|
31
|
+
const input = `curl -H 'Authorization: ${bearer}' api/host`;
|
|
32
|
+
const { redacted, hits } = redactSecrets(input);
|
|
33
|
+
expect(redacted).not.toContain(bearer);
|
|
34
|
+
expect(hits.some((h) => h.kind === "bearer-token")).toBe(true);
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe("redactSecrets — .env references", () => {
|
|
39
|
+
it("redacts bare .env and .env.production references", () => {
|
|
40
|
+
const input = "Credentials live in .env and overrides in .env.production.";
|
|
41
|
+
const { redacted, hits } = redactSecrets(input);
|
|
42
|
+
expect(redacted).not.toContain(" .env ");
|
|
43
|
+
expect(redacted).not.toContain(".env.production");
|
|
44
|
+
expect(hits.filter((h) => h.kind === "env-ref").length).toBeGreaterThanOrEqual(
|
|
45
|
+
2,
|
|
46
|
+
);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe("redactSecrets — KEY=VALUE assignments", () => {
|
|
51
|
+
it("redacts API_KEY=... assignments in-place", () => {
|
|
52
|
+
const input = `OPENAI_API_KEY=sk-fake1234567890abcdef\nDEBUG=1`;
|
|
53
|
+
const { redacted, hits } = redactSecrets(input);
|
|
54
|
+
// The whole assignment collapses into a single marker.
|
|
55
|
+
expect(redacted).toContain("[[REDACTED:api-key-assignment]]");
|
|
56
|
+
expect(redacted).toContain("DEBUG=1"); // untouched
|
|
57
|
+
expect(hits.some((h) => h.kind === "api-key-assignment")).toBe(true);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe("redactSecrets — multi-match, non-overlapping", () => {
|
|
62
|
+
it("redacts multiple secrets in a single payload", () => {
|
|
63
|
+
const gh = "ghp_1234567890abcdefghijklmnopqrstuvwxyz";
|
|
64
|
+
const aws = "AKIAIOSFODNN7EXAMPLE";
|
|
65
|
+
const input = `GitHub: ${gh}\nAWS: ${aws}\nNothing here.`;
|
|
66
|
+
const { redacted, hits } = redactSecrets(input);
|
|
67
|
+
expect(redacted).not.toContain(gh);
|
|
68
|
+
expect(redacted).not.toContain(aws);
|
|
69
|
+
expect(hits.some((h) => h.kind === "github-token")).toBe(true);
|
|
70
|
+
expect(hits.some((h) => h.kind === "aws-key")).toBe(true);
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe("redactSecrets — clean input passthrough", () => {
|
|
75
|
+
it("returns the input unchanged and an empty hit list when nothing matches", () => {
|
|
76
|
+
const input = "No secrets, just a normal message about the weather.";
|
|
77
|
+
const { redacted, hits } = redactSecrets(input);
|
|
78
|
+
expect(redacted).toBe(input);
|
|
79
|
+
expect(hits.length).toBe(0);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe("redact() convenience", () => {
|
|
84
|
+
it("returns the redacted string directly", () => {
|
|
85
|
+
const secret = "sk-proj-THISISAFAKEKEY1234567890";
|
|
86
|
+
const out = redact(`token: ${secret}`);
|
|
87
|
+
expect(out).not.toContain(secret);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("returns the original string when nothing matches", () => {
|
|
91
|
+
const input = "hello world";
|
|
92
|
+
expect(redact(input)).toBe(input);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared test fixtures for brain adapter tests.
|
|
3
|
+
*
|
|
4
|
+
* Every helper here stays framework-free so the unit tests do not pull
|
|
5
|
+
* in the real network / keychain / filesystem paths.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
CredentialStore,
|
|
10
|
+
type CredentialBackend,
|
|
11
|
+
type StoredToken,
|
|
12
|
+
} from "../../credentials";
|
|
13
|
+
import type { HttpClient, OAuthEndpoints } from "../oauth-flow";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* In-memory credential store that satisfies `CredentialBackend`. We
|
|
17
|
+
* construct a regular `CredentialStore` around it so the adapters see
|
|
18
|
+
* the exact public API (.load/.save/.delete/.list/.touch/.backendName).
|
|
19
|
+
*/
|
|
20
|
+
export function makeMemoryStore(
|
|
21
|
+
seed: Record<string, StoredToken> = {},
|
|
22
|
+
): CredentialStore {
|
|
23
|
+
const map = new Map<string, StoredToken>(Object.entries(seed));
|
|
24
|
+
const backend: CredentialBackend = {
|
|
25
|
+
name: "memory",
|
|
26
|
+
async save(provider, token) {
|
|
27
|
+
map.set(provider, token);
|
|
28
|
+
},
|
|
29
|
+
async load(provider) {
|
|
30
|
+
return map.get(provider) ?? null;
|
|
31
|
+
},
|
|
32
|
+
async delete(provider) {
|
|
33
|
+
map.delete(provider);
|
|
34
|
+
},
|
|
35
|
+
async list() {
|
|
36
|
+
return [...map.keys()];
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
return new CredentialStore(backend);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Build a stub `HttpClient` whose behavior is driven by a response
|
|
44
|
+
* factory. The factory receives the URL and RequestInit and returns a
|
|
45
|
+
* `Response`. Tests use this to simulate 401 sequences, token
|
|
46
|
+
* refresh, etc. without spinning up a live server.
|
|
47
|
+
*/
|
|
48
|
+
export function makeStubHttpClient(
|
|
49
|
+
respond: (url: string, init?: RequestInit) => Response | Promise<Response>,
|
|
50
|
+
): HttpClient {
|
|
51
|
+
return async (url, init) => respond(url, init);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export const FAKE_ENDPOINTS: OAuthEndpoints = {
|
|
55
|
+
authorizationUrl: "https://example.test/oauth/authorize",
|
|
56
|
+
tokenUrl: "https://example.test/oauth/token",
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
export function jsonResponse(body: unknown, status = 200): Response {
|
|
60
|
+
return new Response(JSON.stringify(body), {
|
|
61
|
+
status,
|
|
62
|
+
headers: { "content-type": "application/json" },
|
|
63
|
+
});
|
|
64
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for `packages/core/src/brain/adapters/anthropic-oauth.ts`.
|
|
3
|
+
*
|
|
4
|
+
* Mirror-image of the OpenAI adapter test file. We reuse the shared
|
|
5
|
+
* in-memory credential store fixture + stub HTTP client, and exercise
|
|
6
|
+
* the Messages-API-specific quirks (system split, 401 refresh,
|
|
7
|
+
* stop_sequences mapping).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { describe, it, expect, beforeEach } from "bun:test";
|
|
11
|
+
import { promises as fs } from "node:fs";
|
|
12
|
+
import os from "node:os";
|
|
13
|
+
import path from "node:path";
|
|
14
|
+
import {
|
|
15
|
+
AnthropicOAuthAdapter,
|
|
16
|
+
ANTHROPIC_DEFAULT_MODEL,
|
|
17
|
+
} from "../anthropic-oauth";
|
|
18
|
+
import {
|
|
19
|
+
makeMemoryStore,
|
|
20
|
+
makeStubHttpClient,
|
|
21
|
+
FAKE_ENDPOINTS,
|
|
22
|
+
jsonResponse,
|
|
23
|
+
} from "./_helpers";
|
|
24
|
+
import type { StoredToken } from "../../credentials";
|
|
25
|
+
|
|
26
|
+
let tmp: string;
|
|
27
|
+
|
|
28
|
+
beforeEach(async () => {
|
|
29
|
+
tmp = await fs.mkdtemp(path.join(os.tmpdir(), "mandu-anthropic-adapter-"));
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
function seedToken(): StoredToken {
|
|
33
|
+
return {
|
|
34
|
+
access_token: "seed-access",
|
|
35
|
+
refresh_token: "seed-refresh",
|
|
36
|
+
expires_at: Math.floor(Date.now() / 1000) + 3600,
|
|
37
|
+
default_model: ANTHROPIC_DEFAULT_MODEL,
|
|
38
|
+
provider: "anthropic",
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
describe("AnthropicOAuthAdapter — shape + defaults", () => {
|
|
43
|
+
it("reports adapter name anthropic-oauth and default Haiku model", async () => {
|
|
44
|
+
const store = makeMemoryStore({ anthropic: seedToken() });
|
|
45
|
+
const adapter = new AnthropicOAuthAdapter({
|
|
46
|
+
credentialStore: store,
|
|
47
|
+
projectRoot: tmp,
|
|
48
|
+
endpoints: FAKE_ENDPOINTS,
|
|
49
|
+
httpClient: makeStubHttpClient(() => jsonResponse({ content: [] })),
|
|
50
|
+
skipConsent: true,
|
|
51
|
+
});
|
|
52
|
+
expect(adapter.name).toBe("anthropic-oauth");
|
|
53
|
+
expect(adapter.model).toBe(ANTHROPIC_DEFAULT_MODEL);
|
|
54
|
+
const status = await adapter.checkStatus();
|
|
55
|
+
expect(status.available).toBe(true);
|
|
56
|
+
expect(status.model).toBe(ANTHROPIC_DEFAULT_MODEL);
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe("AnthropicOAuthAdapter — redaction invariant", () => {
|
|
61
|
+
it("redacts secrets and records an audit line", async () => {
|
|
62
|
+
const store = makeMemoryStore({ anthropic: seedToken() });
|
|
63
|
+
let capturedBody = "";
|
|
64
|
+
const http = makeStubHttpClient((_url, init) => {
|
|
65
|
+
capturedBody = (init?.body as string) ?? "";
|
|
66
|
+
return jsonResponse({
|
|
67
|
+
content: [{ type: "text", text: "ok" }],
|
|
68
|
+
usage: { input_tokens: 5, output_tokens: 3 },
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
const adapter = new AnthropicOAuthAdapter({
|
|
72
|
+
credentialStore: store,
|
|
73
|
+
projectRoot: tmp,
|
|
74
|
+
endpoints: FAKE_ENDPOINTS,
|
|
75
|
+
httpClient: http,
|
|
76
|
+
skipConsent: true,
|
|
77
|
+
});
|
|
78
|
+
const secret = "ghp_1234567890abcdefghijklmnopqrstuvwxyz";
|
|
79
|
+
const res = await adapter.complete([
|
|
80
|
+
{ role: "user", content: `Investigate token ${secret}.` },
|
|
81
|
+
]);
|
|
82
|
+
expect(res.content).toBe("ok");
|
|
83
|
+
expect(capturedBody).not.toContain(secret);
|
|
84
|
+
expect(capturedBody).toContain("[[REDACTED:");
|
|
85
|
+
const auditPath = path.join(tmp, ".mandu", "brain-redactions.jsonl");
|
|
86
|
+
const contents = await fs.readFile(auditPath, "utf8");
|
|
87
|
+
expect(contents).toContain("anthropic");
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
describe("AnthropicOAuthAdapter — 401 fallback chain", () => {
|
|
92
|
+
it("tries silent refresh then scrubs the token on persistent 401", async () => {
|
|
93
|
+
const store = makeMemoryStore({ anthropic: seedToken() });
|
|
94
|
+
let calls = 0;
|
|
95
|
+
const http = makeStubHttpClient((url) => {
|
|
96
|
+
calls += 1;
|
|
97
|
+
if (url === FAKE_ENDPOINTS.tokenUrl) {
|
|
98
|
+
return jsonResponse({
|
|
99
|
+
access_token: "refreshed",
|
|
100
|
+
refresh_token: "r2",
|
|
101
|
+
expires_in: 3600,
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
return new Response("unauthorized", { status: 401 });
|
|
105
|
+
});
|
|
106
|
+
const adapter = new AnthropicOAuthAdapter({
|
|
107
|
+
credentialStore: store,
|
|
108
|
+
projectRoot: tmp,
|
|
109
|
+
endpoints: FAKE_ENDPOINTS,
|
|
110
|
+
httpClient: http,
|
|
111
|
+
skipConsent: true,
|
|
112
|
+
});
|
|
113
|
+
const res = await adapter.complete([{ role: "user", content: "ping" }]);
|
|
114
|
+
expect(res.content).toBe("");
|
|
115
|
+
expect(calls).toBe(3);
|
|
116
|
+
expect(await store.load("anthropic")).toBeNull();
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
describe("AnthropicOAuthAdapter — system message split", () => {
|
|
121
|
+
it("pulls leading system messages into the `system` field and sends only user/assistant turns in messages[]", async () => {
|
|
122
|
+
const store = makeMemoryStore({ anthropic: seedToken() });
|
|
123
|
+
let capturedBody = "";
|
|
124
|
+
const http = makeStubHttpClient((_url, init) => {
|
|
125
|
+
capturedBody = (init?.body as string) ?? "";
|
|
126
|
+
return jsonResponse({
|
|
127
|
+
content: [{ type: "text", text: "answer" }],
|
|
128
|
+
usage: { input_tokens: 1, output_tokens: 1 },
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
const adapter = new AnthropicOAuthAdapter({
|
|
132
|
+
credentialStore: store,
|
|
133
|
+
projectRoot: tmp,
|
|
134
|
+
endpoints: FAKE_ENDPOINTS,
|
|
135
|
+
httpClient: http,
|
|
136
|
+
skipConsent: true,
|
|
137
|
+
});
|
|
138
|
+
await adapter.complete([
|
|
139
|
+
{ role: "system", content: "You are a helpful assistant." },
|
|
140
|
+
{ role: "user", content: "Hi" },
|
|
141
|
+
]);
|
|
142
|
+
const parsed = JSON.parse(capturedBody);
|
|
143
|
+
expect(parsed.system).toBe("You are a helpful assistant.");
|
|
144
|
+
expect(parsed.messages).toEqual([{ role: "user", content: "Hi" }]);
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
describe("AnthropicOAuthAdapter — telemetryOptOut honored via resolver contract", () => {
|
|
149
|
+
it("returns empty completion when consent is declined (simulates opt-out UX)", async () => {
|
|
150
|
+
const store = makeMemoryStore({ anthropic: seedToken() });
|
|
151
|
+
let dispatched = false;
|
|
152
|
+
const http = makeStubHttpClient(() => {
|
|
153
|
+
dispatched = true;
|
|
154
|
+
return jsonResponse({ content: [{ type: "text", text: "nope" }] });
|
|
155
|
+
});
|
|
156
|
+
const adapter = new AnthropicOAuthAdapter({
|
|
157
|
+
credentialStore: store,
|
|
158
|
+
projectRoot: tmp,
|
|
159
|
+
endpoints: FAKE_ENDPOINTS,
|
|
160
|
+
httpClient: http,
|
|
161
|
+
consentDeps: {
|
|
162
|
+
ask: async () => "N",
|
|
163
|
+
write: () => {},
|
|
164
|
+
env: {} as NodeJS.ProcessEnv,
|
|
165
|
+
},
|
|
166
|
+
});
|
|
167
|
+
const res = await adapter.complete([{ role: "user", content: "hi" }]);
|
|
168
|
+
expect(res.content).toBe("");
|
|
169
|
+
expect(dispatched).toBe(false);
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
describe("AnthropicOAuthAdapter — model override flows into request", () => {
|
|
174
|
+
it("uses overridden model in the outgoing body", async () => {
|
|
175
|
+
const store = makeMemoryStore({ anthropic: seedToken() });
|
|
176
|
+
let capturedBody = "";
|
|
177
|
+
const http = makeStubHttpClient((_url, init) => {
|
|
178
|
+
capturedBody = (init?.body as string) ?? "";
|
|
179
|
+
return jsonResponse({
|
|
180
|
+
content: [{ type: "text", text: "ok" }],
|
|
181
|
+
usage: { input_tokens: 1, output_tokens: 1 },
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
const adapter = new AnthropicOAuthAdapter({
|
|
185
|
+
credentialStore: store,
|
|
186
|
+
projectRoot: tmp,
|
|
187
|
+
endpoints: FAKE_ENDPOINTS,
|
|
188
|
+
httpClient: http,
|
|
189
|
+
skipConsent: true,
|
|
190
|
+
model: "claude-sonnet-4-5-20250929",
|
|
191
|
+
});
|
|
192
|
+
await adapter.complete([{ role: "user", content: "hi" }]);
|
|
193
|
+
const parsed = JSON.parse(capturedBody);
|
|
194
|
+
expect(parsed.model).toBe("claude-sonnet-4-5-20250929");
|
|
195
|
+
});
|
|
196
|
+
});
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for `packages/core/src/brain/adapters/openai-oauth.ts`.
|
|
3
|
+
*
|
|
4
|
+
* The adapter is fully isolatable via its options surface:
|
|
5
|
+
* - `credentialStore` — in-memory fixture (no keychain calls).
|
|
6
|
+
* - `httpClient` — stubbed fetch (no network).
|
|
7
|
+
* - `endpoints` — fake OAuth endpoints.
|
|
8
|
+
* - `skipConsent` — bypass the interactive consent prompt.
|
|
9
|
+
* - `projectRoot` — per-test tmpdir so redaction audit log is
|
|
10
|
+
* sandboxed.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { describe, it, expect, beforeEach } from "bun:test";
|
|
14
|
+
import { promises as fs } from "node:fs";
|
|
15
|
+
import os from "node:os";
|
|
16
|
+
import path from "node:path";
|
|
17
|
+
import {
|
|
18
|
+
OpenAIOAuthAdapter,
|
|
19
|
+
OPENAI_DEFAULT_MODEL,
|
|
20
|
+
} from "../openai-oauth";
|
|
21
|
+
import {
|
|
22
|
+
makeMemoryStore,
|
|
23
|
+
makeStubHttpClient,
|
|
24
|
+
FAKE_ENDPOINTS,
|
|
25
|
+
jsonResponse,
|
|
26
|
+
} from "./_helpers";
|
|
27
|
+
import type { StoredToken } from "../../credentials";
|
|
28
|
+
|
|
29
|
+
let tmp: string;
|
|
30
|
+
|
|
31
|
+
beforeEach(async () => {
|
|
32
|
+
tmp = await fs.mkdtemp(path.join(os.tmpdir(), "mandu-openai-adapter-"));
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
function seedToken(): StoredToken {
|
|
36
|
+
return {
|
|
37
|
+
access_token: "seed-access",
|
|
38
|
+
refresh_token: "seed-refresh",
|
|
39
|
+
expires_at: Math.floor(Date.now() / 1000) + 3600,
|
|
40
|
+
default_model: OPENAI_DEFAULT_MODEL,
|
|
41
|
+
provider: "openai",
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
describe("OpenAIOAuthAdapter — shape + defaults", () => {
|
|
46
|
+
it("reports default model gpt-5.4 and adapter name openai-oauth", async () => {
|
|
47
|
+
const store = makeMemoryStore({ openai: seedToken() });
|
|
48
|
+
const adapter = new OpenAIOAuthAdapter({
|
|
49
|
+
credentialStore: store,
|
|
50
|
+
projectRoot: tmp,
|
|
51
|
+
endpoints: FAKE_ENDPOINTS,
|
|
52
|
+
httpClient: makeStubHttpClient(() => new Response("ok")),
|
|
53
|
+
skipConsent: true,
|
|
54
|
+
});
|
|
55
|
+
expect(adapter.name).toBe("openai-oauth");
|
|
56
|
+
expect(adapter.model).toBe(OPENAI_DEFAULT_MODEL);
|
|
57
|
+
const status = await adapter.checkStatus();
|
|
58
|
+
expect(status.available).toBe(true);
|
|
59
|
+
expect(status.model).toBe(OPENAI_DEFAULT_MODEL);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe("OpenAIOAuthAdapter — redaction invariant", () => {
|
|
64
|
+
it("never transmits detected secrets in the chat body and writes an audit entry", async () => {
|
|
65
|
+
const store = makeMemoryStore({ openai: seedToken() });
|
|
66
|
+
let captured = "";
|
|
67
|
+
const http = makeStubHttpClient((_url, init) => {
|
|
68
|
+
captured = (init?.body as string) ?? "";
|
|
69
|
+
return jsonResponse({
|
|
70
|
+
choices: [{ message: { content: "ok" } }],
|
|
71
|
+
usage: { prompt_tokens: 5, completion_tokens: 3, total_tokens: 8 },
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
const adapter = new OpenAIOAuthAdapter({
|
|
76
|
+
credentialStore: store,
|
|
77
|
+
projectRoot: tmp,
|
|
78
|
+
endpoints: FAKE_ENDPOINTS,
|
|
79
|
+
httpClient: http,
|
|
80
|
+
skipConsent: true,
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
const secret = "sk-proj-SECRET1234567890ABCDEFGH";
|
|
84
|
+
const res = await adapter.complete([
|
|
85
|
+
{ role: "user", content: `Analyze this: key=${secret}` },
|
|
86
|
+
]);
|
|
87
|
+
|
|
88
|
+
expect(res.content).toBe("ok");
|
|
89
|
+
expect(captured).not.toContain(secret);
|
|
90
|
+
expect(captured).toContain("[[REDACTED:");
|
|
91
|
+
|
|
92
|
+
const auditPath = path.join(tmp, ".mandu", "brain-redactions.jsonl");
|
|
93
|
+
const auditContents = await fs.readFile(auditPath, "utf8");
|
|
94
|
+
expect(auditContents).toContain("openai");
|
|
95
|
+
expect(auditContents).not.toContain(secret);
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe("OpenAIOAuthAdapter — 401 fallback chain", () => {
|
|
100
|
+
it("attempts silent refresh once, then returns empty and scrubs the token on persistent 401", async () => {
|
|
101
|
+
const store = makeMemoryStore({ openai: seedToken() });
|
|
102
|
+
let calls = 0;
|
|
103
|
+
const http = makeStubHttpClient((url) => {
|
|
104
|
+
calls += 1;
|
|
105
|
+
if (url === FAKE_ENDPOINTS.tokenUrl) {
|
|
106
|
+
// Refresh endpoint — hand back a new access_token.
|
|
107
|
+
return jsonResponse({
|
|
108
|
+
access_token: "refreshed-access",
|
|
109
|
+
refresh_token: "new-refresh",
|
|
110
|
+
expires_in: 3600,
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
// Chat endpoint — always 401.
|
|
114
|
+
return new Response("unauthorized", { status: 401 });
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
const adapter = new OpenAIOAuthAdapter({
|
|
118
|
+
credentialStore: store,
|
|
119
|
+
projectRoot: tmp,
|
|
120
|
+
endpoints: FAKE_ENDPOINTS,
|
|
121
|
+
httpClient: http,
|
|
122
|
+
skipConsent: true,
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
const res = await adapter.complete([{ role: "user", content: "ping" }]);
|
|
126
|
+
expect(res.content).toBe("");
|
|
127
|
+
// Expect 3 calls: chat(401) → refresh(200) → chat(401) → scrub.
|
|
128
|
+
expect(calls).toBe(3);
|
|
129
|
+
expect(await store.load("openai")).toBeNull();
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
describe("OpenAIOAuthAdapter — no-token → empty completion (not strict)", () => {
|
|
134
|
+
it("returns empty completion when no token is stored and strict=false", async () => {
|
|
135
|
+
const store = makeMemoryStore();
|
|
136
|
+
const adapter = new OpenAIOAuthAdapter({
|
|
137
|
+
credentialStore: store,
|
|
138
|
+
projectRoot: tmp,
|
|
139
|
+
endpoints: FAKE_ENDPOINTS,
|
|
140
|
+
httpClient: makeStubHttpClient(() =>
|
|
141
|
+
jsonResponse({
|
|
142
|
+
choices: [{ message: { content: "should-not-run" } }],
|
|
143
|
+
}),
|
|
144
|
+
),
|
|
145
|
+
skipConsent: true,
|
|
146
|
+
});
|
|
147
|
+
const res = await adapter.complete([{ role: "user", content: "hi" }]);
|
|
148
|
+
expect(res.content).toBe("");
|
|
149
|
+
expect(res.usage?.totalTokens).toBe(0);
|
|
150
|
+
const status = await adapter.checkStatus();
|
|
151
|
+
expect(status.available).toBe(false);
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
describe("OpenAIOAuthAdapter — consent decline short-circuits transmission", () => {
|
|
156
|
+
it("returns empty completion and never dispatches the chat call when consent is declined", async () => {
|
|
157
|
+
const store = makeMemoryStore({ openai: seedToken() });
|
|
158
|
+
let dispatched = false;
|
|
159
|
+
const http = makeStubHttpClient(() => {
|
|
160
|
+
dispatched = true;
|
|
161
|
+
return jsonResponse({ choices: [{ message: { content: "x" } }] });
|
|
162
|
+
});
|
|
163
|
+
const adapter = new OpenAIOAuthAdapter({
|
|
164
|
+
credentialStore: store,
|
|
165
|
+
projectRoot: tmp,
|
|
166
|
+
endpoints: FAKE_ENDPOINTS,
|
|
167
|
+
httpClient: http,
|
|
168
|
+
consentDeps: {
|
|
169
|
+
ask: async () => "n",
|
|
170
|
+
write: () => {},
|
|
171
|
+
env: {} as NodeJS.ProcessEnv,
|
|
172
|
+
},
|
|
173
|
+
});
|
|
174
|
+
const res = await adapter.complete([{ role: "user", content: "hi" }]);
|
|
175
|
+
expect(res.content).toBe("");
|
|
176
|
+
expect(dispatched).toBe(false);
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
describe("OpenAIOAuthAdapter — model override flows through to the wire body", () => {
|
|
181
|
+
it("uses the configured model in the request payload", async () => {
|
|
182
|
+
const store = makeMemoryStore({ openai: seedToken() });
|
|
183
|
+
let capturedBody = "";
|
|
184
|
+
const http = makeStubHttpClient((_url, init) => {
|
|
185
|
+
capturedBody = (init?.body as string) ?? "";
|
|
186
|
+
return jsonResponse({
|
|
187
|
+
choices: [{ message: { content: "ok" } }],
|
|
188
|
+
usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
const adapter = new OpenAIOAuthAdapter({
|
|
192
|
+
credentialStore: store,
|
|
193
|
+
projectRoot: tmp,
|
|
194
|
+
endpoints: FAKE_ENDPOINTS,
|
|
195
|
+
httpClient: http,
|
|
196
|
+
skipConsent: true,
|
|
197
|
+
model: "gpt-4o",
|
|
198
|
+
});
|
|
199
|
+
await adapter.complete([{ role: "user", content: "hello" }]);
|
|
200
|
+
expect(capturedBody).toContain('"model":"gpt-4o"');
|
|
201
|
+
});
|
|
202
|
+
});
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for `resolveBrainAdapter()` in `adapters/index.ts`.
|
|
3
|
+
*
|
|
4
|
+
* Priority order under `adapter: "auto"`:
|
|
5
|
+
* 1. openai-oauth when token present
|
|
6
|
+
* 2. anthropic-oauth when token present
|
|
7
|
+
* 3. ollama when daemon reachable
|
|
8
|
+
* 4. template otherwise
|
|
9
|
+
*
|
|
10
|
+
* `telemetryOptOut: true` disables every cloud tier.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { describe, it, expect } from "bun:test";
|
|
14
|
+
import { resolveBrainAdapter } from "../index";
|
|
15
|
+
import { makeMemoryStore } from "./_helpers";
|
|
16
|
+
import type { StoredToken } from "../../credentials";
|
|
17
|
+
|
|
18
|
+
function openaiToken(): StoredToken {
|
|
19
|
+
return { access_token: "oa", provider: "openai" };
|
|
20
|
+
}
|
|
21
|
+
function anthropicToken(): StoredToken {
|
|
22
|
+
return { access_token: "an", provider: "anthropic" };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe("resolveBrainAdapter — priority order", () => {
|
|
26
|
+
it("picks openai first when both cloud tokens + ollama are available", async () => {
|
|
27
|
+
const store = makeMemoryStore({
|
|
28
|
+
openai: openaiToken(),
|
|
29
|
+
anthropic: anthropicToken(),
|
|
30
|
+
});
|
|
31
|
+
const res = await resolveBrainAdapter({
|
|
32
|
+
adapter: "auto",
|
|
33
|
+
credentialStore: store,
|
|
34
|
+
probeOllama: async () => true,
|
|
35
|
+
});
|
|
36
|
+
expect(res.resolved).toBe("openai");
|
|
37
|
+
expect(res.adapter.name).toBe("openai-oauth");
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("falls to anthropic when only anthropic token present", async () => {
|
|
41
|
+
const store = makeMemoryStore({ anthropic: anthropicToken() });
|
|
42
|
+
const res = await resolveBrainAdapter({
|
|
43
|
+
adapter: "auto",
|
|
44
|
+
credentialStore: store,
|
|
45
|
+
probeOllama: async () => true,
|
|
46
|
+
});
|
|
47
|
+
expect(res.resolved).toBe("anthropic");
|
|
48
|
+
expect(res.adapter.name).toBe("anthropic-oauth");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("falls to ollama when no cloud tokens but daemon is alive", async () => {
|
|
52
|
+
const store = makeMemoryStore();
|
|
53
|
+
const res = await resolveBrainAdapter({
|
|
54
|
+
adapter: "auto",
|
|
55
|
+
credentialStore: store,
|
|
56
|
+
probeOllama: async () => true,
|
|
57
|
+
});
|
|
58
|
+
expect(res.resolved).toBe("ollama");
|
|
59
|
+
expect(res.adapter.name).toBe("ollama");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("falls to template when nothing is reachable", async () => {
|
|
63
|
+
const store = makeMemoryStore();
|
|
64
|
+
const res = await resolveBrainAdapter({
|
|
65
|
+
adapter: "auto",
|
|
66
|
+
credentialStore: store,
|
|
67
|
+
probeOllama: async () => false,
|
|
68
|
+
});
|
|
69
|
+
expect(res.resolved).toBe("template");
|
|
70
|
+
expect(res.adapter.name).toBe("noop");
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe("resolveBrainAdapter — telemetryOptOut", () => {
|
|
75
|
+
it("skips cloud tiers even when tokens exist", async () => {
|
|
76
|
+
const store = makeMemoryStore({
|
|
77
|
+
openai: openaiToken(),
|
|
78
|
+
anthropic: anthropicToken(),
|
|
79
|
+
});
|
|
80
|
+
const res = await resolveBrainAdapter({
|
|
81
|
+
adapter: "auto",
|
|
82
|
+
telemetryOptOut: true,
|
|
83
|
+
credentialStore: store,
|
|
84
|
+
probeOllama: async () => true,
|
|
85
|
+
});
|
|
86
|
+
expect(res.resolved).toBe("ollama");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("falls to template when telemetryOptOut is true and ollama is down", async () => {
|
|
90
|
+
const store = makeMemoryStore({ openai: openaiToken() });
|
|
91
|
+
const res = await resolveBrainAdapter({
|
|
92
|
+
adapter: "auto",
|
|
93
|
+
telemetryOptOut: true,
|
|
94
|
+
credentialStore: store,
|
|
95
|
+
probeOllama: async () => false,
|
|
96
|
+
});
|
|
97
|
+
expect(res.resolved).toBe("template");
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
describe("resolveBrainAdapter — explicit pins degrade gracefully", () => {
|
|
102
|
+
it("explicit 'openai' without a token degrades to template (does not throw)", async () => {
|
|
103
|
+
const store = makeMemoryStore();
|
|
104
|
+
const res = await resolveBrainAdapter({
|
|
105
|
+
adapter: "openai",
|
|
106
|
+
credentialStore: store,
|
|
107
|
+
});
|
|
108
|
+
expect(res.resolved).toBe("template");
|
|
109
|
+
expect(res.reason).toContain("no token");
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("explicit 'anthropic' with telemetryOptOut forces template", async () => {
|
|
113
|
+
const store = makeMemoryStore({ anthropic: anthropicToken() });
|
|
114
|
+
const res = await resolveBrainAdapter({
|
|
115
|
+
adapter: "anthropic",
|
|
116
|
+
telemetryOptOut: true,
|
|
117
|
+
credentialStore: store,
|
|
118
|
+
});
|
|
119
|
+
expect(res.resolved).toBe("template");
|
|
120
|
+
expect(res.reason).toContain("telemetryOptOut");
|
|
121
|
+
});
|
|
122
|
+
});
|