index.ts 43 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935
  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, isMap, isScalar, parseDocument, type YAMLError } from 'yaml'
  43. import { withFileLock, writeFileAtomic } from '@deepseek-ai/dsh-atomic-write'
  44. import { canonicalizeWatchPath, resolveDshHome } from '@deepseek-ai/dsh-home-paths'
  45. import { launchEnvironmentOf } from '@deepseek-ai/dsh-launch-environment'
  46. import { CredentialProvider, credentialRef, parseCredentialKey } from '@deepseek-ai/dsh-credentials'
  47. import type {
  48. ApiKeyRecord,
  49. CredentialInfo,
  50. CredentialKey,
  51. CredentialRecord,
  52. CredentialRecordEntry,
  53. CredentialRecordInfo,
  54. CredentialRef,
  55. ResolvedCredential,
  56. } from '@deepseek-ai/dsh-credentials'
  57. import type { LaunchEnvironmentEntry } from '@deepseek-ai/dsh-launch-environment'
  58. /** Basename of the credentials document inside the harness home. */
  59. export const CREDENTIALS_FILENAME = '.credentials.yaml'
  60. /** Plugin config: file location and hot-reload behavior. */
  61. export interface Config {
  62. /** Credentials document path; defaults to `.credentials.yaml` under the harness home. */
  63. path?: string
  64. /** Harness home used when `path` is omitted; defaults to `$DSH_HOME` or `~/.dsh`. */
  65. dshHome?: string
  66. /** Watch the document and hot-publish external edits; defaults to true. */
  67. watch?: boolean
  68. /** Watcher write-settle window in milliseconds; defaults to 100. */
  69. debounceMs?: number
  70. }
  71. /** Fully resolved provider parameters; defaulting happens here, never inline. */
  72. interface ResolvedSpec {
  73. filename: string
  74. watch: boolean
  75. debounceMs: number
  76. }
  77. /**
  78. * Resolve the runtime spec from plugin config: an explicit `path` wins,
  79. * otherwise the document lives at `<harness home>/.credentials.yaml`.
  80. * @param config - raw plugin config.
  81. * @returns the resolved file location and watch behavior.
  82. */
  83. export function resolveSpec(config: Config): ResolvedSpec {
  84. return {
  85. filename: resolve(config.path ?? join(resolveDshHome(config.dshHome), CREDENTIALS_FILENAME)),
  86. watch: config.watch ?? true,
  87. debounceMs: config.debounceMs ?? 100,
  88. }
  89. }
  90. /** Permission bits outside the owner; a credentials document must have none of them. */
  91. const GROUP_OTHER_BITS = 0o077
  92. /**
  93. * How long a record write waits for the cross-process writer lock. A record
  94. * mutation runs its caller's decision while holding the lock, and for the
  95. * operation this half exists to serve — an owner refreshing an expired token —
  96. * that decision includes a network round trip. The file-work default would
  97. * fail every other writer of this document for its duration. A contender's
  98. * wait is sized by the longest holder it can meet, and refs and records share
  99. * one file and one lock, so every writer of this document — reference writes
  100. * and record deletes included — waits this long, not only the mutation that
  101. * holds it. Like the retry cadence in `dsh-atomic-write`, this is a
  102. * robustness bound of the write protocol rather than a deployment choice: it
  103. * is sized by what a provider request costs, which no deployment varies.
  104. */
  105. const DOCUMENT_LOCK_WAIT_MS = 30_000
  106. /**
  107. * Reject a credentials document other OS users can read, before its contents
  108. * are read at all. The provider creates and replaces the file at `0600`, but a
  109. * hand-written or externally generated one carries whatever umask produced it,
  110. * and silently serving secrets out of a world-readable file would make the
  111. * mode the provider promises meaningless.
  112. *
  113. * POSIX only: Windows has no mode to inspect — its ACLs are not expressible
  114. * here — so the check is skipped rather than faked, and the file's protection
  115. * there is whatever the create and replace APIs express.
  116. * @param filename - absolute path of the document.
  117. * @throws when the path hierarchy is invalid or the file exists with group or other permission bits set.
  118. */
  119. async function assertOwnerOnly(filename: string): Promise<void> {
  120. let mode: number
  121. try {
  122. mode = (await stat(filename)).mode
  123. } catch (error) {
  124. if (!isENOENT(error)) throw error
  125. await canonicalizeWatchPath(filename)
  126. return
  127. }
  128. /* v8 ignore next -- POSIX coverage cannot take the Windows peer; native Windows coverage does. */
  129. if (process.platform === 'win32') return
  130. /* v8 ignore start -- Windows has no POSIX mode enforcement; POSIX behavior tests enforce this peer. */
  131. const offending = mode & GROUP_OTHER_BITS
  132. if (offending === 0) return
  133. throw new Error(
  134. `credentials-local: ${filename} is readable beyond its owner (mode ${(mode & 0o777).toString(8)});`
  135. + ` run "chmod 600 ${filename}" before starting again`,
  136. )
  137. /* v8 ignore stop */
  138. }
  139. /** Whether a filesystem error means absence; every non-ENOENT failure must surface. */
  140. function isENOENT(error: unknown): boolean {
  141. return (error as NodeJS.ErrnoException | null)?.code === 'ENOENT'
  142. }
  143. /**
  144. * Describe one YAML parse failure without quoting the source. The parser's own
  145. * message embeds the offending line, which here holds a secret.
  146. * @param error - the parser's error.
  147. * @returns the error code with its line and column.
  148. */
  149. function describeYamlError(error: YAMLError): string {
  150. const at = error.linePos?.[0]
  151. /* v8 ignore next -- `prettyErrors` populates linePos on every error; the guard answers its optional type */
  152. const where = at === undefined ? '' : ` at line ${String(at.line)}, column ${String(at.col)}`
  153. return `${error.code}${where}`
  154. }
  155. /** The document layout this build reads and writes. */
  156. export const DOCUMENT_VERSION = 1
  157. /** One parsed credentials document: the two key spaces it stores, keyed as written. */
  158. export interface CredentialsDocument {
  159. /** Reference entries, keyed by {@link CredentialRef}. */
  160. refs: Map<string, string>
  161. /** Stored records, keyed by {@link CredentialKey}. */
  162. records: Map<string, CredentialRecord>
  163. }
  164. /**
  165. * Parse one credentials document. Everything is rejected rather than skipped —
  166. * an unversioned root, an unknown top-level key, a key that is not addressable,
  167. * a wrong-typed value, an unknown record tag or field — because this file holds
  168. * nothing but credentials and a silently ignored entry reads as "the credential
  169. * I stored has no effect". Duplicate keys surface as parser errors. An empty
  170. * document is an empty store and needs no version.
  171. * @param text - the document's text.
  172. * @param filename - absolute path, quoted in errors.
  173. * @returns the parsed references and records.
  174. */
  175. export function parseCredentialsDocument(text: string, filename: string): CredentialsDocument {
  176. // `prettyErrors` is on only for `linePos`; `error.message` is never used,
  177. // because the parser quotes the offending source line and in this document
  178. // that line is a secret. Only the code and position leave this function, and
  179. // the same rule governs every other diagnostic here — a key name is safe to
  180. // print, a value is not.
  181. const document = parseDocument(text, { prettyErrors: true, uniqueKeys: true })
  182. if (document.errors.length > 0) {
  183. throw new Error(`credentials-local: invalid document at ${filename}: ${
  184. document.errors.map(describeYamlError).join('; ')}`)
  185. }
  186. const root: unknown = document.toJS() ?? {}
  187. if (typeof root !== 'object' || root === null || Array.isArray(root)) {
  188. throw new TypeError(`credentials-local: ${filename} must be a mapping`)
  189. }
  190. const fields = root as Record<string, unknown>
  191. const keys = Object.keys(fields)
  192. // An empty (or comment-only) document is the empty store and needs no
  193. // version: there is nothing in it a later layout could have meant.
  194. if (keys.length === 0) return { refs: new Map(), records: new Map() }
  195. if (!('version' in fields)) {
  196. throw new Error(
  197. `credentials-local: ${filename} uses the pre-release flat layout. Add \`version: ${DOCUMENT_VERSION}\``
  198. + ` and nest the existing ${keys.length} ${keys.length === 1 ? 'entry' : 'entries'} under \`refs:\`.`
  199. + ' No values need to change.',
  200. )
  201. }
  202. if (fields['version'] !== DOCUMENT_VERSION) {
  203. throw new Error(
  204. `credentials-local: ${filename} declares version ${JSON.stringify(fields['version'])};`
  205. + ` this build reads version ${DOCUMENT_VERSION}`,
  206. )
  207. }
  208. for (const key of keys) {
  209. if (key !== 'version' && key !== 'refs' && key !== 'records') {
  210. throw new Error(`credentials-local: unknown top-level key "${key}" in ${filename}`)
  211. }
  212. }
  213. return { refs: parseRefs(fields['refs'], filename), records: parseRecords(fields['records'], filename) }
  214. }
  215. /**
  216. * Render the version-1 layout for a pre-release flat document, or `undefined`
  217. * for anything else. The flat layout is recognized exactly — a non-empty
  218. * top-level mapping of addressable reference names to non-empty string
  219. * scalars, with no `version` key and no document directives — and the rewrite
  220. * nests the original lines verbatim under `refs:` at two spaces' indent, so
  221. * comments, blank lines, and each value's spelling survive byte for byte.
  222. * Anything the recognizer declines keeps {@link parseCredentialsDocument}'s
  223. * loud rejection: a document this build cannot prove it understands is never
  224. * rewritten. Remove with the pre-release stance at the first tagged release.
  225. * @param text - the document's text.
  226. * @returns the migrated text, or `undefined` when the text is not the recognized flat layout.
  227. */
  228. export function renderFlatLayoutMigration(text: string): string | undefined {
  229. const document = parseDocument(text, { prettyErrors: true, uniqueKeys: true })
  230. if (document.errors.length > 0) return undefined
  231. const flat = document.contents
  232. if (!isMap(flat) || flat.items.length === 0) return undefined
  233. for (const line of text.split('\n')) {
  234. // A directive or document marker would not survive being indented into
  235. // the `refs:` block; no shipped writer ever emitted one here.
  236. if (/^(%|---|\.\.\.)/.test(line)) return undefined
  237. }
  238. for (const pair of flat.items) {
  239. if (!isScalar(pair.key) || typeof pair.key.value !== 'string' || pair.key.value === 'version') return undefined
  240. try {
  241. credentialRef(pair.key.value)
  242. } catch {
  243. // Only credentialRef's rejection of a non-POSIX name lands here; the
  244. // flat reader refused such a key too, so this is not the recognized
  245. // layout and the loud rejection stands.
  246. return undefined
  247. }
  248. if (!isScalar(pair.value) || typeof pair.value.value !== 'string' || pair.value.value.length === 0) return undefined
  249. }
  250. const body = text.split('\n').map(line => (line.length === 0 ? line : ` ${line}`)).join('\n')
  251. return `version: ${DOCUMENT_VERSION}\nrefs:\n${body}${text.endsWith('\n') ? '' : '\n'}`
  252. }
  253. /** Admit a `refs` section: POSIX-identifier keys over non-empty string values. */
  254. function parseRefs(section: unknown, filename: string): Map<string, string> {
  255. const entries = new Map<string, string>()
  256. for (const [key, value] of Object.entries(asSection(section, 'refs', filename))) {
  257. // credentialRef throws on anything that is not a POSIX identifier, which
  258. // is exactly the constraint a stored reference must satisfy to be
  259. // addressable through the seam.
  260. credentialRef(key)
  261. // The key name is quoted, never the value: a wrong-typed entry is still a
  262. // secret the user meant to store.
  263. if (typeof value !== 'string') {
  264. throw new TypeError(`credentials-local: the value for "${key}" in ${filename} must be a string`)
  265. }
  266. if (value.length === 0) {
  267. throw new Error(`credentials-local: the value for "${key}" in ${filename} is empty; remove the key instead`)
  268. }
  269. entries.set(key, value)
  270. }
  271. return entries
  272. }
  273. /** Admit a `records` section: `<scope>/<id>` keys over tagged record mappings. */
  274. function parseRecords(section: unknown, filename: string): Map<string, CredentialRecord> {
  275. const entries = new Map<string, CredentialRecord>()
  276. for (const [key, value] of Object.entries(asSection(section, 'records', filename))) {
  277. parseCredentialKey(key)
  278. entries.set(key, parseRecord(key, value, filename))
  279. }
  280. return entries
  281. }
  282. /**
  283. * Refuse an api-key record the read path could not admit, before it is
  284. * rendered: an empty key, an env name outside the reference grammar, or an
  285. * empty env value would persist a document `parseRecord` rejects at the next
  286. * boot — a durable-boundary write is validated where it is written.
  287. * @param key - the record's credential key, for the failure message.
  288. * @param record - the api-key record a mutation returned.
  289. */
  290. function assertStorableApiKey(key: CredentialKey, record: ApiKeyRecord): void {
  291. if (record.key !== undefined && record.key.length === 0) {
  292. throw new TypeError(`credentials-local: record "${key}" has an empty key; omit the field instead`)
  293. }
  294. for (const [name, value] of Object.entries(record.env ?? {})) {
  295. credentialRef(name)
  296. if (value.length === 0) {
  297. throw new TypeError(`credentials-local: record "${key}" env "${name}" must be a non-empty string`)
  298. }
  299. }
  300. }
  301. /** One section of the document as a plain mapping; absent and null both mean empty. */
  302. function asSection(section: unknown, name: string, filename: string): Record<string, unknown> {
  303. if (section === undefined || section === null) return {}
  304. if (typeof section !== 'object' || Array.isArray(section)) {
  305. throw new TypeError(`credentials-local: "${name}" in ${filename} must be a mapping`)
  306. }
  307. return section as Record<string, unknown>
  308. }
  309. /** Admit one record entry, rejecting an unknown tag or field rather than dropping it. */
  310. function parseRecord(key: string, value: unknown, filename: string): CredentialRecord {
  311. if (typeof value !== 'object' || value === null || Array.isArray(value)) {
  312. throw new TypeError(`credentials-local: record "${key}" in ${filename} must be a mapping`)
  313. }
  314. const fields = value as Record<string, unknown>
  315. const kind = fields['kind']
  316. if (kind === 'api-key') {
  317. assertFields(key, fields, ['kind', 'key', 'env'], filename)
  318. const apiKey = fields['key']
  319. if (apiKey !== undefined && (typeof apiKey !== 'string' || apiKey.length === 0)) {
  320. throw new TypeError(`credentials-local: record "${key}" in ${filename} has a non-string or empty key`)
  321. }
  322. const env = parseRecordEnv(key, fields['env'], filename)
  323. return {
  324. kind: 'api-key',
  325. ...apiKey === undefined ? {} : { key: apiKey },
  326. ...env === undefined ? {} : { env },
  327. }
  328. }
  329. if (kind === 'grant') {
  330. assertFields(key, fields, ['kind', 'payload'], filename)
  331. if (!('payload' in fields)) {
  332. throw new Error(`credentials-local: record "${key}" in ${filename} has no payload`)
  333. }
  334. assertJsonValue(`record "${key}" payload in ${filename}`, fields['payload'], new Set())
  335. return { kind: 'grant', payload: fields['payload'] }
  336. }
  337. if (kind === undefined) throw new Error(`credentials-local: record "${key}" in ${filename} has no kind`)
  338. throw new Error(`credentials-local: record "${key}" in ${filename} has unknown kind ${JSON.stringify(kind)}`)
  339. }
  340. /** Reject a field the tag does not define, so a typo is not silently dropped. */
  341. function assertFields(key: string, fields: Record<string, unknown>, allowed: string[], filename: string): void {
  342. for (const field of Object.keys(fields)) {
  343. if (!allowed.includes(field)) {
  344. throw new Error(`credentials-local: record "${key}" in ${filename} has unknown field "${field}"`)
  345. }
  346. }
  347. }
  348. /** Admit an api-key record's provider environment: POSIX names over non-empty strings. */
  349. function parseRecordEnv(key: string, env: unknown, filename: string): Record<string, string> | undefined {
  350. if (env === undefined) return undefined
  351. if (typeof env !== 'object' || env === null || Array.isArray(env)) {
  352. throw new TypeError(`credentials-local: record "${key}" in ${filename} has a non-mapping env`)
  353. }
  354. const parsed: Record<string, string> = {}
  355. for (const [name, value] of Object.entries(env as Record<string, unknown>)) {
  356. credentialRef(name)
  357. if (typeof value !== 'string' || value.length === 0) {
  358. throw new TypeError(
  359. `credentials-local: record "${key}" env "${name}" in ${filename} must be a non-empty string`,
  360. )
  361. }
  362. parsed[name] = value
  363. }
  364. return parsed
  365. }
  366. /**
  367. * Reject a payload that cannot survive a JSON round trip, on the way in and on
  368. * the way out. The seam promises owners their payload comes back exactly as
  369. * written, and both directions can break that: a document may spell `.inf` or
  370. * an alias cycle, and an owner may hand over a `Date`, a class instance, or a
  371. * `bigint` that this document has no faithful spelling for. Neither the value
  372. * nor any nested value is quoted in a diagnostic.
  373. * @param where - the subject named in a diagnostic, already free of any value.
  374. * @param value - the payload or nested value to admit.
  375. * @param seen - objects on the current path, for cycle detection.
  376. * @throws TypeError naming `where` when the value cannot round-trip.
  377. */
  378. function assertJsonValue(where: string, value: unknown, seen: Set<object>): void {
  379. if (value === null || typeof value === 'string' || typeof value === 'boolean') return
  380. if (typeof value === 'number') {
  381. if (Number.isFinite(value)) return
  382. throw new TypeError(`credentials-local: ${where} holds a non-finite number`)
  383. }
  384. if (typeof value === 'object') {
  385. if (seen.has(value)) throw new TypeError(`credentials-local: ${where} is cyclic`)
  386. if (Object.getPrototypeOf(value) === Object.prototype || Array.isArray(value)) {
  387. seen.add(value)
  388. for (const nested of Object.values(value)) assertJsonValue(where, nested, seen)
  389. seen.delete(value)
  390. return
  391. }
  392. }
  393. throw new TypeError(`credentials-local: ${where} holds a value JSON cannot represent`)
  394. }
  395. /**
  396. * The comment-preserving mutable tree one edit renders from. Editing the
  397. * parsed document rather than rebuilding it keeps comments and the formatting
  398. * of every untouched entry; an absent document starts a fresh one.
  399. * @param text - the current document text, `undefined` while the file is absent.
  400. * @returns the tree to edit, carrying this build's version stamp.
  401. */
  402. function mutableDocument(text: string | undefined): Document {
  403. // `text` only ever caches content that parsed successfully, so this re-parse
  404. // for the mutable comment-preserving tree cannot fail.
  405. const document = text === undefined ? new Document({}) : parseDocument(text)
  406. // Stamped on every edit so a document this provider creates is readable by
  407. // the same parser that admitted the one it edits; an existing stamp is
  408. // rewritten to the identical value.
  409. document.setIn(['version'], DOCUMENT_VERSION)
  410. return document
  411. }
  412. /**
  413. * Render the next document text with one reference set or deleted.
  414. * @param text - the current document text, `undefined` while the file is absent.
  415. * @param ref - the reference to write.
  416. * @param value - the new value, or `undefined` to delete the key.
  417. * @returns the text to persist.
  418. */
  419. function renderRef(text: string | undefined, ref: CredentialRef, value: string | undefined): string {
  420. const document = mutableDocument(text)
  421. if (value === undefined) deleteSectionEntry(document, 'refs', ref)
  422. else document.setIn(['refs', ref], value)
  423. return document.toString()
  424. }
  425. /**
  426. * Render the next document text with one record written or deleted. The record
  427. * node is replaced wholesale rather than edited field by field: records are
  428. * machine-written, so there is no hand formatting inside one to preserve.
  429. * @param text - the current document text, `undefined` while the file is absent.
  430. * @param key - the record to write.
  431. * @param record - the new record, or `undefined` to delete it.
  432. * @returns the text to persist.
  433. */
  434. function renderRecord(text: string | undefined, key: CredentialKey, record: CredentialRecord | undefined): string {
  435. const document = mutableDocument(text)
  436. if (record === undefined) deleteSectionEntry(document, 'records', key)
  437. else document.setIn(['records', key], record)
  438. return document.toString()
  439. }
  440. /**
  441. * Remove one entry from a section, taking its annotation with it. A comment
  442. * block written above a section's first entry annotates that entry, but the
  443. * parser attaches it to the section's map rather than to the pair — leaving it
  444. * behind would move it onto whichever entry became first, which reads as an
  445. * annotation of a credential nobody wrote it for.
  446. * @param document - the mutable tree being edited.
  447. * @param section - the section holding the entry.
  448. * @param key - the entry to remove.
  449. */
  450. function deleteSectionEntry(document: Document, section: 'refs' | 'records', key: string): void {
  451. const map: unknown = document.get(section, true)
  452. /* v8 ignore next -- both callers render a delete only for an entry they just
  453. found in the parsed snapshot, so the section it lives in is always a map;
  454. the guard is what narrows `get`'s `unknown`. */
  455. if (isMap(map)) {
  456. const first = map.items[0]
  457. /* v8 ignore next -- a map that holds the entry has a first item, and the
  458. parser admits only scalar keys, so only the identity test can be false. */
  459. if (first !== undefined && isScalar(first.key) && first.key.value === key) {
  460. map.commentBefore = null
  461. }
  462. }
  463. document.deleteIn([section, key])
  464. }
  465. /**
  466. * Structural equality over two admitted JSON values. Records reach this after
  467. * {@link assertJsonValue}, so the walk meets only JSON shapes; key order is
  468. * ignored because an external editor may reorder a record's fields without
  469. * changing what it stores.
  470. * @param left - one value.
  471. * @param right - the other value.
  472. * @returns whether the two carry the same JSON content.
  473. */
  474. function sameJsonValue(left: unknown, right: unknown): boolean {
  475. if (left === right) return true
  476. if (typeof left !== 'object' || typeof right !== 'object' || left === null || right === null) return false
  477. if (Array.isArray(left) !== Array.isArray(right)) return false
  478. const leftKeys = Object.keys(left)
  479. const rightKeys = Object.keys(right)
  480. if (leftKeys.length !== rightKeys.length) return false
  481. return leftKeys.every(key => key in right
  482. && sameJsonValue((left as Record<string, unknown>)[key], (right as Record<string, unknown>)[key]))
  483. }
  484. /** File-backed credentials provider (`$DSH_HOME/.credentials.yaml`). */
  485. export class LocalCredentialProvider extends CredentialProvider {
  486. /* jscpd:ignore-start -- deliberate config-surface and lifecycle symmetry with
  487. settings-file (prefer symmetry for parallel values); extracting the shared
  488. shape would couple the two providers' teardown semantics across packages. */
  489. static Config: z<Config> = z.object({
  490. path: z.string(),
  491. dshHome: z.string(),
  492. watch: z.boolean().default(true),
  493. debounceMs: z.number().min(0).default(100),
  494. })
  495. private readonly spec: ResolvedSpec
  496. /**
  497. * Raw text of the last read or persisted document; `undefined` while the
  498. * file is absent. Watcher events whose content equals this cache are no-ops,
  499. * which is also the self-write suppression.
  500. */
  501. private text: string | undefined
  502. /** Parsed reference snapshot; replaced wholesale on every reload. */
  503. private values = new Map<string, string>()
  504. /** Parsed record snapshot; replaced wholesale on every reload. */
  505. private records = new Map<string, CredentialRecord>()
  506. /**
  507. * Single exclusive operation chain: watcher reloads and line edits run one
  508. * at a time in queue order (settled tail), so an edit can never render from
  509. * text a concurrent reload is busy replacing.
  510. */
  511. private operations: Promise<void> = Promise.resolve()
  512. /** Set at dispose: refuse new writes and let in-flight work no-op. */
  513. private closed = false
  514. /** Opaque read of {@link closed}: control flow cannot narrow it across awaits. */
  515. private isClosed(): boolean {
  516. return this.closed
  517. }
  518. /* jscpd:ignore-end */
  519. constructor(ctx: Context, public config: Config) {
  520. super(ctx)
  521. // Programmatic construction may bypass Schemastery normalization; resolve
  522. // the same defaults in one explicit step either way.
  523. this.spec = resolveSpec(config)
  524. }
  525. /** The inherited-environment value for a reference, or `undefined` when empty or unset. */
  526. private inherited(ref: CredentialRef): string | undefined {
  527. const entry = launchEnvironmentOf(this.ctx).getFrom(ref, ['process'])
  528. return entry !== undefined && entry.value.length > 0 ? entry.value : undefined
  529. }
  530. /**
  531. * The `.env` fallback for a reference — below the managed store, never above
  532. * it. The invoking project ranks over the user's home file, matching the
  533. * environment layering: the more specific location wins.
  534. */
  535. private dotenvFallback(ref: CredentialRef): LaunchEnvironmentEntry | undefined {
  536. const entry = launchEnvironmentOf(this.ctx).getFrom(ref, ['project-env', 'user-env'])
  537. return entry !== undefined && entry.value.length > 0 ? entry : undefined
  538. }
  539. async* [Service.init](): AsyncGenerator<() => Promise<void> | void, void, void> {
  540. yield async () => {
  541. // Drain: refuse new operations, then settle the queued ones so disposal
  542. // completes only once storage is quiescent.
  543. this.closed = true
  544. await this.operations
  545. }
  546. await this.loadInitial()
  547. if (!this.spec.watch) return
  548. /* jscpd:ignore-start -- same watcher discipline as settings-file by design:
  549. the serialized-refresh and quiesce-on-dispose shape is the reviewed
  550. lifecycle contract, not accidental repetition. */
  551. const watcher = chokidarWatch(await canonicalizeWatchPath(this.spec.filename), {
  552. ignoreInitial: true,
  553. awaitWriteFinish: {
  554. stabilityThreshold: this.spec.debounceMs,
  555. pollInterval: Math.max(1, Math.min(this.spec.debounceMs, 10)),
  556. },
  557. })
  558. watcher.on('all', () => {
  559. if (this.closed) return
  560. this.queueRefresh()
  561. })
  562. watcher.on('ready', () => {
  563. // The initial load raced the watcher's own setup: a change written
  564. // between that read and the watcher becoming active never fires an
  565. // event. One reconcile at ready closes the gap.
  566. if (this.closed) return
  567. this.queueRefresh()
  568. })
  569. watcher.on('error', (error) => {
  570. this.ctx.logger.warn('credentials-local: watcher error on %s', this.spec.filename)
  571. this.ctx.logger.warn(error)
  572. })
  573. yield async () => {
  574. // Quiesce: stop accepting events, close the watcher, then wait out any
  575. // queued or in-flight operation so nothing publishes after disposal.
  576. this.closed = true
  577. await watcher.close()
  578. await this.operations
  579. }
  580. /* jscpd:ignore-end */
  581. }
  582. override resolve(ref: CredentialRef): Promise<ResolvedCredential | undefined> {
  583. const inherited = this.inherited(ref)
  584. if (inherited !== undefined) return Promise.resolve({ value: inherited, source: 'env' })
  585. const stored = this.values.get(ref)
  586. if (stored !== undefined) return Promise.resolve({ value: stored, source: 'file' })
  587. const fallback = this.dotenvFallback(ref)
  588. if (fallback !== undefined) return Promise.resolve({ value: fallback.value, source: fallback.source })
  589. return Promise.resolve(undefined)
  590. }
  591. override describe(ref: CredentialRef): Promise<CredentialInfo> {
  592. // Only the inherited environment is unwritable: it is the one layer this
  593. // process cannot edit. A user `.env` value is writable in the sense that
  594. // matters — storing a key replaces it as the effective one.
  595. if (this.inherited(ref) !== undefined) {
  596. return Promise.resolve({ configured: true, source: 'env', writable: false })
  597. }
  598. const stored = this.values.get(ref)
  599. if (stored !== undefined) return Promise.resolve({ configured: true, source: 'file', writable: true })
  600. const fallback = this.dotenvFallback(ref)
  601. if (fallback !== undefined) return Promise.resolve({ configured: true, source: fallback.source, writable: true })
  602. return Promise.resolve({ configured: false, writable: true })
  603. }
  604. override async set(ref: CredentialRef, value: string): Promise<void> {
  605. if (value.length === 0) {
  606. throw new Error(`credentials-local: an empty value cannot be stored for "${ref}"; use unset`)
  607. }
  608. await this.write(ref, value)
  609. }
  610. override async unset(ref: CredentialRef): Promise<void> {
  611. await this.write(ref, undefined)
  612. }
  613. override readRecord(key: CredentialKey): Promise<CredentialRecord | undefined> {
  614. return Promise.resolve(this.records.get(key))
  615. }
  616. override describeRecord(key: CredentialKey): Promise<CredentialRecordInfo> {
  617. const stored = this.records.get(key)
  618. // Presence is the whole fact here: no layer ranks above this document for
  619. // a record, so nothing can shadow one, and an api-key record carrying
  620. // neither a key nor environment values is a deliberate statement rather
  621. // than a blank.
  622. if (stored === undefined) return Promise.resolve({ configured: false, writable: true })
  623. return Promise.resolve({ configured: true, kind: stored.kind, writable: true })
  624. }
  625. override listRecords(): Promise<readonly CredentialRecordEntry[]> {
  626. return Promise.resolve([...this.records].map(([key, record]) => ({
  627. // The parser has already proven every stored key addressable.
  628. key: parseCredentialKey(key),
  629. kind: record.kind,
  630. })))
  631. }
  632. override async modifyRecord(
  633. key: CredentialKey,
  634. mutate: (current: CredentialRecord | undefined) => Promise<CredentialRecord | undefined>,
  635. ): Promise<CredentialRecord | undefined> {
  636. if (this.isClosed()) throw new Error(`credentials-local is disposed: cannot modify "${key}"`)
  637. return this.enqueue(async () => {
  638. if (this.isClosed()) {
  639. throw new Error(`credentials-local was disposed before the queued "${key}" modify ran`)
  640. }
  641. await mkdir(dirname(this.spec.filename), { recursive: true, mode: 0o700 })
  642. return withFileLock(this.spec.filename, async () => {
  643. // Read-modify-write: `mutate` must decide against the record as it
  644. // stands now, not as this process last saw it — another process may
  645. // have rotated it since.
  646. await this.reconcileFromDisk()
  647. const current = this.records.get(key)
  648. const next = await mutate(current)
  649. if (next === undefined) return current
  650. // Admitted before it is rendered: what the read path would refuse is
  651. // refused here first, so a caller can never persist a document the
  652. // next boot rejects, and a value refused here has not been stored.
  653. if (next.kind === 'grant') assertJsonValue(`record "${key}" payload`, next.payload, new Set())
  654. else assertStorableApiKey(key, next)
  655. const nextText = renderRecord(this.text, key, next)
  656. // 0600: a document holding secrets is never world-readable.
  657. await writeFileAtomic(this.spec.filename, nextText, { mode: 0o600, dirMode: 0o700 })
  658. this.text = nextText
  659. this.records.set(key, next)
  660. // After the commit, on the same terms as a reference write.
  661. this.notifyRecordUpdated(key)
  662. return next
  663. }, { waitMs: DOCUMENT_LOCK_WAIT_MS })
  664. })
  665. }
  666. override async deleteRecord(key: CredentialKey): Promise<void> {
  667. if (this.isClosed()) throw new Error(`credentials-local is disposed: cannot delete "${key}"`)
  668. await this.enqueue(async () => {
  669. if (this.isClosed()) {
  670. throw new Error(`credentials-local was disposed before the queued "${key}" delete ran`)
  671. }
  672. await mkdir(dirname(this.spec.filename), { recursive: true, mode: 0o700 })
  673. await withFileLock(this.spec.filename, async () => {
  674. await this.reconcileFromDisk()
  675. if (!this.records.has(key)) return
  676. const nextText = renderRecord(this.text, key, undefined)
  677. await writeFileAtomic(this.spec.filename, nextText, { mode: 0o600, dirMode: 0o700 })
  678. this.text = nextText
  679. this.records.delete(key)
  680. this.notifyRecordUpdated(key)
  681. }, { waitMs: DOCUMENT_LOCK_WAIT_MS })
  682. })
  683. }
  684. /* jscpd:ignore-start -- the operation-chain and reload lifecycle is the same
  685. reviewed contract as settings-file, deliberately mirrored (prefer symmetry
  686. for parallel values); the two providers own different documents and
  687. failure policies, so extracting a shared helper would couple their teardown
  688. semantics across packages for a handful of lines. */
  689. /** Queue one exclusive document operation behind every earlier one. */
  690. private enqueue<T>(operation: () => Promise<T>): Promise<T> {
  691. const task = this.operations.then(operation)
  692. this.operations = task.then(() => undefined, () => undefined)
  693. return task
  694. }
  695. /** Queue a reload; only an invariant violation escaping the fan-out can reject it. */
  696. private queueRefresh(): void {
  697. void this.enqueue(() => this.refresh()).catch((error: unknown) => {
  698. // Only an invariant violation escaping the update fan-out can reject a
  699. // refresh; keep the operation queue alive and surface it as an error so
  700. // one poisoned commit cannot silently end hot reloading forever.
  701. this.ctx.logger.error('credentials-local: reload commit failed at %s', this.spec.filename)
  702. this.ctx.logger.error(error)
  703. })
  704. }
  705. /* jscpd:ignore-end */
  706. /** Queue one line edit; entry checks reject early, the queue re-judges them at run time. */
  707. private async write(ref: CredentialRef, value: string | undefined): Promise<void> {
  708. const verb = value === undefined ? 'unset' : 'set'
  709. if (this.isClosed()) {
  710. throw new Error(`credentials-local is disposed: cannot ${verb} "${ref}"`)
  711. }
  712. this.assertUnshadowed(ref, verb)
  713. return this.enqueue(async () => {
  714. if (this.isClosed()) {
  715. throw new Error(`credentials-local was disposed before the queued "${ref}" ${verb} ran`)
  716. }
  717. // Re-judged at run time: the environment may have changed while queued.
  718. this.assertUnshadowed(ref, verb)
  719. // The writer lock's exclusive create needs the parent to exist; 0700
  720. // because the harness home holds user-private data.
  721. await mkdir(dirname(this.spec.filename), { recursive: true, mode: 0o700 })
  722. await withFileLock(this.spec.filename, async () => {
  723. // Read-modify-write: fold in any on-disk state this process has not
  724. // observed yet — an external edit still inside the watcher debounce
  725. // window, a change the watcher missed, or another process's write —
  726. // so the line edit below can never resurrect a stale document.
  727. await this.reconcileFromDisk()
  728. const existing = this.values.get(ref)
  729. if (value === undefined && existing === undefined) return
  730. const nextText = renderRef(this.text, ref, value)
  731. // 0600: a document holding secrets is never world-readable.
  732. await writeFileAtomic(this.spec.filename, nextText, { mode: 0o600, dirMode: 0o700 })
  733. this.text = nextText
  734. if (value === undefined) this.values.delete(ref)
  735. else this.values.set(ref, value)
  736. // After the commit: a broken observer must never make the durable
  737. // write look failed (an INVARIANT failure still rethrows).
  738. this.notifyUpdated(ref)
  739. }, { waitMs: DOCUMENT_LOCK_WAIT_MS })
  740. })
  741. }
  742. /**
  743. * Reject a write the inherited environment would shadow into apparent
  744. * no-effect. Only that layer can shadow a write: everything else this
  745. * provider resolves ranks below the document being written.
  746. */
  747. private assertUnshadowed(ref: CredentialRef, verb: 'set' | 'unset'): void {
  748. if (this.inherited(ref) !== undefined) {
  749. throw new Error(
  750. `credentials-local: "${ref}" is supplied read-only by the launching environment, so ${verb} would be`
  751. + ' shadowed; unset it in the shell you start dsh from instead',
  752. )
  753. }
  754. }
  755. /**
  756. * Boot read: an absent file is an empty store; an invalid one fails the
  757. * plugin's activation, because a credentials document that exists but
  758. * cannot be trusted must never be treated as "no credentials stored". The
  759. * one exception is the recognized pre-release flat layout, which is
  760. * upgraded in place first — a key stored by an earlier build must survive
  761. * the layout change without a hand edit.
  762. */
  763. private async loadInitial(): Promise<void> {
  764. await assertOwnerOnly(this.spec.filename)
  765. let text: string
  766. try {
  767. text = await readFile(this.spec.filename, 'utf8')
  768. } catch (error) {
  769. if (!isENOENT(error)) throw error
  770. return
  771. }
  772. if (renderFlatLayoutMigration(text) !== undefined) text = await this.migrateFlatDocument()
  773. const document = parseCredentialsDocument(text, this.spec.filename)
  774. this.values = document.refs
  775. this.records = document.records
  776. this.text = text
  777. }
  778. /**
  779. * One-shot upgrade of the recognized pre-release flat layout, before the
  780. * watcher exists. The rewrite runs under the document's writer lock and
  781. * re-reads first — a concurrent boot may have migrated already — and
  782. * whatever the re-read finds that is not the flat layout is returned
  783. * untouched for the ordinary parse. Values are carried verbatim; only the
  784. * enclosing layout changes. Remove with the pre-release stance at the
  785. * first tagged release.
  786. * @returns the document text this boot should parse.
  787. */
  788. private async migrateFlatDocument(): Promise<string> {
  789. return withFileLock(this.spec.filename, async () => {
  790. const current = await readFile(this.spec.filename, 'utf8')
  791. const migrated = renderFlatLayoutMigration(current)
  792. /* v8 ignore next 2 -- the losing side of the cross-process migration race:
  793. another boot rewrote the document between the unlocked recognize and
  794. this lock. That interleaving cannot be scheduled deterministically
  795. through a whole boot (migration.spec drives it best-effort); the
  796. decision itself is the recognizer's covered versioned-document decline. */
  797. if (migrated === undefined) return current
  798. // 0600: a document holding secrets is never world-readable.
  799. await writeFileAtomic(this.spec.filename, migrated, { mode: 0o600, dirMode: 0o700 })
  800. this.ctx.logger.info(
  801. 'credentials-local: migrated %s to the version %d layout; values are unchanged',
  802. this.spec.filename,
  803. DOCUMENT_VERSION,
  804. )
  805. return migrated
  806. }, { waitMs: DOCUMENT_LOCK_WAIT_MS })
  807. }
  808. /* jscpd:ignore-start -- same deliberate mirror of settings-file's reload and
  809. reconcile policy: warn-and-keep on a reload, throw on a write, invariant
  810. failures propagate. */
  811. /**
  812. * Re-read the document after a watcher event. Unchanged content (including
  813. * this provider's own writes) is a no-op; an unreadable document keeps the
  814. * last good snapshot and warns — a live hot-reload must never take the
  815. * process down. An invariant violation escaping the fan-out is not a reload
  816. * failure and propagates to the queue's error surface.
  817. */
  818. private async refresh(): Promise<void> {
  819. if (this.closed) return
  820. try {
  821. await this.reconcileFromDisk()
  822. } catch (error) {
  823. if ((error as { code?: unknown } | null)?.code === 'INVARIANT') throw error
  824. this.ctx.logger.warn('credentials-local: reload failed at %s; keeping the last good document', this.spec.filename)
  825. this.ctx.logger.warn(error)
  826. }
  827. }
  828. /**
  829. * Compare the on-disk text against the cache and publish any difference
  830. * into the seam. Absence publishes the empty store; an unreadable or
  831. * invalid document throws, so each caller picks its policy — a reload warns
  832. * and keeps the last good snapshot, a write fails loud rather than
  833. * overwriting a document it could not understand.
  834. */
  835. private async reconcileFromDisk(): Promise<void> {
  836. // Re-checked on every reload and before every write: an external editor or
  837. // a restored backup can loosen the mode after boot.
  838. await assertOwnerOnly(this.spec.filename)
  839. let text: string | undefined
  840. try {
  841. text = await readFile(this.spec.filename, 'utf8')
  842. } catch (error) {
  843. if (!isENOENT(error)) throw error
  844. text = undefined
  845. }
  846. if (text === this.text || this.isClosed()) return
  847. const next = text === undefined
  848. ? { refs: new Map<string, string>(), records: new Map<string, CredentialRecord>() }
  849. : parseCredentialsDocument(text, this.spec.filename)
  850. const changedRefs = this.changedRefs(this.values, next.refs)
  851. const changedRecords = this.changedRecords(this.records, next.records)
  852. this.text = text
  853. this.values = next.refs
  854. this.records = next.records
  855. for (const ref of changedRefs) this.notifyUpdated(ref)
  856. for (const key of changedRecords) this.notifyRecordUpdated(key)
  857. }
  858. /* jscpd:ignore-end */
  859. /** Entries whose stored value changed; the parser has already proven every key addressable. */
  860. private changedRefs(prev: Map<string, string>, next: Map<string, string>): CredentialRef[] {
  861. const changed: CredentialRef[] = []
  862. for (const key of new Set([...prev.keys(), ...next.keys()])) {
  863. if (prev.get(key) === next.get(key)) continue
  864. changed.push(credentialRef(key))
  865. }
  866. return changed
  867. }
  868. /** Records whose stored value changed; the parser has already proven every key addressable. */
  869. private changedRecords(
  870. prev: Map<string, CredentialRecord>,
  871. next: Map<string, CredentialRecord>,
  872. ): CredentialKey[] {
  873. const changed: CredentialKey[] = []
  874. for (const key of new Set([...prev.keys(), ...next.keys()])) {
  875. if (sameJsonValue(prev.get(key), next.get(key))) continue
  876. changed.push(parseCredentialKey(key))
  877. }
  878. return changed
  879. }
  880. }
  881. export default LocalCredentialProvider