feat(credentials): file-backed provider layering process env over $DSH_HOME/.env

Live environment wins read-only (shadowed writes reject instead of
appearing to succeed); the file is the writable source with byte-preserving
line edits, a quoting ladder dotenv reads back verbatim, atomic 0600
writes, wholesale snapshot replacement on reload, and write-drain
teardown.
This commit is contained in:
Yichen Jiang
2026-07-29 13:18:28 +08:00
parent 3a794495ad
commit aee06097ee
11 changed files with 1089 additions and 0 deletions
@@ -0,0 +1,46 @@
# dsh-credentials-local
English | [中文](README.zh.md)
File-backed [credentials](../credentials/README.md) provider: two layers, one honest precedence.
| Layer | Source id | Writable | Wins |
|---|---|---|---|
| Live process environment | `env` | no | always |
| `$DSH_HOME/.env` document | `file` | yes (`set`/`unset`) | otherwise |
The environment wins because a launch-time override (`DEEPSEEK_API_KEY=… dsh`, CI secrets, a dev shell sourcing the repo `.env`) is operator intent for this run — and because it cannot be edited from inside, it must be *visibly* read-only: `describe()` reports `source: 'env', writable: false`, and `set`/`unset` reject instead of writing a change the reader would never see. Resolution reads `process.env` live and never writes it back.
## Config
| Field | Default | Meaning |
|---|---|---|
| `path` | `<harness home>/.env` | Credentials document location. |
| `dshHome` | `$DSH_HOME` or `~/.dsh` | Harness home used when `path` is omitted. |
| `watch` | `true` | Hot-publish external edits. |
| `debounceMs` | `100` | Watcher write-settle window. |
## The document
dotenv format, parsed with `dotenv` and edited by a line editor that preserves every byte it does not own: `set` rewrites the first assignment of its key in place (dropping later duplicates, which dotenv's last-wins reading would otherwise let override the edit), `unset` removes only the owning line, comments and unrelated lines survive verbatim. Writes go through [`dsh-atomic-write`](../../util/atomic-write/README.md) with mode `0600`.
Values are rendered in the narrowest style dotenv reads back verbatim — bare, then single-quoted (fully literal), then double-quoted (only without backslashes, which double-quote reading expands). A value no style can represent, and any entry that already spans multiple physical lines, fails loud instead of being corrupted silently. An empty stored value is absent, per the seam rule.
## Hot reload
External edits publish `credentials/updated` per changed reference after the snapshot is replaced **wholesale** — an entry deleted on disk never lingers in memory. The provider's own writes are recognized by content and publish exactly their one commit event. An unreadable document at runtime keeps the last good snapshot and warns; an absent file is an empty store; an unreadable file at boot fails loud. Keys that are not POSIX identifiers are preserved file content the seam cannot address.
## Model Experience
Indirectly: resolved values authorize LLM adapter requests; the consuming adapter owns every model-visible surface.
#### KV Cache effect
No direct invalidation; credentials never enter a request prefix.
## Known Limitations and Deferred Work
- **Multi-line entries refuse `set`/`unset`** — the line editor will not rewrite an entry it would corrupt; edit the file directly.
- **Unrepresentable values fail loud** — control characters, or a mix of both quote styles with backslashes, cannot round-trip the dotenv line format.
- **Environment changes are invisible** — `process.env` is read live per resolution, but no event can announce a change there.
- **Atomic, not crash-durable** — inherited from `dsh-atomic-write`; the store re-reads on boot.
@@ -0,0 +1,46 @@
# dsh-credentials-local
[English](README.md) | 中文
文件型[凭据](../credentials/README.zh.md) provider:两层来源,一条诚实的优先级。
| 层 | 来源 id | 可写 | 优先 |
|---|---|---|---|
| 活跃进程环境 | `env` | 否 | 恒定优先 |
| `$DSH_HOME/.env` 文档 | `file` | 是(`set`/`unset` | 其余情况 |
环境优先,因为启动时注入(`DEEPSEEK_API_KEY=… dsh`、CI secrets、加载了仓库 `.env` 的开发 shell)代表本次运行的操作者意图——而它无法从进程内部修改,就必须**可见地**只读:`describe()` 报告 `source: 'env', writable: false``set`/`unset` 直接拒绝,而不是写下一个读取方永远看不到的变更。解析实时读取 `process.env`,绝不写回。
## 配置
| 字段 | 默认值 | 含义 |
|---|---|---|
| `path` | `<harness home>/.env` | 凭据文档位置。 |
| `dshHome` | `$DSH_HOME``~/.dsh` | `path` 缺省时使用的 harness home。 |
| `watch` | `true` | 热发布外部编辑。 |
| `debounceMs` | `100` | watcher 写入沉降窗口。 |
## 文档本身
dotenv 格式,用 `dotenv` 解析;写回用行级编辑器,保留一切不属于本次编辑的字节:`set` 原位改写该键的第一条赋值行(丢弃后续重复行——dotenv 按最后一条生效,重复行会反过来覆盖这次编辑),`unset` 只删除所属行,注释与无关行逐字保留。落盘经 [`dsh-atomic-write`](../../util/atomic-write/README.zh.md),权限 `0600`
值按 dotenv 能逐字读回的最窄样式渲染——裸值,其次单引号(完全字面),再次双引号(仅限无反斜杠,双引号读取会展开转义)。任何样式都无法表示的值、以及已经跨越多个物理行的条目,响亮失败而不是被静默破坏。空的存储值等于不存在(seam 规则)。
## 热重载
外部编辑在快照**整体替换**后按变更引用逐个发布 `credentials/updated`——磁盘上删掉的条目绝不在内存滞留。provider 自己的写入按内容识别,只发布属于该次提交的一个事件。运行期文档不可读时保留最后一份好快照并告警;文件不存在即空存储;启动时不可读则响亮失败。非 POSIX 标识符的键属于被保留的文件内容,seam 无法寻址。
## Model Experience
Indirectly: resolved values authorize LLM adapter requests; the consuming adapter owns every model-visible surface.
#### KV Cache effect
No direct invalidation; credentials never enter a request prefix.
## Known Limitations and Deferred Work
- **多行条目拒绝 `set`/`unset`**——行编辑器不改写会被它破坏的条目;请直接编辑文件。
- **无法表示的值响亮失败**——控制字符,或同时混用两种引号又含反斜杠的值,无法在 dotenv 行格式中往返。
- **环境变化不可见**——每次解析实时读取 `process.env`,但那里的变化不可能发出事件。
- **原子但不保证崩溃持久**——继承自 `dsh-atomic-write`;存储在启动时重新读取。
@@ -0,0 +1,48 @@
{
"name": "@deepseek-ai/dsh-credentials-local",
"description": "File-backed credentials provider ($DSH_HOME/.env under the live process environment) for the DeepSeek Harness",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-atomic-write": "^0.0.1",
"@deepseek-ai/dsh-credentials": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-paths": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"dependencies": {
"chokidar": "^4.0.3",
"dotenv": "^17.2.0",
"schemastery": "^3.18.0"
},
"devDependencies": {
"@deepseek-ai/dsh-atomic-write": "workspace:^",
"@deepseek-ai/dsh-credentials": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-paths": "workspace:^",
"cordis": "^4.0.0-rc.7"
}
}
@@ -0,0 +1,332 @@
/**
* File-backed credentials provider layering the live process environment over
* a `$DSH_HOME/.env` document. The environment is authoritative and read-only
* (a launch-time override must win, and must be visibly read-only rather than
* silently shadow writes); the file is the provider-managed writable source:
* `set`/`unset` rewrite only their own line and preserve every other byte,
* external edits hot-publish through the seam, and each reload replaces the
* snapshot wholesale so a deleted entry never lingers in memory.
* @module @deepseek-ai/dsh-credentials-local
*/
import { Context, Service } from 'cordis'
import z from 'schemastery'
import { watch as chokidarWatch } from 'chokidar'
import { readFile } from 'node:fs/promises'
import { join, resolve } from 'node:path'
import { parse } from 'dotenv'
import { writeFileAtomic } from '@deepseek-ai/dsh-atomic-write'
import { resolveDshHome } from '@deepseek-ai/dsh-paths'
import { Credentials, credentialRef } from '@deepseek-ai/dsh-credentials'
import type { CredentialInfo, CredentialRef, ResolvedCredential } from '@deepseek-ai/dsh-credentials'
/** Plugin config: file location and hot-reload behavior. */
export interface Config {
/** Credentials document path; defaults to `.env` 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>/.env`.
* @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), '.env')),
watch: config.watch ?? true,
debounceMs: config.debounceMs ?? 100,
}
}
/** 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'
}
/** Match the physical line(s) assigning one reference (ref chars need no escaping). */
function refLinePattern(ref: CredentialRef): RegExp {
return new RegExp(`^\\s*(?:export\\s+)?${ref}\\s*=`)
}
/** Values that survive a dotenv round-trip without quoting. */
const BARE_VALUE = /^[A-Za-z0-9_@%+:,./-]+$/
/** Whether a value contains C0 control characters (newlines included) no dotenv style reads back. */
function hasControlCharacters(value: string): boolean {
for (const char of value) {
if (char.charCodeAt(0) < 0x20) return true
}
return false
}
/**
* Render one `KEY=value` line in the narrowest style dotenv reads back
* verbatim: bare, then single quotes (fully literal), then double quotes
* (safe only without backslashes, which double-quote reading expands).
* A value no style can represent fails loud instead of corrupting silently.
*/
function renderLine(ref: CredentialRef, value: string): string {
if (BARE_VALUE.test(value)) return `${ref}=${value}`
if (hasControlCharacters(value)) {
throw new Error(`credentials-local: the value for "${ref}" contains control characters the .env line format cannot represent`)
}
if (!value.includes('\'')) return `${ref}='${value}'`
if (!value.includes('"') && !value.includes('\\')) return `${ref}="${value}"`
throw new Error(`credentials-local: the value for "${ref}" mixes quoting no .env style can represent; edit the file directly`)
}
/**
* Replace, insert, or delete one reference's assignment while preserving every
* other byte. The first matching line is rewritten in place; further matches
* are dropped (dotenv reads the last one, so duplicates are dead weight that
* would otherwise override the edit).
*/
function upsertLine(text: string | undefined, ref: CredentialRef, line: string | undefined): string {
const lines = text === undefined || text.length === 0 ? [] : text.split('\n')
if (lines.length > 0 && lines[lines.length - 1] === '') lines.pop()
const matcher = refLinePattern(ref)
const out: string[] = []
let placed = false
for (const current of lines) {
if (matcher.test(current)) {
if (line !== undefined && !placed) {
out.push(line)
placed = true
}
continue
}
out.push(current)
}
if (line !== undefined && !placed) out.push(line)
return out.length === 0 ? '' : `${out.join('\n')}\n`
}
/** File-backed credentials provider (`$DSH_HOME/.env`). */
export class CredentialsLocal extends Credentials {
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>()
/** Serializes watcher-triggered reloads so reads never interleave. */
private refreshTask: Promise<void> = Promise.resolve()
/** Serializes writes to the one document; settled tail. */
private writeChain: Promise<unknown> = 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
}
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)
}
async* [Service.init](): AsyncGenerator<() => Promise<void> | void, void, void> {
yield async () => {
// Drain: refuse new writes, then settle the queued ones so disposal
// completes only once storage is quiescent.
this.closed = true
await this.writeChain
}
await this.loadInitial()
if (!this.spec.watch) return
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.refreshTask = this.refreshTask.then(() => this.refresh()).catch((error: unknown) => {
// Only an invariant violation escaping the update fan-out can reject a
// refresh; keep the reload 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)
})
})
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 refresh so nothing publishes after disposal.
this.closed = true
await watcher.close()
await this.refreshTask
}
}
override resolve(ref: CredentialRef): Promise<ResolvedCredential | undefined> {
const env = process.env[ref]
if (env !== undefined && env.length > 0) return Promise.resolve({ value: env, source: 'env' })
const stored = this.values.get(ref)
if (stored !== undefined && stored.length > 0) return Promise.resolve({ value: stored, source: 'file' })
return Promise.resolve(undefined)
}
override describe(ref: CredentialRef): Promise<CredentialInfo> {
const env = process.env[ref]
if (env !== undefined && env.length > 0) {
return Promise.resolve({ configured: true, source: 'env', writable: false })
}
const stored = this.values.get(ref)
if (stored !== undefined && stored.length > 0) {
return Promise.resolve({ configured: true, source: 'file', 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)
}
/** 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)
// The stored tail is settled on both outcomes, so chaining needs no catch
// and one rejected write can never poison the queue for later callers.
const previous = this.writeChain
const run = previous.then(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)
const existing = this.values.get(ref)
if (value === undefined && existing === undefined) return
if (existing !== undefined && existing.includes('\n')) {
throw new Error(
`credentials-local: "${ref}" is a multi-line entry this line editor would corrupt; edit ${this.spec.filename} directly`,
)
}
const nextText = upsertLine(this.text, ref, value === undefined ? undefined : renderLine(ref, value))
// 0600: a document holding secrets is never world-readable.
await writeFileAtomic(this.spec.filename, nextText, { mode: 0o600 })
this.text = nextText
if (value === undefined) this.values.delete(ref)
else this.values.set(ref, value)
this.ctx.emit('credentials/updated', ref)
})
this.writeChain = run.then(() => undefined, () => undefined)
return run
}
/** Reject a write the live environment would shadow into apparent no-effect. */
private assertUnshadowed(ref: CredentialRef, verb: 'set' | 'unset'): void {
const env = process.env[ref]
if (env !== undefined && env.length > 0) {
throw new Error(
`credentials-local: "${ref}" is supplied read-only by the process environment, so ${verb} would be`
+ ' shadowed; change the launching environment instead',
)
}
}
/** Boot read: an absent file is an empty store; any other failure is loud. */
private async loadInitial(): Promise<void> {
let text: string
try {
text = await readFile(this.spec.filename, 'utf8')
} catch (error) {
if (!isENOENT(error)) throw error
return
}
this.text = text
this.values = new Map(Object.entries(parse(text)))
}
/**
* 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. dotenv parsing is lenient by design and cannot fail.
*/
private async refresh(): Promise<void> {
if (this.closed) return
let text: string | undefined
try {
text = await readFile(this.spec.filename, 'utf8')
} catch (error) {
if (!isENOENT(error)) {
this.ctx.logger.warn('credentials-local: reload failed at %s; keeping the last good document', this.spec.filename)
this.ctx.logger.warn(error)
return
}
text = undefined
}
if (text === this.text || this.isClosed()) return
const next = text === undefined ? new Map<string, string>() : new Map(Object.entries(parse(text)))
const changed = this.changedRefs(this.values, next)
this.text = text
this.values = next
for (const ref of changed) this.ctx.emit('credentials/updated', ref)
}
/** Seam-addressable entries whose effective (non-empty) value changed. */
private changedRefs(prev: Map<string, string>, next: Map<string, string>): CredentialRef[] {
const changed: CredentialRef[] = []
for (const key of new Set([...prev.keys(), ...next.keys()])) {
const before = prev.get(key)
const after = next.get(key)
const effectiveBefore = before !== undefined && before.length > 0 ? before : undefined
const effectiveAfter = after !== undefined && after.length > 0 ? after : undefined
if (effectiveBefore === effectiveAfter) continue
try {
changed.push(credentialRef(key))
} catch (_unaddressableKey) {
// A key that is not a POSIX identifier is preserved file content the
// seam cannot address, so no observer could ever see it change.
}
}
return changed
}
}
export default CredentialsLocal
@@ -0,0 +1,31 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-credentials-local`.
* @module @deepseek-ai/dsh-credentials-local/invariant
*/
/* jscpd:ignore-start */
import type { Context } from 'cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-credentials-local'
/** Cordis companion plugin name. */
export const name = 'credentials-local-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/**
* No runtime invariant: the seam companion (`dsh-credentials/invariant`) owns the
* `credentials/updated` lifecycle contract; this provider's file/environment layering is
* asynchronous I/O pinned by its unit suite.
*/
const install: InvariantInstaller = () => {}
/**
* Register this package's invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns the installed registration's disposer after setup succeeds.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
/* jscpd:ignore-end */
@@ -0,0 +1,67 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { credentialRef } from '@deepseek-ai/dsh-credentials'
import { CredentialsLocal } from '../src/index.ts'
// The atomic write is the only asynchronous hold point inside a queued write;
// gating it makes the dispose-versus-queued-write race fully deterministic.
vi.mock('@deepseek-ai/dsh-atomic-write', () => {
let gate: Promise<void> = Promise.resolve()
return {
writeFileAtomic: vi.fn(() => gate),
__setGate: (next: Promise<void>) => {
gate = next
},
}
})
async function setGate(next: Promise<void>): Promise<void> {
const mocked = await import('@deepseek-ai/dsh-atomic-write') as unknown as { __setGate: (next: Promise<void>) => void }
mocked.__setGate(next)
}
const KEY = credentialRef('DSH_CRED_DRAIN_A')
const OTHER = credentialRef('DSH_CRED_DRAIN_B')
const cleanups: Array<() => Promise<void>> = []
afterEach(async () => {
await setGate(Promise.resolve())
while (cleanups.length > 0) await cleanups.pop()!()
})
describe('write-drain teardown', () => {
it('lets the in-flight write land and fails the queued one after disposal', async () => {
const dir = await mkdtemp(join(tmpdir(), 'dsh-credentials-drain-'))
cleanups.push(() => rm(dir, { recursive: true, force: true }))
const ctx = new Context()
const fiber = ctx.plugin(CredentialsLocal, { path: join(dir, '.env'), watch: false })
await fiber
const service = ctx.credentials
let release!: () => void
await setGate(new Promise<void>((resolveGate) => {
release = resolveGate
}))
const first = service.set(KEY, 'one')
// Let the first task pass its liveness checks and park on the gate, so it
// is genuinely in-flight when disposal begins.
await new Promise(resolvePause => setTimeout(resolvePause, 5))
// Attach the rejection handler up front: the queued write fails while the
// drain is still awaited, before any later `await expect` could run.
const secondRejects = expect(service.set(OTHER, 'two')).rejects.toThrow(/disposed before the queued/)
const disposal = fiber.dispose()
// Give the drain disposer its first turn (set closed) before opening the gate.
await new Promise(resolvePause => setTimeout(resolvePause, 10))
release()
await disposal
await expect(first).resolves.toBeUndefined()
await secondRejects
expect(await service.resolve(KEY)).toEqual({ value: 'one', source: 'file' })
expect(await service.resolve(OTHER)).toBeUndefined()
})
})
@@ -0,0 +1,244 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { credentialRef } from '@deepseek-ai/dsh-credentials'
import type { CredentialRef } from '@deepseek-ai/dsh-credentials'
import { CredentialsLocal, resolveSpec } from '../src/index.ts'
const KEY = credentialRef('DSH_CRED_TEST')
const OTHER = credentialRef('DSH_CRED_OTHER')
const cleanups: Array<() => Promise<void>> = []
afterEach(async () => {
vi.unstubAllEnvs()
while (cleanups.length > 0) await cleanups.pop()!()
})
async function tempDir(): Promise<string> {
const dir = await mkdtemp(join(tmpdir(), 'dsh-credentials-local-'))
cleanups.push(() => rm(dir, { recursive: true, force: true }))
return dir
}
async function boot(config: ConstructorParameters<typeof CredentialsLocal>[1]): Promise<Context> {
const ctx = new Context()
const fiber = ctx.plugin(CredentialsLocal, config)
cleanups.push(async () => {
await fiber.dispose()
})
await fiber
return ctx
}
function updates(ctx: Context): CredentialRef[] {
const seen: CredentialRef[] = []
ctx.on('credentials/updated', (ref) => {
seen.push(ref)
})
return seen
}
describe('resolveSpec', () => {
it('defaults to .env under the harness home with watching on', () => {
const spec = resolveSpec({ dshHome: '/custom/home' })
expect(spec).toEqual({ filename: resolve('/custom/home/.env'), watch: true, debounceMs: 100 })
})
it('lets an explicit path win over the home', () => {
const spec = resolveSpec({ path: '/etc/dsh/creds.env', dshHome: '/ignored', watch: false, debounceMs: 5 })
expect(spec).toEqual({ filename: resolve('/etc/dsh/creds.env'), watch: false, debounceMs: 5 })
})
})
describe('layering and reads', () => {
it('treats an absent file as an empty writable store', async () => {
const dir = await tempDir()
const ctx = await boot({ path: join(dir, '.env'), watch: false })
expect(await ctx.credentials.resolve(KEY)).toBeUndefined()
expect(await ctx.credentials.describe(KEY)).toEqual({ configured: false, writable: true })
})
it('serves file entries, including export-prefixed and quoted values', async () => {
const dir = await tempDir()
const path = join(dir, '.env')
await writeFile(path, '# notes\nexport DSH_CRED_TEST=plain\nDSH_CRED_OTHER="with space"\n')
const ctx = await boot({ path, watch: false })
expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'plain', source: 'file' })
expect(await ctx.credentials.resolve(OTHER)).toEqual({ value: 'with space', source: 'file' })
expect(await ctx.credentials.describe(KEY)).toEqual({ configured: true, source: 'file', writable: true })
})
it('lets a non-empty process environment win read-only over the file', async () => {
const dir = await tempDir()
const path = join(dir, '.env')
await writeFile(path, 'DSH_CRED_TEST=from-file\n')
const ctx = await boot({ path, watch: false })
vi.stubEnv('DSH_CRED_TEST', 'from-env')
expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'from-env', source: 'env' })
expect(await ctx.credentials.describe(KEY)).toEqual({ configured: true, source: 'env', writable: false })
})
it('treats empty values as absent in both layers', async () => {
const dir = await tempDir()
const path = join(dir, '.env')
await writeFile(path, 'DSH_CRED_TEST=\n')
const ctx = await boot({ path, watch: false })
vi.stubEnv('DSH_CRED_TEST', '')
expect(await ctx.credentials.resolve(KEY)).toBeUndefined()
expect(await ctx.credentials.describe(KEY)).toEqual({ configured: false, writable: true })
})
it('fails boot loud when the document exists but cannot be read', async () => {
const dir = await tempDir()
const path = join(dir, 'occupied')
await mkdir(path)
const ctx = new Context()
await expect(ctx.plugin(CredentialsLocal, { path, watch: false })).rejects.toThrow()
})
})
describe('line-editing writes', () => {
it('appends a missing key to a fresh 0600 document and emits the commit', async () => {
const dir = await tempDir()
const path = join(dir, '.env')
const ctx = await boot({ path, watch: false })
const seen = updates(ctx)
await ctx.credentials.set(KEY, 'sk-fresh')
expect(await readFile(path, 'utf8')).toBe('DSH_CRED_TEST=sk-fresh\n')
expect((await stat(path)).mode & 0o777).toBe(0o600)
expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'sk-fresh', source: 'file' })
expect(seen).toEqual([KEY])
})
it('rewrites one line in place, preserving every other byte and dropping duplicates', async () => {
const dir = await tempDir()
const path = join(dir, '.env')
await writeFile(path, '# deployment notes\nFIRST=one\n\nDSH_CRED_TEST=old\nTRAILING=x\nDSH_CRED_TEST=older')
const ctx = await boot({ path, watch: false })
await ctx.credentials.set(KEY, 'new value!')
expect(await readFile(path, 'utf8')).toBe('# deployment notes\nFIRST=one\n\nDSH_CRED_TEST=\'new value!\'\nTRAILING=x\n')
})
it('quotes hostile values so they round-trip through a fresh provider', async () => {
const dir = await tempDir()
const path = join(dir, '.env')
const ctx = await boot({ path, watch: false })
const singleQuoted = 'with "quote", back\\slash and space'
const doubleQuoted = "it's got an apostrophe"
await ctx.credentials.set(KEY, singleQuoted)
await ctx.credentials.set(OTHER, doubleQuoted)
const reread = await boot({ path, watch: false })
expect(await reread.credentials.resolve(KEY)).toEqual({ value: singleQuoted, source: 'file' })
expect(await reread.credentials.resolve(OTHER)).toEqual({ value: doubleQuoted, source: 'file' })
})
it('fails loud on values no .env quoting style reads back verbatim', async () => {
const dir = await tempDir()
const ctx = await boot({ path: join(dir, '.env'), watch: false })
await expect(ctx.credentials.set(KEY, 'line one\nline two')).rejects.toThrow(/control characters/)
await expect(ctx.credentials.set(KEY, 'both \' and "')).rejects.toThrow(/mixes quoting/)
})
it('unsets only the owning line and keeps an absent unset silent', async () => {
const dir = await tempDir()
const path = join(dir, '.env')
await writeFile(path, '# keep\nDSH_CRED_TEST=gone\nDSH_CRED_OTHER=stays\n')
const ctx = await boot({ path, watch: false })
const seen = updates(ctx)
await ctx.credentials.unset(KEY)
expect(await readFile(path, 'utf8')).toBe('# keep\nDSH_CRED_OTHER=stays\n')
await ctx.credentials.unset(KEY)
expect(seen).toEqual([KEY])
})
it('rejects empty values, shadowed writes, and multi-line entries', async () => {
const dir = await tempDir()
const path = join(dir, '.env')
await writeFile(path, 'DSH_CRED_TEST="line one\nline two"\n')
const ctx = await boot({ path, watch: false })
await expect(ctx.credentials.set(KEY, '')).rejects.toThrow(/empty value/)
await expect(ctx.credentials.set(KEY, 'next')).rejects.toThrow(/multi-line/)
await expect(ctx.credentials.unset(KEY)).rejects.toThrow(/multi-line/)
vi.stubEnv('DSH_CRED_TEST', 'shadowing')
await expect(ctx.credentials.set(KEY, 'next')).rejects.toThrow(/shadowed/)
await expect(ctx.credentials.unset(KEY)).rejects.toThrow(/shadowed/)
})
it('leaves an empty document after unsetting the only entry', async () => {
const dir = await tempDir()
const path = join(dir, '.env')
await writeFile(path, 'DSH_CRED_TEST=only\n')
const ctx = await boot({ path, watch: false })
await ctx.credentials.unset(KEY)
expect(await readFile(path, 'utf8')).toBe('')
})
it('chains past a rejected write so one bad value cannot poison the queue', async () => {
const dir = await tempDir()
const path = join(dir, '.env')
const ctx = await boot({ path, watch: false })
const bad = expect(ctx.credentials.set(KEY, 'both \' and "')).rejects.toThrow(/mixes quoting/)
const good = ctx.credentials.set(OTHER, 'lands')
await bad
await good
expect(await readFile(path, 'utf8')).toBe('DSH_CRED_OTHER=lands\n')
})
it('serializes concurrent writes so both land in the one document', async () => {
const dir = await tempDir()
const path = join(dir, '.env')
const ctx = await boot({ path, watch: false })
await Promise.all([
ctx.credentials.set(KEY, 'one'),
ctx.credentials.set(OTHER, 'two'),
])
expect(await readFile(path, 'utf8')).toBe('DSH_CRED_TEST=one\nDSH_CRED_OTHER=two\n')
})
it('refuses writes after disposal', async () => {
const dir = await tempDir()
const ctx = new Context()
const fiber = ctx.plugin(CredentialsLocal, { path: join(dir, '.env'), watch: false })
await fiber
// Capture the handle first: disposal also removes the ctx.credentials service.
const service = ctx.credentials
await fiber.dispose()
await expect(service.set(KEY, 'late')).rejects.toThrow(/disposed/)
})
})
describe('real hot reload', () => {
it('publishes external edits, replaces the snapshot wholesale, and suppresses self-writes', async () => {
const dir = await tempDir()
const path = join(dir, '.env')
// Watching starts on an existing document: creation racing watcher setup
// is a chokidar readiness gap, not the reload contract under test.
await writeFile(path, 'DSH_CRED_TEST=boot\n')
const ctx = await boot({ path, debounceMs: 10 })
const seen = updates(ctx)
await writeFile(path, 'DSH_CRED_TEST=live\nDSH_CRED_OTHER=extra\n')
await vi.waitFor(async () => {
expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'live', source: 'file' })
})
// Wholesale replacement: an entry deleted on disk never lingers in memory.
await writeFile(path, 'DSH_CRED_TEST=live\n')
await vi.waitFor(async () => {
expect(await ctx.credentials.resolve(OTHER)).toBeUndefined()
})
const before = seen.length
await ctx.credentials.set(KEY, 'self-written')
await new Promise(resolvePause => setTimeout(resolvePause, 200))
// Exactly the committed write's own event: the watcher echo of our own
// content is recognized by the text cache and publishes nothing extra.
expect(seen.length).toBe(before + 1)
expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'self-written', source: 'file' })
})
})
@@ -0,0 +1,207 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import { chmod, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { credentialRef } from '@deepseek-ai/dsh-credentials'
import { CredentialsLocal } from '../src/index.ts'
// chokidar is the nondeterministic OS boundary: faking it lets these tests
// drive the event pipeline (error events, races with unreadable files)
// deterministically. Real end-to-end watching stays covered by local.spec.ts.
vi.mock('chokidar', async () => {
const { EventEmitter } = await import('node:events')
class FakeWatcher extends EventEmitter {
close = vi.fn(() => Promise.resolve())
}
const instances: Array<{ path: string; options: unknown; watcher: InstanceType<typeof FakeWatcher> }> = []
return {
watch: vi.fn((path: string, options: unknown) => {
const watcher = new FakeWatcher()
instances.push({ path, options, watcher })
return watcher
}),
__instances: instances,
}
})
interface FakeChokidar {
__instances: Array<{
path: string
options: { awaitWriteFinish: { stabilityThreshold: number; pollInterval: number } }
watcher: import('node:events').EventEmitter
}>
}
async function fakeInstances(): Promise<FakeChokidar['__instances']> {
const chokidar = await import('chokidar') as unknown as FakeChokidar
return chokidar.__instances
}
const KEY = credentialRef('DSH_CRED_PIPE')
const cleanups: Array<() => Promise<void>> = []
afterEach(async () => {
while (cleanups.length > 0) await cleanups.pop()!()
;(await fakeInstances()).length = 0
})
async function tempDir(): Promise<string> {
const dir = await mkdtemp(join(tmpdir(), 'dsh-credentials-watch-'))
cleanups.push(() => rm(dir, { recursive: true, force: true }))
return dir
}
async function boot(config: ConstructorParameters<typeof CredentialsLocal>[1]): Promise<Context> {
const ctx = new Context()
const fiber = ctx.plugin(CredentialsLocal, config)
cleanups.push(async () => {
await fiber.dispose()
})
await fiber
return ctx
}
describe('watcher pipeline', () => {
it('clamps the write-settle poll interval for a zero debounce', async () => {
const dir = await tempDir()
await boot({ path: join(dir, '.env'), debounceMs: 0 })
const [instance] = await fakeInstances()
expect(instance!.options.awaitWriteFinish).toEqual({ stabilityThreshold: 0, pollInterval: 1 })
})
it('survives a watcher error and keeps publishing later edits', async () => {
const dir = await tempDir()
const path = join(dir, '.env')
const ctx = await boot({ path, debounceMs: 5 })
const [instance] = await fakeInstances()
instance!.watcher.emit('error', new Error('watch backend failure'))
expect(await ctx.credentials.resolve(KEY)).toBeUndefined()
await writeFile(path, 'DSH_CRED_PIPE=arrived\n')
instance!.watcher.emit('all', 'change', path)
await vi.waitFor(async () => {
expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'arrived', source: 'file' })
})
})
it('keeps the last good snapshot when the file turns unreadable at runtime', async () => {
const dir = await tempDir()
const path = join(dir, '.env')
await writeFile(path, 'DSH_CRED_PIPE=good\n')
const ctx = await boot({ path, debounceMs: 5 })
await chmod(path, 0o000)
cleanups.push(() => chmod(path, 0o600))
const [instance] = await fakeInstances()
instance!.watcher.emit('all', 'change', path)
// The warn-and-keep path is asynchronous; give the serialized refresh a turn.
await new Promise(resolve => setTimeout(resolve, 50))
expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'good', source: 'file' })
})
it('keeps the reload queue alive after an invariant violation escapes the fan-out', async () => {
const dir = await tempDir()
const path = join(dir, '.env')
const ctx = await boot({ path, debounceMs: 5 })
let arm = true
ctx.on('credentials/updated', () => {
if (!arm) return
throw Object.assign(new Error('forged relation'), { code: 'INVARIANT' })
})
const [instance] = await fakeInstances()
await writeFile(path, 'DSH_CRED_PIPE=first\n')
instance!.watcher.emit('all', 'change', path)
// The snapshot commits before the fan-out, so the value lands even though
// the listener threw out of the refresh.
await vi.waitFor(async () => {
expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'first', source: 'file' })
})
arm = false
await writeFile(path, 'DSH_CRED_PIPE=second\n')
instance!.watcher.emit('all', 'change', path)
await vi.waitFor(async () => {
expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'second', source: 'file' })
})
})
it('quiesces the refresh pipeline before dispose completes', async () => {
const dir = await tempDir()
const path = join(dir, '.env')
await writeFile(path, 'DSH_CRED_PIPE=initial\n')
const ctx = new Context()
const fiber = ctx.plugin(CredentialsLocal, { path, debounceMs: 5 })
await fiber
let disposed = false
let postDisposeCommits = 0
ctx.on('credentials/updated', () => {
if (disposed) postDisposeCommits += 1
})
await writeFile(path, 'DSH_CRED_PIPE=changed\n')
const [instance] = await fakeInstances()
// Two queued refreshes: dispose interrupts one mid-flight and the other
// before it starts, so both closed guards must hold.
instance!.watcher.emit('all', 'change', path)
instance!.watcher.emit('all', 'change', path)
await fiber.dispose()
disposed = true
instance!.watcher.emit('all', 'change', path)
await new Promise(resolve => setTimeout(resolve, 100))
expect(postDisposeCommits).toBe(0)
})
it('empties the snapshot when the document is deleted and emits the removals', async () => {
const dir = await tempDir()
const path = join(dir, '.env')
await writeFile(path, 'DSH_CRED_PIPE=doomed\n')
const ctx = await boot({ path, debounceMs: 5 })
const seen: string[] = []
ctx.on('credentials/updated', (ref) => {
seen.push(ref)
})
await rm(path)
const [instance] = await fakeInstances()
instance!.watcher.emit('all', 'unlink', path)
await vi.waitFor(async () => {
expect(await ctx.credentials.resolve(KEY)).toBeUndefined()
})
expect(seen).toEqual([KEY])
})
it('publishes only seam-addressable keys and preserves the rest untouched', async () => {
const dir = await tempDir()
const path = join(dir, '.env')
await writeFile(path, 'BAD-KEY=1\nDSH_CRED_PIPE=a\n')
const ctx = await boot({ path, debounceMs: 5 })
const seen: string[] = []
ctx.on('credentials/updated', (ref) => {
seen.push(ref)
})
await writeFile(path, 'BAD-KEY=2\nDSH_CRED_PIPE=b\n')
const [instance] = await fakeInstances()
instance!.watcher.emit('all', 'change', path)
await vi.waitFor(async () => {
expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'b', source: 'file' })
})
// The dash-named key is preserved file content the seam cannot address:
// its change publishes nothing and breaks nothing.
expect(seen).toEqual([KEY])
})
it('treats an event for a still-absent file as a no-op', async () => {
const dir = await tempDir()
const path = join(dir, '.env')
const ctx = await boot({ path, debounceMs: 5 })
const [instance] = await fakeInstances()
instance!.watcher.emit('all', 'add', path)
await new Promise(resolve => setTimeout(resolve, 50))
expect(await ctx.credentials.resolve(KEY)).toBeUndefined()
})
})
@@ -0,0 +1,33 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": [
{
"path": "../../../vendor/cosmokit"
},
{
"path": "../../../vendor/cordis"
},
{
"path": "../../../vendor/schemastery"
},
{
"path": "../../util/atomic-write"
},
{
"path": "../../util/paths"
},
{
"path": "../credentials"
},
{
"path": "../../support/invariants"
}
]
}
+34
View File
@@ -2074,6 +2074,34 @@ importers:
specifier: ^4.0.0-rc.7
version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5)
packages/credentials/credentials-local:
dependencies:
chokidar:
specifier: ^4.0.3
version: 4.0.3
dotenv:
specifier: ^17.2.0
version: 17.4.2
schemastery:
specifier: ^3.18.0
version: 3.18.0
devDependencies:
'@deepseek-ai/dsh-atomic-write':
specifier: workspace:^
version: link:../../util/atomic-write
'@deepseek-ai/dsh-credentials':
specifier: workspace:^
version: link:../credentials
'@deepseek-ai/dsh-invariants':
specifier: workspace:^
version: link:../../support/invariants
'@deepseek-ai/dsh-paths':
specifier: workspace:^
version: link:../../util/paths
cordis:
specifier: ^4.0.0-rc.7
version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5)
packages/examples/acp-demo:
devDependencies:
'@cordisjs/plugin-include':
@@ -8637,6 +8665,10 @@ packages:
dompurify@3.4.11:
resolution: {integrity: sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==}
dotenv@17.4.2:
resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==}
engines: {node: '>=12'}
dts-resolver@3.0.0:
resolution: {integrity: sha512-1T1f+z+4tl9XD+m+0HBgWoL/nm0bOIffyWaUuUSBlFg/86IWvfx+wjNaO/ybU0AJzG9/Mi5hBUgGV6zCmWEN7Q==}
engines: {node: ^22.18.0 || >=24.0.0}
@@ -13610,6 +13642,8 @@ snapshots:
optionalDependencies:
'@types/trusted-types': 2.0.7
dotenv@17.4.2: {}
dts-resolver@3.0.0(oxc-resolver@11.20.0):
optionalDependencies:
oxc-resolver: 11.20.0
+1
View File
@@ -65,6 +65,7 @@
{ "path": "./packages/settings/settings" },
{ "path": "./packages/settings/settings-local" },
{ "path": "./packages/credentials/credentials" },
{ "path": "./packages/credentials/credentials-local" },
{ "path": "./packages/session-query/tool-session-query" },
{ "path": "./packages/storage/storage" },
{ "path": "./packages/storage/storage-json" },