@deepseek-ai/dsh-credentials-local 0.1.1-rc.1 → 0.1.2-alpha.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.i18n.yaml CHANGED
@@ -2,5 +2,5 @@
2
2
  # side as of the last confirmed-consistent state. Both languages carry equal authority;
3
3
  # after editing either side, bring the other along and re-record with:
4
4
  # pnpm run verify-translation-pairing --write packages/credentials/credentials-local/README.md
5
- README.md: 7acd89ea24be4da3d2cecc6efe619e73e05bb823
6
- README.zh.md: d0443c31661eb0750b02f4f2b4c396730867904f
5
+ README.md: b46bdaaecc6758f787885f0f153372a2b67c3fee
6
+ README.zh.md: b8362233f7046dc99571de4f4ddc92f59763b793
package/README.md CHANGED
@@ -1,32 +1,85 @@
1
- # dsh-credentials-local
1
+ ---
2
+ description: "The file-backed credentials provider for users and maintainers choosing, configuring, or debugging the local credential store and its environment layering."
3
+ kind: "package-reference"
4
+ ---
5
+
6
+ # @deepseek-ai/dsh-credentials-local
2
7
 
3
8
  English | [中文](README.zh.md)
4
9
 
5
- File-backed [credentials](../credentials/README.md) provider: four layers, one honest precedence.
10
+ ## Summary
11
+
12
+ `dsh-credentials-local` is the product's default on-machine credential store: a private file under your harness home where API keys and other secrets live, written from a configuration UI and reloaded automatically when you edit the file yourself. The file is a versioned document with a `refs` section for key values and a `records` section for durable per-plugin credentials, so an authorization grant or provider environment survives restarts beside the keys. Keys come from four places in one fixed order: the environment you launch in wins, then the stored file, then your project's and your home `.env` files. A key you save takes effect immediately, even when an older key sits in a `.env`. Only your OS user can read the file, and the product never hands the agent the file's path.
13
+
14
+ ## Table of Contents
15
+
16
+ - [Use this package](#use-this-package)
17
+ - [Understand the implementation](#understand-the-implementation)
18
+ - [Further Exploration](#further-exploration)
19
+ - [Model Experience](#model-experience)
20
+ - [Known Limitations and Deferred Work](#known-limitations-and-deferred-work)
21
+ - [Dev Note](#dev-note)
22
+
23
+ -----
24
+
25
+ <a id="use-this-package"></a>
26
+ ## Use this package
6
27
 
7
- | Layer | Source id | Writable | Wins |
8
- |---|---|---|---|
9
- | Inherited process environment | `env` | no | always |
10
- | `$DSH_HOME/.credentials.yaml` document | `file` | yes (`set`/`unset`) | over both `.env` layers |
11
- | `<invocation cwd>/.env` | `project-env` | not here | over the user `.env` |
12
- | `$DSH_HOME/.env` | `user-env` | not here | otherwise |
28
+ This package gives a composition a local credential store: save API keys and other secrets once, and every request that names them uses them. The common path is explicit: load the store, save keys through the configuration UI or `ctx.credentials`, and let the product resolve them when needed.
13
29
 
14
- The launching environment wins because a per-run override (`DEEPSEEK_API_KEY=… dsh`, a CI secret, a container `-e`) 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.
30
+ ### When to use it
15
31
 
16
- Everything below it loses to the managed store, so a key written by the Models page takes effect immediately even when an older key sits in a `.env`. Those two layers still resolve when nothing is stored, and `describe()` names them `project-env` or `user-env` with `writable: true` — storing a key replaces them as the effective source.
32
+ Use it as the default local store: the product's base composition loads it, and a key you save through a configuration UI takes effect immediately. Choose a different store when a deployment must keep provider keys away from its own agent — file permissions cannot do that, because the agent's tool processes run as your OS user (see "Who can read the file").
17
33
 
18
- Under the product CLI, resolution reads the launcher's frozen [environment snapshot](../../util/launch-environment/README.md) rather than `process.env`: only the snapshot can say whether a value came from the launching shell or from a file. A composition the product CLI did not boot has the inherited environment as its only layer, which keeps embedders on the semantics they already had.
34
+ ### Setting it up
19
35
 
20
- ## Config
36
+ ```yaml
37
+ - name: '@deepseek-ai/dsh-credentials-local'
38
+ config:
39
+ path: /absolute/path/to/.credentials.yaml
40
+ ```
21
41
 
22
42
  | Field | Default | Meaning |
23
43
  |---|---|---|
24
- | `path` | `<harness home>/.credentials.yaml` | Credentials document location. |
25
- | `dshHome` | `$DSH_HOME` or `~/.dsh` | Harness home used when `path` is omitted. |
26
- | `watch` | `true` | Hot-publish external edits. |
27
- | `debounceMs` | `100` | Watcher write-settle window. |
44
+ | `path` | `<harness home>/.credentials.yaml` | Where the credential file lives |
45
+ | `dshHome` | `$DSH_HOME` or `~/.dsh` | Harness home used when `path` is omitted |
46
+ | `watch` | `true` | Reload the file automatically when it changes on disk |
47
+ | `debounceMs` | `100` | Wait this long after a change before reloading, in milliseconds |
48
+
49
+ The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-credentials-local) is the exhaustive source for every accepted field and its JSDoc.
50
+
51
+ ### Storing and removing keys
52
+
53
+ Save a key with `set`, remove it with `unset`, and check whether a key is configured with `describe` — the same operations the credential API provides:
54
+
55
+ ```ts
56
+ import type { Context } from '@deepseek-ai/cordis'
57
+ import { credentialRef } from '@deepseek-ai/dsh-credentials'
28
58
 
29
- ## The document
59
+ declare const ctx: Context
60
+
61
+ const ref = credentialRef('DEEPSEEK_API_KEY')
62
+ await ctx.credentials.set(ref, 'sk-…') // save
63
+ await ctx.credentials.describe(ref) // { configured, source?, writable } — never the value
64
+ await ctx.credentials.unset(ref) // remove
65
+ ```
66
+
67
+ A key you save is usable by the next request that names it, and `describe` reports whether it is set, where it comes from, and whether you can write to it — never the value itself. Records persist in the same file, addressed by `<owner>/<id>` and managed with the seam's record operations (`readRecord`, `describeRecord`, `listRecords`, `modifyRecord`, `deleteRecord`).
68
+
69
+ ### Where keys come from
70
+
71
+ Keys are resolved in one fixed order — the first place that has a value wins:
72
+
73
+ | Place | Writable? | Wins over |
74
+ |---|---|---|
75
+ | The environment you launched in (`DEEPSEEK_API_KEY=… dsh`) | no | everything |
76
+ | The stored file | yes (`set`/`unset`) | both `.env` files |
77
+ | Your project's `.env` (`<invocation cwd>/.env`) | not here | your home `.env` |
78
+ | Your home `.env` (`$DSH_HOME/.env`) | not here | nothing |
79
+
80
+ The launching environment wins because a per-run override — `DEEPSEEK_API_KEY=… dsh`, a CI secret, a container `-e` — is this run's explicit intent; it cannot be edited from inside the product, so it is reported read-only and writes to it are refused. Everything else loses to the stored file, which is why a key you save takes effect immediately even when an older key sits in a `.env`; those two `.env` layers resolve when nothing is stored. The environment layer is the launcher's snapshot taken at launch ([environment snapshot](../../util/launch-environment/README.md)), so a variable exported after startup is not seen.
81
+
82
+ ### The credential file
30
83
 
31
84
  A versioned YAML document with one section per key space, and nothing else:
32
85
 
@@ -53,41 +106,109 @@ records:
53
106
  kind: api-key # neither: the owner confirmed the ambient credential chain
54
107
  ```
55
108
 
56
- The document holds credentials only, so every deviation is a rejection rather than a skipped entry — a silently ignored key would read as "the credential I stored has no effect". A non-mapping root, an unknown top-level key, a key that is not addressable in its space, a wrong-typed value, an empty string, an unknown record tag or field, a duplicate key, and malformed YAML all fail: loud at boot, and warn-and-keep-the-last-good-snapshot on a live reload.
109
+ You can edit the file directly — the store reloads it automatically and picks up the change, including a key or record you delete. `refs` holds key values by environment-variable name; `records` holds per-plugin credentials by `<owner>/<id>`, each tagged `api-key` or `grant`, whose grant payload the store keeps verbatim because only its owner can interpret it. Comments and the formatting of untouched entries survive product writes; a comment directly above an entry is that entry's note and is removed with it. The file holds only credentials, so anything else is refused loudly rather than silently ignored: a non-mapping root, an unknown top-level key, a key that is not addressable in its space, a wrong-typed or empty value, an unknown record tag or field, duplicate keys, and malformed YAML all fail at startup, and on a live reload the last good content keeps serving with a warning.
110
+
111
+ A key's value can be any text, multi-line values included — no quoting tricks needed. An empty value means "no key", which is why an empty string in the file is rejected: removing a key deletes it, it does not blank it. A `grant` payload must survive a JSON round trip, enforced on the way in and on the way out, so the store refuses a value it could not read back exactly as written. If the file on disk no longer parses, saving fails instead of overwriting content the product could not read.
112
+
113
+ ### Who can read the file
57
114
 
58
- A `grant` payload must survive a JSON round trip, enforced in both directions. YAML spells values JSON has none for — `.inf`, alias cycles — and an owner may hand over a `Date` or a `bigint`; either way the store refuses rather than saving something it could not read back exactly as written.
115
+ Only your OS user can read the file: the product creates it with owner-only permissions, and on POSIX it refuses to load a file that any other user can read — the error tells you to run `chmod 600`. Windows has no mode to inspect, so the check is skipped there rather than faked. The agent is not another user: its tool processes run as you, so they can read the file like any other file you own. The product never hands the agent the file's path and never loads the file into the environment, so reaching a 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.
59
116
 
60
- The pre-release layout was a flat mapping with no `version`. A boot that recognizes it exactly — addressable names over non-empty string scalars, no directives — upgrades the document in place under the writer lock: the original lines nest verbatim under `refs:`, so values, comments, and spellings survive byte for byte. Any other flat shape is refused by name, with the entry count and the one edit needed (`version: 1`, nest under `refs:`) — never read as an empty store, which would surface as an authentication failure on the first request instead of at load. A live reload never migrates: a flat document restored mid-run keeps the last good snapshot until the next boot.
117
+ ### What can go wrong
61
118
 
62
- 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.
119
+ - **A key the launching environment supplies is read-only** — `DEEPSEEK_API_KEY=… dsh` wins for this run; saving or removing it is refused. Clear the variable in the launching shell first.
120
+ - **An empty value cannot be saved** — storing an empty string is refused; remove the key instead.
121
+ - **The store refuses to load a file it cannot trust** — a file any other user can read, malformed YAML, or an unreachable path fails at startup; on a live reload the last good content keeps serving with a warning.
122
+ - **Changes made at the same time are both kept** — if you edit the file while the product writes, your change is folded in rather than overwritten.
63
123
 
64
- 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.
124
+ -----
65
125
 
66
- ## Permissions
126
+ <a id="understand-the-implementation"></a>
127
+ ## Understand the implementation
67
128
 
68
- The provider creates the directory `0700` and creates or atomically replaces the document `0600`. It holds what it *reads* to that same bound: on POSIX a document carrying any group or other permission bit fails before its contents are parsed — at boot and on every reload — and the error names the `chmod 600` repair. Windows has no mode to inspect, so the check is skipped there rather than faked.
129
+ <details>
130
+ <summary>Implementation internals — click to expand</summary>
69
131
 
70
- ## Hot reload
132
+ This section explains the design decisions behind the provider and points at the code that realizes them; the observable behavior is fully covered in [Use this package](#use-this-package).
71
133
 
72
- External edits publish `credentials/reference-updated` per changed reference after the snapshot is replaced **wholesale** — an entry deleted on disk never lingers in memory. Before Chokidar opens the target, the provider realpaths its deepest existing ancestor and restores any missing suffix; file access and diagnostics retain the configured path, while Windows cannot mix an 8.3 alias with long-form libuv events. 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.
134
+ ### Design philosophy
73
135
 
74
- ## Security boundary
136
+ - **One honest precedence.** The inherited environment wins because it is this run's explicit intent and cannot be edited from inside; everything below it loses to the managed store, so a stored key is never displaced by a stale `.env`.
137
+ - **The document holds only credentials.** A versioned document with `refs` and `records` sections 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, which would shadow non-secret entries behind its precedence.
138
+ - **Writes patch, reloads replace.** Line edits preserve comments and untouched entries under the cross-process writer lock; reloads swap the parsed snapshot wholesale so a deleted entry never lingers in memory.
139
+ - **Fail loud where trust is at stake.** Boot and reload reject a document that is unreadable, invalid, or readable beyond its owner; a live reload that fails keeps the last good snapshot and warns rather than taking the process down.
75
140
 
76
- 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 Harness-home layers](../../boot/app-boot/README.md#profiles)) — so reaching the value takes a deliberate read of a path the agent was not given.
141
+ ### Source map
77
142
 
78
- 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.
143
+ | File | Role |
144
+ |---|---|
145
+ | [`src/index.ts`](src/index.ts) | Provider: layer resolution, strict document parse, reference and record write paths under the writer lock, watcher lifecycle, permissions check |
146
+ | [`src/invariant.ts`](src/invariant.ts) | Invariant companion (no runtime invariant; the seam companion owns the event lifecycle contract) |
79
147
 
148
+ ### Resolution and write paths
149
+
150
+ `resolve` and `describe` read the inherited environment snapshot, the parsed document snapshot, and the `.env` fallbacks in precedence order. `set`/`unset` queue onto one exclusive operation chain: entry checks reject early (disposed, empty value, environment-shadowed), and the queue re-judges them at run time before a read-modify-write under the writer lock commits and fires `credentials/reference-updated` exactly once.
151
+
152
+ `modifyRecord` runs on the same chain and lock: it re-reads the document, shows the mutation the record as it stands, admits the result — a non-empty api key, a grant payload that survives a JSON round trip — renders the record wholesale, and commits, firing `credentials/record-updated` once. A composition the product CLI did not boot has only the inherited environment as its layer.
153
+
154
+ ### Reload lifecycle
155
+
156
+ A watcher event or the ready reconcile queues a refresh behind the same chain. `reconcileFromDisk` re-checks permissions, re-reads the text, replaces both snapshots wholesale when the text differs, and publishes one event per changed reference or record; content equal to the text cache — including the provider's own writes — is a no-op. Disposal sets the closed flag, stops accepting events, closes the watcher, and waits out queued operations so nothing publishes after teardown.
157
+
158
+ ### Document versioning
159
+
160
+ The document carries `version: 1`, stamped on every write. A boot that recognizes the pre-release flat layout — a bare mapping of reference names with no `version` — upgrades the document in place under the writer lock, nesting the original lines under `refs:` so values, comments, and spellings survive byte for byte; any other unversioned shape is refused by name rather than read as an empty store. A live reload never migrates: a flat document restored mid-run keeps the last good snapshot until the next boot.
161
+
162
+ ### Diagnostics never quote a value
163
+
164
+ The YAML parser's own message quotes the offending source line, which in this document is the secret itself. Every diagnostic therefore carries only the error code and position — a key name is safe to print, a value is not.
165
+
166
+ </details>
167
+
168
+ -----
169
+
170
+ <a id="further-exploration"></a>
171
+ ## Further Exploration
172
+
173
+ Read these pages when the provider-level contract is not enough. They move from the seam contract to the environment snapshot, the atomic-write primitive, and the boot-time environment layers.
174
+
175
+ - [Credential-reference seam](../credentials/README.md) — `resolve`, `describe`, `set`, `unset`, the record operations, and the seam's update events.
176
+ - [Credentials subsystem reference](../../../docs/subsystems/credentials.md) — `CredentialRef`, per-operation resolution, UI-safe `CredentialInfo`, provider layers.
177
+ - [Launch environment snapshot](../../util/launch-environment/README.md) — the frozen layer snapshot resolution reads instead of `process.env`.
178
+ - [Atomic write](../../util/atomic-write/README.md) — the writer lock and atomic replacement every write uses.
179
+ - [App boot and Harness-home layers](../../boot/app-boot/README.md) — how the product CLI loads `.env` into the snapshot and `process.env`.
180
+
181
+ -----
182
+
183
+ <a id="model-experience"></a>
80
184
  ## Model Experience
81
185
 
82
- Indirectly, through the consuming LLM adapters: stored values authorize their provider requests, and the adapter owns every model-visible surface.
186
+ Indirectly, through the consumers of `ctx.credentials`, which own any model-facing behavior a stored value enables.
83
187
 
84
188
  #### KV Cache effect
85
189
 
86
- No direct invalidation; credentials never enter a request prefix.
190
+ No direct invalidation; stored values never enter a request prefix.
87
191
 
88
192
  ## Known Limitations and Deferred Work
89
193
 
194
+ <a id="known-limitations-and-deferred-work"></a>
195
+
196
+
197
+ These limits define when the provider is a poor fit or needs special operational care. They are current package constraints, not a task backlog.
198
+
90
199
  - **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.
91
- - **A same-UID process can read the document** — see [Security boundary](#security-boundary): the file-effect sandbox modes do not deny reads, and an OS-keychain provider is deferred.
200
+ - **A same-UID process can read the document** — the file-effect sandbox modes do not deny reads, and an OS-keychain provider is deferred.
92
201
  - **Environment changes are invisible** — the snapshot is frozen at launch, so a variable exported after startup reaches neither resolution nor `describe`; changing an environment-sourced credential takes a restart.
93
202
  - **Atomic, not crash-durable** — inherited from `dsh-atomic-write`; the store re-reads on boot.
203
+
204
+ <a id="dev-note"></a>
205
+ ### Dev Note
206
+
207
+ <details>
208
+ <summary>Working context for maintainers — click to expand</summary>
209
+
210
+ This Dev Note is working context for maintainers: open questions and undecided directions. It is explicitly non-authoritative — shipped behavior and limits live in the sections above and in the package code.
211
+
212
+ An OS-keychain provider — a store the model's processes cannot read — is the deferred answer to the same-UID limitation and belongs beside this provider as a sibling package. The seam shape also leaves room for helper-command- and KMS-backed providers; none is shipped.
213
+
214
+ </details>
package/README.zh.md CHANGED
@@ -1,34 +1,87 @@
1
- # dsh-credentials-local
1
+ ---
2
+ description: "面向用户与维护者的文件型凭据提供方:选择、配置或排查本地凭据存储及其环境分层。"
3
+ kind: "package-reference"
4
+ ---
5
+
6
+ # @deepseek-ai/dsh-credentials-local
2
7
 
3
8
  [English](README.md) | 中文
4
9
 
5
- 文件型[凭据](../credentials/README.zh.md)提供方:四层来源,一套明确的优先级。
10
+ ## 概述
11
+
12
+ `dsh-credentials-local` 是产品默认的本机凭据存储:harness home 下的一个私有文件,存放 API 密钥与其他机密,可由配置界面写入,你自己编辑该文件时也会自动重载。该文件是带版本的文档,含一个存放密钥值的 `refs` 分节和一个存放持久化按插件记录的 `records` 分节,因此授权 grant 或提供方环境值能与密钥一起跨重启保留。密钥来自四个位置,顺序固定:你启动时的环境优先,其次是存储文件,再次是项目和主目录的 `.env` 文件。你保存的密钥会立即生效,即使某个 `.env` 里还留着更旧的密钥。只有你的 OS 用户能读取该文件,而且产品绝不把文件路径交给 agent(智能体)。
13
+
14
+ ## 目录
15
+
16
+ - [使用本包](#use-this-package)
17
+ - [理解实现](#understand-the-implementation)
18
+ - [进一步探索](#further-exploration)
19
+ - [模型体验](#model-experience)
20
+ - [已知限制与延期工作](#known-limitations-and-deferred-work)
21
+ - [开发备注](#dev-note)
22
+
23
+ -----
24
+
25
+ <a id="use-this-package"></a>
26
+ ## 使用本包
6
27
 
7
- | 层 | 来源 id | 可写 | 优先 |
8
- |---|---|---|---|
9
- | 继承的进程环境 | `env` | 否 | 始终优先 |
10
- | `$DSH_HOME/.credentials.yaml` 文档 | `file` | 是(`set`/`unset`) | 高于两个 `.env` 层 |
11
- | `<invocation cwd>/.env` | `project-env` | 不在此处 | 高于用户 `.env` |
12
- | `$DSH_HOME/.env` | `user-env` | 不在此处 | 其余情况 |
28
+ 本包为组合提供本地凭据存储:API 密钥与其他机密只需保存一次,之后每个按名引用它们的请求都会用到。常用路径是显式的:加载存储、通过配置界面或 `ctx.credentials` 保存密钥,然后在需要时由产品解析。
13
29
 
14
- 启动环境优先,因为按次覆盖(`DEEPSEEK_API_KEY=… dsh`、CI 机密、容器 `-e`)代表本次运行的操作者意图——而它无法从进程内部修改,就必须*可见地*只读:`describe()` 报告 `source: 'env', writable: false`,`set`/`unset` 直接拒绝,而不是写下一个读取方永远看不到的变更。
30
+ ### 何时使用
15
31
 
16
- 它之下的所有来源优先级都低于受管存储,因此 Models 页写入的密钥会立即生效,即使某个 `.env` 里还留着更旧的密钥。没有存储任何东西时这两层仍会参与解析,`describe()` 会把来源报告为 `project-env` 或 `user-env` 且 `writable: true`——存入一个密钥就会取代它们成为生效来源。
32
+ 把它作为默认本地存储:产品的 base 组合会加载它,你通过配置界面保存的密钥会立即生效。当部署必须让提供方密钥远离自身 agent 时选择其他存储——文件权限做不到这一点,因为 agent 的工具进程以你的 OS 用户身份运行(见「谁能读取该文件」)。
17
33
 
18
- 在产品 CLI(命令行界面)下,解析读取的是启动器冻结的[环境快照](../../util/launch-environment/README.zh.md)而不是 `process.env`:只有快照才说得清某个值来自启动 shell 还是来自某个文件。并非由产品 CLI 启动的组合只有继承环境这一层,这让嵌入方保持它们原有的语义。
34
+ ### 设置
19
35
 
20
- ## 配置
36
+ ```yaml
37
+ - name: '@deepseek-ai/dsh-credentials-local'
38
+ config:
39
+ path: /absolute/path/to/.credentials.yaml
40
+ ```
21
41
 
22
42
  | 字段 | 默认值 | 含义 |
23
43
  |---|---|---|
24
- | `path` | `<harness home>/.credentials.yaml` | 凭据文档位置。 |
25
- | `dshHome` | `$DSH_HOME` 或 `~/.dsh` | `path` 缺省时使用的 harness home。 |
26
- | `watch` | `true` | 热发布外部编辑。 |
27
- | `debounceMs` | `100` | watcher 写入稳定窗口。 |
44
+ | `path` | `<harness home>/.credentials.yaml` | 凭据文件所在位置 |
45
+ | `dshHome` | `$DSH_HOME` 或 `~/.dsh` | `path` 缺省时使用的 harness home |
46
+ | `watch` | `true` | 文件在磁盘上变化时自动重载 |
47
+ | `debounceMs` | `100` | 变化后等待这么久再重载,单位为毫秒 |
48
+
49
+ 生成的[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-credentials-local)是每个受支持字段及其 JSDoc 的穷尽式真源。
28
50
 
29
- ## 文档本身
51
+ ### 存储与移除密钥
30
52
 
31
- 一个带版本的 YAML 文档,每个键空间一个分节,除此之外别无他物:
53
+ 用 `set` 保存密钥、用 `unset` 移除、用 `describe` 检查密钥是否已配置——与凭据 API 提供的操作相同:
54
+
55
+ ```ts
56
+ import type { Context } from '@deepseek-ai/cordis'
57
+ import { credentialRef } from '@deepseek-ai/dsh-credentials'
58
+
59
+ declare const ctx: Context
60
+
61
+ const ref = credentialRef('DEEPSEEK_API_KEY')
62
+ await ctx.credentials.set(ref, 'sk-…') // save
63
+ await ctx.credentials.describe(ref) // { configured, source?, writable } — never the value
64
+ await ctx.credentials.unset(ref) // remove
65
+ ```
66
+
67
+ 你保存的密钥会被下一个按名引用它的请求使用;`describe` 报告它是否已设置、来自哪里、能否写入——绝不返回值本身。记录也持久化在同一文件中:插件按 `<owner>/<id>` 寻址一条记录,并用 seam 的记录操作(`readRecord`、`describeRecord`、`listRecords`、`modifyRecord`、`deleteRecord`)管理它。
68
+
69
+ ### 密钥从哪里来
70
+
71
+ 密钥按一个固定顺序解析——先有值的位置胜出:
72
+
73
+ | 位置 | 可写? | 优先于 |
74
+ |---|---|---|
75
+ | 你启动时的环境(`DEEPSEEK_API_KEY=… dsh`) | 否 | 一切 |
76
+ | 存储文件 | 是(`set`/`unset`) | 两个 `.env` 文件 |
77
+ | 项目的 `.env`(`<invocation cwd>/.env`) | 不在此处 | 主目录 `.env` |
78
+ | 主目录的 `.env`(`$DSH_HOME/.env`) | 不在此处 | 无 |
79
+
80
+ 启动环境优先,因为按次覆盖——`DEEPSEEK_API_KEY=… dsh`、CI 机密、容器 `-e`——代表本次运行的明确意图;它无法从产品内部修改,因此被报告为只读,写入会被拒绝。其他一切来源都输给存储文件,这正是你保存的密钥会立即生效的原因,即使某个 `.env` 里还留着更旧的密钥;没有存储任何东西时,那两个 `.env` 层会参与解析。环境层是启动时拍摄的启动器[环境快照](../../util/launch-environment/README.zh.md),因此启动之后才导出的变量不会被看到。
81
+
82
+ ### 凭据文件本身
83
+
84
+ 带版本的 YAML 文档,每个键空间一个分节,除此之外别无他物:
32
85
 
33
86
  ```yaml
34
87
  version: 1
@@ -53,43 +106,109 @@ records:
53
106
  kind: api-key # neither: the owner confirmed the ambient credential chain
54
107
  ```
55
108
 
56
- 该文档只存放凭据,因此任何偏离都是拒绝,而不是跳过某个条目——被静默忽略的键读起来就是「我存进去的凭据没有生效」。非 mapping 的根、未知的顶层键、在其空间中不可寻址的键、类型不符的值、空字符串、未知的记录标签或字段、重复键以及格式错误的 YAML 全部失败:启动时明确报错,运行期热重载则告警并保留最后可用快照。
109
+ 你可以直接编辑该文件——存储会自动重载并接收变更,包括你删除的密钥或记录。`refs` 按环境变量名存放密钥值;`records` 按 `<owner>/<id>` 存放按插件凭据,每条都带 `api-key` 或 `grant` 标签,其中 grant 的 payload 由存储逐字保留,因为只有它的拥有者能解释。产品写入时会保留注释与未触及条目的排版;直接位于某条目上方的注释属于该条目的注解,会随它一起删除。文件只存放凭据,因此任何其他内容都会被明确拒绝,而不是被静默忽略:非 mapping 的根、未知的顶层键、在其键空间内不可寻址的键、类型错误或空的值、未知的记录标签或字段、重复键以及格式错误的 YAML 都会在启动时失败;运行期热重载时则保留最后可用内容并告警。
110
+
111
+ 密钥的值可以是任意文本,包括多行值——不需要任何引号技巧。空值等于「没有密钥」,这正是文件中的空字符串被拒绝的原因:移除密钥是删除它,而不是把它置空。`grant` 的 payload 必须经受 JSON 往返,进出两个方向都会强制这一点,因此存储会拒绝无法逐字读回的值。如果磁盘上的文件已无法解析,保存会失败,而不是覆盖产品读不懂的内容。
112
+
113
+ ### 谁能读取该文件
57
114
 
58
- `grant` 的 payload 必须能经受 JSON 往返,读写两个方向都强制。YAML 能拼写出 JSON 没有的值——`.inf`、别名环——拥有者也可能递来 `Date` 或 `bigint`;无论哪种,存储都选择拒绝,而不是存下一个自己无法逐字读回的东西。
115
+ 只有你的 OS 用户能读取该文件:产品以仅属主可访问的权限创建它,在 POSIX 上还会拒绝加载任何其他用户可读的文件——错误会提示你运行 `chmod 600`。Windows 没有可检查的 mode,因此在那里跳过该检查而不是伪造它。agent 不是另一个用户:它的工具进程以你的身份运行,因此它们读这个文件与读你拥有的任何其他文件毫无二致。产品绝不把文件路径交给 agent,也绝不把文件载入环境,因此要拿到某个值,需要刻意去读一条并未交给 agent 的路径。这是审慎,不是边界:必须让提供方密钥远离自身 agent 的部署无法靠文件权限做到。
59
116
 
60
- 发布前的旧布局是没有 `version` 的扁平 mapping。启动时若能精确识别它——可寻址名称对非空字符串标量、且没有文档指令——就在写锁下原地升级:原有各行逐字下沉到 `refs:` 之下,值、注释与拼写逐字节保留。其余任何扁平形态都会被指名拒绝,并给出条目数与唯一需要做的编辑(`version: 1`,条目下沉到 `refs:`)——绝不当作空存储读过去,否则它会以第一次请求认证失败的形式出现,而不是在加载时。热重载从不迁移:运行中被恢复出来的扁平文档只会保住上一份完好快照,直到下次启动。
117
+ ### 可能出错的地方
61
118
 
62
- 写入是对已解析文档打补丁而不是重建,因此注释与所有未触及条目的排版都会保留。直接位于某条目上方的注释属于该条目的注解,会随它一起删除。每次写入都先在 [`dsh-atomic-write`](../../util/atomic-write/README.zh.md) 的跨进程写锁下重读文档、把此前未观察到的一切发布出去,再在仅属主可访问(`0700`)的目录下以 `0600` 权限原子提交——因此并发写入者、或落在 watcher 防抖窗口内的外部编辑会被并入,而不是被覆盖。磁盘上已经无法解析的文档会让写入失败,而不是覆盖提供方读不懂的内容。
119
+ - **启动环境提供的密钥是只读的**——`DEEPSEEK_API_KEY=… dsh` 在本轮运行中优先,保存或移除它都会被拒绝。请先在启动 shell 中清除该变量。
120
+ - **空值无法保存**——存储空字符串会被拒绝;请改为移除密钥。
121
+ - **存储拒绝加载它无法信任的文件**——任何其他用户可读的文件、格式错误的 YAML 或无法到达的路径都会在启动时失败;运行期热重载则保留最后可用内容并告警。
122
+ - **同一时刻的修改都会被保留**——如果你在产品写入的同时编辑文件,你的变更会被并入,而不是被覆盖。
63
123
 
64
- 任何字符串值都能往返,包括多行值,因此不会再有条目因为缺少可用引号样式而不可写。空的存储值等于不存在(seam 规则)——这也正是文档中的空字符串被直接拒绝的原因:`unset` 删除键,而不是把它置空。
124
+ -----
65
125
 
66
- ## 权限
126
+ <a id="understand-the-implementation"></a>
127
+ ## 理解实现
67
128
 
68
- 提供方以 `0700` 创建目录,以 `0600` 创建或原子替换文档。它对*读取*同样守住这条界线:在 POSIX 上,只要文档带有任何 group 或 other 权限位,就会在解析其内容之前失败——启动时与每次 reload 都检查——并在错误里给出 `chmod 600` 的修复命令。Windows 没有可检查的 mode,因此在那里跳过该检查而不是伪造它。
129
+ <details>
130
+ <summary>实现细节——点击展开</summary>
69
131
 
70
- ## 热重载
132
+ 本节解释提供方背后的设计决策,并指出实现它们的代码位置;可观察行为已在[使用本包](#use-this-package)中完整说明。
71
133
 
72
- 外部编辑在快照**整体替换**后按变更引用逐个发布 `credentials/reference-updated`——磁盘上删掉的条目绝不在内存滞留。在 Chokidar 打开目标之前,提供方会对层级最深的现有祖先路径执行 realpath 解析,再拼回缺失的后缀;文件访问和诊断仍使用配置路径,从而避免 Windows 混用 8.3 别名与 libuv 的长格式事件路径。提供方自己的写入按内容识别,只发布属于该次提交的一个事件。运行期文档不可读或无效时保留最后可用快照并告警;文件不存在即空存储;启动时不可读或无效则明确报错。
134
+ ### 设计理念
73
135
 
74
- <a id="security-boundary"></a>
136
+ - **一套明确的优先级。** 继承环境优先,因为它是本次运行的明确意图且无法从进程内部修改;它之下的所有来源都输给受管存储,因此已存密钥永远不会被陈旧的 `.env` 挤掉。
137
+ - **文档只存放凭据。** 它是带 `refs` 与 `records` 分节的版本化文档,而不是 dotenv 文件:一个 harness 拥有、且绝不物化进环境的存储,不能同时充当用户的环境层,否则会以自己的优先级遮蔽非机密条目。
138
+ - **写入打补丁,重载整体替换。** 行编辑在跨进程写锁下保留注释与未触及条目;重载整体交换解析后的快照,已删除条目绝不在内存滞留。
139
+ - **信任攸关处明确报错。** 启动与重载都会拒绝不可读、无效或可被属主之外读取的文档;失败的活动重载保留最后可用快照并告警,而不是拖垮进程。
75
140
 
76
- ## 安全边界
141
+ ### 源码地图
77
142
 
78
- 文档在 `0700` 目录下以 `0600` 权限存放,这挡得住其他 OS 用户,**挡不住**模型。工具进程(bash、文件系统工具)以同一用户身份运行,而已交付的 `workspace-write` 文件策略限制的是修改而非读取,因此它们读这个文件与读该用户拥有的任何其他文件毫无二致;也没有任何沙箱模式会把它单独挑出来。harness 真正守住的更窄:它绝不把该文档的解析后路径交给模型,也绝不把它载入进程环境——这与用户的普通环境层 `$DSH_HOME/.env` 不同(见 [app-boot 的 Harness home 各层](../../boot/app-boot/README.zh.md#profiles))——因此要拿到这个值,需要刻意去读一条并未交给 agent(智能体)的路径。
143
+ | 文件 | 职责 |
144
+ |---|---|
145
+ | [`src/index.ts`](src/index.ts) | 提供方:层解析、严格文档解析、写锁下的引用与记录写路径、watcher 生命周期、权限检查 |
146
+ | [`src/invariant.ts`](src/invariant.ts) | 不变式伴生插件(无运行时不变式;事件生命周期约定归 seam 伴生插件) |
79
147
 
80
- 这是审慎,不是边界。必须让提供方密钥远离自身 agent 的部署无法靠文件权限做到;OS 钥匙串提供方——一种模型运行所在进程根本无法读取的存储——才是延后的答案,它应当作为平级包与本提供方并列。
148
+ ### 解析与写入路径
81
149
 
150
+ `resolve` 与 `describe` 按优先级顺序读取继承环境快照、已解析文档快照与 `.env` 后备层。`set`/`unset` 排入同一条独占操作链:入口检查提前拒绝(已释放、空值、被环境遮蔽),队列在运行时会重新判定,随后在写锁下执行读-改-写、提交,并恰好触发一次 `credentials/reference-updated`。
151
+
152
+ `modifyRecord` 走同一条链与同一把锁:它重新读取文档、把当前记录交给变更函数、准入其结果——非空的 api key、能经受 JSON 往返的 grant payload——整体渲染该记录并提交,恰好触发一次 `credentials/record-updated`。并非由产品 CLI 启动的组合只有继承环境这一层。
153
+
154
+ ### 重载生命周期
155
+
156
+ watcher 事件或 ready 对账把一次刷新排到同一链条之后。`reconcileFromDisk` 重新检查权限、重读文本,文本有差异时整体替换两个快照,并按变更引用或记录逐个发布事件;与文本缓存一致的内容——包括提供方自己的写入——是 no-op。释放时设置 closed 标志、停止接收事件、关闭 watcher,并等待排队操作结算完毕,确保 teardown 之后不再有任何发布。
157
+
158
+ ### 文档版本化
159
+
160
+ 文档携带 `version: 1`,每次写入都会盖上版本戳。启动时若识别出预发布扁平布局——没有 `version` 的裸引用名 mapping——会在写锁下就地升级,把原始行嵌套到 `refs:` 之下,值、注释与拼写逐字节保留;任何其他无版本形态都会按名拒绝,而不会被当作空存储。运行期热重载绝不迁移:中途恢复的扁平文档会保留最后可用快照,直到下一次启动。
161
+
162
+ ### 诊断信息绝不引用值
163
+
164
+ YAML 解析器自己的消息会引用出错的那行源码,而在这份文档里那行就是机密本身。因此每条诊断只携带错误码与位置——键名可以安全打印,值不行。
165
+
166
+ </details>
167
+
168
+ -----
169
+
170
+ <a id="further-exploration"></a>
171
+ ## 进一步探索
172
+
173
+ 当提供方级约定不够用时阅读以下页面。它们从 seam 约定逐步进入环境快照、原子写入原语与启动期环境层。
174
+
175
+ - [凭据引用 seam](../credentials/README.zh.md)——`resolve`、`describe`、`set`、`unset`、记录操作与 seam 的更新事件。
176
+ - [凭据子系统参考](../../../docs/subsystems/credentials.zh.md)——`CredentialRef`、按操作解析、对 UI 安全的 `CredentialInfo`、提供方层。
177
+ - [启动环境快照](../../util/launch-environment/README.zh.md)——解析读取的冻结层快照,而非 `process.env`。
178
+ - [原子写入](../../util/atomic-write/README.zh.md)——每次写入所用的写锁与原子替换。
179
+ - [应用启动与 Harness home 各层](../../boot/app-boot/README.zh.md)——产品 CLI 如何把 `.env` 载入快照与 `process.env`。
180
+
181
+ -----
182
+
183
+ <a id="model-experience"></a>
82
184
  ## 模型体验
83
185
 
84
- 经由消费它的 LLM(大语言模型)适配器间接生效:存储的值为适配器向提供方发出的请求授权,所有模型可见内容均由适配器负责。
186
+ 经由 `ctx.credentials` 的消费方间接生效:消费方拥有存储值所启用的全部模型可见行为。
85
187
 
86
188
  #### KV Cache 影响
87
189
 
88
- 无直接失效;凭据绝不进入请求前缀。
190
+ 无直接失效;存储值绝不进入请求前缀。
191
+
192
+ ## 已知限制与延期工作
193
+
194
+ <a id="known-limitations-and-deferred-work"></a>
195
+
89
196
 
90
- ## 已知限制与暂缓事项
197
+ 这些限制说明本提供方何时不合适,或何时需要特别的运维注意。它们是当前包约束,不是任务积压。
91
198
 
92
199
  - **同一引用的并发写入是后写胜出**——写锁加读-改-写让并发写入者不会丢掉彼此的条目,但两个写入者编辑同一个引用时仍以较后的写入为准;没有修订检查。
93
- - **同 UID 进程可以读取该文档**——见[安全边界](#security-boundary):文件效果沙箱模式不会拒绝读取,OS 钥匙串提供方仍是延后项。
94
- - **环境变化不可见**:快照在启动时冻结,因此启动之后 export 的变量既不会进入解析,也不会进入 `describe`;要更换来自环境的凭据需要重启。
200
+ - **同 UID 进程可以读取该文档**——文件效果沙箱模式不会拒绝读取,OS 钥匙串提供方仍是延后项。
201
+ - **环境变化不可见**——快照在启动时冻结,因此启动之后 export 的变量既不会进入解析,也不会进入 `describe`;要更换来自环境的凭据需要重启。
95
202
  - **原子但不具备崩溃持久性**——继承自 `dsh-atomic-write`;存储在启动时重新读取。
203
+
204
+ <a id="dev-note"></a>
205
+ ### 开发备注
206
+
207
+ <details>
208
+ <summary>维护者的工作上下文——点击展开</summary>
209
+
210
+ 本开发备注是维护者的工作上下文:开放问题与尚未决定的探索方向。它明确不具权威性——已交付的行为与限制以上文和包代码为准。
211
+
212
+ OS 钥匙串提供方——一种模型进程根本无法读取的存储——是同 UID 限制的延后答案,应当作为平级包与本提供方并列。seam 的接口还为辅助命令与 KMS 后端提供方预留了空间;目前没有任何一种随附。
213
+
214
+ </details>
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@deepseek-ai/dsh-credentials-local",
3
3
  "description": "File-backed credentials provider ($DSH_HOME/.env under the live process environment) for the DeepSeek Harness",
4
- "version": "0.1.1-rc.1",
4
+ "version": "0.1.2-alpha.2",
5
5
  "publishConfig": {
6
6
  "access": "public"
7
7
  },
@@ -32,24 +32,24 @@
32
32
  ],
33
33
  "license": "MIT",
34
34
  "peerDependencies": {
35
- "@deepseek-ai/dsh-atomic-write": "^0.1.1-rc.1",
36
- "@deepseek-ai/dsh-credentials": "^0.1.1-rc.1",
37
- "@deepseek-ai/dsh-invariants": "^0.1.1-rc.1",
38
- "@deepseek-ai/dsh-home-paths": "^0.1.1-rc.1",
39
- "@deepseek-ai/dsh-launch-environment": "^0.1.1-rc.1",
40
- "@deepseek-ai/cordis": "^4.0.1"
35
+ "@deepseek-ai/dsh-atomic-write": "^0.1.2-alpha.2",
36
+ "@deepseek-ai/dsh-credentials": "^0.1.2-alpha.2",
37
+ "@deepseek-ai/dsh-launch-environment": "^0.1.2-alpha.2",
38
+ "@deepseek-ai/dsh-home-paths": "^0.1.2-alpha.2",
39
+ "@deepseek-ai/dsh-invariants": "^0.1.2-alpha.2",
40
+ "@deepseek-ai/cordis": "^4.0.2"
41
41
  },
42
42
  "dependencies": {
43
43
  "chokidar": "^4.0.3",
44
44
  "yaml": "^2.9.0",
45
- "@deepseek-ai/schemastery": "^3.18.1"
45
+ "@deepseek-ai/schemastery": "^3.18.2"
46
46
  },
47
47
  "devDependencies": {
48
- "@deepseek-ai/dsh-atomic-write": "^0.1.1-rc.1",
49
- "@deepseek-ai/dsh-credentials": "^0.1.1-rc.1",
50
- "@deepseek-ai/dsh-launch-environment": "^0.1.1-rc.1",
51
- "@deepseek-ai/dsh-invariants": "^0.1.1-rc.1",
52
- "@deepseek-ai/cordis": "^4.0.1",
53
- "@deepseek-ai/dsh-home-paths": "^0.1.1-rc.1"
48
+ "@deepseek-ai/dsh-atomic-write": "^0.1.2-alpha.2",
49
+ "@deepseek-ai/dsh-credentials": "^0.1.2-alpha.2",
50
+ "@deepseek-ai/dsh-launch-environment": "^0.1.2-alpha.2",
51
+ "@deepseek-ai/dsh-invariants": "^0.1.2-alpha.2",
52
+ "@deepseek-ai/dsh-home-paths": "^0.1.2-alpha.2",
53
+ "@deepseek-ai/cordis": "^4.0.2"
54
54
  }
55
55
  }