@moxxy/plugin-provider-admin 0.26.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/LICENSE +21 -0
- package/dist/factory.d.ts +12 -0
- package/dist/factory.d.ts.map +1 -0
- package/dist/factory.js +35 -0
- package/dist/factory.js.map +1 -0
- package/dist/index.d.ts +92 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +520 -0
- package/dist/index.js.map +1 -0
- package/dist/key-name.d.ts +27 -0
- package/dist/key-name.d.ts.map +1 -0
- package/dist/key-name.js +34 -0
- package/dist/key-name.js.map +1 -0
- package/dist/store.d.ts +12 -0
- package/dist/store.d.ts.map +1 -0
- package/dist/store.js +108 -0
- package/dist/store.js.map +1 -0
- package/dist/types.d.ts +36 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/package.json +64 -0
- package/src/configure.test.ts +157 -0
- package/src/discovery.test.ts +40 -0
- package/src/factory.test.ts +36 -0
- package/src/factory.ts +47 -0
- package/src/index.test.ts +662 -0
- package/src/index.ts +660 -0
- package/src/key-name.test.ts +61 -0
- package/src/key-name.ts +39 -0
- package/src/store.test.ts +115 -0
- package/src/store.ts +123 -0
- package/src/types.ts +38 -0
|
@@ -0,0 +1,662 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { promises as fs } from 'node:fs';
|
|
3
|
+
import * as os from 'node:os';
|
|
4
|
+
import * as path from 'node:path';
|
|
5
|
+
import type { ProviderDef, ToolContext, ToolDef } from '@moxxy/sdk';
|
|
6
|
+
import { buildProviderAdminPlugin, buildProviderAdminPluginWithApi, type ProviderRegistryLike } from './index.js';
|
|
7
|
+
import { readProvidersConfig } from './store.js';
|
|
8
|
+
|
|
9
|
+
// Stub ONLY the network probe; buildProviderDef stays real. Lets the
|
|
10
|
+
// provider_test tests assert what key the validator received without ever
|
|
11
|
+
// hitting a vendor endpoint.
|
|
12
|
+
const validateMock = vi.hoisted(() =>
|
|
13
|
+
vi.fn(async (_key: string, _opts: { baseURL?: string }) => ({ ok: true as const })),
|
|
14
|
+
);
|
|
15
|
+
vi.mock('./factory.js', async (importOriginal) => {
|
|
16
|
+
const actual = await importOriginal<typeof import('./factory.js')>();
|
|
17
|
+
return { ...actual, validateOpenAICompatKey: validateMock };
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
class FakeRegistry implements ProviderRegistryLike {
|
|
21
|
+
defs = new Map<string, ProviderDef>();
|
|
22
|
+
instances = new Map<string, Record<string, unknown>>();
|
|
23
|
+
active: string | null = null;
|
|
24
|
+
register(def: ProviderDef): void {
|
|
25
|
+
if (this.defs.has(def.name)) throw new Error(`already registered: ${def.name}`);
|
|
26
|
+
this.defs.set(def.name, def);
|
|
27
|
+
}
|
|
28
|
+
replace(def: ProviderDef): void {
|
|
29
|
+
this.defs.set(def.name, def);
|
|
30
|
+
// Mirror core's ProviderRegistry: replace() drops the cached instance.
|
|
31
|
+
this.instances.delete(def.name);
|
|
32
|
+
}
|
|
33
|
+
unregister(name: string): void {
|
|
34
|
+
this.defs.delete(name);
|
|
35
|
+
this.instances.delete(name);
|
|
36
|
+
if (this.active === name) this.active = null;
|
|
37
|
+
}
|
|
38
|
+
list(): ReadonlyArray<ProviderDef> {
|
|
39
|
+
return [...this.defs.values()];
|
|
40
|
+
}
|
|
41
|
+
getActiveName(): string | null {
|
|
42
|
+
return this.active;
|
|
43
|
+
}
|
|
44
|
+
setActive(name: string, config?: Record<string, unknown>): unknown {
|
|
45
|
+
this.active = name;
|
|
46
|
+
const inst = config ?? {};
|
|
47
|
+
this.instances.set(name, inst);
|
|
48
|
+
return inst;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
let tmpDir: string;
|
|
53
|
+
let cfgPath: string;
|
|
54
|
+
let registry: FakeRegistry;
|
|
55
|
+
let tools: Map<string, ToolDef>;
|
|
56
|
+
|
|
57
|
+
beforeEach(async () => {
|
|
58
|
+
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mox-provider-admin-tools-'));
|
|
59
|
+
cfgPath = path.join(tmpDir, 'providers.json');
|
|
60
|
+
registry = new FakeRegistry();
|
|
61
|
+
const plugin = buildProviderAdminPlugin({ providerRegistry: registry, configPath: cfgPath });
|
|
62
|
+
tools = new Map((plugin.tools ?? []).map((t) => [t.name, t]));
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
afterEach(async () => {
|
|
66
|
+
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
function call(name: string, input: Record<string, unknown>): Promise<unknown> {
|
|
70
|
+
const tool = tools.get(name);
|
|
71
|
+
if (!tool) throw new Error(`no tool: ${name}`);
|
|
72
|
+
const parsed = tool.inputSchema.parse(input);
|
|
73
|
+
return Promise.resolve(tool.handler(parsed, {} as never));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const zaiInput = {
|
|
77
|
+
kind: 'openai-compat' as const,
|
|
78
|
+
name: 'zai',
|
|
79
|
+
baseURL: 'https://api.z.ai/api/coding/paas/v4',
|
|
80
|
+
defaultModel: 'glm-4.6',
|
|
81
|
+
models: [
|
|
82
|
+
{ id: 'glm-4.6', contextWindow: 200_000, supportsTools: true, supportsStreaming: true },
|
|
83
|
+
],
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
describe('provider_add', () => {
|
|
87
|
+
it('registers in the live registry AND persists to providers.json', async () => {
|
|
88
|
+
const result = (await call('provider_add', zaiInput)) as { ok: boolean; replaced: boolean };
|
|
89
|
+
expect(result.ok).toBe(true);
|
|
90
|
+
expect(result.replaced).toBe(false);
|
|
91
|
+
expect(registry.defs.has('zai')).toBe(true);
|
|
92
|
+
const cfg = await readProvidersConfig(cfgPath);
|
|
93
|
+
expect(cfg.providers.map((p) => p.name)).toEqual(['zai']);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('replaces an existing provider with the same slug', async () => {
|
|
97
|
+
await call('provider_add', zaiInput);
|
|
98
|
+
const second = (await call('provider_add', {
|
|
99
|
+
...zaiInput,
|
|
100
|
+
defaultModel: 'glm-4.5-air',
|
|
101
|
+
models: [
|
|
102
|
+
{ id: 'glm-4.5-air', contextWindow: 128_000, supportsTools: true, supportsStreaming: true },
|
|
103
|
+
],
|
|
104
|
+
})) as { ok: boolean; replaced: boolean };
|
|
105
|
+
expect(second.replaced).toBe(true);
|
|
106
|
+
const cfg = await readProvidersConfig(cfgPath);
|
|
107
|
+
expect(cfg.providers).toHaveLength(1);
|
|
108
|
+
expect(cfg.providers[0]!.defaultModel).toBe('glm-4.5-air');
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('rejects when defaultModel is not in the models list', async () => {
|
|
112
|
+
await expect(
|
|
113
|
+
call('provider_add', { ...zaiInput, defaultModel: 'not-in-list' }),
|
|
114
|
+
).rejects.toThrow(/not in the models list/);
|
|
115
|
+
expect(registry.defs.size).toBe(0);
|
|
116
|
+
const cfg = await readProvidersConfig(cfgPath);
|
|
117
|
+
expect(cfg.providers).toEqual([]);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('rejects invalid slug shapes via inputSchema', () => {
|
|
121
|
+
const tool = tools.get('provider_add')!;
|
|
122
|
+
const bad = tool.inputSchema.safeParse({ ...zaiInput, name: 'NotASlug' });
|
|
123
|
+
expect(bad.success).toBe(false);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('refuses to shadow a built-in provider but still adds a genuinely-new one', async () => {
|
|
127
|
+
// Simulate the host having already registered the built-in OpenAI provider
|
|
128
|
+
// (its def is in the registry before the plugin is built).
|
|
129
|
+
const withBuiltin = new FakeRegistry();
|
|
130
|
+
const builtinDef = { name: 'openai', models: [{ id: 'gpt-x', contextWindow: 1 }] } as unknown as ProviderDef;
|
|
131
|
+
withBuiltin.register(builtinDef);
|
|
132
|
+
const plugin = buildProviderAdminPlugin({ providerRegistry: withBuiltin, configPath: cfgPath });
|
|
133
|
+
const guardedTools = new Map((plugin.tools ?? []).map((t) => [t.name, t]));
|
|
134
|
+
const addBuiltin = (input: Record<string, unknown>): Promise<unknown> => {
|
|
135
|
+
const tool = guardedTools.get('provider_add')!;
|
|
136
|
+
return Promise.resolve(tool.handler(tool.inputSchema.parse(input), {} as never));
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
// Attempting to redirect 'openai' to an arbitrary baseURL must be rejected
|
|
140
|
+
// AND must leave the built-in def untouched + nothing persisted.
|
|
141
|
+
await expect(
|
|
142
|
+
addBuiltin({ ...zaiInput, name: 'openai', baseURL: 'https://evil.example.com/v1' }),
|
|
143
|
+
).rejects.toThrow(/built-in/i);
|
|
144
|
+
expect(withBuiltin.defs.get('openai')).toBe(builtinDef);
|
|
145
|
+
expect(await readProvidersConfig(cfgPath)).toEqual({ providers: [] });
|
|
146
|
+
|
|
147
|
+
// A genuinely-new slug still succeeds against the same registry.
|
|
148
|
+
const ok = (await addBuiltin(zaiInput)) as { ok: boolean; replaced: boolean };
|
|
149
|
+
expect(ok.ok).toBe(true);
|
|
150
|
+
expect(ok.replaced).toBe(false);
|
|
151
|
+
expect(withBuiltin.defs.has('zai')).toBe(true);
|
|
152
|
+
// The built-in is still its original def.
|
|
153
|
+
expect(withBuiltin.defs.get('openai')).toBe(builtinDef);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('restores the prior def (not deletes it) when the disk write fails on a replace', async () => {
|
|
157
|
+
// Seed a 'zai' entry on disk + run onInit so the PLUGIN owns the live def (a
|
|
158
|
+
// def WE registered, not an external built-in). A later provider_add of the
|
|
159
|
+
// same slug is a genuine replace; if the disk write fails the prior owned
|
|
160
|
+
// def must be restored, not deleted. The read path stays valid; the dir is
|
|
161
|
+
// made read-only so the atomic temp-file write rejects (rollback branch).
|
|
162
|
+
const reg = new FakeRegistry();
|
|
163
|
+
await fs.writeFile(cfgPath, JSON.stringify({ providers: [{ ...zaiInput, kind: 'openai-compat' }] }), 'utf8');
|
|
164
|
+
const plugin = buildProviderAdminPlugin({ providerRegistry: reg, configPath: cfgPath });
|
|
165
|
+
await plugin.hooks!.onInit!({} as never);
|
|
166
|
+
expect(reg.defs.has('zai')).toBe(true);
|
|
167
|
+
const priorDef = reg.defs.get('zai')!;
|
|
168
|
+
|
|
169
|
+
const addTool = plugin.tools!.find((t) => t.name === 'provider_add')!;
|
|
170
|
+
await fs.chmod(tmpDir, 0o500);
|
|
171
|
+
try {
|
|
172
|
+
await expect(
|
|
173
|
+
Promise.resolve(
|
|
174
|
+
addTool.handler(
|
|
175
|
+
addTool.inputSchema.parse({ ...zaiInput, defaultModel: 'glm-4.5-air', models: [{ id: 'glm-4.5-air', contextWindow: 1 }] }),
|
|
176
|
+
{} as never,
|
|
177
|
+
),
|
|
178
|
+
),
|
|
179
|
+
).rejects.toBeTruthy();
|
|
180
|
+
} finally {
|
|
181
|
+
await fs.chmod(tmpDir, 0o700);
|
|
182
|
+
}
|
|
183
|
+
// The owned def must STILL be present and UNCHANGED — restored, not deleted.
|
|
184
|
+
expect(reg.defs.get('zai')).toBe(priorDef);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('unregisters a brand-new provider when the disk write fails (no phantom)', async () => {
|
|
188
|
+
// Fresh slug (not owned, not in registry) → register + write. Make the dir
|
|
189
|
+
// read-only so the write fails; the phantom registration must be rolled back.
|
|
190
|
+
const reg = new FakeRegistry();
|
|
191
|
+
await fs.writeFile(cfgPath, JSON.stringify({ providers: [] }), 'utf8');
|
|
192
|
+
const plugin = buildProviderAdminPlugin({ providerRegistry: reg, configPath: cfgPath });
|
|
193
|
+
const addTool = plugin.tools!.find((t) => t.name === 'provider_add')!;
|
|
194
|
+
await fs.chmod(tmpDir, 0o500);
|
|
195
|
+
try {
|
|
196
|
+
await expect(
|
|
197
|
+
Promise.resolve(addTool.handler(addTool.inputSchema.parse(zaiInput), {} as never)),
|
|
198
|
+
).rejects.toBeTruthy();
|
|
199
|
+
} finally {
|
|
200
|
+
await fs.chmod(tmpDir, 0o700);
|
|
201
|
+
}
|
|
202
|
+
// Nothing left behind in the live registry.
|
|
203
|
+
expect(reg.defs.has('zai')).toBe(false);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it('persists supportsDocuments through the schema → ModelDescriptor chain', async () => {
|
|
207
|
+
// Previously the input schema had no supportsDocuments field, so zod
|
|
208
|
+
// STRIPPED it — attachments degraded to extracted text for every
|
|
209
|
+
// runtime-registered provider even when the vendor model takes PDFs.
|
|
210
|
+
await call('provider_add', {
|
|
211
|
+
...zaiInput,
|
|
212
|
+
models: [
|
|
213
|
+
{
|
|
214
|
+
id: 'glm-4.6',
|
|
215
|
+
contextWindow: 200_000,
|
|
216
|
+
supportsTools: true,
|
|
217
|
+
supportsStreaming: true,
|
|
218
|
+
supportsImages: true,
|
|
219
|
+
supportsDocuments: true,
|
|
220
|
+
},
|
|
221
|
+
],
|
|
222
|
+
});
|
|
223
|
+
const cfg = await readProvidersConfig(cfgPath);
|
|
224
|
+
expect(cfg.providers[0]!.models[0]).toMatchObject({ supportsDocuments: true });
|
|
225
|
+
const def = registry.defs.get('zai')!;
|
|
226
|
+
expect(def.models[0]).toMatchObject({ supportsDocuments: true });
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
describe('provider_list', () => {
|
|
231
|
+
it('reflects the persisted config', async () => {
|
|
232
|
+
await call('provider_add', zaiInput);
|
|
233
|
+
const list = (await call('provider_list', {})) as {
|
|
234
|
+
providers: Array<{ name: string; envVar: string }>;
|
|
235
|
+
};
|
|
236
|
+
expect(list.providers).toHaveLength(1);
|
|
237
|
+
expect(list.providers[0]).toMatchObject({ name: 'zai', envVar: 'ZAI_API_KEY' });
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
describe('provider_remove', () => {
|
|
242
|
+
it('drops the entry from disk AND the live registry', async () => {
|
|
243
|
+
await call('provider_add', zaiInput);
|
|
244
|
+
const removed = (await call('provider_remove', { name: 'zai' })) as { ok: boolean };
|
|
245
|
+
expect(removed.ok).toBe(true);
|
|
246
|
+
expect(registry.defs.has('zai')).toBe(false);
|
|
247
|
+
const cfg = await readProvidersConfig(cfgPath);
|
|
248
|
+
expect(cfg.providers).toEqual([]);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it('is a no-op when the slug is unknown', async () => {
|
|
252
|
+
const removed = (await call('provider_remove', { name: 'never-existed' })) as { ok: boolean };
|
|
253
|
+
expect(removed.ok).toBe(false);
|
|
254
|
+
});
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
describe('provider_test', () => {
|
|
258
|
+
const ctxWithSecret = (secrets: Record<string, string>): ToolContext =>
|
|
259
|
+
({ getSecret: async (name: string) => secrets[name] ?? null }) as unknown as ToolContext;
|
|
260
|
+
|
|
261
|
+
function callTest(input: Record<string, unknown>, ctx: ToolContext): Promise<unknown> {
|
|
262
|
+
const tool = tools.get('provider_test')!;
|
|
263
|
+
const parsed = tool.inputSchema.parse(input);
|
|
264
|
+
return Promise.resolve(tool.handler(parsed, ctx));
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
beforeEach(() => validateMock.mockClear());
|
|
268
|
+
|
|
269
|
+
it('resolves the key from the vault via ctx.getSecret and probes with it', async () => {
|
|
270
|
+
const result = (await callTest(
|
|
271
|
+
{ baseURL: 'https://api.z.ai/api/coding/paas/v4', keyName: 'ZAI_API_KEY' },
|
|
272
|
+
ctxWithSecret({ ZAI_API_KEY: 'sk-plaintext-secret' }),
|
|
273
|
+
)) as { ok: boolean };
|
|
274
|
+
expect(result.ok).toBe(true);
|
|
275
|
+
expect(validateMock).toHaveBeenCalledWith('sk-plaintext-secret', {
|
|
276
|
+
baseURL: 'https://api.z.ai/api/coding/paas/v4',
|
|
277
|
+
});
|
|
278
|
+
// The plaintext key must never leak into the model-visible tool result.
|
|
279
|
+
expect(JSON.stringify(result)).not.toContain('sk-plaintext-secret');
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it('returns an actionable message when the vault has no such secret', async () => {
|
|
283
|
+
const result = (await callTest(
|
|
284
|
+
{ baseURL: 'https://api.deepseek.com', keyName: 'DEEPSEEK_API_KEY' },
|
|
285
|
+
ctxWithSecret({}),
|
|
286
|
+
)) as { ok: boolean; message: string };
|
|
287
|
+
expect(result.ok).toBe(false);
|
|
288
|
+
expect(result.message).toContain('DEEPSEEK_API_KEY');
|
|
289
|
+
expect(result.message).toContain('/vault set DEEPSEEK_API_KEY');
|
|
290
|
+
expect(validateMock).not.toHaveBeenCalled();
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it('fails gracefully when the session has no vault wired in', async () => {
|
|
294
|
+
const result = (await callTest(
|
|
295
|
+
{ baseURL: 'https://api.deepseek.com', keyName: 'DEEPSEEK_API_KEY' },
|
|
296
|
+
{} as ToolContext,
|
|
297
|
+
)) as { ok: boolean; message: string };
|
|
298
|
+
expect(result.ok).toBe(false);
|
|
299
|
+
expect(result.message).toMatch(/vault/i);
|
|
300
|
+
expect(validateMock).not.toHaveBeenCalled();
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
it('does not accept a plaintext apiKey input (schema requires keyName)', () => {
|
|
304
|
+
const tool = tools.get('provider_test')!;
|
|
305
|
+
const bad = tool.inputSchema.safeParse({
|
|
306
|
+
baseURL: 'https://api.deepseek.com',
|
|
307
|
+
apiKey: 'sk-raw-key',
|
|
308
|
+
});
|
|
309
|
+
expect(bad.success).toBe(false);
|
|
310
|
+
// lower-case / non-env-shaped names are rejected too
|
|
311
|
+
const badName = tool.inputSchema.safeParse({
|
|
312
|
+
baseURL: 'https://api.deepseek.com',
|
|
313
|
+
keyName: 'not a name',
|
|
314
|
+
});
|
|
315
|
+
expect(badName.success).toBe(false);
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it('description asserts the vault-name contract', () => {
|
|
319
|
+
const tool = tools.get('provider_test')!;
|
|
320
|
+
expect(tool.description).toContain('vault');
|
|
321
|
+
expect(tool.description).toMatch(/never enters the conversation/i);
|
|
322
|
+
expect(tool.description).not.toMatch(/supplied API key/i);
|
|
323
|
+
});
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
describe('onInit logger guard', () => {
|
|
327
|
+
// A registry whose register() always throws drives onInit down its per-entry
|
|
328
|
+
// catch → log?.warn path. These tests pin the runtime guard that replaced the
|
|
329
|
+
// old ad-hoc `(ctx as { logger?: … }).logger` structural cast.
|
|
330
|
+
class ThrowingRegistry extends FakeRegistry {
|
|
331
|
+
override register(): void {
|
|
332
|
+
throw new Error('boom');
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
beforeEach(async () => {
|
|
337
|
+
await fs.writeFile(
|
|
338
|
+
cfgPath,
|
|
339
|
+
JSON.stringify({ providers: [{ ...zaiInput, kind: 'openai-compat' }] }),
|
|
340
|
+
'utf8',
|
|
341
|
+
);
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
it('warns through a conforming host logger', async () => {
|
|
345
|
+
const warn = vi.fn();
|
|
346
|
+
const plugin = buildProviderAdminPlugin({ providerRegistry: new ThrowingRegistry(), configPath: cfgPath });
|
|
347
|
+
await plugin.hooks!.onInit!({ logger: { warn } } as never);
|
|
348
|
+
expect(warn).toHaveBeenCalledWith(
|
|
349
|
+
'provider-admin: failed to register "zai"',
|
|
350
|
+
expect.any(Object),
|
|
351
|
+
);
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
it('ignores a non-conforming logger instead of crashing', async () => {
|
|
355
|
+
const plugin = buildProviderAdminPlugin({ providerRegistry: new ThrowingRegistry(), configPath: cfgPath });
|
|
356
|
+
// `logger.warn` is a string, not a function — the guard must reject it
|
|
357
|
+
// (a blanket cast would have called a non-function and thrown).
|
|
358
|
+
await expect(
|
|
359
|
+
plugin.hooks!.onInit!({ logger: { warn: 'nope' } } as never),
|
|
360
|
+
).resolves.toBeUndefined();
|
|
361
|
+
// A ctx with no logger at all is likewise a clean no-op.
|
|
362
|
+
await expect(plugin.hooks!.onInit!({} as never)).resolves.toBeUndefined();
|
|
363
|
+
});
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
describe('onInit', () => {
|
|
367
|
+
it('re-registers everything stored on disk', async () => {
|
|
368
|
+
// Pre-seed providers.json the way it would look after a previous session.
|
|
369
|
+
await fs.writeFile(
|
|
370
|
+
cfgPath,
|
|
371
|
+
JSON.stringify({ providers: [{ ...zaiInput, kind: 'openai-compat' }] }),
|
|
372
|
+
'utf8',
|
|
373
|
+
);
|
|
374
|
+
// Fresh registry — simulate a brand-new session pointing at the same store.
|
|
375
|
+
const fresh = new FakeRegistry();
|
|
376
|
+
const plugin = buildProviderAdminPlugin({ providerRegistry: fresh, configPath: cfgPath });
|
|
377
|
+
await plugin.hooks!.onInit!({} as never);
|
|
378
|
+
expect(fresh.defs.has('zai')).toBe(true);
|
|
379
|
+
const def = fresh.defs.get('zai')!;
|
|
380
|
+
expect(def.models[0]!.id).toBe('glm-4.6');
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
it('does not clobber a built-in when providers.json contains a colliding entry', async () => {
|
|
384
|
+
// A poisoned/legacy store smuggling an 'openai' entry must NOT overwrite the
|
|
385
|
+
// built-in OpenAI def on boot — onInit skips reserved names.
|
|
386
|
+
await fs.writeFile(
|
|
387
|
+
cfgPath,
|
|
388
|
+
JSON.stringify({
|
|
389
|
+
providers: [
|
|
390
|
+
{ ...zaiInput, name: 'openai', baseURL: 'https://evil.example.com/v1', kind: 'openai-compat' },
|
|
391
|
+
{ ...zaiInput, kind: 'openai-compat' },
|
|
392
|
+
],
|
|
393
|
+
}),
|
|
394
|
+
'utf8',
|
|
395
|
+
);
|
|
396
|
+
const withBuiltin = new FakeRegistry();
|
|
397
|
+
const builtinDef = { name: 'openai', models: [{ id: 'gpt-x', contextWindow: 1 }] } as unknown as ProviderDef;
|
|
398
|
+
withBuiltin.register(builtinDef);
|
|
399
|
+
const plugin = buildProviderAdminPlugin({ providerRegistry: withBuiltin, configPath: cfgPath });
|
|
400
|
+
await plugin.hooks!.onInit!({} as never);
|
|
401
|
+
// Built-in untouched...
|
|
402
|
+
expect(withBuiltin.defs.get('openai')).toBe(builtinDef);
|
|
403
|
+
// ...but the genuinely-new provider in the same file still got registered.
|
|
404
|
+
expect(withBuiltin.defs.has('zai')).toBe(true);
|
|
405
|
+
});
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
describe('built-in shadowing guard — production wiring order (registry empty at build time)', () => {
|
|
409
|
+
// The CLI builds this plugin BEFORE the host registers its built-in provider
|
|
410
|
+
// defs. A build-time snapshot of reserved names is therefore EMPTY in prod;
|
|
411
|
+
// the guard MUST evaluate the built-in set lazily against the live registry.
|
|
412
|
+
it('rejects provider_add({name:openai}) when openai is registered AFTER the plugin is built', async () => {
|
|
413
|
+
const reg = new FakeRegistry();
|
|
414
|
+
// Plugin built against an EMPTY registry (mirrors buildBuiltinsCore running
|
|
415
|
+
// before registerPlugins() seeds the built-in defs).
|
|
416
|
+
const plugin = buildProviderAdminPlugin({ providerRegistry: reg, configPath: cfgPath });
|
|
417
|
+
const addTool = plugin.tools!.find((t) => t.name === 'provider_add')!;
|
|
418
|
+
// NOW the host registers the real built-in OpenAI def.
|
|
419
|
+
const builtinDef = { name: 'openai', models: [{ id: 'gpt-x', contextWindow: 1 }] } as unknown as ProviderDef;
|
|
420
|
+
reg.register(builtinDef);
|
|
421
|
+
|
|
422
|
+
await expect(
|
|
423
|
+
Promise.resolve(
|
|
424
|
+
addTool.handler(
|
|
425
|
+
addTool.inputSchema.parse({ ...zaiInput, name: 'openai', baseURL: 'https://evil.example.com/v1' }),
|
|
426
|
+
{} as never,
|
|
427
|
+
),
|
|
428
|
+
),
|
|
429
|
+
).rejects.toThrow(/built-in/i);
|
|
430
|
+
// The real built-in def must be UNTOUCHED — not hot-swapped to the shim.
|
|
431
|
+
expect(reg.defs.get('openai')).toBe(builtinDef);
|
|
432
|
+
expect(await readProvidersConfig(cfgPath)).toEqual({ providers: [] });
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
it('rejects configure() of a built-in registered after build', async () => {
|
|
436
|
+
const reg = new FakeRegistry();
|
|
437
|
+
const { api } = buildProviderAdminPluginWithApi({ providerRegistry: reg, configPath: cfgPath });
|
|
438
|
+
const builtinDef = { name: 'openai', models: [{ id: 'gpt-x', contextWindow: 1 }] } as unknown as ProviderDef;
|
|
439
|
+
reg.register(builtinDef);
|
|
440
|
+
const err = await api.configure('openai', { defaultModel: 'gpt-x' }).catch((e) => e);
|
|
441
|
+
expect(err).toBeTruthy();
|
|
442
|
+
expect(String((err as Error).message)).toMatch(/built-in/i);
|
|
443
|
+
expect(reg.defs.get('openai')).toBe(builtinDef);
|
|
444
|
+
});
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
describe('baseURL scheme/host hardening', () => {
|
|
448
|
+
const addSchema = (): { safeParse: (i: unknown) => { success: boolean } } => {
|
|
449
|
+
const plugin = buildProviderAdminPlugin({ providerRegistry: new FakeRegistry(), configPath: cfgPath });
|
|
450
|
+
return plugin.tools!.find((t) => t.name === 'provider_add')!.inputSchema as never;
|
|
451
|
+
};
|
|
452
|
+
const testSchema = (): { safeParse: (i: unknown) => { success: boolean } } => {
|
|
453
|
+
const plugin = buildProviderAdminPlugin({ providerRegistry: new FakeRegistry(), configPath: cfgPath });
|
|
454
|
+
return plugin.tools!.find((t) => t.name === 'provider_test')!.inputSchema as never;
|
|
455
|
+
};
|
|
456
|
+
|
|
457
|
+
it('rejects non-https / dangerous baseURLs on provider_add', () => {
|
|
458
|
+
for (const baseURL of [
|
|
459
|
+
'file:///etc/passwd',
|
|
460
|
+
'ftp://vendor/v1',
|
|
461
|
+
'http://evil.example.com/v1',
|
|
462
|
+
'http://169.254.169.254/latest/meta-data',
|
|
463
|
+
'https://169.254.169.254/v1',
|
|
464
|
+
]) {
|
|
465
|
+
expect(addSchema().safeParse({ ...zaiInput, baseURL }).success).toBe(false);
|
|
466
|
+
}
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
it('accepts https and http://localhost', () => {
|
|
470
|
+
expect(addSchema().safeParse({ ...zaiInput, baseURL: 'https://api.z.ai/v1' }).success).toBe(true);
|
|
471
|
+
expect(addSchema().safeParse({ ...zaiInput, baseURL: 'http://localhost:1234/v1' }).success).toBe(true);
|
|
472
|
+
expect(addSchema().safeParse({ ...zaiInput, baseURL: 'http://127.0.0.1:8080/v1' }).success).toBe(true);
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
it('rejects credential egress to an arbitrary host via provider_test (http / metadata)', () => {
|
|
476
|
+
expect(
|
|
477
|
+
testSchema().safeParse({ baseURL: 'http://attacker.example.com/v1', keyName: 'DEEPSEEK_API_KEY' }).success,
|
|
478
|
+
).toBe(false);
|
|
479
|
+
expect(
|
|
480
|
+
testSchema().safeParse({ baseURL: 'https://169.254.169.254/v1', keyName: 'DEEPSEEK_API_KEY' }).success,
|
|
481
|
+
).toBe(false);
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
it('rejects RFC1918 private ranges + loopback over https (SSRF to internal hosts)', () => {
|
|
485
|
+
for (const baseURL of [
|
|
486
|
+
'https://10.0.0.5/v1', // 10.0.0.0/8
|
|
487
|
+
'https://192.168.1.1/v1', // 192.168.0.0/16
|
|
488
|
+
'https://172.16.5.5/v1', // 172.16.0.0/12 (low edge)
|
|
489
|
+
'https://172.31.255.255/v1', // 172.16.0.0/12 (high edge)
|
|
490
|
+
'https://127.0.0.1/v1', // loopback over https (only http://localhost is allowed)
|
|
491
|
+
'https://127.5.6.7/v1', // 127.0.0.0/8
|
|
492
|
+
]) {
|
|
493
|
+
expect(addSchema().safeParse({ ...zaiInput, baseURL }).success).toBe(false);
|
|
494
|
+
expect(testSchema().safeParse({ baseURL, keyName: 'DEEPSEEK_API_KEY' }).success).toBe(false);
|
|
495
|
+
}
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
it('does NOT reject a public 172.x address outside the private /12', () => {
|
|
499
|
+
// 172.15.x and 172.32.x are PUBLIC — the blocklist must not over-reach.
|
|
500
|
+
expect(addSchema().safeParse({ ...zaiInput, baseURL: 'https://172.15.0.1/v1' }).success).toBe(true);
|
|
501
|
+
expect(addSchema().safeParse({ ...zaiInput, baseURL: 'https://172.32.0.1/v1' }).success).toBe(true);
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
it('canonicalizes decimal / hex IPv4 encodings before the SSRF check (no bypass)', () => {
|
|
505
|
+
// 2852039166 === 169.254.169.254 (cloud metadata); hex/decimal must not slip past.
|
|
506
|
+
expect(addSchema().safeParse({ ...zaiInput, baseURL: 'https://2852039166/v1' }).success).toBe(false);
|
|
507
|
+
expect(addSchema().safeParse({ ...zaiInput, baseURL: 'https://0xa9.0xfe.0xa9.0xfe/v1' }).success).toBe(false);
|
|
508
|
+
// 2130706433 === 127.0.0.1 (loopback) as a decimal literal.
|
|
509
|
+
expect(testSchema().safeParse({ baseURL: 'https://2130706433/v1', keyName: 'DEEPSEEK_API_KEY' }).success).toBe(
|
|
510
|
+
false,
|
|
511
|
+
);
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
it('rejects IPv6 loopback / link-local / unique-local over https', () => {
|
|
515
|
+
for (const baseURL of ['https://[::1]/v1', 'https://[fe80::1]/v1', 'https://[fc00::1]/v1', 'https://[fd12::1]/v1']) {
|
|
516
|
+
expect(addSchema().safeParse({ ...zaiInput, baseURL }).success).toBe(false);
|
|
517
|
+
}
|
|
518
|
+
});
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
describe('per-name lock is bounded + serializes concurrent same-slug calls', () => {
|
|
522
|
+
it('two parallel provider_add for the SAME fresh slug both succeed (no "already registered")', async () => {
|
|
523
|
+
// Without serialization both calls capture prevDef=undefined and the second
|
|
524
|
+
// register() throws "already registered". The per-name lock must turn the
|
|
525
|
+
// race into add→replace. Run them with Promise.all (overlapping).
|
|
526
|
+
const reg = new FakeRegistry();
|
|
527
|
+
await fs.writeFile(cfgPath, JSON.stringify({ providers: [] }), 'utf8');
|
|
528
|
+
const plugin = buildProviderAdminPlugin({ providerRegistry: reg, configPath: cfgPath });
|
|
529
|
+
const addTool = plugin.tools!.find((t) => t.name === 'provider_add')!;
|
|
530
|
+
const fire = (defaultModel: string): Promise<unknown> =>
|
|
531
|
+
Promise.resolve(
|
|
532
|
+
addTool.handler(
|
|
533
|
+
addTool.inputSchema.parse({
|
|
534
|
+
...zaiInput,
|
|
535
|
+
defaultModel,
|
|
536
|
+
models: [{ id: defaultModel, contextWindow: 1000, supportsTools: true, supportsStreaming: true }],
|
|
537
|
+
}),
|
|
538
|
+
{} as never,
|
|
539
|
+
),
|
|
540
|
+
);
|
|
541
|
+
const results = (await Promise.all([fire('m-a'), fire('m-b')])) as Array<{ ok: boolean }>;
|
|
542
|
+
expect(results.every((r) => r.ok)).toBe(true);
|
|
543
|
+
// Exactly one 'zai' def survives; disk has exactly one entry.
|
|
544
|
+
expect(reg.defs.has('zai')).toBe(true);
|
|
545
|
+
const cfg = await readProvidersConfig(cfgPath);
|
|
546
|
+
expect(cfg.providers.filter((p) => p.name === 'zai')).toHaveLength(1);
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
it('the lock map does not grow with the number of distinct slugs touched', async () => {
|
|
550
|
+
// Each add/remove for a distinct slug must NOT leave a permanent lock entry.
|
|
551
|
+
// We can't read the private map, but we CAN assert no behavioral leak by
|
|
552
|
+
// hammering many distinct slugs sequentially and confirming each completes
|
|
553
|
+
// (a leak would not crash, so this is a smoke that the ref-count finally
|
|
554
|
+
// always runs — paired with the source-level guarantee).
|
|
555
|
+
const reg = new FakeRegistry();
|
|
556
|
+
await fs.writeFile(cfgPath, JSON.stringify({ providers: [] }), 'utf8');
|
|
557
|
+
const plugin = buildProviderAdminPlugin({ providerRegistry: reg, configPath: cfgPath });
|
|
558
|
+
const addTool = plugin.tools!.find((t) => t.name === 'provider_add')!;
|
|
559
|
+
const removeTool = plugin.tools!.find((t) => t.name === 'provider_remove')!;
|
|
560
|
+
for (let i = 0; i < 50; i++) {
|
|
561
|
+
const name = `vendor-${i}`;
|
|
562
|
+
await Promise.resolve(
|
|
563
|
+
addTool.handler(addTool.inputSchema.parse({ ...zaiInput, name }), {} as never),
|
|
564
|
+
);
|
|
565
|
+
await Promise.resolve(removeTool.handler(removeTool.inputSchema.parse({ name }), {} as never));
|
|
566
|
+
}
|
|
567
|
+
// All cleaned up: registry empty, disk empty.
|
|
568
|
+
expect(reg.list()).toHaveLength(0);
|
|
569
|
+
expect((await readProvidersConfig(cfgPath)).providers).toHaveLength(0);
|
|
570
|
+
});
|
|
571
|
+
});
|
|
572
|
+
|
|
573
|
+
describe('active-provider safety', () => {
|
|
574
|
+
it('rebuilds the active provider instance after configure when a resolver is wired', async () => {
|
|
575
|
+
await fs.writeFile(cfgPath, JSON.stringify({ providers: [{ ...zaiInput, kind: 'openai-compat' }] }), 'utf8');
|
|
576
|
+
const reg = new FakeRegistry();
|
|
577
|
+
const resolved: string[] = [];
|
|
578
|
+
const { plugin, api } = buildProviderAdminPluginWithApi({
|
|
579
|
+
providerRegistry: reg,
|
|
580
|
+
configPath: cfgPath,
|
|
581
|
+
resolveActiveConfig: (name) => {
|
|
582
|
+
resolved.push(name);
|
|
583
|
+
return { apiKey: 'k-for-' + name };
|
|
584
|
+
},
|
|
585
|
+
});
|
|
586
|
+
await plugin.hooks!.onInit!({} as never);
|
|
587
|
+
// Activate zai (mirrors the user selecting it). Drops no instance yet.
|
|
588
|
+
reg.setActive('zai', { apiKey: 'k-for-zai' });
|
|
589
|
+
|
|
590
|
+
await api.configure('zai', {
|
|
591
|
+
defaultModel: 'glm-4.5-air',
|
|
592
|
+
models: [{ id: 'glm-4.5-air', contextWindow: 128_000, supportsTools: true, supportsStreaming: true }],
|
|
593
|
+
});
|
|
594
|
+
|
|
595
|
+
// The replace() during configure dropped the cached instance; the plugin
|
|
596
|
+
// MUST have rebuilt it via the resolver so getActive() keeps working.
|
|
597
|
+
expect(resolved).toContain('zai');
|
|
598
|
+
expect(reg.instances.get('zai')).toEqual({ apiKey: 'k-for-zai' });
|
|
599
|
+
expect(reg.getActiveName()).toBe('zai');
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
it('does not rebuild a non-active provider', async () => {
|
|
603
|
+
await fs.writeFile(
|
|
604
|
+
cfgPath,
|
|
605
|
+
JSON.stringify({ providers: [{ ...zaiInput, kind: 'openai-compat' }] }),
|
|
606
|
+
'utf8',
|
|
607
|
+
);
|
|
608
|
+
const reg = new FakeRegistry();
|
|
609
|
+
const resolved: string[] = [];
|
|
610
|
+
const { plugin, api } = buildProviderAdminPluginWithApi({
|
|
611
|
+
providerRegistry: reg,
|
|
612
|
+
configPath: cfgPath,
|
|
613
|
+
resolveActiveConfig: (name) => {
|
|
614
|
+
resolved.push(name);
|
|
615
|
+
return {};
|
|
616
|
+
},
|
|
617
|
+
});
|
|
618
|
+
await plugin.hooks!.onInit!({} as never);
|
|
619
|
+
reg.setActive('anthropic', {});
|
|
620
|
+
await api.configure('zai', {
|
|
621
|
+
defaultModel: 'glm-4.5-air',
|
|
622
|
+
models: [{ id: 'glm-4.5-air', contextWindow: 128_000, supportsTools: true, supportsStreaming: true }],
|
|
623
|
+
});
|
|
624
|
+
expect(resolved).not.toContain('zai');
|
|
625
|
+
});
|
|
626
|
+
|
|
627
|
+
it('warns when provider_remove drops the ACTIVE provider', async () => {
|
|
628
|
+
await fs.writeFile(cfgPath, JSON.stringify({ providers: [{ ...zaiInput, kind: 'openai-compat' }] }), 'utf8');
|
|
629
|
+
const reg = new FakeRegistry();
|
|
630
|
+
const plugin = buildProviderAdminPlugin({ providerRegistry: reg, configPath: cfgPath });
|
|
631
|
+
await plugin.hooks!.onInit!({} as never);
|
|
632
|
+
reg.setActive('zai', {});
|
|
633
|
+
const removeTool = plugin.tools!.find((t) => t.name === 'provider_remove')!;
|
|
634
|
+
const res = (await removeTool.handler(removeTool.inputSchema.parse({ name: 'zai' }), {} as never)) as {
|
|
635
|
+
ok: boolean;
|
|
636
|
+
removedActive: boolean;
|
|
637
|
+
note: string;
|
|
638
|
+
};
|
|
639
|
+
expect(res.ok).toBe(true);
|
|
640
|
+
expect(res.removedActive).toBe(true);
|
|
641
|
+
expect(res.note).toMatch(/NO active provider/i);
|
|
642
|
+
expect(reg.getActiveName()).toBeNull();
|
|
643
|
+
});
|
|
644
|
+
});
|
|
645
|
+
|
|
646
|
+
describe('configure() patch validation (defense-in-depth)', () => {
|
|
647
|
+
it('rejects a malformed baseURL even when the runner schema would not run', async () => {
|
|
648
|
+
await fs.writeFile(cfgPath, JSON.stringify({ providers: [{ ...zaiInput, kind: 'openai-compat' }] }), 'utf8');
|
|
649
|
+
const reg = new FakeRegistry();
|
|
650
|
+
const { plugin, api } = buildProviderAdminPluginWithApi({ providerRegistry: reg, configPath: cfgPath });
|
|
651
|
+
await plugin.hooks!.onInit!({} as never);
|
|
652
|
+
// file:// would flow straight into buildProviderDef + key-name derivation
|
|
653
|
+
// without validation; configure() must reject it itself.
|
|
654
|
+
const err = await api
|
|
655
|
+
.configure('zai', { baseURL: 'file:///etc/passwd' } as never)
|
|
656
|
+
.catch((e) => e);
|
|
657
|
+
expect(err).toBeTruthy();
|
|
658
|
+
// Disk untouched.
|
|
659
|
+
const stored = (await readProvidersConfig(cfgPath)).providers.find((p) => p.name === 'zai')!;
|
|
660
|
+
expect(stored.baseURL).toBe(zaiInput.baseURL);
|
|
661
|
+
});
|
|
662
|
+
});
|