feat(credentials): move the store to .credentials.yaml and layer $DSH_HOME/.env

$DSH_HOME/.env carried two incompatible jobs. As credentials-local's writable
secret store it could not be hoisted into process.env — hoisting makes every
stored key read as a read-only launch override and blocks rotation from the
TUI and the web page. But its name and dotenv format promise an environment
file, so a DEEPSEEK_BASE_URL sitting beside a working DEEPSEEK_API_KEY in the
same file was silently ignored: only the credential provider read the
document, and it addresses credential references alone.

Split the two jobs into two files.

.credentials.yaml is the provider-managed store: a strict YAML mapping of
CredentialRef to non-empty string, no version field, no wrapper level. Because
it holds credentials and nothing else, a non-mapping root, a non-identifier
key, a non-string value, an empty string, a duplicate key, and malformed YAML
are all rejections rather than skipped entries — loud at boot and at a write,
warn-and-keep-last-good on a live reload. The dotenv physical-line editor
gives way to a patch of the parsed document, so comments and untouched entries
keep their formatting and any string value round-trips, multi-line included.
Writer lock, read-modify-write, atomic 0600 write under a 0700 directory,
watcher, self-write suppression, and quiescent disposal are unchanged.

$DSH_HOME/.env becomes the user's ordinary environment layer. app-boot's new
loadLayeredEnv loads the invoking directory's .env then the Harness home's,
giving user < project < inherited; the home resolves from the inherited
environment first, so a project .env cannot redirect it.

Credential precedence is unchanged: the live environment still wins read-only
over the file, and shadowed writes still reject. Whether a provider-managed
store should instead win over the environment is a separate decision.

No migration: a key already in $DSH_HOME/.env keeps resolving through the new
environment layer, as a read-only env source that shadows the stored one.
This commit is contained in:
Yichen Jiang
2026-08-04 14:50:38 +08:00
parent 88c035c98e
commit 03b534de16
41 changed files with 566 additions and 423 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/credentials/README.md
README.md: 1d450cbeef84750fa57ca0151563c496aed0ce12
README.zh.md: 843230c3cebf35f234d3ad812165b16ea734678b
README.md: 4ab315e01a30d55869dbbb27dfbaf0f318eadd9f
README.zh.md: 736f7f02eb26b7e0931b676b854dd108fdfae3eb

View File

