@stackstackstack/dsh-credentials-local 0.1.5

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 DeepSeek
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,6 @@
1
+ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
2
+ # side as of the last confirmed-consistent state. Both languages carry equal authority;
3
+ # after editing either side, bring the other along and re-record with:
4
+ # pnpm run verify-translation-pairing --write packages/credentials/credentials-local/README.md
5
+ README.md: 63895d34fe8396d55c1d6ef29b3e3d9a9dc17264
6
+ README.zh.md: 3c60fb7bbf06a0ae9ec905f36a5c543c2fb6af1b
package/README.md ADDED
@@ -0,0 +1,71 @@
1
+ # dsh-credentials-local
2
+
3
+ English | [中文](README.zh.md)
4
+
5
+ File-backed [credentials](../credentials/README.md) provider: four layers, one honest precedence.
6
+
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 |
13
+
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.
15
+
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.
17
+
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.
19
+
20
+ ## Config
21
+
22
+ | Field | Default | Meaning |
23
+ |---|---|---|
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. |
28
+
29
+ ## The document
30
+
31
+ A YAML mapping of credential reference to value, and nothing else:
32
+
33
+ ```yaml
34
+ DEEPSEEK_API_KEY: sk-…
35
+ OPENAI_API_KEY: sk-…
36
+ ```
37
+
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.
39
+
40
+ 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
+
42
+ 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.
43
+
44
+ ## Permissions
45
+
46
+ 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.
47
+
48
+ ## Hot reload
49
+
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.
51
+
52
+ ## Security boundary
53
+
54
+ 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.
55
+
56
+ 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.
57
+
58
+ ## Model Experience
59
+
60
+ Indirectly, through the consuming LLM adapters: stored values authorize their provider requests, and the adapter owns every model-visible surface.
61
+
62
+ #### KV Cache effect
63
+
64
+ No direct invalidation; credentials never enter a request prefix.
65
+
66
+ ## Known Limitations and Deferred Work
67
+
68
+ - **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.
69
+ - **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.
70
+ - **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.
71
+ - **Atomic, not crash-durable** — inherited from `dsh-atomic-write`; the store re-reads on boot.
package/README.zh.md ADDED
@@ -0,0 +1,73 @@
1
+ # dsh-credentials-local
2
+
3
+ [English](README.md) | 中文
4
+
5
+ 文件型[凭据](../credentials/README.md)提供方:四层来源,一套明确的优先级。
6
+
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` | 不在此处 | 其余情况 |
13
+
14
+ 启动环境优先,因为按次覆盖(`DEEPSEEK_API_KEY=… dsh`、CI 机密、容器 `-e`)代表本次运行的操作者意图——而它无法从进程内部修改,就必须*可见地*只读:`describe()` 报告 `source: 'env', writable: false`,`set`/`unset` 直接拒绝,而不是写下一个读取方永远看不到的变更。
15
+
16
+ 它之下的所有来源优先级都低于受管存储,因此 Models 页写入的密钥会立即生效,即使某个 `.env` 里还留着更旧的密钥。没有存储任何东西时这两层仍会参与解析,`describe()` 会把来源报告为 `project-env` 或 `user-env` 且 `writable: true`——存入一个密钥就会取代它们成为生效来源。
17
+
18
+ 在产品 CLI(命令行界面)下,解析读取的是启动器冻结的[环境快照](../../util/launch-environment/README.md)而不是 `process.env`:只有快照才说得清某个值来自启动 shell 还是来自某个文件。并非由产品 CLI 启动的组合只有继承环境这一层,这让嵌入方保持它们原有的语义。
19
+
20
+ ## 配置
21
+
22
+ | 字段 | 默认值 | 含义 |
23
+ |---|---|---|
24
+ | `path` | `<harness home>/.credentials.yaml` | 凭据文档位置。 |
25
+ | `dshHome` | `$DSH_HOME` 或 `~/.dsh` | `path` 缺省时使用的 harness home。 |
26
+ | `watch` | `true` | 热发布外部编辑。 |
27
+ | `debounceMs` | `100` | watcher 写入稳定窗口。 |
28
+
29
+ ## 文档本身
30
+
31
+ 一个从凭据引用到值的 YAML mapping,除此之外别无他物:
32
+
33
+ ```yaml
34
+ DEEPSEEK_API_KEY: sk-…
35
+ OPENAI_API_KEY: sk-…
36
+ ```
37
+
38
+ 该文档只存放凭据,因此任何偏离都是拒绝,而不是跳过某个条目——被静默忽略的键读起来就是「我存进去的密钥没有生效」。非 mapping 的根、非 POSIX 标识符的键、非字符串值、空字符串、重复键以及格式错误的 YAML 全部失败:启动时明确报错,运行期热重载则告警并保留最后可用快照。没有 `version` 字段,也没有包装层;格式就是这个 mapping。
39
+
40
+ 写入是对已解析文档打补丁而不是重建,因此注释与所有未触及条目的排版都会保留。直接位于某条目上方的注释属于该条目的注解,会随它一起删除。每次写入都先在 [`dsh-atomic-write`](../../util/atomic-write/README.md) 的跨进程写锁下重读文档、把此前未观察到的一切发布出去,再在仅属主可访问(`0700`)的目录下以 `0600` 权限原子提交——因此并发写入者、或落在 watcher 防抖窗口内的外部编辑会被并入,而不是被覆盖。磁盘上已经无法解析的文档会让写入失败,而不是覆盖提供方读不懂的内容。
41
+
42
+ 任何字符串值都能往返,包括多行值,因此不会再有条目因为缺少可用引号样式而不可写。空的存储值等于不存在(seam 规则)——这也正是文档中的空字符串被直接拒绝的原因:`unset` 删除键,而不是把它置空。
43
+
44
+ ## 权限
45
+
46
+ 提供方以 `0700` 创建目录,以 `0600` 创建或原子替换文档。它对*读取*同样守住这条界线:在 POSIX 上,只要文档带有任何 group 或 other 权限位,就会在解析其内容之前失败——启动时与每次 reload 都检查——并在错误里给出 `chmod 600` 的修复命令。Windows 没有可检查的 mode,因此在那里跳过该检查而不是伪造它。
47
+
48
+ ## 热重载
49
+
50
+ 外部编辑在快照**整体替换**后按变更引用逐个发布 `credentials/updated`——磁盘上删掉的条目绝不在内存滞留。在 Chokidar 打开目标之前,提供方会对层级最深的现有祖先路径执行 realpath 解析,再拼回缺失的后缀;文件访问和诊断仍使用配置路径,从而避免 Windows 混用 8.3 别名与 libuv 的长格式事件路径。提供方自己的写入按内容识别,只发布属于该次提交的一个事件。运行期文档不可读或无效时保留最后可用快照并告警;文件不存在即空存储;启动时不可读或无效则明确报错。
51
+
52
+ <a id="security-boundary"></a>
53
+
54
+ ## 安全边界
55
+
56
+ 文档在 `0700` 目录下以 `0600` 权限存放,这挡得住其他 OS 用户,**挡不住**模型。工具进程(bash、文件系统工具)以同一用户身份运行,而已交付的 `workspace-write` 文件策略限制的是修改而非读取,因此它们读这个文件与读该用户拥有的任何其他文件毫无二致;也没有任何沙箱模式会把它单独挑出来。harness 真正守住的更窄:它绝不把该文档的解析后路径交给模型,也绝不把它载入进程环境——这与用户的普通环境层 `$DSH_HOME/.env` 不同(见 [app-boot 的 Harness home 各层](../../boot/app-boot/README.md#profiles))——因此要拿到这个值,需要刻意去读一条并未交给 agent(智能体)的路径。
57
+
58
+ 这是审慎,不是边界。必须让提供方密钥远离自身 agent 的部署无法靠文件权限做到;OS 钥匙串提供方——一种模型运行所在进程根本无法读取的存储——才是延后的答案,它应当作为平级包与本提供方并列。
59
+
60
+ ## 模型体验
61
+
62
+ 经由消费它的 LLM(大语言模型)适配器间接生效:存储的值为适配器向提供方发出的请求授权,所有模型可见内容均由适配器负责。
63
+
64
+ #### KV Cache 影响
65
+
66
+ 无直接失效;凭据绝不进入请求前缀。
67
+
68
+ ## 已知限制与暂缓事项
69
+
70
+ - **同一引用的并发写入是后写胜出**——写锁加读-改-写让并发写入者不会丢掉彼此的条目,但两个写入者编辑同一个引用时仍以较后的写入为准;没有修订检查。
71
+ - **同 UID 进程可以读取该文档**——见[安全边界](#security-boundary):文件效果沙箱模式不会拒绝读取,OS 钥匙串提供方仍是延后项。
72
+ - **环境变化不可见**:快照在启动时冻结,因此启动之后 export 的变量既不会进入解析,也不会进入 `describe`;要更换来自环境的凭据需要重启。
73
+ - **原子但不具备崩溃持久性**——继承自 `dsh-atomic-write`;存储在启动时重新读取。
package/lib/index.js ADDED
@@ -0,0 +1,398 @@
1
+ import { Service } from "@deepseek-ai/cordis";
2
+ import z from "@deepseek-ai/schemastery";
3
+ import { watch } from "chokidar";
4
+ import { mkdir, readFile, stat } from "node:fs/promises";
5
+ import { dirname, join, resolve } from "node:path";
6
+ import { Document, parseDocument } from "yaml";
7
+ import { withFileLock, writeFileAtomic } from "@stackstackstack/dsh-atomic-write";
8
+ import { canonicalizeWatchPath, resolveDshHome } from "@stackstackstack/dsh-home-paths";
9
+ import { launchEnvironmentOf } from "@stackstackstack/dsh-launch-environment";
10
+ import { CredentialProvider, credentialRef } from "@stackstackstack/dsh-credentials";
11
+ //#region lib/types/index.js
12
+ /**
13
+ * File-backed credentials provider over `$DSH_HOME/.credentials.yaml`, layered
14
+ * against the environment by how much each layer is trusted:
15
+ *
16
+ * ```text
17
+ * inherited process environment (read-only, wins)
18
+ * > $DSH_HOME/.credentials.yaml (provider-managed, writable)
19
+ * > <invocation cwd>/.env (read-only fallback)
20
+ * > $DSH_HOME/.env (read-only fallback)
21
+ * ```
22
+ *
23
+ * The inherited environment wins because `DEEPSEEK_API_KEY=… dsh`, a CI
24
+ * secret, or a container `-e` is this run's explicit intent; it cannot be
25
+ * edited from inside, so it must be *visibly* read-only rather than silently
26
+ * shadow writes. Everything below it loses to the managed store, so a key the
27
+ * Models page writes takes effect immediately even when an older key sits in
28
+ * the user's `.env`.
29
+ *
30
+ * The invoking project may supply a key, because the product trusts the
31
+ * project it is launched in. It ranks below the managed store, so a key stored
32
+ * through the Models page is never displaced by one a checkout happens to carry.
33
+ *
34
+ * The file is the provider-managed writable source: every write re-reads the
35
+ * document under a cross-process writer lock before patching only its own key
36
+ * — comments and the formatting of every untouched entry survive — external
37
+ * edits hot-publish through the seam, and each reload replaces the snapshot
38
+ * wholesale so a deleted entry never lingers in memory.
39
+ *
40
+ * The document holds nothing but credentials, which is why it is a strict
41
+ * `CredentialRef`-to-string mapping rather than a dotenv file: a store the
42
+ * Harness owns and never materializes into the environment cannot also serve
43
+ * as the user's environment layer; a store that doubled as the environment
44
+ * layer would shadow non-secret entries behind its precedence, making them
45
+ * silently unreachable.
46
+ * @module @stackstackstack/dsh-credentials-local
47
+ */
48
+ /** Basename of the credentials document inside the harness home. */
49
+ const CREDENTIALS_FILENAME = ".credentials.yaml";
50
+ /**
51
+ * Resolve the runtime spec from plugin config: an explicit `path` wins,
52
+ * otherwise the document lives at `<harness home>/.credentials.yaml`.
53
+ * @param config - raw plugin config.
54
+ * @returns the resolved file location and watch behavior.
55
+ */
56
+ function resolveSpec(config) {
57
+ return {
58
+ filename: resolve(config.path ?? join(resolveDshHome(config.dshHome), ".credentials.yaml")),
59
+ watch: config.watch ?? true,
60
+ debounceMs: config.debounceMs ?? 100
61
+ };
62
+ }
63
+ /** Permission bits outside the owner; a credentials document must have none of them. */
64
+ const GROUP_OTHER_BITS = 63;
65
+ /**
66
+ * Reject a credentials document other OS users can read, before its contents
67
+ * are read at all. The provider creates and replaces the file at `0600`, but a
68
+ * hand-written or externally generated one carries whatever umask produced it,
69
+ * and silently serving secrets out of a world-readable file would make the
70
+ * mode the provider promises meaningless.
71
+ *
72
+ * POSIX only: Windows has no mode to inspect — its ACLs are not expressible
73
+ * here — so the check is skipped rather than faked, and the file's protection
74
+ * there is whatever the create and replace APIs express.
75
+ * @param filename - absolute path of the document.
76
+ * @throws when the path hierarchy is invalid or the file exists with group or other permission bits set.
77
+ */
78
+ async function assertOwnerOnly(filename) {
79
+ let mode;
80
+ try {
81
+ mode = (await stat(filename)).mode;
82
+ } catch (error) {
83
+ if (!isENOENT(error)) throw error;
84
+ await canonicalizeWatchPath(filename);
85
+ return;
86
+ }
87
+ /* v8 ignore next -- POSIX coverage cannot take the Windows peer; native Windows coverage does. */
88
+ if (process.platform === "win32") return;
89
+ if ((mode & GROUP_OTHER_BITS) === 0) return;
90
+ throw new Error(`credentials-local: ${filename} is readable beyond its owner (mode ${(mode & 511).toString(8)}); run "chmod 600 ${filename}" before starting again`);
91
+ /* v8 ignore stop */
92
+ }
93
+ /** Whether a filesystem error means absence; every non-ENOENT failure must surface. */
94
+ function isENOENT(error) {
95
+ return error?.code === "ENOENT";
96
+ }
97
+ /**
98
+ * Describe one YAML parse failure without quoting the source. The parser's own
99
+ * message embeds the offending line, which here holds a secret.
100
+ * @param error - the parser's error.
101
+ * @returns the error code with its line and column.
102
+ */
103
+ function describeYamlError(error) {
104
+ const at = error.linePos?.[0];
105
+ /* v8 ignore next -- `prettyErrors` populates linePos on every error; the guard answers its optional type */
106
+ const where = at === void 0 ? "" : ` at line ${String(at.line)}, column ${String(at.col)}`;
107
+ return `${error.code}${where}`;
108
+ }
109
+ /**
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.
117
+ * @param text - the document's text.
118
+ * @param filename - absolute path, quoted in errors.
119
+ * @returns the parsed entries, keyed by reference.
120
+ */
121
+ function parseCredentialsDocument(text, filename) {
122
+ const document = parseDocument(text, {
123
+ prettyErrors: true,
124
+ uniqueKeys: true
125
+ });
126
+ if (document.errors.length > 0) throw new Error(`credentials-local: invalid document at ${filename}: ${document.errors.map(describeYamlError).join("; ")}`);
127
+ 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`);
129
+ const entries = /* @__PURE__ */ new Map();
130
+ for (const [key, value] of Object.entries(root)) {
131
+ credentialRef(key);
132
+ if (typeof value !== "string") throw new TypeError(`credentials-local: the value for "${key}" in ${filename} must be a string`);
133
+ if (value.length === 0) throw new Error(`credentials-local: the value for "${key}" in ${filename} is empty; remove the key instead`);
134
+ entries.set(key, value);
135
+ }
136
+ return entries;
137
+ }
138
+ /**
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.
142
+ * @param text - the current document text, `undefined` while the file is absent.
143
+ * @param ref - the reference to write.
144
+ * @param value - the new value, or `undefined` to delete the key.
145
+ * @returns the text to persist.
146
+ */
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);
151
+ return document.toString();
152
+ }
153
+ /** File-backed credentials provider (`$DSH_HOME/.credentials.yaml`). */
154
+ var LocalCredentialProvider = class extends CredentialProvider {
155
+ config;
156
+ static Config = z.object({
157
+ path: z.string(),
158
+ dshHome: z.string(),
159
+ watch: z.boolean().default(true),
160
+ debounceMs: z.number().min(0).default(100)
161
+ });
162
+ spec;
163
+ /**
164
+ * Raw text of the last read or persisted document; `undefined` while the
165
+ * file is absent. Watcher events whose content equals this cache are no-ops,
166
+ * which is also the self-write suppression.
167
+ */
168
+ text;
169
+ /** Parsed document snapshot; replaced wholesale on every reload. */
170
+ values = /* @__PURE__ */ new Map();
171
+ /**
172
+ * Single exclusive operation chain: watcher reloads and line edits run one
173
+ * at a time in queue order (settled tail), so an edit can never render from
174
+ * text a concurrent reload is busy replacing.
175
+ */
176
+ operations = Promise.resolve();
177
+ /** Set at dispose: refuse new writes and let in-flight work no-op. */
178
+ closed = false;
179
+ /** Opaque read of {@link closed}: control flow cannot narrow it across awaits. */
180
+ isClosed() {
181
+ return this.closed;
182
+ }
183
+ constructor(ctx, config) {
184
+ super(ctx);
185
+ this.config = config;
186
+ this.spec = resolveSpec(config);
187
+ }
188
+ /** The inherited-environment value for a reference, or `undefined` when empty or unset. */
189
+ inherited(ref) {
190
+ const entry = launchEnvironmentOf(this.ctx).getFrom(ref, ["process"]);
191
+ return entry !== void 0 && entry.value.length > 0 ? entry.value : void 0;
192
+ }
193
+ /**
194
+ * The `.env` fallback for a reference — below the managed store, never above
195
+ * it. The invoking project ranks over the user's home file, matching the
196
+ * environment layering: the more specific location wins.
197
+ */
198
+ dotenvFallback(ref) {
199
+ const entry = launchEnvironmentOf(this.ctx).getFrom(ref, ["project-env", "user-env"]);
200
+ return entry !== void 0 && entry.value.length > 0 ? entry : void 0;
201
+ }
202
+ async *[Service.init]() {
203
+ yield async () => {
204
+ this.closed = true;
205
+ await this.operations;
206
+ };
207
+ await this.loadInitial();
208
+ if (!this.spec.watch) return;
209
+ const watcher = watch(await canonicalizeWatchPath(this.spec.filename), {
210
+ ignoreInitial: true,
211
+ awaitWriteFinish: {
212
+ stabilityThreshold: this.spec.debounceMs,
213
+ pollInterval: Math.max(1, Math.min(this.spec.debounceMs, 10))
214
+ }
215
+ });
216
+ watcher.on("all", () => {
217
+ if (this.closed) return;
218
+ this.queueRefresh();
219
+ });
220
+ watcher.on("ready", () => {
221
+ if (this.closed) return;
222
+ this.queueRefresh();
223
+ });
224
+ watcher.on("error", (error) => {
225
+ this.ctx.logger.warn("credentials-local: watcher error on %s", this.spec.filename);
226
+ this.ctx.logger.warn(error);
227
+ });
228
+ yield async () => {
229
+ this.closed = true;
230
+ await watcher.close();
231
+ await this.operations;
232
+ };
233
+ }
234
+ resolve(ref) {
235
+ const inherited = this.inherited(ref);
236
+ if (inherited !== void 0) return Promise.resolve({
237
+ value: inherited,
238
+ source: "env"
239
+ });
240
+ const stored = this.values.get(ref);
241
+ if (stored !== void 0) return Promise.resolve({
242
+ value: stored,
243
+ source: "file"
244
+ });
245
+ const fallback = this.dotenvFallback(ref);
246
+ if (fallback !== void 0) return Promise.resolve({
247
+ value: fallback.value,
248
+ source: fallback.source
249
+ });
250
+ return Promise.resolve(void 0);
251
+ }
252
+ describe(ref) {
253
+ if (this.inherited(ref) !== void 0) return Promise.resolve({
254
+ configured: true,
255
+ source: "env",
256
+ writable: false
257
+ });
258
+ if (this.values.get(ref) !== void 0) return Promise.resolve({
259
+ configured: true,
260
+ source: "file",
261
+ writable: true
262
+ });
263
+ const fallback = this.dotenvFallback(ref);
264
+ if (fallback !== void 0) return Promise.resolve({
265
+ configured: true,
266
+ source: fallback.source,
267
+ writable: true
268
+ });
269
+ return Promise.resolve({
270
+ configured: false,
271
+ writable: true
272
+ });
273
+ }
274
+ async set(ref, value) {
275
+ if (value.length === 0) throw new Error(`credentials-local: an empty value cannot be stored for "${ref}"; use unset`);
276
+ await this.write(ref, value);
277
+ }
278
+ async unset(ref) {
279
+ await this.write(ref, void 0);
280
+ }
281
+ /** Queue one exclusive document operation behind every earlier one. */
282
+ enqueue(operation) {
283
+ const task = this.operations.then(operation);
284
+ this.operations = task.then(() => void 0, () => void 0);
285
+ return task;
286
+ }
287
+ /** Queue a reload; only an invariant violation escaping the fan-out can reject it. */
288
+ queueRefresh() {
289
+ this.enqueue(() => this.refresh()).catch((error) => {
290
+ this.ctx.logger.error("credentials-local: reload commit failed at %s", this.spec.filename);
291
+ this.ctx.logger.error(error);
292
+ });
293
+ }
294
+ /** Queue one line edit; entry checks reject early, the queue re-judges them at run time. */
295
+ async write(ref, value) {
296
+ const verb = value === void 0 ? "unset" : "set";
297
+ if (this.isClosed()) throw new Error(`credentials-local is disposed: cannot ${verb} "${ref}"`);
298
+ this.assertUnshadowed(ref, verb);
299
+ return this.enqueue(async () => {
300
+ if (this.isClosed()) throw new Error(`credentials-local was disposed before the queued "${ref}" ${verb} ran`);
301
+ this.assertUnshadowed(ref, verb);
302
+ await mkdir(dirname(this.spec.filename), {
303
+ recursive: true,
304
+ mode: 448
305
+ });
306
+ await withFileLock(this.spec.filename, async () => {
307
+ await this.reconcileFromDisk();
308
+ const existing = this.values.get(ref);
309
+ if (value === void 0 && existing === void 0) return;
310
+ const nextText = renderDocument(this.text, ref, value);
311
+ await writeFileAtomic(this.spec.filename, nextText, {
312
+ mode: 384,
313
+ dirMode: 448
314
+ });
315
+ this.text = nextText;
316
+ if (value === void 0) this.values.delete(ref);
317
+ else this.values.set(ref, value);
318
+ this.notifyUpdated(ref);
319
+ });
320
+ });
321
+ }
322
+ /**
323
+ * Reject a write the inherited environment would shadow into apparent
324
+ * no-effect. Only that layer can shadow a write: everything else this
325
+ * provider resolves ranks below the document being written.
326
+ */
327
+ assertUnshadowed(ref, verb) {
328
+ if (this.inherited(ref) !== void 0) throw new Error(`credentials-local: "${ref}" is supplied read-only by the launching environment, so ${verb} would be shadowed; unset it in the shell you start dsh from instead`);
329
+ }
330
+ /**
331
+ * Boot read: an absent file is an empty store; an invalid one fails the
332
+ * plugin's activation, because a credentials document that exists but
333
+ * cannot be trusted must never be treated as "no credentials stored".
334
+ */
335
+ async loadInitial() {
336
+ await assertOwnerOnly(this.spec.filename);
337
+ let text;
338
+ try {
339
+ text = await readFile(this.spec.filename, "utf8");
340
+ } catch (error) {
341
+ if (!isENOENT(error)) throw error;
342
+ return;
343
+ }
344
+ this.values = parseCredentialsDocument(text, this.spec.filename);
345
+ this.text = text;
346
+ }
347
+ /**
348
+ * Re-read the document after a watcher event. Unchanged content (including
349
+ * this provider's own writes) is a no-op; an unreadable document keeps the
350
+ * last good snapshot and warns — a live hot-reload must never take the
351
+ * process down. An invariant violation escaping the fan-out is not a reload
352
+ * failure and propagates to the queue's error surface.
353
+ */
354
+ async refresh() {
355
+ if (this.closed) return;
356
+ try {
357
+ await this.reconcileFromDisk();
358
+ } catch (error) {
359
+ if (error?.code === "INVARIANT") throw error;
360
+ this.ctx.logger.warn("credentials-local: reload failed at %s; keeping the last good document", this.spec.filename);
361
+ this.ctx.logger.warn(error);
362
+ }
363
+ }
364
+ /**
365
+ * Compare the on-disk text against the cache and publish any difference
366
+ * into the seam. Absence publishes the empty store; an unreadable or
367
+ * invalid document throws, so each caller picks its policy — a reload warns
368
+ * and keeps the last good snapshot, a write fails loud rather than
369
+ * overwriting a document it could not understand.
370
+ */
371
+ async reconcileFromDisk() {
372
+ await assertOwnerOnly(this.spec.filename);
373
+ let text;
374
+ try {
375
+ text = await readFile(this.spec.filename, "utf8");
376
+ } catch (error) {
377
+ if (!isENOENT(error)) throw error;
378
+ text = void 0;
379
+ }
380
+ 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);
383
+ this.text = text;
384
+ this.values = next;
385
+ for (const ref of changed) this.notifyUpdated(ref);
386
+ }
387
+ /** Entries whose stored value changed; the parser has already proven every key addressable. */
388
+ changedRefs(prev, next) {
389
+ const changed = [];
390
+ for (const key of new Set([...prev.keys(), ...next.keys()])) {
391
+ if (prev.get(key) === next.get(key)) continue;
392
+ changed.push(credentialRef(key));
393
+ }
394
+ return changed;
395
+ }
396
+ };
397
+ //#endregion
398
+ export { CREDENTIALS_FILENAME, LocalCredentialProvider, LocalCredentialProvider as default, parseCredentialsDocument, resolveSpec };
@@ -0,0 +1,24 @@
1
+ //#region lib/types/invariant.js
2
+ /**
3
+ * Package-owned invariant companion for `@stackstackstack/dsh-credentials-local`.
4
+ * @module @stackstackstack/dsh-credentials-local/invariant
5
+ */
6
+ const PACKAGE_NAME = "@stackstackstack/dsh-credentials-local";
7
+ /** Cordis companion plugin name. */
8
+ const name = "credentials-local-invariant";
9
+ /** Service required before the companion can reserve package ownership. */
10
+ const inject = ["invariants"];
11
+ /**
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
14
+ * asynchronous I/O pinned by its unit suite.
15
+ */
16
+ const install = () => {};
17
+ /**
18
+ * Register this package's invariant companion.
19
+ * @param ctx - Cordis context carrying the invariant service.
20
+ * @returns the installed registration's disposer after setup succeeds.
21
+ */
22
+ const apply = (ctx) => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install));
23
+ //#endregion
24
+ export { apply, inject, name };
@@ -0,0 +1,155 @@
1
+ /**
2
+ * File-backed credentials provider over `$DSH_HOME/.credentials.yaml`, layered
3
+ * against the environment by how much each layer is trusted:
4
+ *
5
+ * ```text
6
+ * inherited process environment (read-only, wins)
7
+ * > $DSH_HOME/.credentials.yaml (provider-managed, writable)
8
+ * > <invocation cwd>/.env (read-only fallback)
9
+ * > $DSH_HOME/.env (read-only fallback)
10
+ * ```
11
+ *
12
+ * The inherited environment wins because `DEEPSEEK_API_KEY=… dsh`, a CI
13
+ * secret, or a container `-e` is this run's explicit intent; it cannot be
14
+ * edited from inside, so it must be *visibly* read-only rather than silently
15
+ * shadow writes. Everything below it loses to the managed store, so a key the
16
+ * Models page writes takes effect immediately even when an older key sits in
17
+ * the user's `.env`.
18
+ *
19
+ * The invoking project may supply a key, because the product trusts the
20
+ * project it is launched in. It ranks below the managed store, so a key stored
21
+ * through the Models page is never displaced by one a checkout happens to carry.
22
+ *
23
+ * The file is the provider-managed writable source: every write re-reads the
24
+ * document under a cross-process writer lock before patching only its own key
25
+ * — comments and the formatting of every untouched entry survive — external
26
+ * edits hot-publish through the seam, and each reload replaces the snapshot
27
+ * wholesale so a deleted entry never lingers in memory.
28
+ *
29
+ * The document holds nothing but credentials, which is why it is a strict
30
+ * `CredentialRef`-to-string mapping rather than a dotenv file: a store the
31
+ * Harness owns and never materializes into the environment cannot also serve
32
+ * as the user's environment layer; a store that doubled as the environment
33
+ * layer would shadow non-secret entries behind its precedence, making them
34
+ * silently unreachable.
35
+ * @module @stackstackstack/dsh-credentials-local
36
+ */
37
+ import { Context, Service } from '@deepseek-ai/cordis';
38
+ import z from '@deepseek-ai/schemastery';
39
+ import { CredentialProvider } from '@stackstackstack/dsh-credentials';
40
+ import type { CredentialInfo, CredentialRef, ResolvedCredential } from '@stackstackstack/dsh-credentials';
41
+ /** Basename of the credentials document inside the harness home. */
42
+ export declare const CREDENTIALS_FILENAME = ".credentials.yaml";
43
+ /** Plugin config: file location and hot-reload behavior. */
44
+ export interface Config {
45
+ /** Credentials document path; defaults to `.credentials.yaml` under the harness home. */
46
+ path?: string;
47
+ /** Harness home used when `path` is omitted; defaults to `$DSH_HOME` or `~/.dsh`. */
48
+ dshHome?: string;
49
+ /** Watch the document and hot-publish external edits; defaults to true. */
50
+ watch?: boolean;
51
+ /** Watcher write-settle window in milliseconds; defaults to 100. */
52
+ debounceMs?: number;
53
+ }
54
+ /** Fully resolved provider parameters; defaulting happens here, never inline. */
55
+ interface ResolvedSpec {
56
+ filename: string;
57
+ watch: boolean;
58
+ debounceMs: number;
59
+ }
60
+ /**
61
+ * Resolve the runtime spec from plugin config: an explicit `path` wins,
62
+ * otherwise the document lives at `<harness home>/.credentials.yaml`.
63
+ * @param config - raw plugin config.
64
+ * @returns the resolved file location and watch behavior.
65
+ */
66
+ export declare function resolveSpec(config: Config): ResolvedSpec;
67
+ /**
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.
75
+ * @param text - the document's text.
76
+ * @param filename - absolute path, quoted in errors.
77
+ * @returns the parsed entries, keyed by reference.
78
+ */
79
+ export declare function parseCredentialsDocument(text: string, filename: string): Map<string, string>;
80
+ /** File-backed credentials provider (`$DSH_HOME/.credentials.yaml`). */
81
+ export declare class LocalCredentialProvider extends CredentialProvider {
82
+ config: Config;
83
+ static Config: z<Config>;
84
+ private readonly spec;
85
+ /**
86
+ * Raw text of the last read or persisted document; `undefined` while the
87
+ * file is absent. Watcher events whose content equals this cache are no-ops,
88
+ * which is also the self-write suppression.
89
+ */
90
+ private text;
91
+ /** Parsed document snapshot; replaced wholesale on every reload. */
92
+ private values;
93
+ /**
94
+ * Single exclusive operation chain: watcher reloads and line edits run one
95
+ * at a time in queue order (settled tail), so an edit can never render from
96
+ * text a concurrent reload is busy replacing.
97
+ */
98
+ private operations;
99
+ /** Set at dispose: refuse new writes and let in-flight work no-op. */
100
+ private closed;
101
+ /** Opaque read of {@link closed}: control flow cannot narrow it across awaits. */
102
+ private isClosed;
103
+ constructor(ctx: Context, config: Config);
104
+ /** The inherited-environment value for a reference, or `undefined` when empty or unset. */
105
+ private inherited;
106
+ /**
107
+ * The `.env` fallback for a reference — below the managed store, never above
108
+ * it. The invoking project ranks over the user's home file, matching the
109
+ * environment layering: the more specific location wins.
110
+ */
111
+ private dotenvFallback;
112
+ [Service.init](): AsyncGenerator<() => Promise<void> | void, void, void>;
113
+ resolve(ref: CredentialRef): Promise<ResolvedCredential | undefined>;
114
+ describe(ref: CredentialRef): Promise<CredentialInfo>;
115
+ set(ref: CredentialRef, value: string): Promise<void>;
116
+ unset(ref: CredentialRef): Promise<void>;
117
+ /** Queue one exclusive document operation behind every earlier one. */
118
+ private enqueue;
119
+ /** Queue a reload; only an invariant violation escaping the fan-out can reject it. */
120
+ private queueRefresh;
121
+ /** Queue one line edit; entry checks reject early, the queue re-judges them at run time. */
122
+ private write;
123
+ /**
124
+ * Reject a write the inherited environment would shadow into apparent
125
+ * no-effect. Only that layer can shadow a write: everything else this
126
+ * provider resolves ranks below the document being written.
127
+ */
128
+ private assertUnshadowed;
129
+ /**
130
+ * Boot read: an absent file is an empty store; an invalid one fails the
131
+ * plugin's activation, because a credentials document that exists but
132
+ * cannot be trusted must never be treated as "no credentials stored".
133
+ */
134
+ private loadInitial;
135
+ /**
136
+ * Re-read the document after a watcher event. Unchanged content (including
137
+ * this provider's own writes) is a no-op; an unreadable document keeps the
138
+ * last good snapshot and warns — a live hot-reload must never take the
139
+ * process down. An invariant violation escaping the fan-out is not a reload
140
+ * failure and propagates to the queue's error surface.
141
+ */
142
+ private refresh;
143
+ /**
144
+ * Compare the on-disk text against the cache and publish any difference
145
+ * into the seam. Absence publishes the empty store; an unreadable or
146
+ * invalid document throws, so each caller picks its policy — a reload warns
147
+ * and keeps the last good snapshot, a write fails loud rather than
148
+ * overwriting a document it could not understand.
149
+ */
150
+ private reconcileFromDisk;
151
+ /** Entries whose stored value changed; the parser has already proven every key addressable. */
152
+ private changedRefs;
153
+ }
154
+ export default LocalCredentialProvider;
155
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Package-owned invariant companion for `@stackstackstack/dsh-credentials-local`.
3
+ * @module @stackstackstack/dsh-credentials-local/invariant
4
+ */
5
+ import type { Context } from '@deepseek-ai/cordis';
6
+ /** Cordis companion plugin name. */
7
+ export declare const name = "credentials-local-invariant";
8
+ /** Service required before the companion can reserve package ownership. */
9
+ export declare const inject: string[];
10
+ /**
11
+ * Register this package's invariant companion.
12
+ * @param ctx - Cordis context carrying the invariant service.
13
+ * @returns the installed registration's disposer after setup succeeds.
14
+ */
15
+ export declare const apply: (ctx: Context) => Promise<() => void>;
16
+ //# sourceMappingURL=invariant.d.ts.map
package/package.json ADDED
@@ -0,0 +1,55 @@
1
+ {
2
+ "name": "@stackstackstack/dsh-credentials-local",
3
+ "description": "File-backed credentials provider ($DSH_HOME/.env under the live process environment) for the DeepSeek Harness",
4
+ "version": "0.1.5",
5
+ "publishConfig": {
6
+ "access": "public"
7
+ },
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
11
+ "directory": "packages/credentials/credentials-local"
12
+ },
13
+ "type": "module",
14
+ "main": "lib/index.js",
15
+ "types": "lib/types/index.d.ts",
16
+ "exports": {
17
+ ".": {
18
+ "types": "./lib/types/index.d.ts",
19
+ "default": "./lib/index.js"
20
+ },
21
+ "./invariant": {
22
+ "types": "./lib/types/invariant.d.ts",
23
+ "default": "./lib/invariant.js"
24
+ },
25
+ "./src/*": "./src/*",
26
+ "./package.json": "./package.json"
27
+ },
28
+ "files": [
29
+ "lib/index.js",
30
+ "lib/invariant.js",
31
+ "lib/types/**/*.d.ts"
32
+ ],
33
+ "license": "MIT",
34
+ "peerDependencies": {
35
+ "@stackstackstack/dsh-atomic-write": "^0.1.5",
36
+ "@stackstackstack/dsh-credentials": "^0.1.5",
37
+ "@stackstackstack/dsh-invariants": "^0.1.5",
38
+ "@stackstackstack/dsh-home-paths": "^0.1.5",
39
+ "@deepseek-ai/cordis": "^4.0.1",
40
+ "@stackstackstack/dsh-launch-environment": "^0.1.5"
41
+ },
42
+ "dependencies": {
43
+ "chokidar": "^4.0.3",
44
+ "yaml": "^2.9.0",
45
+ "@deepseek-ai/schemastery": "^3.18.1"
46
+ },
47
+ "devDependencies": {
48
+ "@stackstackstack/dsh-atomic-write": "^0.1.5",
49
+ "@stackstackstack/dsh-launch-environment": "^0.1.5",
50
+ "@stackstackstack/dsh-invariants": "^0.1.5",
51
+ "@stackstackstack/dsh-home-paths": "^0.1.5",
52
+ "@stackstackstack/dsh-credentials": "^0.1.5",
53
+ "@deepseek-ai/cordis": "^4.0.1"
54
+ }
55
+ }