fix(credentials-local): keep foreign big integers exact and cover reload warnings

Re-serializing a document with a foreign integer beyond 2^53 no longer rounds it;
the reload warning is tested; the GITLAB_TOKEN test no longer depends on the host
environment; README and JSDoc describe the tolerated top-level keys.
This commit is contained in:
Turtle committed 2026-10-13 15:37:16 +08:00
1 parent 8e413612c8
commit 004aa05085
6 files changed
+53 -20

No files matched your search

@@ -30,8 +30,8 @@
en: a55fac148b62e007
zh: fdb3d19ccae676ee
/deepseek-ai-dsh-credentials-local/use-this-package/the-credential-file:
en: cbd156a76595ee1f
zh: 171d3c8116699eea
en: b18c236af3c175ed
zh: 516c6bc7acfcf7e6
/deepseek-ai-dsh-credentials-local/use-this-package/who-can-read-the-file:
en: 76bcb1dab6a112e2
zh: 42669953eef0fc53
@@ -42,8 +42,8 @@
en: 538553a9a48e2f93
zh: d981c42878e80762
/deepseek-ai-dsh-credentials-local/understand-the-implementation/design-philosophy:
en: 2f76cb96086897ce
zh: 6b75376681446446
en: eaa1671bd6877d79
zh: 50c56ac9293809f1
/deepseek-ai-dsh-credentials-local/understand-the-implementation/source-map:
en: a83715807279a902
zh: fef1884e2a16bd46
@@ -106,7 +106,7 @@ records:
kind: api-key # neither: the owner confirmed the ambient credential chain
```
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. An unknown top-level key, such as a token another tool wrote, is kept on disk and ignored with a startup warning, because only `refs` and `records` entries resolve. Inside the document anything else is refused loudly rather than silently ignored: a non-mapping root, 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.
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. An unknown top-level key, such as a token another tool wrote, is kept on disk and ignored with a warning at startup and on each reload that changes the file, because only `refs` and `records` entries resolve. Inside the document anything else is refused loudly rather than silently ignored: a non-mapping root, 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.
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.
@@ -134,7 +134,7 @@ This section explains the design decisions behind the provider and points at the
### Design philosophy
- **One honest precedence.** The managed store wins for every provider; launch environment, project `.env`, and harness-home `.env` are deterministic fallbacks in that order.
- **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.
- **The document holds only credentials.** A foreign top-level key is tolerated but resolves nothing. 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.
- **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.
- **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.
@@ -106,7 +106,7 @@ records:
kind: api-key # neither: the owner confirmed the ambient credential chain
```
你可以直接编辑该文件——存储会自动重载并接收变更,包括你删除的密钥或记录。`refs` 按环境变量名存放密钥值;`records` 按 `<owner>/<id>` 存放各插件的凭据,每条都带 `api-key` 或 `grant` 标签,其中 grant 的 payload 由存储逐字保留,因为只有它的拥有者能解释。产品写入时会保留注释与未触及条目的排版;直接位于某条目上方的注释属于该条目的注解,会随它一起删除。未知的顶层键(例如其他工具写入的令牌)会保留在磁盘上并在启动时告警后忽略,因为只有 `refs` 与 `records` 中的条目会被解析。文档内其他内容都会被明确拒绝,而不是被静默忽略:非 mapping 的根、在其键空间内不可寻址的键、类型错误或空的值、未知的记录标签或字段、重复键以及格式错误的 YAML 都会在启动时失败;运行期热重载时则保留最后可用内容并告警。
你可以直接编辑该文件——存储会自动重载并接收变更,包括你删除的密钥或记录。`refs` 按环境变量名存放密钥值;`records` 按 `<owner>/<id>` 存放各插件的凭据,每条都带 `api-key` 或 `grant` 标签,其中 grant 的 payload 由存储逐字保留,因为只有它的拥有者能解释。产品写入时会保留注释与未触及条目的排版;直接位于某条目上方的注释属于该条目的注解,会随它一起删除。未知的顶层键(例如其他工具写入的令牌)会保留在磁盘上并被忽略,启动时以及每次改变文件内容的重载时都会告警,因为只有 `refs` 与 `records` 中的条目会被解析。文档内其他内容都会被明确拒绝,而不是被静默忽略:非 mapping 的根、在其键空间内不可寻址的键、类型错误或空的值、未知的记录标签或字段、重复键以及格式错误的 YAML 都会在启动时失败;运行期热重载时则保留最后可用内容并告警。
密钥的值可以是任意文本,包括多行值——不需要任何引号技巧。空值等于「没有密钥」,这正是文件中的空字符串被拒绝的原因:移除密钥是删除它,而不是把它置空。`grant` 的 payload 必须经受 JSON 往返,进出两个方向都会强制这一点,因此存储会拒绝无法逐字读回的值。如果磁盘上的文件已无法解析,保存会失败,而不是覆盖产品读不懂的内容。
@@ -134,7 +134,7 @@ records:
### 设计理念
- **一套明确的优先级。** 受管存储对每个提供方都胜出;启动环境、项目 `.env` 与 harness home 的 `.env` 依次是确定的后备来源。
- **文档只存放凭据。** 它是带 `refs` 与 `records` 分节的版本化文档,而不是 dotenv 文件:一个 harness 拥有、且绝不物化进环境的存储,不能同时充当用户的环境层,否则会以自己的优先级遮蔽非机密条目。
- **文档只存放凭据。** 外来的顶层键会被容忍,但不解析出任何凭据。它是带 `refs` 与 `records` 分节的版本化文档,而不是 dotenv 文件:一个 harness 拥有、且绝不物化进环境的存储,不能同时充当用户的环境层,否则会以自己的优先级遮蔽非机密条目。
- **写入打补丁,重载整体替换。** 行编辑在跨进程写锁下保留注释与未触及条目;重载整体交换解析后的快照,已删除条目绝不在内存滞留。
- **信任攸关处明确报错。** 启动与重载都会拒绝不可读、无效或可被属主之外读取的文档;运行期重载失败时会保留最后可用快照并告警,而不是拖垮进程。
@@ -25,8 +25,9 @@
* edits hot-publish through the seam, and each reload replaces the snapshot
* wholesale so a deleted entry never lingers in memory.
*
* The document holds nothing but credentials, which is why it is a strict
* `CredentialRef`-to-string mapping rather than a dotenv file: a store the
* The document holds credentials, which is why `refs` is a strict
* `CredentialRef`-to-string mapping rather than a dotenv file; a foreign
* top-level key another tool wrote is kept but resolves nothing: a store the
* Harness owns and never materializes into the environment cannot also serve
* as the user's environment layer; a store that doubled as the environment
* layer would shadow non-secret entries behind its precedence, making them
@@ -165,7 +166,7 @@ function describeYamlError(error: YAMLError): string {
/** The document layout this build reads and writes. */
export const DOCUMENT_VERSION = 1
/** One parsed credentials document: the two key spaces it stores, keyed as written. */
/** One parsed credentials document: the two key spaces it stores, keyed as written, plus the top-level keys it does not read. */
export interface CredentialsDocument {
/** Reference entries, keyed by {@link CredentialRef}. */
refs: Map<string, string>
@@ -186,7 +187,7 @@ export interface CredentialsDocument {
* and needs no version.
* @param text - the document's text.
* @param filename - absolute path, quoted in errors.
* @returns the parsed references and records.
* @returns the parsed references, records, and ignored top-level keys.
*/
export function parseCredentialsDocument(text: string, filename: string): CredentialsDocument {
// `prettyErrors` is on only for `linePos`; `error.message` is never used,
@@ -426,8 +427,9 @@ function assertJsonValue(where: string, value: unknown, seen: Set<object>): void
*/
function mutableDocument(text: string | undefined): Document {
// `text` only ever caches content that parsed successfully, so this re-parse
// for the mutable comment-preserving tree cannot fail.
const document = text === undefined ? new Document({}) : parseDocument(text)
// for the mutable comment-preserving tree cannot fail. `intAsBigInt` keeps an
// integer beyond 2^53 in a foreign top-level entry exact when re-serialized.
const document = text === undefined ? new Document({}) : parseDocument(text, { intAsBigInt: true })
// Stamped on every edit so a document this provider creates is readable by
// the same parser that admitted the one it edits; an existing stamp is
// rewritten to the identical value.
@@ -237,9 +237,9 @@ describe('layer ladder', () => {
})
describe('document validation', () => {
// Every rejection below is a boot failure rather than a skipped entry: this
// document holds nothing but credentials, so an ignored key would read as
// "the secret I stored has no effect".
// Every rejection below is a boot failure rather than a skipped entry: inside
// `refs` and `records`, an ignored entry would read as "the secret I stored
// has no effect". Only a foreign top-level key is tolerated.
it.each([
['a non-mapping root', 'just a string\n', /must be a mapping/],
['a sequence root', '- DSH_CRED_TEST\n', /must be a mapping/],
@@ -317,7 +317,9 @@ describe('document validation', () => {
it('boots past an unknown top-level key, warns by name, and keeps it through a write', async () => {
const dir = await tempDir()
const path = join(dir, '.credentials.yaml')
await writeCredentials(path, 'version: 1\nGITLAB_TOKEN: glpat-secret\nrefs:\n DSH_CRED_TEST: sk-file\n')
vi.stubEnv('GITLAB_TOKEN', '')
await writeCredentials(path, 'version: 1\nGITLAB_TOKEN: glpat-secret\nACCOUNT_ID: 9007199254740993\n'
+ 'refs:\n DSH_CRED_TEST: sk-file\n')
const ctx = new Context()
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
const fiber = ctx.plugin(LocalCredentialProvider, { path, watch: false })
@@ -327,11 +329,12 @@ describe('document validation', () => {
await fiber
expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'sk-file', source: 'file' })
expect(await ctx.credentials.resolve(credentialRef('GITLAB_TOKEN'))).toBeUndefined()
expect(warn).toHaveBeenCalledWith(expect.stringContaining('ignoring unknown top-level'), '"GITLAB_TOKEN"', path)
expect(warn).toHaveBeenCalledWith(expect.stringContaining('ignoring unknown top-level'), '"GITLAB_TOKEN", "ACCOUNT_ID"', path)
expect(JSON.stringify(warn.mock.calls)).not.toContain('glpat-secret')
await ctx.credentials.set(OTHER, 'sk-other')
expect(await readFile(path, 'utf8')).toBe(
'version: 1\nGITLAB_TOKEN: glpat-secret\nrefs:\n DSH_CRED_TEST: sk-file\n DSH_CRED_OTHER: sk-other\n')
'version: 1\nGITLAB_TOKEN: glpat-secret\nACCOUNT_ID: 9007199254740993\n'
+ 'refs:\n DSH_CRED_TEST: sk-file\n DSH_CRED_OTHER: sk-other\n')
})
it('reads an empty document as an empty store', async () => {
@@ -237,6 +237,34 @@ describe('watcher pipeline', () => {
expect(seen).toEqual([KEY])
})
it('warns about an unknown top-level key a live reload brings in, once per change', async () => {
const dir = await tempDir()
const path = join(dir, '.credentials.yaml')
await writeCredentials(path, 'version: 1\nrefs:\n DSH_CRED_PIPE: a\n')
const ctx = new Context()
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
const fiber = ctx.plugin(LocalCredentialProvider, { path, debounceMs: 5 })
cleanups.push(async () => {
await fiber.dispose()
})
await fiber
expect(warn).not.toHaveBeenCalled()
await writeCredentials(path, 'version: 1\nFOREIGN: x\nrefs:\n DSH_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' })
})
expect(warn).toHaveBeenCalledTimes(1)
expect(warn).toHaveBeenCalledWith(expect.stringContaining('ignoring unknown top-level'), '"FOREIGN"', path)
// An event that finds the same text publishes and reports nothing again.
instance!.watcher.emit('all', 'change', path)
await new Promise(resolve => setTimeout(resolve, 50))
expect(warn).toHaveBeenCalledTimes(1)
})
it('treats an event for a still-absent file as a no-op', async () => {
const dir = await tempDir()
const path = join(dir, '.credentials.yaml')