/** * 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. * @module @deepseek-ai/dsh-credentials-local */ import { Context, Service } from 'cordis' 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 { 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' /** Plugin config: file location and hot-reload behavior. */ export interface Config { /** Credentials document path; defaults to `.env` 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 `/.env`. * @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')), watch: config.watch ?? true, debounceMs: config.debounceMs ?? 100, } } /** 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' } /** 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 } return false } /** * 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. */ 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`) } /** 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`). */ 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) } 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 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' }) return Promise.resolve(undefined) } override describe(ref: CredentialRef): Promise { const env = process.env[ref] if (env !== undefined && env.length > 0) { 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') }) } 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 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)) // 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 live environment would shadow into apparent no-effect. */ private assertUnshadowed(ref: CredentialRef, verb: 'set' | 'unset'): void { const env = process.env[ref] 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', ) } } /** Boot read: an absent file is an empty store; any other failure is loud. */ private async loadInitial(): Promise { let text: string try { text = await readFile(this.spec.filename, 'utf8') } catch (error) { if (!isENOENT(error)) throw error return } this.text = text this.values = new Map(Object.entries(parse(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 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. */ private async reconcileFromDisk(): Promise { 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() : new Map(Object.entries(parse(text))) const changed = this.changedRefs(this.values, next) this.text = text this.values = next for (const ref of changed) this.notifyUpdated(ref) } /* jscpd:ignore-end */ /** Seam-addressable entries whose effective (non-empty) value changed. */ private changedRefs(prev: Map, next: Map): 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. } } return changed } } export default CredentialsLocal