Review found five real defects in the configuration-source work, all confirmed against the code rather than argued: 1. The note claimed --config outranks settings.yaml. It does not: the settings seam registers a plugin's cordis entry config as the `base` layer and the user section layers over it, and the seam cannot tell a shipped value from a --config one. The note now states shipped reality and names --config-replace as the lever for a deployment that must win. Separately, a literal `apiKey` in settings outranked both the environment and .credentials.yaml — the field is removed, so configuration carries a reference and nothing else. 2. DEEPSEEK_SEARCH_BASE_URL was functionally deleted: the shipped inline went away without the provider learning to read it. It now resolves from the environment snapshot, as the README always claimed. 3. The bootstrap deny list missed the interpreter start-up hooks. BASH_ENV is the sharpest: `bash -c` sources it on every bash tool call, so a project .env could run a file of its choosing before every command. The list now covers BASH_ENV and its per-language siblings, the Git hook commands, and the remaining preload and CA variables, organised by what a variable does rather than which runtime owns it. 4. YAML parse errors quoted the offending source line — which in a credentials document is the secret — into boot stderr and the watcher's logger. Only the error code and position are reported now, in credentials-local and settings-local alike, pinned by a test that asserts the secret is absent. 5. 0600 governed only files the harness wrote. A hand-created 0644 document was read normally. POSIX now checks the mode before reading contents, at boot and on every reload; Windows has no mode to inspect and is skipped rather than faked. The project a session is launched in is trusted by default, with no prompt and no stored trust record: it may supply its own endpoint, ordinary variables, and a key ranked below the managed store. Trust stops at the harness itself — a discovered file still cannot set DSH_PERMISSION_MODE, PATH, BASH_ENV, or the rest, because those take effect with no user action, before any turn, outside the permission policy and the sandbox.
200 lines
8.5 KiB
TypeScript
200 lines
8.5 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 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 LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
|
|
import { assemble } from './assemble.ts'
|
|
import { closeMockServers, mockServer, textEvents } from './mock-server.ts'
|
|
|
|
const NS = settingsNamespace('llm-deepseek')
|
|
const KEY_REF = credentialRef('DEEPSEEK_API_KEY')
|
|
|
|
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-llm-dynamic-'))
|
|
cleanups.push(() => rm(dir, { recursive: true, force: true }))
|
|
return dir
|
|
}
|
|
|
|
interface Harness {
|
|
ctx: Context
|
|
settingsFiber: { dispose(): Promise<void> }
|
|
}
|
|
|
|
/**
|
|
* Real dynamic composition: llm + settings-local + credentials-local +
|
|
* llm-deepseek over one temp harness home. `watch: false` keeps every change
|
|
* flowing through the in-process write path, which is deterministic; external
|
|
* file watching is the providers' own covered concern.
|
|
*/
|
|
async function boot(dir: string, config: object): Promise<Harness> {
|
|
const ctx = new Context()
|
|
cleanups.push(async () => {
|
|
await ctx.fiber.dispose()
|
|
})
|
|
await ctx.plugin(LlmService)
|
|
const settingsFiber = ctx.plugin(SettingsLocal, { path: join(dir, 'settings.yaml'), watch: false })
|
|
await settingsFiber
|
|
await ctx.plugin(CredentialsLocal, { path: join(dir, '.credentials.yaml'), watch: false })
|
|
await ctx.plugin(LlmDeepSeek, config)
|
|
return { ctx, settingsFiber }
|
|
}
|
|
|
|
function prompt(ctx: Context) {
|
|
return assemble(ctx, { model: 'deepseek-v4-flash', messages: [] })
|
|
}
|
|
|
|
describe('request-level dynamic configuration', () => {
|
|
it('routes the next request with the freshly resolved base URL and credential', async () => {
|
|
vi.stubEnv('DEEPSEEK_API_KEY', '')
|
|
const dir = await home()
|
|
await writeFile(join(dir, '.credentials.yaml'), 'DEEPSEEK_API_KEY: first-key\n', { mode: 0o600 })
|
|
const serverA = await mockServer([{ kind: 'sse', events: textEvents }])
|
|
const serverB = await mockServer([{ kind: 'sse', events: textEvents }])
|
|
const { ctx } = await boot(dir, { baseURL: serverA.url })
|
|
|
|
await prompt(ctx)
|
|
expect(serverA.headers[0]?.authorization).toBe('Bearer first-key')
|
|
|
|
await ctx.settings.update(NS, { baseURL: serverB.url })
|
|
await ctx.credentials.set(KEY_REF, 'second-key')
|
|
|
|
await prompt(ctx)
|
|
// No restart, no re-registration: the next request resolved both facts.
|
|
expect(serverA.requests).toHaveLength(1)
|
|
expect(serverB.headers[0]?.authorization).toBe('Bearer second-key')
|
|
})
|
|
|
|
it('refuses a literal apiKey in settings and keeps serving the stored credential', async () => {
|
|
vi.stubEnv('DEEPSEEK_API_KEY', '')
|
|
const dir = await home()
|
|
await writeFile(join(dir, '.credentials.yaml'), 'DEEPSEEK_API_KEY: file-key\n', { mode: 0o600 })
|
|
const server = await mockServer([{ kind: 'sse', events: textEvents }])
|
|
const { ctx } = await boot(dir, { baseURL: server.url })
|
|
|
|
// Configuration carries a reference, never a value. The namespace has no
|
|
// `apiKey` field, so writing one is dropped by the schema rather than
|
|
// rejected (no adapter namespace is strict); what matters is that a
|
|
// settings document cannot become a second credential store outranking
|
|
// `.credentials.yaml` and the environment.
|
|
await ctx.settings.update(NS, { apiKey: 'literal-key' })
|
|
await prompt(ctx)
|
|
expect(server.headers[0]?.authorization).toBe('Bearer file-key')
|
|
})
|
|
|
|
it('starts keyless and serves the next request once the key arrives', async () => {
|
|
vi.stubEnv('DEEPSEEK_API_KEY', '')
|
|
const dir = await home()
|
|
const server = await mockServer([{ kind: 'sse', events: textEvents }])
|
|
const { ctx } = await boot(dir, { baseURL: server.url })
|
|
|
|
await expect(prompt(ctx)).rejects.toMatchObject({ code: 'MISSING_CREDENTIAL' })
|
|
await ctx.credentials.set(KEY_REF, 'sk-arrived')
|
|
await prompt(ctx)
|
|
expect(server.headers[0]?.authorization).toBe('Bearer sk-arrived')
|
|
})
|
|
|
|
it('advertises a live settings catalog without re-registration', async () => {
|
|
const dir = await home()
|
|
const { ctx } = await boot(dir, { apiKey: 'k', baseURL: 'http://127.0.0.1:1' })
|
|
|
|
await expect(ctx.llm.listModels('deepseek-official')).resolves.toHaveLength(2)
|
|
await ctx.settings.update(NS, { models: [{ id: 'settings-model', name: 'From Settings' }] })
|
|
await expect(ctx.llm.listModels('deepseek-official')).resolves.toEqual([
|
|
{ provider: 'deepseek-official', id: 'settings-model', name: 'From Settings' },
|
|
])
|
|
})
|
|
|
|
it('re-registers the route in place when the captured retry policy changes, without an empty-registry window', async () => {
|
|
const dir = await home()
|
|
const { ctx } = await boot(dir, { apiKey: 'k', baseURL: 'http://127.0.0.1:1' })
|
|
|
|
// Observing the topology event, not just the end state: disposing and
|
|
// re-registering also lands on the right final registry, but publishes an
|
|
// empty route set in between, so an observer sees the provider disappear.
|
|
const observed: string[][] = []
|
|
ctx.on('llm/adapters-updated', () => {
|
|
observed.push(ctx.llm.listProviders().map(provider => provider.id))
|
|
})
|
|
|
|
await ctx.settings.update(NS, {
|
|
retryPolicy: { mode: 'always', backoff: { initialDelayMs: 25, maxDelayMs: 100, jitterRatio: 0.2 } },
|
|
})
|
|
expect(ctx.llm.providerRetryPolicy('deepseek-official')).toEqual({
|
|
mode: 'always',
|
|
initialDelayMs: 25,
|
|
maxDelayMs: 100,
|
|
jitterRatio: 0.2,
|
|
})
|
|
expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek-official', name: 'DeepSeek' }])
|
|
expect(observed).toEqual([['deepseek-official']])
|
|
})
|
|
|
|
it('keeps the last good options when a settings snapshot fails beyond-schema validation', async () => {
|
|
const dir = await home()
|
|
const { ctx } = await boot(dir, { apiKey: 'k', baseURL: 'http://127.0.0.1:1' })
|
|
|
|
// Schema-valid but resolver-invalid: duplicate catalog ids pass the array
|
|
// schema and fail the explicit resolve step.
|
|
await ctx.settings.update(NS, { models: [{ id: 'dup' }, { id: 'dup' }] })
|
|
await expect(ctx.llm.listModels('deepseek-official')).resolves.toHaveLength(2)
|
|
await ctx.settings.update(NS, { models: [{ id: 'recovered' }] })
|
|
await expect(ctx.llm.listModels('deepseek-official')).resolves.toEqual([
|
|
{ provider: 'deepseek-official', id: 'recovered', name: 'recovered' },
|
|
])
|
|
})
|
|
|
|
it('keeps the whole last-good snapshot when a rejected one changed the URL', async () => {
|
|
const dir = await home()
|
|
const good = await mockServer([{ kind: 'sse', events: textEvents }])
|
|
const rejected = await mockServer([{ kind: 'sse', events: textEvents }])
|
|
vi.stubEnv('DEEPSEEK_API_KEY', 'good-key')
|
|
const { ctx } = await boot(dir, { baseURL: good.url })
|
|
|
|
// One snapshot moves the endpoint and fails the resolve step beyond the
|
|
// schema (duplicate catalog ids).
|
|
await ctx.settings.update(NS, {
|
|
baseURL: rejected.url,
|
|
models: [{ id: 'dup' }, { id: 'dup' }],
|
|
})
|
|
|
|
await prompt(ctx)
|
|
// The rejected generation contributes nothing: not its endpoint, and — the
|
|
// regression this pins — not its key either.
|
|
expect(rejected.requests).toHaveLength(0)
|
|
expect(good.requests).toHaveLength(1)
|
|
expect(good.headers[0]?.authorization).toBe('Bearer good-key')
|
|
})
|
|
|
|
it('falls back to the composition entry when settings detach', async () => {
|
|
vi.stubEnv('DEEPSEEK_API_KEY', '')
|
|
const dir = await home()
|
|
await writeFile(join(dir, '.credentials.yaml'), 'DEEPSEEK_API_KEY: steady-key\n', { mode: 0o600 })
|
|
const serverA = await mockServer([{ kind: 'sse', events: textEvents }])
|
|
const serverB = await mockServer([{ kind: 'sse', events: textEvents }])
|
|
const { ctx, settingsFiber } = await boot(dir, { baseURL: serverA.url })
|
|
|
|
await ctx.settings.update(NS, { baseURL: serverB.url })
|
|
await prompt(ctx)
|
|
expect(serverB.requests).toHaveLength(1)
|
|
|
|
await settingsFiber.dispose()
|
|
await prompt(ctx)
|
|
expect(serverA.requests).toHaveLength(1)
|
|
expect(serverA.headers[0]?.authorization).toBe('Bearer steady-key')
|
|
})
|
|
})
|