Files
deepseek-harness/packages/llm/llm-deepseek/tests/loader-composition.spec.ts
T
Yichen Jiang 8f045bfdbd fix(cli)!: stop hoisting $DSH_HOME/.env into process.env
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.
2026-07-30 15:44:32 +08:00

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')
})
})