@deepseek-ai/dsh-credentials-local 0.1.0-rc.8 → 0.1.1-rc.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: 63895d34fe8396d55c1d6ef29b3e3d9a9dc17264
6
- README.zh.md: 3c60fb7bbf06a0ae9ec905f36a5c543c2fb6af1b
5
+ README.md: 7acd89ea24be4da3d2cecc6efe619e73e05bb823
6
+ README.zh.md: d0443c31661eb0750b02f4f2b4c396730867904f
package/README.md CHANGED
@@ -28,14 +28,36 @@ Under the product CLI, resolution reads the launcher's frozen [environment snaps
28
28
 
29
29
  ## The document
30
30
 
31
- A YAML mapping of credential reference to value, and nothing else:
31
+ A versioned YAML document with one section per key space, and nothing else:
32
32
 
33
33
  ```yaml
34
- DEEPSEEK_API_KEY: sk-…
35
- OPENAI_API_KEY: sk-…
34
+ version: 1
35
+
36
+ refs:
37
+ DEEPSEEK_API_KEY: sk-…
38
+ OPENAI_API_KEY: sk-…
39
+
40
+ records:
41
+ llm-pi-ai/openai-codex:
42
+ kind: grant
43
+ payload: # written verbatim; this provider does not interpret it
44
+ type: oauth
45
+ access: eyJhbGciOi…
46
+ refresh: rft_9f8e7d…
47
+ expires: 1786000000000
48
+ llm-pi-ai/amazon-bedrock:
49
+ kind: api-key # environment values, no key: this route uses an AWS profile
50
+ env:
51
+ AWS_PROFILE: prod
52
+ llm-pi-ai/amazon-bedrock-dev:
53
+ kind: api-key # neither: the owner confirmed the ambient credential chain
36
54
  ```
37
55
 
38
- The document holds credentials only, so every deviation is a rejection rather than a skipped entry — a silently ignored key would read as "the secret I stored has no effect". A non-mapping root, a key that is not a POSIX identifier, a non-string value, an empty string, a duplicate key, and malformed YAML all fail: loud at boot, and warn-and-keep-the-last-good-snapshot on a live reload. There is no `version` field and no wrapper level; the format is the mapping.
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.
57
+
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.
59
+
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.
39
61
 
40
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.
41
63
 
@@ -47,7 +69,7 @@ The provider creates the directory `0700` and creates or atomically replaces the
47
69
 
48
70
  ## Hot reload
49
71
 
50
- External edits publish `credentials/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.
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.
51
73
 
52
74
  ## Security boundary
53
75
 
package/README.zh.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  [English](README.md) | 中文
4
4
 
5
- 文件型[凭据](../credentials/README.md)提供方:四层来源,一套明确的优先级。
5
+ 文件型[凭据](../credentials/README.zh.md)提供方:四层来源,一套明确的优先级。
6
6
 
7
7
  | 层 | 来源 id | 可写 | 优先 |
8
8
  |---|---|---|---|
@@ -15,7 +15,7 @@
15
15
 
16
16
  它之下的所有来源优先级都低于受管存储,因此 Models 页写入的密钥会立即生效,即使某个 `.env` 里还留着更旧的密钥。没有存储任何东西时这两层仍会参与解析,`describe()` 会把来源报告为 `project-env` 或 `user-env` 且 `writable: true`——存入一个密钥就会取代它们成为生效来源。
17
17
 
18
- 在产品 CLI(命令行界面)下,解析读取的是启动器冻结的[环境快照](../../util/launch-environment/README.md)而不是 `process.env`:只有快照才说得清某个值来自启动 shell 还是来自某个文件。并非由产品 CLI 启动的组合只有继承环境这一层,这让嵌入方保持它们原有的语义。
18
+ 在产品 CLI(命令行界面)下,解析读取的是启动器冻结的[环境快照](../../util/launch-environment/README.zh.md)而不是 `process.env`:只有快照才说得清某个值来自启动 shell 还是来自某个文件。并非由产品 CLI 启动的组合只有继承环境这一层,这让嵌入方保持它们原有的语义。
19
19
 
20
20
  ## 配置
21
21
 
@@ -28,16 +28,38 @@
28
28
 
29
29
  ## 文档本身
30
30
 
31
- 一个从凭据引用到值的 YAML mapping,除此之外别无他物:
31
+ 一个带版本的 YAML 文档,每个键空间一个分节,除此之外别无他物:
32
32
 
33
33
  ```yaml
