fix(config): trust the invoking project, and stop leaking what it must not decide
Review found five real defects in the configuration-source work, all confirmed against the code rather than argued: 1. The note claimed --config outranks settings.yaml. It does not: the settings seam registers a plugin's cordis entry config as the `base` layer and the user section layers over it, and the seam cannot tell a shipped value from a --config one. The note now states shipped reality and names --config-replace as the lever for a deployment that must win. Separately, a literal `apiKey` in settings outranked both the environment and .credentials.yaml — the field is removed, so configuration carries a reference and nothing else. 2. DEEPSEEK_SEARCH_BASE_URL was functionally deleted: the shipped inline went away without the provider learning to read it. It now resolves from the environment snapshot, as the README always claimed. 3. The bootstrap deny list missed the interpreter start-up hooks. BASH_ENV is the sharpest: `bash -c` sources it on every bash tool call, so a project .env could run a file of its choosing before every command. The list now covers BASH_ENV and its per-language siblings, the Git hook commands, and the remaining preload and CA variables, organised by what a variable does rather than which runtime owns it. 4. YAML parse errors quoted the offending source line — which in a credentials document is the secret — into boot stderr and the watcher's logger. Only the error code and position are reported now, in credentials-local and settings-local alike, pinned by a test that asserts the secret is absent. 5. 0600 governed only files the harness wrote. A hand-created 0644 document was read normally. POSIX now checks the mode before reading contents, at boot and on every reload; Windows has no mode to inspect and is skipped rather than faked. The project a session is launched in is trusted by default, with no prompt and no stored trust record: it may supply its own endpoint, ordinary variables, and a key ranked below the managed store. Trust stops at the harness itself — a discovered file still cannot set DSH_PERMISSION_MODE, PATH, BASH_ENV, or the rest, because those take effect with no user action, before any turn, outside the permission policy and the sandbox.
This commit is contained in:
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.md
|
||||
2026-08-04-configuration-source-ownership.md: f19067abb899e41742f88ce6d17623bc5b82d008
|
||||
2026-08-04-configuration-source-ownership.zh.md: a5fd7c61ee71eb9ed9184c3f9c557fb1c3b951ad
|
||||
2026-08-04-configuration-source-ownership.md: 101c0e6ba4954b3fbb418b775322a9fd92c46a8c
|
||||
2026-08-04-configuration-source-ownership.zh.md: ad59f9a96e144dd5078898da57195a8bb6897451
|
||||
+23
-12
@@ -16,24 +16,35 @@ And `!!js process.env.X` in the shipped composition made the same value reachabl
|
||||
|
||||
## Decision
|
||||
|
||||
**One ordering, four kinds of source.** Every user-facing value resolves in the same order; the domains differ only in which tiers exist.
|
||||
**One ordering for non-secret values.** Every configurable value that is not itself a credential resolves in the same order; the domains differ only in which tiers exist.
|
||||
|
||||
```text
|
||||
explicit for this run per-operation override, CLI argument
|
||||
> authored by deployment --config / --config-replace
|
||||
> user settings settings.yaml
|
||||
> composition --config / --config-replace, shipped base
|
||||
> this launch's shell inherited process environment
|
||||
> product-managed store settings.yaml, .credentials.yaml
|
||||
> discovered file $DSH_HOME/.env
|
||||
> defaults schema default, shipped base, provider public default
|
||||
> defaults schema default, provider public default
|
||||
```
|
||||
|
||||
Credentials have no deployment tier (configuration carries a reference, never a value) and no default. Endpoints have every tier. Model selection has CLI, settings, and the shipped default. The earlier proposal ranked a UI-written credential *below* the environment while ranking UI-written settings *above* it; the distinguishing fact is not the domain but who authored the file, so `.credentials.yaml` and `settings.yaml` now sit together, both under the launching shell and both over a discovered `.env`.
|
||||
Settings sit above composition because that is what the [settings seam](2026-07-28-user-settings-seam.md) does: a plugin registers its cordis entry config as the `base` layer and the user's section layers over it, and the seam cannot tell a value the shipped base set from one a `--config` overlay set — both arrive as entry config. A deployment that must pin a field against a user's stored settings therefore uses `--config-replace`, which bypasses the tree the settings base is derived from. Composition still outranks the environment, so a stale `DEEPSEEK_BASE_URL` in a shell cannot rewrite a configured endpoint.
|
||||
|
||||
**The invoking directory's `.env` decides no credential and no route.** `EnvironmentSnapshot.getFrom(name, sources)` searches only the layers a caller names, and omitting one is a refusal rather than a demotion: the adapters ask for `['process', 'user-env']`, so no future reordering can let a project file back into a decision it was excluded from. A project `.env` remains an ordinary environment layer for ordinary variables.
|
||||
**Credentials keep a narrower, separate ordering**, and this note does not unify them:
|
||||
|
||||
**A discovered file may not decide how the process starts.** `isBootstrapOnly` rejects, at load and before anything is materialized, any `.env` that sets a variable governing how a process launches (`PATH`, `SHELL`, `NODE_OPTIONS`, `LD_PRELOAD`, …), where code or model-visible instructions load from (the whole `DSH_*` namespace, `HOME`, `XDG_*`), or how the network is reached and trusted (proxy and CA variables). Matching is case-insensitive, so `https_proxy` is not a bypass.
|
||||
```text
|
||||
inherited process environment (read-only, wins)
|
||||
> $DSH_HOME/.credentials.yaml (provider-managed, writable)
|
||||
> <invocation cwd>/.env
|
||||
> $DSH_HOME/.env
|
||||
```
|
||||
|
||||
The whole `DSH_*` namespace is denied rather than an audited subset. The harness's own switches — the permission mode, the agents home that holds model-visible skills, the bundled skill root — are exactly what a hostile project would reach for, and a switch added later must not become settable by being forgotten. There is no opt-out: an escape hatch would have to be readable from somewhere, and anything a discovered file could set is the hole itself.
|
||||
The launching environment wins because `DEEPSEEK_API_KEY=… dsh`, a CI secret, and a container `-e` are the one override an operator must be able to apply per run without editing machine state, and because it cannot be edited from inside it must be *visibly* read-only. Configuration is meant to carry only the *reference* — which name to resolve — and that name follows the non-secret ordering above.
|
||||
|
||||
**The project the harness is launched in is trusted, by default and without a prompt.** A checkout may carry its own endpoint, its own ordinary variables, and its own key; the key ranks below the managed store, so a key stored through the web page or TUI is never displaced by one a checkout happens to contain. `EnvironmentSnapshot.getFrom(name, sources)` still searches only the layers a caller names, and omitting one is a refusal rather than a demotion — the mechanism exists for the decisions where a layer must be unreachable, not because the project is one of them today.
|
||||
|
||||
**Trust does not extend to changing the harness itself.** `isBootstrapOnly` rejects, at load and before anything is materialized, any `.env` that sets a variable governing how a process launches (`PATH`, `SHELL`, `NODE_OPTIONS`, `LD_PRELOAD`), what code a runtime executes before the program it was asked to run (`BASH_ENV`, `PERL5OPT`, `PYTHONSTARTUP`, `RUBYOPT`, `JAVA_TOOL_OPTIONS`, the Git hook commands), where model-visible instructions load from (the whole `DSH_*` namespace, `HOME`, `XDG_*`), or how the network is reached and trusted (proxy and CA variables). Matching is case-insensitive, so `https_proxy` is not a bypass.
|
||||
|
||||
The line is that these take effect with no user action, before any turn, outside the permission policy and the sandbox. `DSH_PERMISSION_MODE` would switch off the approvals that make trusting a project meaningful at all, and `BASH_ENV` runs a file of the project's choosing on every single `bash -c` the bash tool issues — the project's code running under the agent's policy is the deal; the project rewriting that policy is not. Enumerating these is a losing game one variable at a time, which is why the whole `DSH_*` namespace is denied rather than an audited subset, and why the list is organised by what a variable *does* rather than by which runtime owns it. There is no opt-out: an escape hatch would have to be readable from somewhere, and anything a discovered file could set is the hole itself.
|
||||
|
||||
**`packages/util/environment` owns the snapshot**, deliberately as a utility rather than a three-package capability seam. The snapshot is frozen before Cordis starts and injected once by the launcher, so there is no runtime implementation to swap; consumers need types and pure functions, which a `util/` package gives them without depending on a UI package. `environmentOf(ctx)` returns the launcher's snapshot, or the inherited environment as the only layer — an SDK host or bare `cordis.yml` discovered no files, so its single layer really is what it was launched with, and the same trusted lookups keep working there unchanged.
|
||||
|
||||
@@ -43,16 +54,16 @@ The whole `DSH_*` namespace is denied rather than an audited subset. The harness
|
||||
|
||||
- The web credential form now takes effect against an older key in the user's `.env`; only a key exported in the launching shell still makes it read-only, and the diagnostic says so.
|
||||
- A `.env` holding `DSH_*`, `PATH`, or a proxy variable fails the launch instead of being applied. Developers keeping switches in a repository `.env` move them to their shell — a deliberate, loud break.
|
||||
- `--config` is no longer overridable by a stale shell endpoint, so a deployment can pin an enterprise gateway.
|
||||
- Given up: an endpoint or key in the invoking directory's `.env` no longer applies. Per-project routing is a `--config` overlay or an `export` in that project's shell.
|
||||
- `--config` is no longer overridable by a stale shell endpoint. It is still overridable by a user's stored `settings.yaml`, which is the settings seam's layering and not something this note changes; a deployment that must win against stored settings uses `--config-replace`.
|
||||
- Not solved: the layers are still materialized into `process.env`, so ordinary project variables continue to reach child processes under the subprocess scrub. Bootstrap variables cannot come from a file at all, which closes the escalation path; a project `.env` setting something like `GIT_SSH_COMMAND` for the tools an agent runs remains possible and is recorded as a limitation on the package.
|
||||
- The adapters no longer accept a literal `apiKey`: configuration carries the reference and nothing else, so a settings document cannot become a second credential store. No adapter namespace is strict, so writing one is dropped rather than rejected.
|
||||
- Exa and Perplexity still capture their key at load time rather than through the credential seam. They no longer read raw `process.env` — they resolve through the trusted layers — but converting them to per-request seam resolution is separate work.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Keep the proposal's split ladders (credentials env-over-file, endpoints settings-over-env).** Rejected on its own inconsistency: both arguments — "an export is this run's intent" and "a deployment's file should not be rewritten by a stale shell" — apply to both domains. Sorting by *who authored the source* explains both and produces one table instead of four.
|
||||
**Unify credentials into the non-secret ordering, by who authored each source.** Attempted and abandoned: it reads well, but the settings seam already fixes composition *below* the user section, so "authored by deployment" is not a tier the seam can express — and moving `.credentials.yaml` above the launching environment would take away the one override CI, containers, and a per-run `DEEPSEEK_API_KEY=…` depend on. Two orderings that each say why they are shaped that way beat one that describes neither accurately.
|
||||
|
||||
**Let the invoking directory's `.env` supply a credential, ranked below the managed store.** Rejected: with no key stored, a hostile project's key would be used silently, and the account holder reads every prompt sent under it. That is the same exfiltration the endpoint rule exists to prevent, so it takes the same answer.
|
||||
**Withhold routing and credentials from the invoking project until it is explicitly trusted.** Rejected as the product's stance: a checkout is trusted by default, with no prompt and no stored trust record. The residual is real and worth naming — cloning a repository that carries a `.env` naming another endpoint or key routes that session through it — and a later project-trust gate is where that gets addressed, not a rule that makes the common case require ceremony.
|
||||
|
||||
**Audit an allowlist of `DSH_*` variables a `.env` may set.** Rejected: the list would have to be re-audited on every new switch, and the failure mode of forgetting is silent. Denying the namespace fails safe.
|
||||
|
||||
|
||||
+24
-13
@@ -16,26 +16,37 @@ endpoint 可以被项目重定向。调用目录的 `.env` 和其他层一样会
|
||||
|
||||
## Decision
|
||||
|
||||
**一条顺序,四类来源。** 每个面向用户的值按同一顺序解析;各领域的差别只在于哪些层存在。
|
||||
**非密钥值走同一条顺序。** 每个本身不是凭据的可配置值都按同一顺序解析;各领域的差别只在于哪些层存在。
|
||||
|
||||
```text
|
||||
explicit for this run per-operation override, CLI argument
|
||||
> authored by deployment --config / --config-replace
|
||||
> user settings settings.yaml
|
||||
> composition --config / --config-replace, shipped base
|
||||
> this launch's shell inherited process environment
|
||||
> product-managed store settings.yaml, .credentials.yaml
|
||||
> discovered file $DSH_HOME/.env
|
||||
> defaults schema default, shipped base, provider public default
|
||||
> defaults schema default, provider public default
|
||||
```
|
||||
|
||||
自上而下依次是:本次运行的显式意图、部署授权、本次启动的 shell、产品受管存储、被发现的文件、默认值。
|
||||
自上而下依次是:本次运行的显式意图、用户 settings、composition、本次启动的 shell、被发现的文件、默认值。
|
||||
|
||||
凭据没有部署层(配置携带引用,从不携带值),也没有默认值层。endpoint 拥有全部层。模型选择只有 CLI、settings 与已交付默认值。此前的方案把 UI 写入的凭据排在环境*之下*,却把 UI 写入的 settings 排在环境*之上*;真正的区分依据不是领域,而是这个文件由谁书写,因此 `.credentials.yaml` 与 `settings.yaml` 现在并列,同在启动 shell 之下、同在被发现的 `.env` 之上。
|
||||
settings 在 composition 之上,因为 [settings seam](2026-07-28-user-settings-seam.md) 就是这么做的:插件把自己的 cordis entry config 注册为 `base` 层,用户 section 叠加其上,而 seam 无法区分某个值是交付基座设的还是 `--config` overlay 设的——两者都以 entry config 的形式抵达。因此,需要把某字段钉死、不被用户已存 settings 覆盖的部署方,应使用 `--config-replace`,它绕过了 settings base 所派生的那棵树。composition 仍然高于环境,所以 shell 里陈旧的 `DEEPSEEK_BASE_URL` 无法改写已配置的 endpoint。
|
||||
|
||||
**调用目录的 `.env` 不决定任何凭据与路由。** `EnvironmentSnapshot.getFrom(name, sources)` 只搜索调用方点名的层,省略某层是拒绝而不是降级:适配器请求的是 `['process', 'user-env']`,因此后续任何重新排序都无法让项目文件重新进入一个它被排除在外的决策。对普通变量而言,项目 `.env` 仍然是普通环境层。
|
||||
**凭据保留一条更窄的独立顺序**,本 Note 不把它并入上表:
|
||||
|
||||
**被发现的文件不得决定进程如何启动。** `isBootstrapOnly` 会在加载时、且在物化任何内容之前,拒绝任何设置了下列变量的 `.env`:决定进程如何启动的(`PATH`、`SHELL`、`NODE_OPTIONS`、`LD_PRELOAD` 等)、决定代码或模型可见指令从哪里加载的(整个 `DSH_*` 命名空间、`HOME`、`XDG_*`),以及决定网络如何抵达与信任的(proxy 与 CA 变量)。匹配不区分大小写,因此 `https_proxy` 不是绕过手段。
|
||||
```text
|
||||
inherited process environment (read-only, wins)
|
||||
> $DSH_HOME/.credentials.yaml (provider-managed, writable)
|
||||
> <invocation cwd>/.env
|
||||
> $DSH_HOME/.env
|
||||
```
|
||||
|
||||
被拒绝的是整个 `DSH_*` 命名空间,而不是一份经过审查的子集。harness 自己的开关——权限模式、存放模型可见 skill(技能)的 agents home、内置 skill 根目录——恰恰是敌意项目最想伸手的地方,而后来新增的开关不能因为被遗忘就变得可设置。不设逃生门:逃生门本身总得从某处读取,而任何被发现的文件能设置的东西,就是那个漏洞本身。
|
||||
继承环境优先,因为 `DEEPSEEK_API_KEY=… dsh`、CI 机密与容器 `-e` 是运维必须能按次施加、且无需改动机器状态的那一种覆盖;而它无法从进程内部修改,就必须*可见地*只读。配置本应只携带*引用*——解析哪个名字——该名字本身遵循上面的非密钥顺序。
|
||||
|
||||
**harness 被启动于其中的项目默认可信,且不做询问。** 一个 checkout 可以携带自己的 endpoint、自己的普通变量和自己的密钥;密钥排在受管存储之下,因此通过 Web 页面或 TUI 存下的密钥绝不会被 checkout 中恰好带有的那一个顶掉。`EnvironmentSnapshot.getFrom(name, sources)` 仍然只搜索调用方点名的层,省略某层仍是拒绝而不是降级——该机制是为「某一层必须不可达」的那些决策准备的,而项目层今天不在其列。
|
||||
|
||||
**信任不延伸到改变 harness 本身。** `isBootstrapOnly` 会在加载时、且在物化任何内容之前,拒绝任何设置了下列变量的 `.env`:决定进程如何启动的(`PATH`、`SHELL`、`NODE_OPTIONS`、`LD_PRELOAD`)、决定运行时在执行被要求运行的程序之前先执行哪些代码的(`BASH_ENV`、`PERL5OPT`、`PYTHONSTARTUP`、`RUBYOPT`、`JAVA_TOOL_OPTIONS`、Git 的钩子命令)、决定模型可见指令从哪里加载的(整个 `DSH_*` 命名空间、`HOME`、`XDG_*`),以及决定网络如何抵达与信任的(proxy 与 CA 变量)。匹配不区分大小写,因此 `https_proxy` 不是绕过手段。
|
||||
|
||||
这条界线在于:它们无需任何用户动作、在任何一轮开始之前、且在权限策略与沙箱之外就生效。`DSH_PERMISSION_MODE` 会关掉让「信任项目」根本成立的那道审批,而 `BASH_ENV` 会在 bash 工具发出的每一次 `bash -c` 上执行项目指定的文件——项目的代码在 agent 的策略下运行是约定,项目改写那份策略不是。一个变量一个变量地枚举是必输的游戏,所以整个 `DSH_*` 命名空间被拒绝而不是只拒绝一份经审查的子集,也所以这份清单是按变量*做什么*而不是按哪个运行时拥有它来组织的。不设逃生门:逃生门本身总得从某处读取,而任何被发现的文件能设置的东西,就是那个漏洞本身。
|
||||
|
||||
**`packages/util/environment` 拥有该快照**,刻意做成 utility 而不是三包能力 seam。快照在 Cordis 启动前就冻结,并由启动器一次性注入,因此不存在需要切换的运行时实现;消费方需要的只是类型和纯函数,而 `util/` 包能提供这些且不必依赖 UI 包。`environmentOf(ctx)` 返回启动器的快照,或者返回只含继承环境的那一层——SDK 宿主或裸 `cordis.yml` 从未发现过任何文件,它那唯一一层确实就是它被启动时的环境,因此同样的受信查询在那里原样继续工作。
|
||||
|
||||
@@ -45,16 +56,16 @@ explicit for this run per-operation override, CLI argument
|
||||
|
||||
- Web 凭据表单现在能压过用户 `.env` 里更旧的密钥;只有在启动 shell 里 export 的密钥才会让它变成只读,诊断信息也会这么说。
|
||||
- 含 `DSH_*`、`PATH` 或 proxy 变量的 `.env` 会导致启动失败而不是被应用。把开关放在仓库 `.env` 里的开发者需要改放到 shell——这是一次刻意且响亮的破坏。
|
||||
- `--config` 不再会被陈旧的 shell endpoint 覆盖,因此部署方可以钉住企业网关。
|
||||
- 放弃的:调用目录 `.env` 里的 endpoint 或密钥不再生效。按项目切换路由请用 `--config` overlay 或该项目 shell 里的 `export`。
|
||||
- `--config` 不再会被陈旧的 shell endpoint 覆盖。但它仍然会被用户已存的 `settings.yaml` 覆盖,这是 settings seam 的分层方式,本 Note 不改变它;需要压过已存 settings 的部署方应使用 `--config-replace`。
|
||||
- 未解决的:各层仍然会被物化进 `process.env`,因此普通项目变量继续按子进程清洗规则抵达子进程。bootstrap 变量完全不能来自文件,提权路径已封闭;项目 `.env` 为 agent 运行的工具设置诸如 `GIT_SSH_COMMAND` 之类的变量仍然可能,已作为限制记录在该包上。
|
||||
- 适配器不再接受字面 `apiKey`:配置只携带引用,因此 settings 文档无法成为第二个凭据存储。由于没有任何适配器 namespace 是 strict 的,写入该键会被 schema 丢弃而不是报错。
|
||||
- Exa 与 Perplexity 仍在加载时捕获密钥,而不是经凭据 seam。它们不再读裸 `process.env`——改为经受信层解析——但把它们改造成按请求经 seam 解析是另一件事。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**沿用方案里分开的两条 ladder(凭据环境压过文件、endpoint settings 压过环境)。** 因其自身的不自洽而否决:两条理由——「export 是本次运行的意图」和「部署方的文件不该被陈旧 shell 改写」——对两个领域同样成立。按*来源由谁书写*排序能同时解释两者,并且把四张表变成一张。
|
||||
**按「来源由谁书写」把凭据并入非密钥顺序。** 尝试过并放弃:它读起来很顺,但 settings seam 已经把 composition 固定在用户 section *之下*,因此「部署授权」根本不是该 seam 能表达的一层;而把 `.credentials.yaml` 抬到启动环境之上,会夺走 CI、容器和一次性 `DEEPSEEK_API_KEY=…` 所依赖的那唯一一种覆盖。两条各自说清自身形状成因的顺序,好过一条两边都描述不准的顺序。
|
||||
|
||||
**允许调用目录 `.env` 提供凭据,排在受管存储之下。** 否决:在没有存储密钥时,敌意项目的密钥会被静默使用,而该账号持有者能读到以它发出的每一条提示词。这与 endpoint 规则要防的外泄是同一件事,因此答案也相同。
|
||||
**在项目被显式信任之前,不给它路由与凭据能力。** 作为产品立场被否决:checkout 默认可信,不询问,也不存储信任记录。残留风险是真实的、值得写明——克隆一个携带 `.env`、其中指定了另一个 endpoint 或密钥的仓库,会让该会话经由它——处理它的地方是日后的 project trust 门禁,而不是一条让常见情形都要走仪式的规则。
|
||||
|
||||
**审查出一份 `.env` 可设置的 `DSH_*` 白名单。** 否决:每新增一个开关都要重新审查,而遗漏的失败模式是静默的。拒绝整个命名空间是 fail safe。
|
||||
|
||||
|
||||
@@ -423,7 +423,7 @@ export interface Config {
|
||||
}
|
||||
```
|
||||
|
||||
Source: [`packages/credentials/credentials-local/src/index.ts:54`](../packages/credentials/credentials-local/src/index.ts)
|
||||
Source: [`packages/credentials/credentials-local/src/index.ts:55`](../packages/credentials/credentials-local/src/index.ts)
|
||||
|
||||
## `@deepseek-ai/dsh-fs-local`
|
||||
|
||||
@@ -628,8 +628,6 @@ Requires: `llm`
|
||||
* reasoning effort resolves to `high`.
|
||||
*/
|
||||
export interface Config {
|
||||
/** Literal API key; prefer {@link apiKeyEnv} so no secret enters configuration files. */
|
||||
apiKey?: string
|
||||
/** Credential reference (environment-variable name) resolved per request; defaults to `DEEPSEEK_API_KEY`. */
|
||||
apiKeyEnv?: string
|
||||
/** Endpoint base; falls back to $DEEPSEEK_BASE_URL from a trusted environment layer, then the public API. */
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
patches:
|
||||
- id: llm-deepseek
|
||||
config:
|
||||
apiKey: snapshot-key
|
||||
baseURL: !!js process.env.DSH_SNAPSHOT_BASE_URL
|
||||
thinking: disabled
|
||||
- id: cli-agent
|
||||
|
||||
@@ -244,13 +244,12 @@ describe('headless stream-json snapshots', () => {
|
||||
prepare: (cwd) => { runCwd = cwd },
|
||||
})
|
||||
|
||||
// The guidance leads with the credential store — the path that keeps the
|
||||
// secret out of configuration files — and offers a literal key last.
|
||||
// The guidance names both places a credential can come from, and nothing
|
||||
// else: configuration carries the reference, never a literal key.
|
||||
expect(result.stderr).toBe(
|
||||
'dsh-cli-demo: turn 1 failed at step 1: llm-deepseek: no API key for provider route "deepseek-official";'
|
||||
+ ' store DEEPSEEK_API_KEY through the credentials service (the web Models page writes it),'
|
||||
+ ' export DEEPSEEK_API_KEY in the launching environment, or — as a last resort — set a literal'
|
||||
+ ' "apiKey" in the llm-deepseek settings section\n',
|
||||
+ ' or export DEEPSEEK_API_KEY in the launching environment\n',
|
||||
)
|
||||
const normalized = normalizeHeadlessStream(result.stdout, runCwd)
|
||||
if (refreshing) await writeFile(streamExpected, normalized)
|
||||
@@ -314,6 +313,9 @@ describe('headless stream-json snapshots', () => {
|
||||
],
|
||||
tsconfigPath,
|
||||
env: {
|
||||
// Configuration carries only the reference; the key rides the
|
||||
// launching environment, which is the whole credential plane here.
|
||||
DEEPSEEK_API_KEY: 'snapshot-key',
|
||||
DSH_SNAPSHOT_BASE_URL: server.url,
|
||||
NODE_OPTIONS: [process.env.NODE_OPTIONS, '--disable-warning=ExperimentalWarning'].filter(Boolean).join(' '),
|
||||
},
|
||||
|
||||
+2
-2
@@ -5,5 +5,5 @@
|
||||
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/header","seq":4,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash","maxTokens":256000,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}}
|
||||
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/context","seq":5,"time":0,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash","contextWindow":1000000}}}
|
||||
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":6,"time":0,"data":{"turn":1,"step":1}}}
|
||||
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":7,"time":0,"data":{"turn":1,"reason":{"kind":"error","step":1,"failure":{"message":"llm-deepseek: no API key for provider route \"deepseek-official\"; store DEEPSEEK_API_KEY through the credentials service (the web Models page writes it), export DEEPSEEK_API_KEY in the launching environment, or — as a last resort — set a literal \"apiKey\" in the llm-deepseek settings section","code":"MISSING_CREDENTIAL"}}}}}
|
||||
{"type":"result","success":false,"sessionId":"{{sessionId}}","turn":1,"result":"","reason":{"kind":"error","step":1,"failure":{"message":"llm-deepseek: no API key for provider route \"deepseek-official\"; store DEEPSEEK_API_KEY through the credentials service (the web Models page writes it), export DEEPSEEK_API_KEY in the launching environment, or — as a last resort — set a literal \"apiKey\" in the llm-deepseek settings section","code":"MISSING_CREDENTIAL"}}}
|
||||
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":7,"time":0,"data":{"turn":1,"reason":{"kind":"error","step":1,"failure":{"message":"llm-deepseek: no API key for provider route \"deepseek-official\"; store DEEPSEEK_API_KEY through the credentials service (the web Models page writes it), or export DEEPSEEK_API_KEY in the launching environment","code":"MISSING_CREDENTIAL"}}}}}
|
||||
{"type":"result","success":false,"sessionId":"{{sessionId}}","turn":1,"result":"","reason":{"kind":"error","step":1,"failure":{"message":"llm-deepseek: no API key for provider route \"deepseek-official\"; store DEEPSEEK_API_KEY through the credentials service (the web Models page writes it), or export DEEPSEEK_API_KEY in the launching environment","code":"MISSING_CREDENTIAL"}}}
|
||||
@@ -3,9 +3,10 @@
|
||||
* against the environment by how much each layer is trusted:
|
||||
*
|
||||
* ```text
|
||||
* inherited process environment (read-only, wins)
|
||||
* > $DSH_HOME/.credentials.yaml (provider-managed, writable)
|
||||
* > $DSH_HOME/.env (read-only fallback)
|
||||
* inherited process environment (read-only, wins)
|
||||
* > $DSH_HOME/.credentials.yaml (provider-managed, writable)
|
||||
* > <invocation cwd>/.env (read-only fallback)
|
||||
* > $DSH_HOME/.env (read-only fallback)
|
||||
* ```
|
||||
*
|
||||
* The inherited environment wins because `DEEPSEEK_API_KEY=… dsh`, a CI
|
||||
@@ -15,10 +16,10 @@
|
||||
* web page or TUI writes takes effect immediately even when an older key sits
|
||||
* in the user's `.env`.
|
||||
*
|
||||
* The invoking directory's `.env` supplies no credential at all. A project
|
||||
* directory can be written by the model, and a substituted key would send
|
||||
* every request — prompts included — through an account someone else reads;
|
||||
* that decision belongs to the launching shell, not to a discovered file.
|
||||
* The invoking project may supply a key, because the product trusts the
|
||||
* project it is launched in. It ranks below the managed store, so a key stored
|
||||
* through the web page or TUI is never displaced by one a checkout happens to
|
||||
* carry.
|
||||
*
|
||||
* The file is the provider-managed writable source: every write re-reads the
|
||||
* document under a cross-process writer lock before patching only its own key
|
||||
@@ -37,7 +38,7 @@
|
||||
import { Context, Service } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import { watch as chokidarWatch } from 'chokidar'
|
||||
import { mkdir, readFile } from 'node:fs/promises'
|
||||
import { mkdir, readFile, stat } from 'node:fs/promises'
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
import { Document, parseDocument } from 'yaml'
|
||||
import { withFileLock, writeFileAtomic } from '@deepseek-ai/dsh-atomic-write'
|
||||
@@ -83,11 +84,56 @@ export function resolveSpec(config: Config): ResolvedSpec {
|
||||
}
|
||||
}
|
||||
|
||||
/** Permission bits outside the owner; a credentials document must have none of them. */
|
||||
const GROUP_OTHER_BITS = 0o077
|
||||
|
||||
/**
|
||||
* Reject a credentials document other OS users can read, before its contents
|
||||
* are read at all. The provider creates and replaces the file at `0600`, but a
|
||||
* hand-written or externally generated one carries whatever umask produced it,
|
||||
* and silently serving secrets out of a world-readable file would make the
|
||||
* mode the provider promises meaningless.
|
||||
*
|
||||
* POSIX only: Windows has no mode to inspect — its ACLs are not expressible
|
||||
* here — so the check is skipped rather than faked, and the file's protection
|
||||
* there is whatever the create and replace APIs express.
|
||||
* @param filename - absolute path of the document.
|
||||
* @throws when the file exists with group or other permission bits set.
|
||||
*/
|
||||
async function assertOwnerOnly(filename: string): Promise<void> {
|
||||
if (process.platform === 'win32') return
|
||||
let mode: number
|
||||
try {
|
||||
mode = (await stat(filename)).mode
|
||||
} catch (error) {
|
||||
if (!isENOENT(error)) throw error
|
||||
return
|
||||
}
|
||||
const offending = mode & GROUP_OTHER_BITS
|
||||
if (offending === 0) return
|
||||
throw new Error(
|
||||
`credentials-local: ${filename} is readable beyond its owner (mode ${(mode & 0o777).toString(8)});`
|
||||
+ ` run "chmod 600 ${filename}" before starting again`,
|
||||
)
|
||||
}
|
||||
|
||||
/** Whether a filesystem error means absence; every non-ENOENT failure must surface. */
|
||||
function isENOENT(error: unknown): boolean {
|
||||
return (error as NodeJS.ErrnoException | null)?.code === 'ENOENT'
|
||||
}
|
||||
|
||||
/**
|
||||
* Describe one YAML parse failure without quoting the source. The parser's own
|
||||
* message embeds the offending line, which here holds a secret.
|
||||
* @param error - the parser's error.
|
||||
* @returns the error code with its line and column.
|
||||
*/
|
||||
function describeYamlError(error: { code?: string; linePos?: [{ line: number; col: number }, ...unknown[]] }): string {
|
||||
const at = error.linePos?.[0]
|
||||
const where = at === undefined ? '' : ` at line ${String(at.line)}, column ${String(at.col)}`
|
||||
return `${error.code ?? 'YAML_ERROR'}${where}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse one credentials document into its entries. The document is a strict
|
||||
* mapping of {@link CredentialRef} to non-empty string: a non-mapping root, a
|
||||
@@ -101,10 +147,15 @@ function isENOENT(error: unknown): boolean {
|
||||
* @returns the parsed entries, keyed by reference.
|
||||
*/
|
||||
export function parseCredentialsDocument(text: string, filename: string): Map<string, string> {
|
||||
// `prettyErrors` is on only for `linePos`; `error.message` is never used,
|
||||
// because the parser quotes the offending source line and in this document
|
||||
// that line is a secret. Only the code and position leave this function, and
|
||||
// the same rule governs every other diagnostic here — a key name is safe to
|
||||
// print, a value is not.
|
||||
const document = parseDocument(text, { prettyErrors: true, uniqueKeys: true })
|
||||
if (document.errors.length > 0) {
|
||||
throw new Error(`credentials-local: invalid document at ${filename}: ${
|
||||
document.errors.map(error => error.message).join('; ')}`)
|
||||
document.errors.map(describeYamlError).join('; ')}`)
|
||||
}
|
||||
const root: unknown = document.toJS() ?? {}
|
||||
if (typeof root !== 'object' || root === null || Array.isArray(root)) {
|
||||
@@ -116,6 +167,8 @@ export function parseCredentialsDocument(text: string, filename: string): Map<st
|
||||
// is exactly the constraint a stored reference must satisfy to be
|
||||
// addressable through the seam.
|
||||
credentialRef(key)
|
||||
// The key name is quoted, never the value: a wrong-typed entry is still a
|
||||
// secret the user meant to store.
|
||||
if (typeof value !== 'string') {
|
||||
throw new TypeError(`credentials-local: the value for "${key}" in ${filename} must be a string`)
|
||||
}
|
||||
@@ -194,9 +247,13 @@ export class CredentialsLocal extends Credentials {
|
||||
return entry !== undefined && entry.value.length > 0 ? entry.value : undefined
|
||||
}
|
||||
|
||||
/** The user `.env` fallback for a reference — below the managed store, never above it. */
|
||||
private userEnvFallback(ref: CredentialRef): EnvironmentEntry | undefined {
|
||||
const entry = environmentOf(this.ctx).getFrom(ref, ['user-env'])
|
||||
/**
|
||||
* The `.env` fallback for a reference — below the managed store, never above
|
||||
* it. The invoking project ranks over the user's home file, matching the
|
||||
* environment layering: the more specific location wins.
|
||||
*/
|
||||
private dotenvFallback(ref: CredentialRef): EnvironmentEntry | undefined {
|
||||
const entry = environmentOf(this.ctx).getFrom(ref, ['project-env', 'user-env'])
|
||||
return entry !== undefined && entry.value.length > 0 ? entry : undefined
|
||||
}
|
||||
|
||||
@@ -249,8 +306,8 @@ export class CredentialsLocal extends Credentials {
|
||||
if (inherited !== undefined) return Promise.resolve({ value: inherited, source: 'env' })
|
||||
const stored = this.values.get(ref)
|
||||
if (stored !== undefined) return Promise.resolve({ value: stored, source: 'file' })
|
||||
const fallback = this.userEnvFallback(ref)
|
||||
if (fallback !== undefined) return Promise.resolve({ value: fallback.value, source: 'user-env' })
|
||||
const fallback = this.dotenvFallback(ref)
|
||||
if (fallback !== undefined) return Promise.resolve({ value: fallback.value, source: fallback.source })
|
||||
return Promise.resolve(undefined)
|
||||
}
|
||||
|
||||
@@ -263,9 +320,8 @@ export class CredentialsLocal extends Credentials {
|
||||
}
|
||||
const stored = this.values.get(ref)
|
||||
if (stored !== undefined) return Promise.resolve({ configured: true, source: 'file', writable: true })
|
||||
if (this.userEnvFallback(ref) !== undefined) {
|
||||
return Promise.resolve({ configured: true, source: 'user-env', writable: true })
|
||||
}
|
||||
const fallback = this.dotenvFallback(ref)
|
||||
if (fallback !== undefined) return Promise.resolve({ configured: true, source: fallback.source, writable: true })
|
||||
return Promise.resolve({ configured: false, writable: true })
|
||||
}
|
||||
|
||||
@@ -361,6 +417,7 @@ export class CredentialsLocal extends Credentials {
|
||||
* cannot be trusted must never be treated as "no credentials stored".
|
||||
*/
|
||||
private async loadInitial(): Promise<void> {
|
||||
await assertOwnerOnly(this.spec.filename)
|
||||
let text: string
|
||||
try {
|
||||
text = await readFile(this.spec.filename, 'utf8')
|
||||
@@ -401,6 +458,9 @@ export class CredentialsLocal extends Credentials {
|
||||
* overwriting a document it could not understand.
|
||||
*/
|
||||
private async reconcileFromDisk(): Promise<void> {
|
||||
// Re-checked on every reload and before every write: an external editor or
|
||||
// a restored backup can loosen the mode after boot.
|
||||
await assertOwnerOnly(this.spec.filename)
|
||||
let text: string | undefined
|
||||
try {
|
||||
text = await readFile(this.spec.filename, 'utf8')
|
||||
|
||||
@@ -8,6 +8,11 @@ import { createEnvironmentSnapshot, DSH_ENVIRONMENT_KEY } from '@deepseek-ai/dsh
|
||||
import type { CredentialRef } from '@deepseek-ai/dsh-credentials'
|
||||
import { CredentialsLocal, resolveSpec } from '../src/index.ts'
|
||||
|
||||
/** Credential documents are seeded owner-only, exactly as the provider creates them. */
|
||||
function writeCredentials(file: string, text: string): Promise<void> {
|
||||
return writeFile(file, text, { mode: 0o600 })
|
||||
}
|
||||
|
||||
const KEY = credentialRef('DSH_CRED_TEST')
|
||||
const OTHER = credentialRef('DSH_CRED_OTHER')
|
||||
|
||||
@@ -65,7 +70,7 @@ describe('layering and reads', () => {
|
||||
it('serves file entries alongside comments and quoted values', async () => {
|
||||
const dir = await tempDir()
|
||||
const path = join(dir, '.credentials.yaml')
|
||||
await writeFile(path, '# notes\nDSH_CRED_TEST: plain\nDSH_CRED_OTHER: "with space"\n')
|
||||
await writeCredentials(path, '# notes\nDSH_CRED_TEST: plain\nDSH_CRED_OTHER: "with space"\n')
|
||||
const ctx = await boot({ path, watch: false })
|
||||
expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'plain', source: 'file' })
|
||||
expect(await ctx.credentials.resolve(OTHER)).toEqual({ value: 'with space', source: 'file' })
|
||||
@@ -75,7 +80,7 @@ describe('layering and reads', () => {
|
||||
it('lets a non-empty process environment win read-only over the file', async () => {
|
||||
const dir = await tempDir()
|
||||
const path = join(dir, '.credentials.yaml')
|
||||
await writeFile(path, 'DSH_CRED_TEST: from-file\n')
|
||||
await writeCredentials(path, 'DSH_CRED_TEST: from-file\n')
|
||||
const ctx = await boot({ path, watch: false })
|
||||
vi.stubEnv('DSH_CRED_TEST', 'from-env')
|
||||
expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'from-env', source: 'env' })
|
||||
@@ -85,7 +90,7 @@ describe('layering and reads', () => {
|
||||
it('treats an empty environment value as absent, falling through to the file', async () => {
|
||||
const dir = await tempDir()
|
||||
const path = join(dir, '.credentials.yaml')
|
||||
await writeFile(path, 'DSH_CRED_TEST: stored\n')
|
||||
await writeCredentials(path, 'DSH_CRED_TEST: stored\n')
|
||||
const ctx = await boot({ path, watch: false })
|
||||
vi.stubEnv('DSH_CRED_TEST', '')
|
||||
expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'stored', source: 'file' })
|
||||
@@ -119,7 +124,7 @@ describe('layer ladder', () => {
|
||||
it('lets the stored value beat the user .env, so a UI write takes effect immediately', async () => {
|
||||
const dir = await tempDir()
|
||||
const path = join(dir, '.credentials.yaml')
|
||||
await writeFile(path, 'DSH_CRED_TEST: stored\n')
|
||||
await writeCredentials(path, 'DSH_CRED_TEST: stored\n')
|
||||
const ctx = await bootLayered(path, [
|
||||
{ source: 'process', values: {} },
|
||||
{ source: 'user-env', path: '/home/.dsh/.env', values: { DSH_CRED_TEST: 'older-user-env' } },
|
||||
@@ -143,22 +148,41 @@ describe('layer ladder', () => {
|
||||
expect(await ctx.credentials.describe(KEY)).toEqual({ configured: true, source: 'user-env', writable: true })
|
||||
})
|
||||
|
||||
it('ignores the invoking directory .env entirely', async () => {
|
||||
it('serves the invoking project .env over the user one, but never over the store', async () => {
|
||||
const dir = await tempDir()
|
||||
const ctx = await bootLayered(join(dir, '.credentials.yaml'), [
|
||||
{ source: 'process', values: {} },
|
||||
{ source: 'project-env', path: '/work/.env', values: { DSH_CRED_TEST: 'from-project' } },
|
||||
])
|
||||
// A project directory can be written by the model, and a substituted key
|
||||
// would route every request through an account someone else reads.
|
||||
expect(await ctx.credentials.resolve(KEY)).toBeUndefined()
|
||||
expect(await ctx.credentials.describe(KEY)).toEqual({ configured: false, writable: true })
|
||||
const path = join(dir, '.credentials.yaml')
|
||||
// The product trusts the project it is launched in, so a checkout may
|
||||
// carry its own key — ranked above the user's home file (more specific
|
||||
// wins) and below the managed store, which a stored key must never lose to.
|
||||
const layers = [
|
||||
{ source: 'process' as const, values: {} },
|
||||
{ source: 'project-env' as const, path: '/work/.env', values: { DSH_CRED_TEST: 'from-project' } },
|
||||
{ source: 'user-env' as const, path: '/home/.dsh/.env', values: { DSH_CRED_TEST: 'from-user' } },
|
||||
]
|
||||
const bare = await bootLayered(path, layers)
|
||||
expect(await bare.credentials.resolve(KEY)).toEqual({ value: 'from-project', source: 'project-env' })
|
||||
expect(await bare.credentials.describe(KEY)).toEqual({ configured: true, source: 'project-env', writable: true })
|
||||
|
||||
await writeCredentials(path, 'DSH_CRED_TEST: stored\n')
|
||||
const stored = await bootLayered(path, layers)
|
||||
expect(await stored.credentials.resolve(KEY)).toEqual({ value: 'stored', source: 'file' })
|
||||
})
|
||||
|
||||
it('refuses a document other OS users can read', async () => {
|
||||
const dir = await tempDir()
|
||||
const path = join(dir, '.credentials.yaml')
|
||||
await writeFile(path, 'DSH_CRED_TEST: leaked\n', { mode: 0o644 })
|
||||
const ctx = new Context()
|
||||
// Before the contents are read at all: serving secrets out of a
|
||||
// world-readable file would make the 0600 the provider writes meaningless.
|
||||
await expect(ctx.plugin(CredentialsLocal, { path, watch: false }))
|
||||
.rejects.toThrow(/readable beyond its owner \(mode 644\)/)
|
||||
})
|
||||
|
||||
it('lets only the inherited environment shadow the store, read-only', async () => {
|
||||
const dir = await tempDir()
|
||||
const path = join(dir, '.credentials.yaml')
|
||||
await writeFile(path, 'DSH_CRED_TEST: stored\n')
|
||||
await writeCredentials(path, 'DSH_CRED_TEST: stored\n')
|
||||
const ctx = await bootLayered(path, [
|
||||
{ source: 'process', values: { DSH_CRED_TEST: 'from-shell' } },
|
||||
{ source: 'user-env', path: '/home/.dsh/.env', values: { DSH_CRED_TEST: 'from-user-env' } },
|
||||
@@ -184,15 +208,36 @@ describe('document validation', () => {
|
||||
])('fails boot on %s', async (_case, text, message) => {
|
||||
const dir = await tempDir()
|
||||
const path = join(dir, '.credentials.yaml')
|
||||
await writeFile(path, text)
|
||||
await writeCredentials(path, text)
|
||||
const ctx = new Context()
|
||||
await expect(ctx.plugin(CredentialsLocal, { path, watch: false })).rejects.toThrow(message)
|
||||
})
|
||||
|
||||
it('never puts a credential value in a diagnostic', async () => {
|
||||
const dir = await tempDir()
|
||||
const path = join(dir, '.credentials.yaml')
|
||||
const secret = 'sk-live-DO-NOT-LOG-abcdef123456'
|
||||
// The yaml parser's own message quotes the offending source line, which in
|
||||
// this document is the secret itself. Boot stderr and the watcher's logger
|
||||
// both receive whatever this throws.
|
||||
await writeCredentials(path, `DSH_CRED_TEST: "${secret}\n`)
|
||||
let failure: unknown
|
||||
try {
|
||||
await new Context().plugin(CredentialsLocal, { path, watch: false })
|
||||
} catch (error) {
|
||||
failure = error
|
||||
}
|
||||
expect(String(failure)).toMatch(/invalid document/)
|
||||
// The position survives; the line's contents do not.
|
||||
expect(String(failure)).toMatch(/line 2, column 1/)
|
||||
expect(String(failure)).not.toContain(secret)
|
||||
expect((failure as Error).stack ?? '').not.toContain(secret)
|
||||
})
|
||||
|
||||
it('reads an empty document as an empty store', async () => {
|
||||
const dir = await tempDir()
|
||||
const path = join(dir, '.credentials.yaml')
|
||||
await writeFile(path, '# nothing stored yet\n')
|
||||
await writeCredentials(path, '# nothing stored yet\n')
|
||||
const ctx = await boot({ path, watch: false })
|
||||
expect(await ctx.credentials.resolve(KEY)).toBeUndefined()
|
||||
})
|
||||
@@ -214,7 +259,7 @@ describe('document writes', () => {
|
||||
it('patches one entry, preserving comments and every untouched entry', async () => {
|
||||
const dir = await tempDir()
|
||||
const path = join(dir, '.credentials.yaml')
|
||||
await writeFile(path, '# deployment notes\nDSH_CRED_OTHER: keep\n\n# the one under edit\nDSH_CRED_TEST: old\n')
|
||||
await writeCredentials(path, '# deployment notes\nDSH_CRED_OTHER: keep\n\n# the one under edit\nDSH_CRED_TEST: old\n')
|
||||
const ctx = await boot({ path, watch: false })
|
||||
await ctx.credentials.set(KEY, 'new value!')
|
||||
expect(await readFile(path, 'utf8')).toBe(
|
||||
@@ -242,7 +287,7 @@ describe('document writes', () => {
|
||||
// Comments above an entry are that entry's annotation and go with it when
|
||||
// it is removed — including anything above the document's first entry.
|
||||
// Every other entry keeps its own comments.
|
||||
await writeFile(path, '# about the doomed one\nDSH_CRED_TEST: gone\n# about the survivor\nDSH_CRED_OTHER: stays\n')
|
||||
await writeCredentials(path, '# about the doomed one\nDSH_CRED_TEST: gone\n# about the survivor\nDSH_CRED_OTHER: stays\n')
|
||||
const ctx = await boot({ path, watch: false })
|
||||
const seen = updates(ctx)
|
||||
await ctx.credentials.unset(KEY)
|
||||
@@ -254,7 +299,7 @@ describe('document writes', () => {
|
||||
it('rejects empty values and writes the environment would shadow', async () => {
|
||||
const dir = await tempDir()
|
||||
const path = join(dir, '.credentials.yaml')
|
||||
await writeFile(path, 'DSH_CRED_TEST: stored\n')
|
||||
await writeCredentials(path, 'DSH_CRED_TEST: stored\n')
|
||||
const ctx = await boot({ path, watch: false })
|
||||
|
||||
await expect(ctx.credentials.set(KEY, '')).rejects.toThrow(/empty value/)
|
||||
@@ -267,7 +312,7 @@ describe('document writes', () => {
|
||||
it('leaves an empty mapping after unsetting the only entry', async () => {
|
||||
const dir = await tempDir()
|
||||
const path = join(dir, '.credentials.yaml')
|
||||
await writeFile(path, 'DSH_CRED_TEST: only\n')
|
||||
await writeCredentials(path, 'DSH_CRED_TEST: only\n')
|
||||
const ctx = await boot({ path, watch: false })
|
||||
await ctx.credentials.unset(KEY)
|
||||
expect(await readFile(path, 'utf8')).toBe('{}\n')
|
||||
@@ -282,7 +327,7 @@ describe('document writes', () => {
|
||||
const ctx = await boot({ path, watch: false })
|
||||
// An external editor left the document unparsable: the read-modify-write
|
||||
// must refuse rather than overwrite content it cannot understand.
|
||||
await writeFile(path, 'DSH_CRED_TEST: "unterminated\n')
|
||||
await writeCredentials(path, 'DSH_CRED_TEST: "unterminated\n')
|
||||
await expect(ctx.credentials.set(OTHER, 'lands')).rejects.toThrow(/invalid document/)
|
||||
})
|
||||
|
||||
@@ -326,17 +371,17 @@ describe('real hot reload', () => {
|
||||
const path = join(dir, '.credentials.yaml')
|
||||
// Watching starts on an existing document: creation racing watcher setup
|
||||
// is a chokidar readiness gap, not the reload contract under test.
|
||||
await writeFile(path, 'DSH_CRED_TEST: boot\n')
|
||||
await writeCredentials(path, 'DSH_CRED_TEST: boot\n')
|
||||
const ctx = await boot({ path, debounceMs: 10 })
|
||||
const seen = updates(ctx)
|
||||
|
||||
await writeFile(path, 'DSH_CRED_TEST: live\nDSH_CRED_OTHER: extra\n')
|
||||
await writeCredentials(path, 'DSH_CRED_TEST: live\nDSH_CRED_OTHER: extra\n')
|
||||
await vi.waitFor(async () => {
|
||||
expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'live', source: 'file' })
|
||||
})
|
||||
|
||||
// Wholesale replacement: an entry deleted on disk never lingers in memory.
|
||||
await writeFile(path, 'DSH_CRED_TEST: live\n')
|
||||
await writeCredentials(path, 'DSH_CRED_TEST: live\n')
|
||||
await vi.waitFor(async () => {
|
||||
expect(await ctx.credentials.resolve(OTHER)).toBeUndefined()
|
||||
})
|
||||
|
||||
@@ -10,6 +10,11 @@ import { join } from 'node:path'
|
||||
import { credentialRef } from '@deepseek-ai/dsh-credentials'
|
||||
import { CredentialsLocal } from '../src/index.ts'
|
||||
|
||||
/** Credential documents are seeded owner-only, exactly as the provider creates them. */
|
||||
function writeCredentials(file: string, text: string): Promise<void> {
|
||||
return writeFile(file, text, { mode: 0o600 })
|
||||
}
|
||||
|
||||
const ALPHA = credentialRef('DSH_REVIEW_ALPHA')
|
||||
const BETA = credentialRef('DSH_REVIEW_BETA')
|
||||
const INNER = credentialRef('DSH_REVIEW_INNER')
|
||||
@@ -44,7 +49,7 @@ describe('read-modify-write', () => {
|
||||
await ctx.credentials.set(ALPHA, 'one')
|
||||
// The external edit has landed on disk but no watcher reported it (watch
|
||||
// is off — the same blind spot as a debounce window or a missed event).
|
||||
await writeFile(path, `${ALPHA}: one\n${BETA}: external\n`)
|
||||
await writeCredentials(path, `${ALPHA}: one\n${BETA}: external\n`)
|
||||
await ctx.credentials.set(ALPHA, 'two')
|
||||
const text = await readFile(path, 'utf8')
|
||||
expect(text).toContain(`${BETA}: external`)
|
||||
@@ -124,7 +129,7 @@ describe('document editor', () => {
|
||||
const dir = await tempDir()
|
||||
const path = join(dir, '.credentials.yaml')
|
||||
const wrapped = `DSH_REVIEW_WRAPPED: |-\n line1\n line2\n${ALPHA}: a\n`
|
||||
await writeFile(path, wrapped)
|
||||
await writeCredentials(path, wrapped)
|
||||
const ctx = await boot({ path, watch: false })
|
||||
await ctx.credentials.set(ALPHA, 'b')
|
||||
expect(await readFile(path, 'utf8')).toBe(`DSH_REVIEW_WRAPPED: |-\n line1\n line2\n${ALPHA}: b\n`)
|
||||
|
||||
@@ -6,6 +6,11 @@ import { join } from 'node:path'
|
||||
import { credentialRef } from '@deepseek-ai/dsh-credentials'
|
||||
import { CredentialsLocal } from '../src/index.ts'
|
||||
|
||||
/** Credential documents are seeded owner-only, exactly as the provider creates them. */
|
||||
function writeCredentials(file: string, text: string): Promise<void> {
|
||||
return writeFile(file, text, { mode: 0o600 })
|
||||
}
|
||||
|
||||
// chokidar is the nondeterministic OS boundary: faking it lets these tests
|
||||
// drive the event pipeline (error events, races with unreadable files)
|
||||
// deterministically. Real end-to-end watching stays covered by local.spec.ts.
|
||||
@@ -80,7 +85,7 @@ describe('watcher pipeline', () => {
|
||||
instance!.watcher.emit('error', new Error('watch backend failure'))
|
||||
expect(await ctx.credentials.resolve(KEY)).toBeUndefined()
|
||||
|
||||
await writeFile(path, 'DSH_CRED_PIPE: arrived\n')
|
||||
await writeCredentials(path, 'DSH_CRED_PIPE: arrived\n')
|
||||
instance!.watcher.emit('all', 'change', path)
|
||||
await vi.waitFor(async () => {
|
||||
expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'arrived', source: 'file' })
|
||||
@@ -90,7 +95,7 @@ describe('watcher pipeline', () => {
|
||||
it('keeps the last good snapshot when the file turns unreadable at runtime', async () => {
|
||||
const dir = await tempDir()
|
||||
const path = join(dir, '.credentials.yaml')
|
||||
await writeFile(path, 'DSH_CRED_PIPE: good\n')
|
||||
await writeCredentials(path, 'DSH_CRED_PIPE: good\n')
|
||||
const ctx = await boot({ path, debounceMs: 5 })
|
||||
|
||||
await chmod(path, 0o000)
|
||||
@@ -113,7 +118,7 @@ describe('watcher pipeline', () => {
|
||||
})
|
||||
const [instance] = await fakeInstances()
|
||||
|
||||
await writeFile(path, 'DSH_CRED_PIPE: first\n')
|
||||
await writeCredentials(path, 'DSH_CRED_PIPE: first\n')
|
||||
instance!.watcher.emit('all', 'change', path)
|
||||
// The snapshot commits before the fan-out, so the value lands even though
|
||||
// the listener threw out of the refresh.
|
||||
@@ -122,7 +127,7 @@ describe('watcher pipeline', () => {
|
||||
})
|
||||
|
||||
arm = false
|
||||
await writeFile(path, 'DSH_CRED_PIPE: second\n')
|
||||
await writeCredentials(path, 'DSH_CRED_PIPE: second\n')
|
||||
instance!.watcher.emit('all', 'change', path)
|
||||
await vi.waitFor(async () => {
|
||||
expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'second', source: 'file' })
|
||||
@@ -132,7 +137,7 @@ describe('watcher pipeline', () => {
|
||||
it('quiesces the refresh pipeline before dispose completes', async () => {
|
||||
const dir = await tempDir()
|
||||
const path = join(dir, '.credentials.yaml')
|
||||
await writeFile(path, 'DSH_CRED_PIPE: initial\n')
|
||||
await writeCredentials(path, 'DSH_CRED_PIPE: initial\n')
|
||||
const ctx = new Context()
|
||||
const fiber = ctx.plugin(CredentialsLocal, { path, debounceMs: 5 })
|
||||
await fiber
|
||||
@@ -142,7 +147,7 @@ describe('watcher pipeline', () => {
|
||||
if (disposed) postDisposeCommits += 1
|
||||
})
|
||||
|
||||
await writeFile(path, 'DSH_CRED_PIPE: changed\n')
|
||||
await writeCredentials(path, 'DSH_CRED_PIPE: changed\n')
|
||||
const [instance] = await fakeInstances()
|
||||
// Two queued refreshes: dispose interrupts one mid-flight and the other
|
||||
// before it starts, so both closed guards must hold.
|
||||
@@ -159,7 +164,7 @@ describe('watcher pipeline', () => {
|
||||
it('empties the snapshot when the document is deleted and emits the removals', async () => {
|
||||
const dir = await tempDir()
|
||||
const path = join(dir, '.credentials.yaml')
|
||||
await writeFile(path, 'DSH_CRED_PIPE: doomed\n')
|
||||
await writeCredentials(path, 'DSH_CRED_PIPE: doomed\n')
|
||||
const ctx = await boot({ path, debounceMs: 5 })
|
||||
const seen: string[] = []
|
||||
ctx.on('credentials/updated', (ref) => {
|
||||
@@ -178,7 +183,7 @@ describe('watcher pipeline', () => {
|
||||
it('keeps the last good snapshot when an external edit makes the document invalid', async () => {
|
||||
const dir = await tempDir()
|
||||
const path = join(dir, '.credentials.yaml')
|
||||
await writeFile(path, 'DSH_CRED_PIPE: a\n')
|
||||
await writeCredentials(path, 'DSH_CRED_PIPE: a\n')
|
||||
const ctx = await boot({ path, debounceMs: 5 })
|
||||
const seen: string[] = []
|
||||
ctx.on('credentials/updated', (ref) => {
|
||||
@@ -189,7 +194,7 @@ describe('watcher pipeline', () => {
|
||||
// this document holds nothing but credentials. A live reload must warn
|
||||
// and keep serving the last good snapshot rather than take the process
|
||||
// down or silently drop the entry it could not validate.
|
||||
await writeFile(path, 'BAD-KEY: 2\nDSH_CRED_PIPE: b\n')
|
||||
await writeCredentials(path, 'BAD-KEY: 2\nDSH_CRED_PIPE: b\n')
|
||||
const [instance] = await fakeInstances()
|
||||
instance!.watcher.emit('all', 'change', path)
|
||||
await new Promise(resolve => setTimeout(resolve, 50))
|
||||
@@ -197,7 +202,7 @@ describe('watcher pipeline', () => {
|
||||
expect(seen).toEqual([])
|
||||
|
||||
// Repairing the document resumes publishing.
|
||||
await writeFile(path, 'DSH_CRED_PIPE: b\n')
|
||||
await writeCredentials(path, 'DSH_CRED_PIPE: b\n')
|
||||
instance!.watcher.emit('all', 'change', path)
|
||||
await vi.waitFor(async () => {
|
||||
expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'b', source: 'file' })
|
||||
@@ -218,11 +223,11 @@ describe('watcher pipeline', () => {
|
||||
it('reconciles at watcher ready so a change during setup is not missed', async () => {
|
||||
const dir = await tempDir()
|
||||
const path = join(dir, '.credentials.yaml')
|
||||
await writeFile(path, `${KEY}: a\n`)
|
||||
await writeCredentials(path, `${KEY}: a\n`)
|
||||
const ctx = await boot({ path, debounceMs: 5 })
|
||||
// Written after the initial load but before the watcher became active:
|
||||
// no 'all' event will ever fire for it.
|
||||
await writeFile(path, `${KEY}: written-before-ready\n`)
|
||||
await writeCredentials(path, `${KEY}: written-before-ready\n`)
|
||||
const [instance] = await fakeInstances()
|
||||
instance!.watcher.emit('ready')
|
||||
await vi.waitFor(async () => {
|
||||
|
||||
@@ -47,12 +47,11 @@ export interface DeepSeekConnectionOptions {
|
||||
/** Endpoint base; `/chat/completions` is appended. */
|
||||
baseURL: string
|
||||
/**
|
||||
* Literal API key of this same resolution, when the configuration carried
|
||||
* one. Travelling with the endpoint is the point: a request can never pair
|
||||
* one generation's URL with another generation's secret.
|
||||
* Credential reference of this same resolution, resolved per request.
|
||||
* Travelling with the endpoint is the point: a request can never pair one
|
||||
* generation's URL with another generation's secret. Configuration carries
|
||||
* only this name — a literal key is not a configuration value.
|
||||
*/
|
||||
apiKey?: string
|
||||
/** Credential reference of this same resolution, resolved per request when no literal key exists. */
|
||||
apiKeyEnv: CredentialRef
|
||||
/** Request defaults applied to every call (thinking mode, effort). */
|
||||
defaults: RequestDefaults
|
||||
|
||||
@@ -59,8 +59,6 @@ const DEFAULT_MODELS: DeepSeekCatalogModel[] = [
|
||||
* reasoning effort resolves to `high`.
|
||||
*/
|
||||
export interface Config {
|
||||
/** Literal API key; prefer {@link apiKeyEnv} so no secret enters configuration files. */
|
||||
apiKey?: string
|
||||
/** Credential reference (environment-variable name) resolved per request; defaults to `DEEPSEEK_API_KEY`. */
|
||||
apiKeyEnv?: string
|
||||
/** Endpoint base; falls back to $DEEPSEEK_BASE_URL from a trusted environment layer, then the public API. */
|
||||
@@ -89,7 +87,6 @@ const catalogModel: z<DeepSeekCatalogModel> = z.object({
|
||||
})
|
||||
|
||||
export const Config: z<Config> = z.object({
|
||||
apiKey: z.string().role('secret'),
|
||||
apiKeyEnv: z.string().role('credential-ref').default(DEFAULT_API_KEY_ENV),
|
||||
baseURL: z.string(),
|
||||
thinking: z.union(['enabled', 'disabled']),
|
||||
@@ -147,9 +144,9 @@ function resolveModels(models: readonly DeepSeekCatalogModel[] | undefined): Dee
|
||||
* load (fail loud) and for each settings snapshot at its first use.
|
||||
* @param config - raw plugin config or resolved settings snapshot.
|
||||
* @param environment - this run's environment layers, or `undefined` outside
|
||||
* the product CLI. Only the launching shell and the user's own `.env` may
|
||||
* supply an endpoint: a base URL decides where the resolved API key is sent,
|
||||
* so a file inside the workspace must not be able to redirect it.
|
||||
* the product CLI. Every layer may supply an endpoint: the product trusts the
|
||||
* project it is launched in, so a checkout can point its own agent at the
|
||||
* gateway that checkout is meant to use.
|
||||
* @returns validated connection facts plus the credential reference.
|
||||
*/
|
||||
export function resolveAdapterOptions(config: Config, environment?: EnvironmentSnapshot): ResolvedDeepSeekOptions {
|
||||
@@ -175,10 +172,9 @@ export function resolveAdapterOptions(config: Config, environment?: EnvironmentS
|
||||
)
|
||||
}
|
||||
return {
|
||||
...config.apiKey !== undefined && config.apiKey.length > 0 ? { apiKey: config.apiKey } : {},
|
||||
apiKeyEnv: credentialRef(config.apiKeyEnv ?? DEFAULT_API_KEY_ENV),
|
||||
baseURL: config.baseURL
|
||||
?? environment?.getFrom(BASE_URL_ENV, ['process', 'user-env'])?.value
|
||||
?? environment?.getFrom(BASE_URL_ENV, ['process', 'project-env', 'user-env'])?.value
|
||||
?? PUBLIC_BASE_URL,
|
||||
defaults: {
|
||||
thinking: config.thinking,
|
||||
@@ -220,7 +216,6 @@ export function apply(ctx: Context, config: Config): void {
|
||||
const resolveApiKey = async (connection: ResolvedDeepSeekOptions): Promise<string> => {
|
||||
// Every credential fact comes from the caller's snapshot, so a rejected
|
||||
// settings generation cannot leak its key onto the previous endpoint.
|
||||
if (connection.apiKey !== undefined) return connection.apiKey
|
||||
const ref = connection.apiKeyEnv
|
||||
const credentials = ctx.get('credentials')
|
||||
if (credentials !== undefined) {
|
||||
@@ -228,16 +223,13 @@ export function apply(ctx: Context, config: Config): void {
|
||||
if (hit !== undefined) return hit.value
|
||||
} else {
|
||||
// Without the seam there is no managed store to rank against, so the
|
||||
// launching environment is the whole credential plane — but only that
|
||||
// layer: a key from a discovered project file would route this request
|
||||
// through an account the launch never chose.
|
||||
const inherited = environmentOf(ctx).getFrom(ref, ['process'])
|
||||
if (inherited !== undefined && inherited.value.length > 0) return inherited.value
|
||||
// environment is the whole credential plane.
|
||||
const ambient = environmentOf(ctx).getFrom(ref, ['process', 'project-env', 'user-env'])
|
||||
if (ambient !== undefined && ambient.value.length > 0) return ambient.value
|
||||
}
|
||||
throw new LlmError(
|
||||
`llm-deepseek: no API key for provider route "${PROVIDER}"; store ${ref} through the credentials`
|
||||
+ ` service (the web Models page writes it), export ${ref} in the launching environment, or — as a`
|
||||
+ ' last resort — set a literal "apiKey" in the llm-deepseek settings section',
|
||||
+ ` service (the web Models page writes it), or export ${ref} in the launching environment`,
|
||||
'MISSING_CREDENTIAL',
|
||||
)
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ import LlmService, { createUserMessage,
|
||||
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
|
||||
import { DeepSeekAdapter, PUBLIC_BASE_URL, resolveAdapterOptions } from '@deepseek-ai/dsh-llm-deepseek'
|
||||
import { DeepSeekAdapter, resolveAdapterOptions } from '@deepseek-ai/dsh-llm-deepseek'
|
||||
import { httpErrorCode } from '../src/adapter.ts'
|
||||
import { assemble } from './assemble.ts'
|
||||
import { closeMockServers, mockServer, textEvents } from './mock-server.ts'
|
||||
@@ -26,9 +26,12 @@ afterEach(async () => {
|
||||
})
|
||||
|
||||
async function harness(baseURL: string, config: object = {}) {
|
||||
// Configuration carries only the reference; the key comes from the
|
||||
// environment, which is the whole credential plane without a mounted seam.
|
||||
vi.stubEnv('DEEPSEEK_API_KEY', 'test-key')
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(LlmDeepSeek, { apiKey: 'test-key', baseURL, ...config })
|
||||
await ctx.plugin(LlmDeepSeek, { baseURL, ...config })
|
||||
return ctx
|
||||
}
|
||||
|
||||
@@ -567,7 +570,6 @@ describe('plugin registration and config', () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
const fiber = await ctx.plugin(LlmDeepSeek, {
|
||||
apiKey: 'k',
|
||||
baseURL: server.url,
|
||||
})
|
||||
expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek-official', name: 'DeepSeek' }])
|
||||
@@ -586,7 +588,6 @@ describe('plugin registration and config', () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(LlmDeepSeek, {
|
||||
apiKey: 'k',
|
||||
baseURL: 'http://127.0.0.1:1',
|
||||
retryPolicy: {
|
||||
mode: 'always',
|
||||
@@ -605,7 +606,7 @@ describe('plugin registration and config', () => {
|
||||
it('owns the deepseek provider and advertises the default models', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(LlmDeepSeek, { apiKey: 'k', baseURL: 'http://127.0.0.1:1' })
|
||||
await ctx.plugin(LlmDeepSeek, { baseURL: 'http://127.0.0.1:1' })
|
||||
expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek-official', name: 'DeepSeek' }])
|
||||
await expect(ctx.llm.listModels('deepseek-official')).resolves.toEqual([
|
||||
{ provider: 'deepseek-official', id: 'deepseek-v4-flash', name: 'DeepSeek-V4-Flash' },
|
||||
@@ -633,7 +634,6 @@ describe('plugin registration and config', () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(LlmDeepSeek, {
|
||||
apiKey: 'k',
|
||||
baseURL: 'http://127.0.0.1:1',
|
||||
reasoningEffort: effort,
|
||||
})
|
||||
@@ -654,7 +654,6 @@ describe('plugin registration and config', () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(LlmDeepSeek, {
|
||||
apiKey: 'k',
|
||||
baseURL: 'http://127.0.0.1:1',
|
||||
thinking: 'disabled',
|
||||
reasoningEffort: 'off',
|
||||
@@ -674,7 +673,6 @@ describe('plugin registration and config', () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await expect(ctx.plugin(LlmDeepSeek, {
|
||||
apiKey: 'k',
|
||||
baseURL: 'http://127.0.0.1:1',
|
||||
thinking: 'disabled',
|
||||
reasoningEffort,
|
||||
@@ -704,7 +702,7 @@ describe('plugin registration and config', () => {
|
||||
it('uses the default model catalog when apply is called directly', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
LlmDeepSeek.apply(ctx, { apiKey: 'k', baseURL: 'http://127.0.0.1:1' })
|
||||
LlmDeepSeek.apply(ctx, { baseURL: 'http://127.0.0.1:1' })
|
||||
await expect(ctx.llm.listModels('deepseek-official')).resolves.toEqual([
|
||||
{ provider: 'deepseek-official', id: 'deepseek-v4-flash', name: 'DeepSeek-V4-Flash' },
|
||||
{ provider: 'deepseek-official', id: 'deepseek-v4-pro', name: 'DeepSeek-V4-Pro' },
|
||||
@@ -715,7 +713,6 @@ describe('plugin registration and config', () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(LlmDeepSeek, {
|
||||
apiKey: 'k',
|
||||
baseURL: 'http://127.0.0.1:1',
|
||||
models: [
|
||||
{ id: 'private-fast', contextWindow: 32_000 },
|
||||
@@ -749,7 +746,6 @@ describe('plugin registration and config', () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(LlmDeepSeek, {
|
||||
apiKey: 'k',
|
||||
baseURL: 'http://127.0.0.1:1',
|
||||
defaultContextWindow: 256_000,
|
||||
models: [
|
||||
@@ -770,7 +766,6 @@ describe('plugin registration and config', () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(LlmDeepSeek, {
|
||||
apiKey: 'k',
|
||||
baseURL: 'http://127.0.0.1:1',
|
||||
models: [],
|
||||
})
|
||||
@@ -787,7 +782,6 @@ describe('plugin registration and config', () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await expect(ctx.plugin(LlmDeepSeek, {
|
||||
apiKey: 'k',
|
||||
baseURL: 'http://127.0.0.1:1',
|
||||
models: [...models],
|
||||
})).rejects.toThrow(message)
|
||||
@@ -799,7 +793,6 @@ describe('plugin registration and config', () => {
|
||||
await ctx.plugin(LlmService)
|
||||
expect(() => {
|
||||
LlmDeepSeek.apply(ctx, {
|
||||
apiKey: 'k',
|
||||
baseURL: 'http://127.0.0.1:1',
|
||||
models: [{ id: 'invalid-context', contextWindow: 0 }],
|
||||
})
|
||||
@@ -816,7 +809,6 @@ describe('plugin registration and config', () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await expect(ctx.plugin(LlmDeepSeek, {
|
||||
apiKey: 'k',
|
||||
baseURL: 'http://127.0.0.1:1',
|
||||
defaultContextWindow,
|
||||
})).rejects.toThrow(/defaultContextWindow/)
|
||||
@@ -833,7 +825,6 @@ describe('plugin registration and config', () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await expect(ctx.plugin(LlmDeepSeek, {
|
||||
apiKey: 'k',
|
||||
baseURL: 'http://127.0.0.1:1',
|
||||
maxTokens,
|
||||
})).rejects.toThrow(/maxTokens/)
|
||||
@@ -864,7 +855,7 @@ describe('plugin registration and config', () => {
|
||||
// The guidance leads with the credential store — the path that keeps the
|
||||
// secret out of configuration files — and mentions a literal key last.
|
||||
await expect(assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }))
|
||||
.rejects.toThrow(/store DEEPSEEK_API_KEY through the credentials service.*as a last resort.*"apiKey"/s)
|
||||
.rejects.toThrow(/store DEEPSEEK_API_KEY through the credentials service.*export DEEPSEEK_API_KEY/s)
|
||||
})
|
||||
|
||||
it('reads the ambient variable when no credentials seam is mounted', async () => {
|
||||
@@ -900,25 +891,26 @@ describe('plugin registration and config', () => {
|
||||
it('uses DEEPSEEK_BASE_URL when config omits baseURL', async () => {
|
||||
const server = await mockServer([{ kind: 'sse', events: textEvents }])
|
||||
vi.stubEnv('DEEPSEEK_BASE_URL', server.url)
|
||||
vi.stubEnv('DEEPSEEK_API_KEY', 'test-key')
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(LlmDeepSeek, { apiKey: 'k' })
|
||||
await ctx.plugin(LlmDeepSeek, {})
|
||||
await assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] })
|
||||
expect(server.requests).toHaveLength(1)
|
||||
})
|
||||
|
||||
|
||||
it('takes DEEPSEEK_BASE_URL from the launching shell or the user .env, never from the project', () => {
|
||||
it('takes DEEPSEEK_BASE_URL from any environment layer, with explicit config still on top', () => {
|
||||
const trusted = createEnvironmentSnapshot([
|
||||
{ source: 'user-env', path: '/home/.dsh/.env', values: { DEEPSEEK_BASE_URL: 'https://user.example' } },
|
||||
])
|
||||
expect(resolveAdapterOptions({}, trusted).baseURL).toBe('https://user.example')
|
||||
// A base URL decides where the resolved API key is sent, so a file inside
|
||||
// a model-writable workspace must not be able to redirect it.
|
||||
// The product trusts the project it is launched in, so a checkout can
|
||||
// point its own agent at the gateway that checkout is meant to use.
|
||||
const project = createEnvironmentSnapshot([
|
||||
{ source: 'project-env', path: '/work/.env', values: { DEEPSEEK_BASE_URL: 'https://attacker.example' } },
|
||||
{ source: 'project-env', path: '/work/.env', values: { DEEPSEEK_BASE_URL: 'https://project.example' } },
|
||||
])
|
||||
expect(resolveAdapterOptions({}, project).baseURL).toBe(PUBLIC_BASE_URL)
|
||||
expect(resolveAdapterOptions({}, project).baseURL).toBe('https://project.example')
|
||||
// An explicitly configured endpoint outranks every environment layer, so a
|
||||
// stale shell value cannot rewrite a deployment's own gateway.
|
||||
const shell = createEnvironmentSnapshot([
|
||||
@@ -966,12 +958,10 @@ describe('plugin registration and config', () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await expect(ctx.plugin(LlmDeepSeek, {
|
||||
apiKey: 'k',
|
||||
baseURL: 'http://127.0.0.1:1',
|
||||
streamIdleTimeoutMs: 0,
|
||||
})).rejects.toThrow(/streamIdleTimeoutMs/)
|
||||
await expect(ctx.plugin(LlmDeepSeek, {
|
||||
apiKey: 'k',
|
||||
baseURL: 'http://127.0.0.1:1',
|
||||
streamIdleTimeoutMs: MAX_TIMER_DELAY_MS + 1,
|
||||
})).rejects.toThrow(/streamIdleTimeoutMs/)
|
||||
@@ -982,7 +972,6 @@ describe('plugin registration and config', () => {
|
||||
await ctx.plugin(LlmService)
|
||||
|
||||
await expect(ctx.plugin(LlmDeepSeek, {
|
||||
apiKey: 'k',
|
||||
baseURL: 'http://127.0.0.1:1',
|
||||
retryPolicy: { mode: 'normal', maxRetries: -1 },
|
||||
})).rejects.toThrow(/retryPolicy/)
|
||||
|
||||
@@ -61,7 +61,7 @@ describe('request-level dynamic configuration', () => {
|
||||
it('routes the next request with the freshly resolved base URL and credential', async () => {
|
||||
vi.stubEnv('DEEPSEEK_API_KEY', '')
|
||||
const dir = await home()
|
||||
await writeFile(join(dir, '.credentials.yaml'), 'DEEPSEEK_API_KEY: first-key\n')
|
||||
await writeFile(join(dir, '.credentials.yaml'), 'DEEPSEEK_API_KEY: first-key\n', { mode: 0o600 })
|
||||
const serverA = await mockServer([{ kind: 'sse', events: textEvents }])
|
||||
const serverB = await mockServer([{ kind: 'sse', events: textEvents }])
|
||||
const { ctx } = await boot(dir, { baseURL: serverA.url })
|
||||
@@ -78,16 +78,21 @@ describe('request-level dynamic configuration', () => {
|
||||
expect(serverB.headers[0]?.authorization).toBe('Bearer second-key')
|
||||
})
|
||||
|
||||
it('prefers a literal settings apiKey over the credential layers', async () => {
|
||||
it('refuses a literal apiKey in settings and keeps serving the stored credential', async () => {
|
||||
vi.stubEnv('DEEPSEEK_API_KEY', '')
|
||||
const dir = await home()
|
||||
await writeFile(join(dir, '.credentials.yaml'), 'DEEPSEEK_API_KEY: file-key\n')
|
||||
await writeFile(join(dir, '.credentials.yaml'), 'DEEPSEEK_API_KEY: file-key\n', { mode: 0o600 })
|
||||
const server = await mockServer([{ kind: 'sse', events: textEvents }])
|
||||
const { ctx } = await boot(dir, { baseURL: server.url })
|
||||
|
||||
// Configuration carries a reference, never a value. The namespace has no
|
||||
// `apiKey` field, so writing one is dropped by the schema rather than
|
||||
// rejected (no adapter namespace is strict); what matters is that a
|
||||
// settings document cannot become a second credential store outranking
|
||||
// `.credentials.yaml` and the environment.
|
||||
await ctx.settings.update(NS, { apiKey: 'literal-key' })
|
||||
await prompt(ctx)
|
||||
expect(server.headers[0]?.authorization).toBe('Bearer literal-key')
|
||||
expect(server.headers[0]?.authorization).toBe('Bearer file-key')
|
||||
})
|
||||
|
||||
it('starts keyless and serves the next request once the key arrives', async () => {
|
||||
@@ -152,17 +157,16 @@ describe('request-level dynamic configuration', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('sends the whole last-good snapshot when a rejected one changed both the key and the URL', async () => {
|
||||
vi.stubEnv('DEEPSEEK_API_KEY', '')
|
||||
it('keeps the whole last-good snapshot when a rejected one changed the URL', async () => {
|
||||
const dir = await home()
|
||||
const good = await mockServer([{ kind: 'sse', events: textEvents }])
|
||||
const rejected = await mockServer([{ kind: 'sse', events: textEvents }])
|
||||
const { ctx } = await boot(dir, { apiKey: 'good-key', baseURL: good.url })
|
||||
vi.stubEnv('DEEPSEEK_API_KEY', 'good-key')
|
||||
const { ctx } = await boot(dir, { baseURL: good.url })
|
||||
|
||||
// One snapshot moves the endpoint AND the literal key, and fails the
|
||||
// resolve step beyond the schema (duplicate catalog ids).
|
||||
// One snapshot moves the endpoint and fails the resolve step beyond the
|
||||
// schema (duplicate catalog ids).
|
||||
await ctx.settings.update(NS, {
|
||||
apiKey: 'rejected-key',
|
||||
baseURL: rejected.url,
|
||||
models: [{ id: 'dup' }, { id: 'dup' }],
|
||||
})
|
||||
@@ -178,7 +182,7 @@ describe('request-level dynamic configuration', () => {
|
||||
it('falls back to the composition entry when settings detach', async () => {
|
||||
vi.stubEnv('DEEPSEEK_API_KEY', '')
|
||||
const dir = await home()
|
||||
await writeFile(join(dir, '.credentials.yaml'), 'DEEPSEEK_API_KEY: steady-key\n')
|
||||
await writeFile(join(dir, '.credentials.yaml'), 'DEEPSEEK_API_KEY: steady-key\n', { mode: 0o600 })
|
||||
const serverA = await mockServer([{ kind: 'sse', events: textEvents }])
|
||||
const serverB = await mockServer([{ kind: 'sse', events: textEvents }])
|
||||
const { ctx, settingsFiber } = await boot(dir, { baseURL: serverA.url })
|
||||
|
||||
@@ -51,7 +51,7 @@ async function loadComposition(
|
||||
const credentialsPath = join(root, '.credentials.yaml')
|
||||
if (options.withDynamic && fresh) {
|
||||
await writeFile(settingsPath, '# personal settings\n')
|
||||
await writeFile(credentialsPath, 'DEEPSEEK_API_KEY: boot-key\n')
|
||||
await writeFile(credentialsPath, 'DEEPSEEK_API_KEY: boot-key\n', { mode: 0o600 })
|
||||
}
|
||||
|
||||
const configPath = join(root, 'cordis.yml')
|
||||
@@ -76,7 +76,6 @@ async function loadComposition(
|
||||
" name: '@deepseek-ai/dsh-llm-deepseek'",
|
||||
' config:',
|
||||
` baseURL: ${JSON.stringify(options.baseURL)}`,
|
||||
...options.withDynamic ? [] : [' apiKey: entry-key'],
|
||||
'',
|
||||
].join('\n'))
|
||||
|
||||
@@ -122,7 +121,7 @@ describe('llm-deepseek real dynamic composition', () => {
|
||||
await vi.waitFor(() => {
|
||||
expect((ctx.get('settings')!.get(NS) as { baseURL?: string }).baseURL).toBe(serverB.url)
|
||||
}, { timeout: 5000 })
|
||||
await writeFile(credentialsPath, 'DEEPSEEK_API_KEY: rotated-key\n')
|
||||
await writeFile(credentialsPath, 'DEEPSEEK_API_KEY: rotated-key\n', { mode: 0o600 })
|
||||
await vi.waitFor(async () => {
|
||||
expect(await ctx.get('credentials')!.resolve(KEY_REF)).toEqual({ value: 'rotated-key', source: 'file' })
|
||||
}, { timeout: 5000 })
|
||||
@@ -161,8 +160,10 @@ describe('llm-deepseek real dynamic composition', () => {
|
||||
expect(second.headers[0]?.authorization).toBe('Bearer rotated-after-restart')
|
||||
})
|
||||
|
||||
it('boots the same adapter without settings or credentials entries on entry config alone', async () => {
|
||||
vi.stubEnv('DEEPSEEK_API_KEY', '')
|
||||
it('boots the same adapter on entry config alone, resolving the reference from the environment', async () => {
|
||||
// No settings and no credentials provider: configuration carries only the
|
||||
// reference, so the environment is the whole credential plane here.
|
||||
vi.stubEnv('DEEPSEEK_API_KEY', 'entry-key')
|
||||
const server = await mockServer([{ kind: 'sse', events: textEvents }])
|
||||
const { ctx } = await loadComposition({ withDynamic: false, baseURL: server.url })
|
||||
|
||||
|
||||
@@ -100,9 +100,8 @@ export function apply(ctx: Context, config: Config): void {
|
||||
const credentials = ctx.get('credentials')
|
||||
const hit = credentials !== undefined
|
||||
? (await credentials.resolve(ref))?.value
|
||||
// Without the seam the launching environment is the whole credential
|
||||
// plane — but only that layer, never a discovered project file.
|
||||
: environmentOf(ctx).getFrom(ref, ['process'])?.value
|
||||
// Without the seam the environment is the whole credential plane.
|
||||
: environmentOf(ctx).getFrom(ref, ['process', 'project-env', 'user-env'])?.value
|
||||
if (hit !== undefined && hit.length > 0) return hit
|
||||
throw new LlmError(
|
||||
`llm-pi-ai: no credential for provider route "${provider}"; its profile resolves ${ref}, which is not`
|
||||
|
||||
@@ -53,7 +53,7 @@ describe('request-level dynamic profiles', () => {
|
||||
it('mounts bare and dormant, then registers routes the moment settings supply providers', async () => {
|
||||
vi.stubEnv('PI_DYNAMIC_KEY', '')
|
||||
const dir = await home()
|
||||
await writeFile(join(dir, '.credentials.yaml'), 'PI_DYNAMIC_KEY: pk-from-settings\n')
|
||||
await writeFile(join(dir, '.credentials.yaml'), 'PI_DYNAMIC_KEY: pk-from-settings\n', { mode: 0o600 })
|
||||
const server = await mockServer([{ events: textEvents }])
|
||||
// The exact product posture: `- id: llm-pi-ai` with no config at all.
|
||||
const ctx = await boot(dir, {})
|
||||
@@ -112,7 +112,7 @@ describe('request-level dynamic profiles', () => {
|
||||
it('rotates the per-request credential referenced by apiKeyEnv', async () => {
|
||||
vi.stubEnv('PI_DYNAMIC_KEY', '')
|
||||
const dir = await home()
|
||||
await writeFile(join(dir, '.credentials.yaml'), 'PI_DYNAMIC_KEY: pk-one\n')
|
||||
await writeFile(join(dir, '.credentials.yaml'), 'PI_DYNAMIC_KEY: pk-one\n', { mode: 0o600 })
|
||||
const server = await mockServer([{ events: textEvents }, { events: textEvents }])
|
||||
const ctx = await boot(dir, {
|
||||
providers: { deepseek: { apiKeyEnv: 'PI_DYNAMIC_KEY', baseURL: server.url } },
|
||||
|
||||
@@ -40,7 +40,7 @@ async function loadComposition(): Promise<{ ctx: Context; settingsPath: string }
|
||||
root = await mkdtemp(join(tmpdir(), 'dsh-pi-composition-'))
|
||||
const settingsPath = join(root, 'settings.yaml')
|
||||
await writeFile(settingsPath, '# personal settings\n')
|
||||
await writeFile(join(root, '.credentials.yaml'), 'PI_COMPOSITION_KEY: key-from-store\n')
|
||||
await writeFile(join(root, '.credentials.yaml'), 'PI_COMPOSITION_KEY: key-from-store\n', { mode: 0o600 })
|
||||
|
||||
const configPath = join(root, 'cordis.yml')
|
||||
await writeFile(configPath, [
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import { createServer } from 'node:http'
|
||||
import type { AddressInfo } from 'node:net'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
@@ -34,10 +34,10 @@ async function harness(
|
||||
baseURL: string,
|
||||
options: { streamIdleTimeoutMs?: number; initialDelayMs?: number } = {},
|
||||
): Promise<Context> {
|
||||
vi.stubEnv('DEEPSEEK_API_KEY', 'mock-key')
|
||||
const ctx = new Context()
|
||||
await mountAgentLoopTestDependencies(ctx)
|
||||
await ctx.plugin(LlmDeepSeek, {
|
||||
apiKey: 'mock-key',
|
||||
baseURL,
|
||||
streamIdleTimeoutMs: options.streamIdleTimeoutMs ?? 1_000,
|
||||
retryPolicy: {
|
||||
|
||||
@@ -242,10 +242,16 @@ export class SettingsLocal extends Settings {
|
||||
private parse(text: string): Record<string, unknown> {
|
||||
let root: unknown
|
||||
if (this.spec.format === 'yaml') {
|
||||
// `prettyErrors` is on only for `linePos`; `error.message` is never
|
||||
// used, because the parser quotes the offending source line and a
|
||||
// settings document can hold a `role('secret')` value.
|
||||
const document = parseDocument(text, { prettyErrors: true })
|
||||
if (document.errors.length > 0) {
|
||||
throw new Error(`settings-local: invalid document at ${this.spec.filename}: ${
|
||||
document.errors.map(error => error.message).join('; ')}`)
|
||||
document.errors.map((error) => {
|
||||
const at = error.linePos?.[0]
|
||||
return `${error.code}${at === undefined ? '' : ` at line ${String(at.line)}, column ${String(at.col)}`}`
|
||||
}).join('; ')}`)
|
||||
}
|
||||
root = document.toJS() ?? {}
|
||||
} else {
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/util/environment/README.md
|
||||
README.md: f642aa715c87878b2eaab9f034fb18163a6fbd2e
|
||||
README.zh.md: a095730dbc8c2a4e7dc8dc57dc2930685c8fb453
|
||||
README.md: 526c7263106962cdbc19ec58c00b06e58849a258
|
||||
README.zh.md: 203b8252d2e96235ec083481ccafda129902cd38
|
||||
@@ -7,7 +7,7 @@ This run's environment as one immutable snapshot that remembers **which layer su
|
||||
| Layer | Source id | What it is |
|
||||
|---|---|---|
|
||||
| Inherited process environment | `process` | What the launching shell, CI job, or container passed in — this run's explicit intent |
|
||||
| `<invocation cwd>/.env` | `project-env` | Whatever the project directory happens to contain; a model working in that workspace can write it |
|
||||
| `<invocation cwd>/.env` | `project-env` | The project the harness was launched in, which the product trusts to configure its own agent |
|
||||
| `$DSH_HOME/.env` | `user-env` | The user's own machine-level defaults |
|
||||
|
||||
Values do also reach `process.env` — a user's `--config` tree and third-party libraries read it — but that flattened view is not the authority for anything the harness resolves.
|
||||
@@ -16,14 +16,14 @@ Values do also reach `process.env` — a user's `--config` tree and third-party
|
||||
|
||||
`get(name)` searches every layer, most trusted first. `getFrom(name, sources)` searches only the layers the caller trusts.
|
||||
|
||||
**Omitting a layer is a refusal, not a demotion.** A base URL decides where a resolved API key is sent, so the LLM adapters ask for `['process', 'user-env']`: no future reordering can let a project file redirect a credential, because that layer is never consulted at all.
|
||||
**Omitting a layer is a refusal, not a demotion** — a caller that must never accept a layer leaves it out of the list, so no future reordering can let it back in. The provider adapters name all three, because the product trusts the project it runs in; the mechanism exists for the decisions where that is not true.
|
||||
|
||||
```ts
|
||||
import type { Context } from 'cordis'
|
||||
import { environmentOf } from '@deepseek-ai/dsh-environment'
|
||||
|
||||
declare const ctx: Context
|
||||
const endpoint = environmentOf(ctx).getFrom('DEEPSEEK_BASE_URL', ['process', 'user-env'])?.value
|
||||
const endpoint = environmentOf(ctx).getFrom('DEEPSEEK_BASE_URL', ['process', 'project-env', 'user-env'])?.value
|
||||
```
|
||||
|
||||
`environmentOf(ctx)` returns the launcher's snapshot when the product CLI booted the tree, and otherwise the inherited environment as the only layer. That fallback does not weaken the rules: an SDK host or a bare `cordis.yml` discovered no files, so everything it has really is the environment it was launched with.
|
||||
@@ -32,11 +32,13 @@ const endpoint = environmentOf(ctx).getFrom('DEEPSEEK_BASE_URL', ['process', 'us
|
||||
|
||||
`isBootstrapOnly(name)` names the variables only the inherited environment may set. The launcher rejects a `.env` that declares one, before applying anything.
|
||||
|
||||
A bootstrap variable decides **how a process launches** (`PATH`, `SHELL`, `NODE_OPTIONS`, `NODE_PATH`, `LD_PRELOAD`, `LD_LIBRARY_PATH`, `DYLD_*`), **where code or model-visible instructions load from** (the whole `DSH_*` namespace, `HOME`, `USERPROFILE`, `XDG_*`), or **how the network is reached and trusted** (`HTTP_PROXY`, `HTTPS_PROXY`, `ALL_PROXY`, `NO_PROXY`, `SSL_CERT_FILE`, `SSL_CERT_DIR`, `NODE_EXTRA_CA_CERTS`). Matching is case-insensitive, so `https_proxy` is not a bypass.
|
||||
Trusting a project to configure the agent's work is not the same as letting it change the harness. A bootstrap variable decides **how a process launches** (`PATH`, `SHELL`, `NODE_OPTIONS`, `LD_PRELOAD`, `DYLD_*`), **what code a runtime executes before the program it was asked to run** (`BASH_ENV` and its per-language siblings — `PERL5OPT`, `PYTHONSTARTUP`, `RUBYOPT`, `JAVA_TOOL_OPTIONS` — plus the Git hook commands), **where model-visible instructions load from** (the whole `DSH_*` namespace, `HOME`, `XDG_*`), or **how the network is reached and trusted** (proxy and CA variables). Matching is case-insensitive, so `https_proxy` is not a bypass.
|
||||
|
||||
These take effect with no user action, before any turn, outside the permission policy and the sandbox: `DSH_PERMISSION_MODE` would switch off the approvals that make trusting a project meaningful, and `BASH_ENV` runs a file of the project's choosing on every `bash -c` the bash tool issues.
|
||||
|
||||
The whole `DSH_*` namespace is denied rather than an audited subset: the harness's own switches — the permission mode, the agents home, the bundled skill root — are exactly what a hostile project would want, and a switch added later must not become settable by forgetting to list it.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **The snapshot is not a subprocess boundary** — every layer is also materialized into `process.env`, so ordinary project variables still reach child processes under [`dsh-subprocess`](../../subprocess/subprocess/README.md)'s scrub. Bootstrap variables cannot come from a file at all, but a project `.env` can still set, say, `GIT_SSH_COMMAND` for the tools an agent runs.
|
||||
- **The snapshot is not a subprocess boundary** — every layer is also materialized into `process.env`, so ordinary project variables reach child processes under [`dsh-subprocess`](../../subprocess/subprocess/README.md)'s scrub. That is intended for ordinary variables; the code-loading hooks that would abuse it are rejected at load instead, and the deny list is the thing to extend when a new runtime hook appears.
|
||||
- **No per-workspace layer** — the project layer is the *invoking* directory, fixed at launch. A workspace selected later in the Web UI contributes nothing, deliberately: following it would let a model's own workspace change the harness environment mid-session.
|
||||
@@ -7,7 +7,7 @@
|
||||
| 层 | 来源 id | 它是什么 |
|
||||
|---|---|---|
|
||||
| 继承的进程环境 | `process` | 启动 shell、CI 任务或容器传入的东西——本次运行的明确意图 |
|
||||
| `<invocation cwd>/.env` | `project-env` | 项目目录里恰好有的东西;在该工作区里工作的模型可以写它 |
|
||||
| `<invocation cwd>/.env` | `project-env` | harness 被启动于其中的项目;产品信任它配置自己的 agent |
|
||||
| `$DSH_HOME/.env` | `user-env` | 用户自己的机器级默认值 |
|
||||
|
||||
这些值同样会进入 `process.env`——用户自己的 `--config` 树和第三方库要读它——但那份压平的视图不是 harness 解析任何值的依据。
|
||||
@@ -16,14 +16,14 @@
|
||||
|
||||
`get(name)` 按可信度从高到低搜索所有层。`getFrom(name, sources)` 只搜索调用方信任的层。
|
||||
|
||||
**省略某一层是拒绝,不是降级。** base URL 决定已解析的 API key 被发往何处,因此 LLM 适配器请求的是 `['process', 'user-env']`:后续任何重新排序都无法让项目文件重定向凭据,因为那一层根本不会被查询。
|
||||
**省略某一层是拒绝,不是降级**——绝不能接受某一层的调用方直接不把它列进去,后续任何重新排序都无法让它回来。provider 适配器三层全列,因为产品信任它所运行的项目;该机制是为那些「并非如此」的决策准备的。
|
||||
|
||||
```ts
|
||||
import type { Context } from 'cordis'
|
||||
import { environmentOf } from '@deepseek-ai/dsh-environment'
|
||||
|
||||
declare const ctx: Context
|
||||
const endpoint = environmentOf(ctx).getFrom('DEEPSEEK_BASE_URL', ['process', 'user-env'])?.value
|
||||
const endpoint = environmentOf(ctx).getFrom('DEEPSEEK_BASE_URL', ['process', 'project-env', 'user-env'])?.value
|
||||
```
|
||||
|
||||
当产品 CLI(命令行界面)启动了这棵树时,`environmentOf(ctx)` 返回启动器的快照;否则返回只含继承环境的那一层。该回退并不削弱规则:SDK 宿主或裸 `cordis.yml` 从未发现过任何文件,因此它拥有的一切确实就是它被启动时的环境。
|
||||
@@ -32,11 +32,13 @@ const endpoint = environmentOf(ctx).getFrom('DEEPSEEK_BASE_URL', ['process', 'us
|
||||
|
||||
`isBootstrapOnly(name)` 给出只有继承环境才能设置的变量。启动器一旦发现某个 `.env` 声明了其中之一,就会在应用任何内容之前拒绝启动。
|
||||
|
||||
bootstrap 变量决定**进程如何启动**(`PATH`、`SHELL`、`NODE_OPTIONS`、`NODE_PATH`、`LD_PRELOAD`、`LD_LIBRARY_PATH`、`DYLD_*`)、**代码或模型可见的指令从哪里加载**(整个 `DSH_*` 命名空间、`HOME`、`USERPROFILE`、`XDG_*`),或者**网络如何抵达与信任**(`HTTP_PROXY`、`HTTPS_PROXY`、`ALL_PROXY`、`NO_PROXY`、`SSL_CERT_FILE`、`SSL_CERT_DIR`、`NODE_EXTRA_CA_CERTS`)。匹配不区分大小写,因此 `https_proxy` 不是绕过手段。
|
||||
信任一个项目配置 agent 的工作,不等于让它改变 harness 本身。bootstrap 变量决定**进程如何启动**(`PATH`、`SHELL`、`NODE_OPTIONS`、`LD_PRELOAD`、`DYLD_*`)、**运行时在执行被要求运行的程序之前先执行哪些代码**(`BASH_ENV` 及其各语言同类——`PERL5OPT`、`PYTHONSTARTUP`、`RUBYOPT`、`JAVA_TOOL_OPTIONS`——以及 Git 的钩子命令)、**模型可见的指令从哪里加载**(整个 `DSH_*` 命名空间、`HOME`、`XDG_*`),或者**网络如何抵达与信任**(proxy 与 CA 变量)。匹配不区分大小写,因此 `https_proxy` 不是绕过手段。
|
||||
|
||||
这些变量无需任何用户动作、在任何一轮开始之前、且在权限策略与沙箱之外就生效:`DSH_PERMISSION_MODE` 会关掉让「信任项目」有意义的那道审批,而 `BASH_ENV` 会在 bash 工具发出的每一次 `bash -c` 上执行项目指定的文件。
|
||||
|
||||
整个 `DSH_*` 命名空间被拒绝,而不是只拒绝一份经过审查的子集:harness 自己的开关——权限模式、agents home、内置 skill(技能)根目录——恰恰是敌意项目最想要的,而后来新增的开关不能因为忘记登记就变得可设置。
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **快照不是子进程边界**:每一层同样会被物化进 `process.env`,因此普通的项目变量仍会按 [`dsh-subprocess`](../../subprocess/subprocess/README.md) 的清洗规则抵达子进程。bootstrap 变量完全不能来自文件,但项目 `.env` 仍可以为 agent 运行的工具设置诸如 `GIT_SSH_COMMAND` 之类的变量。
|
||||
- **快照不是子进程边界**:每一层同样会被物化进 `process.env`,因此项目里的普通变量会按 [`dsh-subprocess`](../../subprocess/subprocess/README.md) 的清洗规则抵达子进程。这对普通变量是有意为之;会滥用这一点的代码加载钩子改为在加载时拒绝,新的运行时钩子出现时该扩展的是那份拒绝清单。
|
||||
- **没有按工作区划分的层**:项目层是*调用*目录,在启动时固定。之后在 Web UI 中选择的工作区不贡献任何内容,这是刻意的:跟随它等于让模型自己的工作区在会话中途改变 harness 的环境。
|
||||
@@ -146,29 +146,51 @@ const BOOTSTRAP_NAMES = new Set([
|
||||
// Process launch and module resolution.
|
||||
'PATH', 'HOME', 'USERPROFILE', 'SHELL',
|
||||
'NODE_OPTIONS', 'NODE_PATH', 'NODE_EXTRA_CA_CERTS',
|
||||
'LD_PRELOAD', 'LD_LIBRARY_PATH',
|
||||
'LD_PRELOAD', 'LD_LIBRARY_PATH', 'LD_AUDIT',
|
||||
// Interpreter start-up hooks: each of these makes a runtime execute a file
|
||||
// of the setter's choosing on every invocation, before the program runs.
|
||||
// `BASH_ENV` is the sharpest — the bash tool spawns `bash -c`, which sources
|
||||
// it every time — but every runtime an agent shells out to has one.
|
||||
'BASH_ENV', 'ENV', 'SHELLOPTS', 'BASHOPTS',
|
||||
'PERL5OPT', 'PERL5LIB', 'PYTHONSTARTUP', 'PYTHONPATH', 'RUBYOPT', 'RUBYLIB',
|
||||
'JAVA_TOOL_OPTIONS', '_JAVA_OPTIONS', 'JDK_JAVA_OPTIONS',
|
||||
// Version-control hooks that run a command on the setter's behalf.
|
||||
'GIT_SSH', 'GIT_SSH_COMMAND', 'GIT_EXTERNAL_DIFF', 'GIT_PAGER', 'GIT_EDITOR',
|
||||
'EDITOR', 'VISUAL', 'PAGER',
|
||||
// Network reach and trust.
|
||||
'SSL_CERT_FILE', 'SSL_CERT_DIR',
|
||||
'HTTP_PROXY', 'HTTPS_PROXY', 'ALL_PROXY', 'NO_PROXY',
|
||||
'REQUESTS_CA_BUNDLE', 'CURL_CA_BUNDLE',
|
||||
])
|
||||
|
||||
/** Name prefixes no discovered file may set. */
|
||||
const BOOTSTRAP_PREFIXES = ['DSH_', 'XDG_', 'DYLD_']
|
||||
const BOOTSTRAP_PREFIXES = ['DSH_', 'XDG_', 'DYLD_', 'BASH_FUNC_']
|
||||
|
||||
/**
|
||||
* Whether a variable may come only from the inherited process environment.
|
||||
*
|
||||
* A bootstrap variable decides how a process launches (`PATH`, `NODE_OPTIONS`,
|
||||
* `LD_PRELOAD`), where code or model-visible instructions load from (`DSH_*`
|
||||
* covers the Harness home, the agents home, and the bundled skill root), or
|
||||
* how the network is reached and trusted (proxy and CA variables). A file the
|
||||
* harness merely finds — including one a model can write inside the workspace
|
||||
* — must never set them, so they are rejected at load rather than ranked
|
||||
* below another layer.
|
||||
* The invoking project is trusted to *configure* the agent's work — its
|
||||
* endpoints, its ordinary variables, even a credential. It is not trusted to
|
||||
* change the harness itself, and that is what a bootstrap variable does: it
|
||||
* decides how a process launches (`PATH`, `NODE_OPTIONS`, `LD_PRELOAD`), what
|
||||
* code a runtime executes before the program it was asked to run (`BASH_ENV`
|
||||
* and its per-language siblings, the Git hook commands), where model-visible
|
||||
* instructions load from (`DSH_*` covers the Harness home, the agents home,
|
||||
* and the bundled skill root), or how the network is reached and trusted
|
||||
* (proxy and CA variables).
|
||||
*
|
||||
* The whole `DSH_*` namespace is denied rather than an audited subset: the
|
||||
* harness's own switches are exactly the ones a hostile project would want,
|
||||
* and a new switch must not become settable by forgetting to list it.
|
||||
* The distinction is that these take effect with no user action, before any
|
||||
* turn, outside the permission policy and the sandbox — `DSH_PERMISSION_MODE`
|
||||
* would switch off the approvals that make trusting a project meaningful at
|
||||
* all, and `BASH_ENV` runs a file of the project's choosing on every single
|
||||
* `bash -c` the tool issues. Trusting a project's code to run under the
|
||||
* agent's policy is not the same as letting it rewrite that policy.
|
||||
*
|
||||
* They are therefore rejected at load rather than ranked below another layer:
|
||||
* a user who wrote one into a file believes it applies, and silently ignoring
|
||||
* it is its own failure. The whole `DSH_*` namespace is denied rather than an
|
||||
* audited subset, because a switch added later must not become settable by
|
||||
* being forgotten.
|
||||
* @param name - the variable name.
|
||||
* @returns true when only the inherited environment may supply it.
|
||||
*/
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/web/web-search-deepseek/README.md
|
||||
README.md: 9046934de209ed0787efa50332e5be16bfdf55c6
|
||||
README.zh.md: 94e01daba69cecd2f5c3c6680979ee5fd66d7cdd
|
||||
README.md: 95340314fe08d0963b899f4a1d704a98f85963a5
|
||||
README.zh.md: efd02af805faa96781654b4a4a0dd69a6b8ed3e4
|
||||
@@ -20,7 +20,7 @@ It reuses the `DEEPSEEK_API_KEY` credential reference (no new secret) but **not*
|
||||
|---|---|---|
|
||||
| `apiKey` | omitted | Literal DeepSeek API key. Prefer `apiKeyEnv` so no secret enters configuration; a non-empty literal wins. |
|
||||
| `apiKeyEnv` | `DEEPSEEK_API_KEY` | Credential reference resolved for each search through `ctx.credentials`, or from the process environment when that seam is absent. A missing value fails the call as `WEB_PROVIDER_CREDENTIAL_MISSING`. |
|
||||
| `baseURL` | `https://api.deepseek.com/anthropic/v1` | Anthropic-compatible endpoint base; `/messages` is appended. Use a separate env var such as `$DEEPSEEK_SEARCH_BASE_URL` when overriding it; do not reuse `$DEEPSEEK_BASE_URL`, which belongs to the chat-completions LLM adapter. An unparseable value makes the provider unavailable. |
|
||||
| `baseURL` | `https://api.deepseek.com/anthropic/v1` | Anthropic-compatible endpoint base; `/messages` is appended. Falls back to `$DEEPSEEK_SEARCH_BASE_URL` from any environment layer; do not reuse `$DEEPSEEK_BASE_URL`, which belongs to the chat-completions LLM adapter. An unparseable value makes the provider unavailable. |
|
||||
| `model` | `deepseek-v4-flash` | Anthropic-format model name. |
|
||||
| `apiVersion` | `2023-06-01` | `anthropic-version` header value. |
|
||||
| `maxTokens` | `4096` | Positive-integer upper bound on generated tokens for the Messages request. |
|
||||
@@ -31,7 +31,7 @@ It reuses the `DEEPSEEK_API_KEY` credential reference (no new secret) but **not*
|
||||
name: '@deepseek-ai/dsh-web-search-deepseek'
|
||||
config:
|
||||
apiKeyEnv: DEEPSEEK_API_KEY
|
||||
baseURL: !!js process.env.DEEPSEEK_SEARCH_BASE_URL
|
||||
baseURL: https://gateway.internal/anthropic/v1
|
||||
```
|
||||
|
||||
## Mapping
|
||||
|
||||
@@ -20,7 +20,7 @@ Exa 和 Perplexity 提供专用搜索端点,DeepSeek 则没有。该提供方
|
||||
|---|---|---|
|
||||
| `apiKey` | 未设置 | DeepSeek API 密钥字面值。优先使用 `apiKeyEnv`,避免密钥进入配置;非空字面值优先。 |
|
||||
| `apiKeyEnv` | `DEEPSEEK_API_KEY` | 每次搜索都会通过 `ctx.credentials` 解析该凭据引用;没有该 seam 时则从进程环境解析。值缺失时,调用以 `WEB_PROVIDER_CREDENTIAL_MISSING` 失败。 |
|
||||
| `baseURL` | `https://api.deepseek.com/anthropic/v1` | Anthropic 兼容端点基址;追加 `/messages`。覆盖时使用 `$DEEPSEEK_SEARCH_BASE_URL` 等独立环境变量;禁止复用属于 chat-completions LLM 适配器的 `$DEEPSEEK_BASE_URL`。无法解析时提供方不可用。 |
|
||||
| `baseURL` | `https://api.deepseek.com/anthropic/v1` | Anthropic 兼容端点基址;追加 `/messages`。缺省时回退到任一环境层中的 `$DEEPSEEK_SEARCH_BASE_URL`;禁止复用属于 chat-completions LLM 适配器的 `$DEEPSEEK_BASE_URL`。无法解析时提供方不可用。 |
|
||||
| `model` | `deepseek-v4-flash` | Anthropic 格式模型名称。 |
|
||||
| `apiVersion` | `2023-06-01` | `anthropic-version` 标头值。 |
|
||||
| `maxTokens` | `4096` | Messages 请求生成 token 的正整数上限。 |
|
||||
@@ -31,7 +31,7 @@ Exa 和 Perplexity 提供专用搜索端点,DeepSeek 则没有。该提供方
|
||||
name: '@deepseek-ai/dsh-web-search-deepseek'
|
||||
config:
|
||||
apiKeyEnv: DEEPSEEK_API_KEY
|
||||
baseURL: !!js process.env.DEEPSEEK_SEARCH_BASE_URL
|
||||
baseURL: https://gateway.internal/anthropic/v1
|
||||
```
|
||||
|
||||
## 映射
|
||||
|
||||
@@ -68,6 +68,14 @@ export const Config: z<Config> = z.object({
|
||||
maxUses: z.number().step(1).min(1),
|
||||
})
|
||||
|
||||
/**
|
||||
* Environment variable naming this provider's endpoint. Deliberately distinct
|
||||
* from `$DEEPSEEK_BASE_URL`, which belongs to the chat-completions adapter:
|
||||
* search speaks the Anthropic-compatible Messages API, so one variable cannot
|
||||
* serve both.
|
||||
*/
|
||||
const SEARCH_BASE_URL_ENV = 'DEEPSEEK_SEARCH_BASE_URL'
|
||||
|
||||
/** Register the DeepSeek search provider with `ctx.web`. */
|
||||
export function apply(ctx: Context, config: Config): void {
|
||||
const maxTokens = config.maxTokens ?? DEEPSEEK_DEFAULT_MAX_TOKENS
|
||||
@@ -81,13 +89,14 @@ export function apply(ctx: Context, config: Config): void {
|
||||
resolveApiKey: async () => {
|
||||
const credentials = ctx.get('credentials')
|
||||
if (credentials !== undefined) return (await credentials.resolve(apiKeyEnv))?.value
|
||||
// Without the seam the launching environment is the whole credential
|
||||
// plane — but only that layer, never a discovered project file.
|
||||
const inherited = environmentOf(ctx).getFrom(apiKeyEnv, ['process'])
|
||||
return inherited !== undefined && inherited.value.length > 0 ? inherited.value : undefined
|
||||
// Without the seam the environment is the whole credential plane.
|
||||
const ambient = environmentOf(ctx).getFrom(apiKeyEnv, ['process', 'project-env', 'user-env'])
|
||||
return ambient !== undefined && ambient.value.length > 0 ? ambient.value : undefined
|
||||
},
|
||||
apiKeyEnv,
|
||||
baseURL: config.baseURL ?? DEEPSEEK_DEFAULT_BASE_URL,
|
||||
baseURL: config.baseURL
|
||||
?? environmentOf(ctx).getFrom(SEARCH_BASE_URL_ENV, ['process', 'project-env', 'user-env'])?.value
|
||||
?? DEEPSEEK_DEFAULT_BASE_URL,
|
||||
model: config.model ?? DEEPSEEK_DEFAULT_MODEL,
|
||||
apiVersion: config.apiVersion ?? DEEPSEEK_DEFAULT_API_VERSION,
|
||||
maxTokens,
|
||||
|
||||
@@ -59,10 +59,9 @@ export const Config: z<Config> = z.object({
|
||||
/** Register the Exa search provider with `ctx.web`. */
|
||||
export function apply(ctx: Context, config: Config): void {
|
||||
ctx.web.registerSearchProvider(new ExaSearchProvider({
|
||||
// Only the launching shell and the user's own `.env` may name this key:
|
||||
// a project directory can be written by the model, and a substituted key
|
||||
// would route every request through an account someone else reads.
|
||||
apiKey: config.apiKey ?? environmentOf(ctx).getFrom('EXA_API_KEY', ['process', 'user-env'])?.value ?? '',
|
||||
// Every environment layer may name this key: the product trusts the
|
||||
// project it is launched in, and the managed store is not involved here.
|
||||
apiKey: config.apiKey ?? environmentOf(ctx).getFrom('EXA_API_KEY', ['process', 'project-env', 'user-env'])?.value ?? '',
|
||||
baseURL: config.baseURL ?? EXA_DEFAULT_BASE_URL,
|
||||
searchType: config.searchType ?? EXA_DEFAULT_SEARCH_TYPE,
|
||||
highlightsPerResult: config.highlightsPerResult ?? EXA_DEFAULT_HIGHLIGHTS_PER_RESULT,
|
||||
|
||||
@@ -53,10 +53,9 @@ export const Config: z<Config> = z.object({
|
||||
/** Register the Perplexity search provider with `ctx.web`. */
|
||||
export function apply(ctx: Context, config: Config): void {
|
||||
ctx.web.registerSearchProvider(new PerplexitySearchProvider({
|
||||
// Only the launching shell and the user's own `.env` may name this key:
|
||||
// a project directory can be written by the model, and a substituted key
|
||||
// would route every request through an account someone else reads.
|
||||
apiKey: config.apiKey ?? environmentOf(ctx).getFrom('PERPLEXITY_API_KEY', ['process', 'user-env'])?.value ?? '',
|
||||
// Every environment layer may name this key: the product trusts the
|
||||
// project it is launched in, and the managed store is not involved here.
|
||||
apiKey: config.apiKey ?? environmentOf(ctx).getFrom('PERPLEXITY_API_KEY', ['process', 'project-env', 'user-env'])?.value ?? '',
|
||||
baseURL: config.baseURL ?? PERPLEXITY_DEFAULT_BASE_URL,
|
||||
model: config.model ?? PERPLEXITY_DEFAULT_MODEL,
|
||||
maxTokens: config.maxTokens ?? PERPLEXITY_DEFAULT_MAX_TOKENS,
|
||||
|
||||
Reference in New Issue
Block a user