refactor(credentials,llm): remove speculative mutation and route lifecycle

This commit is contained in:
Tianyi Cui
2026-07-31 01:08:54 +08:00
parent 16802cd612
commit f9f8148e79
100 changed files with 559 additions and 2752 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: e7dc38db9be95bdacabc19538c06d87723e9c922
README.zh.md: 74bf5d9b53e6861271b4810474c6eddd4388da51

View File

@@ -2,13 +2,13 @@
English | [中文](README.zh.md)
The credential capability seam, as three-package shape dictates (interface / implementation / consumers):
The credential capability keeps secret values behind provider-owned references:
| 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/`](credentials/README.md) | Abstract `ctx.credentials`: branded `CredentialRef` references and per-operation `resolve` |
| [`credentials-local/`](credentials-local/README.md) | Read-only provider: the live process environment layered over an on-demand `$DSH_HOME/.env` read |
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.
Configuration can carry a reference such as `apiKeyEnv: DEEPSEEK_API_KEY` instead of the secret itself. LLM adapters resolve that reference for each model request, so an externally rotated environment or dotenv value reaches the next request without restarting the harness.
The seam shape leaves room for keyring-, helper-command-, and KMS-backed providers.
The seam can also support keyring-, helper-command-, and KMS-backed providers when a shipped consumer needs one.

View File

@@ -2,13 +2,13 @@
[English](README.md) | 中文
凭据能力 seam按三包形态的要求组织接口实现消费方
凭据能力把机密值留在提供方拥有的引用背后
| 包 | 角色 |
| 包package | 角色 |
|---|---|
| [`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/`](credentials/README.md) | 抽象 `ctx.credentials`:品牌化 `CredentialRef` 引用按操作 `resolve` |
| [`credentials-local/`](credentials-local/README.md) | 只读提供方:活跃进程环境叠加按需读取的 `$DSH_HOME/.env` |
配置文件携带的是对机密的*引用*`apiKeyEnv: DEEPSEEK_API_KEY`绝不携带机密本身设置文档可以放心同步与渲染轮换值不触碰任何配置。LLM 适配器是第一批消费方——它们每次模型请求解析一次引用,正因如此,片刻前存入的密钥无需重启任何组件即可作用于紧随其后的下一次请求。
配置可以携带 `apiKeyEnv: DEEPSEEK_API_KEY` 这样的引用而非机密本身。LLM大语言模型适配器每次模型请求都会解析该引用因此从外部轮换的环境变量或 dotenv 值无需重启 harness 即可作用于下一次请求。
seam 形状为 keyring、辅助命令 KMS 后端的 provider 留有余地
已交付的消费方需要时,该 seam 也可以支持由 keyring、辅助命令 KMS 支撑的提供方

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: 126140b10719dc6f7bc458a118ba1feb1f440270
README.zh.md: c22575115ab44b5e86a847ffe8f1fa1a795b580d
README.md: dcfaea036595c68eb07b104d9075b67d17dad2ac
README.zh.md: 635d41a8e156b71d749419e90e408c184e9adbbc

View File

@@ -2,14 +2,14 @@
English | [中文](README.zh.md)
File-backed [credentials](../credentials/README.md) provider: two layers, one honest precedence.
Read-only [credentials](../credentials/README.md) provider with two externally managed sources:
| Layer | Source id | Writable | Wins |
|---|---|---|---|
| Live process environment | `env` | no | always |
| `$DSH_HOME/.env` document | `file` | yes (`set`/`unset`) | otherwise |
| Layer | Wins |
|---|---|
| Live process environment | Always, when the named value is non-empty |
| `$DSH_HOME/.env` document | 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.
The environment wins because a launch-time override (`DEEPSEEK_API_KEY=… dsh`, CI secrets, or a prepared shell) is operator intent for that process. The provider never writes either source.
## Config
@@ -17,28 +17,20 @@ The environment wins because a launch-time override (`DEEPSEEK_API_KEY=… dsh`,
|---|---|---|
| `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
## Resolution
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.
Each `resolve(ref)` reads `process.env[ref]` first. If it is absent or empty, the provider reads the dotenv document and parses it with `dotenv`; a missing file, missing key, or empty value resolves to `undefined`, while any other file error rejects the operation. Nothing is watched or cached, so an external edit is visible to the next resolution without a provider lifecycle or mutation API.
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.
The provider accepts dotenv's parsing semantics, including last-assignment precedence. It does not create the document or control its permissions; the operator or external credential-management surface owns both.
## 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, so under the shipped `danger-full-access` default they can read this file exactly like any other file the user owns, and 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.
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.
The harness does not expose the resolved document path to the model or hoist the file into the process environment (see [app-boot's Personal config](../../ui/app-boot/README.md#personal-config)). This is discretion, not isolation: tools run as the same OS user and can read any file that user's permissions allow. A deployment that must keep provider keys away from its own agent needs a provider backed by a store those tool processes cannot read.
## Model Experience
Indirectly, through the consuming LLM adapters: stored values authorize their provider requests, and the adapter owns every model-visible surface.
Indirectly, through the consuming LLM adapters: resolved values authorize their provider requests, and the adapter owns every model-visible surface.
#### KV Cache effect
@@ -46,9 +38,6 @@ 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; `describe` reports them `writable: false` and edits must go to the file directly.
- **Same-reference concurrent writes are last-write-wins** — the writer lock and the read-modify-write keep concurrent writers from dropping each other's entries, but two writers editing one reference still resolve to the later write; there is no revision check.
- **A same-UID process can read the document** — see [Security boundary](#security-boundary): only a confining sandbox mode denies it, and an OS-keychain provider is deferred.
- **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.
- **Mutation is external** — edit the dotenv document, launching environment, or upstream secret store; this provider intentionally has no write API.
- **Every file fallback performs I/O** — the implementation favors a small always-current read path over a watcher, cache, and invalidation lifecycle.
- **A same-UID process can read the document** — file permissions do not isolate a secret from model-invoked tools running as the same user.

View File

@@ -2,14 +2,14 @@
[English](README.md) | 中文
文件型[凭据](../credentials/README.md) provider两层来源一条诚实的优先级。
只读[凭据](../credentials/README.md)提供方,包含两个由外部管理的来源:
| 层 | 来源 id | 可写 | 优先 |
|---|---|---|---|
| 活跃进程环境 | `env` | 否 | 恒定优先 |
| `$DSH_HOME/.env` 文档 | `file` | 是(`set`/`unset` | 其余情况 |
| 层 | 优先 |
|---|---|
| 活跃进程环境 | 点名的值非空时始终优先 |
| `$DSH_HOME/.env` 文档 | 其余情况 |
环境优先,因为启动时覆盖(`DEEPSEEK_API_KEY=… dsh`、CI 机密、加载了仓库 `.env` 的开发 shell代表本次运行的操作者意图——而它无法从进程内部修改就必须*可见地*只读:`describe()` 报告 `source: 'env', writable: false``set`/`unset` 直接拒绝,而不是写下一个读取方永远看不到的变更。解析实时读取 `process.env`,绝不写回
环境优先,因为启动时覆盖(`DEEPSEEK_API_KEY=… dsh`、CI 机密或预先设置好环境的 shell代表操作者对该进程的意图。提供方不会写入任何一个来源
## 配置
@@ -17,28 +17,20 @@
|---|---|---|
| `path` | `<harness home>/.env` | 凭据文档位置。 |
| `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 防抖窗口内的外部编辑会被并入,而不是被覆盖
每次调用 `resolve(ref)` 时,提供方先读取 `process.env[ref]`。若该值不存在或为空,提供方再读取 dotenv 文档并以 `dotenv` 解析;文件不存在、键不存在或值为空时返回 `undefined`,其他文件错误则使该操作失败。实现不使用 watcher 或缓存,因此外部编辑无需经过提供方生命周期或变更 API即可在下一次解析时生效
值按 dotenv 能逐字读回的最窄样式渲染——裸值其次单引号完全字面再次双引号仅限无反斜杠双引号读取会展开转义。任何样式都无法表示的值以及已经跨越多个物理行的条目都会响亮失败而不是被静默破坏。空的存储值等于不存在seam 规则)
## 热重载
外部编辑在快照**整体替换**后按变更引用逐个发布 `credentials/updated`——磁盘上删掉的条目绝不在内存滞留。provider 自己的写入按内容识别,只发布属于该次提交的一个事件。运行期文档不可读时保留最后可用快照并告警;文件不存在即空存储;启动时不可读则响亮失败。非 POSIX 标识符的键属于被保留的文件内容seam 无法寻址。
提供方接受 dotenv 的解析语义,包括最后一次赋值优先。它既不创建文档,也不控制其权限;两者均归操作者或外部凭据管理接口所有
## 安全边界
文档在 `0700` 目录下以 `0600` 权限存放,这挡得住其他 OS 用户,**挡不住**模型。工具进程bash、文件系统工具以同一用户身份运行因此在出厂默认的 `danger-full-access`它们读这个文件与读该用户拥有的任何其他文件毫无二致也没有任何沙箱模式会把它单独挑出来。harness 真正守住的更窄:它绝不把该文档的解析后路径交给模型,也绝不把它载入进程环境(见 [app-boot 的个人配置](../../ui/app-boot/README.md#personal-config)),因此要拿到这个值,需要刻意去读一条并未交给 agent 的路径
这是审慎,不是边界。必须让提供方密钥远离自身 agent 的部署无法靠文件权限做到OS 钥匙串 provider——一个模型的进程根本读不到的存储——才是延后的答案它应当作为平级包与本 provider 并列。
harness 不会向模型暴露解析后的文档路径,也不会把文件载入进程环境(见 [app-boot 的个人配置](../../ui/app-boot/README.md#personal-config))。这是审慎,不是隔离:工具以同一 OS 用户身份运行,可以读取该用户权限允许的任何文件。必须让提供方密钥远离自身 agent智能体的部署需要采用由这些工具进程无法读取的存储支撑的提供方
## Model Experience
经由消费它的 LLM 适配器间接生效:存储的值为适配器的提供方请求授权,每个模型可见面都归适配器所有。
经由消费它的 LLM(大语言模型)适配器间接生效:解析出的值为适配器的提供方请求授权,每个模型可见面都归适配器所有。
#### KV Cache effect
@@ -46,9 +38,6 @@ dotenv 格式,用 `dotenv` 解析;写回用物理行级编辑器,保留一
## Known Limitations and Deferred Work
- **多行条目拒绝 `set`/`unset`**——行编辑器不改写会被它破坏的条目;`describe` 把它们报为 `writable: false`,编辑必须直接落到文件上
- **同一引用的并发写入是后写胜出**——写锁加读-改-写让并发写入者不会丢掉彼此的条目,但两个写入者编辑同一个引用时仍以较后的写入为准;没有修订检查
- **同 UID 进程可以读取该文档**——见[安全边界](#security-boundary)只有受限沙箱模式会拒绝它OS 钥匙串 provider 仍是延后项。
- **无法表示的值响亮失败**——控制字符,或同时混用两种引号又含反斜杠的值,无法在 dotenv 行格式中往返。
- **环境变化不可见**——每次解析实时读取 `process.env`,但那里的变化不可能发出事件。
- **原子但不保证崩溃持久**——继承自 `dsh-atomic-write`;存储在启动时重新读取。
- **修改由外部完成**:请编辑 dotenv 文档、启动环境或上游机密存储;该提供方刻意不提供写入 API
- **每次回退到文件都会执行 I/O**:实现选择小而始终读取当前值的路径,不引入 watcher、缓存和失效生命周期
- **同一 OS 用户的进程可以读取该文档**:文件权限无法将机密与以同一用户身份运行的模型调用工具隔离。

View File

@@ -27,19 +27,16 @@
],
"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:^",

View File

@@ -1,463 +1,72 @@
/**
* 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.
* Read-only credential provider layering the live process environment over a
* `$DSH_HOME/.env` document read on demand.
* @module @deepseek-ai/dsh-credentials-local
*/
import { Context, Service } from 'cordis'
import { Context } from 'cordis'
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 { readFile } from 'node:fs/promises'
import { join, resolve } from 'node:path'
import { parse } from 'dotenv'
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'
import { Credentials } from '@deepseek-ai/dsh-credentials'
import type { CredentialRef } from '@deepseek-ai/dsh-credentials'
/** Plugin config: file location and hot-reload behavior. */
/** Plugin config: the optional credential document location. */
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. */
/** Fully resolved provider parameters. */
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`.
* Resolve the runtime spec from plugin config.
* @param config - raw plugin config.
* @returns the resolved file location and watch behavior.
* @returns the absolute credential document path.
*/
export function resolveSpec(config: Config): ResolvedSpec {
return {
filename: resolve(config.path ?? join(resolveDshHome(config.dshHome), '.env')),
watch: config.watch ?? true,
debounceMs: config.debounceMs ?? 100,
}
return { filename: resolve(config.path ?? join(resolveDshHome(config.dshHome), '.env')) }
}
/** Whether a filesystem error means absence; every non-ENOENT failure must surface. */
/** Whether a filesystem error means absence; every non-ENOENT failure surfaces. */
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
}
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`)
}
/** 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`). */
export class CredentialsLocal extends Credentials {
/* jscpd:ignore-start -- deliberate config-surface and lifecycle symmetry with
settings-local (prefer symmetry for parallel values); extracting the shared
shape would couple the two providers' teardown semantics across packages. */
static Config: z<Config> = z.object({
path: z.string(),
dshHome: z.string(),
watch: z.boolean().default(true),
debounceMs: z.number().min(0).default(100),
})
private readonly spec: ResolvedSpec
/**
* Raw text of the last read or persisted document; `undefined` while the
* file is absent. Watcher events whose content equals this cache are no-ops,
* which is also the self-write suppression.
*/
private text: string | undefined
/** Parsed document snapshot; replaced wholesale on every reload. */
private values = new Map<string, string>()
/**
* Single exclusive operation chain: watcher reloads and line edits run one
* at a time in queue order (settled tail), so an edit can never render from
* text a concurrent reload is busy replacing.
*/
private operations: Promise<void> = Promise.resolve()
/** Set at dispose: refuse new writes and let in-flight work no-op. */
private closed = false
/** Opaque read of {@link closed}: control flow cannot narrow it across awaits. */
private isClosed(): boolean {
return this.closed
}
/* jscpd:ignore-end */
constructor(ctx: Context, public config: Config) {
super(ctx)
// Programmatic construction may bypass Schemastery normalization; resolve
// the same defaults in one explicit step either way.
this.spec = resolveSpec(config)
}
async* [Service.init](): AsyncGenerator<() => Promise<void> | void, void, void> {
yield async () => {
// Drain: refuse new operations, then settle the queued ones so disposal
// completes only once storage is quiescent.
this.closed = true
await this.operations
}
await this.loadInitial()
if (!this.spec.watch) return
/* jscpd:ignore-start -- same watcher discipline as settings-local by design:
the serialized-refresh and quiesce-on-dispose shape is the reviewed
lifecycle contract, not accidental repetition. */
const watcher = chokidarWatch(this.spec.filename, {
ignoreInitial: true,
awaitWriteFinish: {
stabilityThreshold: this.spec.debounceMs,
pollInterval: Math.max(1, Math.min(this.spec.debounceMs, 10)),
},
})
watcher.on('all', () => {
if (this.closed) return
this.queueRefresh()
})
watcher.on('ready', () => {
// The initial load raced the watcher's own setup: a change written
// between that read and the watcher becoming active never fires an
// event. One reconcile at ready closes the gap.
if (this.closed) return
this.queueRefresh()
})
watcher.on('error', (error) => {
this.ctx.logger.warn('credentials-local: watcher error on %s', this.spec.filename)
this.ctx.logger.warn(error)
})
yield async () => {
// Quiesce: stop accepting events, close the watcher, then wait out any
// queued or in-flight operation so nothing publishes after disposal.
this.closed = true
await watcher.close()
await this.operations
}
/* jscpd:ignore-end */
}
override async resolve(ref: CredentialRef): Promise<string | undefined> {
const ambient = process.env[ref]
if (ambient !== undefined && ambient.length > 0) return ambient
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) {
// 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') })
}
return Promise.resolve({ configured: false, writable: true })
}
override async set(ref: CredentialRef, value: string): Promise<void> {
if (value.length === 0) {
throw new Error(`credentials-local: an empty value cannot be stored for "${ref}"; use unset`)
}
await this.write(ref, value)
}
override async unset(ref: CredentialRef): Promise<void> {
await this.write(ref, undefined)
}
/* jscpd:ignore-start -- the operation-chain and reload lifecycle is the same
reviewed contract as settings-local, deliberately mirrored (prefer symmetry
for parallel values); the two providers own different documents and
failure policies, so extracting the shape would couple their teardown
semantics across packages for a handful of lines. */
/** Queue one exclusive document operation behind every earlier one. */
private enqueue<T>(operation: () => Promise<T>): Promise<T> {
const task = this.operations.then(operation)
this.operations = task.then(() => undefined, () => undefined)
return task
}
/** Queue a reload; only an invariant violation escaping the fan-out can reject it. */
private queueRefresh(): void {
void this.enqueue(() => this.refresh()).catch((error: unknown) => {
// Only an invariant violation escaping the update fan-out can reject a
// refresh; keep the operation queue alive and surface it as an error so
// one poisoned commit cannot silently end hot reloading forever.
this.ctx.logger.error('credentials-local: reload commit failed at %s', this.spec.filename)
this.ctx.logger.error(error)
})
}
/* jscpd:ignore-end */
/** Queue one line edit; entry checks reject early, the queue re-judges them at run time. */
private async write(ref: CredentialRef, value: string | undefined): Promise<void> {
const verb = value === undefined ? 'unset' : 'set'
if (this.isClosed()) {
throw new Error(`credentials-local is disposed: cannot ${verb} "${ref}"`)
}
this.assertUnshadowed(ref, verb)
return this.enqueue(async () => {
if (this.isClosed()) {
throw new Error(`credentials-local was disposed before the queued "${ref}" ${verb} ran`)
}
// Re-judged at run time: the environment may have changed while queued.
this.assertUnshadowed(ref, verb)
// The writer lock's exclusive create needs the parent to exist; 0700
// because the harness home holds user-private data.
await mkdir(dirname(this.spec.filename), { recursive: true, mode: 0o700 })
await withFileLock(this.spec.filename, async () => {
// Read-modify-write: fold in any on-disk state this process has not
// observed yet — an external edit still inside the watcher debounce
// window, a change the watcher missed, or another process's write —
// so the line edit below can never resurrect a stale document.
await this.reconcileFromDisk()
const existing = this.values.get(ref)
if (value === undefined && existing === undefined) return
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, dirMode: 0o700 })
this.text = nextText
if (value === undefined) this.values.delete(ref)
else this.values.set(ref, value)
// After the commit: a broken observer must never make the durable
// write look failed (an INVARIANT failure still rethrows).
this.notifyUpdated(ref)
}, {
onStaleBreak: (lockPath) => {
this.ctx.logger.warn('credentials-local: breaking a stale writer lock at %s', lockPath)
},
})
})
}
/** 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
if (isENOENT(error)) return undefined
throw error
}
this.text = text
this.values = new Map(Object.entries(parse(text)))
}
/* jscpd:ignore-start -- same deliberate mirror of settings-local's reload and
reconcile policy: warn-and-keep on a reload, throw on a write, invariant
failures propagate. */
/**
* Re-read the document after a watcher event. Unchanged content (including
* this provider's own writes) is a no-op; an unreadable document keeps the
* last good snapshot and warns — a live hot-reload must never take the
* process down. An invariant violation escaping the fan-out is not a reload
* failure and propagates to the queue's error surface.
*/
private async refresh(): Promise<void> {
if (this.closed) return
try {
await this.reconcileFromDisk()
} catch (error) {
if ((error as { code?: unknown } | null)?.code === 'INVARIANT') throw error
this.ctx.logger.warn('credentials-local: reload failed at %s; keeping the last good document', this.spec.filename)
this.ctx.logger.warn(error)
}
}
/**
* Compare the on-disk text against the cache and publish any difference
* into the seam. Absence publishes the empty store; an unreadable 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.
*/
private async reconcileFromDisk(): Promise<void> {
let text: string | undefined
try {
text = await readFile(this.spec.filename, 'utf8')
} catch (error) {
if (!isENOENT(error)) throw error
text = undefined
}
if (text === this.text || this.isClosed()) return
const next = text === undefined ? new Map<string, string>() : 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.notifyUpdated(ref)
}
/* jscpd:ignore-end */
/** 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
const stored = parse(text)[ref]
return stored === undefined || stored.length === 0 ? undefined : stored
}
}

View File

@@ -15,8 +15,7 @@ export const name = 'credentials-local-invariant'
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
* No runtime invariant: this provider's file/environment layering is
* asynchronous I/O pinned by its unit suite.
*/
const install: InvariantInstaller = () => {}

View File

@@ -1,71 +0,0 @@
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 gated asynchronous hold point inside a queued
// write; gating it makes the dispose-versus-queued-write race fully
// deterministic. The lock helper passes through so the gated operation still
// runs inside its real acquire/release cycle.
vi.mock('@deepseek-ai/dsh-atomic-write', async (importOriginal) => {
const actual = await importOriginal<typeof import('@deepseek-ai/dsh-atomic-write')>()
let gate: Promise<void> = Promise.resolve()
return {
...actual,
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()
})
})

View File

@@ -1,15 +1,13 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'
import { mkdir, mkdtemp, rm, 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 () => {
@@ -23,222 +21,69 @@ async function tempDir(): Promise<string> {
return dir
}
async function boot(config: ConstructorParameters<typeof CredentialsLocal>[1]): Promise<Context> {
async function boot(path: string): Promise<Context> {
const ctx = new Context()
const fiber = ctx.plugin(CredentialsLocal, config)
cleanups.push(async () => {
await fiber.dispose()
})
await fiber
await ctx.plugin(CredentialsLocal, { path })
cleanups.push(async () => { await ctx.fiber.dispose() })
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('defaults to .env under the harness home', () => {
expect(resolveSpec({ dshHome: '/custom/home' }))
.toEqual({ filename: resolve('/custom/home/.env') })
})
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 })
expect(resolveSpec({ path: '/etc/dsh/creds.env', dshHome: '/ignored' }))
.toEqual({ filename: resolve('/etc/dsh/creds.env') })
})
})
describe('layering and reads', () => {
it('treats an absent file as an empty writable store', async () => {
describe('read-only resolution', () => {
it('treats an absent file as unconfigured', async () => {
const dir = await tempDir()
const ctx = await boot({ path: join(dir, '.env'), watch: false })
const ctx = await boot(join(dir, '.env'))
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('parses export-prefixed, quoted, and multiline dotenv 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 })
await writeFile(path, '# notes\nexport DSH_CRED_TEST=plain\nDSH_CRED_OTHER="line one\nline two"\n')
const ctx = await boot(path)
expect(await ctx.credentials.resolve(KEY)).toBe('plain')
expect(await ctx.credentials.resolve(OTHER)).toBe('line one\nline two')
})
it('lets a non-empty process environment win read-only over the file', async () => {
it('reads the live environment first on every call', 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 })
const ctx = await boot(path)
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 })
expect(await ctx.credentials.resolve(KEY)).toBe('from-env')
vi.stubEnv('DSH_CRED_TEST', '')
expect(await ctx.credentials.resolve(KEY)).toBe('from-file')
})
it('treats empty values as absent in both layers', async () => {
it('re-reads the file on every call and treats empty values as absent', async () => {
const dir = await tempDir()
const path = join(dir, '.env')
await writeFile(path, 'DSH_CRED_TEST=first\n')
const ctx = await boot(path)
expect(await ctx.credentials.resolve(KEY)).toBe('first')
await writeFile(path, 'DSH_CRED_TEST=second\n')
expect(await ctx.credentials.resolve(KEY)).toBe('second')
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 () => {
it('surfaces non-absence read failures at resolution time', 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' })
const ctx = await boot(path)
await expect(ctx.credentials.resolve(KEY)).rejects.toThrow()
})
})

View File

@@ -1,202 +0,0 @@
// 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.
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import { mkdtemp, readFile, rm, stat, utimes, 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'
const ALPHA = credentialRef('DSH_REVIEW_ALPHA')
const BETA = credentialRef('DSH_REVIEW_BETA')
const INNER = credentialRef('DSH_REVIEW_INNER')
const cleanups: Array<() => Promise<void>> = []
afterEach(async () => {
while (cleanups.length > 0) await cleanups.pop()!()
})
async function tempDir(): Promise<string> {
const dir = await mkdtemp(join(tmpdir(), 'dsh-cred-review-'))
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('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 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 ctx.credentials.set(ALPHA, 'two')
const text = await readFile(path, 'utf8')
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' })
})
it('keeps both refs when two providers write the same document concurrently', async () => {
const dir = await tempDir()
const path = join(dir, '.env')
const first = await boot({ path, watch: false })
const second = await boot({ path, watch: false })
await Promise.all([
(async () => { for (const value of ['1', '2', '3'] as const) await first.credentials.set(ALPHA, value) })(),
(async () => { for (const value of ['1', '2', '3'] as const) await second.credentials.set(BETA, value) })(),
])
const third = await boot({ path, watch: false })
expect(await third.credentials.resolve(ALPHA)).toEqual({ value: '3', source: 'file' })
expect(await third.credentials.resolve(BETA)).toEqual({ value: '3', source: 'file' })
})
it('breaks a stale writer lock with a warning and writes through', async () => {
const dir = await tempDir()
const path = join(dir, '.env')
const ctx = await boot({ path, watch: false })
await writeFile(`${path}.lock`, 'crashed-holder\n')
const past = (Date.now() - 60_000) / 1000
await utimes(`${path}.lock`, past, past)
await ctx.credentials.set(ALPHA, 'nine')
expect(await readFile(path, 'utf8')).toContain(`${ALPHA}=nine`)
})
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 })
await ctx.credentials.set(ALPHA, 'one')
expect((await stat(home)).mode & 0o777).toBe(0o700)
})
})
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 })
ctx.on('credentials/updated', () => {
throw new Error('observer boom')
})
const second = vi.fn()
ctx.on('credentials/updated', second)
await expect(ctx.credentials.set(ALPHA, 'one')).resolves.toBeUndefined()
expect(second).toHaveBeenCalledWith(ALPHA)
expect(await ctx.credentials.resolve(ALPHA)).toEqual({ value: 'one', source: 'file' })
})
it('contains an async listener rejection', async () => {
const dir = await tempDir()
const ctx = await boot({ path: join(dir, '.env'), 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'))
ctx.on('credentials/updated', boom)
await expect(ctx.credentials.set(ALPHA, 'one')).resolves.toBeUndefined()
await new Promise(resolve => setTimeout(resolve, 10))
})
it('rethrows an invariant-coded failure after the commit and the remaining listeners', async () => {
const dir = await tempDir()
const path = join(dir, '.env')
const ctx = await boot({ path, watch: false })
ctx.on('credentials/updated', () => {
throw Object.assign(new Error('forged relation'), { code: 'INVARIANT' })
})
const second = vi.fn()
ctx.on('credentials/updated', second)
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 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 () => {
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`
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' })
})
it('preserves CRLF line endings on untouched and edited lines', 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 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' })
})
})

View File

@@ -1,223 +0,0 @@
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)
instance!.watcher.emit('ready')
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()
})
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 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`)
const [instance] = await fakeInstances()
instance!.watcher.emit('ready')
await vi.waitFor(async () => {
expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'written-before-ready', source: 'file' })
})
})
})

