The shipped surfaces loaded the harness home's .env into the process environment before cordis booted. credentials-local then saw every stored key as an ambient launch override: describe reported source 'env' with writable false, and set/unset rejected as shadowed — so a key the web page or TUI stored was unrotatable and undeletable from the next run onward, and the adapter kept using the value captured at launch. The home's .env is now the credential provider's own store, read by that provider alone and hot-reloaded by it. The genuine launch environment and the invoking directory's .env (loaded by the bin) remain the read-only ambient layer, so a plain composition without the provider still resolves keys exactly as before. Proven by a real restart in the loader composition: store a key through the seam, dispose the tree, re-boot over the same harness home, and the entry is still file-sourced and writable — rotating it lands on the very next request.
175 lines
7.7 KiB
TypeScript
175 lines
7.7 KiB
TypeScript
/**
|
|
* Real-composition guard for the dynamic-configuration chain: LlmService,
|
|
* settings-local, credentials-local, and llm-deepseek boot from a test-only
|
|
* cordis.yml through the actual Loader + Include path, external edits of
|
|
* settings.yaml and .env hot-publish through their providers, and the very
|
|
* next request carries the fresh base URL and credential. The same adapter
|
|
* composition without settings or credentials entries keeps entry-config
|
|
* behavior — the documented optional-inject fallback.
|
|
*/
|
|
|
|
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
|
import { tmpdir } from 'node:os'
|
|
import { join } from 'node:path'
|
|
import { pathToFileURL } from 'node:url'
|
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
import { Context } from 'cordis'
|
|
import Loader from '@cordisjs/plugin-loader'
|
|
import Include from '@cordisjs/plugin-include'
|
|
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')
|
|
|
|
let root: string | undefined
|
|
let context: Context | undefined
|
|
|
|
afterEach(async () => {
|
|
await context?.fiber.dispose()
|
|
context = undefined
|
|
if (root !== undefined) await rm(root, { recursive: true, force: true })
|
|
root = undefined
|
|
await closeMockServers()
|
|
vi.unstubAllEnvs()
|
|
})
|
|
|
|
async function loadComposition(
|
|
options: { withDynamic: boolean; baseURL: string; reuseRoot?: string },
|
|
): Promise<{ ctx: Context; settingsPath: string; envPath: string }> {
|
|
// A reused root is the restart case: the same harness home, its documents
|
|
// exactly as the previous process left them.
|
|
const fresh = options.reuseRoot === undefined
|
|
root = options.reuseRoot ?? await mkdtemp(join(tmpdir(), 'dsh-llm-composition-'))
|
|
const settingsPath = join(root, 'settings.yaml')
|
|
const envPath = join(root, '.env')
|
|
if (options.withDynamic && fresh) {
|
|
await writeFile(settingsPath, '# personal settings\n')
|
|
await writeFile(envPath, 'DEEPSEEK_API_KEY=boot-key\n')
|
|
}
|
|
|
|
const configPath = join(root, 'cordis.yml')
|
|
await writeFile(configPath, [
|
|
'- id: llm',
|
|
" name: 'test-llm-service'",
|
|
...options.withDynamic
|
|
? [
|
|
'- id: settings',
|
|
" name: '@deepseek-ai/dsh-settings-local'",
|
|
' config:',
|
|
` path: ${JSON.stringify(settingsPath)}`,
|
|
' debounceMs: 10',
|
|
'- id: credentials',
|
|
" name: '@deepseek-ai/dsh-credentials-local'",
|
|
' config:',
|
|
` path: ${JSON.stringify(envPath)}`,
|
|
' debounceMs: 10',
|
|
]
|
|
: [],
|
|
'- id: llm-deepseek',
|
|
" name: '@deepseek-ai/dsh-llm-deepseek'",
|
|
' config:',
|
|
` baseURL: ${JSON.stringify(options.baseURL)}`,
|
|
...options.withDynamic ? [] : [' apiKey: entry-key'],
|
|
'',
|
|
].join('\n'))
|
|
|
|
const ctx = new Context()
|
|
context = ctx
|
|
ctx.baseUrl = pathToFileURL(root).href + '/'
|
|
await ctx.plugin(Loader)
|
|
ctx.loader.builtins.include = Include
|
|
const modules = new Map<string, unknown>([
|
|
['test-llm-service', LlmService],
|
|
['@deepseek-ai/dsh-settings-local', SettingsLocal],
|
|
['@deepseek-ai/dsh-credentials-local', CredentialsLocal],
|
|
['@deepseek-ai/dsh-llm-deepseek', LlmDeepSeek],
|
|
])
|
|
ctx.loader.internal = {
|
|
version: 'v2',
|
|
async import(specifier: string) {
|
|
if (!modules.has(specifier)) throw new Error(`unexpected Loader import: ${specifier}`)
|
|
return modules.get(specifier)
|
|
},
|
|
} as unknown as NonNullable<typeof ctx.loader.internal>
|
|
await ctx.loader.create({
|
|
name: 'cordis:include',
|
|
config: { path: pathToFileURL(configPath).href },
|
|
})
|
|
await ctx.loader.await()
|
|
return { ctx, settingsPath, envPath }
|
|
}
|
|
|
|
describe('llm-deepseek real dynamic composition', () => {
|
|
it('boots from cordis.yml and routes the next request after external settings and .env edits', async () => {
|
|
vi.stubEnv('DEEPSEEK_API_KEY', '')
|
|
const serverA = await mockServer([{ kind: 'sse', events: textEvents }])
|
|
const serverB = await mockServer([{ kind: 'sse', events: textEvents }])
|
|
const { ctx, settingsPath, envPath } = await loadComposition({ withDynamic: true, baseURL: serverA.url })
|
|
|
|
expect(ctx.get('settings')!.describe().map(entry => entry.ns)).toEqual([NS])
|
|
await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] })
|
|
expect(serverA.headers[0]?.authorization).toBe('Bearer boot-key')
|
|
|
|
// External edits, exactly as a user or the web UI would leave them on disk.
|
|
await writeFile(settingsPath, `llm-deepseek:\n baseURL: ${serverB.url}\n`)
|
|
await vi.waitFor(() => {
|
|
expect((ctx.get('settings')!.get(NS) as { baseURL?: string }).baseURL).toBe(serverB.url)
|
|
}, { timeout: 5000 })
|
|
await writeFile(envPath, 'DEEPSEEK_API_KEY=rotated-key\n')
|
|
await vi.waitFor(async () => {
|
|
expect(await ctx.get('credentials')!.resolve(KEY_REF)).toEqual({ value: 'rotated-key', source: 'file' })
|
|
}, { timeout: 5000 })
|
|
|
|
await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] })
|
|
expect(serverA.requests).toHaveLength(1)
|
|
expect(serverB.headers[0]?.authorization).toBe('Bearer rotated-key')
|
|
})
|
|
|
|
it('keeps a stored key writable and rotatable across a real restart', async () => {
|
|
// No ambient DEEPSEEK_API_KEY: the shipped surfaces no longer hoist
|
|
// $DSH_HOME/.env into process.env, so a stored key must stay file-sourced.
|
|
vi.stubEnv('DEEPSEEK_API_KEY', '')
|
|
const first = await mockServer([{ kind: 'sse', events: textEvents }])
|
|
const second = await mockServer([{ kind: 'sse', events: textEvents }])
|
|
const boot = await loadComposition({ withDynamic: true, baseURL: first.url })
|
|
const home = root!
|
|
await boot.ctx.get('credentials')!.set(KEY_REF, 'stored-by-ui')
|
|
expect(await boot.ctx.get('credentials')!.describe(KEY_REF))
|
|
.toEqual({ configured: true, source: 'file', writable: true })
|
|
await assemble(boot.ctx, { model: 'deepseek-v4-flash', messages: [] })
|
|
expect(first.headers[0]?.authorization).toBe('Bearer stored-by-ui')
|
|
await boot.ctx.fiber.dispose()
|
|
context = undefined
|
|
|
|
// Restart over the same harness home.
|
|
const restarted = await loadComposition({ withDynamic: true, baseURL: second.url, reuseRoot: home })
|
|
const credentials = restarted.ctx.get('credentials')!
|
|
// The stored key is still the provider's own writable file entry — not a
|
|
// read-only launch override, which is what hoisting it would have made it.
|
|
expect(await credentials.resolve(KEY_REF)).toEqual({ value: 'stored-by-ui', source: 'file' })
|
|
expect(await credentials.describe(KEY_REF)).toEqual({ configured: true, source: 'file', writable: true })
|
|
// Rotation still works after the restart, and the next request uses it.
|
|
await credentials.set(KEY_REF, 'rotated-after-restart')
|
|
await assemble(restarted.ctx, { model: 'deepseek-v4-flash', messages: [] })
|
|
expect(second.headers[0]?.authorization).toBe('Bearer rotated-after-restart')
|
|
})
|
|
|
|
it('boots the same adapter without settings or credentials entries on entry config alone', async () => {
|
|
vi.stubEnv('DEEPSEEK_API_KEY', '')
|
|
const server = await mockServer([{ kind: 'sse', events: textEvents }])
|
|
const { ctx } = await loadComposition({ withDynamic: false, baseURL: server.url })
|
|
|
|
expect(ctx.get('settings')).toBeUndefined()
|
|
expect(ctx.get('credentials')).toBeUndefined()
|
|
await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] })
|
|
expect(server.headers[0]?.authorization).toBe('Bearer entry-key')
|
|
})
|
|
})
|