34
- DEEPSEEK_API_KEY: sk-…
35
- OPENAI_API_KEY: sk-…
34
+ version: 1
35
+
36
+ refs:
37
+ DEEPSEEK_API_KEY: sk-…
38
+ OPENAI_API_KEY: sk-…
39
+
40
+ records:
41
+ llm-pi-ai/openai-codex:
42
+ kind: grant
43
+ payload: # written verbatim; this provider does not interpret it
44
+ type: oauth
45
+ access: eyJhbGciOi…
46
+ refresh: rft_9f8e7d…
47
+ expires: 1786000000000
48
+ llm-pi-ai/amazon-bedrock:
49
+ kind: api-key # environment values, no key: this route uses an AWS profile
50
+ env:
51
+ AWS_PROFILE: prod
52
+ llm-pi-ai/amazon-bedrock-dev:
53
+ kind: api-key # neither: the owner confirmed the ambient credential chain
36
54
  ```
37
55
 
38
- 该文档只存放凭据,因此任何偏离都是拒绝,而不是跳过某个条目——被静默忽略的键读起来就是「我存进去的密钥没有生效」。非 mapping 的根、非 POSIX 标识符的键、非字符串值、空字符串、重复键以及格式错误的 YAML 全部失败:启动时明确报错,运行期热重载则告警并保留最后可用快照。没有 `version` 字段,也没有包装层;格式就是这个 mapping。
56
+ 该文档只存放凭据,因此任何偏离都是拒绝,而不是跳过某个条目——被静默忽略的键读起来就是「我存进去的凭据没有生效」。非 mapping 的根、未知的顶层键、在其空间中不可寻址的键、类型不符的值、空字符串、未知的记录标签或字段、重复键以及格式错误的 YAML 全部失败:启动时明确报错,运行期热重载则告警并保留最后可用快照。
39
57
 
40
- 写入是对已解析文档打补丁而不是重建,因此注释与所有未触及条目的排版都会保留。直接位于某条目上方的注释属于该条目的注解,会随它一起删除。每次写入都先在 [`dsh-atomic-write`](../../util/atomic-write/README.md) 的跨进程写锁下重读文档、把此前未观察到的一切发布出去,再在仅属主可访问(`0700`)的目录下以 `0600` 权限原子提交——因此并发写入者、或落在 watcher 防抖窗口内的外部编辑会被并入,而不是被覆盖。磁盘上已经无法解析的文档会让写入失败,而不是覆盖提供方读不懂的内容。
58
+ `grant` 的 payload 必须能经受 JSON 往返,读写两个方向都强制。YAML 能拼写出 JSON 没有的值——`.inf`、别名环——拥有者也可能递来 `Date` 或 `bigint`;无论哪种,存储都选择拒绝,而不是存下一个自己无法逐字读回的东西。
59
+
60
+ 发布前的旧布局是没有 `version` 的扁平 mapping。启动时若能精确识别它——可寻址名称对非空字符串标量、且没有文档指令——就在写锁下原地升级:原有各行逐字下沉到 `refs:` 之下,值、注释与拼写逐字节保留。其余任何扁平形态都会被指名拒绝,并给出条目数与唯一需要做的编辑(`version: 1`,条目下沉到 `refs:`)——绝不当作空存储读过去,否则它会以第一次请求认证失败的形式出现,而不是在加载时。热重载从不迁移:运行中被恢复出来的扁平文档只会保住上一份完好快照,直到下次启动。
61
+
62
+ 写入是对已解析文档打补丁而不是重建,因此注释与所有未触及条目的排版都会保留。直接位于某条目上方的注释属于该条目的注解,会随它一起删除。每次写入都先在 [`dsh-atomic-write`](../../util/atomic-write/README.zh.md) 的跨进程写锁下重读文档、把此前未观察到的一切发布出去,再在仅属主可访问(`0700`)的目录下以 `0600` 权限原子提交——因此并发写入者、或落在 watcher 防抖窗口内的外部编辑会被并入,而不是被覆盖。磁盘上已经无法解析的文档会让写入失败,而不是覆盖提供方读不懂的内容。
41
63
 
42
64
  任何字符串值都能往返,包括多行值,因此不会再有条目因为缺少可用引号样式而不可写。空的存储值等于不存在(seam 规则)——这也正是文档中的空字符串被直接拒绝的原因:`unset` 删除键,而不是把它置空。
43
65
 
@@ -47,13 +69,13 @@ OPENAI_API_KEY: sk-…
47
69
 
48
70
  ## 热重载
49
71
 
50
- 外部编辑在快照**整体替换**后按变更引用逐个发布 `credentials/updated`——磁盘上删掉的条目绝不在内存滞留。在 Chokidar 打开目标之前,提供方会对层级最深的现有祖先路径执行 realpath 解析,再拼回缺失的后缀;文件访问和诊断仍使用配置路径,从而避免 Windows 混用 8.3 别名与 libuv 的长格式事件路径。提供方自己的写入按内容识别,只发布属于该次提交的一个事件。运行期文档不可读或无效时保留最后可用快照并告警;文件不存在即空存储;启动时不可读或无效则明确报错。
72
+ 外部编辑在快照**整体替换**后按变更引用逐个发布 `credentials/reference-updated`——磁盘上删掉的条目绝不在内存滞留。在 Chokidar 打开目标之前,提供方会对层级最深的现有祖先路径执行 realpath 解析,再拼回缺失的后缀;文件访问和诊断仍使用配置路径,从而避免 Windows 混用 8.3 别名与 libuv 的长格式事件路径。提供方自己的写入按内容识别,只发布属于该次提交的一个事件。运行期文档不可读或无效时保留最后可用快照并告警;文件不存在即空存储;启动时不可读或无效则明确报错。
51
73
 
52
74
  <a id="security-boundary"></a>
53
75
 
54
76
  ## 安全边界
55
77
 
56
- 文档在 `0700` 目录下以 `0600` 权限存放,这挡得住其他 OS 用户,**挡不住**模型。工具进程(bash、文件系统工具)以同一用户身份运行,而已交付的 `workspace-write` 文件策略限制的是修改而非读取,因此它们读这个文件与读该用户拥有的任何其他文件毫无二致;也没有任何沙箱模式会把它单独挑出来。harness 真正守住的更窄:它绝不把该文档的解析后路径交给模型,也绝不把它载入进程环境——这与用户的普通环境层 `$DSH_HOME/.env` 不同(见 [app-boot 的 Harness home 各层](../../boot/app-boot/README.md#profiles))——因此要拿到这个值,需要刻意去读一条并未交给 agent(智能体)的路径。
78
+ 文档在 `0700` 目录下以 `0600` 权限存放,这挡得住其他 OS 用户,**挡不住**模型。工具进程(bash、文件系统工具)以同一用户身份运行,而已交付的 `workspace-write` 文件策略限制的是修改而非读取,因此它们读这个文件与读该用户拥有的任何其他文件毫无二致;也没有任何沙箱模式会把它单独挑出来。harness 真正守住的更窄:它绝不把该文档的解析后路径交给模型,也绝不把它载入进程环境——这与用户的普通环境层 `$DSH_HOME/.env` 不同(见 [app-boot 的 Harness home 各层](../../boot/app-boot/README.zh.md#profiles))——因此要拿到这个值,需要刻意去读一条并未交给 agent(智能体)的路径。
57
79
 
58
80
  这是审慎,不是边界。必须让提供方密钥远离自身 agent 的部署无法靠文件权限做到;OS 钥匙串提供方——一种模型运行所在进程根本无法读取的存储——才是延后的答案,它应当作为平级包与本提供方并列。
59
81
 
package/lib/index.js CHANGED
@@ -3,11 +3,11 @@ import z from "@deepseek-ai/schemastery";
3
3
  import { watch } from "chokidar";
4
4
  import { mkdir, readFile, stat } from "node:fs/promises";
5
5
  import { dirname, join, resolve } from "node:path";
6
- import { Document, parseDocument } from "yaml";
6
+ import { Document, isMap, isScalar, parseDocument } from "yaml";
7
7
  import { withFileLock, writeFileAtomic } from "@deepseek-ai/dsh-atomic-write";
8
8
  import { canonicalizeWatchPath, resolveDshHome } from "@deepseek-ai/dsh-home-paths";
9
9
  import { launchEnvironmentOf } from "@deepseek-ai/dsh-launch-environment";
10
- import { CredentialProvider, credentialRef } from "@deepseek-ai/dsh-credentials";
10
+ import { CredentialProvider, credentialRef, parseCredentialKey } from "@deepseek-ai/dsh-credentials";
11
11
  //#region lib/types/index.js
12
12
  /**
13
13
  * File-backed credentials provider over `$DSH_HOME/.credentials.yaml`, layered
@@ -63,6 +63,20 @@ function resolveSpec(config) {
63
63
  /** Permission bits outside the owner; a credentials document must have none of them. */
64
64
  const GROUP_OTHER_BITS = 63;
65
65
  /**
66
+ * How long a record write waits for the cross-process writer lock. A record
67
+ * mutation runs its caller's decision while holding the lock, and for the
68
+ * operation this half exists to serve — an owner refreshing an expired token —
69
+ * that decision includes a network round trip. The file-work default would
70
+ * fail every other writer of this document for its duration. A contender's
71
+ * wait is sized by the longest holder it can meet, and refs and records share
72
+ * one file and one lock, so every writer of this document — reference writes
73
+ * and record deletes included — waits this long, not only the mutation that
74
+ * holds it. Like the retry cadence in `dsh-atomic-write`, this is a
75
+ * robustness bound of the write protocol rather than a deployment choice: it
76
+ * is sized by what a provider request costs, which no deployment varies.
77
+ */
78
+ const DOCUMENT_LOCK_WAIT_MS = 3e4;
79
+ /**
66
80
  * Reject a credentials document other OS users can read, before its contents
67
81
  * are read at all. The provider creates and replaces the file at `0600`, but a
68
82
  * hand-written or externally generated one carries whatever umask produced it,
@@ -106,17 +120,18 @@ function describeYamlError(error) {
106
120
  const where = at === void 0 ? "" : ` at line ${String(at.line)}, column ${String(at.col)}`;
107
121
  return `${error.code}${where}`;
108
122
  }
123
+ /** The document layout this build reads and writes. */
124
+ const DOCUMENT_VERSION = 1;
109
125
  /**
110
- * Parse one credentials document into its entries. The document is a strict
111
- * mapping of {@link CredentialRef} to non-empty string: a non-mapping root, a
112
- * key that is not a POSIX identifier, a non-string value, and an empty string
113
- * are all rejected rather than skipped, because this file holds nothing but
114
- * credentials and a silently ignored entry reads as "the key I stored has no
115
- * effect". Duplicate keys surface as parser errors. An empty document is an
116
- * empty store.
126
+ * Parse one credentials document. Everything is rejected rather than skipped —
127
+ * an unversioned root, an unknown top-level key, a key that is not addressable,
128
+ * a wrong-typed value, an unknown record tag or field — because this file holds
129
+ * nothing but credentials and a silently ignored entry reads as "the credential
130
+ * I stored has no effect". Duplicate keys surface as parser errors. An empty
131
+ * document is an empty store and needs no version.
117
132
  * @param text - the document's text.
118
133
  * @param filename - absolute path, quoted in errors.
119
- * @returns the parsed entries, keyed by reference.
134
+ * @returns the parsed references and records.
120
135
  */
121
136
  function parseCredentialsDocument(text, filename) {
122
137
  const document = parseDocument(text, {
@@ -125,9 +140,58 @@ function parseCredentialsDocument(text, filename) {
125
140
  });
126
141
  if (document.errors.length > 0) throw new Error(`credentials-local: invalid document at ${filename}: ${document.errors.map(describeYamlError).join("; ")}`);
127
142
  const root = document.toJS() ?? {};
128
- if (typeof root !== "object" || root === null || Array.isArray(root)) throw new TypeError(`credentials-local: ${filename} must be a mapping of credential reference to value`);
143
+ if (typeof root !== "object" || root === null || Array.isArray(root)) throw new TypeError(`credentials-local: ${filename} must be a mapping`);
144
+ const fields = root;
145
+ const keys = Object.keys(fields);
146
+ if (keys.length === 0) return {
147
+ refs: /* @__PURE__ */ new Map(),
148
+ records: /* @__PURE__ */ new Map()
149
+ };
150
+ if (!("version" in fields)) throw new Error(`credentials-local: ${filename} uses the pre-release flat layout. Add \`version: 1\` and nest the existing ${keys.length} ${keys.length === 1 ? "entry" : "entries"} under \`refs:\`. No values need to change.`);
151
+ if (fields["version"] !== 1) throw new Error(`credentials-local: ${filename} declares version ${JSON.stringify(fields["version"])}; this build reads version 1`);
152
+ for (const key of keys) if (key !== "version" && key !== "refs" && key !== "records") throw new Error(`credentials-local: unknown top-level key "${key}" in ${filename}`);
153
+ return {
154
+ refs: parseRefs(fields["refs"], filename),
155
+ records: parseRecords(fields["records"], filename)
156
+ };
157
+ }
158
+ /**
159
+ * Render the version-1 layout for a pre-release flat document, or `undefined`
160
+ * for anything else. The flat layout is recognized exactly — a non-empty
161
+ * top-level mapping of addressable reference names to non-empty string
162
+ * scalars, with no `version` key and no document directives — and the rewrite
163
+ * nests the original lines verbatim under `refs:` at two spaces' indent, so
164
+ * comments, blank lines, and each value's spelling survive byte for byte.
165
+ * Anything the recognizer declines keeps {@link parseCredentialsDocument}'s
166
+ * loud rejection: a document this build cannot prove it understands is never
167
+ * rewritten. Remove with the pre-release stance at the first tagged release.
168
+ * @param text - the document's text.
169
+ * @returns the migrated text, or `undefined` when the text is not the recognized flat layout.
170
+ */
171
+ function renderFlatLayoutMigration(text) {
172
+ const document = parseDocument(text, {
173
+ prettyErrors: true,
174
+ uniqueKeys: true
175
+ });
176
+ if (document.errors.length > 0) return void 0;
177
+ const flat = document.contents;
178
+ if (!isMap(flat) || flat.items.length === 0) return void 0;
179
+ for (const line of text.split("\n")) if (/^(%|---|\.\.\.)/.test(line)) return void 0;
180
+ for (const pair of flat.items) {
181
+ if (!isScalar(pair.key) || typeof pair.key.value !== "string" || pair.key.value === "version") return void 0;
182
+ try {
183
+ credentialRef(pair.key.value);
184
+ } catch {
185
+ return;
186
+ }
187
+ if (!isScalar(pair.value) || typeof pair.value.value !== "string" || pair.value.value.length === 0) return void 0;
188
+ }
189
+ return `version: 1\nrefs:\n${text.split("\n").map((line) => line.length === 0 ? line : ` ${line}`).join("\n")}${text.endsWith("\n") ? "" : "\n"}`;
190
+ }
191
+ /** Admit a `refs` section: POSIX-identifier keys over non-empty string values. */
192
+ function parseRefs(section, filename) {
129
193
  const entries = /* @__PURE__ */ new Map();
130
- for (const [key, value] of Object.entries(root)) {
194
+ for (const [key, value] of Object.entries(asSection(section, "refs", filename))) {
131
195
  credentialRef(key);
132
196
  if (typeof value !== "string") throw new TypeError(`credentials-local: the value for "${key}" in ${filename} must be a string`);
133
197
  if (value.length === 0) throw new Error(`credentials-local: the value for "${key}" in ${filename} is empty; remove the key instead`);
@@ -135,21 +199,194 @@ function parseCredentialsDocument(text, filename) {
135
199
  }
136
200
  return entries;
137
201
  }
202
+ /** Admit a `records` section: `<scope>/<id>` keys over tagged record mappings. */
203
+ function parseRecords(section, filename) {
204
+ const entries = /* @__PURE__ */ new Map();
205
+ for (const [key, value] of Object.entries(asSection(section, "records", filename))) {
206
+ parseCredentialKey(key);
207
+ entries.set(key, parseRecord(key, value, filename));
208
+ }
209
+ return entries;
210
+ }
211
+ /**
212
+ * Refuse an api-key record the read path could not admit, before it is
213
+ * rendered: an empty key, an env name outside the reference grammar, or an
214
+ * empty env value would persist a document `parseRecord` rejects at the next
215
+ * boot — a durable-boundary write is validated where it is written.
216
+ * @param key - the record's credential key, for the failure message.
217
+ * @param record - the api-key record a mutation returned.
218
+ */
219
+ function assertStorableApiKey(key, record) {
220
+ if (record.key !== void 0 && record.key.length === 0) throw new TypeError(`credentials-local: record "${key}" has an empty key; omit the field instead`);
221
+ for (const [name, value] of Object.entries(record.env ?? {})) {
222
+ credentialRef(name);
223
+ if (value.length === 0) throw new TypeError(`credentials-local: record "${key}" env "${name}" must be a non-empty string`);
224
+ }
225
+ }
226
+ /** One section of the document as a plain mapping; absent and null both mean empty. */
227
+ function asSection(section, name, filename) {
228
+ if (section === void 0 || section === null) return {};
229
+ if (typeof section !== "object" || Array.isArray(section)) throw new TypeError(`credentials-local: "${name}" in ${filename} must be a mapping`);
230
+ return section;
231
+ }
232
+ /** Admit one record entry, rejecting an unknown tag or field rather than dropping it. */
233
+ function parseRecord(key, value, filename) {
234
+ if (typeof value !== "object" || value === null || Array.isArray(value)) throw new TypeError(`credentials-local: record "${key}" in ${filename} must be a mapping`);
235
+ const fields = value;
236
+ const kind = fields["kind"];
237
+ if (kind === "api-key") {
238
+ assertFields(key, fields, [
239
+ "kind",
240
+ "key",
241
+ "env"
242
+ ], filename);
243
+ const apiKey = fields["key"];
244
+ if (apiKey !== void 0 && (typeof apiKey !== "string" || apiKey.length === 0)) throw new TypeError(`credentials-local: record "${key}" in ${filename} has a non-string or empty key`);
245
+ const env = parseRecordEnv(key, fields["env"], filename);
246
+ return {
247
+ kind: "api-key",
248
+ ...apiKey === void 0 ? {} : { key: apiKey },
249
+ ...env === void 0 ? {} : { env }
250
+ };
251
+ }
252
+ if (kind === "grant") {
253
+ assertFields(key, fields, ["kind", "payload"], filename);
254
+ if (!("payload" in fields)) throw new Error(`credentials-local: record "${key}" in ${filename} has no payload`);
255
+ assertJsonValue(`record "${key}" payload in ${filename}`, fields["payload"], /* @__PURE__ */ new Set());
256
+ return {
257
+ kind: "grant",
258
+ payload: fields["payload"]
259
+ };
260
+ }
261
+ if (kind === void 0) throw new Error(`credentials-local: record "${key}" in ${filename} has no kind`);
262
+ throw new Error(`credentials-local: record "${key}" in ${filename} has unknown kind ${JSON.stringify(kind)}`);
263
+ }
264
+ /** Reject a field the tag does not define, so a typo is not silently dropped. */
265
+ function assertFields(key, fields, allowed, filename) {
266
+ for (const field of Object.keys(fields)) if (!allowed.includes(field)) throw new Error(`credentials-local: record "${key}" in ${filename} has unknown field "${field}"`);
267
+ }
268
+ /** Admit an api-key record's provider environment: POSIX names over non-empty strings. */
269
+ function parseRecordEnv(key, env, filename) {
270
+ if (env === void 0) return void 0;
271
+ if (typeof env !== "object" || env === null || Array.isArray(env)) throw new TypeError(`credentials-local: record "${key}" in ${filename} has a non-mapping env`);
272
+ const parsed = {};
273
+ for (const [name, value] of Object.entries(env)) {
274
+ credentialRef(name);
275
+ if (typeof value !== "string" || value.length === 0) throw new TypeError(`credentials-local: record "${key}" env "${name}" in ${filename} must be a non-empty string`);
276
+ parsed[name] = value;
277
+ }
278
+ return parsed;
279
+ }
138
280
  /**
139
- * Render the next document text with one reference set or deleted. Editing
140
- * the parsed document rather than rebuilding it keeps comments and the
141
- * formatting of every untouched entry; an absent document starts a fresh one.
281
+ * Reject a payload that cannot survive a JSON round trip, on the way in and on
282
+ * the way out. The seam promises owners their payload comes back exactly as
283
+ * written, and both directions can break that: a document may spell `.inf` or
284
+ * an alias cycle, and an owner may hand over a `Date`, a class instance, or a
285
+ * `bigint` that this document has no faithful spelling for. Neither the value
286
+ * nor any nested value is quoted in a diagnostic.
287
+ * @param where - the subject named in a diagnostic, already free of any value.
288
+ * @param value - the payload or nested value to admit.
289
+ * @param seen - objects on the current path, for cycle detection.
290
+ * @throws TypeError naming `where` when the value cannot round-trip.
291
+ */
292
+ function assertJsonValue(where, value, seen) {
293
+ if (value === null || typeof value === "string" || typeof value === "boolean") return;
294
+ if (typeof value === "number") {
295
+ if (Number.isFinite(value)) return;
296
+ throw new TypeError(`credentials-local: ${where} holds a non-finite number`);
297
+ }
298
+ if (typeof value === "object") {
299
+ if (seen.has(value)) throw new TypeError(`credentials-local: ${where} is cyclic`);
300
+ if (Object.getPrototypeOf(value) === Object.prototype || Array.isArray(value)) {
301
+ seen.add(value);
302
+ for (const nested of Object.values(value)) assertJsonValue(where, nested, seen);
303
+ seen.delete(value);
304
+ return;
305
+ }
306
+ }
307
+ throw new TypeError(`credentials-local: ${where} holds a value JSON cannot represent`);
308
+ }
309
+ /**
310
+ * The comment-preserving mutable tree one edit renders from. Editing the
311
+ * parsed document rather than rebuilding it keeps comments and the formatting
312
+ * of every untouched entry; an absent document starts a fresh one.
313
+ * @param text - the current document text, `undefined` while the file is absent.
314
+ * @returns the tree to edit, carrying this build's version stamp.
315
+ */
316
+ function mutableDocument(text) {
317
+ const document = text === void 0 ? new Document({}) : parseDocument(text);
318
+ document.setIn(["version"], 1);
319
+ return document;
320
+ }
321
+ /**
322
+ * Render the next document text with one reference set or deleted.
142
323
  * @param text - the current document text, `undefined` while the file is absent.
143
324
  * @param ref - the reference to write.
144
325
  * @param value - the new value, or `undefined` to delete the key.
145
326
  * @returns the text to persist.
146
327
  */
147
- function renderDocument(text, ref, value) {
148
- const document = text === void 0 ? new Document({}) : parseDocument(text);
149
- if (value === void 0) document.deleteIn([ref]);
150
- else document.setIn([ref], value);
328
+ function renderRef(text, ref, value) {
329
+ const document = mutableDocument(text);
330
+ if (value === void 0) deleteSectionEntry(document, "refs", ref);
331
+ else document.setIn(["refs", ref], value);
332
+ return document.toString();
333
+ }
334
+ /**
335
+ * Render the next document text with one record written or deleted. The record
336
+ * node is replaced wholesale rather than edited field by field: records are
337
+ * machine-written, so there is no hand formatting inside one to preserve.
338
+ * @param text - the current document text, `undefined` while the file is absent.
339
+ * @param key - the record to write.
340
+ * @param record - the new record, or `undefined` to delete it.
341
+ * @returns the text to persist.
342
+ */
343
+ function renderRecord(text, key, record) {
344
+ const document = mutableDocument(text);
345
+ if (record === void 0) deleteSectionEntry(document, "records", key);
346
+ else document.setIn(["records", key], record);
151
347
  return document.toString();
152
348
  }
349
+ /**
350
+ * Remove one entry from a section, taking its annotation with it. A comment
351
+ * block written above a section's first entry annotates that entry, but the
352
+ * parser attaches it to the section's map rather than to the pair — leaving it
353
+ * behind would move it onto whichever entry became first, which reads as an
354
+ * annotation of a credential nobody wrote it for.
355
+ * @param document - the mutable tree being edited.
356
+ * @param section - the section holding the entry.
357
+ * @param key - the entry to remove.
358
+ */
359
+ function deleteSectionEntry(document, section, key) {
360
+ const map = document.get(section, true);
361
+ /* v8 ignore next -- both callers render a delete only for an entry they just
362
+ found in the parsed snapshot, so the section it lives in is always a map;
363
+ the guard is what narrows `get`'s `unknown`. */
364
+ if (isMap(map)) {
365
+ const first = map.items[0];
366
+ /* v8 ignore next -- a map that holds the entry has a first item, and the
367
+ parser admits only scalar keys, so only the identity test can be false. */
368
+ if (first !== void 0 && isScalar(first.key) && first.key.value === key) map.commentBefore = null;
369
+ }
370
+ document.deleteIn([section, key]);
371
+ }
372
+ /**
373
+ * Structural equality over two admitted JSON values. Records reach this after
374
+ * {@link assertJsonValue}, so the walk meets only JSON shapes; key order is
375
+ * ignored because an external editor may reorder a record's fields without
376
+ * changing what it stores.
377
+ * @param left - one value.
378
+ * @param right - the other value.
379
+ * @returns whether the two carry the same JSON content.
380
+ */
381
+ function sameJsonValue(left, right) {
382
+ if (left === right) return true;
383
+ if (typeof left !== "object" || typeof right !== "object" || left === null || right === null) return false;
384
+ if (Array.isArray(left) !== Array.isArray(right)) return false;
385
+ const leftKeys = Object.keys(left);
386
+ const rightKeys = Object.keys(right);
387
+ if (leftKeys.length !== rightKeys.length) return false;
388
+ return leftKeys.every((key) => key in right && sameJsonValue(left[key], right[key]));
389
+ }
153
390
  /** File-backed credentials provider (`$DSH_HOME/.credentials.yaml`). */
154
391
  var LocalCredentialProvider = class extends CredentialProvider {
155
392
  config;
@@ -166,8 +403,10 @@ var LocalCredentialProvider = class extends CredentialProvider {
166
403
  * which is also the self-write suppression.
167
404
  */
168
405
  text;
169
- /** Parsed document snapshot; replaced wholesale on every reload. */
406
+ /** Parsed reference snapshot; replaced wholesale on every reload. */
170
407
  values = /* @__PURE__ */ new Map();
408
+ /** Parsed record snapshot; replaced wholesale on every reload. */
409
+ records = /* @__PURE__ */ new Map();
171
410
  /**
172
411
  * Single exclusive operation chain: watcher reloads and line edits run one
173
412
  * at a time in queue order (settled tail), so an edit can never render from
@@ -278,6 +517,76 @@ var LocalCredentialProvider = class extends CredentialProvider {
278
517
  async unset(ref) {
279
518
  await this.write(ref, void 0);
280
519
  }
520
+ readRecord(key) {
521
+ return Promise.resolve(this.records.get(key));
522
+ }
523
+ describeRecord(key) {
524
+ const stored = this.records.get(key);
525
+ if (stored === void 0) return Promise.resolve({
526
+ configured: false,
527
+ writable: true
528
+ });
529
+ return Promise.resolve({
530
+ configured: true,
531
+ kind: stored.kind,
532
+ writable: true
533
+ });
534
+ }
535
+ listRecords() {
536
+ return Promise.resolve([...this.records].map(([key, record]) => ({
537
+ key: parseCredentialKey(key),
538
+ kind: record.kind
539
+ })));
540
+ }
541
+ async modifyRecord(key, mutate) {
542
+ if (this.isClosed()) throw new Error(`credentials-local is disposed: cannot modify "${key}"`);
543
+ return this.enqueue(async () => {
544
+ if (this.isClosed()) throw new Error(`credentials-local was disposed before the queued "${key}" modify ran`);
545
+ await mkdir(dirname(this.spec.filename), {
546
+ recursive: true,
547
+ mode: 448
548
+ });
549
+ return withFileLock(this.spec.filename, async () => {
550
+ await this.reconcileFromDisk();
551
+ const current = this.records.get(key);
552
+ const next = await mutate(current);
553
+ if (next === void 0) return current;
554
+ if (next.kind === "grant") assertJsonValue(`record "${key}" payload`, next.payload, /* @__PURE__ */ new Set());
555
+ else assertStorableApiKey(key, next);
556
+ const nextText = renderRecord(this.text, key, next);
557
+ await writeFileAtomic(this.spec.filename, nextText, {
558
+ mode: 384,
559
+ dirMode: 448
560
+ });
561
+ this.text = nextText;
562
+ this.records.set(key, next);
563
+ this.notifyRecordUpdated(key);
564
+ return next;
565
+ }, { waitMs: DOCUMENT_LOCK_WAIT_MS });
566
+ });
567
+ }
568
+ async deleteRecord(key) {
569
+ if (this.isClosed()) throw new Error(`credentials-local is disposed: cannot delete "${key}"`);
570
+ await this.enqueue(async () => {
571
+ if (this.isClosed()) throw new Error(`credentials-local was disposed before the queued "${key}" delete ran`);
572
+ await mkdir(dirname(this.spec.filename), {
573
+ recursive: true,
574
+ mode: 448
575
+ });
576
+ await withFileLock(this.spec.filename, async () => {
577
+ await this.reconcileFromDisk();
578
+ if (!this.records.has(key)) return;
579
+ const nextText = renderRecord(this.text, key, void 0);
580
+ await writeFileAtomic(this.spec.filename, nextText, {
581
+ mode: 384,
582
+ dirMode: 448
583
+ });
584
+ this.text = nextText;
585
+ this.records.delete(key);
586
+ this.notifyRecordUpdated(key);
587
+ }, { waitMs: DOCUMENT_LOCK_WAIT_MS });
588
+ });
589
+ }
281
590
  /** Queue one exclusive document operation behind every earlier one. */
