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
@@ -1,13 +1,19 @@
/**
* File-backed credentials provider layering the live process environment over
* a `$DSH_HOME/.env` document. The environment is authoritative and read-only
* (a launch-time override must win, and must be visibly read-only rather than
* silently shadow writes); the file is the provider-managed writable source:
* every write re-reads the document under a cross-process writer lock before
* rewriting only its own line — preserving every other byte, physical line
* endings and quoted multi-line values included — external edits hot-publish
* through the seam, and each reload replaces the snapshot wholesale so a
* deleted entry never lingers in memory.
* a `$DSH_HOME/.credentials.yaml` document. The environment is authoritative
* and read-only (a launch-time override must win, and must be visibly
* read-only rather than silently shadow writes); the file is the
* provider-managed writable source: every write re-reads the document under a
* cross-process writer lock before patching only its own key — comments and
* the formatting of every untouched entry survive — external edits
* hot-publish through the seam, and each reload replaces the snapshot
* wholesale so a deleted entry never lingers in memory.
*
* The document holds nothing but credentials, which is why it is a strict
* `CredentialRef`-to-string mapping rather than a dotenv file: a store the
* Harness owns and never materializes into the environment cannot also serve
* as the user's environment layer, and conflating the two is what made a
* non-secret in the old `$DSH_HOME/.env` silently unreachable.
* @module @deepseek-ai/dsh-credentials-local
*/
@@ -16,15 +22,18 @@ import z from 'schemastery'
import { watch as chokidarWatch } from 'chokidar'
import { mkdir, readFile } from 'node:fs/promises'
import { dirname, join, resolve } from 'node:path'
import { parse } from 'dotenv'
import { Document, parseDocument } from 'yaml'
import { withFileLock, writeFileAtomic } from '@deepseek-ai/dsh-atomic-write'
import { resolveDshHome } from '@deepseek-ai/dsh-paths'
import { Credentials, credentialRef } from '@deepseek-ai/dsh-credentials'
import type { CredentialInfo, CredentialRef, ResolvedCredential } from '@deepseek-ai/dsh-credentials'
/** Basename of the credentials document inside the harness home. */
export const CREDENTIALS_FILENAME = '.credentials.yaml'
/** Plugin config: file location and hot-reload behavior. */
export interface Config {
/** Credentials document path; defaults to `.env` under the harness home. */
/** Credentials document path; defaults to `.credentials.yaml` under the harness home. */
path?: string
/** Harness home used when `path` is omitted; defaults to `$DSH_HOME` or `~/.dsh`. */
dshHome?: string
@@ -43,13 +52,13 @@ interface ResolvedSpec {
/**
* Resolve the runtime spec from plugin config: an explicit `path` wins,
* otherwise the document lives at `<harness home>/.env`.
* otherwise the document lives at `<harness home>/.credentials.yaml`.
* @param config - raw plugin config.
* @returns the resolved file location and watch behavior.
*/
export function resolveSpec(config: Config): ResolvedSpec {
return {
filename: resolve(config.path ?? join(resolveDshHome(config.dshHome), '.env')),
filename: resolve(config.path ?? join(resolveDshHome(config.dshHome), CREDENTIALS_FILENAME)),
watch: config.watch ?? true,
debounceMs: config.debounceMs ?? 100,
}
@@ -60,129 +69,64 @@ function isENOENT(error: unknown): boolean {
return (error as NodeJS.ErrnoException | null)?.code === 'ENOENT'
}
/** Values that survive a dotenv round-trip without quoting. */
const BARE_VALUE = /^[A-Za-z0-9_@%+:,./-]+$/
/** Whether a value contains C0 control characters (newlines included) no dotenv style reads back. */
function hasControlCharacters(value: string): boolean {
for (const char of value) {
if (char.charCodeAt(0) < 0x20) return true
/**
* Parse one credentials document into its entries. The document is a strict
* mapping of {@link CredentialRef} to non-empty string: a non-mapping root, a
* key that is not a POSIX identifier, a non-string value, and an empty string
* are all rejected rather than skipped, because this file holds nothing but
* credentials and a silently ignored entry reads as "the key I stored has no
* effect". Duplicate keys surface as parser errors. An empty document is an
* empty store.
* @param text - the document's text.
* @param filename - absolute path, quoted in errors.
* @returns the parsed entries, keyed by reference.
*/
export function parseCredentialsDocument(text: string, filename: string): Map<string, string> {
const document = parseDocument(text, { prettyErrors: true, uniqueKeys: true })
if (document.errors.length > 0) {
throw new Error(`credentials-local: invalid document at ${filename}: ${
document.errors.map(error => error.message).join('; ')}`)
}
return false
const root: unknown = document.toJS() ?? {}
if (typeof root !== 'object' || root === null || Array.isArray(root)) {
throw new TypeError(`credentials-local: ${filename} must be a mapping of credential reference to value`)
}
const entries = new Map<string, string>()
for (const [key, value] of Object.entries(root as Record<string, unknown>)) {
// credentialRef throws on anything that is not a POSIX identifier, which
// is exactly the constraint a stored reference must satisfy to be
// addressable through the seam.
credentialRef(key)
if (typeof value !== 'string') {
throw new TypeError(`credentials-local: the value for "${key}" in ${filename} must be a string`)
}
if (value.length === 0) {
throw new Error(`credentials-local: the value for "${key}" in ${filename} is empty; remove the key instead`)
}
entries.set(key, value)
}
return entries
}
/**
* Render one `KEY=value` line in the narrowest style dotenv reads back
* verbatim: bare, then single quotes (fully literal), then double quotes
* (safe only without backslashes, which double-quote reading expands).
* A value no style can represent fails loud instead of corrupting silently.
* Render the next document text with one reference set or deleted. Editing
* the parsed document rather than rebuilding it keeps comments and the
* formatting of every untouched entry; an absent document starts a fresh one.
* @param text - the current document text, `undefined` while the file is absent.
* @param ref - the reference to write.
* @param value - the new value, or `undefined` to delete the key.
* @returns the text to persist.
*/
function renderLine(ref: CredentialRef, value: string): string {
if (BARE_VALUE.test(value)) return `${ref}=${value}`
if (hasControlCharacters(value)) {
throw new Error(`credentials-local: the value for "${ref}" contains control characters the .env line format cannot represent`)
}
if (!value.includes('\'')) return `${ref}='${value}'`
if (!value.includes('"') && !value.includes('\\')) return `${ref}="${value}"`
throw new Error(`credentials-local: the value for "${ref}" mixes quoting no .env style can represent; edit the file directly`)
function renderDocument(text: string | undefined, ref: CredentialRef, value: string | undefined): string {
// `text` only ever caches content that parsed successfully, so this re-parse
// for the mutable comment-preserving tree cannot fail.
const document = text === undefined ? new Document({}) : parseDocument(text)
if (value === undefined) document.deleteIn([ref])
else document.setIn([ref], value)
return document.toString()
}
/** Split text into physical lines with their terminators attached. */
function physicalLines(text: string): string[] {
return text.length === 0 ? [] : text.split(/(?<=\n)/)
}
/** One physical line's content without its terminator. */
function lineContent(line: string): string {
if (line.endsWith('\r\n')) return line.slice(0, -2)
if (line.endsWith('\n')) return line.slice(0, -1)
return line
}
/** One physical line's terminator (empty on a final unterminated line). */
function lineTerminator(line: string): string {
return line.slice(lineContent(line).length)
}
/** An assignment line: optional export, a POSIX identifier, `=`, the value part. */
const ASSIGNMENT = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=(.*)$/
/** Quote characters dotenv reads across physical lines. */
const MULTILINE_QUOTES = ['\'', '"', '`']
/**
* The quote character an assignment's value part opens without closing on its
* own line — the following physical lines are that value's continuation, not
* assignments — or `undefined` for a single-line value.
*/
function opensMultiline(valuePart: string): string | undefined {
const trimmed = valuePart.trimStart()
const quote = trimmed[0]
if (quote === undefined || !MULTILINE_QUOTES.includes(quote)) return undefined
const rest = trimmed.slice(1)
const body = quote === '"' ? rest.replaceAll('\\"', '') : rest
return body.includes(quote) ? undefined : quote
}
/** Whether a continuation line closes the given quote. */
function closesQuote(content: string, quote: string): boolean {
const body = quote === '"' ? content.replaceAll('\\"', '') : content
return body.includes(quote)
}
/**
* Replace, insert, or delete one reference's assignment while preserving
* every other byte: untouched lines keep their exact content and terminators
* (CRLF included), and the physical lines inside another key's quoted
* multi-line value are never mistaken for assignments. The first matching
* assignment is rewritten in place with its own line ending; later duplicates
* drop (dotenv reads the last one, so a surviving duplicate would override
* the edit); an insert appends in the document's dominant ending style.
*/
function upsertLine(text: string | undefined, ref: CredentialRef, rendered: string | undefined): string {
const lines = physicalLines(text ?? '')
const dominant = lines.some(line => line.endsWith('\r\n')) ? '\r\n' : '\n'
const out: string[] = []
let placed = false
let pendingQuote: string | undefined
for (const line of lines) {
const content = lineContent(line)
if (pendingQuote !== undefined) {
// Inside a quoted multi-line value: never an assignment, always kept.
if (closesQuote(content, pendingQuote)) pendingQuote = undefined
out.push(line)
continue
}
const match = ASSIGNMENT.exec(content)
if (match === null) {
out.push(line)
continue
}
const [, key, valuePart] = match
if (key !== ref) {
/* v8 ignore next -- the value group is `(.*)`, which always participates; the fallback only satisfies noUncheckedIndexedAccess */
pendingQuote = opensMultiline(valuePart ?? '')
out.push(line)
continue
}
// The write path refuses multi-line targets before rendering, so the
// matched assignment is single-line and drops or rewrites wholesale.
if (rendered !== undefined && !placed) {
out.push(`${rendered}${lineTerminator(line) === '' ? dominant : lineTerminator(line)}`)
placed = true
}
}
if (rendered !== undefined && !placed) {
const last = out[out.length - 1]
if (last !== undefined && lineTerminator(last) === '') {
out[out.length - 1] = `${last}${dominant}`
}
out.push(`${rendered}${dominant}`)
}
return out.join('')
}
/** File-backed credentials provider (`$DSH_HOME/.env`). */
/** File-backed credentials provider (`$DSH_HOME/.credentials.yaml`). */
export class CredentialsLocal extends Credentials {
/* jscpd:ignore-start -- deliberate config-surface and lifecycle symmetry with
settings-local (prefer symmetry for parallel values); extracting the shared
@@ -273,7 +217,7 @@ export class CredentialsLocal extends Credentials {
const env = process.env[ref]
if (env !== undefined && env.length > 0) return Promise.resolve({ value: env, source: 'env' })
const stored = this.values.get(ref)
if (stored !== undefined && stored.length > 0) return Promise.resolve({ value: stored, source: 'file' })
if (stored !== undefined) return Promise.resolve({ value: stored, source: 'file' })
return Promise.resolve(undefined)
}
@@ -283,11 +227,7 @@ export class CredentialsLocal extends Credentials {
return Promise.resolve({ configured: true, source: 'env', writable: false })
}
const stored = this.values.get(ref)
if (stored !== undefined && stored.length > 0) {
// A quoted multi-line value resolves fine but the line editor refuses to
// rewrite it, so writability must say what set() would actually do.
return Promise.resolve({ configured: true, source: 'file', writable: !stored.includes('\n') })
}
if (stored !== undefined) return Promise.resolve({ configured: true, source: 'file', writable: true })
return Promise.resolve({ configured: false, writable: true })
}
@@ -350,12 +290,7 @@ export class CredentialsLocal extends Credentials {
await this.reconcileFromDisk()
const existing = this.values.get(ref)
if (value === undefined && existing === undefined) return
if (existing !== undefined && existing.includes('\n')) {
throw new Error(
`credentials-local: "${ref}" is a multi-line entry this line editor would corrupt; edit ${this.spec.filename} directly`,
)
}
const nextText = upsertLine(this.text, ref, value === undefined ? undefined : renderLine(ref, value))
const nextText = renderDocument(this.text, ref, value)
// 0600: a document holding secrets is never world-readable.
await writeFileAtomic(this.spec.filename, nextText, { mode: 0o600, dirMode: 0o700 })
this.text = nextText
@@ -374,12 +309,16 @@ export class CredentialsLocal extends Credentials {
if (env !== undefined && env.length > 0) {
throw new Error(
`credentials-local: "${ref}" is supplied read-only by the process environment, so ${verb} would be`
+ ' shadowed; change the launching environment instead',
+ ' shadowed; unset it in the launching environment (or in a loaded .env) instead',
)
}
}
/** Boot read: an absent file is an empty store; any other failure is loud. */
/**
* Boot read: an absent file is an empty store; an invalid one fails the
* plugin's activation, because a credentials document that exists but
* cannot be trusted must never be treated as "no credentials stored".
*/
private async loadInitial(): Promise<void> {
let text: string
try {
@@ -388,8 +327,8 @@ export class CredentialsLocal extends Credentials {
if (!isENOENT(error)) throw error
return
}
this.values = parseCredentialsDocument(text, this.spec.filename)
this.text = text
this.values = new Map(Object.entries(parse(text)))
}
/* jscpd:ignore-start -- same deliberate mirror of settings-local's reload and
@@ -415,10 +354,10 @@ export class CredentialsLocal extends Credentials {
/**
* Compare the on-disk text against the cache and publish any difference
* into the seam. Absence publishes the empty store; an unreadable file
* throws, so each caller picks its policy — a reload warns and keeps the
* last good snapshot, a write fails loud. dotenv parsing is lenient by
* design and cannot fail.
* into the seam. Absence publishes the empty store; an unreadable or
* invalid document throws, so each caller picks its policy — a reload warns
* and keeps the last good snapshot, a write fails loud rather than
* overwriting a document it could not understand.
*/
private async reconcileFromDisk(): Promise<void> {
let text: string | undefined
@@ -429,7 +368,7 @@ export class CredentialsLocal extends Credentials {
text = undefined
}
if (text === this.text || this.isClosed()) return
const next = text === undefined ? new Map<string, string>() : new Map(Object.entries(parse(text)))
const next = text === undefined ? new Map<string, string>() : parseCredentialsDocument(text, this.spec.filename)
const changed = this.changedRefs(this.values, next)
this.text = text
this.values = next
@@ -437,21 +376,12 @@ export class CredentialsLocal extends Credentials {
}
/* jscpd:ignore-end */
/** Seam-addressable entries whose effective (non-empty) value changed. */
/** Entries whose stored value changed; the parser has already proven every key addressable. */
private changedRefs(prev: Map<string, string>, next: Map<string, string>): CredentialRef[] {
const changed: CredentialRef[] = []
for (const key of new Set([...prev.keys(), ...next.keys()])) {
const before = prev.get(key)
const after = next.get(key)
const effectiveBefore = before !== undefined && before.length > 0 ? before : undefined
const effectiveAfter = after !== undefined && after.length > 0 ? after : undefined
if (effectiveBefore === effectiveAfter) continue
try {
changed.push(credentialRef(key))
} catch (_unaddressableKey) {
// A key that is not a POSIX identifier is preserved file content the
// seam cannot address, so no observer could ever see it change.
}
if (prev.get(key) === next.get(key)) continue
changed.push(credentialRef(key))
}
return changed
}