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

@@ -69,12 +69,13 @@
- id: settings
name: '@deepseek-ai/dsh-settings-local'
# Credential store: the live process environment over `$DSH_HOME/.env`
# Credential store: the live process environment over `$DSH_HOME/.credentials.yaml`
# (owner-only file, hot-reloaded). Adapters resolve their key references
# through it at each request, so no key is inlined in this file. The web
# Models page's key inputs write it through `credentials.set`; nothing hoists
# the document into the process environment, which would make every stored key
# read as an unrotatable ambient override.
# Models page's key inputs write it through `credentials.set`. The document
# holds credentials only and is never hoisted into the process environment;
# the user's ordinary environment layer is `$DSH_HOME/.env`, and a key placed
# there instead reads as an unrotatable ambient override.
- id: credentials
name: '@deepseek-ai/dsh-credentials-local'

View File

@@ -4,9 +4,9 @@
* Everything here is what must exist before the Loader runs: the patch
* composition over the shipped base and surface overlay (CLI flags + the
* resolved frontend dist), and the fail-loud activation audit after the tree
* settles. The environment is what the bin already loaded (ambient plus the
* invoking directory's `.env`); `$DSH_HOME/.env` belongs to the credential
* provider and is never hoisted here.
* settles. The environment is what the bin already loaded (ambient over the
* invoking directory's `.env` over `$DSH_HOME/.env`); credentials live in
* `$DSH_HOME/.credentials.yaml` and are never hoisted into it.
*/
import { readFileSync } from 'node:fs'

View File

@@ -10,7 +10,7 @@
import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { loadEnv } from '@deepseek-ai/dsh-app-boot'
import { loadLayeredEnv } from '@deepseek-ai/dsh-app-boot'
import { parseDshArgs } from './args.ts'
// Both the source tree (apps/cli/src) and the bundled bin (apps/cli/lib) sit
@@ -24,7 +24,7 @@ function readVersion(): string {
return typeof manifest.version === 'string' ? manifest.version : '0.0.0'
}
loadEnv('dsh')
loadLayeredEnv('dsh')
// The env opt-in is read at the process boundary; `1` is the documented value.
const invocation = parseDshArgs(process.argv.slice(2), readVersion(), process.env.DSH_EXPERIMENTAL === '1')

View File

@@ -115,12 +115,11 @@ export async function runTui(
)
process.exit(1)
}
// The bin already loaded the invoking directory's .env, and that is the
// whole environment: $DSH_HOME/.env is credentials-local's writable store,
// and hoisting it would make every stored key read as a read-only ambient
// override on the next run — unrotatable from the TUI or the web page.
// The environment is settled, so switching the workspace here cannot alter
// its precedence. The cwd IS the workspace seam: the shipped config
// The bin already loaded both environment files, and that is the whole
// environment: credentials live in `$DSH_HOME/.credentials.yaml`, which is
// never hoisted, so a stored key stays rotatable from the TUI and the web
// page. The environment is settled, so switching the workspace here cannot
// alter its precedence — the project layer is the *invoking* directory's. The cwd IS the workspace seam: the shipped config
// resolves the session cwd and the HMR watch root from it, so one chdir moves
// both together. Sessions themselves live under the Harness home so `/resume`
// spans every workspace, and are unaffected by this chdir.

View File

@@ -667,40 +667,41 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => {
expect(output).toContain('\u001B[?2004l')
}, PTY_SMOKE_TEST_TIMEOUT_MS)
it('applies the personal overlay: config.yaml patches an overlay-inserted row, the invoking directory\'s .env feeds its !!js, and the home .env stays out of the environment', async () => {
// The whole personal-config chain in one boot, plus the environment layer
// it deliberately excludes. config.yaml patches the `tui` row — a row the
it('applies the personal overlay: config.yaml patches an overlay-inserted row, and both .env layers feed its !!js with the project one winning', async () => {
// The whole personal-config chain in one boot, plus the environment
// layering underneath it. config.yaml patches the `tui` row — a row the
// SURFACE OVERLAY inserted, not one the base declares — proving a later
// patch list reaches a row an earlier one inserted. The single `!!js`
// expression prefers the PERSONAL variable, so the welcome can only render
// the project value while the harness home's .env — the credential store
// of `dsh-credentials-local` — is NOT hoisted into `process.env`; hoisting
// it would make every stored key read as a read-only launch override on
// the next run and hand it to every subprocess the agent starts.
// patch list reaches a row an earlier one inserted. The `!!js` expression
// renders both halves of the layering in one line: `DSH_LAYER_WELCOME` is
// set by BOTH .env files and must render the project value, while
// `DSH_USER_ONLY` exists only in the harness home's .env and must still
// arrive. Credentials are not part of this: they live in
// `.credentials.yaml`, which is never hoisted into `process.env`.
const output = await smoke({
label: 'dsh personal overlay',
tempDirPrefix: 'dsh-personal-overlay-',
binScript: dshBinScript,
configArgs: [],
prepare: seedWorkspace({
workspace: { '.env': 'DSH_PROJECT_WELCOME=PROJECT OVERLAY READY.\n' },
workspace: { '.env': 'DSH_LAYER_WELCOME=PROJECT WINS.\n' },
personal: {
'.env': 'DSH_PERSONAL_WELCOME=HOME ENV LEAKED.\n',
'.env': 'DSH_LAYER_WELCOME=USER LAYER LOST.\nDSH_USER_ONLY=USER LAYER LOADED.\n',
'config.yaml': [
'- id: workspace-context',
' disabled: true',
'- id: tui',
' config:',
" sessionId: !!js configuredAgentIdentities?.main?.id ?? 'main'",
' welcome: !!js process.env.DSH_PERSONAL_WELCOME ?? process.env.DSH_PROJECT_WELCOME',
' welcome: !!js "(process.env.DSH_LAYER_WELCOME ?? \'PROJECT LAYER MISSING.\')'
+ ' + \' \' + (process.env.DSH_USER_ONLY ?? \'USER LAYER MISSING.\')"',
'',
].join('\n'),
},
}),
actions: [{ waitFor: 'PROJECT OVERLAY READY.', send: '/exit\r' }],
actions: [{ waitFor: 'PROJECT WINS. USER LAYER LOADED.', send: '/exit\r' }],
})
expect(output).toContain('PROJECT OVERLAY READY.')
expect(output).not.toContain('HOME ENV LEAKED.')
expect(output).toContain('PROJECT WINS. USER LAYER LOADED.')
expect(output).not.toContain('USER LAYER LOST.')
expect(output).toContain('\u001B[?2004l')
}, PTY_SMOKE_TEST_TIMEOUT_MS)