Two had real security consequences: The bootstrap rejection ran on npm dotenv's parser while process.loadEnvFile applied the file with Node's own. Two independently maintained dialects meant the check and the thing it guards could disagree: a name Node accepts but the checker misses would reach process.env unchecked, and BASH_ENV there runs a file of the project's choosing on every `bash -c` the bash tool issues. Parse once with node:util's parseEnv — the same engine loadEnvFile uses — and assign the entries already checked, which also drops the dotenv dependency. llm-pi-ai still returned a literal profile.apiKey ahead of everything, and it registers a settings namespace, so the defect removed from llm-deepseek survived intact in its design twin. The field is gone from the profile schema, the resolution path, and the tests. The rest are consistency and documentation defects the review named: - verify-config-source-ownership did not scan the Python runtime's bundled cordis.yml, which still inlined apiKey and baseURL. Both are covered now, and the line-anchored INLINE_DENY documents that it is a tripwire, not a parser. - The deny list missed NODE_TLS_REJECT_UNAUTHORIZED, the askpass hooks, the GIT_CONFIG_* redirections, and PYTHONHOME — all implied by its own stated rule about what a variable does. - Snapshot lookups folded case on Windows, where environment names are case-insensitive and an exact-match Map could miss a higher-ranked layer. - The credentials note claimed a read-time permission check was "not taken" while this PR implemented it; the credentials-local README still described two layers, live process.env reads, dotenv-era limitations, and a renamed anchor; the llm-deepseek README still advertised the removed literal apiKey; and web.ts and base.cordis.yml kept personal-overlay wording. - The ownership note's literal-apiKey claim now names its scope: the web-search providers keep a literal field but register no settings namespace, so nothing can shadow a stored credential through them.
214 lines
9.1 KiB
TypeScript
214 lines
9.1 KiB
TypeScript
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
import { Context } from 'cordis'
|
|
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
|
import { tmpdir } from 'node:os'
|
|
import { join } from 'node:path'
|
|
import LlmService, { LlmAdapter } from '@deepseek-ai/dsh-llm'
|
|
import { credentialRef } from '@deepseek-ai/dsh-credentials'
|
|
import { CredentialsLocal } from '@deepseek-ai/dsh-credentials-local'
|
|
import { settingsNamespace } from '@deepseek-ai/dsh-settings'
|
|
import { SettingsLocal } from '@deepseek-ai/dsh-settings-local'
|
|
import * as LlmPiAi from '@deepseek-ai/dsh-llm-pi-ai'
|
|
import { assemble } from './assemble.ts'
|
|
import { closeMockServers, mockServer, textEvents } from './mock-server.ts'
|
|
|
|
const NS = settingsNamespace('llm-pi-ai')
|
|
|
|
/** Minimal foreign adapter: only needs to own a route the pi-ai plugin then wants. */
|
|
class StubAdapter extends LlmAdapter {
|
|
|
|
override async * stream(): AsyncIterable<never> {
|
|
throw new Error('stub adapter must never stream')
|
|
}
|
|
}
|
|
|
|
const cleanups: Array<() => Promise<void>> = []
|
|
|
|
afterEach(async () => {
|
|
while (cleanups.length > 0) await cleanups.pop()!()
|
|
await closeMockServers()
|
|
vi.unstubAllEnvs()
|
|
})
|
|
|
|
async function home(): Promise<string> {
|
|
const dir = await mkdtemp(join(tmpdir(), 'dsh-pi-dynamic-'))
|
|
cleanups.push(() => rm(dir, { recursive: true, force: true }))
|
|
return dir
|
|
}
|
|
|
|
/** Real dynamic composition mirroring the deepseek twin's harness. */
|
|
async function boot(dir: string, config: LlmPiAi.Config): Promise<Context> {
|
|
const ctx = new Context()
|
|
cleanups.push(async () => {
|
|
await ctx.fiber.dispose()
|
|
})
|
|
await ctx.plugin(LlmService)
|
|
await ctx.plugin(SettingsLocal, { path: join(dir, 'settings.yaml'), watch: false })
|
|
await ctx.plugin(CredentialsLocal, { path: join(dir, '.credentials.yaml'), watch: false })
|
|
await ctx.plugin(LlmPiAi, config)
|
|
return ctx
|
|
}
|
|
|
|
describe('request-level dynamic profiles', () => {
|
|
it('mounts bare and dormant, then registers routes the moment settings supply providers', async () => {
|
|
vi.stubEnv('PI_DYNAMIC_KEY', '')
|
|
const dir = await home()
|
|
await writeFile(
|
|
join(dir, '.credentials.yaml'),
|
|
'PI_DYNAMIC_KEY: pk-from-settings\nPI_LIVE_KEY: live-key\nPI_OTHER_KEY: other\n',
|
|
{ mode: 0o600 },
|
|
)
|
|
const server = await mockServer([{ events: textEvents }])
|
|
// The exact product posture: `- id: llm-pi-ai` with no config at all.
|
|
const ctx = await boot(dir, {})
|
|
|
|
expect(ctx.llm.listProviders()).toEqual([])
|
|
// Dormant ≠ invisible: every installed catalog provider is configurable
|
|
// before any route exists, each addressed inside the providers dict.
|
|
const directory = ctx.llm.listConfigurableProviders()
|
|
expect(directory.length).toBeGreaterThan(30)
|
|
expect(directory).toContainEqual({
|
|
provider: 'openai',
|
|
displayName: 'openai',
|
|
settingsNs: 'llm-pi-ai',
|
|
settingsPath: ['providers', 'openai'],
|
|
})
|
|
await ctx.settings.update(NS, {
|
|
providers: { deepseek: { apiKeyEnv: 'PI_DYNAMIC_KEY', baseURL: server.url } },
|
|
})
|
|
expect(ctx.llm.listProviders().map(provider => provider.id)).toEqual(['deepseek'])
|
|
await expect(ctx.llm.listModels('deepseek')).resolves.not.toHaveLength(0)
|
|
|
|
const result = await assemble(ctx, { provider: 'deepseek', model: 'deepseek-v4-flash', messages: [] })
|
|
expect(result.message.content).toEqual([{ type: 'text', text: 'hello' }])
|
|
expect(server.headers[0]?.authorization).toBe('Bearer pk-from-settings')
|
|
|
|
// Emptying the user layer returns the adapter to its dormant state.
|
|
await ctx.settings.replace(NS, {})
|
|
expect(ctx.llm.listProviders()).toEqual([])
|
|
})
|
|
|
|
it('adds a provider route from settings and drops it when the user layer resets', async () => {
|
|
const dir = await home()
|
|
await writeFile(
|
|
join(dir, '.credentials.yaml'),
|
|
'PI_LIVE_KEY: live-key\nPI_OTHER_KEY: other\n',
|
|
{ mode: 0o600 },
|
|
)
|
|
const server = await mockServer([{ events: textEvents }])
|
|
const ctx = await boot(dir, {
|
|
providers: { openai: { apiKeyEnv: 'PI_DYNAMIC_KEY', baseURL: 'http://127.0.0.1:1/v1' } },
|
|
})
|
|
|
|
expect(ctx.llm.listProviders().map(provider => provider.id)).toEqual(['openai'])
|
|
await ctx.settings.update(NS, {
|
|
providers: { deepseek: { apiKeyEnv: 'PI_LIVE_KEY', baseURL: server.url } },
|
|
})
|
|
expect(ctx.llm.listProviders().map(provider => provider.id)).toEqual(['openai', 'deepseek'])
|
|
|
|
const result = await assemble(ctx, { provider: 'deepseek', model: 'deepseek-v4-flash', messages: [] })
|
|
expect(result.message.content).toEqual([{ type: 'text', text: 'hello' }])
|
|
expect(server.headers[0]?.authorization).toBe('Bearer live-key')
|
|
|
|
// Reset the user layer: the settings-born route unregisters, the
|
|
// composition route stays.
|
|
await ctx.settings.replace(NS, {})
|
|
expect(ctx.llm.listProviders().map(provider => provider.id)).toEqual(['openai'])
|
|
await expect(assemble(ctx, { provider: 'deepseek', model: 'deepseek-v4-flash', messages: [] }))
|
|
.rejects.toMatchObject({ code: 'NO_ADAPTER' })
|
|
})
|
|
|
|
it('rotates the per-request credential referenced by apiKeyEnv', async () => {
|
|
vi.stubEnv('PI_DYNAMIC_KEY', '')
|
|
const dir = await home()
|
|
await writeFile(join(dir, '.credentials.yaml'), 'PI_DYNAMIC_KEY: pk-one\n', { mode: 0o600 })
|
|
const server = await mockServer([{ events: textEvents }, { events: textEvents }])
|
|
const ctx = await boot(dir, {
|
|
providers: { deepseek: { apiKeyEnv: 'PI_DYNAMIC_KEY', baseURL: server.url } },
|
|
})
|
|
|
|
await assemble(ctx, { provider: 'deepseek', model: 'deepseek-v4-flash', messages: [] })
|
|
expect(server.headers[0]?.authorization).toBe('Bearer pk-one')
|
|
|
|
await ctx.credentials.set(credentialRef('PI_DYNAMIC_KEY'), 'pk-two')
|
|
await assemble(ctx, { provider: 'deepseek', model: 'deepseek-v4-flash', messages: [] })
|
|
expect(server.headers[1]?.authorization).toBe('Bearer pk-two')
|
|
})
|
|
|
|
it('re-registers routes in place when a captured retry policy changes', async () => {
|
|
const dir = await home()
|
|
const ctx = await boot(dir, { providers: { openai: {} } })
|
|
|
|
await ctx.settings.update(NS, {
|
|
providers: {
|
|
openai: {
|
|
retryPolicy: { mode: 'always', backoff: { initialDelayMs: 25, maxDelayMs: 100, jitterRatio: 0.2 } },
|
|
},
|
|
},
|
|
})
|
|
expect(ctx.llm.providerRetryPolicy('openai')).toEqual({
|
|
mode: 'always',
|
|
initialDelayMs: 25,
|
|
maxDelayMs: 100,
|
|
jitterRatio: 0.2,
|
|
})
|
|
expect(ctx.llm.listProviders().map(provider => provider.id)).toEqual(['openai'])
|
|
})
|
|
|
|
it('keeps the last good profiles when a settings snapshot names an unknown provider', async () => {
|
|
const dir = await home()
|
|
const ctx = await boot(dir, { providers: { openai: {} } })
|
|
|
|
// Schema-valid but catalog-invalid: the resolver rejects it and the
|
|
// last good route set keeps serving.
|
|
await ctx.settings.update(NS, { providers: { 'not-a-real-provider': {} } })
|
|
expect(ctx.llm.listProviders().map(provider => provider.id)).toEqual(['openai'])
|
|
})
|
|
|
|
it('keeps serving its routes when a settings-born route collides with another adapter', async () => {
|
|
const dir = await home()
|
|
await writeFile(
|
|
join(dir, '.credentials.yaml'),
|
|
'PI_LIVE_KEY: live-key\nPI_OTHER_KEY: other\n',
|
|
{ mode: 0o600 },
|
|
)
|
|
const server = await mockServer([{ events: textEvents }, { events: textEvents }])
|
|
const ctx = await boot(dir, { providers: { openai: { apiKeyEnv: 'PI_LIVE_KEY', baseURL: `${server.url}/v1` } } })
|
|
// Another adapter owns `anthropic`; the registry must refuse to hand it over.
|
|
ctx.llm.registerAdapter(['anthropic'], new StubAdapter())
|
|
|
|
await ctx.settings.update(NS, {
|
|
providers: {
|
|
openai: { apiKeyEnv: 'PI_LIVE_KEY', baseURL: `${server.url}/v1` },
|
|
anthropic: { apiKeyEnv: 'PI_OTHER_KEY' },
|
|
},
|
|
})
|
|
|
|
// The conflicting swap was refused whole: the previous route set still
|
|
// owns openai (an eager dispose would have dropped it), and anthropic
|
|
// still belongs to its original adapter.
|
|
expect(ctx.llm.listProviders().map(provider => provider.id).sort()).toEqual(['anthropic', 'openai'])
|
|
const result = await assemble(ctx, { provider: 'openai', model: 'gpt-4.1', messages: [] })
|
|
expect(result.finish.kind).toBe('error')
|
|
expect(server.paths).toEqual(['/v1/responses'])
|
|
|
|
// Reverting to the working configuration re-applies, even though its
|
|
// facts equal the ones the registry already holds.
|
|
await ctx.settings.replace(NS, {})
|
|
expect(ctx.llm.listProviders().map(provider => provider.id).sort()).toEqual(['anthropic', 'openai'])
|
|
await assemble(ctx, { provider: 'openai', model: 'gpt-4.1', messages: [] })
|
|
expect(server.paths).toEqual(['/v1/responses', '/v1/responses'])
|
|
})
|
|
|
|
it('ignores a settings document that merely reorders its provider keys', async () => {
|
|
const dir = await home()
|
|
const ctx = await boot(dir, { providers: { openai: {}, anthropic: {} } })
|
|
const before = ctx.llm.listProviders().map(provider => provider.id)
|
|
|
|
// Same routes, different YAML key order: nothing about the registration
|
|
// changed, so no swap should happen at all.
|
|
await ctx.settings.update(NS, { providers: { anthropic: {}, openai: {} } })
|
|
expect(ctx.llm.listProviders().map(provider => provider.id)).toEqual(before)
|
|
})
|
|
})
|