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