@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.
@@ -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
+ });
@@ -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(status: number, rawBody: string): string {
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(status: number, rawBody: string): string {
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
- let msg = parsed.error?.message ?? "";
235
- // Belt-and-braces redaction: Google sometimes echoes the key in
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.0";
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";