1
/**2
* File-backed credentials provider over `$DSH_HOME/.credentials.yaml`, layered3
* against the environment by how much each layer is trusted:4
*5
* ```text6
* 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 CI13
* secret, or a container `-e` is this run's explicit intent; it cannot be14
* edited from inside, so it must be *visibly* read-only rather than silently15
* shadow writes. Everything below it loses to the managed store, so a key the16
* Models page writes takes effect immediately even when an older key sits in17
* the user's `.env`.18
*19
* The invoking project may supply a key, because the product trusts the20
* project it is launched in. It ranks below the managed store, so a key stored21
* 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 the24
* document under a cross-process writer lock before patching only its own key25
* — comments and the formatting of every untouched entry survive — external26
* edits hot-publish through the seam, and each reload replaces the snapshot27
* wholesale so a deleted entry never lingers in memory.28
*29
* The document holds nothing but credentials, which is why it is a strict30
* `CredentialRef`-to-string mapping rather than a dotenv file: a store the31
* Harness owns and never materializes into the environment cannot also serve32
* as the user's environment layer; a store that doubled as the environment33
* layer would shadow non-secret entries behind its precedence, making them34
* silently unreachable.35
* @module @deepseek-ai/dsh-credentials-local36
*/38
import { Context, Service } from '@deepseek-ai/cordis'39
import z from '@deepseek-ai/schemastery'40
import { watch as chokidarWatch } from 'chokidar'41
import { mkdir, readFile, stat } from 'node:fs/promises'42
import { dirname, join, resolve } from 'node:path'43
import { Document, isMap, isScalar, parseDocument, type YAMLError } from 'yaml'44
import { withFileLock, writeFileAtomic } from '@deepseek-ai/dsh-atomic-write'45
import { canonicalizeWatchPath, resolveDshHome } from '@deepseek-ai/dsh-home-paths'46
import { launchEnvironmentOf } from '@deepseek-ai/dsh-launch-environment'47
import { CredentialProvider, credentialRef, parseCredentialKey } from '@deepseek-ai/dsh-credentials'48
import type {49
ApiKeyRecord,50
CredentialInfo,51
CredentialKey,52
CredentialRecord,53
CredentialRecordEntry,54
CredentialRecordInfo,55
CredentialRef,56
ResolvedCredential,57
} from '@deepseek-ai/dsh-credentials'58
import type { LaunchEnvironmentEntry } from '@deepseek-ai/dsh-launch-environment'60
/** Basename of the credentials document inside the harness home. */61
export const CREDENTIALS_FILENAME = '.credentials.yaml'63
/** Plugin config: file location and hot-reload behavior. */64
export interface Config {65
/** Credentials document path; defaults to `.credentials.yaml` under the harness home. */66
path?: string67
/** Harness home used when `path` is omitted; defaults to `$DSH_HOME` or `~/.dsh`. */68
dshHome?: string69
/** Watch the document and hot-publish external edits; defaults to true. */70
watch?: boolean71
/** Watcher write-settle window in milliseconds; defaults to 100. */72
debounceMs?: number73
}75
/** Fully resolved provider parameters; defaulting happens here, never inline. */76
interface ResolvedSpec {77
filename: string78
watch: boolean79
debounceMs: number80
}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
*/88
export 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
}96
/** Permission bits outside the owner; a credentials document must have none of them. */97
const GROUP_OTHER_BITS = 0o07799
/**100
* How long a record write waits for the cross-process writer lock. A record101
* mutation runs its caller's decision while holding the lock, and for the102
* operation this half exists to serve — an owner refreshing an expired token —103
* that decision includes a network round trip. The file-work default would104
* fail every other writer of this document for its duration. A contender's105
* wait is sized by the longest holder it can meet, and refs and records share106
* one file and one lock, so every writer of this document — reference writes107
* and record deletes included — waits this long, not only the mutation that108
* holds it. Like the retry cadence in `dsh-atomic-write`, this is a109
* robustness bound of the write protocol rather than a deployment choice: it110
* is sized by what a provider request costs, which no deployment varies.111
*/112
const DOCUMENT_LOCK_WAIT_MS = 30_000114
/**115
* Reject a credentials document other OS users can read, before its contents116
* are read at all. The provider creates and replaces the file at `0600`, but a117
* hand-written or externally generated one carries whatever umask produced it,118
* and silently serving secrets out of a world-readable file would make the119
* mode the provider promises meaningless.120
*121
* POSIX only: Windows has no mode to inspect — its ACLs are not expressible122
* here — so the check is skipped rather than faked, and the file's protection123
* 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
*/127
async function assertOwnerOnly(filename: string): Promise<void> {128
let mode: number129
try {130
mode = (await stat(filename)).mode131
} catch (error) {132
if (!isENOENT(error)) throw error133
await canonicalizeWatchPath(filename)134
return135
}136
/* v8 ignore next -- POSIX coverage cannot take the Windows peer; native Windows coverage does. */137
if (process.platform === 'win32') return138
/* v8 ignore start -- Windows has no POSIX mode enforcement; POSIX behavior tests enforce this peer. */139
const offending = mode & GROUP_OTHER_BITS140
if (offending === 0) return141
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
}148
/** Whether a filesystem error means absence; every non-ENOENT failure must surface. */149
function isENOENT(error: unknown): boolean {150
return (error as NodeJS.ErrnoException | null)?.code === 'ENOENT'151
}153
/**154
* Describe one YAML parse failure without quoting the source. The parser's own155
* 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
*/159
function 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
}166
/** The document layout this build reads and writes. */167
export const DOCUMENT_VERSION = 1169
/** One parsed credentials document: the two key spaces it stores, keyed as written. */170
export 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
}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 holds181
* nothing but credentials and a silently ignored entry reads as "the credential182
* I stored has no effect". Duplicate keys surface as parser errors. An empty183
* 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
*/188
export 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 document191
// that line is a secret. Only the code and position leave this function, and192
// the same rule governs every other diagnostic here — a key name is safe to193
// 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 no206
// 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
}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-empty232
* top-level mapping of addressable reference names to non-empty string233
* scalars, with no `version` key and no document directives — and the rewrite234
* nests the original lines verbatim under `refs:` at two spaces' indent, so235
* comments, blank lines, and each value's spelling survive byte for byte.236
* Anything the recognizer declines keeps {@link parseCredentialsDocument}'s237
* loud rejection: a document this build cannot prove it understands is never238
* 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
*/242
export function renderFlatLayoutMigration(text: string): string | undefined {243
const document = parseDocument(text, { prettyErrors: true, uniqueKeys: true })244
if (document.errors.length > 0) return undefined245
const flat = document.contents246
if (!isMap(flat) || flat.items.length === 0) return undefined247
for (const line of text.split('\n')) {248
// A directive or document marker would not survive being indented into249
// the `refs:` block; no shipped writer ever emitted one here.250
if (/^(%|---|\.\.\.)/.test(line)) return undefined251
}252
for (const pair of flat.items) {253
if (!isScalar(pair.key) || typeof pair.key.value !== 'string' || pair.key.value === 'version') return undefined254
try {255
credentialRef(pair.key.value)256
} catch {257
// Only credentialRef's rejection of a non-POSIX name lands here; the258
// flat reader refused such a key too, so this is not the recognized259
// layout and the loud rejection stands.260
return undefined261
}262
if (!isScalar(pair.value) || typeof pair.value.value !== 'string' || pair.value.value.length === 0) return undefined263
}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
}268
/** Admit a `refs` section: POSIX-identifier keys over non-empty string values. */269
function 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, which273
// is exactly the constraint a stored reference must satisfy to be274
// addressable through the seam.275
credentialRef(key)276
// The key name is quoted, never the value: a wrong-typed entry is still a277
// 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 entries287
}289
/** Admit a `records` section: `<scope>/<id>` keys over tagged record mappings. */290
function 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 entries297
}299
/**300
* Refuse an api-key record the read path could not admit, before it is301
* rendered: an empty key, an env name outside the reference grammar, or an302
* empty env value would persist a document `parseRecord` rejects at the next303
* 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
*/307
function 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
}319
/** One section of the document as a plain mapping; absent and null both mean empty. */320
function 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
}328
/** Admit one record entry, rejecting an unknown tag or field rather than dropping it. */329
function 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
}360
/** Reject a field the tag does not define, so a typo is not silently dropped. */361
function 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
}369
/** Admit an api-key record's provider environment: POSIX names over non-empty strings. */370
function parseRecordEnv(key: string, env: unknown, filename: string): Record<string, string> | undefined {371
if (env === undefined) return undefined372
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] = value384
}385
return parsed386
}388
/**389
* Reject a payload that cannot survive a JSON round trip, on the way in and on390
* the way out. The seam promises owners their payload comes back exactly as391
* written, and both directions can break that: a document may spell `.inf` or392
* an alias cycle, and an owner may hand over a `Date`, a class instance, or a393
* `bigint` that this document has no faithful spelling for. Neither the value394
* 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
*/400
function assertJsonValue(where: string, value: unknown, seen: Set<object>): void {401
if (value === null || typeof value === 'string' || typeof value === 'boolean') return402
if (typeof value === 'number') {403
if (Number.isFinite(value)) return404
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
return413
}414
}415
throw new TypeError(`credentials-local: ${where} holds a value JSON cannot represent`)416
}418
/**419
* The comment-preserving mutable tree one edit renders from. Editing the420
* parsed document rather than rebuilding it keeps comments and the formatting421
* 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
*/425
function mutableDocument(text: string | undefined): Document {426
// `text` only ever caches content that parsed successfully, so this re-parse427
// 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 by430
// the same parser that admitted the one it edits; an existing stamp is431
// rewritten to the identical value.432
document.setIn(['version'], DOCUMENT_VERSION)433
return document434
}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
*/443
function 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
}450
/**451
* Render the next document text with one record written or deleted. The record452
* node is replaced wholesale rather than edited field by field: records are453
* 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
*/459
function 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
}466
/**467
* Remove one entry from a section, taking its annotation with it. A comment468
* block written above a section's first entry annotates that entry, but the469
* parser attaches it to the section's map rather than to the pair — leaving it470
* behind would move it onto whichever entry became first, which reads as an471
* 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
*/476
function 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 just479
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 the484
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 = null487
}488
}489
document.deleteIn([section, key])490
}492
/**493
* Structural equality over two admitted JSON values. Records reach this after494
* {@link assertJsonValue}, so the walk meets only JSON shapes; key order is495
* ignored because an external editor may reorder a record's fields without496
* 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
*/501
function sameJsonValue(left: unknown, right: unknown): boolean {502
if (left === right) return true503
if (typeof left !== 'object' || typeof right !== 'object' || left === null || right === null) return false504
if (Array.isArray(left) !== Array.isArray(right)) return false505
const leftKeys = Object.keys(left)506
const rightKeys = Object.keys(right)507
if (leftKeys.length !== rightKeys.length) return false508
return leftKeys.every(key => key in right509
&& sameJsonValue((left as Record<string, unknown>)[key], (right as Record<string, unknown>)[key]))510
}512
/** File-backed credentials provider (`$DSH_HOME/.credentials.yaml`). */513
export 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
})521
private readonly spec: ResolvedSpec522
/**523
* Raw text of the last read or persisted document; `undefined` while the524
* 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 | undefined528
/** 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 one534
* at a time in queue order (settled tail), so an edit can never render from535
* 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 = false541
/** Opaque read of {@link closed}: control flow cannot narrow it across awaits. */542
private isClosed(): boolean {543
return this.closed544
}546
constructor(ctx: Context, public config: Config) {547
super(ctx)548
// Programmatic construction may bypass Schemastery normalization; resolve549
// the same defaults in one explicit step either way.550
this.spec = resolveSpec(config)551
}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 : undefined557
}559
/**560
* The `.env` fallback for a reference — below the managed store, never above561
* it. The invoking project ranks over the user's home file, matching the562
* 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 : undefined567
}569
async* [Service.init](): AsyncGenerator<() => Promise<void> | void, void, void> {570
yield async () => {571
// Drain: refuse new operations, then settle the queued ones so disposal572
// completes only once storage is quiescent.573
this.closed = true574
await this.operations575
}576
await this.loadInitial()577
if (!this.spec.watch) return578
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) return587
this.queueRefresh()588
})589
watcher.on('ready', () => {590
// The initial load raced the watcher's own setup: a change written591
// between that read and the watcher becoming active never fires an592
// event. One reconcile at ready closes the gap.593
if (this.closed) return594
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 any602
// queued or in-flight operation so nothing publishes after disposal.603
this.closed = true604
await watcher.close()605
await this.operations606
}607
}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
}619
override describe(ref: CredentialRef): Promise<CredentialInfo> {620
// Only the inherited environment is unwritable: it is the one layer this621
// process cannot edit. A user `.env` value is writable in the sense that622
// 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
}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
}640
override async unset(ref: CredentialRef): Promise<void> {641
await this.write(ref, undefined)642
}644
override readRecord(key: CredentialKey): Promise<CredentialRecord | undefined> {645
return Promise.resolve(this.records.get(key))646
}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 for651
// a record, so nothing can shadow one, and an api-key record carrying652
// neither a key nor environment values is a deliberate statement rather653
// 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
}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
}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 it678
// stands now, not as this process last saw it — another process may679
// 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 current684
// Admitted before it is rendered: what the read path would refuse is685
// refused here first, so a caller can never persist a document the686
// 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 = nextText693
this.records.set(key, next)694
// After the commit, on the same terms as a reference write.695
this.notifyRecordUpdated(key)696
return next697
}, { waitMs: DOCUMENT_LOCK_WAIT_MS })698
})699
}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)) return711
const nextText = renderRecord(this.text, key, undefined)712
await writeFileAtomic(this.spec.filename, nextText, { mode: 0o600, dirMode: 0o700 })713
this.text = nextText714
this.records.delete(key)715
this.notifyRecordUpdated(key)716
}, { waitMs: DOCUMENT_LOCK_WAIT_MS })717
})718
}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 task725
}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
}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; 0700746
// 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 not750
// observed yet — an external edit still inside the watcher debounce751
// 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) return756
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 = nextText760
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 durable763
// write look failed.764
this.notifyUpdated(ref)765
}, { waitMs: DOCUMENT_LOCK_WAIT_MS })766
})767
}769
/**770
* Reject a write the inherited environment would shadow into apparent771
* no-effect. Only that layer can shadow a write: everything else this772
* 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
}783
/**784
* Boot read: an absent file is an empty store; an invalid one fails the785
* plugin's activation, because a credentials document that exists but786
* cannot be trusted must never be treated as "no credentials stored". The787
* one exception is the recognized pre-release flat layout, which is788
* upgraded in place first — a key stored by an earlier build must survive789
* the layout change without a hand edit.790
*/791
private async loadInitial(): Promise<void> {792
await assertOwnerOnly(this.spec.filename)793
let text: string794
try {795
text = await readFile(this.spec.filename, 'utf8')796
} catch (error) {797
if (!isENOENT(error)) throw error798
return799
}800
if (renderFlatLayoutMigration(text) !== undefined) text = await this.migrateFlatDocument()801
const document = parseCredentialsDocument(text, this.spec.filename)802
this.values = document.refs803
this.records = document.records804
this.text = text805
}807
/**808
* One-shot upgrade of the recognized pre-release flat layout, before the809
* watcher exists. The rewrite runs under the document's writer lock and810
* re-reads first — a concurrent boot may have migrated already — and811
* whatever the re-read finds that is not the flat layout is returned812
* untouched for the ordinary parse. Values are carried verbatim; only the813
* enclosing layout changes. Remove with the pre-release stance at the814
* 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 and823
this lock. That interleaving cannot be scheduled deterministically824
through a whole boot (migration.spec drives it best-effort); the825
decision itself is the recognizer's covered versioned-document decline. */826
if (migrated === undefined) return current827
// 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 migrated835
}, { waitMs: DOCUMENT_LOCK_WAIT_MS })836
}838
/**839
* Re-read the document after a watcher event. Unchanged content (including840
* this provider's own writes) is a no-op; an unreadable document keeps the841
* last good snapshot and warns — a live hot-reload must never take the842
* process down.843
*/844
private async refresh(): Promise<void> {845
if (this.closed) return846
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
}854
/**855
* Compare the on-disk text against the cache and publish any difference856
* into the seam. Absence publishes the empty store; an unreadable or857
* invalid document throws, so each caller picks its policy — a reload warns858
* and keeps the last good snapshot, a write fails loud rather than859
* 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 or863
// a restored backup can loosen the mode after boot.864
await assertOwnerOnly(this.spec.filename)865
let text: string | undefined866
try {867
text = await readFile(this.spec.filename, 'utf8')868
} catch (error) {869
if (!isENOENT(error)) throw error870
text = undefined871
}872
if (text === this.text || this.isClosed()) return873
const next = text === undefined874
? { 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 = text879
this.values = next.refs880
this.records = next.records881
for (const ref of changedRefs) this.notifyUpdated(ref)882
for (const key of changedRecords) this.notifyRecordUpdated(key)883
}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)) continue890
changed.push(credentialRef(key))891
}892
return changed893
}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))) continue903
changed.push(parseCredentialKey(key))904
}905
return changed906
}907
}909
export default LocalCredentialProvider