/** * File-backed credentials provider over `$DSH_HOME/.credentials.yaml`, layered * against the environment by how much each layer is trusted: * * ```text * inherited process environment (read-only, wins) * > $DSH_HOME/.credentials.yaml (provider-managed, writable) * > /.env (read-only fallback) * > $DSH_HOME/.env (read-only fallback) * ``` * * The inherited environment wins because `DEEPSEEK_API_KEY=… dsh`, a CI * secret, or a container `-e` is this run's explicit intent; it cannot be * edited from inside, so it must be *visibly* read-only rather than silently * shadow writes. Everything below it loses to the managed store, so a key the * Models page writes takes effect immediately even when an older key sits in * the user's `.env`. * * The invoking project may supply a key, because the product trusts the * project it is launched in. It ranks below the managed store, so a key stored * through the Models page is never displaced by one a checkout happens to carry. * * 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 */ import { Context, Service } from 'cordis' import z from 'schemastery' import { watch as chokidarWatch } from 'chokidar' import { mkdir, readFile, stat } from 'node:fs/promises' import { dirname, join, resolve } from 'node:path' import { Document, parseDocument, type YAMLError } from 'yaml' import { withFileLock, writeFileAtomic } from '@deepseek-ai/dsh-atomic-write' import { resolveDshHome } from '@deepseek-ai/dsh-paths' import { environmentOf } from '@deepseek-ai/dsh-environment' import { Credentials, credentialRef } from '@deepseek-ai/dsh-credentials' import type { CredentialInfo, CredentialRef, ResolvedCredential } from '@deepseek-ai/dsh-credentials' import type { EnvironmentEntry } from '@deepseek-ai/dsh-environment' /** 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 `.credentials.yaml` under the harness home. */ path?: string /** Harness home used when `path` is omitted; defaults to `$DSH_HOME` or `~/.dsh`. */ dshHome?: string /** Watch the document and hot-publish external edits; defaults to true. */ watch?: boolean /** Watcher write-settle window in milliseconds; defaults to 100. */ debounceMs?: number } /** Fully resolved provider parameters; defaulting happens here, never inline. */ interface ResolvedSpec { filename: string watch: boolean debounceMs: number } /** * Resolve the runtime spec from plugin config: an explicit `path` wins, * otherwise the document lives at `/.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), CREDENTIALS_FILENAME)), watch: config.watch ?? true, debounceMs: config.debounceMs ?? 100, } } /** Permission bits outside the owner; a credentials document must have none of them. */ const GROUP_OTHER_BITS = 0o077 /** * Reject a credentials document other OS users can read, before its contents * are read at all. The provider creates and replaces the file at `0600`, but a * hand-written or externally generated one carries whatever umask produced it, * and silently serving secrets out of a world-readable file would make the * mode the provider promises meaningless. * * POSIX only: Windows has no mode to inspect — its ACLs are not expressible * here — so the check is skipped rather than faked, and the file's protection * there is whatever the create and replace APIs express. * @param filename - absolute path of the document. * @throws when the file exists with group or other permission bits set. */ async function assertOwnerOnly(filename: string): Promise { /* v8 ignore next -- native Windows coverage exercises the skip; POSIX covers the check */ if (process.platform === 'win32') return let mode: number try { mode = (await stat(filename)).mode } catch (error) { if (!isENOENT(error)) throw error return } const offending = mode & GROUP_OTHER_BITS if (offending === 0) return throw new Error( `credentials-local: ${filename} is readable beyond its owner (mode ${(mode & 0o777).toString(8)});` + ` run "chmod 600 ${filename}" before starting again`, ) } /** Whether a filesystem error means absence; every non-ENOENT failure must surface. */ function isENOENT(error: unknown): boolean { return (error as NodeJS.ErrnoException | null)?.code === 'ENOENT' } /** * Describe one YAML parse failure without quoting the source. The parser's own * message embeds the offending line, which here holds a secret. * @param error - the parser's error. * @returns the error code with its line and column. */ function describeYamlError(error: YAMLError): string { const at = error.linePos?.[0] /* v8 ignore next -- `prettyErrors` populates linePos on every error; the guard answers its optional type */ const where = at === undefined ? '' : ` at line ${String(at.line)}, column ${String(at.col)}` return `${error.code}${where}` } /** * 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 { // `prettyErrors` is on only for `linePos`; `error.message` is never used, // because the parser quotes the offending source line and in this document // that line is a secret. Only the code and position leave this function, and // the same rule governs every other diagnostic here — a key name is safe to // print, a value is not. 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(describeYamlError).join('; ')}`) } 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() for (const [key, value] of Object.entries(root as Record)) { // 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) // The key name is quoted, never the value: a wrong-typed entry is still a // secret the user meant to store. 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 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 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() } /** 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 shape would couple the two providers' teardown semantics across packages. */ static Config: z = z.object({ path: z.string(), dshHome: z.string(), watch: z.boolean().default(true), debounceMs: z.number().min(0).default(100), }) private readonly spec: ResolvedSpec /** * Raw text of the last read or persisted document; `undefined` while the * file is absent. Watcher events whose content equals this cache are no-ops, * which is also the self-write suppression. */ private text: string | undefined /** Parsed document snapshot; replaced wholesale on every reload. */ private values = new Map() /** * Single exclusive operation chain: watcher reloads and line edits run one * at a time in queue order (settled tail), so an edit can never render from * text a concurrent reload is busy replacing. */ private operations: Promise = Promise.resolve() /** Set at dispose: refuse new writes and let in-flight work no-op. */ private closed = false /** Opaque read of {@link closed}: control flow cannot narrow it across awaits. */ private isClosed(): boolean { return this.closed } /* jscpd:ignore-end */ constructor(ctx: Context, public config: Config) { super(ctx) // Programmatic construction may bypass Schemastery normalization; resolve // the same defaults in one explicit step either way. this.spec = resolveSpec(config) } /** The inherited-environment value for a reference, or `undefined` when empty or unset. */ private inherited(ref: CredentialRef): string | undefined { const entry = environmentOf(this.ctx).getFrom(ref, ['process']) return entry !== undefined && entry.value.length > 0 ? entry.value : undefined } /** * The `.env` fallback for a reference — below the managed store, never above * it. The invoking project ranks over the user's home file, matching the * environment layering: the more specific location wins. */ private dotenvFallback(ref: CredentialRef): EnvironmentEntry | undefined { const entry = environmentOf(this.ctx).getFrom(ref, ['project-env', 'user-env']) return entry !== undefined && entry.value.length > 0 ? entry : undefined } async* [Service.init](): AsyncGenerator<() => Promise | void, void, void> { yield async () => { // Drain: refuse new operations, then settle the queued ones so disposal // completes only once storage is quiescent. this.closed = true await this.operations } await this.loadInitial() if (!this.spec.watch) return /* jscpd:ignore-start -- same watcher discipline as settings-local by design: the serialized-refresh and quiesce-on-dispose shape is the reviewed lifecycle contract, not accidental repetition. */ const watcher = chokidarWatch(this.spec.filename, { ignoreInitial: true, awaitWriteFinish: { stabilityThreshold: this.spec.debounceMs, pollInterval: Math.max(1, Math.min(this.spec.debounceMs, 10)), }, }) watcher.on('all', () => { if (this.closed) return this.queueRefresh() }) watcher.on('ready', () => { // The initial load raced the watcher's own setup: a change written // between that read and the watcher becoming active never fires an // event. One reconcile at ready closes the gap. if (this.closed) return this.queueRefresh() }) watcher.on('error', (error) => { this.ctx.logger.warn('credentials-local: watcher error on %s', this.spec.filename) this.ctx.logger.warn(error) }) yield async () => { // Quiesce: stop accepting events, close the watcher, then wait out any // queued or in-flight operation so nothing publishes after disposal. this.closed = true await watcher.close() await this.operations } /* jscpd:ignore-end */ } override resolve(ref: CredentialRef): Promise { const inherited = this.inherited(ref) if (inherited !== undefined) return Promise.resolve({ value: inherited, source: 'env' }) const stored = this.values.get(ref) if (stored !== undefined) return Promise.resolve({ value: stored, source: 'file' }) const fallback = this.dotenvFallback(ref) if (fallback !== undefined) return Promise.resolve({ value: fallback.value, source: fallback.source }) return Promise.resolve(undefined) } override describe(ref: CredentialRef): Promise { // Only the inherited environment is unwritable: it is the one layer this // process cannot edit. A user `.env` value is writable in the sense that // matters — storing a key replaces it as the effective one. if (this.inherited(ref) !== undefined) { return Promise.resolve({ configured: true, source: 'env', writable: false }) } const stored = this.values.get(ref) if (stored !== undefined) return Promise.resolve({ configured: true, source: 'file', writable: true }) const fallback = this.dotenvFallback(ref) if (fallback !== undefined) return Promise.resolve({ configured: true, source: fallback.source, writable: true }) return Promise.resolve({ configured: false, writable: true }) } override async set(ref: CredentialRef, value: string): Promise { if (value.length === 0) { throw new Error(`credentials-local: an empty value cannot be stored for "${ref}"; use unset`) } await this.write(ref, value) } override async unset(ref: CredentialRef): Promise { await this.write(ref, undefined) } /* jscpd:ignore-start -- the operation-chain and reload lifecycle is the same reviewed contract as settings-local, deliberately mirrored (prefer symmetry for parallel values); the two providers own different documents and failure policies, so extracting the shape would couple their teardown semantics across packages for a handful of lines. */ /** Queue one exclusive document operation behind every earlier one. */ private enqueue(operation: () => Promise): Promise { const task = this.operations.then(operation) this.operations = task.then(() => undefined, () => undefined) return task } /** Queue a reload; only an invariant violation escaping the fan-out can reject it. */ private queueRefresh(): void { void this.enqueue(() => this.refresh()).catch((error: unknown) => { // Only an invariant violation escaping the update fan-out can reject a // refresh; keep the operation queue alive and surface it as an error so // one poisoned commit cannot silently end hot reloading forever. this.ctx.logger.error('credentials-local: reload commit failed at %s', this.spec.filename) this.ctx.logger.error(error) }) } /* jscpd:ignore-end */ /** Queue one line edit; entry checks reject early, the queue re-judges them at run time. */ private async write(ref: CredentialRef, value: string | undefined): Promise { const verb = value === undefined ? 'unset' : 'set' if (this.isClosed()) { throw new Error(`credentials-local is disposed: cannot ${verb} "${ref}"`) } this.assertUnshadowed(ref, verb) return this.enqueue(async () => { if (this.isClosed()) { throw new Error(`credentials-local was disposed before the queued "${ref}" ${verb} ran`) } // Re-judged at run time: the environment may have changed while queued. this.assertUnshadowed(ref, verb) // The writer lock's exclusive create needs the parent to exist; 0700 // because the harness home holds user-private data. await mkdir(dirname(this.spec.filename), { recursive: true, mode: 0o700 }) await withFileLock(this.spec.filename, async () => { // Read-modify-write: fold in any on-disk state this process has not // observed yet — an external edit still inside the watcher debounce // window, a change the watcher missed, or another process's write — // so the line edit below can never resurrect a stale document. await this.reconcileFromDisk() const existing = this.values.get(ref) if (value === undefined && existing === undefined) return 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 if (value === undefined) this.values.delete(ref) else this.values.set(ref, value) // After the commit: a broken observer must never make the durable // write look failed (an INVARIANT failure still rethrows). this.notifyUpdated(ref) }) }) } /** * Reject a write the inherited environment would shadow into apparent * no-effect. Only that layer can shadow a write: everything else this * provider resolves ranks below the document being written. */ private assertUnshadowed(ref: CredentialRef, verb: 'set' | 'unset'): void { if (this.inherited(ref) !== undefined) { throw new Error( `credentials-local: "${ref}" is supplied read-only by the launching environment, so ${verb} would be` + ' shadowed; unset it in the shell you start dsh from instead', ) } } /** * 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 { await assertOwnerOnly(this.spec.filename) let text: string try { text = await readFile(this.spec.filename, 'utf8') } catch (error) { if (!isENOENT(error)) throw error return } this.values = parseCredentialsDocument(text, this.spec.filename) this.text = text } /* jscpd:ignore-start -- same deliberate mirror of settings-local's reload and reconcile policy: warn-and-keep on a reload, throw on a write, invariant failures propagate. */ /** * Re-read the document after a watcher event. Unchanged content (including * this provider's own writes) is a no-op; an unreadable document keeps the * last good snapshot and warns — a live hot-reload must never take the * process down. An invariant violation escaping the fan-out is not a reload * failure and propagates to the queue's error surface. */ private async refresh(): Promise { if (this.closed) return try { await this.reconcileFromDisk() } catch (error) { if ((error as { code?: unknown } | null)?.code === 'INVARIANT') throw error this.ctx.logger.warn('credentials-local: reload failed at %s; keeping the last good document', this.spec.filename) this.ctx.logger.warn(error) } } /** * Compare the on-disk text against the cache and publish any difference * 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 { // Re-checked on every reload and before every write: an external editor or // a restored backup can loosen the mode after boot. await assertOwnerOnly(this.spec.filename) let text: string | undefined try { text = await readFile(this.spec.filename, 'utf8') } catch (error) { if (!isENOENT(error)) throw error text = undefined } if (text === this.text || this.isClosed()) return const next = text === undefined ? new Map() : parseCredentialsDocument(text, this.spec.filename) const changed = this.changedRefs(this.values, next) this.text = text this.values = next for (const ref of changed) this.notifyUpdated(ref) } /* jscpd:ignore-end */ /** Entries whose stored value changed; the parser has already proven every key addressable. */ private changedRefs(prev: Map, next: Map): CredentialRef[] { const changed: CredentialRef[] = [] for (const key of new Set([...prev.keys(), ...next.keys()])) { if (prev.get(key) === next.get(key)) continue changed.push(credentialRef(key)) } return changed } } export default CredentialsLocal