282
591
  enqueue(operation) {
283
592
  const task = this.operations.then(operation);
@@ -307,7 +616,7 @@ var LocalCredentialProvider = class extends CredentialProvider {
307
616
  await this.reconcileFromDisk();
308
617
  const existing = this.values.get(ref);
309
618
  if (value === void 0 && existing === void 0) return;
310
- const nextText = renderDocument(this.text, ref, value);
619
+ const nextText = renderRef(this.text, ref, value);
311
620
  await writeFileAtomic(this.spec.filename, nextText, {
312
621
  mode: 384,
313
622
  dirMode: 448
@@ -316,7 +625,7 @@ var LocalCredentialProvider = class extends CredentialProvider {
316
625
  if (value === void 0) this.values.delete(ref);
317
626
  else this.values.set(ref, value);
318
627
  this.notifyUpdated(ref);
319
- });
628
+ }, { waitMs: DOCUMENT_LOCK_WAIT_MS });
320
629
  });
321
630
  }
322
631
  /**
@@ -330,7 +639,10 @@ var LocalCredentialProvider = class extends CredentialProvider {
330
639
  /**
331
640
  * Boot read: an absent file is an empty store; an invalid one fails the
332
641
  * plugin's activation, because a credentials document that exists but
333
- * cannot be trusted must never be treated as "no credentials stored".
642
+ * cannot be trusted must never be treated as "no credentials stored". The
643
+ * one exception is the recognized pre-release flat layout, which is
644
+ * upgraded in place first — a key stored by an earlier build must survive
645
+ * the layout change without a hand edit.
334
646
  */
