@almightygpt/core 0.10.0 → 0.10.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/dist/auth/__tests__/keychain.test.d.ts +18 -0
- package/dist/auth/__tests__/keychain.test.d.ts.map +1 -0
- package/dist/auth/__tests__/keychain.test.js +155 -0
- package/dist/auth/__tests__/keychain.test.js.map +1 -0
- package/dist/auth/__tests__/resolver.test.d.ts +13 -0
- package/dist/auth/__tests__/resolver.test.d.ts.map +1 -0
- package/dist/auth/__tests__/resolver.test.js +182 -0
- package/dist/auth/__tests__/resolver.test.js.map +1 -0
- package/dist/auth/__tests__/validator.test.d.ts +15 -0
- package/dist/auth/__tests__/validator.test.d.ts.map +1 -0
- package/dist/auth/__tests__/validator.test.js +197 -0
- package/dist/auth/__tests__/validator.test.js.map +1 -0
- package/dist/auth/validator.js +19 -14
- package/dist/auth/validator.js.map +1 -1
- package/package.json +4 -2
- package/src/auth/__tests__/keychain.test.ts +171 -0
- package/src/auth/__tests__/resolver.test.ts +231 -0
- package/src/auth/__tests__/validator.test.ts +241 -0
- package/src/auth/validator.ts +27 -14
- package/src/index.ts +1 -1
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Validator tests with mocked fetch.
|
|
3
|
+
*
|
|
4
|
+
* Covers Codex's v0.8 security review demand:
|
|
5
|
+
* "Add a regression test that failed validation never logs or returns
|
|
6
|
+
* a URL containing the key."
|
|
7
|
+
*
|
|
8
|
+
* Plus the broader contract:
|
|
9
|
+
* - happy path → ok: true with model
|
|
10
|
+
* - timeout → ok: false with friendly message
|
|
11
|
+
* - non-OK response → normalized error with statusCode, never raw body
|
|
12
|
+
* - submitted key NEVER appears in error / model field for ANY provider
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { describe, it, expect, beforeEach, vi } from "vitest";
|
|
16
|
+
import { validateKey } from "../validator.js";
|
|
17
|
+
|
|
18
|
+
const fetchMock = vi.fn();
|
|
19
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
20
|
+
|
|
21
|
+
// Keys with recognizable patterns so we can grep for them in any output.
|
|
22
|
+
const KEYS = {
|
|
23
|
+
openai: "sk-test-openai-CANARY-VALUE-12345",
|
|
24
|
+
anthropic: "sk-ant-test-anthropic-CANARY-VALUE-67890",
|
|
25
|
+
google: "AIza-test-google-CANARY-VALUE-abcde",
|
|
26
|
+
} as const;
|
|
27
|
+
|
|
28
|
+
function jsonResponse(status: number, body: object | string): Response {
|
|
29
|
+
return new Response(typeof body === "string" ? body : JSON.stringify(body), {
|
|
30
|
+
status,
|
|
31
|
+
headers: { "content-type": "application/json" },
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
beforeEach(() => {
|
|
36
|
+
fetchMock.mockReset();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe("validator — OpenAI", () => {
|
|
40
|
+
it("happy path returns ok with model name from response", async () => {
|
|
41
|
+
fetchMock.mockResolvedValueOnce(
|
|
42
|
+
jsonResponse(200, { model: "gpt-4o-2024-08-06", choices: [] }),
|
|
43
|
+
);
|
|
44
|
+
const r = await validateKey("openai", KEYS.openai);
|
|
45
|
+
expect(r.ok).toBe(true);
|
|
46
|
+
expect(r.model).toBe("gpt-4o-2024-08-06");
|
|
47
|
+
expect(r.latencyMs).toBeGreaterThanOrEqual(0);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("posts to /v1/chat/completions with the key as Bearer header (NOT in URL)", async () => {
|
|
51
|
+
fetchMock.mockResolvedValueOnce(
|
|
52
|
+
jsonResponse(200, { model: "gpt-4o" }),
|
|
53
|
+
);
|
|
54
|
+
await validateKey("openai", KEYS.openai);
|
|
55
|
+
const [url, init] = fetchMock.mock.calls[0]!;
|
|
56
|
+
expect(String(url)).toBe("https://api.openai.com/v1/chat/completions");
|
|
57
|
+
expect(String(url)).not.toContain(KEYS.openai);
|
|
58
|
+
expect((init as RequestInit).headers).toMatchObject({
|
|
59
|
+
authorization: `Bearer ${KEYS.openai}`,
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("non-OK response returns normalized error with statusCode and no raw body in error", async () => {
|
|
64
|
+
fetchMock.mockResolvedValueOnce(
|
|
65
|
+
jsonResponse(401, {
|
|
66
|
+
error: {
|
|
67
|
+
message: "Incorrect API key provided",
|
|
68
|
+
type: "invalid_request_error",
|
|
69
|
+
code: "invalid_api_key",
|
|
70
|
+
},
|
|
71
|
+
}),
|
|
72
|
+
);
|
|
73
|
+
const r = await validateKey("openai", KEYS.openai);
|
|
74
|
+
expect(r.ok).toBe(false);
|
|
75
|
+
expect(r.statusCode).toBe(401);
|
|
76
|
+
expect(r.error).toContain("Incorrect API key provided");
|
|
77
|
+
// The error string should be short — not the raw JSON body.
|
|
78
|
+
expect(r.error!.length).toBeLessThan(200);
|
|
79
|
+
// rawBody is preserved separately for explicit debug use.
|
|
80
|
+
expect(r.rawBody).toBeDefined();
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe("validator — Anthropic", () => {
|
|
85
|
+
it("happy path returns ok with model from response", async () => {
|
|
86
|
+
fetchMock.mockResolvedValueOnce(
|
|
87
|
+
jsonResponse(200, { model: "claude-sonnet-4-6", content: [] }),
|
|
88
|
+
);
|
|
89
|
+
const r = await validateKey("anthropic", KEYS.anthropic);
|
|
90
|
+
expect(r.ok).toBe(true);
|
|
91
|
+
expect(r.model).toBe("claude-sonnet-4-6");
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("posts to /v1/messages with x-api-key header (NOT in URL)", async () => {
|
|
95
|
+
fetchMock.mockResolvedValueOnce(
|
|
96
|
+
jsonResponse(200, { model: "claude-sonnet-4-6" }),
|
|
97
|
+
);
|
|
98
|
+
await validateKey("anthropic", KEYS.anthropic);
|
|
99
|
+
const [url, init] = fetchMock.mock.calls[0]!;
|
|
100
|
+
expect(String(url)).toBe("https://api.anthropic.com/v1/messages");
|
|
101
|
+
expect(String(url)).not.toContain(KEYS.anthropic);
|
|
102
|
+
expect((init as RequestInit).headers).toMatchObject({
|
|
103
|
+
"x-api-key": KEYS.anthropic,
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("non-OK response returns normalized error with parsed message", async () => {
|
|
108
|
+
fetchMock.mockResolvedValueOnce(
|
|
109
|
+
jsonResponse(400, {
|
|
110
|
+
type: "error",
|
|
111
|
+
error: {
|
|
112
|
+
type: "authentication_error",
|
|
113
|
+
message: "invalid x-api-key",
|
|
114
|
+
},
|
|
115
|
+
}),
|
|
116
|
+
);
|
|
117
|
+
const r = await validateKey("anthropic", KEYS.anthropic);
|
|
118
|
+
expect(r.ok).toBe(false);
|
|
119
|
+
expect(r.statusCode).toBe(400);
|
|
120
|
+
expect(r.error).toContain("invalid x-api-key");
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
describe("validator — Google", () => {
|
|
125
|
+
it("happy path returns ok with the configured model name", async () => {
|
|
126
|
+
fetchMock.mockResolvedValueOnce(
|
|
127
|
+
jsonResponse(200, { candidates: [{ content: { parts: [] } }] }),
|
|
128
|
+
);
|
|
129
|
+
const r = await validateKey("google", KEYS.google);
|
|
130
|
+
expect(r.ok).toBe(true);
|
|
131
|
+
expect(r.model).toBe("gemini-2.5-flash");
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("CODEX V0.8 P1: uses x-goog-api-key header — NEVER puts the key in the URL", async () => {
|
|
135
|
+
fetchMock.mockResolvedValueOnce(
|
|
136
|
+
jsonResponse(200, { candidates: [] }),
|
|
137
|
+
);
|
|
138
|
+
await validateKey("google", KEYS.google);
|
|
139
|
+
const [url, init] = fetchMock.mock.calls[0]!;
|
|
140
|
+
// This is the regression-test Codex specifically requested.
|
|
141
|
+
expect(String(url)).not.toContain(KEYS.google);
|
|
142
|
+
expect(String(url)).not.toContain("?key=");
|
|
143
|
+
// And the key MUST be in the header instead.
|
|
144
|
+
expect((init as RequestInit).headers).toMatchObject({
|
|
145
|
+
"x-goog-api-key": KEYS.google,
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("non-OK response returns normalized error with statusCode", async () => {
|
|
150
|
+
fetchMock.mockResolvedValueOnce(
|
|
151
|
+
jsonResponse(403, {
|
|
152
|
+
error: {
|
|
153
|
+
code: 403,
|
|
154
|
+
message: "API key not valid",
|
|
155
|
+
status: "PERMISSION_DENIED",
|
|
156
|
+
},
|
|
157
|
+
}),
|
|
158
|
+
);
|
|
159
|
+
const r = await validateKey("google", KEYS.google);
|
|
160
|
+
expect(r.ok).toBe(false);
|
|
161
|
+
expect(r.statusCode).toBe(403);
|
|
162
|
+
expect(r.error).toContain("API key not valid");
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
describe("validator — KEY-LEAK REGRESSION (Codex v0.8 P2 #6)", () => {
|
|
167
|
+
/**
|
|
168
|
+
* The submitted key must never appear in `error` or `model` for
|
|
169
|
+
* ANY provider, in ANY failure path. Tests every provider × every
|
|
170
|
+
* failure mode we care about.
|
|
171
|
+
*/
|
|
172
|
+
const failureModes: Array<{ status: number; body: object | string }> = [
|
|
173
|
+
{ status: 400, body: { error: { message: "bad request" } } },
|
|
174
|
+
{ status: 401, body: { error: { message: "unauthorized" } } },
|
|
175
|
+
{ status: 403, body: { error: { message: "forbidden" } } },
|
|
176
|
+
{ status: 429, body: { error: { message: "rate limited" } } },
|
|
177
|
+
{ status: 500, body: "Internal Server Error (plain text body)" },
|
|
178
|
+
{
|
|
179
|
+
status: 200,
|
|
180
|
+
body: "malformed-not-json{{{",
|
|
181
|
+
},
|
|
182
|
+
];
|
|
183
|
+
|
|
184
|
+
for (const provider of ["openai", "anthropic", "google"] as const) {
|
|
185
|
+
for (const mode of failureModes) {
|
|
186
|
+
it(`${provider} ${mode.status}: error message never contains the submitted key verbatim`, async () => {
|
|
187
|
+
fetchMock.mockResolvedValueOnce(
|
|
188
|
+
jsonResponse(mode.status, mode.body),
|
|
189
|
+
);
|
|
190
|
+
const key = KEYS[provider];
|
|
191
|
+
const r = await validateKey(provider, key);
|
|
192
|
+
// Check every string-shaped field we expose to users.
|
|
193
|
+
for (const field of [r.error, r.model] as Array<string | undefined>) {
|
|
194
|
+
if (field) {
|
|
195
|
+
expect(field).not.toContain(key);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
it("if a provider echoes the key in its error body, the normalized error MUST NOT include the key", async () => {
|
|
203
|
+
// Worst-case: provider literally echoes the key in the response.
|
|
204
|
+
// Could happen with poorly-written upstream tooling.
|
|
205
|
+
fetchMock.mockResolvedValueOnce(
|
|
206
|
+
jsonResponse(400, {
|
|
207
|
+
error: {
|
|
208
|
+
message: `key ${KEYS.openai} is malformed`,
|
|
209
|
+
},
|
|
210
|
+
}),
|
|
211
|
+
);
|
|
212
|
+
const r = await validateKey("openai", KEYS.openai);
|
|
213
|
+
expect(r.ok).toBe(false);
|
|
214
|
+
// The normalized error MUST redact the key.
|
|
215
|
+
expect(r.error).not.toContain(KEYS.openai);
|
|
216
|
+
expect(r.error).toContain("<redacted-key>");
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
describe("validator — network failure handling", () => {
|
|
221
|
+
it("network throw returns ok:false with friendly message (not stack trace)", async () => {
|
|
222
|
+
fetchMock.mockRejectedValueOnce(
|
|
223
|
+
new TypeError("fetch failed: ECONNREFUSED"),
|
|
224
|
+
);
|
|
225
|
+
const r = await validateKey("openai", KEYS.openai);
|
|
226
|
+
expect(r.ok).toBe(false);
|
|
227
|
+
expect(r.error).toBeDefined();
|
|
228
|
+
expect(r.error).not.toContain(KEYS.openai);
|
|
229
|
+
// friendlyNetworkError should produce a short message
|
|
230
|
+
expect(r.error!.length).toBeLessThan(150);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it("abort (timeout) returns ok:false with friendly message", async () => {
|
|
234
|
+
fetchMock.mockRejectedValueOnce(
|
|
235
|
+
Object.assign(new Error("aborted"), { name: "AbortError" }),
|
|
236
|
+
);
|
|
237
|
+
const r = await validateKey("openai", KEYS.openai);
|
|
238
|
+
expect(r.ok).toBe(false);
|
|
239
|
+
expect(r.error).toMatch(/timed out|abort/i);
|
|
240
|
+
});
|
|
241
|
+
});
|
package/src/auth/validator.ts
CHANGED
|
@@ -107,7 +107,7 @@ async function validateOpenAI(key: string): Promise<ValidationResult> {
|
|
|
107
107
|
return {
|
|
108
108
|
ok: false,
|
|
109
109
|
statusCode: res.status,
|
|
110
|
-
error: normalizeOpenAIError(res.status, rawBody),
|
|
110
|
+
error: normalizeOpenAIError(res.status, rawBody, key),
|
|
111
111
|
rawBody,
|
|
112
112
|
};
|
|
113
113
|
}
|
|
@@ -142,7 +142,7 @@ async function validateAnthropic(key: string): Promise<ValidationResult> {
|
|
|
142
142
|
return {
|
|
143
143
|
ok: false,
|
|
144
144
|
statusCode: res.status,
|
|
145
|
-
error: normalizeAnthropicError(res.status, rawBody),
|
|
145
|
+
error: normalizeAnthropicError(res.status, rawBody, key),
|
|
146
146
|
rawBody,
|
|
147
147
|
};
|
|
148
148
|
}
|
|
@@ -193,28 +193,36 @@ async function validateGoogle(key: string): Promise<ValidationResult> {
|
|
|
193
193
|
// Never echo the raw key back even by accident (defense in depth: we
|
|
194
194
|
// also redact anything that looks like the submitted key).
|
|
195
195
|
|
|
196
|
-
function normalizeOpenAIError(
|
|
196
|
+
function normalizeOpenAIError(
|
|
197
|
+
status: number,
|
|
198
|
+
rawBody: string,
|
|
199
|
+
submittedKey: string,
|
|
200
|
+
): string {
|
|
197
201
|
// OpenAI shape: { "error": { "message": "...", "type": "...", "code": "..." } }
|
|
198
202
|
try {
|
|
199
203
|
const parsed = JSON.parse(rawBody) as {
|
|
200
204
|
error?: { message?: string; type?: string; code?: string };
|
|
201
205
|
};
|
|
202
206
|
const msg = parsed.error?.message;
|
|
203
|
-
if (msg) return `[${status}] OpenAI: ${truncate(msg, 200)}`;
|
|
207
|
+
if (msg) return `[${status}] OpenAI: ${truncate(redactKey(msg, submittedKey), 200)}`;
|
|
204
208
|
} catch {
|
|
205
209
|
/* fall through */
|
|
206
210
|
}
|
|
207
211
|
return statusOnlyMessage("OpenAI", status);
|
|
208
212
|
}
|
|
209
213
|
|
|
210
|
-
function normalizeAnthropicError(
|
|
214
|
+
function normalizeAnthropicError(
|
|
215
|
+
status: number,
|
|
216
|
+
rawBody: string,
|
|
217
|
+
submittedKey: string,
|
|
218
|
+
): string {
|
|
211
219
|
// Anthropic shape: { "type": "error", "error": { "type": "...", "message": "..." } }
|
|
212
220
|
try {
|
|
213
221
|
const parsed = JSON.parse(rawBody) as {
|
|
214
222
|
error?: { type?: string; message?: string };
|
|
215
223
|
};
|
|
216
224
|
const msg = parsed.error?.message;
|
|
217
|
-
if (msg) return `[${status}] Anthropic: ${truncate(msg, 200)}`;
|
|
225
|
+
if (msg) return `[${status}] Anthropic: ${truncate(redactKey(msg, submittedKey), 200)}`;
|
|
218
226
|
} catch {
|
|
219
227
|
/* fall through */
|
|
220
228
|
}
|
|
@@ -231,20 +239,25 @@ function normalizeGoogleError(
|
|
|
231
239
|
const parsed = JSON.parse(rawBody) as {
|
|
232
240
|
error?: { code?: number; message?: string; status?: string };
|
|
233
241
|
};
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
// error messages (e.g. "API key not valid. Pass a valid API key.")
|
|
237
|
-
// — we don't ship the actual key value if it ever ends up here.
|
|
238
|
-
if (submittedKey && msg.includes(submittedKey)) {
|
|
239
|
-
msg = msg.replace(submittedKey, "<redacted-key>");
|
|
240
|
-
}
|
|
241
|
-
if (msg) return `[${status}] Google: ${truncate(msg, 200)}`;
|
|
242
|
+
const msg = parsed.error?.message ?? "";
|
|
243
|
+
if (msg) return `[${status}] Google: ${truncate(redactKey(msg, submittedKey), 200)}`;
|
|
242
244
|
} catch {
|
|
243
245
|
/* fall through */
|
|
244
246
|
}
|
|
245
247
|
return statusOnlyMessage("Google", status);
|
|
246
248
|
}
|
|
247
249
|
|
|
250
|
+
/**
|
|
251
|
+
* Belt-and-braces: if a provider echoes the submitted key in its
|
|
252
|
+
* error body, redact before surfacing to the user. Codex's v0.8 P2 #6
|
|
253
|
+
* found this gap (originally Google-only); now applied to all three
|
|
254
|
+
* providers via this shared helper.
|
|
255
|
+
*/
|
|
256
|
+
function redactKey(msg: string, key: string): string {
|
|
257
|
+
if (!key || !msg.includes(key)) return msg;
|
|
258
|
+
return msg.split(key).join("<redacted-key>");
|
|
259
|
+
}
|
|
260
|
+
|
|
248
261
|
function statusOnlyMessage(provider: string, status: number): string {
|
|
249
262
|
if (status === 401 || status === 403) {
|
|
250
263
|
return `[${status}] ${provider} rejected the key (unauthorized).`;
|
package/src/index.ts
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* - budget/ ✅ task #14 BudgetTracker + BudgetExceededError
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
-
export const VERSION = "0.10.
|
|
16
|
+
export const VERSION = "0.10.1";
|
|
17
17
|
|
|
18
18
|
// MCP server (v0.9.0+) — exposes AlmightyGPT's review surface as MCP tools.
|
|
19
19
|
export { startMcpServer } from "./mcp/server.js";
|