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

@@ -66,21 +66,21 @@ async function boot(config: ConstructorParameters<typeof CredentialsLocal>[1]):
describe('watcher pipeline', () => {
it('clamps the write-settle poll interval for a zero debounce', async () => {
const dir = await tempDir()
await boot({ path: join(dir, '.env'), debounceMs: 0 })
await boot({ path: join(dir, '.credentials.yaml'), debounceMs: 0 })
const [instance] = await fakeInstances()
expect(instance!.options.awaitWriteFinish).toEqual({ stabilityThreshold: 0, pollInterval: 1 })
})
it('survives a watcher error and keeps publishing later edits', async () => {
const dir = await tempDir()
const path = join(dir, '.env')
const path = join(dir, '.credentials.yaml')
const ctx = await boot({ path, debounceMs: 5 })
const [instance] = await fakeInstances()
instance!.watcher.emit('error', new Error('watch backend failure'))
expect(await ctx.credentials.resolve(KEY)).toBeUndefined()
await writeFile(path, 'DSH_CRED_PIPE=arrived\n')
await writeFile(path, 'DSH_CRED_PIPE: arrived\n')
instance!.watcher.emit('all', 'change', path)
await vi.waitFor(async () => {
expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'arrived', source: 'file' })
@@ -89,8 +89,8 @@ describe('watcher pipeline', () => {
it('keeps the last good snapshot when the file turns unreadable at runtime', async () => {
const dir = await tempDir()
const path = join(dir, '.env')
await writeFile(path, 'DSH_CRED_PIPE=good\n')
const path = join(dir, '.credentials.yaml')
await writeFile(path, 'DSH_CRED_PIPE: good\n')
const ctx = await boot({ path, debounceMs: 5 })
await chmod(path, 0o000)
@@ -104,7 +104,7 @@ describe('watcher pipeline', () => {
it('keeps the reload queue alive after an invariant violation escapes the fan-out', async () => {
const dir = await tempDir()
const path = join(dir, '.env')
const path = join(dir, '.credentials.yaml')
const ctx = await boot({ path, debounceMs: 5 })
let arm = true
ctx.on('credentials/updated', () => {
@@ -113,7 +113,7 @@ describe('watcher pipeline', () => {
})
const [instance] = await fakeInstances()
await writeFile(path, 'DSH_CRED_PIPE=first\n')
await writeFile(path, 'DSH_CRED_PIPE: first\n')
instance!.watcher.emit('all', 'change', path)
// The snapshot commits before the fan-out, so the value lands even though
// the listener threw out of the refresh.
@@ -122,7 +122,7 @@ describe('watcher pipeline', () => {
})
arm = false
await writeFile(path, 'DSH_CRED_PIPE=second\n')
await writeFile(path, 'DSH_CRED_PIPE: second\n')
instance!.watcher.emit('all', 'change', path)
await vi.waitFor(async () => {
expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'second', source: 'file' })
@@ -131,8 +131,8 @@ describe('watcher pipeline', () => {
it('quiesces the refresh pipeline before dispose completes', async () => {
const dir = await tempDir()
const path = join(dir, '.env')
await writeFile(path, 'DSH_CRED_PIPE=initial\n')
const path = join(dir, '.credentials.yaml')
await writeFile(path, 'DSH_CRED_PIPE: initial\n')
const ctx = new Context()
const fiber = ctx.plugin(CredentialsLocal, { path, debounceMs: 5 })
await fiber
@@ -142,7 +142,7 @@ describe('watcher pipeline', () => {
if (disposed) postDisposeCommits += 1
})
await writeFile(path, 'DSH_CRED_PIPE=changed\n')
await writeFile(path, 'DSH_CRED_PIPE: changed\n')
const [instance] = await fakeInstances()
// Two queued refreshes: dispose interrupts one mid-flight and the other
// before it starts, so both closed guards must hold.
@@ -158,8 +158,8 @@ describe('watcher pipeline', () => {
it('empties the snapshot when the document is deleted and emits the removals', async () => {
const dir = await tempDir()
const path = join(dir, '.env')
await writeFile(path, 'DSH_CRED_PIPE=doomed\n')
const path = join(dir, '.credentials.yaml')
await writeFile(path, 'DSH_CRED_PIPE: doomed\n')
const ctx = await boot({ path, debounceMs: 5 })
const seen: string[] = []
ctx.on('credentials/updated', (ref) => {
@@ -175,30 +175,39 @@ describe('watcher pipeline', () => {
expect(seen).toEqual([KEY])
})
it('publishes only seam-addressable keys and preserves the rest untouched', async () => {
it('keeps the last good snapshot when an external edit makes the document invalid', async () => {
const dir = await tempDir()
const path = join(dir, '.env')
await writeFile(path, 'BAD-KEY=1\nDSH_CRED_PIPE=a\n')
const path = join(dir, '.credentials.yaml')
await writeFile(path, 'DSH_CRED_PIPE: a\n')
const ctx = await boot({ path, debounceMs: 5 })
const seen: string[] = []
ctx.on('credentials/updated', (ref) => {
seen.push(ref)
})
await writeFile(path, 'BAD-KEY=2\nDSH_CRED_PIPE=b\n')
// A key the seam cannot address is a rejection, not preserved content:
// this document holds nothing but credentials. A live reload must warn
// and keep serving the last good snapshot rather than take the process
// down or silently drop the entry it could not validate.
await writeFile(path, 'BAD-KEY: 2\nDSH_CRED_PIPE: b\n')
const [instance] = await fakeInstances()
instance!.watcher.emit('all', 'change', path)
await new Promise(resolve => setTimeout(resolve, 50))
expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'a', source: 'file' })
expect(seen).toEqual([])
// Repairing the document resumes publishing.
await writeFile(path, 'DSH_CRED_PIPE: b\n')
instance!.watcher.emit('all', 'change', path)
await vi.waitFor(async () => {
expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'b', source: 'file' })
})
// The dash-named key is preserved file content the seam cannot address:
// its change publishes nothing and breaks nothing.
expect(seen).toEqual([KEY])
})
it('treats an event for a still-absent file as a no-op', async () => {
const dir = await tempDir()
const path = join(dir, '.env')
const path = join(dir, '.credentials.yaml')
const ctx = await boot({ path, debounceMs: 5 })
const [instance] = await fakeInstances()
instance!.watcher.emit('all', 'add', path)
@@ -208,12 +217,12 @@ describe('watcher pipeline', () => {
it('reconciles at watcher ready so a change during setup is not missed', async () => {
const dir = await tempDir()
const path = join(dir, '.env')
await writeFile(path, `${KEY}=a\n`)
const path = join(dir, '.credentials.yaml')
await writeFile(path, `${KEY}: a\n`)
const ctx = await boot({ path, debounceMs: 5 })
// Written after the initial load but before the watcher became active:
// no 'all' event will ever fire for it.
await writeFile(path, `${KEY}=written-before-ready\n`)
await writeFile(path, `${KEY}: written-before-ready\n`)
const [instance] = await fakeInstances()
instance!.watcher.emit('ready')
await vi.waitFor(async () => {