mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
Code already treats $DSH_HOME/.env as ordinary launch environment and stores managed credentials in .credentials.yaml, but public docs still described the old store, old precedence, removed literal adapter keys, and the deleted TUI. That directed users to the wrong file and overstated the supported configuration surface. Update the existing English and Chinese owners in place, document inherited > managed > project > user credential resolution, and record the loadLayeredEnv export. Regenerate only pairing records and the source-line catalog; add no new section or site route.
493 lines
22 KiB
TypeScript
493 lines
22 KiB
TypeScript
/**
|
|
* File-backed credentials provider over `$DSH_HOME/.credentials.yaml`, layered
|
|
* against the environment by how much each layer is trusted:
|
|
*
|
|
* ```text
|
|
* inherited process environment (read-only, wins)
|
|
* > $DSH_HOME/.credentials.yaml (provider-managed, writable)
|
|
* > <invocation cwd>/.env (read-only fallback)
|
|
* > $DSH_HOME/.env (read-only fallback)
|
|
* ```
|
|
*
|
|
* The inherited environment wins because `DEEPSEEK_API_KEY=… dsh`, a CI
|
|
* secret, or a container `-e` is this run's explicit intent; it cannot be
|
|
* edited from inside, so it must be *visibly* read-only rather than silently
|
|
* shadow writes. Everything below it loses to the managed store, so a key the
|
|
* Models page writes takes effect immediately even when an older key sits in
|
|
* the user's `.env`.
|
|
*
|
|
* The invoking project may supply a key, because the product trusts the
|
|
* project it is launched in. It ranks below the managed store, so a key stored
|
|
* through the Models page is never displaced by one a checkout happens to carry.
|
|
*
|
|
* The file is the provider-managed writable source: every write re-reads the
|
|
* document under a cross-process writer lock before patching only its own key
|
|
* — comments and the formatting of every untouched entry survive — external
|
|
* edits hot-publish through the seam, and each reload replaces the snapshot
|
|
* wholesale so a deleted entry never lingers in memory.
|
|
*
|
|
* The document holds nothing but credentials, which is why it is a strict
|
|
* `CredentialRef`-to-string mapping rather than a dotenv file: a store the
|
|
* Harness owns and never materializes into the environment cannot also serve
|
|
* as the user's environment layer, and conflating the two is what made a
|
|
* non-secret in the old `$DSH_HOME/.env` silently unreachable.
|
|
* @module @deepseek-ai/dsh-credentials-local
|
|
*/
|
|
|
|
import { Context, Service } from 'cordis'
|
|
import z from 'schemastery'
|
|
import { watch as chokidarWatch } from 'chokidar'
|
|
import { mkdir, readFile, stat } from 'node:fs/promises'
|
|
import { dirname, join, resolve } from 'node:path'
|
|
import { Document, parseDocument, type YAMLError } from 'yaml'
|
|
import { withFileLock, writeFileAtomic } from '@deepseek-ai/dsh-atomic-write'
|
|
import { resolveDshHome } from '@deepseek-ai/dsh-paths'
|
|
import { environmentOf } from '@deepseek-ai/dsh-environment'
|
|
import { Credentials, credentialRef } from '@deepseek-ai/dsh-credentials'
|
|
import type { CredentialInfo, CredentialRef, ResolvedCredential } from '@deepseek-ai/dsh-credentials'
|
|
import type { EnvironmentEntry } from '@deepseek-ai/dsh-environment'
|
|
|
|
/** Basename of the credentials document inside the harness home. */
|
|
export const CREDENTIALS_FILENAME = '.credentials.yaml'
|
|
|
|
/** Plugin config: file location and hot-reload behavior. */
|
|
export interface Config {
|
|
/** Credentials document path; defaults to `.credentials.yaml` under the harness home. */
|
|
path?: string
|
|
/** Harness home used when `path` is omitted; defaults to `$DSH_HOME` or `~/.dsh`. */
|
|
dshHome?: string
|
|
/** Watch the document and hot-publish external edits; defaults to true. */
|
|
watch?: boolean
|
|
/** Watcher write-settle window in milliseconds; defaults to 100. */
|
|
debounceMs?: number
|
|
}
|
|
|
|
/** Fully resolved provider parameters; defaulting happens here, never inline. */
|
|
interface ResolvedSpec {
|
|
filename: string
|
|
watch: boolean
|
|
debounceMs: number
|
|
}
|
|
|
|
/**
|
|
* Resolve the runtime spec from plugin config: an explicit `path` wins,
|
|
* otherwise the document lives at `<harness home>/.credentials.yaml`.
|
|
* @param config - raw plugin config.
|
|
* @returns the resolved file location and watch behavior.
|
|
*/
|
|
export function resolveSpec(config: Config): ResolvedSpec {
|
|
return {
|
|
filename: resolve(config.path ?? join(resolveDshHome(config.dshHome), CREDENTIALS_FILENAME)),
|
|
watch: config.watch ?? true,
|
|
debounceMs: config.debounceMs ?? 100,
|
|
}
|
|
}
|
|
|
|
/** Permission bits outside the owner; a credentials document must have none of them. */
|
|
const GROUP_OTHER_BITS = 0o077
|
|
|
|
/**
|
|
* Reject a credentials document other OS users can read, before its contents
|
|
* are read at all. The provider creates and replaces the file at `0600`, but a
|
|
* hand-written or externally generated one carries whatever umask produced it,
|
|
* and silently serving secrets out of a world-readable file would make the
|
|
* mode the provider promises meaningless.
|
|
*
|
|
* POSIX only: Windows has no mode to inspect — its ACLs are not expressible
|
|
* here — so the check is skipped rather than faked, and the file's protection
|
|
* there is whatever the create and replace APIs express.
|
|
* @param filename - absolute path of the document.
|
|
* @throws when the file exists with group or other permission bits set.
|
|
*/
|
|
async function assertOwnerOnly(filename: string): Promise<void> {
|
|
/* v8 ignore next -- native Windows coverage exercises the skip; POSIX covers the check */
|
|
if (process.platform === 'win32') return
|
|
let mode: number
|
|
try {
|
|
mode = (await stat(filename)).mode
|
|
} catch (error) {
|
|
if (!isENOENT(error)) throw error
|
|
return
|
|
}
|
|
const offending = mode & GROUP_OTHER_BITS
|
|
if (offending === 0) return
|
|
throw new Error(
|
|
`credentials-local: ${filename} is readable beyond its owner (mode ${(mode & 0o777).toString(8)});`
|
|
+ ` run "chmod 600 ${filename}" before starting again`,
|
|
)
|
|
}
|
|
|
|
/** Whether a filesystem error means absence; every non-ENOENT failure must surface. */
|
|
function isENOENT(error: unknown): boolean {
|
|
return (error as NodeJS.ErrnoException | null)?.code === 'ENOENT'
|
|
}
|
|
|
|
/**
|
|
* Describe one YAML parse failure without quoting the source. The parser's own
|
|
* message embeds the offending line, which here holds a secret.
|
|
* @param error - the parser's error.
|
|
* @returns the error code with its line and column.
|
|
*/
|
|
function describeYamlError(error: YAMLError): string {
|
|
const at = error.linePos?.[0]
|
|
/* v8 ignore next -- `prettyErrors` populates linePos on every error; the guard answers its optional type */
|
|
const where = at === undefined ? '' : ` at line ${String(at.line)}, column ${String(at.col)}`
|
|
return `${error.code}${where}`
|
|
}
|
|
|
|
/**
|
|
* Parse one credentials document into its entries. The document is a strict
|
|
* mapping of {@link CredentialRef} to non-empty string: a non-mapping root, a
|
|
* key that is not a POSIX identifier, a non-string value, and an empty string
|
|
* are all rejected rather than skipped, because this file holds nothing but
|
|
* credentials and a silently ignored entry reads as "the key I stored has no
|
|
* effect". Duplicate keys surface as parser errors. An empty document is an
|
|
* empty store.
|
|
* @param text - the document's text.
|
|
* @param filename - absolute path, quoted in errors.
|
|
* @returns the parsed entries, keyed by reference.
|
|
*/
|
|
export function parseCredentialsDocument(text: string, filename: string): Map<string, string> {
|
|
// `prettyErrors` is on only for `linePos`; `error.message` is never used,
|
|
// because the parser quotes the offending source line and in this document
|
|
// that line is a secret. Only the code and position leave this function, and
|
|
// the same rule governs every other diagnostic here — a key name is safe to
|
|
// print, a value is not.
|
|
const document = parseDocument(text, { prettyErrors: true, uniqueKeys: true })
|
|
if (document.errors.length > 0) {
|
|
throw new Error(`credentials-local: invalid document at ${filename}: ${
|
|
document.errors.map(describeYamlError).join('; ')}`)
|
|
}
|
|
const root: unknown = document.toJS() ?? {}
|
|
if (typeof root !== 'object' || root === null || Array.isArray(root)) {
|
|
throw new TypeError(`credentials-local: ${filename} must be a mapping of credential reference to value`)
|
|
}
|
|
const entries = new Map<string, string>()
|
|
for (const [key, value] of Object.entries(root as Record<string, unknown>)) {
|
|
// credentialRef throws on anything that is not a POSIX identifier, which
|
|
// is exactly the constraint a stored reference must satisfy to be
|
|
// addressable through the seam.
|
|
credentialRef(key)
|
|
// The key name is quoted, never the value: a wrong-typed entry is still a
|
|
// secret the user meant to store.
|
|
if (typeof value !== 'string') {
|
|
throw new TypeError(`credentials-local: the value for "${key}" in ${filename} must be a string`)
|
|
}
|
|
if (value.length === 0) {
|
|
throw new Error(`credentials-local: the value for "${key}" in ${filename} is empty; remove the key instead`)
|
|
}
|
|
entries.set(key, value)
|
|
}
|
|
return entries
|
|
}
|
|
|
|
/**
|
|
* Render the next document text with one reference set or deleted. Editing
|
|
* the parsed document rather than rebuilding it keeps comments and the
|
|
* formatting of every untouched entry; an absent document starts a fresh one.
|
|
* @param text - the current document text, `undefined` while the file is absent.
|
|
* @param ref - the reference to write.
|
|
* @param value - the new value, or `undefined` to delete the key.
|
|
* @returns the text to persist.
|
|
*/
|
|
function renderDocument(text: string | undefined, ref: CredentialRef, value: string | undefined): string {
|
|
// `text` only ever caches content that parsed successfully, so this re-parse
|
|
// for the mutable comment-preserving tree cannot fail.
|
|
const document = text === undefined ? new Document({}) : parseDocument(text)
|
|
if (value === undefined) document.deleteIn([ref])
|
|
else document.setIn([ref], value)
|
|
return document.toString()
|
|
}
|
|
|
|
/** File-backed credentials provider (`$DSH_HOME/.credentials.yaml`). */
|
|
export class CredentialsLocal extends Credentials {
|
|
/* jscpd:ignore-start -- deliberate config-surface and lifecycle symmetry with
|
|
settings-local (prefer symmetry for parallel values); extracting the shared
|
|
shape would couple the two providers' teardown semantics across packages. */
|
|
static Config: z<Config> = z.object({
|
|
path: z.string(),
|
|
dshHome: z.string(),
|
|
watch: z.boolean().default(true),
|
|
debounceMs: z.number().min(0).default(100),
|
|
})
|
|
|
|
private readonly spec: ResolvedSpec
|
|
/**
|
|
* Raw text of the last read or persisted document; `undefined` while the
|
|
* file is absent. Watcher events whose content equals this cache are no-ops,
|
|
* which is also the self-write suppression.
|
|
*/
|
|
private text: string | undefined
|
|
/** Parsed document snapshot; replaced wholesale on every reload. */
|
|
private values = new Map<string, string>()
|
|
/**
|
|
* Single exclusive operation chain: watcher reloads and line edits run one
|
|
* at a time in queue order (settled tail), so an edit can never render from
|
|
* text a concurrent reload is busy replacing.
|
|
*/
|
|
private operations: Promise<void> = Promise.resolve()
|
|
/** Set at dispose: refuse new writes and let in-flight work no-op. */
|
|
private closed = false
|
|
|
|
/** Opaque read of {@link closed}: control flow cannot narrow it across awaits. */
|
|
private isClosed(): boolean {
|
|
return this.closed
|
|
}
|
|
/* jscpd:ignore-end */
|
|
|
|
constructor(ctx: Context, public config: Config) {
|
|
super(ctx)
|
|
// Programmatic construction may bypass Schemastery normalization; resolve
|
|
// the same defaults in one explicit step either way.
|
|
this.spec = resolveSpec(config)
|
|
}
|
|
|
|
/** The inherited-environment value for a reference, or `undefined` when empty or unset. */
|
|
private inherited(ref: CredentialRef): string | undefined {
|
|
const entry = environmentOf(this.ctx).getFrom(ref, ['process'])
|
|
return entry !== undefined && entry.value.length > 0 ? entry.value : undefined
|
|
}
|
|
|
|
/**
|
|
* The `.env` fallback for a reference — below the managed store, never above
|
|
* it. The invoking project ranks over the user's home file, matching the
|
|
* environment layering: the more specific location wins.
|
|
*/
|
|
private dotenvFallback(ref: CredentialRef): EnvironmentEntry | undefined {
|
|
const entry = environmentOf(this.ctx).getFrom(ref, ['project-env', 'user-env'])
|
|
return entry !== undefined && entry.value.length > 0 ? entry : undefined
|
|
}
|
|
|
|
async* [Service.init](): AsyncGenerator<() => Promise<void> | void, void, void> {
|
|
yield async () => {
|
|
// Drain: refuse new operations, then settle the queued ones so disposal
|
|
// completes only once storage is quiescent.
|
|
this.closed = true
|
|
await this.operations
|
|
}
|
|
await this.loadInitial()
|
|
if (!this.spec.watch) return
|
|
/* jscpd:ignore-start -- same watcher discipline as settings-local by design:
|
|
the serialized-refresh and quiesce-on-dispose shape is the reviewed
|
|
lifecycle contract, not accidental repetition. */
|
|
const watcher = chokidarWatch(this.spec.filename, {
|
|
ignoreInitial: true,
|
|
awaitWriteFinish: {
|
|
stabilityThreshold: this.spec.debounceMs,
|
|
pollInterval: Math.max(1, Math.min(this.spec.debounceMs, 10)),
|
|
},
|
|
})
|
|
watcher.on('all', () => {
|
|
if (this.closed) return
|
|
this.queueRefresh()
|
|
})
|
|
watcher.on('ready', () => {
|
|
// The initial load raced the watcher's own setup: a change written
|
|
// between that read and the watcher becoming active never fires an
|
|
// event. One reconcile at ready closes the gap.
|
|
if (this.closed) return
|
|
this.queueRefresh()
|
|
})
|
|
watcher.on('error', (error) => {
|
|
this.ctx.logger.warn('credentials-local: watcher error on %s', this.spec.filename)
|
|
this.ctx.logger.warn(error)
|
|
})
|
|
yield async () => {
|
|
// Quiesce: stop accepting events, close the watcher, then wait out any
|
|
// queued or in-flight operation so nothing publishes after disposal.
|
|
this.closed = true
|
|
await watcher.close()
|
|
await this.operations
|
|
}
|
|
/* jscpd:ignore-end */
|
|
}
|
|
|
|
override resolve(ref: CredentialRef): Promise<ResolvedCredential | undefined> {
|
|
const inherited = this.inherited(ref)
|
|
if (inherited !== undefined) return Promise.resolve({ value: inherited, source: 'env' })
|
|
const stored = this.values.get(ref)
|
|
if (stored !== undefined) return Promise.resolve({ value: stored, source: 'file' })
|
|
const fallback = this.dotenvFallback(ref)
|
|
if (fallback !== undefined) return Promise.resolve({ value: fallback.value, source: fallback.source })
|
|
return Promise.resolve(undefined)
|
|
}
|
|
|
|
override describe(ref: CredentialRef): Promise<CredentialInfo> {
|
|
// Only the inherited environment is unwritable: it is the one layer this
|
|
// process cannot edit. A user `.env` value is writable in the sense that
|
|
// matters — storing a key replaces it as the effective one.
|
|
if (this.inherited(ref) !== undefined) {
|
|
return Promise.resolve({ configured: true, source: 'env', writable: false })
|
|
}
|
|
const stored = this.values.get(ref)
|
|
if (stored !== undefined) return Promise.resolve({ configured: true, source: 'file', writable: true })
|
|
const fallback = this.dotenvFallback(ref)
|
|
if (fallback !== undefined) return Promise.resolve({ configured: true, source: fallback.source, writable: true })
|
|
return Promise.resolve({ configured: false, writable: true })
|
|
}
|
|
|
|
override async set(ref: CredentialRef, value: string): Promise<void> {
|
|
if (value.length === 0) {
|
|
throw new Error(`credentials-local: an empty value cannot be stored for "${ref}"; use unset`)
|
|
}
|
|
await this.write(ref, value)
|
|
}
|
|
|
|
override async unset(ref: CredentialRef): Promise<void> {
|
|
await this.write(ref, undefined)
|
|
}
|
|
|
|
/* jscpd:ignore-start -- the operation-chain and reload lifecycle is the same
|
|
reviewed contract as settings-local, deliberately mirrored (prefer symmetry
|
|
for parallel values); the two providers own different documents and
|
|
failure policies, so extracting the shape would couple their teardown
|
|
semantics across packages for a handful of lines. */
|
|
/** Queue one exclusive document operation behind every earlier one. */
|
|
private enqueue<T>(operation: () => Promise<T>): Promise<T> {
|
|
const task = this.operations.then(operation)
|
|
this.operations = task.then(() => undefined, () => undefined)
|
|
return task
|
|
}
|
|
|
|
/** Queue a reload; only an invariant violation escaping the fan-out can reject it. */
|
|
private queueRefresh(): void {
|
|
void this.enqueue(() => this.refresh()).catch((error: unknown) => {
|
|
// Only an invariant violation escaping the update fan-out can reject a
|
|
// refresh; keep the operation queue alive and surface it as an error so
|
|
// one poisoned commit cannot silently end hot reloading forever.
|
|
this.ctx.logger.error('credentials-local: reload commit failed at %s', this.spec.filename)
|
|
this.ctx.logger.error(error)
|
|
})
|
|
}
|
|
/* jscpd:ignore-end */
|
|
|
|
/** Queue one line edit; entry checks reject early, the queue re-judges them at run time. */
|
|
private async write(ref: CredentialRef, value: string | undefined): Promise<void> {
|
|
const verb = value === undefined ? 'unset' : 'set'
|
|
if (this.isClosed()) {
|
|
throw new Error(`credentials-local is disposed: cannot ${verb} "${ref}"`)
|
|
}
|
|
this.assertUnshadowed(ref, verb)
|
|
return this.enqueue(async () => {
|
|
if (this.isClosed()) {
|
|
throw new Error(`credentials-local was disposed before the queued "${ref}" ${verb} ran`)
|
|
}
|
|
// Re-judged at run time: the environment may have changed while queued.
|
|
this.assertUnshadowed(ref, verb)
|
|
// The writer lock's exclusive create needs the parent to exist; 0700
|
|
// because the harness home holds user-private data.
|
|
await mkdir(dirname(this.spec.filename), { recursive: true, mode: 0o700 })
|
|
await withFileLock(this.spec.filename, async () => {
|
|
// Read-modify-write: fold in any on-disk state this process has not
|
|
// observed yet — an external edit still inside the watcher debounce
|
|
// window, a change the watcher missed, or another process's write —
|
|
// so the line edit below can never resurrect a stale document.
|
|
await this.reconcileFromDisk()
|
|
const existing = this.values.get(ref)
|
|
if (value === undefined && existing === undefined) return
|
|
const nextText = renderDocument(this.text, ref, value)
|
|
// 0600: a document holding secrets is never world-readable.
|
|
await writeFileAtomic(this.spec.filename, nextText, { mode: 0o600, dirMode: 0o700 })
|
|
this.text = nextText
|
|
if (value === undefined) this.values.delete(ref)
|
|
else this.values.set(ref, value)
|
|
// After the commit: a broken observer must never make the durable
|
|
// write look failed (an INVARIANT failure still rethrows).
|
|
this.notifyUpdated(ref)
|
|
})
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Reject a write the inherited environment would shadow into apparent
|
|
* no-effect. Only that layer can shadow a write: everything else this
|
|
* provider resolves ranks below the document being written.
|
|
*/
|
|
private assertUnshadowed(ref: CredentialRef, verb: 'set' | 'unset'): void {
|
|
if (this.inherited(ref) !== undefined) {
|
|
throw new Error(
|
|
`credentials-local: "${ref}" is supplied read-only by the launching environment, so ${verb} would be`
|
|
+ ' shadowed; unset it in the shell you start dsh from instead',
|
|
)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Boot read: an absent file is an empty store; an invalid one fails the
|
|
* plugin's activation, because a credentials document that exists but
|
|
* cannot be trusted must never be treated as "no credentials stored".
|
|
*/
|
|
private async loadInitial(): Promise<void> {
|
|
await assertOwnerOnly(this.spec.filename)
|
|
let text: string
|
|
try {
|
|
text = await readFile(this.spec.filename, 'utf8')
|
|
} catch (error) {
|
|
if (!isENOENT(error)) throw error
|
|
return
|
|
}
|
|
this.values = parseCredentialsDocument(text, this.spec.filename)
|
|
this.text = text
|
|
}
|
|
|
|
/* jscpd:ignore-start -- same deliberate mirror of settings-local's reload and
|
|
reconcile policy: warn-and-keep on a reload, throw on a write, invariant
|
|
failures propagate. */
|
|
/**
|
|
* Re-read the document after a watcher event. Unchanged content (including
|
|
* this provider's own writes) is a no-op; an unreadable document keeps the
|
|
* last good snapshot and warns — a live hot-reload must never take the
|
|
* process down. An invariant violation escaping the fan-out is not a reload
|
|
* failure and propagates to the queue's error surface.
|
|
*/
|
|
private async refresh(): Promise<void> {
|
|
if (this.closed) return
|
|
try {
|
|
await this.reconcileFromDisk()
|
|
} catch (error) {
|
|
if ((error as { code?: unknown } | null)?.code === 'INVARIANT') throw error
|
|
this.ctx.logger.warn('credentials-local: reload failed at %s; keeping the last good document', this.spec.filename)
|
|
this.ctx.logger.warn(error)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Compare the on-disk text against the cache and publish any difference
|
|
* into the seam. Absence publishes the empty store; an unreadable or
|
|
* invalid document throws, so each caller picks its policy — a reload warns
|
|
* and keeps the last good snapshot, a write fails loud rather than
|
|
* overwriting a document it could not understand.
|
|
*/
|
|
private async reconcileFromDisk(): Promise<void> {
|
|
// Re-checked on every reload and before every write: an external editor or
|
|
// a restored backup can loosen the mode after boot.
|
|
await assertOwnerOnly(this.spec.filename)
|
|
let text: string | undefined
|
|
try {
|
|
text = await readFile(this.spec.filename, 'utf8')
|
|
} catch (error) {
|
|
if (!isENOENT(error)) throw error
|
|
text = undefined
|
|
}
|
|
if (text === this.text || this.isClosed()) return
|
|
const next = text === undefined ? new Map<string, string>() : parseCredentialsDocument(text, this.spec.filename)
|
|
const changed = this.changedRefs(this.values, next)
|
|
this.text = text
|
|
this.values = next
|
|
for (const ref of changed) this.notifyUpdated(ref)
|
|
}
|
|
/* jscpd:ignore-end */
|
|
|
|
/** Entries whose stored value changed; the parser has already proven every key addressable. */
|
|
private changedRefs(prev: Map<string, string>, next: Map<string, string>): CredentialRef[] {
|
|
const changed: CredentialRef[] = []
|
|
for (const key of new Set([...prev.keys(), ...next.keys()])) {
|
|
if (prev.get(key) === next.get(key)) continue
|
|
changed.push(credentialRef(key))
|
|
}
|
|
return changed
|
|
}
|
|
}
|
|
|
|
export default CredentialsLocal
|