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:
@@ -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> = []
|
||||
|
||||
Reference in New Issue
Block a user