335
647
  async loadInitial() {
336
648
  await assertOwnerOnly(this.spec.filename);
@@ -341,10 +653,41 @@ var LocalCredentialProvider = class extends CredentialProvider {
341
653
  if (!isENOENT(error)) throw error;
342
654
  return;
343
655
  }
344
- this.values = parseCredentialsDocument(text, this.spec.filename);
656
+ if (renderFlatLayoutMigration(text) !== void 0) text = await this.migrateFlatDocument();
657
+ const document = parseCredentialsDocument(text, this.spec.filename);
658
+ this.values = document.refs;
659
+ this.records = document.records;
345
660
  this.text = text;
346
661
  }
347
662
  /**
663
+ * One-shot upgrade of the recognized pre-release flat layout, before the
664
+ * watcher exists. The rewrite runs under the document's writer lock and
665
+ * re-reads first — a concurrent boot may have migrated already — and
666
+ * whatever the re-read finds that is not the flat layout is returned
667
+ * untouched for the ordinary parse. Values are carried verbatim; only the
668
+ * enclosing layout changes. Remove with the pre-release stance at the
669
+ * first tagged release.
670
+ * @returns the document text this boot should parse.
671
+ */
672
+ async migrateFlatDocument() {
673
+ return withFileLock(this.spec.filename, async () => {
674
+ const current = await readFile(this.spec.filename, "utf8");
675
+ const migrated = renderFlatLayoutMigration(current);
676
+ /* v8 ignore next 2 -- the losing side of the cross-process migration race:
677
+ another boot rewrote the document between the unlocked recognize and
678
+ this lock. That interleaving cannot be scheduled deterministically
679
+ through a whole boot (migration.spec drives it best-effort); the
680
+ decision itself is the recognizer's covered versioned-document decline. */
681
+ if (migrated === void 0) return current;
682
+ await writeFileAtomic(this.spec.filename, migrated, {
683
+ mode: 384,
684
+ dirMode: 448
685
+ });
686
+ this.ctx.logger.info("credentials-local: migrated %s to the version %d layout; values are unchanged", this.spec.filename, 1);
687
+ return migrated;
688
+ }, { waitMs: DOCUMENT_LOCK_WAIT_MS });
689
+ }
690
+ /**
348
691
  * Re-read the document after a watcher event. Unchanged content (including
349
692
  * this provider's own writes) is a no-op; an unreadable document keeps the
350
693
  * last good snapshot and warns — a live hot-reload must never take the
@@ -378,11 +721,17 @@ var LocalCredentialProvider = class extends CredentialProvider {
378
721
  text = void 0;
379
722
  }
380
723
  if (text === this.text || this.isClosed()) return;
381
- const next = text === void 0 ? /* @__PURE__ */ new Map() : parseCredentialsDocument(text, this.spec.filename);
382
- const changed = this.changedRefs(this.values, next);
724
+ const next = text === void 0 ? {
725
+ refs: /* @__PURE__ */ new Map(),
726
+ records: /* @__PURE__ */ new Map()
727
+ } : parseCredentialsDocument(text, this.spec.filename);
728
+ const changedRefs = this.changedRefs(this.values, next.refs);
729
+ const changedRecords = this.changedRecords(this.records, next.records);
383
730
  this.text = text;
