@almightygpt/core 0.10.0 → 0.11.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/dist/adapters/defaults.d.ts.map +1 -1
- package/dist/adapters/defaults.js +5 -0
- package/dist/adapters/defaults.js.map +1 -1
- package/dist/adapters/factory.d.ts.map +1 -1
- package/dist/adapters/factory.js +11 -1
- package/dist/adapters/factory.js.map +1 -1
- package/dist/adapters/index.d.ts +9 -5
- package/dist/adapters/index.d.ts.map +1 -1
- package/dist/adapters/index.js +9 -5
- package/dist/adapters/index.js.map +1 -1
- package/dist/adapters/ollama.d.ts +47 -0
- package/dist/adapters/ollama.d.ts.map +1 -0
- package/dist/adapters/ollama.js +124 -0
- package/dist/adapters/ollama.js.map +1 -0
- package/dist/adapters/openrouter.d.ts +50 -0
- package/dist/adapters/openrouter.d.ts.map +1 -0
- package/dist/adapters/openrouter.js +148 -0
- package/dist/adapters/openrouter.js.map +1 -0
- 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 +200 -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/types.d.ts +1 -1
- package/dist/auth/types.d.ts.map +1 -1
- package/dist/auth/types.js +8 -0
- package/dist/auth/types.js.map +1 -1
- package/dist/auth/validator.d.ts.map +1 -1
- package/dist/auth/validator.js +117 -11
- package/dist/auth/validator.js.map +1 -1
- package/dist/config/schema.d.ts +12 -12
- package/dist/config/schema.d.ts.map +1 -1
- package/dist/config/schema.js +8 -1
- package/dist/config/schema.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/package.json +4 -2
- package/src/adapters/defaults.ts +5 -0
- package/src/adapters/factory.ts +11 -1
- package/src/adapters/index.ts +9 -5
- package/src/adapters/ollama.ts +157 -0
- package/src/adapters/openrouter.ts +194 -0
- package/src/auth/__tests__/keychain.test.ts +171 -0
- package/src/auth/__tests__/resolver.test.ts +255 -0
- package/src/auth/__tests__/validator.test.ts +241 -0
- package/src/auth/types.ts +15 -1
- package/src/auth/validator.ts +130 -11
- package/src/config/schema.ts +8 -1
- package/src/index.ts +1 -1
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ollama adapter (v0.12.0+).
|
|
3
|
+
*
|
|
4
|
+
* Ollama runs LLMs locally — no cloud calls, no API key, code never
|
|
5
|
+
* leaves the machine. Opens AlmightyGPT to regulated / government /
|
|
6
|
+
* air-gapped enterprise users who can't use cloud AI but want the
|
|
7
|
+
* Worker/Reviewer convention.
|
|
8
|
+
*
|
|
9
|
+
* Ollama exposes an OpenAI-compatible API at http://localhost:11434/v1,
|
|
10
|
+
* so this adapter is a thin wrapper around the OpenAI SDK with a
|
|
11
|
+
* custom baseURL.
|
|
12
|
+
*
|
|
13
|
+
* Requires the user to have Ollama installed + running locally. See
|
|
14
|
+
* https://ollama.ai. The adapter does NOT install Ollama or pull
|
|
15
|
+
* models — that's the user's setup step.
|
|
16
|
+
*
|
|
17
|
+
* Default model: llama3.3:70b (large, very capable for code review).
|
|
18
|
+
* Users with smaller machines should override via config:
|
|
19
|
+
* - llama3.2:3b (3B parameter, runs on most laptops)
|
|
20
|
+
* - qwen2.5-coder:7b (7B, code-tuned, decent for review)
|
|
21
|
+
* - qwen2.5-coder:32b (32B, much stronger for code, needs ~24GB RAM)
|
|
22
|
+
* - deepseek-r1:14b (14B, reasoning-tuned)
|
|
23
|
+
*
|
|
24
|
+
* No auth, no API key, no pricing — local model.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import OpenAI from "openai";
|
|
28
|
+
import { AdapterError, type Adapter, type AdapterInput, type AdapterOutput } from "./types.js";
|
|
29
|
+
import { DEFAULT_MODELS } from "./defaults.js";
|
|
30
|
+
|
|
31
|
+
const DEFAULT_BASE_URL = "http://localhost:11434/v1";
|
|
32
|
+
const DEFAULT_MODEL = DEFAULT_MODELS.ollama;
|
|
33
|
+
|
|
34
|
+
export interface OllamaAdapterOptions {
|
|
35
|
+
/** Override the local Ollama base URL. Defaults to http://localhost:11434/v1. */
|
|
36
|
+
baseUrl?: string;
|
|
37
|
+
defaultModel?: string;
|
|
38
|
+
defaultMaxOutputTokens?: number;
|
|
39
|
+
defaultTimeoutMs?: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export class OllamaAdapter implements Adapter {
|
|
43
|
+
readonly name: string;
|
|
44
|
+
readonly provider = "ollama";
|
|
45
|
+
|
|
46
|
+
private readonly client: OpenAI;
|
|
47
|
+
readonly defaultModel: string;
|
|
48
|
+
private readonly defaultMaxOutputTokens: number;
|
|
49
|
+
private readonly defaultTimeoutMs: number;
|
|
50
|
+
private readonly baseUrl: string;
|
|
51
|
+
|
|
52
|
+
constructor(name = "ollama", options: OllamaAdapterOptions = {}) {
|
|
53
|
+
this.name = name;
|
|
54
|
+
this.defaultModel = options.defaultModel ?? DEFAULT_MODEL;
|
|
55
|
+
// Larger default — local models often run slower than cloud, and
|
|
56
|
+
// shorter responses are rarely the bottleneck.
|
|
57
|
+
this.defaultMaxOutputTokens = options.defaultMaxOutputTokens ?? 8192;
|
|
58
|
+
// Longer default — large local models can take 30-90s on CPU/GPU.
|
|
59
|
+
this.defaultTimeoutMs = options.defaultTimeoutMs ?? 300_000;
|
|
60
|
+
this.baseUrl =
|
|
61
|
+
options.baseUrl ?? process.env["OLLAMA_BASE_URL"] ?? DEFAULT_BASE_URL;
|
|
62
|
+
|
|
63
|
+
// OpenAI SDK requires SOMETHING for apiKey even when the server
|
|
64
|
+
// doesn't validate it. "ollama" is the documented sentinel.
|
|
65
|
+
this.client = new OpenAI({
|
|
66
|
+
apiKey: "ollama",
|
|
67
|
+
baseURL: this.baseUrl,
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Ollama is "available" if the local daemon responds. */
|
|
72
|
+
async isAvailable(): Promise<boolean> {
|
|
73
|
+
try {
|
|
74
|
+
// Cheap probe — Ollama's /api/tags lists local models. If the
|
|
75
|
+
// daemon isn't running, this throws / times out fast.
|
|
76
|
+
const probeUrl = this.baseUrl.replace(/\/v1\/?$/, "") + "/api/tags";
|
|
77
|
+
const controller = new AbortController();
|
|
78
|
+
const timer = setTimeout(() => controller.abort(), 2000);
|
|
79
|
+
try {
|
|
80
|
+
const res = await fetch(probeUrl, { signal: controller.signal });
|
|
81
|
+
return res.ok;
|
|
82
|
+
} finally {
|
|
83
|
+
clearTimeout(timer);
|
|
84
|
+
}
|
|
85
|
+
} catch {
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async execute(input: AdapterInput): Promise<AdapterOutput> {
|
|
91
|
+
const model = input.model ?? this.defaultModel;
|
|
92
|
+
const maxOutputTokens = input.maxOutputTokens ?? this.defaultMaxOutputTokens;
|
|
93
|
+
const timeoutMs = input.timeoutMs ?? this.defaultTimeoutMs;
|
|
94
|
+
|
|
95
|
+
const start = Date.now();
|
|
96
|
+
let response: OpenAI.Chat.Completions.ChatCompletion;
|
|
97
|
+
try {
|
|
98
|
+
response = await this.client.chat.completions.create(
|
|
99
|
+
{
|
|
100
|
+
model,
|
|
101
|
+
max_tokens: maxOutputTokens,
|
|
102
|
+
messages: [
|
|
103
|
+
{ role: "system", content: input.systemPrompt },
|
|
104
|
+
{ role: "user", content: input.userMessage },
|
|
105
|
+
],
|
|
106
|
+
},
|
|
107
|
+
{ timeout: timeoutMs },
|
|
108
|
+
);
|
|
109
|
+
} catch (err) {
|
|
110
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
111
|
+
// Common failure: Ollama daemon isn't running.
|
|
112
|
+
if (message.includes("ECONNREFUSED") || message.includes("fetch failed")) {
|
|
113
|
+
throw new AdapterError(
|
|
114
|
+
`Ollama daemon unreachable at ${this.baseUrl}. ` +
|
|
115
|
+
"Is Ollama installed and running? Try `ollama serve` or " +
|
|
116
|
+
"open the Ollama app.",
|
|
117
|
+
this.name,
|
|
118
|
+
err,
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
// Common failure: model not pulled locally.
|
|
122
|
+
if (message.includes("model") && message.includes("not found")) {
|
|
123
|
+
throw new AdapterError(
|
|
124
|
+
`Ollama model "${model}" not found locally. Pull it first: ` +
|
|
125
|
+
`\`ollama pull ${model}\``,
|
|
126
|
+
this.name,
|
|
127
|
+
err,
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
throw new AdapterError(
|
|
131
|
+
`Ollama call failed: ${message}`,
|
|
132
|
+
this.name,
|
|
133
|
+
err,
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const choice = response.choices[0];
|
|
138
|
+
if (!choice || !choice.message.content) {
|
|
139
|
+
throw new AdapterError(
|
|
140
|
+
"Ollama returned an empty response.",
|
|
141
|
+
this.name,
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return {
|
|
146
|
+
content: choice.message.content,
|
|
147
|
+
tokensIn: response.usage?.prompt_tokens ?? 0,
|
|
148
|
+
cachedTokensIn: 0,
|
|
149
|
+
tokensOut: response.usage?.completion_tokens ?? 0,
|
|
150
|
+
// Local models are $0. Always.
|
|
151
|
+
costUsd: 0,
|
|
152
|
+
latencyMs: Date.now() - start,
|
|
153
|
+
modelUsed: response.model ?? model,
|
|
154
|
+
provider: this.provider,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
}
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenRouter adapter (v0.12.0+).
|
|
3
|
+
*
|
|
4
|
+
* OpenRouter is a meta-provider that fronts 100+ models behind a single
|
|
5
|
+
* OpenAI-compatible API. Adding it unlocks DeepSeek, Mistral, Codestral,
|
|
6
|
+
* Llama (hosted), Grok, Qwen, and many others without us shipping one
|
|
7
|
+
* adapter per provider.
|
|
8
|
+
*
|
|
9
|
+
* Reads OPENROUTER_API_KEY from the environment (resolver: explicit →
|
|
10
|
+
* env → keychain → missing).
|
|
11
|
+
*
|
|
12
|
+
* Default model: deepseek/deepseek-chat — cheap, capable, popular OR
|
|
13
|
+
* pick. Users override via the `model` field in their config's agents
|
|
14
|
+
* map, e.g. `model: "anthropic/claude-haiku-4-5"`.
|
|
15
|
+
*
|
|
16
|
+
* Model naming: `<provider>/<model>` as required by OpenRouter
|
|
17
|
+
* (e.g. `deepseek/deepseek-chat`, `meta-llama/llama-3.3-70b-instruct`).
|
|
18
|
+
*
|
|
19
|
+
* Pricing: passed through from OpenRouter's response. Their `usage`
|
|
20
|
+
* object includes a `cost` field (USD) when available; otherwise we
|
|
21
|
+
* estimate from a small pricing table for popular models.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import OpenAI from "openai";
|
|
25
|
+
import { AdapterError, type Adapter, type AdapterInput, type AdapterOutput } from "./types.js";
|
|
26
|
+
import { resolveApiKey } from "../auth/resolver.js";
|
|
27
|
+
import { DEFAULT_MODELS } from "./defaults.js";
|
|
28
|
+
|
|
29
|
+
const OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1";
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Fallback pricing for popular OpenRouter models in case the API doesn't
|
|
33
|
+
* return cost. USD per 1M tokens. Match by prefix (startsWith).
|
|
34
|
+
*
|
|
35
|
+
* Source: https://openrouter.ai/models (verify periodically).
|
|
36
|
+
*/
|
|
37
|
+
const PRICING_USD_PER_1M: Record<string, { input: number; output: number }> = {
|
|
38
|
+
"deepseek/deepseek-chat": { input: 0.27, output: 1.1 },
|
|
39
|
+
"deepseek/deepseek-coder": { input: 0.27, output: 1.1 },
|
|
40
|
+
"anthropic/claude-haiku-4-5": { input: 1.0, output: 5.0 },
|
|
41
|
+
"anthropic/claude-sonnet-4-6": { input: 3.0, output: 15.0 },
|
|
42
|
+
"openai/gpt-4o-mini": { input: 0.15, output: 0.6 },
|
|
43
|
+
"openai/gpt-4o": { input: 2.5, output: 10.0 },
|
|
44
|
+
"meta-llama/llama-3.3-70b-instruct": { input: 0.59, output: 0.79 },
|
|
45
|
+
"qwen/qwen-2.5-coder-32b-instruct": { input: 0.07, output: 0.16 },
|
|
46
|
+
"mistralai/codestral-2508": { input: 0.3, output: 0.9 },
|
|
47
|
+
"x-ai/grok-2": { input: 2.0, output: 10.0 },
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const DEFAULT_MODEL = DEFAULT_MODELS.openrouter;
|
|
51
|
+
|
|
52
|
+
export interface OpenRouterAdapterOptions {
|
|
53
|
+
apiKey?: string;
|
|
54
|
+
defaultModel?: string;
|
|
55
|
+
defaultMaxOutputTokens?: number;
|
|
56
|
+
defaultTimeoutMs?: number;
|
|
57
|
+
/** Optional HTTP-Referer for OR's stats / leaderboards. */
|
|
58
|
+
appUrl?: string;
|
|
59
|
+
/** Optional X-Title for OR's stats / leaderboards. */
|
|
60
|
+
appTitle?: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export class OpenRouterAdapter implements Adapter {
|
|
64
|
+
readonly name: string;
|
|
65
|
+
readonly provider = "openrouter";
|
|
66
|
+
|
|
67
|
+
private client: OpenAI | null = null;
|
|
68
|
+
private resolved = false;
|
|
69
|
+
private readonly explicitApiKey: string | undefined;
|
|
70
|
+
private readonly appUrl: string | undefined;
|
|
71
|
+
private readonly appTitle: string | undefined;
|
|
72
|
+
readonly defaultModel: string;
|
|
73
|
+
private readonly defaultMaxOutputTokens: number;
|
|
74
|
+
private readonly defaultTimeoutMs: number;
|
|
75
|
+
|
|
76
|
+
constructor(name = "openrouter", options: OpenRouterAdapterOptions = {}) {
|
|
77
|
+
this.name = name;
|
|
78
|
+
this.defaultModel = options.defaultModel ?? DEFAULT_MODEL;
|
|
79
|
+
this.defaultMaxOutputTokens = options.defaultMaxOutputTokens ?? 4096;
|
|
80
|
+
this.defaultTimeoutMs = options.defaultTimeoutMs ?? 120_000;
|
|
81
|
+
this.explicitApiKey = options.apiKey;
|
|
82
|
+
this.appUrl = options.appUrl ?? "https://github.com/roxjayanath/almightygpt";
|
|
83
|
+
this.appTitle = options.appTitle ?? "AlmightyGPT";
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
private async ensureClient(): Promise<OpenAI | null> {
|
|
87
|
+
if (this.resolved) return this.client;
|
|
88
|
+
this.resolved = true;
|
|
89
|
+
const opts = this.explicitApiKey ? { explicit: this.explicitApiKey } : {};
|
|
90
|
+
const result = await resolveApiKey("openrouter", opts);
|
|
91
|
+
if (result.source === "missing") return null;
|
|
92
|
+
this.client = new OpenAI({
|
|
93
|
+
apiKey: result.key!,
|
|
94
|
+
baseURL: OPENROUTER_BASE_URL,
|
|
95
|
+
defaultHeaders: {
|
|
96
|
+
...(this.appUrl ? { "HTTP-Referer": this.appUrl } : {}),
|
|
97
|
+
...(this.appTitle ? { "X-Title": this.appTitle } : {}),
|
|
98
|
+
},
|
|
99
|
+
});
|
|
100
|
+
return this.client;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async isAvailable(): Promise<boolean> {
|
|
104
|
+
return (await this.ensureClient()) !== null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async execute(input: AdapterInput): Promise<AdapterOutput> {
|
|
108
|
+
const client = await this.ensureClient();
|
|
109
|
+
if (!client) {
|
|
110
|
+
throw new AdapterError(
|
|
111
|
+
'No OpenRouter API key found. Run "almightygpt auth openrouter" ' +
|
|
112
|
+
"or export OPENROUTER_API_KEY in your environment.",
|
|
113
|
+
this.name,
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const model = input.model ?? this.defaultModel;
|
|
118
|
+
const maxOutputTokens = input.maxOutputTokens ?? this.defaultMaxOutputTokens;
|
|
119
|
+
const timeoutMs = input.timeoutMs ?? this.defaultTimeoutMs;
|
|
120
|
+
|
|
121
|
+
const start = Date.now();
|
|
122
|
+
let response: OpenAI.Chat.Completions.ChatCompletion;
|
|
123
|
+
try {
|
|
124
|
+
response = await client.chat.completions.create(
|
|
125
|
+
{
|
|
126
|
+
model,
|
|
127
|
+
max_tokens: maxOutputTokens,
|
|
128
|
+
messages: [
|
|
129
|
+
{ role: "system", content: input.systemPrompt },
|
|
130
|
+
{ role: "user", content: input.userMessage },
|
|
131
|
+
],
|
|
132
|
+
...(input.responseFormat === "json_object"
|
|
133
|
+
? { response_format: { type: "json_object" } }
|
|
134
|
+
: {}),
|
|
135
|
+
},
|
|
136
|
+
{ timeout: timeoutMs },
|
|
137
|
+
);
|
|
138
|
+
} catch (err) {
|
|
139
|
+
throw new AdapterError(
|
|
140
|
+
`OpenRouter call failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
141
|
+
this.name,
|
|
142
|
+
err,
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const choice = response.choices[0];
|
|
147
|
+
if (!choice || !choice.message.content) {
|
|
148
|
+
throw new AdapterError(
|
|
149
|
+
"OpenRouter returned an empty response.",
|
|
150
|
+
this.name,
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const content = choice.message.content;
|
|
155
|
+
const usage = response.usage as
|
|
156
|
+
| (typeof response.usage & {
|
|
157
|
+
/** OpenRouter returns the actual cost in USD when available. */
|
|
158
|
+
cost?: number;
|
|
159
|
+
})
|
|
160
|
+
| undefined;
|
|
161
|
+
const tokensIn = usage?.prompt_tokens ?? 0;
|
|
162
|
+
const tokensOut = usage?.completion_tokens ?? 0;
|
|
163
|
+
// Prefer OR's reported cost (real, billed); fall back to estimate.
|
|
164
|
+
const costUsd =
|
|
165
|
+
typeof usage?.cost === "number"
|
|
166
|
+
? usage.cost
|
|
167
|
+
: estimateCostUsd(model, tokensIn, tokensOut);
|
|
168
|
+
|
|
169
|
+
return {
|
|
170
|
+
content,
|
|
171
|
+
tokensIn,
|
|
172
|
+
cachedTokensIn: 0,
|
|
173
|
+
tokensOut,
|
|
174
|
+
costUsd,
|
|
175
|
+
latencyMs: Date.now() - start,
|
|
176
|
+
modelUsed: response.model ?? model,
|
|
177
|
+
provider: this.provider,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function estimateCostUsd(
|
|
183
|
+
model: string,
|
|
184
|
+
tokensIn: number,
|
|
185
|
+
tokensOut: number,
|
|
186
|
+
): number {
|
|
187
|
+
const key = Object.keys(PRICING_USD_PER_1M).find((k) =>
|
|
188
|
+
model.toLowerCase().startsWith(k),
|
|
189
|
+
);
|
|
190
|
+
if (!key) return 0;
|
|
191
|
+
const rates = PRICING_USD_PER_1M[key];
|
|
192
|
+
if (!rates) return 0;
|
|
193
|
+
return (tokensIn / 1_000_000) * rates.input + (tokensOut / 1_000_000) * rates.output;
|
|
194
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keychain adapter tests.
|
|
3
|
+
*
|
|
4
|
+
* Strategy (addresses Codex's v0.8 plan-review R3 — "KeychainAdapter
|
|
5
|
+
* dependency injection"): we mock the dynamic import of
|
|
6
|
+
* `@napi-rs/keyring` itself, replacing the Entry class with a fake
|
|
7
|
+
* we control per test. This sidesteps needing to refactor the real
|
|
8
|
+
* code to accept an injected adapter — the dynamic-import boundary
|
|
9
|
+
* IS the seam we test against.
|
|
10
|
+
*
|
|
11
|
+
* Codex's main concern (P2 #4) was that read failures were silently
|
|
12
|
+
* converted into "absent". These tests prove the new behavior:
|
|
13
|
+
* - found → { kind: "found", key }
|
|
14
|
+
* - absent → { kind: "absent" }
|
|
15
|
+
* - throws → { kind: "error", message } (was: silently dropped)
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { describe, it, expect, beforeEach, vi } from "vitest";
|
|
19
|
+
import { _resetKeychainCache } from "../keychain.js";
|
|
20
|
+
|
|
21
|
+
// Track the entry methods per test so we can rewire mid-suite.
|
|
22
|
+
let mockGetPassword: () => string | null;
|
|
23
|
+
let mockSetPassword: (key: string) => void;
|
|
24
|
+
let mockDeletePassword: () => boolean;
|
|
25
|
+
let constructorThrows = false;
|
|
26
|
+
let importThrows = false;
|
|
27
|
+
|
|
28
|
+
vi.mock("@napi-rs/keyring", () => ({
|
|
29
|
+
get Entry() {
|
|
30
|
+
if (importThrows) throw new Error("synthetic import failure");
|
|
31
|
+
return class FakeEntry {
|
|
32
|
+
constructor(_service: string, _account: string) {
|
|
33
|
+
if (constructorThrows) {
|
|
34
|
+
throw new Error("native binding refused to initialize");
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
getPassword() {
|
|
38
|
+
return mockGetPassword();
|
|
39
|
+
}
|
|
40
|
+
setPassword(p: string) {
|
|
41
|
+
mockSetPassword(p);
|
|
42
|
+
}
|
|
43
|
+
deletePassword() {
|
|
44
|
+
return mockDeletePassword();
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
},
|
|
48
|
+
}));
|
|
49
|
+
|
|
50
|
+
import { getKeychain } from "../keychain.js";
|
|
51
|
+
|
|
52
|
+
beforeEach(() => {
|
|
53
|
+
_resetKeychainCache();
|
|
54
|
+
mockGetPassword = () => null;
|
|
55
|
+
mockSetPassword = () => {};
|
|
56
|
+
mockDeletePassword = () => true;
|
|
57
|
+
constructorThrows = false;
|
|
58
|
+
importThrows = false;
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe("keychain adapter — availability", () => {
|
|
62
|
+
it("reports available=true when native binding loads cleanly", async () => {
|
|
63
|
+
const kc = await getKeychain();
|
|
64
|
+
expect(kc.available).toBe(true);
|
|
65
|
+
expect(kc.describeBackend()).not.toContain("unavailable");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("reports available=false + reason when native binding throws on init", async () => {
|
|
69
|
+
constructorThrows = true;
|
|
70
|
+
const kc = await getKeychain();
|
|
71
|
+
expect(kc.available).toBe(false);
|
|
72
|
+
expect(kc.describeBackend()).toContain("unavailable");
|
|
73
|
+
expect(kc.describeBackend()).toMatch(/initialize|native/i);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("get on unavailable adapter returns { kind: 'absent' } (not throw)", async () => {
|
|
77
|
+
constructorThrows = true;
|
|
78
|
+
const kc = await getKeychain();
|
|
79
|
+
const r = await kc.get("openai");
|
|
80
|
+
expect(r).toEqual({ kind: "absent" });
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("set on unavailable adapter throws with a clear hint", async () => {
|
|
84
|
+
constructorThrows = true;
|
|
85
|
+
const kc = await getKeychain();
|
|
86
|
+
await expect(kc.set("openai", "x")).rejects.toThrow(/unavailable|environment/i);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe("keychain adapter — get behavior (Codex P2 #4 regression coverage)", () => {
|
|
91
|
+
it("found path returns { kind: 'found', key }", async () => {
|
|
92
|
+
mockGetPassword = () => "stored-secret";
|
|
93
|
+
const kc = await getKeychain();
|
|
94
|
+
const r = await kc.get("anthropic");
|
|
95
|
+
expect(r).toEqual({ kind: "found", key: "stored-secret" });
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("absent path returns { kind: 'absent' } when getPassword returns null", async () => {
|
|
99
|
+
mockGetPassword = () => null;
|
|
100
|
+
const kc = await getKeychain();
|
|
101
|
+
const r = await kc.get("openai");
|
|
102
|
+
expect(r).toEqual({ kind: "absent" });
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("error path returns { kind: 'error', message } when getPassword throws — NOT silently absent", async () => {
|
|
106
|
+
mockGetPassword = () => {
|
|
107
|
+
throw new Error("keyring locked by OS");
|
|
108
|
+
};
|
|
109
|
+
const kc = await getKeychain();
|
|
110
|
+
const r = await kc.get("openai");
|
|
111
|
+
expect(r.kind).toBe("error");
|
|
112
|
+
if (r.kind === "error") {
|
|
113
|
+
expect(r.message).toContain("keyring locked");
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("error path preserves non-Error throws via String(err)", async () => {
|
|
118
|
+
mockGetPassword = () => {
|
|
119
|
+
throw "raw string error";
|
|
120
|
+
};
|
|
121
|
+
const kc = await getKeychain();
|
|
122
|
+
const r = await kc.get("openai");
|
|
123
|
+
expect(r.kind).toBe("error");
|
|
124
|
+
if (r.kind === "error") {
|
|
125
|
+
expect(r.message).toContain("raw string error");
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
describe("keychain adapter — set / remove", () => {
|
|
131
|
+
it("set forwards the key to the underlying Entry", async () => {
|
|
132
|
+
const writes: string[] = [];
|
|
133
|
+
mockSetPassword = (p) => writes.push(p);
|
|
134
|
+
const kc = await getKeychain();
|
|
135
|
+
await kc.set("google", "new-key");
|
|
136
|
+
expect(writes).toEqual(["new-key"]);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("remove returns true when entry existed", async () => {
|
|
140
|
+
mockDeletePassword = () => true;
|
|
141
|
+
const kc = await getKeychain();
|
|
142
|
+
const removed = await kc.remove("openai");
|
|
143
|
+
expect(removed).toBe(true);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("remove returns false (not throws) when entry didn't exist", async () => {
|
|
147
|
+
mockDeletePassword = () => {
|
|
148
|
+
throw new Error("not found");
|
|
149
|
+
};
|
|
150
|
+
const kc = await getKeychain();
|
|
151
|
+
const removed = await kc.remove("openai");
|
|
152
|
+
expect(removed).toBe(false);
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
describe("keychain adapter — caching", () => {
|
|
157
|
+
it("getKeychain returns the same instance across calls (singleton)", async () => {
|
|
158
|
+
const a = await getKeychain();
|
|
159
|
+
const b = await getKeychain();
|
|
160
|
+
expect(a).toBe(b);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("_resetKeychainCache forces a fresh load on next getKeychain", async () => {
|
|
164
|
+
const a = await getKeychain();
|
|
165
|
+
_resetKeychainCache();
|
|
166
|
+
constructorThrows = true; // change behavior between loads
|
|
167
|
+
const b = await getKeychain();
|
|
168
|
+
expect(a.available).toBe(true);
|
|
169
|
+
expect(b.available).toBe(false);
|
|
170
|
+
});
|
|
171
|
+
});
|