返回源码地图

packages/credentials/credentials-local/src/index.ts

main snapshot · da00f7f5358f · 正文引用章节 17 / 17;完整原文可核对,不声称全文件人工逐行审计

完整原文供逐行核对;页面收录不代表每行都经过人工语义审核。MIT 许可见 许可证。

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