feat(credentials): move the store to .credentials.yaml and layer $DSH_HOME/.env

$DSH_HOME/.env carried two incompatible jobs. As credentials-local's writable
secret store it could not be hoisted into process.env — hoisting makes every
stored key read as a read-only launch override and blocks rotation from the
TUI and the web page. But its name and dotenv format promise an environment
file, so a DEEPSEEK_BASE_URL sitting beside a working DEEPSEEK_API_KEY in the
same file was silently ignored: only the credential provider read the
document, and it addresses credential references alone.

Split the two jobs into two files.

.credentials.yaml is the provider-managed store: a strict YAML mapping of
CredentialRef to non-empty string, no version field, no wrapper level. Because
it holds credentials and nothing else, a non-mapping root, a non-identifier
key, a non-string value, an empty string, a duplicate key, and malformed YAML
are all rejections rather than skipped entries — loud at boot and at a write,
warn-and-keep-last-good on a live reload. The dotenv physical-line editor
gives way to a patch of the parsed document, so comments and untouched entries
keep their formatting and any string value round-trips, multi-line included.
Writer lock, read-modify-write, atomic 0600 write under a 0700 directory,
watcher, self-write suppression, and quiescent disposal are unchanged.

$DSH_HOME/.env becomes the user's ordinary environment layer. app-boot's new
loadLayeredEnv loads the invoking directory's .env then the Harness home's,
giving user < project < inherited; the home resolves from the inherited
environment first, so a project .env cannot redirect it.

Credential precedence is unchanged: the live environment still wins read-only
over the file, and shadowed writes still reject. Whether a provider-managed
store should instead win over the environment is a separate decision.

No migration: a key already in $DSH_HOME/.env keeps resolving through the new
environment layer, as a read-only env source that shadows the stored one.
This commit is contained in:
Yichen Jiang
2026-08-04 14:50:38 +08:00
parent 88c035c98e
commit 03b534de16
41 changed files with 566 additions and 423 deletions

View File