View File

@@ -17,9 +17,6 @@
{
"path": "../../../vendor/schemastery"
},
{
"path": "../../util/atomic-write"
},
{
"path": "../../util/paths"
},

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/README.md
README.md: 1c18c4762360ad081227b7097cd82ddab4fcdefc
README.zh.md: 751fb7c1e8326cef91b925c5f8b9f40d92e1bba6
README.md: 1070f21c11a7ce87a7d901a235edbd3d0b0a8da1
README.zh.md: 0787a6b93daeeb7a49b2230fba309e2bd7a508d9

View File

@@ -2,13 +2,7 @@
English | [中文](README.zh.md)
Abstract credential seam (`ctx.credentials`). One doctrine, three consequences:
**Configuration carries references to secrets, never the secrets.** A settings section or `cordis.yml` entry says `apiKeyEnv: DEEPSEEK_API_KEY`; the value behind that reference lives with a credential provider. So the settings document stays safe to sync and to render in a configuration UI, `describe()` can answer "is this configured, where from, can I write it" without ever holding a value, and rotating a secret touches no configuration file.
**Consumers resolve per operation.** `resolve(ref)` is called at the start of each operation (the LLM adapters resolve once per model request) and never cached across operations — that read is what makes a changed credential reach the very next request without restarting any plugin.
**An empty stored value is absent.** Everywhere: `resolve` skips it, `describe` reports it unconfigured. A blank can never masquerade as a configured secret.
Abstract read-only credential seam (`ctx.credentials`). Configuration carries a branded reference such as `DEEPSEEK_API_KEY`; the provider owns the value, and the consumer resolves it only when starting an operation.
## Surface
@@ -18,20 +12,15 @@ import { credentialRef } from '@deepseek-ai/dsh-credentials'
declare const ctx: Context
const ref = credentialRef('DEEPSEEK_API_KEY') // POSIX shell identifier, branded
const hit = await ctx.credentials.resolve(ref) // { value, source } | undefined
const info = await ctx.credentials.describe(ref) // { configured, source?, writable } — never the value
await ctx.credentials.set(ref, 'sk-…') // rejects while a read-only source shadows the ref
await ctx.credentials.unset(ref) // no-op when absent; same shadowing rule
const ref = credentialRef('DEEPSEEK_API_KEY')
const value = await ctx.credentials.resolve(ref) // string | undefined
```
`credentials/updated (ref)` fires after a committed change to a provider-managed source — a `set`, an `unset`, or an external edit observed in storage. Ambient process-environment changes are not observable and never emit. Consumers do not need the event (they re-resolve per operation); it exists for configuration UIs refreshing a "configured" badge.
The shadowing rule on `set`/`unset` is deliberate fail-loud: when a read-only source (the live process environment, in the local provider) currently supplies the reference, a write would appear to succeed while resolution keeps returning the shadowing value — the seam rejects instead, and `describe().writable` lets a UI render the reference read-only up front.
`credentialRef()` accepts POSIX-style environment-variable names and brands them so references do not mix with unrelated cross-package strings. `resolve(ref)` returns the current non-empty value or `undefined`. Consumers resolve once per operation and do not cache across operations; mutation, source metadata, enumeration, and change events stay out of the seam until a current consumer requires them.
## Providers
[`dsh-credentials-local`](../credentials-local/README.md) layers the live process environment over a `$DSH_HOME/.env` file. The seam shape leaves room for keyring-, helper-command-, and KMS-backed providers; a remote settings provider never needs to carry secrets.
[`dsh-credentials-local`](../credentials-local/README.md) layers the live process environment over a `$DSH_HOME/.env` file. Other providers may resolve the same reference vocabulary from a keyring, helper command, or KMS without changing consumers.
## Model Experience
@@ -43,6 +32,5 @@ No direct invalidation; credentials never enter a request prefix.
## Known Limitations and Deferred Work
- **No enumeration** — the seam answers questions about references it is given; configuration surfaces learn the references from settings schemas, so a `list()` has no current consumer.
- **References are environment-variable-shaped** — one flat POSIX-identifier namespace until a provider needs richer addressing.
- **Process-environment changes are invisible** — no event can fire for them; a UI only re-reads `describe()` on its own navigation.
- **No mutation, description, or enumeration** — the seam only resolves references already named by consumer configuration; a credential-management UI requires its own justified contract.
- **References are environment-variable-shaped** — one flat POSIX-identifier namespace remains sufficient for current consumers.

View File

@@ -2,13 +2,7 @@
[English](README.md) | 中文
抽象凭据 seam`ctx.credentials`)。一条准则,三个推论:
**配置只携带对机密的引用,绝不携带机密本身。** settings 分节或 `cordis.yml` 条目写 `apiKeyEnv: DEEPSEEK_API_KEY`,引用背后的值归凭据 provider 所有。于是设置文档可以放心同步、放心渲染进配置界面;`describe()` 无需持有值就能回答「配置了吗、来自哪层、能否写入」;轮换机密不触碰任何配置文件。
**消费方按操作解析。** `resolve(ref)` 在每个操作开始时调用LLM 适配器每次模型请求解析一次),绝不跨操作缓存——正是这次读取让改过的凭据无需重启任何插件就作用于下一次请求。
**空的存储值等于不存在。**处处如此:`resolve` 跳过它,`describe` 报告未配置。空白永远不会伪装成已配置的机密。
抽象的只读凭据 seam`ctx.credentials`)。配置携带 `DEEPSEEK_API_KEY` 这样的品牌化引用;值归提供方所有,消费方只在操作开始时解析它。
## 接口面
@@ -18,24 +12,19 @@ import { credentialRef } from '@deepseek-ai/dsh-credentials'
declare const ctx: Context
const ref = credentialRef('DEEPSEEK_API_KEY') // POSIX shell identifier, branded
const hit = await ctx.credentials.resolve(ref) // { value, source } | undefined
const info = await ctx.credentials.describe(ref) // { configured, source?, writable } — never the value
await ctx.credentials.set(ref, 'sk-…') // rejects while a read-only source shadows the ref
await ctx.credentials.unset(ref) // no-op when absent; same shadowing rule
const ref = credentialRef('DEEPSEEK_API_KEY')
const value = await ctx.credentials.resolve(ref) // string | undefined
```
`credentials/updated (ref)` 在 provider 管理的来源发生已提交变更后触发——`set``unset` 或在存储中观察到的外部编辑。进程环境变量的变化不可观测,永不触发。消费方需要该事件(它们按操作重新解析);它服务于配置界面刷新「已配置」徽标
`set`/`unset` 的遮蔽规则是刻意的响亮失败:当只读来源(本地 provider 中即活跃进程环境正在提供该引用时写入会表面成功而解析仍返回遮蔽值——seam 选择直接拒绝,并通过 `describe().writable` 让界面提前把该引用渲染为只读。
`credentialRef()` 接受 POSIX 风格的环境变量名并为其添加品牌类型使引用不会与其他跨包package字符串混用。`resolve(ref)` 返回当前非空值,未配置时返回 `undefined`。消费方每个操作解析一次,不跨操作缓存;在当前消费方需要之前seam 不引入修改、来源元数据、枚举或变更事件
## Providers
[`dsh-credentials-local`](../credentials-local/README.md) 把活跃进程环境叠加在 `$DSH_HOME/.env` 文件之上。seam 形状为 keyring、辅助命令KMS 后端的 provider 留好了位置;远端 settings provider 永远不必携带机密
[`dsh-credentials-local`](../credentials-local/README.md) 把活跃进程环境叠加在 `$DSH_HOME/.env` 文件之上。其他提供方可以从 keyring、辅助命令KMS 解析相同的引用词汇,而无需改动消费方
## Model Experience
经由消费它的 LLM 适配器间接生效:解析出的值为适配器的提供方请求授权,每个模型可见面都归适配器所有。
经由消费它的 LLM(大语言模型)适配器间接生效:解析出的值为适配器的提供方请求授权,每个模型可见面都归适配器所有。
#### KV Cache effect
@@ -43,6 +32,5 @@ await ctx.credentials.unset(ref) // no-op when absent; s
## Known Limitations and Deferred Work
- **不提供枚举**——seam 只回答被问到的引用;配置界面从 settings schema 得知引用集合,`list()` 没有当前消费方
- **引用限定为环境变量形状**——在有 provider 需要更丰富寻址前,保持单一扁平的 POSIX 标识符命名空间。
- **进程环境变化不可见**——不可能为其发事件;界面只能在自身导航时重新读取 `describe()`
- **不提供修改、描述或枚举**seam 只解析消费方配置已经点名的引用;凭据管理 UI 需要自身有明确依据的契约
- **引用限定为环境变量形状**单一扁平的 POSIX 标识符命名空间足以满足当前消费方

View File

@@ -1,10 +1,6 @@
/**
* Credential seam (`ctx.credentials`). Settings and composition files carry
* *references* to secrets — environment-variable names — while providers own
* the actual values and their storage. Consumers resolve a reference once per
* operation, so a changed credential reaches the next operation without any
* plugin restart, and configuration surfaces describe a reference without
* ever seeing its value.
* Read-only credential seam (`ctx.credentials`). Configuration carries
* branded references to secrets; providers resolve their current values.
* @module @deepseek-ai/dsh-credentials
*/
@@ -28,135 +24,25 @@ export function credentialRef(value: string): CredentialRef {
return value as CredentialRef
}
/** One resolved credential value and the source layer that supplied it. */
export interface ResolvedCredential {
/** The non-empty secret value. */
value: string
/** Provider-defined source layer id (the local provider uses `env` and `file`). */
source: string
}
/** Source and writability facts for one reference, safe for configuration UIs — never the value. */
export interface CredentialInfo {
/** Whether {@link Credentials.resolve} would currently return a value. */
configured: boolean
/** Source layer currently supplying the value; absent while unconfigured. */
source?: string
/** Whether {@link Credentials.set} would currently succeed for this reference. */
writable: boolean
}
declare module 'cordis' {
interface Context {
credentials: Credentials
}
interface Events {
/**
* Committed change to a provider-managed credential source: a `set`, an
* `unset`, or an external edit observed in storage. Ambient
* process-environment changes are not observable and never emit. Listener
* failures are contained and logged — a sync throw and an async rejection
* alike — without changing the committed operation's outcome, except
* `INVARIANT`-coded failures, which rethrow after every listener ran;
* that rethrow reaches the emitter only from synchronous listeners, so
* invariant checks on this event must not be async functions.
* @param ref - the reference whose stored value changed.
* @mode emit
*/
'credentials/updated'(ref: CredentialRef): void
}
}
/**
* Abstract credential service. Providers implement the four operations over
* their source layers; one seam-wide rule binds them all: an empty stored
* value is absent everywhere — `resolve` skips it, `describe` reports it
* unconfigured — so a blank never masquerades as a configured secret.
*/
/** Abstract read-only credential service. */
export abstract class Credentials extends Service {
constructor(ctx: Context) {
super(ctx, 'credentials')
}
/**
* Resolve one reference to its current value. Resolution is per call:
* consumers re-resolve at each operation and must not cache across
* operations — that per-operation read is what makes a changed credential
* reach the next operation without a restart.
* Resolve one reference to its current non-empty value. Consumers call once
* per operation and do not cache across operations.
* @param ref - the reference to resolve.
* @returns the value and its source, or `undefined` while unconfigured.
* @returns the current value, or `undefined` while unconfigured.
*/
abstract resolve(ref: CredentialRef): Promise<ResolvedCredential | undefined>
/**
* Describe one reference for configuration surfaces without exposing the
* value.
* @param ref - the reference to describe.
* @returns configured state, supplying source, and writability.
*/
abstract describe(ref: CredentialRef): Promise<CredentialInfo>
/**
* Durably store one value in the provider-managed writable source. Rejects
* while a read-only source shadows the reference — the write would appear
* to succeed while resolution keeps returning the shadowing value — and
* rejects an empty value (use {@link unset}).
* @param ref - the reference to store.
* @param value - the non-empty secret value.
*/
abstract set(ref: CredentialRef, value: string): Promise<void>
/**
* Remove one reference from the provider-managed writable source; removing
* an absent reference is a no-op. Rejects while a read-only source shadows
* the reference, like {@link set}.
* @param ref - the reference to remove.
*/
abstract unset(ref: CredentialRef): Promise<void>
/* jscpd:ignore-start -- deliberate symmetry with the settings seam's commit
fan-out: the contained-dispatch shape is the reviewed listener-lifecycle
contract, and extracting it would couple the two seams' event semantics. */
/**
* Fan `credentials/updated` out with contained listener failures: every
* listener runs, and a sync throw or async rejection is logged without
* changing the committed operation's outcome — except `INVARIANT`-coded
* failures, which rethrow after every listener ran (the rethrow reaches the
* caller only from synchronous listeners, so invariant checks on this event
* must not be async functions). Providers call this only after the write or
* reload actually committed, so a broken observer can never make a durable
* change look failed.
* @param ref - the reference whose stored value changed.
*/
protected notifyUpdated(ref: CredentialRef): void {
let invariantFailure: unknown
const args = ['credentials/updated', ref]
for (const listener of this.ctx.events.dispatch('emit', args) as Array<(...listenerArgs: unknown[]) => unknown>) {
try {
const returned = listener(ref)
if (returned != null && typeof (returned as PromiseLike<unknown>).then === 'function') {
void Promise.resolve(returned as PromiseLike<unknown>).then(undefined, (error: unknown) => {
this.warnListenerFailure(ref, error)
})
}
} catch (error) {
if ((error as { code?: unknown } | null)?.code === 'INVARIANT') {
invariantFailure ??= error
continue
}
this.warnListenerFailure(ref, error)
}
}
if (invariantFailure !== undefined) throw invariantFailure as Error
}
/* jscpd:ignore-end */
/** Contained-listener diagnostic shared by the sync and async failure paths. */
private warnListenerFailure(ref: CredentialRef, error: unknown): void {
this.ctx.logger.warn('credentials: a credentials/updated listener for "%s" failed', ref)
this.ctx.logger.warn(error)
}
abstract resolve(ref: CredentialRef): Promise<string | undefined>
}
export default Credentials

View File

@@ -4,7 +4,7 @@
*/
import type { Context } from 'cordis'
import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-credentials'
@@ -14,20 +14,11 @@ export const name = 'credentials-invariant'
export const inject = ['invariants']
/**
* Install the commit-event lifecycle contract: `credentials/updated` names a
* committed provider-source change, so it can only fire while a credentials
* service is live — an emission after disposal means a provider leaked work
* past its teardown quiescence. The value relation itself (`describe`
* agreeing with `resolve`) is asynchronous provider I/O and stays pinned by
* each provider's own suite.
* No runtime invariant: this read-only seam exposes no event sequence or
* mutable data relation; provider resolution crosses an asynchronous I/O
* boundary and stays pinned by each provider's own suite.
*/
const install: InvariantInstaller = (ctx: Context, fail: InvariantFailure) => {
ctx.on('credentials/updated', (ref) => {
if (ctx.get('credentials') === undefined) {
fail(`credentials/updated for "${ref}" emitted without a live credentials service`)
}
})
}
const install: InvariantInstaller = () => {}
/**
* Register this package's invariant companion.

View File

@@ -1,17 +1,10 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { credentialRef } from '../src/index.ts'
import type { CredentialRef } from '../src/index.ts'
import { MemoryCredentials } from './memory.ts'
const REF = credentialRef('DEEPSEEK_API_KEY')
async function boot(seed: Record<string, string> = {}): Promise<Context> {
const ctx = new Context()
await ctx.plugin(MemoryCredentials, seed)
return ctx
}
describe('credentialRef', () => {
it('brands POSIX shell identifiers', () => {
expect(credentialRef('DEEPSEEK_API_KEY')).toBe('DEEPSEEK_API_KEY')
@@ -26,39 +19,17 @@ describe('credentialRef', () => {
})
})
describe('the credentials seam through the memory provider', () => {
it('mounts as ctx.credentials and resolves a seeded reference with its source', async () => {
const ctx = await boot({ DEEPSEEK_API_KEY: 'sk-seeded' })
expect(await ctx.credentials.resolve(REF)).toEqual({ value: 'sk-seeded', source: 'memory' })
expect(await ctx.credentials.describe(REF)).toEqual({ configured: true, source: 'memory', writable: true })
describe('the credentials seam', () => {
it('mounts as ctx.credentials and resolves non-empty values', async () => {
const ctx = new Context()
await ctx.plugin(MemoryCredentials, { DEEPSEEK_API_KEY: 'sk-seeded' })
expect(await ctx.credentials.resolve(REF)).toBe('sk-seeded')
})
it('treats an empty stored value as absent everywhere', async () => {
const ctx = await boot({ DEEPSEEK_API_KEY: '' })
it('treats an empty provider value as absent', async () => {
const ctx = new Context()
await ctx.plugin(MemoryCredentials, { DEEPSEEK_API_KEY: '' })
expect(await ctx.credentials.resolve(REF)).toBeUndefined()
expect(await ctx.credentials.describe(REF)).toEqual({ configured: false, writable: true })
})
it('stores through set, removes through unset, and emits the committed change', async () => {
const ctx = await boot()
const events: CredentialRef[] = []
ctx.on('credentials/updated', ref => void events.push(ref))
await ctx.credentials.set(REF, 'sk-live')
expect(await ctx.credentials.resolve(REF)).toEqual({ value: 'sk-live', source: 'memory' })
await ctx.credentials.unset(REF)
expect(await ctx.credentials.resolve(REF)).toBeUndefined()
expect(events).toEqual([REF, REF])
})
it('rejects an empty set and keeps an absent unset silent', async () => {
const ctx = await boot()
const events: CredentialRef[] = []
ctx.on('credentials/updated', ref => void events.push(ref))
await expect(ctx.credentials.set(REF, '')).rejects.toThrow(/empty value/)
await ctx.credentials.unset(REF)
expect(events).toEqual([])
})
it('removes the service with its fiber', async () => {

View File

@@ -1,30 +1,9 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import InvariantService from '@deepseek-ai/dsh-invariants'
import { credentialRef } from '../src/index.ts'
import * as CredentialsInvariant from '../src/invariant.ts'
import { MemoryCredentials } from './memory.ts'
const REF = credentialRef('DEEPSEEK_API_KEY')
describe('credentials invariant companion', () => {
it('accepts a committed change emitted by a live service', async () => {
const ctx = new Context()
await ctx.plugin(InvariantService)
await ctx.plugin(CredentialsInvariant)
await ctx.plugin(MemoryCredentials)
await expect(ctx.credentials.set(REF, 'sk-live')).resolves.toBeUndefined()
})
it('fails an update event emitted without a live service', async () => {
const ctx = new Context()
await ctx.plugin(InvariantService)
await ctx.plugin(CredentialsInvariant)
expect(() => { ctx.emit('credentials/updated', REF) }).toThrow(/invariant violated by "@deepseek-ai\/dsh-credentials"/)
})
it('reserves the package name against duplicate registration', async () => {
const ctx = new Context()
await ctx.plugin(InvariantService)

View File

@@ -1,11 +1,8 @@
import type { Context } from 'cordis'
import { Credentials } from '../src/index.ts'
import type { CredentialInfo, CredentialRef, ResolvedCredential } from '../src/index.ts'
import type { CredentialRef } from '../src/index.ts'
/**
* In-memory credentials provider for interface and consumer tests: one
* always-writable `memory` source seeded from plugin config.
*/
/** In-memory read-only credentials provider for seam tests. */
export class MemoryCredentials extends Credentials {
private readonly store = new Map<string, string>()
@@ -14,36 +11,8 @@ export class MemoryCredentials extends Credentials {
for (const [key, value] of Object.entries(seed)) this.store.set(key, value)
}
override resolve(ref: CredentialRef): Promise<ResolvedCredential | undefined> {
override resolve(ref: CredentialRef): Promise<string | undefined> {
const value = this.store.get(ref)
return Promise.resolve(value === undefined || value.length === 0
? undefined
: { value, source: 'memory' })
}
override describe(ref: CredentialRef): Promise<CredentialInfo> {
const value = this.store.get(ref)
const configured = value !== undefined && value.length > 0
return Promise.resolve({
configured,
...configured ? { source: 'memory' } : {},
writable: true,
})
}
override set(ref: CredentialRef, value: string): Promise<void> {
if (value.length === 0) {
return Promise.reject(new Error('memory credentials: an empty value cannot be stored; use unset'))
}
this.store.set(ref, value)
this.ctx.emit('credentials/updated', ref)
return Promise.resolve()
}
override unset(ref: CredentialRef): Promise<void> {
if (this.store.delete(ref)) {
this.ctx.emit('credentials/updated', ref)
}
return Promise.resolve()
return Promise.resolve(value === undefined || value.length === 0 ? undefined : value)
}
}