index.ts 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496
  1. /**
  2. * File-backed credentials provider over `$DSH_HOME/.credentials.yaml`, layered
  3. * against the environment by how much each layer is trusted:
  4. *
  5. * ```text
  6. * inherited process environment (read-only, wins)
  7. * > $DSH_HOME/.credentials.yaml (provider-managed, writable)
  8. * > <invocation cwd>/.env (read-only fallback)
  9. * > $DSH_HOME/.env (read-only fallback)
  10. * ```
  11. *
  12. * The inherited environment wins because `DEEPSEEK_API_KEY=… dsh`, a CI
  13. * secret, or a container `-e` is this run's explicit intent; it cannot be
  14. * edited from inside, so it must be *visibly* read-only rather than silently
  15. * shadow writes. Everything below it loses to the managed store, so a key the
  16. * Models page writes takes effect immediately even when an older key sits in
  17. * the user's `.env`.
  18. *
  19. * The invoking project may supply a key, because the product trusts the
  20. * project it is launched in. It ranks below the managed store, so a key stored
  21. * through the Models page is never displaced by one a checkout happens to carry.
  22. *
  23. * The file is the provider-managed writable source: every write re-reads the
  24. * document under a cross-process writer lock before patching only its own key
  25. * — comments and the formatting of every untouched entry survive — external
  26. * edits hot-publish through the seam, and each reload replaces the snapshot
  27. * wholesale so a deleted entry never lingers in memory.
  28. *
  29. * The document holds nothing but credentials, which is why it is a strict
  30. * `CredentialRef`-to-string mapping rather than a dotenv file: a store the
  31. * Harness owns and never materializes into the environment cannot also serve
  32. * as the user's environment layer; a store that doubled as the environment
  33. * layer would shadow non-secret entries behind its precedence, making them
  34. * silently unreachable.
  35. * @module @deepseek-ai/dsh-credentials-local
  36. */
  37. import { Context, Service } from '@deepseek-ai/cordis'
  38. import z from '@deepseek-ai/schemastery'
  39. import { watch as chokidarWatch } from 'chokidar'
  40. import { mkdir, readFile, stat } from 'node:fs/promises'
  41. import { dirname, join, resolve } from 'node:path'
  42. import { Document, parseDocument, type YAMLError } from 'yaml'
  43. import { withFileLock, writeFileAtomic } from '@deepseek-ai/dsh-atomic-write'
  44. import { canonicalizeWatchPath, resolveDshHome } from '@deepseek-ai/dsh-paths'
  45. import { environmentOf } from '@deepseek-ai/dsh-environment'
  46. import { Credentials, credentialRef } from '@deepseek-ai/dsh-credentials'
  47. import type { CredentialInfo, CredentialRef, ResolvedCredential } from '@deepseek-ai/dsh-credentials'
  48. import type { EnvironmentEntry } from '@deepseek-ai/dsh-environment'
  49. /** Basename of the credentials document inside the harness home. */
  50. export const CREDENTIALS_FILENAME = '.credentials.yaml'
  51. /** Plugin config: file location and hot-reload behavior. */
  52. export interface Config {
  53. /** Credentials document path; defaults to `.credentials.yaml` under the harness home. */
  54. path?: string
  55. /** Harness home used when `path` is omitted; defaults to `$DSH_HOME` or `~/.dsh`. */
  56. dshHome?: string
  57. /** Watch the document and hot-publish external edits; defaults to true. */
  58. watch?: boolean
  59. /** Watcher write-settle window in milliseconds; defaults to 100. */
  60. debounceMs?: number
  61. }
  62. /** Fully resolved provider parameters; defaulting happens here, never inline. */
  63. interface ResolvedSpec {
  64. filename: string
  65. watch: boolean
  66. debounceMs: number
  67. }
  68. /**
  69. * Resolve the runtime spec from plugin config: an explicit `path` wins,
  70. * otherwise the document lives at `<harness home>/.credentials.yaml`.
  71. * @param config - raw plugin config.
  72. * @returns the resolved file location and watch behavior.
  73. */
  74. export function resolveSpec(config: Config): ResolvedSpec {
  75. return {
  76. filename: resolve(config.path ?? join(resolveDshHome(config.dshHome), CREDENTIALS_FILENAME)),
  77. watch: config.watch ?? true,
  78. debounceMs: config.debounceMs ?? 100,
  79. }
  80. }
  81. /** Permission bits outside the owner; a credentials document must have none of them. */
  82. const GROUP_OTHER_BITS = 0o077
  83. /**
  84. * Reject a credentials document other OS users can read, before its contents
  85. * are read at all. The provider creates and replaces the file at `0600`, but a
  86. * hand-written or externally generated one carries whatever umask produced it,
  87. * and silently serving secrets out of a world-readable file would make the
  88. * mode the provider promises meaningless.
  89. *
  90. * POSIX only: Windows has no mode to inspect — its ACLs are not expressible
  91. * here — so the check is skipped rather than faked, and the file's protection
  92. * there is whatever the create and replace APIs express.
  93. * @param filename - absolute path of the document.
  94. * @throws when the path hierarchy is invalid or the file exists with group or other permission bits set.
  95. */
  96. async function assertOwnerOnly(filename: string): Promise<void> {
  97. let mode: number
  98. try {
  99. mode = (await stat(filename)).mode
  100. } catch (error) {
  101. if (!isENOENT(error)) throw error
  102. await canonicalizeWatchPath(filename)
  103. return
  104. }
  105. /* v8 ignore next -- POSIX coverage cannot take the Windows peer; native Windows coverage does. */
  106. if (process.platform === 'win32') return
  107. /* v8 ignore start -- Windows has no POSIX mode enforcement; POSIX behavior tests enforce this peer. */
  108. const offending = mode & GROUP_OTHER_BITS
  109. if (offending === 0) return
  110. throw new Error(
  111. `credentials-local: ${filename} is readable beyond its owner (mode ${(mode & 0o777).toString(8)});`
  112. + ` run "chmod 600 ${filename}" before starting again`,
  113. )
  114. /* v8 ignore stop */
  115. }
  116. /** Whether a filesystem error means absence; every non-ENOENT failure must surface. */
  117. function isENOENT(error: unknown): boolean {
  118. return (error as NodeJS.ErrnoException | null)?.code === 'ENOENT'
  119. }
  120. /**
  121. * Describe one YAML parse failure without quoting the source. The parser's own
  122. * message embeds the offending line, which here holds a secret.
  123. * @param error - the parser's error.
  124. * @returns the error code with its line and column.
  125. */
  126. function describeYamlError(error: YAMLError): string {
  127. const at = error.linePos?.[0]
  128. /* v8 ignore next -- `prettyErrors` populates linePos on every error; the guard answers its optional type */
  129. const where = at === undefined ? '' : ` at line ${String(at.line)}, column ${String(at.col)}`
  130. return `${error.code}${where}`
  131. }
  132. /**
  133. * Parse one credentials document into its entries. The document is a strict
  134. * mapping of {@link CredentialRef} to non-empty string: a non-mapping root, a
  135. * key that is not a POSIX identifier, a non-string value, and an empty string
  136. * are all rejected rather than skipped, because this file holds nothing but
  137. * credentials and a silently ignored entry reads as "the key I stored has no
  138. * effect". Duplicate keys surface as parser errors. An empty document is an
  139. * empty store.
  140. * @param text - the document's text.
  141. * @param filename - absolute path, quoted in errors.
  142. * @returns the parsed entries, keyed by reference.
  143. */
  144. export function parseCredentialsDocument(text: string, filename: string): Map<string, string> {
  145. // `prettyErrors` is on only for `linePos`; `error.message` is never used,
  146. // because the parser quotes the offending source line and in this document
  147. // that line is a secret. Only the code and position leave this function, and
  148. // the same rule governs every other diagnostic here — a key name is safe to
  149. // print, a value is not.
  150. const document = parseDocument(text, { prettyErrors: true, uniqueKeys: true })
  151. if (document.errors.length > 0) {
  152. throw new Error(`credentials-local: invalid document at ${filename}: ${
  153. document.errors.map(describeYamlError).join('; ')}`)
  154. }
  155. const root: unknown = document.toJS() ?? {}
  156. if (typeof root !== 'object' || root === null || Array.isArray(root)) {
  157. throw new TypeError(`credentials-local: ${filename} must be a mapping of credential reference to value`)
  158. }
  159. const entries = new Map<string, string>()
  160. for (const [key, value] of Object.entries(root as Record<string, unknown>)) {
  161. // credentialRef throws on anything that is not a POSIX identifier, which
  162. // is exactly the constraint a stored reference must satisfy to be
  163. // addressable through the seam.
  164. credentialRef(key)
  165. // The key name is quoted, never the value: a wrong-typed entry is still a
  166. // secret the user meant to store.
  167. if (typeof value !== 'string') {
  168. throw new TypeError(`credentials-local: the value for "${key}" in ${filename} must be a string`)
  169. }
  170. if (value.length === 0) {
  171. throw new Error(`credentials-local: the value for "${key}" in ${filename} is empty; remove the key instead`)
  172. }
  173. entries.set(key, value)
  174. }
  175. return entries
  176. }
  177. /**
  178. * Render the next document text with one reference set or deleted. Editing
  179. * the parsed document rather than rebuilding it keeps comments and the
  180. * formatting of every untouched entry; an absent document starts a fresh one.
  181. * @param text - the current document text, `undefined` while the file is absent.
  182. * @param ref - the reference to write.
  183. * @param value - the new value, or `undefined` to delete the key.
  184. * @returns the text to persist.
  185. */
  186. function renderDocument(text: string | undefined, ref: CredentialRef, value: string | undefined): string {
  187. // `text` only ever caches content that parsed successfully, so this re-parse
  188. // for the mutable comment-preserving tree cannot fail.
  189. const document = text === undefined ? new Document({}) : parseDocument(text)
  190. if (value === undefined) document.deleteIn([ref])
  191. else document.setIn([ref], value)
  192. return document.toString()
  193. }
  194. /** File-backed credentials provider (`$DSH_HOME/.credentials.yaml`). */
  195. export class CredentialsLocal extends Credentials {
  196. /* jscpd:ignore-start -- deliberate config-surface and lifecycle symmetry with
  197. settings-local (prefer symmetry for parallel values); extracting the shared
  198. shape would couple the two providers' teardown semantics across packages. */
  199. static Config: z<Config> = z.object({
  200. path: z.string(),
  201. dshHome: z.string(),
  202. watch: z.boolean().default(true),
  203. debounceMs: z.number().min(0).default(100),
  204. })
  205. private readonly spec: ResolvedSpec
  206. /**
  207. * Raw text of the last read or persisted document; `undefined` while the
  208. * file is absent. Watcher events whose content equals this cache are no-ops,
  209. * which is also the self-write suppression.
  210. */
  211. private text: string | undefined
  212. /** Parsed document snapshot; replaced wholesale on every reload. */
  213. private values = new Map<string, string>()
  214. /**
  215. * Single exclusive operation chain: watcher reloads and line edits run one
  216. * at a time in queue order (settled tail), so an edit can never render from
  217. * text a concurrent reload is busy replacing.
  218. */
  219. private operations: Promise<void> = Promise.resolve()
  220. /** Set at dispose: refuse new writes and let in-flight work no-op. */
  221. private closed = false
  222. /** Opaque read of {@link closed}: control flow cannot narrow it across awaits. */
  223. private isClosed(): boolean {
  224. return this.closed
  225. }
  226. /* jscpd:ignore-end */
  227. constructor(ctx: Context, public config: Config) {
  228. super(ctx)
  229. // Programmatic construction may bypass Schemastery normalization; resolve
  230. // the same defaults in one explicit step either way.
  231. this.spec = resolveSpec(config)
  232. }
  233. /** The inherited-environment value for a reference, or `undefined` when empty or unset. */
  234. private inherited(ref: CredentialRef): string | undefined {
  235. const entry = environmentOf(this.ctx).getFrom(ref, ['process'])
  236. return entry !== undefined && entry.value.length > 0 ? entry.value : undefined
  237. }
  238. /**
  239. * The `.env` fallback for a reference — below the managed store, never above
  240. * it. The invoking project ranks over the user's home file, matching the
  241. * environment layering: the more specific location wins.
  242. */
  243. private dotenvFallback(ref: CredentialRef): EnvironmentEntry | undefined {
  244. const entry = environmentOf(this.ctx).getFrom(ref, ['project-env', 'user-env'])
  245. return entry !== undefined && entry.value.length > 0 ? entry : undefined
  246. }
  247. async* [Service.init](): AsyncGenerator<() => Promise<void> | void, void, void> {
  248. yield async () => {
  249. // Drain: refuse new operations, then settle the queued ones so disposal
  250. // completes only once storage is quiescent.
  251. this.closed = true
  252. await this.operations
  253. }
  254. await this.loadInitial()
  255. if (!this.spec.watch) return
  256. /* jscpd:ignore-start -- same watcher discipline as settings-local by design:
  257. the serialized-refresh and quiesce-on-dispose shape is the reviewed
  258. lifecycle contract, not accidental repetition. */
  259. const watcher = chokidarWatch(await canonicalizeWatchPath(this.spec.filename), {
  260. ignoreInitial: true,
  261. awaitWriteFinish: {
  262. stabilityThreshold: this.spec.debounceMs,
  263. pollInterval: Math.max(1, Math.min(this.spec.debounceMs, 10)),
  264. },
  265. })
  266. watcher.on('all', () => {
  267. if (this.closed) return
  268. this.queueRefresh()
  269. })
  270. watcher.on('ready', () => {
  271. // The initial load raced the watcher's own setup: a change written
  272. // between that read and the watcher becoming active never fires an
  273. // event. One reconcile at ready closes the gap.
  274. if (this.closed) return
  275. this.queueRefresh()
  276. })
  277. watcher.on('error', (error) => {
  278. this.ctx.logger.warn('credentials-local: watcher error on %s', this.spec.filename)
  279. this.ctx.logger.warn(error)
  280. })
  281. yield async () => {
  282. // Quiesce: stop accepting events, close the watcher, then wait out any
  283. // queued or in-flight operation so nothing publishes after disposal.
  284. this.closed = true
  285. await watcher.close()
  286. await this.operations
  287. }
  288. /* jscpd:ignore-end */
  289. }
  290. override resolve(ref: CredentialRef): Promise<ResolvedCredential | undefined> {
  291. const inherited = this.inherited(ref)
  292. if (inherited !== undefined) return Promise.resolve({ value: inherited, source: 'env' })
  293. const stored = this.values.get(ref)
  294. if (stored !== undefined) return Promise.resolve({ value: stored, source: 'file' })
  295. const fallback = this.dotenvFallback(ref)
  296. if (fallback !== undefined) return Promise.resolve({ value: fallback.value, source: fallback.source })
  297. return Promise.resolve(undefined)
  298. }
  299. override describe(ref: CredentialRef): Promise<CredentialInfo> {
  300. // Only the inherited environment is unwritable: it is the one layer this
  301. // process cannot edit. A user `.env` value is writable in the sense that
  302. // matters — storing a key replaces it as the effective one.
  303. if (this.inherited(ref) !== undefined) {
  304. return Promise.resolve({ configured: true, source: 'env', writable: false })
  305. }
  306. const stored = this.values.get(ref)
  307. if (stored !== undefined) return Promise.resolve({ configured: true, source: 'file', writable: true })
  308. const fallback = this.dotenvFallback(ref)
  309. if (fallback !== undefined) return Promise.resolve({ configured: true, source: fallback.source, writable: true })
  310. return Promise.resolve({ configured: false, writable: true })
  311. }
  312. override async set(ref: CredentialRef, value: string): Promise<void> {
  313. if (value.length === 0) {
  314. throw new Error(`credentials-local: an empty value cannot be stored for "${ref}"; use unset`)
  315. }
  316. await this.write(ref, value)
  317. }
  318. override async unset(ref: CredentialRef): Promise<void> {
  319. await this.write(ref, undefined)
  320. }
  321. /* jscpd:ignore-start -- the operation-chain and reload lifecycle is the same
  322. reviewed contract as settings-local, deliberately mirrored (prefer symmetry
  323. for parallel values); the two providers own different documents and
  324. failure policies, so extracting a shared helper would couple their teardown
  325. semantics across packages for a handful of lines. */
  326. /** Queue one exclusive document operation behind every earlier one. */
  327. private enqueue<T>(operation: () => Promise<T>): Promise<T> {
  328. const task = this.operations.then(operation)
  329. this.operations = task.then(() => undefined, () => undefined)
  330. return task
  331. }
  332. /** Queue a reload; only an invariant violation escaping the fan-out can reject it. */
  333. private queueRefresh(): void {
  334. void this.enqueue(() => this.refresh()).catch((error: unknown) => {
  335. // Only an invariant violation escaping the update fan-out can reject a
  336. // refresh; keep the operation queue alive and surface it as an error so
  337. // one poisoned commit cannot silently end hot reloading forever.
  338. this.ctx.logger.error('credentials-local: reload commit failed at %s', this.spec.filename)
  339. this.ctx.logger.error(error)
  340. })
  341. }
  342. /* jscpd:ignore-end */
  343. /** Queue one line edit; entry checks reject early, the queue re-judges them at run time. */
  344. private async write(ref: CredentialRef, value: string | undefined): Promise<void> {
  345. const verb = value === undefined ? 'unset' : 'set'
  346. if (this.isClosed()) {
  347. throw new Error(`credentials-local is disposed: cannot ${verb} "${ref}"`)
  348. }
  349. this.assertUnshadowed(ref, verb)
  350. return this.enqueue(async () => {
  351. if (this.isClosed()) {
  352. throw new Error(`credentials-local was disposed before the queued "${ref}" ${verb} ran`)
  353. }
  354. // Re-judged at run time: the environment may have changed while queued.
  355. this.assertUnshadowed(ref, verb)
  356. // The writer lock's exclusive create needs the parent to exist; 0700
  357. // because the harness home holds user-private data.
  358. await mkdir(dirname(this.spec.filename), { recursive: true, mode: 0o700 })
  359. await withFileLock(this.spec.filename, async () => {
  360. // Read-modify-write: fold in any on-disk state this process has not
  361. // observed yet — an external edit still inside the watcher debounce
  362. // window, a change the watcher missed, or another process's write —
  363. // so the line edit below can never resurrect a stale document.
  364. await this.reconcileFromDisk()
  365. const existing = this.values.get(ref)
  366. if (value === undefined && existing === undefined) return
  367. const nextText = renderDocument(this.text, ref, value)
  368. // 0600: a document holding secrets is never world-readable.
  369. await writeFileAtomic(this.spec.filename, nextText, { mode: 0o600, dirMode: 0o700 })
  370. this.text = nextText
  371. if (value === undefined) this.values.delete(ref)
  372. else this.values.set(ref, value)
  373. // After the commit: a broken observer must never make the durable
  374. // write look failed (an INVARIANT failure still rethrows).
  375. this.notifyUpdated(ref)
  376. })
  377. })
  378. }
  379. /**
  380. * Reject a write the inherited environment would shadow into apparent
  381. * no-effect. Only that layer can shadow a write: everything else this
  382. * provider resolves ranks below the document being written.
  383. */
  384. private assertUnshadowed(ref: CredentialRef, verb: 'set' | 'unset'): void {
  385. if (this.inherited(ref) !== undefined) {
  386. throw new Error(
  387. `credentials-local: "${ref}" is supplied read-only by the launching environment, so ${verb} would be`
  388. + ' shadowed; unset it in the shell you start dsh from instead',
  389. )
  390. }
  391. }
  392. /**
  393. * Boot read: an absent file is an empty store; an invalid one fails the
  394. * plugin's activation, because a credentials document that exists but
  395. * cannot be trusted must never be treated as "no credentials stored".
  396. */
  397. private async loadInitial(): Promise<void> {
  398. await assertOwnerOnly(this.spec.filename)
  399. let text: string
  400. try {
  401. text = await readFile(this.spec.filename, 'utf8')
  402. } catch (error) {
  403. if (!isENOENT(error)) throw error
  404. return
  405. }
  406. this.values = parseCredentialsDocument(text, this.spec.filename)
  407. this.text = text
  408. }
  409. /* jscpd:ignore-start -- same deliberate mirror of settings-local's reload and
  410. reconcile policy: warn-and-keep on a reload, throw on a write, invariant
  411. failures propagate. */
  412. /**
  413. * Re-read the document after a watcher event. Unchanged content (including
  414. * this provider's own writes) is a no-op; an unreadable document keeps the
  415. * last good snapshot and warns — a live hot-reload must never take the
  416. * process down. An invariant violation escaping the fan-out is not a reload
  417. * failure and propagates to the queue's error surface.
  418. */
  419. private async refresh(): Promise<void> {
  420. if (this.closed) return
  421. try {
  422. await this.reconcileFromDisk()
  423. } catch (error) {
  424. if ((error as { code?: unknown } | null)?.code === 'INVARIANT') throw error
  425. this.ctx.logger.warn('credentials-local: reload failed at %s; keeping the last good document', this.spec.filename)
  426. this.ctx.logger.warn(error)
  427. }
  428. }
  429. /**
  430. * Compare the on-disk text against the cache and publish any difference
  431. * into the seam. Absence publishes the empty store; an unreadable or
  432. * invalid document throws, so each caller picks its policy — a reload warns
  433. * and keeps the last good snapshot, a write fails loud rather than
  434. * overwriting a document it could not understand.
  435. */
  436. private async reconcileFromDisk(): Promise<void> {
  437. // Re-checked on every reload and before every write: an external editor or
  438. // a restored backup can loosen the mode after boot.
  439. await assertOwnerOnly(this.spec.filename)
  440. let text: string | undefined
  441. try {
  442. text = await readFile(this.spec.filename, 'utf8')
  443. } catch (error) {
  444. if (!isENOENT(error)) throw error
  445. text = undefined
  446. }
  447. if (text === this.text || this.isClosed()) return
  448. const next = text === undefined ? new Map<string, string>() : parseCredentialsDocument(text, this.spec.filename)
  449. const changed = this.changedRefs(this.values, next)
  450. this.text = text
  451. this.values = next
  452. for (const ref of changed) this.notifyUpdated(ref)
  453. }
  454. /* jscpd:ignore-end */
  455. /** Entries whose stored value changed; the parser has already proven every key addressable. */
  456. private changedRefs(prev: Map<string, string>, next: Map<string, string>): CredentialRef[] {
  457. const changed: CredentialRef[] = []
  458. for (const key of new Set([...prev.keys(), ...next.keys()])) {
  459. if (prev.get(key) === next.get(key)) continue
  460. changed.push(credentialRef(key))
  461. }
  462. return changed
  463. }
  464. }
  465. export default CredentialsLocal