| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496 |
- /**
- * 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)
- * > <invocation cwd>/.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; a store that doubled as the environment
- * layer would shadow non-secret entries behind its precedence, making them
- * silently unreachable.
- * @module @deepseek-ai/dsh-credentials-local
- */
- import { Context, Service } from '@deepseek-ai/cordis'
- import z from '@deepseek-ai/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 { canonicalizeWatchPath, 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 `<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), 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 path hierarchy is invalid or the file exists with group or other permission bits set.
- */
- async function assertOwnerOnly(filename: string): Promise<void> {
- let mode: number
- try {
- mode = (await stat(filename)).mode
- } catch (error) {
- if (!isENOENT(error)) throw error
- await canonicalizeWatchPath(filename)
- return
- }
- /* v8 ignore next -- POSIX coverage cannot take the Windows peer; native Windows coverage does. */
- if (process.platform === 'win32') return
- /* v8 ignore start -- Windows has no POSIX mode enforcement; POSIX behavior tests enforce this peer. */
- 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`,
- )
- /* v8 ignore stop */
- }
- /** 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<string, string> {
- // `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<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)
- // 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<Config> = 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<string, string>()
- /**
- * 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<void> = 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, 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(await canonicalizeWatchPath(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<ResolvedCredential | undefined> {
- 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<CredentialInfo> {
- // 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<void> {
- 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<void> {
- 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 a shared helper would couple their teardown
- semantics across packages for a handful of lines. */
- /** Queue one exclusive document operation behind every earlier one. */
- private enqueue<T>(operation: () => Promise<T>): Promise<T> {
- 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<void> {
- 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<void> {
- 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<void> {
- 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<void> {
- // 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<string, string>() : 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<string, string>, next: Map<string, string>): 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
|