@@ -7,7 +7,7 @@ The credential capability seam, as three-package shape dictates (interface / imp
| Package | Role |
|---|---|
| [`credentials/`](credentials/README.md) | Abstract `ctx.credentials`: branded `CredentialRef` references, per-operation `resolve`, UI-safe `describe`, fail-loud `set`/`unset`, the `credentials/updated` commit event |
| [`credentials-local/`](credentials-local/README.md) | File/environment provider: the live process environment (read-only, wins) layered over `$DSH_HOME/.env` (writable, byte-preserving line edits, hot-reloaded) |
| [`credentials-local/`](credentials-local/README.md) | File/environment provider: the live process environment (read-only, wins) layered over `$DSH_HOME/.credentials.yaml` (writable, comment-preserving edits, hot-reloaded) |
Configuration files carry *references* to secrets (`apiKeyEnv: DEEPSEEK_API_KEY`), never the secrets: the settings document stays safe to sync and render, and rotating a value touches no configuration. The LLM adapters are the first consumers — they resolve their reference once per model request, which is what makes a key stored moments ago reach the very next request without restarting anything.

View File

@@ -7,7 +7,7 @@
| 包 | 角色 |
|---|---|
| [`credentials/`](credentials/README.md) | 抽象 `ctx.credentials`:品牌化 `CredentialRef` 引用、按操作 `resolve`、对 UI 安全的 `describe`、响亮失败的 `set`/`unset`,以及 `credentials/updated` 提交事件 |
| [`credentials-local/`](credentials-local/README.md) | 文件/环境 provider活跃进程环境只读、优先叠加在 `$DSH_HOME/.env`(可写、保字节行级编辑、热重载)之上 |
| [`credentials-local/`](credentials-local/README.md) | 文件/环境 provider活跃进程环境只读、优先叠加在 `$DSH_HOME/.credentials.yaml`(可写、保留注释的编辑、热重载)之上 |
配置文件携带的是对机密的*引用*`apiKeyEnv: DEEPSEEK_API_KEY`绝不携带机密本身设置文档可以放心同步与渲染轮换值不触碰任何配置。LLM 适配器是第一批消费方——它们每次模型请求解析一次引用,正因如此,片刻前存入的密钥无需重启任何组件即可作用于紧随其后的下一次请求。

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/credentials/credentials-local/README.md
README.md: 02b883958faf8b695a3a2abf2df77790cc2fca86
README.zh.md: 59c7fd5747f327e8998882ca4db1473173e793b5
README.md: ca2af9d8a514b43aeef19abec7cda4e44645bdaf
README.zh.md: a8be53629853fe6fb7c39ef2281ac798b5624010

View File

@@ -7,7 +7,7 @@ File-backed [credentials](../credentials/README.md) provider: two layers, one ho
| Layer | Source id | Writable | Wins |
|---|---|---|---|
| Live process environment | `env` | no | always |
| `$DSH_HOME/.env` document | `file` | yes (`set`/`unset`) | otherwise |
| `$DSH_HOME/.credentials.yaml` 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.
@@ -15,24 +15,33 @@ The environment wins because a launch-time override (`DEEPSEEK_API_KEY=… dsh`,
| Field | Default | Meaning |
|---|---|---|
| `path` | `<harness home>/.env` | Credentials document location. |
| `path` | `<harness home>/.credentials.yaml` | 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 physical-line editor that preserves every byte it does not own: `set` rewrites the first assignment of its key in place with that line's own ending (dropping later duplicates, which dotenv's last-wins reading would otherwise let override the edit), `unset` removes only the owning line, and comments, unrelated lines, CRLF endings, and the continuation lines of another key's quoted multi-line value all survive verbatim. Every write first re-reads the document under the cross-process writer lock of [`dsh-atomic-write`](../../util/atomic-write/README.md) and publishes anything it had not observed, then commits atomically with mode `0600` under an owner-only (`0700`) directory — so a concurrent writer or an external edit inside the watcher's debounce window is folded in rather than overwritten.
A YAML mapping of credential reference to value, and nothing else:
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.
```yaml
DEEPSEEK_API_KEY: sk-…
OPENAI_API_KEY: sk-…
```
The document holds credentials only, so every deviation is a rejection rather than a skipped entry — a silently ignored key would read as "the secret I stored has no effect". A non-mapping root, a key that is not a POSIX identifier, a non-string value, an empty string, a duplicate key, and malformed YAML all fail: loud at boot, and warn-and-keep-the-last-good-snapshot on a live reload. There is no `version` field and no wrapper level; the format is the mapping.
Writes patch the parsed document rather than rebuilding it, so comments and the formatting of every untouched entry survive. A comment directly above an entry is that entry's annotation and is removed with it. Every write first re-reads the document under the cross-process writer lock of [`dsh-atomic-write`](../../util/atomic-write/README.md) and publishes anything it had not observed, then commits atomically with mode `0600` under an owner-only (`0700`) directory — so a concurrent writer or an external edit inside the watcher's debounce window is folded in rather than overwritten. An on-disk document that no longer parses fails the write instead of overwriting content the provider could not understand.
Any string value round-trips, multi-line values included, so no entry is unwritable for want of a quoting style. An empty stored value is absent, per the seam rule — which is why an empty string in the document is rejected outright: `unset` removes a key, it does not blank it.
## 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.
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 or invalid document at runtime keeps the last good snapshot and warns; an absent file is an empty store; an unreadable or invalid file at boot fails loud.
## Security boundary
The document is `0600` under a `0700` directory, which stops other OS users — **not** the model. Tool processes (bash, the filesystem tools) run as the same user, and the shipped `workspace-write` file policy confines mutations rather than reads, so they can read this file exactly like any other file the user owns; no sandbox mode singles it out. What the harness does hold to is narrower: it never hands the model a resolved path to the document, and never loads it into the process environment (see [app-boot's Personal config](../../ui/app-boot/README.md#personal-config)), so reaching the value takes a deliberate read of a path the agent was not given.
The document is `0600` under a `0700` directory, which stops other OS users — **not** the model. Tool processes (bash, the filesystem tools) run as the same user, and the shipped `workspace-write` file policy confines mutations rather than reads, so they can read this file exactly like any other file the user owns; no sandbox mode singles it out. What the harness does hold to is narrower: it never hands the model a resolved path to the document, and never loads it into the process environment — unlike `$DSH_HOME/.env`, which is the user's ordinary environment layer (see [app-boot's Personal config](../../ui/app-boot/README.md#personal-config)) so reaching the value takes a deliberate read of a path the agent was not given.
That is discretion, not a boundary. A deployment that must keep provider keys away from its own agent cannot get there with file permissions; an OS-keychain provider — a store the model's processes cannot read at all — is the deferred answer and belongs beside this provider as a sibling package.

View File

@@ -7,7 +7,7 @@
| 层 | 来源 id | 可写 | 优先 |
|---|---|---|---|
| 活跃进程环境 | `env` | 否 | 恒定优先 |
| `$DSH_HOME/.env` 文档 | `file` | 是(`set`/`unset` | 其余情况 |
| `$DSH_HOME/.credentials.yaml` 文档 | `file` | 是(`set`/`unset` | 其余情况 |
环境优先,因为启动时覆盖(`DEEPSEEK_API_KEY=… dsh`、CI 机密、加载了仓库 `.env` 的开发 shell代表本次运行的操作者意图——而它无法从进程内部修改就必须*可见地*只读:`describe()` 报告 `source: 'env', writable: false``set`/`unset` 直接拒绝,而不是写下一个读取方永远看不到的变更。解析实时读取 `process.env`,绝不写回。
@@ -15,24 +15,33 @@
| 字段 | 默认值 | 含义 |
|---|---|---|
| `path` | `<harness home>/.env` | 凭据文档位置。 |
| `path` | `<harness home>/.credentials.yaml` | 凭据文档位置。 |
| `dshHome` | `$DSH_HOME``~/.dsh` | `path` 缺省时使用的 harness home。 |
| `watch` | `true` | 热发布外部编辑。 |
| `debounceMs` | `100` | watcher 写入稳定窗口。 |
## 文档本身
dotenv 格式,用 `dotenv` 解析;写回用物理行级编辑器,保留一切不属于本次编辑的字节:`set` 原位改写该键的第一条赋值行、沿用该行自身的行尾丢弃后续重复行——dotenv 按最后一条生效,重复行会反过来覆盖这次编辑),`unset` 只删除所属行注释、无关行、CRLF 行尾,以及另一个键的引号多行值的续行,都逐字保留。每次写入都先在 [`dsh-atomic-write`](../../util/atomic-write/README.md) 的跨进程写锁下重读文档、把此前未观察到的一切发布出去,再在仅属主可访问(`0700`)的目录下以 `0600` 权限原子提交——因此并发写入者、或落在 watcher 防抖窗口内的外部编辑会被并入,而不是被覆盖。
一个从凭据引用到值的 YAML mapping除此之外别无他物
值按 dotenv 能逐字读回的最窄样式渲染——裸值其次单引号完全字面再次双引号仅限无反斜杠双引号读取会展开转义。任何样式都无法表示的值以及已经跨越多个物理行的条目都会响亮失败而不是被静默破坏。空的存储值等于不存在seam 规则)。
```yaml
DEEPSEEK_API_KEY: sk-…
OPENAI_API_KEY: sk-…
```
该文档只存放凭据,因此任何偏离都是拒绝,而不是跳过某个条目——被静默忽略的键读起来就是「我存进去的密钥没有生效」。非 mapping 的根、非 POSIX 标识符的键、非字符串值、空字符串、重复键以及格式错误的 YAML 全部失败:启动时响亮失败,运行期热重载则告警并保留最后可用快照。没有 `version` 字段,也没有包装层;格式就是这个 mapping。
写入是对已解析文档打补丁而不是重建,因此注释与所有未触及条目的排版都会保留。直接位于某条目上方的注释属于该条目的注解,会随它一起删除。每次写入都先在 [`dsh-atomic-write`](../../util/atomic-write/README.md) 的跨进程写锁下重读文档、把此前未观察到的一切发布出去,再在仅属主可访问(`0700`)的目录下以 `0600` 权限原子提交——因此并发写入者、或落在 watcher 防抖窗口内的外部编辑会被并入,而不是被覆盖。磁盘上已经无法解析的文档会让写入失败,而不是覆盖 provider 读不懂的内容。
任何字符串值都能往返包括多行值因此不会再有条目因为缺少可用引号样式而不可写。空的存储值等于不存在seam 规则)——这也正是文档中的空字符串被直接拒绝的原因:`unset` 删除键,而不是把它置空。
## 热重载
外部编辑在快照**整体替换**后按变更引用逐个发布 `credentials/updated`——磁盘上删掉的条目绝不在内存滞留。provider 自己的写入按内容识别,只发布属于该次提交的一个事件。运行期文档不可读时保留最后可用快照并告警;文件不存在即空存储;启动时不可读则响亮失败。非 POSIX 标识符的键属于被保留的文件内容seam 无法寻址。
外部编辑在快照**整体替换**后按变更引用逐个发布 `credentials/updated`——磁盘上删掉的条目绝不在内存滞留。provider 自己的写入按内容识别,只发布属于该次提交的一个事件。运行期文档不可读或无效时保留最后可用快照并告警;文件不存在即空存储;启动时不可读或无效则响亮失败。
## 安全边界
文档在 `0700` 目录下以 `0600` 权限存放,这挡得住其他 OS 用户,**挡不住**模型。工具进程bash、文件系统工具以同一用户身份运行而已交付的 `workspace-write` 文件策略限制的是修改而非读取因此它们读这个文件与读该用户拥有的任何其他文件毫无二致也没有任何沙箱模式会把它单独挑出来。harness 真正守住的更窄:它绝不把该文档的解析后路径交给模型,也绝不把它载入进程环境(见 [app-boot 的个人配置](../../ui/app-boot/README.md#personal-config)因此要拿到这个值,需要刻意去读一条并未交给 agent 的路径。
文档在 `0700` 目录下以 `0600` 权限存放,这挡得住其他 OS 用户,**挡不住**模型。工具进程bash、文件系统工具以同一用户身份运行而已交付的 `workspace-write` 文件策略限制的是修改而非读取因此它们读这个文件与读该用户拥有的任何其他文件毫无二致也没有任何沙箱模式会把它单独挑出来。harness 真正守住的更窄:它绝不把该文档的解析后路径交给模型,也绝不把它载入进程环境——这与用户的普通环境层 `$DSH_HOME/.env` 不同(见 [app-boot 的个人配置](../../ui/app-boot/README.md#personal-config)——因此要拿到这个值,需要刻意去读一条并未交给 agent 的路径。
这是审慎,不是边界。必须让提供方密钥远离自身 agent 的部署无法靠文件权限做到OS 钥匙串 provider——一个模型的进程根本读不到的存储——才是延后的答案它应当作为平级包与本 provider 并列。

View File

@@ -35,8 +35,8 @@
},
"dependencies": {
"chokidar": "^4.0.3",
"dotenv": "^17.2.0",
"schemastery": "^3.18.0"
"schemastery": "^3.18.0",
"yaml": "^2.9.0"
},
"devDependencies": {
"@deepseek-ai/dsh-atomic-write": "workspace:^",

View File

@@ -1,13 +1,19 @@
/**
* 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:
* every write re-reads the document under a cross-process writer lock before
* rewriting only its own line — preserving every other byte, physical line
* endings and quoted multi-line values included — external edits hot-publish
* through the seam, and each reload replaces the snapshot wholesale so a
* deleted entry never lingers in memory.
* a `$DSH_HOME/.credentials.yaml` 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: 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
*/
@@ -16,15 +22,18 @@ import z from 'schemastery'
import { watch as chokidarWatch } from 'chokidar'
import { mkdir, readFile } from 'node:fs/promises'
import { dirname, join, resolve } from 'node:path'
import { parse } from 'dotenv'
import { Document, parseDocument } from 'yaml'
import { withFileLock, 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'
/** 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 `.env` under the harness home. */
/** 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
@@ -43,13 +52,13 @@ interface ResolvedSpec {
/**
* Resolve the runtime spec from plugin config: an explicit `path` wins,
* otherwise the document lives at `<harness home>/.env`.
* 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), '.env')),
filename: resolve(config.path ?? join(resolveDshHome(config.dshHome), CREDENTIALS_FILENAME)),
watch: config.watch ?? true,
debounceMs: config.debounceMs ?? 100,
}
@@ -60,129 +69,64 @@ function isENOENT(error: unknown): boolean {
return (error as NodeJS.ErrnoException | null)?.code === 'ENOENT'
}
/** 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
/**
* 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> {
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(error => error.message).join('; ')}`)
}
return false
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)
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 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.
* 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 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`)
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()
}
/** Split text into physical lines with their terminators attached. */
function physicalLines(text: string): string[] {
return text.length === 0 ? [] : text.split(/(?<=\n)/)
}
/** One physical line's content without its terminator. */
function lineContent(line: string): string {
if (line.endsWith('\r\n')) return line.slice(0, -2)
if (line.endsWith('\n')) return line.slice(0, -1)
return line
}
/** One physical line's terminator (empty on a final unterminated line). */
function lineTerminator(line: string): string {
return line.slice(lineContent(line).length)
}
/** An assignment line: optional export, a POSIX identifier, `=`, the value part. */
const ASSIGNMENT = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=(.*)$/
/** Quote characters dotenv reads across physical lines. */
const MULTILINE_QUOTES = ['\'', '"', '`']
/**
* The quote character an assignment's value part opens without closing on its
* own line — the following physical lines are that value's continuation, not
* assignments — or `undefined` for a single-line value.
*/
function opensMultiline(valuePart: string): string | undefined {
const trimmed = valuePart.trimStart()
const quote = trimmed[0]
if (quote === undefined || !MULTILINE_QUOTES.includes(quote)) return undefined
const rest = trimmed.slice(1)
const body = quote === '"' ? rest.replaceAll('\\"', '') : rest
return body.includes(quote) ? undefined : quote
}
/** Whether a continuation line closes the given quote. */
function closesQuote(content: string, quote: string): boolean {
const body = quote === '"' ? content.replaceAll('\\"', '') : content
return body.includes(quote)
}
/**
* Replace, insert, or delete one reference's assignment while preserving
* every other byte: untouched lines keep their exact content and terminators
* (CRLF included), and the physical lines inside another key's quoted
* multi-line value are never mistaken for assignments. The first matching
* assignment is rewritten in place with its own line ending; later duplicates
* drop (dotenv reads the last one, so a surviving duplicate would override
* the edit); an insert appends in the document's dominant ending style.
*/
function upsertLine(text: string | undefined, ref: CredentialRef, rendered: string | undefined): string {
const lines = physicalLines(text ?? '')
const dominant = lines.some(line => line.endsWith('\r\n')) ? '\r\n' : '\n'
const out: string[] = []
let placed = false
let pendingQuote: string | undefined
for (const line of lines) {
const content = lineContent(line)
if (pendingQuote !== undefined) {
// Inside a quoted multi-line value: never an assignment, always kept.
if (closesQuote(content, pendingQuote)) pendingQuote = undefined
out.push(line)
continue
}
const match = ASSIGNMENT.exec(content)
if (match === null) {
out.push(line)
continue
}
const [, key, valuePart] = match
if (key !== ref) {
/* v8 ignore next -- the value group is `(.*)`, which always participates; the fallback only satisfies noUncheckedIndexedAccess */
pendingQuote = opensMultiline(valuePart ?? '')
out.push(line)
continue
}
// The write path refuses multi-line targets before rendering, so the
// matched assignment is single-line and drops or rewrites wholesale.
if (rendered !== undefined && !placed) {
out.push(`${rendered}${lineTerminator(line) === '' ? dominant : lineTerminator(line)}`)
placed = true
}
}
if (rendered !== undefined && !placed) {
const last = out[out.length - 1]
if (last !== undefined && lineTerminator(last) === '') {
out[out.length - 1] = `${last}${dominant}`
}
out.push(`${rendered}${dominant}`)
}
return out.join('')
}
/** File-backed credentials provider (`$DSH_HOME/.env`). */
/** 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
@@ -273,7 +217,7 @@ export class CredentialsLocal extends Credentials {
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' })
if (stored !== undefined) return Promise.resolve({ value: stored, source: 'file' })
return Promise.resolve(undefined)
}
@@ -283,11 +227,7 @@ export class CredentialsLocal extends Credentials {
return Promise.resolve({ configured: true, source: 'env', writable: false })
}
const stored = this.values.get(ref)
if (stored !== undefined && stored.length > 0) {
// A quoted multi-line value resolves fine but the line editor refuses to
// rewrite it, so writability must say what set() would actually do.
return Promise.resolve({ configured: true, source: 'file', writable: !stored.includes('\n') })
}
if (stored !== undefined) return Promise.resolve({ configured: true, source: 'file', writable: true })
return Promise.resolve({ configured: false, writable: true })
}
@@ -350,12 +290,7 @@ export class CredentialsLocal extends Credentials {
await this.reconcileFromDisk()
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))
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
@@ -374,12 +309,16 @@ export class CredentialsLocal extends Credentials {
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',
+ ' shadowed; unset it in the launching environment (or in a loaded .env) instead',
)
}
}
/** Boot read: an absent file is an empty store; any other failure is loud. */
/**
* 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> {
let text: string
try {
@@ -388,8 +327,8 @@ export class CredentialsLocal extends Credentials {
if (!isENOENT(error)) throw error
return
}
this.values = parseCredentialsDocument(text, this.spec.filename)
this.text = text
this.values = new Map(Object.entries(parse(text)))
}
/* jscpd:ignore-start -- same deliberate mirror of settings-local's reload and
@@ -415,10 +354,10 @@ export class CredentialsLocal extends Credentials {
/**
* Compare the on-disk text against the cache and publish any difference
* into the seam. Absence publishes the empty store; an unreadable file
* throws, so each caller picks its policy — a reload warns and keeps the
* last good snapshot, a write fails loud. dotenv parsing is lenient by
* design and cannot fail.
* 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> {
let text: string | undefined
@@ -429,7 +368,7 @@ export class CredentialsLocal extends Credentials {
text = undefined
}
if (text === this.text || this.isClosed()) return
const next = text === undefined ? new Map<string, string>() : new Map(Object.entries(parse(text)))
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
@@ -437,21 +376,12 @@ export class CredentialsLocal extends Credentials {
}
/* jscpd:ignore-end */
/** Seam-addressable entries whose effective (non-empty) value changed. */
/** 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()])) {
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.
}
if (prev.get(key) === next.get(key)) continue
changed.push(credentialRef(key))
}
return changed
}

View File

@@ -42,7 +42,7 @@ describe('write-drain teardown', () => {
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 })
const fiber = ctx.plugin(CredentialsLocal, { path: join(dir, '.credentials.yaml'), watch: false })
await fiber
const service = ctx.credentials

View File

@@ -42,29 +42,29 @@ function updates(ctx: Context): CredentialRef[] {
}
describe('resolveSpec', () => {
it('defaults to .env under the harness home with watching on', () => {
it('defaults to .credentials.yaml 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 })
expect(spec).toEqual({ filename: resolve('/custom/home/.credentials.yaml'), 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 })
const spec = resolveSpec({ path: '/etc/dsh/creds.yaml', dshHome: '/ignored', watch: false, debounceMs: 5 })
expect(spec).toEqual({ filename: resolve('/etc/dsh/creds.yaml'), 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 })
const ctx = await boot({ path: join(dir, '.credentials.yaml'), 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 () => {
it('serves file entries alongside comments 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 path = join(dir, '.credentials.yaml')
await writeFile(path, '# notes\nDSH_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' })
@@ -73,22 +73,22 @@ describe('layering and reads', () => {
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 path = join(dir, '.credentials.yaml')
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 () => {
it('treats an empty environment value as absent, falling through to the file', async () => {
const dir = await tempDir()
const path = join(dir, '.env')
await writeFile(path, 'DSH_CRED_TEST=\n')
const path = join(dir, '.credentials.yaml')
await writeFile(path, 'DSH_CRED_TEST: stored\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 })
expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'stored', source: 'file' })
expect(await ctx.credentials.describe(KEY)).toEqual({ configured: true, source: 'file', writable: true })
})
it('fails boot loud when the document exists but cannot be read', async () => {
@@ -100,110 +100,149 @@ describe('layering and reads', () => {
})
})
describe('line-editing writes', () => {
it('appends a missing key to a fresh 0600 document and emits the commit', async () => {
describe('document validation', () => {
// Every rejection below is a boot failure rather than a skipped entry: this
// document holds nothing but credentials, so an ignored key would read as
// "the secret I stored has no effect".
it.each([
['a non-mapping root', 'just a string\n', /must be a mapping/],
['a sequence root', '- DSH_CRED_TEST\n', /must be a mapping/],
['a key that is not a POSIX identifier', 'not-a-ref: value\n', /credential ref/],
['a non-string value', 'DSH_CRED_TEST: 123\n', /must be a string/],
['an empty value', 'DSH_CRED_TEST: ""\n', /is empty/],
['duplicate keys', 'DSH_CRED_TEST: one\nDSH_CRED_TEST: two\n', /invalid document/],
['malformed yaml', 'DSH_CRED_TEST: "unterminated\n', /invalid document/],
])('fails boot on %s', async (_case, text, message) => {
const dir = await tempDir()
const path = join(dir, '.env')
const path = join(dir, '.credentials.yaml')
await writeFile(path, text)
const ctx = new Context()
await expect(ctx.plugin(CredentialsLocal, { path, watch: false })).rejects.toThrow(message)
})
it('reads an empty document as an empty store', async () => {
const dir = await tempDir()
const path = join(dir, '.credentials.yaml')
await writeFile(path, '# nothing stored yet\n')
const ctx = await boot({ path, watch: false })
expect(await ctx.credentials.resolve(KEY)).toBeUndefined()
})
})
describe('document writes', () => {
it('adds a missing key to a fresh 0600 document and emits the commit', async () => {
const dir = await tempDir()
const path = join(dir, '.credentials.yaml')
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 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 () => {
it('patches one entry, preserving comments and every untouched entry', 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 path = join(dir, '.credentials.yaml')
await writeFile(path, '# deployment notes\nDSH_CRED_OTHER: keep\n\n# the one under edit\nDSH_CRED_TEST: old\n')
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')
expect(await readFile(path, 'utf8')).toBe(
'# deployment notes\nDSH_CRED_OTHER: keep\n\n# the one under edit\nDSH_CRED_TEST: new value!\n',
)
})
it('quotes hostile values so they round-trip through a fresh provider', async () => {
it('round-trips values no dotenv line could represent', async () => {
const dir = await tempDir()
const path = join(dir, '.env')
const path = join(dir, '.credentials.yaml')
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 multiLine = 'line one\nline two'
const mixedQuotes = 'both \' and "'
await ctx.credentials.set(KEY, multiLine)
await ctx.credentials.set(OTHER, mixedQuotes)
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' })
expect(await reread.credentials.resolve(KEY)).toEqual({ value: multiLine, source: 'file' })
expect(await reread.credentials.resolve(OTHER)).toEqual({ value: mixedQuotes, source: 'file' })
expect(await reread.credentials.describe(KEY)).toEqual({ configured: true, source: 'file', writable: true })
})
it('fails loud on values no .env quoting style reads back verbatim', async () => {
it('unsets only the owning entry, with its own annotation, and keeps an absent unset silent', 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 path = join(dir, '.credentials.yaml')
// Comments above an entry are that entry's annotation and go with it when
// it is removed — including anything above the document's first entry.
// Every other entry keeps its own comments.
await writeFile(path, '# about the doomed one\nDSH_CRED_TEST: gone\n# about the survivor\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')
expect(await readFile(path, 'utf8')).toBe('# about the survivor\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 () => {
it('rejects empty values and writes the environment would shadow', async () => {
const dir = await tempDir()
const path = join(dir, '.env')
await writeFile(path, 'DSH_CRED_TEST="line one\nline two"\n')
const path = join(dir, '.credentials.yaml')
await writeFile(path, 'DSH_CRED_TEST: stored\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 () => {
it('leaves an empty mapping after unsetting the only entry', async () => {
const dir = await tempDir()
const path = join(dir, '.env')
await writeFile(path, 'DSH_CRED_TEST=only\n')
const path = join(dir, '.credentials.yaml')
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('')
expect(await readFile(path, 'utf8')).toBe('{}\n')
// The emptied document still reloads as an empty store, not a parse error.
const reread = await boot({ path, watch: false })
expect(await reread.credentials.resolve(KEY)).toBeUndefined()
})
it('fails a write loud when the on-disk document became invalid', async () => {
const dir = await tempDir()
const path = join(dir, '.credentials.yaml')
const ctx = await boot({ path, watch: false })
// An external editor left the document unparsable: the read-modify-write
// must refuse rather than overwrite content it cannot understand.
await writeFile(path, 'DSH_CRED_TEST: "unterminated\n')
await expect(ctx.credentials.set(OTHER, 'lands')).rejects.toThrow(/invalid document/)
})
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 path = join(dir, '.credentials.yaml')
const ctx = await boot({ path, watch: false })
const bad = expect(ctx.credentials.set(KEY, 'both \' and "')).rejects.toThrow(/mixes quoting/)
const bad = expect(ctx.credentials.set(KEY, '')).rejects.toThrow(/empty value/)
const good = ctx.credentials.set(OTHER, 'lands')
await bad
await good
expect(await readFile(path, 'utf8')).toBe('DSH_CRED_OTHER=lands\n')
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 path = join(dir, '.credentials.yaml')
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')
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 })
const fiber = ctx.plugin(CredentialsLocal, { path: join(dir, '.credentials.yaml'), watch: false })
await fiber
// Capture the handle first: disposal also removes the ctx.credentials service.
const service = ctx.credentials
@@ -215,20 +254,20 @@ describe('line-editing writes', () => {
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')
const path = join(dir, '.credentials.yaml')
// 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')
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 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 writeFile(path, 'DSH_CRED_TEST: live\n')
await vi.waitFor(async () => {
expect(await ctx.credentials.resolve(OTHER)).toBeUndefined()
})

View File

@@ -1,7 +1,7 @@
// Third-review behaviors: read-modify-write under the writer lock (external
// edits survive an API write), the contained credentials/updated fan-out (a
// broken observer never fails a committed write), and the physical-line
// editor's multi-line and CRLF discipline.
// broken observer never fails a committed write), and the YAML document
// editor's isolation between entries.
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import { mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'
@@ -37,18 +37,18 @@ async function boot(config: ConstructorParameters<typeof CredentialsLocal>[1]):
describe('read-modify-write', () => {
it('folds an unobserved external edit into a write instead of overwriting it', async () => {
const dir = await tempDir()
const path = join(dir, '.env')
const path = join(dir, '.credentials.yaml')
const ctx = await boot({ path, watch: false })
const seen: string[] = []
ctx.on('credentials/updated', (ref) => { seen.push(ref) })
await ctx.credentials.set(ALPHA, 'one')
// The external edit has landed on disk but no watcher reported it (watch
// is off — the same blind spot as a debounce window or a missed event).
await writeFile(path, `${ALPHA}=one\n${BETA}=external\n`)
await writeFile(path, `${ALPHA}: one\n${BETA}: external\n`)
await ctx.credentials.set(ALPHA, 'two')
const text = await readFile(path, 'utf8')
expect(text).toContain(`${BETA}=external`)
expect(text).toContain(`${ALPHA}=two`)
expect(text).toContain(`${BETA}: external`)
expect(text).toContain(`${ALPHA}: two`)
// The fold published the unobserved entry before the write's own commit.
expect(seen).toEqual([ALPHA, BETA, ALPHA])
expect(await ctx.credentials.resolve(BETA)).toEqual({ value: 'external', source: 'file' })
@@ -56,7 +56,7 @@ describe('read-modify-write', () => {
it('keeps both refs when two providers write the same document concurrently', async () => {
const dir = await tempDir()
const path = join(dir, '.env')
const path = join(dir, '.credentials.yaml')
const first = await boot({ path, watch: false })
const second = await boot({ path, watch: false })
await Promise.all([
@@ -71,7 +71,7 @@ describe('read-modify-write', () => {
it('creates the credentials directory owner-only', async () => {
const dir = await tempDir()
const home = join(dir, 'home')
const ctx = await boot({ path: join(home, '.env'), watch: false })
const ctx = await boot({ path: join(home, '.credentials.yaml'), watch: false })
await ctx.credentials.set(ALPHA, 'one')
expect((await stat(home)).mode & 0o777).toBe(0o700)
})
@@ -80,7 +80,7 @@ describe('read-modify-write', () => {
describe('contained update fan-out', () => {
it('does not fail a committed set when a listener throws, and later listeners still run', async () => {
const dir = await tempDir()
const ctx = await boot({ path: join(dir, '.env'), watch: false })
const ctx = await boot({ path: join(dir, '.credentials.yaml'), watch: false })
ctx.on('credentials/updated', () => {
throw new Error('observer boom')
})
@@ -93,7 +93,7 @@ describe('contained update fan-out', () => {
it('contains an async listener rejection', async () => {
const dir = await tempDir()
const ctx = await boot({ path: join(dir, '.env'), watch: false })
const ctx = await boot({ path: join(dir, '.credentials.yaml'), watch: false })
// An unknown-returning function keeps the typed surface legal while the
// runtime value is still the rejected promise the containment must handle.
const boom = (): unknown => Promise.reject(new Error('async observer boom'))
@@ -104,7 +104,7 @@ describe('contained update fan-out', () => {
it('rethrows an invariant-coded failure after the commit and the remaining listeners', async () => {
const dir = await tempDir()
const path = join(dir, '.env')
const path = join(dir, '.credentials.yaml')
const ctx = await boot({ path, watch: false })
ctx.on('credentials/updated', () => {
throw Object.assign(new Error('forged relation'), { code: 'INVARIANT' })
@@ -114,78 +114,33 @@ describe('contained update fan-out', () => {
await expect(ctx.credentials.set(ALPHA, 'one')).rejects.toThrow(/forged relation/)
// Harness-fatal by design — but the write itself committed first.
expect(second).toHaveBeenCalledWith(ALPHA)
expect(await readFile(path, 'utf8')).toContain(`${ALPHA}=one`)
expect(await readFile(path, 'utf8')).toContain(`${ALPHA}: one`)
expect(await ctx.credentials.resolve(ALPHA)).toEqual({ value: 'one', source: 'file' })
})
})
describe('physical-line editor', () => {
it('never mistakes a quoted multi-line continuation for an assignment', async () => {
describe('document editor', () => {
it('leaves a sibling multi-line value untouched while patching one entry', async () => {
const dir = await tempDir()
const path = join(dir, '.env')
const wrapped = `DSH_REVIEW_WRAPPED="line1\n${INNER}=looks-like-one\nline3"\n${ALPHA}=a\n`
const path = join(dir, '.credentials.yaml')
const wrapped = `DSH_REVIEW_WRAPPED: |-\n line1\n line2\n${ALPHA}: a\n`
await writeFile(path, wrapped)
const ctx = await boot({ path, watch: false })
await ctx.credentials.set(ALPHA, 'b')
// The wrapped value survives byte-for-byte; only ALPHA's line changed.
const afterAlpha = await readFile(path, 'utf8')
expect(afterAlpha).toBe(`DSH_REVIEW_WRAPPED="line1\n${INNER}=looks-like-one\nline3"\n${ALPHA}=b\n`)
// Setting the inner-looking ref appends a real assignment; the
// continuation line inside the quoted value stays untouched.
await ctx.credentials.set(INNER, 'real')
const afterInner = await readFile(path, 'utf8')
expect(afterInner).toBe(`DSH_REVIEW_WRAPPED="line1\n${INNER}=looks-like-one\nline3"\n${ALPHA}=b\n${INNER}=real\n`)
expect(await ctx.credentials.resolve(INNER)).toEqual({ value: 'real', source: 'file' })
expect(await readFile(path, 'utf8')).toBe(`DSH_REVIEW_WRAPPED: |-\n line1\n line2\n${ALPHA}: b\n`)
expect(await ctx.credentials.resolve(credentialRef('DSH_REVIEW_WRAPPED')))
.toEqual({ value: 'line1\nline2', source: 'file' })
})
it('preserves CRLF line endings on untouched and edited lines', async () => {
it('stores a value that looks like another entry without creating one', async () => {
const dir = await tempDir()
const path = join(dir, '.env')
await writeFile(path, `# note\r\n${ALPHA}=a\r\n${BETA}=keep\r\n`)
const path = join(dir, '.credentials.yaml')
const ctx = await boot({ path, watch: false })
await ctx.credentials.set(ALPHA, 'b')
expect(await readFile(path, 'utf8')).toBe(`# note\r\n${ALPHA}=b\r\n${BETA}=keep\r\n`)
await ctx.credentials.set(INNER, 'new')
expect(await readFile(path, 'utf8')).toBe(`# note\r\n${ALPHA}=b\r\n${BETA}=keep\r\n${INNER}=new\r\n`)
})
it('terminates a final unterminated line before appending', async () => {
const dir = await tempDir()
const path = join(dir, '.env')
await writeFile(path, `${ALPHA}=a`)
const ctx = await boot({ path, watch: false })
await ctx.credentials.set(BETA, 'b')
expect(await readFile(path, 'utf8')).toBe(`${ALPHA}=a\n${BETA}=b\n`)
})
it('rewrites a final unterminated assignment in the dominant ending style', async () => {
const dir = await tempDir()
const path = join(dir, '.env')
await writeFile(path, `${ALPHA}=a`)
const ctx = await boot({ path, watch: false })
await ctx.credentials.set(ALPHA, 'b')
expect(await readFile(path, 'utf8')).toBe(`${ALPHA}=b\n`)
})
it('tracks a single-quoted multi-line value through its continuation', async () => {
const dir = await tempDir()
const path = join(dir, '.env')
await writeFile(path, `DSH_REVIEW_SQ='line1\n${INNER}=shadow\nline3'\n`)
const ctx = await boot({ path, watch: false })
await ctx.credentials.set(ALPHA, 'x')
expect(await readFile(path, 'utf8'))
.toBe(`DSH_REVIEW_SQ='line1\n${INNER}=shadow\nline3'\n${ALPHA}=x\n`)
})
it('reports a multi-line entry as unwritable and refuses to edit it', async () => {
const dir = await tempDir()
const path = join(dir, '.env')
await writeFile(path, `${ALPHA}="line1\nline2"\n`)
const ctx = await boot({ path, watch: false })
expect(await ctx.credentials.describe(ALPHA)).toEqual({ configured: true, source: 'file', writable: false })
await expect(ctx.credentials.set(ALPHA, 'flat')).rejects.toThrow(/multi-line entry/)
await expect(ctx.credentials.unset(ALPHA)).rejects.toThrow(/multi-line entry/)
// Resolution still serves the multi-line value.
expect(await ctx.credentials.resolve(ALPHA)).toEqual({ value: 'line1\nline2', source: 'file' })
// The stored text must stay a value: a quoted-scalar write that leaked its
// own structure would silently mint a credential nobody stored.
await ctx.credentials.set(ALPHA, `${INNER}: injected`)
const reread = await boot({ path, watch: false })
expect(await reread.credentials.resolve(ALPHA)).toEqual({ value: `${INNER}: injected`, source: 'file' })
expect(await reread.credentials.resolve(INNER)).toBeUndefined()
})
})

View File

@@ -66,21 +66,21 @@ async function boot(config: ConstructorParameters<typeof CredentialsLocal>[1]):
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 })
await boot({ path: join(dir, '.credentials.yaml'), 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 path = join(dir, '.credentials.yaml')
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')
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' })
@@ -89,8 +89,8 @@ describe('watcher pipeline', () => {
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 path = join(dir, '.credentials.yaml')
await writeFile(path, 'DSH_CRED_PIPE: good\n')
const ctx = await boot({ path, debounceMs: 5 })
await chmod(path, 0o000)
@@ -104,7 +104,7 @@ describe('watcher pipeline', () => {
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 path = join(dir, '.credentials.yaml')
const ctx = await boot({ path, debounceMs: 5 })
let arm = true
ctx.on('credentials/updated', () => {
@@ -113,7 +113,7 @@ describe('watcher pipeline', () => {
})
const [instance] = await fakeInstances()
await writeFile(path, 'DSH_CRED_PIPE=first\n')
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.
@@ -122,7 +122,7 @@ describe('watcher pipeline', () => {
})
arm = false
await writeFile(path, 'DSH_CRED_PIPE=second\n')
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' })
@@ -131,8 +131,8 @@ describe('watcher pipeline', () => {
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 path = join(dir, '.credentials.yaml')
await writeFile(path, 'DSH_CRED_PIPE: initial\n')
const ctx = new Context()
const fiber = ctx.plugin(CredentialsLocal, { path, debounceMs: 5 })
await fiber
@@ -142,7 +142,7 @@ describe('watcher pipeline', () => {
if (disposed) postDisposeCommits += 1
})
await writeFile(path, 'DSH_CRED_PIPE=changed\n')
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.
@@ -158,8 +158,8 @@ describe('watcher pipeline', () => {
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 path = join(dir, '.credentials.yaml')
await writeFile(path, 'DSH_CRED_PIPE: doomed\n')
const ctx = await boot({ path, debounceMs: 5 })
const seen: string[] = []
ctx.on('credentials/updated', (ref) => {
@@ -175,30 +175,39 @@ describe('watcher pipeline', () => {
expect(seen).toEqual([KEY])
})
it('publishes only seam-addressable keys and preserves the rest untouched', async () => {
it('keeps the last good snapshot when an external edit makes the document invalid', async () => {
const dir = await tempDir()
const path = join(dir, '.env')
await writeFile(path, 'BAD-KEY=1\nDSH_CRED_PIPE=a\n')
const path = join(dir, '.credentials.yaml')
await writeFile(path, 'DSH_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')
// A key the seam cannot address is a rejection, not preserved content:
// this document holds nothing but credentials. A live reload must warn
// and keep serving the last good snapshot rather than take the process
// down or silently drop the entry it could not validate.
await writeFile(path, 'BAD-KEY: 2\nDSH_CRED_PIPE: b\n')
const [instance] = await fakeInstances()
instance!.watcher.emit('all', 'change', path)
await new Promise(resolve => setTimeout(resolve, 50))
expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'a', source: 'file' })
expect(seen).toEqual([])
// Repairing the document resumes publishing.
await writeFile(path, 'DSH_CRED_PIPE: b\n')
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 path = join(dir, '.credentials.yaml')
const ctx = await boot({ path, debounceMs: 5 })
const [instance] = await fakeInstances()
instance!.watcher.emit('all', 'add', path)
@@ -208,12 +217,12 @@ describe('watcher pipeline', () => {
it('reconciles at watcher ready so a change during setup is not missed', async () => {
const dir = await tempDir()
const path = join(dir, '.env')
await writeFile(path, `${KEY}=a\n`)
const path = join(dir, '.credentials.yaml')
await writeFile(path, `${KEY}: a\n`)
const ctx = await boot({ path, debounceMs: 5 })
// Written after the initial load but before the watcher became active:
// no 'all' event will ever fire for it.
await writeFile(path, `${KEY}=written-before-ready\n`)
await writeFile(path, `${KEY}: written-before-ready\n`)
const [instance] = await fakeInstances()
instance!.watcher.emit('ready')
await vi.waitFor(async () => {