384
- this.values = next;
385
- for (const ref of changed) this.notifyUpdated(ref);
731
+ this.values = next.refs;
732
+ this.records = next.records;
733
+ for (const ref of changedRefs) this.notifyUpdated(ref);
734
+ for (const key of changedRecords) this.notifyRecordUpdated(key);
386
735
  }
387
736
  /** Entries whose stored value changed; the parser has already proven every key addressable. */
388
737
  changedRefs(prev, next) {
@@ -393,6 +742,15 @@ var LocalCredentialProvider = class extends CredentialProvider {
393
742
  }
394
743
  return changed;
395
744
  }
745
+ /** Records whose stored value changed; the parser has already proven every key addressable. */
746
+ changedRecords(prev, next) {
747
+ const changed = [];
748
+ for (const key of new Set([...prev.keys(), ...next.keys()])) {
749
+ if (sameJsonValue(prev.get(key), next.get(key))) continue;
750
+ changed.push(parseCredentialKey(key));
751
+ }
752
+ return changed;
753
+ }
396
754
  };
397
755
  //#endregion
398
- export { CREDENTIALS_FILENAME, LocalCredentialProvider, LocalCredentialProvider as default, parseCredentialsDocument, resolveSpec };
756
+ export { CREDENTIALS_FILENAME, DOCUMENT_VERSION, LocalCredentialProvider, LocalCredentialProvider as default, parseCredentialsDocument, renderFlatLayoutMigration, resolveSpec };
package/lib/invariant.js CHANGED
@@ -10,7 +10,7 @@ const name = "credentials-local-invariant";
10
10
  const inject = ["invariants"];
