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
+61 -1
View File
@@ -7,7 +7,7 @@ import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
import {
addHarnessSourceSection, assertEntriesActivated, assertEntriesLoaded, boot,
FAIL_LOUD_RELEASE_TIMEOUT_MS, HARNESS_SOURCE_SECTION,
installFailLoud, loadEnv, loadOverlayPatches, resolveConfigPath, type FailLoudProcess,
installFailLoud, loadEnv, loadLayeredEnv, loadOverlayPatches, resolveConfigPath, type FailLoudProcess,
} from '../src/index.ts'
const NAME = 'dsh-test-bin'
@@ -86,6 +86,66 @@ describe('loadEnv', () => {
})
})
describe('loadLayeredEnv', () => {
const NAMES = ['DSH_APP_BOOT_LAYERED_SHARED', 'DSH_APP_BOOT_LAYERED_USER', 'DSH_APP_BOOT_LAYERED_PROJECT'] as const
function clear(): void {
for (const name of NAMES) Reflect.deleteProperty(process.env, name)
}
it('layers user under project under the inherited environment', () => {
const home = tmp()
const project = tmp()
writeFileSync(join(home, '.env'), [
`${NAMES[0]}=user`,
`${NAMES[1]}=user-only`,
'DSH_APP_BOOT_LAYERED_INHERITED=user-loses',
'',
].join('\n'))
writeFileSync(join(project, '.env'), [
`${NAMES[0]}=project`,
`${NAMES[2]}=project-only`,
'DSH_APP_BOOT_LAYERED_INHERITED=project-loses',
'',
].join('\n'))
clear()
vi.stubEnv('DSH_HOME', home)
vi.stubEnv('DSH_APP_BOOT_LAYERED_INHERITED', 'inherited')
const warn = vi.fn()
try {
loadLayeredEnv(NAME, project, warn)
// Both files load; the project layer wins the name they share, and the
// inherited environment wins over both.
expect(process.env[NAMES[0]]).toBe('project')
expect(process.env[NAMES[1]]).toBe('user-only')
expect(process.env[NAMES[2]]).toBe('project-only')
expect(process.env['DSH_APP_BOOT_LAYERED_INHERITED']).toBe('inherited')
expect(warn).not.toHaveBeenCalled()
} finally {
clear()
vi.unstubAllEnvs()
}
})
it('resolves the harness home before the project file can redirect it', () => {
const home = tmp()
const decoy = tmp()
const project = tmp()
writeFileSync(join(home, '.env'), `${NAMES[1]}=real-home\n`)
writeFileSync(join(decoy, '.env'), `${NAMES[1]}=decoy-home\n`)
writeFileSync(join(project, '.env'), `DSH_HOME=${decoy}\n`)
clear()
vi.stubEnv('DSH_HOME', home)
try {
loadLayeredEnv(NAME, project, vi.fn())
expect(process.env[NAMES[1]]).toBe('real-home')
} finally {
clear()
vi.unstubAllEnvs()
}
})
})
describe('installFailLoud', () => {
function fakeProc(): FailLoudProcess & { handlers: Array<(err: unknown) => void>; written: string[]; exits: number[] } {
const handlers: Array<(err: unknown) => void> = []