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.
This commit is contained in:
@@ -41,12 +41,15 @@ afterEach(async () => {
|
||||
})
|
||||
|
||||
async function loadComposition(
|
||||
options: { withDynamic: boolean; baseURL: string },
|
||||
options: { withDynamic: boolean; baseURL: string; reuseRoot?: string },
|
||||
): Promise<{ ctx: Context; settingsPath: string; envPath: string }> {
|
||||
root = await mkdtemp(join(tmpdir(), 'dsh-llm-composition-'))
|
||||
// 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) {
|
||||
if (options.withDynamic && fresh) {
|
||||
await writeFile(settingsPath, '# personal settings\n')
|
||||
await writeFile(envPath, 'DEEPSEEK_API_KEY=boot-key\n')
|
||||
}
|
||||
@@ -129,6 +132,35 @@ describe('llm-deepseek real dynamic composition', () => {
|
||||
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 }])
|
||||
|
||||
@@ -26,8 +26,8 @@ This package carries no loader hooks and no dev-mode surface. The [`dsh` app](..
|
||||
|
||||
A developer's machine-local preferences live outside every repository in the Harness home (default `~/.dsh`, overridable via `$DSH_HOME`; the single root [`resolveDshHome`](../../util/paths/README.md) resolves), consumed by the `dsh` CLI's TUI surface ([`apps/cli`](../../../apps/cli/README.md)); the demo bins boot their committed trees verbatim. Two optional files:
|
||||
|
||||
- **`.env`** — loaded after the invoking directory's `.env`; `process.loadEnvFile` never overrides, so precedence is ambient environment > project `.env` > personal `.env`.
|
||||
- **`config.yaml`** — loader overlay patches applied over the shipped default config, with the same semantics as an include entry's `patches` (the committed Code Mode overlay is the template): an id-targeted patch replaces the named entry's whole `config` (restate unchanged fields), `insert` adds entries, and `!!js` expressions interpolate at mount — so a personal `apiKey` can reference the personal `.env`. A patch naming an entry id absent from the booted tree is skipped with a loader warning. An empty or comments-only file throws (it parses to nothing, not to a list); disable the overlay with `[]` or by deleting the file.
|
||||
- **`.env`** — the credential store of [`dsh-credentials-local`](../../credentials/credentials-local/README.md), read by that provider alone. No surface hoists it into `process.env`: doing so would make every stored key look like a read-only launch override on the next run, blocking rotation from the TUI and the web page. The environment layers are the ambient one and the invoking directory's `.env` (loaded by the bin; `process.loadEnvFile` never overrides), and a composition without the credential provider keeps resolving keys from those alone.
|
||||
- **`config.yaml`** — loader overlay patches applied over the shipped default config, with the same semantics as an include entry's `patches` (the committed Code Mode overlay is the template): an id-targeted patch replaces the named entry's whole `config` (restate unchanged fields), `insert` adds entries, and `!!js` expressions interpolate at mount. A patch naming an entry id absent from the booted tree is skipped with a loader warning. An empty or comments-only file throws (it parses to nothing, not to a list); disable the overlay with `[]` or by deleting the file.
|
||||
|
||||
Subprocess test launchers point `DSH_HOME` at an isolated per-test directory so a developer's personal overlay can never leak into fixtures.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user