11
11
  /**
12
12
  * No runtime invariant: the Service Definition companion (`dsh-credentials/invariant`) owns the
13
- * `credentials/updated` lifecycle contract; this provider's file/environment layering is
13
+ * `credentials/reference-updated` lifecycle contract; this provider's file/environment layering is
14
14
  * asynchronous I/O pinned by its unit suite.
15
15
  */
16
16
  const install = () => {};
@@ -37,7 +37,7 @@
37
37
  import { Context, Service } from '@deepseek-ai/cordis';
38
38
  import z from '@deepseek-ai/schemastery';
39
39
  import { CredentialProvider } from '@deepseek-ai/dsh-credentials';
40
- import type { CredentialInfo, CredentialRef, ResolvedCredential } from '@deepseek-ai/dsh-credentials';
40
+ import type { CredentialInfo, CredentialKey, CredentialRecord, CredentialRecordEntry, CredentialRecordInfo, CredentialRef, ResolvedCredential } from '@deepseek-ai/dsh-credentials';
41
41
  /** Basename of the credentials document inside the harness home. */
42
42
  export declare const CREDENTIALS_FILENAME = ".credentials.yaml";
43
43
  /** Plugin config: file location and hot-reload behavior. */
@@ -64,19 +64,41 @@ interface ResolvedSpec {
64
64
  * @returns the resolved file location and watch behavior.
65
65
  */
66
66
  export declare function resolveSpec(config: Config): ResolvedSpec;
67
+ /** The document layout this build reads and writes. */
68
+ export declare const DOCUMENT_VERSION = 1;
69
+ /** One parsed credentials document: the two key spaces it stores, keyed as written. */
70
+ export interface CredentialsDocument {
71
+ /** Reference entries, keyed by {@link CredentialRef}. */
72
+ refs: Map<string, string>;
73
+ /** Stored records, keyed by {@link CredentialKey}. */
74
+ records: Map<string, CredentialRecord>;
75
+ }
67
76
  /**
68
- * Parse one credentials document into its entries. The document is a strict
69
- * mapping of {@link CredentialRef} to non-empty string: a non-mapping root, a
70
- * key that is not a POSIX identifier, a non-string value, and an empty string
71
- * are all rejected rather than skipped, because this file holds nothing but
72
- * credentials and a silently ignored entry reads as "the key I stored has no
73
- * effect". Duplicate keys surface as parser errors. An empty document is an
74
- * empty store.
77
+ * Parse one credentials document. Everything is rejected rather than skipped —
78
+ * an unversioned root, an unknown top-level key, a key that is not addressable,
79
+ * a wrong-typed value, an unknown record tag or field — because this file holds
80
+ * nothing but credentials and a silently ignored entry reads as "the credential
81
+ * I stored has no effect". Duplicate keys surface as parser errors. An empty
82
+ * document is an empty store and needs no version.
75
83
  * @param text - the document's text.
76
84
  * @param filename - absolute path, quoted in errors.
77
- * @returns the parsed entries, keyed by reference.
85
+ * @returns the parsed references and records.
86
+ */
87
+ export declare function parseCredentialsDocument(text: string, filename: string): CredentialsDocument;
88
+ /**
89
+ * Render the version-1 layout for a pre-release flat document, or `undefined`
90
+ * for anything else. The flat layout is recognized exactly — a non-empty
91
+ * top-level mapping of addressable reference names to non-empty string
92
+ * scalars, with no `version` key and no document directives — and the rewrite
93
+ * nests the original lines verbatim under `refs:` at two spaces' indent, so
94
+ * comments, blank lines, and each value's spelling survive byte for byte.
95
+ * Anything the recognizer declines keeps {@link parseCredentialsDocument}'s
96
+ * loud rejection: a document this build cannot prove it understands is never
97
+ * rewritten. Remove with the pre-release stance at the first tagged release.
98
+ * @param text - the document's text.
99
+ * @returns the migrated text, or `undefined` when the text is not the recognized flat layout.
78
100
  */
79
- export declare function parseCredentialsDocument(text: string, filename: string): Map<string, string>;
101
+ export declare function renderFlatLayoutMigration(text: string): string | undefined;
80
102
  /** File-backed credentials provider (`$DSH_HOME/.credentials.yaml`). */
81
103
  export declare class LocalCredentialProvider extends CredentialProvider {
82
104
  config: Config;
@@ -88,8 +110,10 @@ export declare class LocalCredentialProvider extends CredentialProvider {
88
110
  * which is also the self-write suppression.
89
111
  */
90
112
  private text;
91
- /** Parsed document snapshot; replaced wholesale on every reload. */
113
+ /** Parsed reference snapshot; replaced wholesale on every reload. */
92
114
  private values;
115
+ /** Parsed record snapshot; replaced wholesale on every reload. */
116
+ private records;
93
117
  /**
94
118
  * Single exclusive operation chain: watcher reloads and line edits run one
95
119
  * at a time in queue order (settled tail), so an edit can never render from
@@ -114,6 +138,11 @@ export declare class LocalCredentialProvider extends CredentialProvider {
114
138
  describe(ref: CredentialRef): Promise<CredentialInfo>;
115
139
  set(ref: CredentialRef, value: string): Promise<void>;
116
140
  unset(ref: CredentialRef): Promise<void>;
141
+ readRecord(key: CredentialKey): Promise<CredentialRecord | undefined>;
142
+ describeRecord(key: CredentialKey): Promise<CredentialRecordInfo>;
143
+ listRecords(): Promise<readonly CredentialRecordEntry[]>;
144
+ modifyRecord(key: CredentialKey, mutate: (current: CredentialRecord | undefined) => Promise<CredentialRecord | undefined>): Promise<CredentialRecord | undefined>;
145
+ deleteRecord(key: CredentialKey): Promise<void>;
117
146
  /** Queue one exclusive document operation behind every earlier one. */
118
147
  private enqueue;
119
148
  /** Queue a reload; only an invariant violation escaping the fan-out can reject it. */
@@ -129,9 +158,23 @@ export declare class LocalCredentialProvider extends CredentialProvider {
129
158
  /**
130
159
  * Boot read: an absent file is an empty store; an invalid one fails the
131
160
  * plugin's activation, because a credentials document that exists but
132
- * cannot be trusted must never be treated as "no credentials stored".
161
+ * cannot be trusted must never be treated as "no credentials stored". The
162
+ * one exception is the recognized pre-release flat layout, which is
163
+ * upgraded in place first — a key stored by an earlier build must survive
164
+ * the layout change without a hand edit.
133
165
  */
134
166
  private loadInitial;
167
+ /**
168
+ * One-shot upgrade of the recognized pre-release flat layout, before the
169
+ * watcher exists. The rewrite runs under the document's writer lock and
170
+ * re-reads first — a concurrent boot may have migrated already — and
171
+ * whatever the re-read finds that is not the flat layout is returned
172
+ * untouched for the ordinary parse. Values are carried verbatim; only the
173
+ * enclosing layout changes. Remove with the pre-release stance at the
174
+ * first tagged release.
175
+ * @returns the document text this boot should parse.
176
+ */
177
+ private migrateFlatDocument;
135
178
  /**
136
179
  * Re-read the document after a watcher event. Unchanged content (including
137
180
  * this provider's own writes) is a no-op; an unreadable document keeps the
@@ -150,6 +193,8 @@ export declare class LocalCredentialProvider extends CredentialProvider {
150
193
  private reconcileFromDisk;
151
194
  /** Entries whose stored value changed; the parser has already proven every key addressable. */
152
195
  private changedRefs;
196
+ /** Records whose stored value changed; the parser has already proven every key addressable. */
197
+ private changedRecords;
153
198
  }
154
199
  export default LocalCredentialProvider;
155
200
  //# sourceMappingURL=index.d.ts.map
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.0-rc.8",
4
+ "version": "0.1.1-rc.2",
5
5
  "publishConfig": {
6
6
  "access": "public"
7
7
  },
@@ -32,12 +32,12 @@
32
32
  ],
33
33
  "license": "MIT",
34
34
  "peerDependencies": {
35
- "@deepseek-ai/dsh-atomic-write": "^0.1.0-rc.8",
36
- "@deepseek-ai/dsh-credentials": "^0.1.0-rc.8",
37
- "@deepseek-ai/dsh-launch-environment": "^0.1.0-rc.8",
38
- "@deepseek-ai/dsh-invariants": "^0.1.0-rc.8",
39
- "@deepseek-ai/dsh-home-paths": "^0.1.0-rc.8",
40
- "@deepseek-ai/cordis": "^4.0.1"
35
+ "@deepseek-ai/dsh-launch-environment": "^0.1.1-rc.2",
36
+ "@deepseek-ai/dsh-invariants": "^0.1.1-rc.2",
37
+ "@deepseek-ai/dsh-home-paths": "^0.1.1-rc.2",
38
+ "@deepseek-ai/cordis": "^4.0.1",
39
+ "@deepseek-ai/dsh-atomic-write": "^0.1.1-rc.2",
40
+ "@deepseek-ai/dsh-credentials": "^0.1.1-rc.2"
41
41
  },
42
42
  "dependencies": {
43
43
  "chokidar": "^4.0.3",
@@ -45,11 +45,11 @@
45
45
  "@deepseek-ai/schemastery": "^3.18.1"
46
46
  },
47
47
  "devDependencies": {
48
- "@deepseek-ai/dsh-credentials": "^0.1.0-rc.8",
49
- "@deepseek-ai/dsh-atomic-write": "^0.1.0-rc.8",
50
- "@deepseek-ai/dsh-launch-environment": "^0.1.0-rc.8",
51
- "@deepseek-ai/dsh-invariants": "^0.1.0-rc.8",
52
- "@deepseek-ai/dsh-home-paths": "^0.1.0-rc.8",
53
- "@deepseek-ai/cordis": "^4.0.1"
48
+ "@deepseek-ai/dsh-credentials": "^0.1.1-rc.2",
49
+ "@deepseek-ai/dsh-launch-environment": "^0.1.1-rc.2",
50
+ "@deepseek-ai/dsh-atomic-write": "^0.1.1-rc.2",
51
+ "@deepseek-ai/dsh-invariants": "^0.1.1-rc.2",
52
+ "@deepseek-ai/cordis": "^4.0.1",
53
+ "@deepseek-ai/dsh-home-paths": "^0.1.1-rc.2"
54
54
  }
55
55
  }