@@ -48,7 +48,7 @@ async function boot(dir: string, config: object): Promise<Harness> {
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, '.env'), watch: false })
await ctx.plugin(CredentialsLocal, { path: join(dir, '.credentials.yaml'), watch: false })
await ctx.plugin(LlmDeepSeek, config)
return { ctx, settingsFiber }
}
@@ -61,7 +61,7 @@ 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, '.env'), 'DEEPSEEK_API_KEY=first-key\n')
await writeFile(join(dir, '.credentials.yaml'), 'DEEPSEEK_API_KEY: first-key\n')
const serverA = await mockServer([{ kind: 'sse', events: textEvents }])
const serverB = await mockServer([{ kind: 'sse', events: textEvents }])
const { ctx } = await boot(dir, { baseURL: serverA.url })
@@ -81,7 +81,7 @@ describe('request-level dynamic configuration', () => {
it('prefers a literal settings apiKey over the credential layers', async () => {
vi.stubEnv('DEEPSEEK_API_KEY', '')
const dir = await home()
await writeFile(join(dir, '.env'), 'DEEPSEEK_API_KEY=file-key\n')
await writeFile(join(dir, '.credentials.yaml'), 'DEEPSEEK_API_KEY: file-key\n')
const server = await mockServer([{ kind: 'sse', events: textEvents }])
const { ctx } = await boot(dir, { baseURL: server.url })
@@ -178,7 +178,7 @@ describe('request-level dynamic configuration', () => {
it('falls back to the composition entry when settings detach', async () => {
vi.stubEnv('DEEPSEEK_API_KEY', '')
const dir = await home()
await writeFile(join(dir, '.env'), 'DEEPSEEK_API_KEY=steady-key\n')
await writeFile(join(dir, '.credentials.yaml'), 'DEEPSEEK_API_KEY: steady-key\n')
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 })

View File

@@ -2,7 +2,7 @@
* 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
* settings.yaml and the credentials document 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.
@@ -42,16 +42,16 @@ afterEach(async () => {
async function loadComposition(
options: { withDynamic: boolean; baseURL: string; reuseRoot?: string },
): Promise<{ ctx: Context; settingsPath: string; envPath: string }> {
): Promise<{ ctx: Context; settingsPath: string; credentialsPath: 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')
const credentialsPath = join(root, '.credentials.yaml')
if (options.withDynamic && fresh) {
await writeFile(settingsPath, '# personal settings\n')
await writeFile(envPath, 'DEEPSEEK_API_KEY=boot-key\n')
await writeFile(credentialsPath, 'DEEPSEEK_API_KEY: boot-key\n')
}
const configPath = join(root, 'cordis.yml')
@@ -68,7 +68,7 @@ async function loadComposition(
'- id: credentials',
" name: '@deepseek-ai/dsh-credentials-local'",
' config:',
` path: ${JSON.stringify(envPath)}`,
` path: ${JSON.stringify(credentialsPath)}`,
' debounceMs: 10',
]
: [],
@@ -103,15 +103,15 @@ async function loadComposition(
config: { path: pathToFileURL(configPath).href },
})
await ctx.loader.await()
return { ctx, settingsPath, envPath }
return { ctx, settingsPath, credentialsPath }
}
describe('llm-deepseek real dynamic composition', () => {
it('boots from cordis.yml and routes the next request after external settings and .env edits', async () => {
it('boots from cordis.yml and routes the next request after external settings and credential 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 })
const { ctx, settingsPath, credentialsPath } = 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: [] })
@@ -122,7 +122,7 @@ describe('llm-deepseek real dynamic composition', () => {
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 writeFile(credentialsPath, '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 })
@@ -134,7 +134,7 @@ describe('llm-deepseek real dynamic composition', () => {
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.
// the credentials document 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 }])

View File

@@ -44,7 +44,7 @@ async function boot(dir: string, config: LlmPiAi.Config): Promise<Context> {
})
await ctx.plugin(LlmService)
await ctx.plugin(SettingsLocal, { path: join(dir, 'settings.yaml'), watch: false })
await ctx.plugin(CredentialsLocal, { path: join(dir, '.env'), watch: false })
await ctx.plugin(CredentialsLocal, { path: join(dir, '.credentials.yaml'), watch: false })
await ctx.plugin(LlmPiAi, config)
return ctx
}
@@ -53,7 +53,7 @@ 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, '.env'), 'PI_DYNAMIC_KEY=pk-from-settings\n')
await writeFile(join(dir, '.credentials.yaml'), 'PI_DYNAMIC_KEY: pk-from-settings\n')
const server = await mockServer([{ events: textEvents }])
// The exact product posture: `- id: llm-pi-ai` with no config at all.
const ctx = await boot(dir, {})
@@ -112,7 +112,7 @@ describe('request-level dynamic profiles', () => {
it('rotates the per-request credential referenced by apiKeyEnv', async () => {
vi.stubEnv('PI_DYNAMIC_KEY', '')
const dir = await home()
await writeFile(join(dir, '.env'), 'PI_DYNAMIC_KEY=pk-one\n')
await writeFile(join(dir, '.credentials.yaml'), 'PI_DYNAMIC_KEY: pk-one\n')
const server = await mockServer([{ events: textEvents }, { events: textEvents }])
const ctx = await boot(dir, {
providers: { deepseek: { apiKeyEnv: 'PI_DYNAMIC_KEY', baseURL: server.url } },

View File

@@ -3,7 +3,7 @@
* settings-local, credentials-local, and a bare `llm-pi-ai` row boot from a
* test-only cordis.yml through the actual Loader + Include path, an external
* edit of settings.yaml registers the route live, and the next request
* carries the credential the .env supplies. A hand-mounted `ctx.plugin` cannot
* carries the credential the credentials document supplies. A hand-mounted `ctx.plugin` cannot
* catch Loader export-shape failures, which is why the twin adapter has the
* same guard.
*/
@@ -40,7 +40,7 @@ async function loadComposition(): Promise<{ ctx: Context; settingsPath: string }
root = await mkdtemp(join(tmpdir(), 'dsh-pi-composition-'))
const settingsPath = join(root, 'settings.yaml')
await writeFile(settingsPath, '# personal settings\n')
await writeFile(join(root, '.env'), 'PI_COMPOSITION_KEY=key-from-store\n')
await writeFile(join(root, '.credentials.yaml'), 'PI_COMPOSITION_KEY: key-from-store\n')
const configPath = join(root, 'cordis.yml')
await writeFile(configPath, [
@@ -54,7 +54,7 @@ async function loadComposition(): Promise<{ ctx: Context; settingsPath: string }
'- id: credentials',
" name: '@deepseek-ai/dsh-credentials-local'",
' config:',
` path: ${JSON.stringify(join(root, '.env'))}`,
` path: ${JSON.stringify(join(root, '.credentials.yaml'))}`,
' debounceMs: 10',
'- id: llm-pi-ai',
" name: '@deepseek-ai/dsh-llm-pi-ai'",