feat(config)!: one ordering for configuration sources, and a bootstrap deny rule

$DSH_HOME/.env had just become an ordinary environment layer, which left the
harness resolving user-facing values from a flattened process.env that could
no longer say where a value came from. A key stored through the web page
stayed shadowed by an older key in the user's own .env. An endpoint could be
redirected by the project: the invoking directory's .env is materialized like
every other layer, and a base URL decides where a resolved API key is sent, so
a DEEPSEEK_BASE_URL written into a model-editable workspace would send the
user's credential — and the prompts carrying their code — to whatever host
that file named.

Give every user-facing value one ordering, with four kinds of source:

  explicit for this run     per-operation override, CLI argument
  > authored by deployment  --config / --config-replace
  > this launch's shell     inherited process environment
  > product-managed store   settings.yaml, .credentials.yaml
  > discovered file         $DSH_HOME/.env
  > defaults                schema default, shipped base, public default

The domains differ only in which tiers exist. The earlier split — credentials
ranking the environment over the managed file while settings ranked over the
environment — was inconsistent: the distinguishing fact is who authored the
source, not the domain.

packages/util/environment owns an immutable snapshot with per-layer
provenance. 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 reordering can let a project file back into a
decision it was excluded from.

isBootstrapOnly rejects, before anything is materialized, any .env setting a
variable that governs 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 (proxy and CA
variables). The namespace is denied wholesale so a switch added later cannot
become settable by being forgotten, and there is no opt-out.

verify-config-source-ownership keeps both rules: no unregistered process.env
read under packages/*/*/src (26 allowlisted with reasons), and no apiKey,
baseURL, or headers inlined from the environment in shipped Cordis config —
removing those inlines is what makes the deployment tier meaningful.
This commit is contained in:
Yichen Jiang
2026-08-04 16:17:32 +08:00
parent 8ddc53f7a0
commit 0512b12714
59 changed files with 1241 additions and 165 deletions
@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# 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
@@ -0,0 +1,63 @@
# Agent Note: One ordering for configuration sources, and what a discovered file may not decide
Status: implemented
English | [中文](2026-08-04-configuration-source-ownership.zh.md)
## Problem
`$DSH_HOME/.env` had just [become an ordinary environment layer](2026-08-04-credentials-yaml-and-user-environment-layer.md), which left the harness resolving user-facing values from a flattened `process.env` that could no longer say where a value came from. Three consequences followed.
A key stored through the web page stayed shadowed by an older key in the user's own `.env`, because the credential provider compared "the environment" against its file and the environment now included that file. The migration dead end the split was supposed to remove had simply moved.
An endpoint could be redirected by the project. The invoking directory's `.env` is materialized like every other layer, and a base URL decides where a resolved API key is sent — so a `DEEPSEEK_BASE_URL` written into a workspace the model can edit would send the user's own credential, and the prompts carrying their code, to whatever host that file named. Nothing about the flattened view could distinguish that from the operator exporting the same variable.
And `!!js process.env.X` in the shipped composition made the same value reachable twice: once through the entry config and once through whatever ladder its consumer applied, with the winner decided by layer order rather than by what the value means.
## Decision
**One ordering, four kinds of source.** Every user-facing value 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
> 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
```
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`.
**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.
**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.
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.
**`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.
**`verify-config-source-ownership`** keeps both rules: no unregistered `process.env` read under `packages/*/*/src` (26 allowlisted, each with the reason it is a process fact), and no `apiKey`/`baseURL`/`headers` inlined from the environment in shipped Cordis configuration. Removing those inlines is what makes the deployment tier meaningful — with the shipped tree silent on `baseURL`, a present value means a human or deployment set it.
## Consequences
- 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.
- 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.
- 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.
**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.
**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.
**Rank a bootstrap variable below the process layer instead of rejecting it.** Rejected: `PATH` and `NODE_OPTIONS` have no meaningful "loser" behavior — a user who put one in a `.env` believes it applies, and silently ignoring it is the "my setting has no effect" failure this whole series exists to remove.
**Build the snapshot as a three-package capability seam (`environment` / `environment-local` / consumers).** Rejected as premature: the producer runs before Cordis exists and there is no second implementation to select. The repository rule is to not split preemptively.
**Stop materializing the layers into `process.env`.** Deferred, not rejected: it would keep project variables out of child processes entirely, but it silently breaks any user `--config` tree that reads `!!js process.env.X`. The snapshot is already the authority for everything the harness resolves, so this can land later without changing any ladder.
@@ -0,0 +1,65 @@
# Agent Note: 配置来源的统一顺序,以及被发现的文件不得决定什么
Status: implemented
[English](2026-08-04-configuration-source-ownership.md) | 中文
## Problem
`$DSH_HOME/.env` 刚刚[变成普通环境层](2026-08-04-credentials-yaml-and-user-environment-layer.md),这使得 harness 解析面向用户的值时面对的是一个压平的 `process.env`,再也说不清某个值来自哪里。由此产生三个后果。
通过 Web 页面存下的密钥仍然被用户自己 `.env` 里更旧的密钥遮蔽,因为凭据 provider 是拿「环境」与自己的文件比较,而现在环境包含了那个文件。这次拆分本该消除的迁移死路,只是换了个位置。
endpoint 可以被项目重定向。调用目录的 `.env` 和其他层一样会被物化,而 base URL 决定已解析的 API key 发往何处——于是写进模型可编辑工作区的 `DEEPSEEK_BASE_URL`,会把用户自己的凭据、以及承载其代码的提示词,一起发给该文件指定的任何主机。压平的视图无法把这件事和运维显式 export 同一个变量区分开。
而已交付组合里的 `!!js process.env.X` 让同一个值有两条抵达路径:一条经 entry config,一条经消费方各自的 ladder,胜负取决于层序而非这个值的语义。
## Decision
**一条顺序,四类来源。** 每个面向用户的值按同一顺序解析;各领域的差别只在于哪些层存在。
```text
explicit for this run per-operation override, CLI argument
> authored by deployment --config / --config-replace
> 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
```
自上而下依次是:本次运行的显式意图、部署授权、本次启动的 shell、产品受管存储、被发现的文件、默认值。
凭据没有部署层(配置携带引用,从不携带值),也没有默认值层。endpoint 拥有全部层。模型选择只有 CLI、settings 与已交付默认值。此前的方案把 UI 写入的凭据排在环境*之下*,却把 UI 写入的 settings 排在环境*之上*;真正的区分依据不是领域,而是这个文件由谁书写,因此 `.credentials.yaml``settings.yaml` 现在并列,同在启动 shell 之下、同在被发现的 `.env` 之上。
**调用目录的 `.env` 不决定任何凭据与路由。** `EnvironmentSnapshot.getFrom(name, sources)` 只搜索调用方点名的层,省略某层是拒绝而不是降级:适配器请求的是 `['process', 'user-env']`,因此后续任何重新排序都无法让项目文件重新进入一个它被排除在外的决策。对普通变量而言,项目 `.env` 仍然是普通环境层。
**被发现的文件不得决定进程如何启动。** `isBootstrapOnly` 会在加载时、且在物化任何内容之前,拒绝任何设置了下列变量的 `.env`:决定进程如何启动的(`PATH``SHELL``NODE_OPTIONS``LD_PRELOAD` 等)、决定代码或模型可见指令从哪里加载的(整个 `DSH_*` 命名空间、`HOME``XDG_*`),以及决定网络如何抵达与信任的(proxy 与 CA 变量)。匹配不区分大小写,因此 `https_proxy` 不是绕过手段。
被拒绝的是整个 `DSH_*` 命名空间,而不是一份经过审查的子集。harness 自己的开关——权限模式、存放模型可见 skill(技能)的 agents home、内置 skill 根目录——恰恰是敌意项目最想伸手的地方,而后来新增的开关不能因为被遗忘就变得可设置。不设逃生门:逃生门本身总得从某处读取,而任何被发现的文件能设置的东西,就是那个漏洞本身。
**`packages/util/environment` 拥有该快照**,刻意做成 utility 而不是三包能力 seam。快照在 Cordis 启动前就冻结,并由启动器一次性注入,因此不存在需要切换的运行时实现;消费方需要的只是类型和纯函数,而 `util/` 包能提供这些且不必依赖 UI 包。`environmentOf(ctx)` 返回启动器的快照,或者返回只含继承环境的那一层——SDK 宿主或裸 `cordis.yml` 从未发现过任何文件,它那唯一一层确实就是它被启动时的环境,因此同样的受信查询在那里原样继续工作。
**`verify-config-source-ownership`** 守住这两条规则:`packages/*/*/src` 下没有未登记的 `process.env` 读取(26 处在 allowlist 中,各自写明它为何是进程事实),以及已交付 Cordis 配置中不得从环境内联 `apiKey`/`baseURL`/`headers`。删除这些内联正是「部署层」得以成立的原因——已交付配置树对 `baseURL` 保持沉默之后,「有值」就意味着「人或部署设过它」。
## Consequences
- Web 凭据表单现在能压过用户 `.env` 里更旧的密钥;只有在启动 shell 里 export 的密钥才会让它变成只读,诊断信息也会这么说。
-`DSH_*``PATH` 或 proxy 变量的 `.env` 会导致启动失败而不是被应用。把开关放在仓库 `.env` 里的开发者需要改放到 shell——这是一次刻意且响亮的破坏。
- `--config` 不再会被陈旧的 shell endpoint 覆盖,因此部署方可以钉住企业网关。
- 放弃的:调用目录 `.env` 里的 endpoint 或密钥不再生效。按项目切换路由请用 `--config` overlay 或该项目 shell 里的 `export`
- 未解决的:各层仍然会被物化进 `process.env`,因此普通项目变量继续按子进程清洗规则抵达子进程。bootstrap 变量完全不能来自文件,提权路径已封闭;项目 `.env` 为 agent 运行的工具设置诸如 `GIT_SSH_COMMAND` 之类的变量仍然可能,已作为限制记录在该包上。
- Exa 与 Perplexity 仍在加载时捕获密钥,而不是经凭据 seam。它们不再读裸 `process.env`——改为经受信层解析——但把它们改造成按请求经 seam 解析是另一件事。
## Alternatives considered
**沿用方案里分开的两条 ladder(凭据环境压过文件、endpoint settings 压过环境)。** 因其自身的不自洽而否决:两条理由——「export 是本次运行的意图」和「部署方的文件不该被陈旧 shell 改写」——对两个领域同样成立。按*来源由谁书写*排序能同时解释两者,并且把四张表变成一张。
**允许调用目录 `.env` 提供凭据,排在受管存储之下。** 否决:在没有存储密钥时,敌意项目的密钥会被静默使用,而该账号持有者能读到以它发出的每一条提示词。这与 endpoint 规则要防的外泄是同一件事,因此答案也相同。
**审查出一份 `.env` 可设置的 `DSH_*` 白名单。** 否决:每新增一个开关都要重新审查,而遗漏的失败模式是静默的。拒绝整个命名空间是 fail safe。
**把 bootstrap 变量排在 process 层之下,而不是拒绝它。** 否决:`PATH``NODE_OPTIONS` 没有有意义的「输了之后」行为——把它写进 `.env` 的用户认为它生效,而静默忽略正是整个系列要消除的那种「我的设置没有效果」。
**把快照做成三包能力 seam`environment` / `environment-local` / 消费方)。** 作为过早拆分而否决:生产方在 Cordis 存在之前就运行,也没有第二个实现需要选择。仓库规则是不要预先拆分。
**不再把各层物化进 `process.env`。** 延后而非否决:它能让项目变量彻底进不了子进程,但会静默破坏任何读 `!!js process.env.X` 的用户 `--config` 树。快照已经是 harness 解析一切的依据,因此这件事以后落地也不改变任何 ladder。
+1
View File
@@ -52,6 +52,7 @@ External packages that a workspace package resolves at runtime. `scripts/install
| [`clsx`](https://github.com/lukeed/clsx) | MIT |
| [`commander`](https://github.com/tj/commander.js) | MIT |
| [`diff`](https://github.com/kpdecker/jsdiff) | BSD-3-Clause |
| [`dotenv`](https://github.com/motdotla/dotenv) | BSD-2-Clause |
| [`eventsource-parser`](https://github.com/rexxars/eventsource-parser) | MIT |
| [`handlebars`](https://github.com/handlebars-lang/handlebars.js) | MIT |
| [`immer`](https://github.com/immerjs/immer) | MIT |
-1
View File
@@ -360,7 +360,6 @@
name: '@deepseek-ai/dsh-web-search-deepseek'
config:
apiKeyEnv: DEEPSEEK_API_KEY
baseURL: !!js process.env.DEEPSEEK_SEARCH_BASE_URL
- id: tool-web
name: '@deepseek-ai/dsh-tool-web'
-2
View File
@@ -40,8 +40,6 @@
# resolution materializes request defaults before the request header is logged.
- id: llm-deepseek
config:
apiKey: !!js process.env.DEEPSEEK_API_KEY
baseURL: !!js process.env.DEEPSEEK_BASE_URL
thinking: enabled
reasoningEffort: max
-5
View File
@@ -36,11 +36,6 @@
# once the web UI owns the choice per session.
mode: !!js process.env.DSH_TOOLS_MODE
- id: llm-deepseek
config:
apiKey: !!js process.env.DEEPSEEK_API_KEY
baseURL: !!js process.env.DEEPSEEK_BASE_URL
# ── web-only host rows, the transport layer, and the browser roster ─────────
# `dshClient` rows are the browser roster the modules node half scans into
+2 -1
View File
@@ -54,6 +54,7 @@
"@deepseek-ai/dsh-compact-basic": "workspace:^",
"@deepseek-ai/dsh-compact-tool-result-prune": "workspace:^",
"@deepseek-ai/dsh-credentials-local": "workspace:^",
"@deepseek-ai/dsh-environment": "workspace:^",
"@deepseek-ai/dsh-frontend": "workspace:^",
"@deepseek-ai/dsh-fs-local": "workspace:^",
"@deepseek-ai/dsh-fs-policy": "workspace:^",
@@ -74,9 +75,9 @@
"@deepseek-ai/dsh-paths": "workspace:^",
"@deepseek-ai/dsh-permission": "workspace:^",
"@deepseek-ai/dsh-plan-mode": "workspace:^",
"@deepseek-ai/dsh-repeat-tool-guard": "workspace:^",
"@deepseek-ai/dsh-pty": "workspace:^",
"@deepseek-ai/dsh-pty-local": "workspace:^",
"@deepseek-ai/dsh-repeat-tool-guard": "workspace:^",
"@deepseek-ai/dsh-repository-plugin": "workspace:^",
"@deepseek-ai/dsh-sandbox-local": "workspace:^",
"@deepseek-ai/dsh-sandbox-policy": "workspace:^",
+6
View File
@@ -14,6 +14,7 @@ import { createRequire } from 'node:module'
import { networkInterfaces } from 'node:os'
import { resolve } from 'node:path'
import { Context } from 'cordis'
import { DSH_ENVIRONMENT_KEY, type EnvironmentSnapshot } from '@deepseek-ai/dsh-environment'
import type { PatchOptions } from '@cordisjs/plugin-include'
import yaml from 'js-yaml'
import { boot, installFailLoud, loadOverlayPatches } from '@deepseek-ai/dsh-app-boot'
@@ -102,6 +103,8 @@ const includeYamlSchema = yaml.JSON_SCHEMA.extend(jsExprType)
/** Constructor facts for one dsh invocation over the shared composition (argv already parsed by the surface bin). */
export interface AppCLIEntryOptions {
/** This run's frozen environment, provided to the tree before any config entry mounts. */
environment: EnvironmentSnapshot
/** Absolute path of the shared base config the Loader includes. */
configPath: string
/**
@@ -255,6 +258,9 @@ export class AppCLIEntry {
...this.patches,
]
this.ctx = await boot('dsh', resolve(this.bootConfigPath()), patches, async (ctx) => {
// Before any config-tree entry mounts, so a plugin that resolves a
// user-facing value at construction already sees this run's layers.
ctx.provide(DSH_ENVIRONMENT_KEY, this.options.environment)
await this.options.prepare?.(ctx)
if (this.options.dev) await ctx.loader.create({ name: '@deepseek-ai/dsh-client-hmr' })
})
+9 -6
View File
@@ -24,24 +24,27 @@ function readVersion(): string {
return typeof manifest.version === 'string' ? manifest.version : '0.0.0'
}
loadLayeredEnv('dsh')
const environment = loadLayeredEnv('dsh')
// The env opt-in is read at the process boundary; `1` is the documented value.
const invocation = parseDshArgs(process.argv.slice(2), readVersion(), process.env.DSH_EXPERIMENTAL === '1')
switch (invocation.mode) {
case 'web': {
const { runWeb } = await import('./web.ts')
await runWeb(invocation.host, invocation.port, invocation.dev, invocation.workspaceRoot, invocation.trustedHosts, invocation.config)
await runWeb(
environment, invocation.host, invocation.port, invocation.dev,
invocation.workspaceRoot, invocation.trustedHosts, invocation.config,
)
break
}
case 'headless': {
const { runHeadless } = await import('./headless.ts')
await runHeadless(invocation.prompt, invocation.config, invocation.configReplace)
await runHeadless(environment, invocation.prompt, invocation.config, invocation.configReplace)
break
}
case 'tui': {
const { runTui } = await import('./tui.ts')
await runTui(invocation.config, invocation.resume, undefined, undefined, invocation.configReplace)
await runTui(environment, invocation.config, invocation.resume, undefined, undefined, invocation.configReplace)
break
}
case 'dump-config': {
@@ -51,12 +54,12 @@ switch (invocation.mode) {
}
case 'meta': {
const { runTui, SOURCE_ROOT } = await import('./tui.ts')
await runTui(invocation.config, undefined, SOURCE_ROOT, undefined, invocation.configReplace)
await runTui(environment, invocation.config, undefined, SOURCE_ROOT, undefined, invocation.configReplace)
break
}
case 'upgrade': {
const { runTui } = await import('./tui.ts')
await runTui(invocation.config, undefined, undefined, `dsh-${invocation.mode}`, invocation.configReplace)
await runTui(environment, invocation.config, undefined, undefined, `dsh-${invocation.mode}`, invocation.configReplace)
break
}
default:
+6 -1
View File
@@ -10,6 +10,7 @@
import { fileURLToPath } from 'node:url'
import { resolveConfigPath } from '@deepseek-ai/dsh-app-boot'
import type { EnvironmentSnapshot } from '@deepseek-ai/dsh-environment'
import { InProcessApiClient, toFetchHandler } from '@deepseek-ai/dsh-host-apiproxy'
import type { MuxFrame } from '@deepseek-ai/dsh-host-apiproxy/api'
import type { RpcRequest, RpcResponse } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
@@ -71,15 +72,19 @@ async function consumeUntilTurnEnd(frames: AsyncIterable<RpcRequest<MuxFrame>>,
* Run one headless turn for `task` and exit (completed → 0, else 1). The task
* is the non-empty prompt the argument adapter parsed from `-p`/`--prompt`
* (the adapter rejects an empty task, so no guard is needed here).
* @param environment - this run's frozen environment snapshot.
* @param task - the prompt text for the single turn.
* @param config - a `--config` overlay applied over the shipped composition, or `undefined`.
* @param configReplace - a `--config-replace` tree booted instead of the
* shipped composition, or `undefined`. It must mount a webserver row: this
* surface reaches its own agent over the same HTTP gateway the browser uses.
*/
export async function runHeadless(task: string, config?: string, configReplace?: string): Promise<void> {
export async function runHeadless(
environment: EnvironmentSnapshot, task: string, config?: string, configReplace?: string,
): Promise<void> {
// A missing DEEPSEEK_API_KEY throws here (plugin load is fail-loud, uncaught by design).
const entry = new AppCLIEntry({
environment,
configPath: fileURLToPath(new URL('../config/base.cordis.yml', import.meta.url)),
overlayPath: fileURLToPath(new URL('../config/web.cordis.yml', import.meta.url)),
...config !== undefined && { extraOverlayPath: resolveConfigPath(config, undefined) },
+5
View File
@@ -29,6 +29,7 @@ import {
resolveConfigPath,
} from '@deepseek-ai/dsh-app-boot'
import { resolveDshHome } from '@deepseek-ai/dsh-paths'
import { DSH_ENVIRONMENT_KEY, type EnvironmentSnapshot } from '@deepseek-ai/dsh-environment'
import type { PatchOptions } from '@cordisjs/plugin-include'
import { SessionId } from '@deepseek-ai/dsh-session'
import { configHasTelemetryRow, resolveTelemetryPatch } from './app-cli-entry.ts'
@@ -78,6 +79,8 @@ export const SOURCE_ROOT = fileURLToPath(new URL('../../..', import.meta.url))
the CLI PTY smoke drives this path end to end, --config overlay included */
/**
* Run the interactive TUI from the invoking directory.
* @param environment - this run's frozen environment snapshot, provided to the
* tree before any config entry mounts.
* @param config - an overlay patch list applied over the shared base and the
* TUI overlay, or `undefined` for the shipped composition alone; already
* parsed from `--config`.
@@ -97,6 +100,7 @@ export const SOURCE_ROOT = fileURLToPath(new URL('../../..', import.meta.url))
* already parsed from `--config-replace`.
*/
export async function runTui(
environment: EnvironmentSnapshot,
config: string | undefined,
resumeSessionId: string | undefined,
workspace?: string,
@@ -225,6 +229,7 @@ export async function runTui(
// Runs after the Loader installs and before any config-tree entry mounts,
// so the fail-loud release hook can reach the tree for the whole window in
// which an entry may reject.
hostCtx.provide(DSH_ENVIRONMENT_KEY, environment)
app.current = hostCtx
// The launcher owns session identity and the exit line: a config-mounted
// app bundle reads both from these slots, so no cordis.yml key can drop
+4
View File
@@ -12,6 +12,7 @@ import { addHarnessSourceSection, resolveConfigPath } from '@deepseek-ai/dsh-app
import type {} from '@deepseek-ai/dsh-host-webserver'
import type {} from '@deepseek-ai/dsh-system-prompt'
import type {} from '@deepseek-ai/dsh-tool-bash'
import type { EnvironmentSnapshot } from '@deepseek-ai/dsh-environment'
import { AppCLIEntry } from './app-cli-entry.ts'
// The shared core every `dsh` surface mounts, plus this surface's overlay over it.
@@ -85,6 +86,7 @@ export function prepareWebRuntimeContext(ctx: Context, sourceRoot: string, mode:
/**
* Serve the browser UI from the shipped config tree. `host`/`port` are passed
* through only when the flag was given; absent, the shipped Web overlay value stands.
* @param environment - this run's frozen environment snapshot.
* @param host - the bind host, or `undefined` to keep the config default.
* @param port - the listen port (`0` requests an OS-assigned port), or `undefined` to keep the config default.
* @param dev - mount the client HMR receiver; `pnpm run dev:web` separately rebuilds watched plugin bundles.
@@ -95,6 +97,7 @@ export function prepareWebRuntimeContext(ctx: Context, sourceRoot: string, mode:
* personal overlay; already parsed from `--config`.
*/
export async function runWeb(
environment: EnvironmentSnapshot,
host: string | undefined,
port: number | undefined,
dev: boolean,
@@ -104,6 +107,7 @@ export async function runWeb(
): Promise<void> {
const mode: WebMode = dev ? 'development' : 'production'
const entry = new AppCLIEntry({
environment,
configPath: BASE_CONFIG,
overlayPath: WEB_OVERLAY,
...config !== undefined && { extraOverlayPath: resolveConfigPath(config, undefined) },
+6 -6
View File
@@ -672,9 +672,9 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => {
// layering underneath it. The named file patches the `tui` row — a row the
// SURFACE OVERLAY inserted, not one the base declares — proving a later
// patch list reaches a row an earlier one inserted. The `!!js` expression
// renders both halves of the layering in one line: `DSH_LAYER_WELCOME` is
// renders both halves of the layering in one line: `OVERLAY_LAYER_WELCOME` is
// set by BOTH .env files and must render the project value, while
// `DSH_USER_ONLY` exists only in the harness home's .env and must still
// `OVERLAY_USER_ONLY` exists only in the harness home's .env and must still
// arrive. Credentials are not part of this: they live in
// `.credentials.yaml`, which is never hoisted into `process.env`.
const output = await smoke({
@@ -683,17 +683,17 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => {
binScript: dshBinScript,
configArgs: ['--config', '.dsh/config.yaml'],
prepare: seedWorkspace({
workspace: { '.env': 'DSH_LAYER_WELCOME=PROJECT WINS.\n' },
workspace: { '.env': 'OVERLAY_LAYER_WELCOME=PROJECT WINS.\n' },
harnessHome: {
'.env': 'DSH_LAYER_WELCOME=USER LAYER LOST.\nDSH_USER_ONLY=USER LAYER LOADED.\n',
'.env': 'OVERLAY_LAYER_WELCOME=USER LAYER LOST.\nOVERLAY_USER_ONLY=USER LAYER LOADED.\n',
'config.yaml': [
'- id: workspace-context',
' disabled: true',
'- id: tui',
' config:',
" sessionId: !!js configuredAgentIdentities?.main?.id ?? 'main'",
' welcome: !!js "(process.env.DSH_LAYER_WELCOME ?? \'PROJECT LAYER MISSING.\')'
+ ' + \' \' + (process.env.DSH_USER_ONLY ?? \'USER LAYER MISSING.\')"',
' welcome: !!js "(process.env.OVERLAY_LAYER_WELCOME ?? \'PROJECT LAYER MISSING.\')'
+ ' + \' \' + (process.env.OVERLAY_USER_ONLY ?? \'USER LAYER MISSING.\')"',
'',
].join('\n'),
},
+3
View File
@@ -29,6 +29,9 @@
{
"path": "../../packages/ui/tui"
},
{
"path": "../../packages/util/environment"
},
{
"path": "../../packages/util/paths"
},
+7 -6
View File
@@ -423,7 +423,7 @@ export interface Config {
}
```
Source: [`packages/credentials/credentials-local/src/index.ts:35`](../packages/credentials/credentials-local/src/index.ts)
Source: [`packages/credentials/credentials-local/src/index.ts:54`](../packages/credentials/credentials-local/src/index.ts)
## `@deepseek-ai/dsh-fs-local`
@@ -632,7 +632,7 @@ export interface Config {
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, then the public API. */
/** Endpoint base; falls back to $DEEPSEEK_BASE_URL from a trusted environment layer, then the public API. */
baseURL?: string
/** Deployment thinking policy; `disabled` limits every conversation request to `off`. */
thinking?: 'enabled' | 'disabled'
@@ -665,7 +665,7 @@ export interface DeepSeekCatalogModel {
Depends on: [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts)
Source: [`packages/llm/llm-deepseek/src/index.ts:60`](../packages/llm/llm-deepseek/src/index.ts)
Source: [`packages/llm/llm-deepseek/src/index.ts:61`](../packages/llm/llm-deepseek/src/index.ts)
## `@deepseek-ai/dsh-llm-pi-ai`
@@ -2229,7 +2229,7 @@ export interface Config {
}
```
Source: [`packages/web/web-search-deepseek/src/index.ts:43`](../packages/web/web-search-deepseek/src/index.ts)
Source: [`packages/web/web-search-deepseek/src/index.ts:44`](../packages/web/web-search-deepseek/src/index.ts)
## `@deepseek-ai/dsh-web-search-exa`
@@ -2251,7 +2251,7 @@ export interface Config {
}
```
Source: [`packages/web/web-search-exa/src/index.ts:37`](../packages/web/web-search-exa/src/index.ts)
Source: [`packages/web/web-search-exa/src/index.ts:38`](../packages/web/web-search-exa/src/index.ts)
## `@deepseek-ai/dsh-web-search-perplexity`
@@ -2273,7 +2273,7 @@ export interface Config {
}
```
Source: [`packages/web/web-search-perplexity/src/index.ts:31`](../packages/web/web-search-perplexity/src/index.ts)
Source: [`packages/web/web-search-perplexity/src/index.ts:32`](../packages/web/web-search-perplexity/src/index.ts)
## `@deepseek-ai/dsh-workflow-workerthread`
@@ -2417,6 +2417,7 @@ Imported as libraries by other packages; a `cordis.yml` cannot load them.
- `@deepseek-ai/dsh-client-ui-slots` ([`packages/client/ui-slots/src/index.ts`](../packages/client/ui-slots/src/index.ts))
- `@deepseek-ai/dsh-client-web` ([`packages/client/web/src/index.ts`](../packages/client/web/src/index.ts))
- `@deepseek-ai/dsh-client-web-react` ([`packages/client/web-react/src/index.ts`](../packages/client/web-react/src/index.ts))
- `@deepseek-ai/dsh-environment` ([`packages/util/environment/src/index.ts`](../packages/util/environment/src/index.ts))
- `@deepseek-ai/dsh-helper` ([`packages/sdk/helper/src/index.ts`](../packages/sdk/helper/src/index.ts))
- `@deepseek-ai/dsh-hook-protocol` ([`packages/hooks/hook-protocol/src/index.ts`](../packages/hooks/hook-protocol/src/index.ts))
- `@deepseek-ai/dsh-jsonrpc-demo` ([`packages/examples/jsonrpc-demo/src/index.ts`](../packages/examples/jsonrpc-demo/src/index.ts))
-2
View File
@@ -9,8 +9,6 @@
- id: llm-deepseek
name: '@deepseek-ai/dsh-llm-deepseek'
config:
apiKey: !!js process.env.DEEPSEEK_API_KEY
baseURL: !!js process.env.DEEPSEEK_BASE_URL
thinking: enabled
reasoningEffort: max
models:
-2
View File
@@ -13,8 +13,6 @@
- id: llm-deepseek
name: '@deepseek-ai/dsh-llm-deepseek'
config:
apiKey: !!js process.env.DEEPSEEK_API_KEY
baseURL: !!js process.env.DEEPSEEK_BASE_URL
thinking: enabled
reasoningEffort: max
retryPolicy:
-2
View File
@@ -12,8 +12,6 @@
- id: llm-deepseek
name: '@deepseek-ai/dsh-llm-deepseek'
config:
apiKey: !!js process.env.DEEPSEEK_API_KEY
baseURL: !!js process.env.DEEPSEEK_BASE_URL
thinking: enabled
reasoningEffort: max
@@ -8,8 +8,6 @@
- id: llm-deepseek
name: '@deepseek-ai/dsh-llm-deepseek'
config:
apiKey: !!js process.env.DEEPSEEK_API_KEY
baseURL: !!js process.env.DEEPSEEK_BASE_URL
- id: sandbox
name: '@deepseek-ai/dsh-sandbox-local'
+79 -78
View File
@@ -17,101 +17,102 @@
"build": "npm run build:lib && npm run build:web",
"build:lib": "tsc -b && tsdown",
"build:web": "pnpm --filter @deepseek-ai/dsh-frontend run build",
"clean": "tsx scripts/clean.ts",
"change-scope": "tsx scripts/change-scope.ts",
"typecheck": "tsc -b",
"lint": "tsx scripts/run-oxlint.ts .",
"lint:fix": "eslint --config eslint.format.config.mjs --fix . && tsx scripts/run-oxlint.ts . --fix",
"duplication": "jscpd --config .jscpd.json packages scripts",
"test": "vitest run",
"test:coverage": "vitest run --coverage",
"test:e2e": "vitest run --config vitest.e2e.config.ts",
"test:snapshot": "vitest run --config vitest.snapshot.config.ts",
"test:snapshot:record": "DSH_SNAPSHOT=record vitest run --config vitest.snapshot.config.ts --update",
"test:snapshot:refresh": "DSH_SNAPSHOT=refresh vitest run --config vitest.snapshot.config.ts",
"migrate:packed-session-fixtures": "tsx scripts/migrate-packed-session-fixtures.ts",
"test:web": "npm run build && npm run test:web:built",
"test:web:refresh": "npm run build && DSH_SNAPSHOT=refresh vitest run --config vitest.web.config.ts",
"test:web:built": "vitest run --config vitest.web.config.ts",
"test:gui": "vitest run packages/client packages/host",
"check:all": "tsx scripts/run-gates.ts check-all",
"check:ci": "tsx scripts/run-gates.ts ci-primary",
"check:ci:linux-primary": "tsx scripts/run-gates.ts ci-linux-primary",
"check:ci:static": "tsx scripts/run-gates.ts ci-static",
"check:ci:lint": "tsx scripts/run-gates.ts ci-lint",
"check:ci:coverage": "tsx scripts/run-gates.ts ci-coverage",
"check:ci:snapshot": "tsx scripts/run-gates.ts ci-snapshot",
"check:ci:artifacts": "tsx scripts/run-gates.ts ci-artifacts",
"check:ci:consumers": "tsx scripts/run-gates.ts ci-consumers",
"check:ci:coverage": "tsx scripts/run-gates.ts ci-coverage",
"check:ci:lint": "tsx scripts/run-gates.ts ci-lint",
"check:ci:linux-primary": "tsx scripts/run-gates.ts ci-linux-primary",
"check:ci:snapshot": "tsx scripts/run-gates.ts ci-snapshot",
"check:ci:static": "tsx scripts/run-gates.ts ci-static",
"check:ci:windows-blocking": "tsx scripts/run-gates.ts ci-windows-blocking",
"check:ci:windows-complete": "tsx scripts/run-gates.ts ci-windows-complete",
"check:ci:windows-observational": "tsx scripts/run-gates.ts ci-windows-observational",
"check:windows-wine": "bash scripts/wine-windows-gates.sh",
"check:node-compat": "tsx scripts/run-gates.ts node-compat",
"knip": "knip --treat-config-hints-as-errors",
"publint": "tsx scripts/publint-all.ts",
"check:windows-wine": "bash scripts/wine-windows-gates.sh",
"clean": "tsx scripts/clean.ts",
"constraints": "tsx scripts/check-workspace-constraints.ts",
"demo:acp": "node --import tsx packages/examples/acp-demo/src/bin.ts --config examples/acp-agent/cordis.yml",
"demo:code-mode": "node scripts/demo-code-mode.mjs",
"demo:cordis": "node scripts/demo-cordis.mjs",
"demo:headless": "node --import tsx packages/examples/cli-demo/src/bin.ts --config examples/headless-agent/cordis.yml",
"demo:tui": "node --import tsx/esm apps/cli/src/bin.ts",
"demo:web": "npm run build && node --import tsx/esm apps/cli/src/bin.ts web",
"dev:web": "tsx scripts/dev-web.ts --poll",
"doc-sync": "tsx scripts/run-gates.ts doc-sync",
"doc-typecheck": "tsx scripts/doc-typecheck.ts",
"verify-md-wrap": "tsx scripts/verify-md-wrap.ts",
"verify-md-links": "tsx scripts/verify-md-links.ts",
"verify-doc-refs": "tsx scripts/verify-doc-refs.ts",
"verify-package-paths": "tsx scripts/verify-package-paths.ts",
"verify-package-invariants": "tsx scripts/verify-package-invariants.ts",
"verify-built-package-invariants": "node scripts/verify-built-package-invariants.mjs",
"verify-package-readme-model-experience": "tsx scripts/verify-package-readme-model-experience.ts",
"verify-mermaid": "tsx scripts/verify-mermaid.ts",
"docs:build": "pnpm --filter @deepseek-ai/website run build",
"docs:build:mpa": "pnpm --filter @deepseek-ai/website exec vitepress build . --mpa",
"docs:check": "pnpm exec vitest run scripts/project-doc-site.spec.ts && pnpm run docs:build",
"docs:dev": "pnpm --filter @deepseek-ai/website run dev",
"docs:preview": "pnpm --filter @deepseek-ai/website run preview",
"dsh": "node --import tsx/esm apps/cli/src/bin.ts",
"duplication": "jscpd --config .jscpd.json packages scripts",
"gen-config-catalog": "tsx scripts/gen-config-catalog.ts",
"gen-cordis-api": "tsx scripts/gen-cordis-api.ts",
"gen-cordis-catalog": "tsx scripts/gen-cordis-catalog.ts",
"gen-doc-graphs": "tsx scripts/gen-doc-graphs.ts",
"gen-module-graph": "tsx scripts/gen-module-graph.ts",
"gen-persistence-catalog": "tsx scripts/gen-persistence-catalog.ts",
"gen-scoped-events": "tsx scripts/gen-scoped-events.ts",
"gen-third-party-notices": "tsx scripts/gen-third-party-notices.ts",
"gen-tool-catalog": "tsx scripts/gen-tool-catalog.ts",
"gen-translation-brief": "tsx scripts/gen-translation-brief.ts",
"hygiene": "pnpm run knip && pnpm run publint && pnpm run constraints && pnpm run verify-package-invariants && pnpm run verify-built-package-invariants && pnpm run verify-cordis-config && pnpm run verify-node-next-types && pnpm run verify-runtime-closure && pnpm run verify-vendored-links",
"knip": "knip --treat-config-hints-as-errors",
"lint": "tsx scripts/run-oxlint.ts .",
"lint:fix": "eslint --config eslint.format.config.mjs --fix . && tsx scripts/run-oxlint.ts . --fix",
"migrate:packed-session-fixtures": "tsx scripts/migrate-packed-session-fixtures.ts",
"mock:llm": "node --import tsx packages/support/llm-mock-server/src/bin.ts",
"postinstall": "node scripts/install-lefthook.mjs",
"publint": "tsx scripts/publint-all.ts",
"test": "vitest run",
"test:coverage": "vitest run --coverage",
"test:e2e": "vitest run --config vitest.e2e.config.ts",
"test:gui": "vitest run packages/client packages/host",
"test:snapshot": "vitest run --config vitest.snapshot.config.ts",
"test:snapshot:record": "DSH_SNAPSHOT=record vitest run --config vitest.snapshot.config.ts --update",
"test:snapshot:refresh": "DSH_SNAPSHOT=refresh vitest run --config vitest.snapshot.config.ts",
"test:web": "npm run build && npm run test:web:built",
"test:web:built": "vitest run --config vitest.web.config.ts",
"test:web:refresh": "npm run build && DSH_SNAPSHOT=refresh vitest run --config vitest.web.config.ts",
"typecheck": "tsc -b",
"verify-agent-note-classification": "tsx scripts/verify-agent-note-classification.ts",
"verify-agent-note-format": "tsx scripts/verify-agent-note-format.ts",
"verify-archived-agent-notes": "tsx scripts/verify-archived-agent-notes.ts",
"verify-type-equiv": "tsx scripts/verify-type-equiv.ts",
"verify-translation-prompt": "tsx scripts/verify-translation-prompt.ts",
"verify-translation-pairing": "tsx scripts/verify-translation-pairing.ts",
"gen-translation-brief": "tsx scripts/gen-translation-brief.ts",
"verify-doc-budgets": "tsx scripts/verify-doc-budgets.ts",
"docs:dev": "pnpm --filter @deepseek-ai/website run dev",
"docs:build": "pnpm --filter @deepseek-ai/website run build",
"docs:build:mpa": "pnpm --filter @deepseek-ai/website exec vitepress build . --mpa",
"docs:preview": "pnpm --filter @deepseek-ai/website run preview",
"docs:check": "pnpm exec vitest run scripts/project-doc-site.spec.ts && pnpm run docs:build",
"website:dev": "pnpm run docs:dev",
"website:build": "pnpm run docs:build",
"verify-package-readme-limitations": "tsx scripts/verify-package-readme-limitations.ts",
"verify-node-next-types": "tsx scripts/verify-node-next-types.ts",
"verify-runtime-closure": "tsx scripts/verify-runtime-closure.ts",
"verify-vendored-links": "tsx scripts/verify-vendored-links.ts",
"verify-cordis-config": "tsx scripts/verify-cordis-config.ts",
"verify-built-package-invariants": "node scripts/verify-built-package-invariants.mjs",
"verify-client-domain-graph": "tsx scripts/verify-client-domain-graph.ts",
"gen-cordis-catalog": "tsx scripts/gen-cordis-catalog.ts",
"verify-cordis-catalog": "tsx scripts/gen-cordis-catalog.ts --check",
"gen-cordis-api": "tsx scripts/gen-cordis-api.ts",
"verify-cordis-api": "tsx scripts/gen-cordis-api.ts --check",
"verify-export-jsdoc": "tsx scripts/verify-export-jsdoc.ts",
"gen-tool-catalog": "tsx scripts/gen-tool-catalog.ts",
"verify-tool-catalog": "tsx scripts/gen-tool-catalog.ts --check",
"gen-config-catalog": "tsx scripts/gen-config-catalog.ts",
"verify-config-catalog": "tsx scripts/gen-config-catalog.ts --check",
"gen-doc-graphs": "tsx scripts/gen-doc-graphs.ts",
"verify-config-source-ownership": "tsx scripts/verify-config-source-ownership.ts",
"verify-cordis-api": "tsx scripts/gen-cordis-api.ts --check",
"verify-cordis-catalog": "tsx scripts/gen-cordis-catalog.ts --check",
"verify-cordis-config": "tsx scripts/verify-cordis-config.ts",
"verify-doc-budgets": "tsx scripts/verify-doc-budgets.ts",
"verify-doc-graphs": "tsx scripts/gen-doc-graphs.ts --check",
"gen-persistence-catalog": "tsx scripts/gen-persistence-catalog.ts",
"verify-persistence-catalog": "tsx scripts/gen-persistence-catalog.ts --check",
"gen-third-party-notices": "tsx scripts/gen-third-party-notices.ts",
"verify-third-party-notices": "tsx scripts/gen-third-party-notices.ts --check",
"gen-module-graph": "tsx scripts/gen-module-graph.ts",
"gen-scoped-events": "tsx scripts/gen-scoped-events.ts",
"verify-scoped-events": "tsx scripts/gen-scoped-events.ts --check",
"verify-doc-refs": "tsx scripts/verify-doc-refs.ts",
"verify-export-jsdoc": "tsx scripts/verify-export-jsdoc.ts",
"verify-md-links": "tsx scripts/verify-md-links.ts",
"verify-md-wrap": "tsx scripts/verify-md-wrap.ts",
"verify-mermaid": "tsx scripts/verify-mermaid.ts",
"verify-module-graph": "tsx scripts/gen-module-graph.ts --check",
"constraints": "tsx scripts/check-workspace-constraints.ts",
"doc-sync": "tsx scripts/run-gates.ts doc-sync",
"hygiene": "pnpm run knip && pnpm run publint && pnpm run constraints && pnpm run verify-package-invariants && pnpm run verify-built-package-invariants && pnpm run verify-cordis-config && pnpm run verify-node-next-types && pnpm run verify-runtime-closure && pnpm run verify-vendored-links",
"dsh": "node --import tsx/esm apps/cli/src/bin.ts",
"demo:headless": "node --import tsx packages/examples/cli-demo/src/bin.ts --config examples/headless-agent/cordis.yml",
"demo:tui": "node --import tsx/esm apps/cli/src/bin.ts",
"demo:code-mode": "node scripts/demo-code-mode.mjs",
"demo:cordis": "node scripts/demo-cordis.mjs",
"demo:acp": "node --import tsx packages/examples/acp-demo/src/bin.ts --config examples/acp-agent/cordis.yml",
"demo:web": "npm run build && node --import tsx/esm apps/cli/src/bin.ts web",
"mock:llm": "node --import tsx packages/support/llm-mock-server/src/bin.ts",
"dev:web": "tsx scripts/dev-web.ts --poll",
"postinstall": "node scripts/install-lefthook.mjs"
"verify-node-next-types": "tsx scripts/verify-node-next-types.ts",
"verify-package-invariants": "tsx scripts/verify-package-invariants.ts",
"verify-package-paths": "tsx scripts/verify-package-paths.ts",
"verify-package-readme-limitations": "tsx scripts/verify-package-readme-limitations.ts",
"verify-package-readme-model-experience": "tsx scripts/verify-package-readme-model-experience.ts",
"verify-persistence-catalog": "tsx scripts/gen-persistence-catalog.ts --check",
"verify-runtime-closure": "tsx scripts/verify-runtime-closure.ts",
"verify-scoped-events": "tsx scripts/gen-scoped-events.ts --check",
"verify-third-party-notices": "tsx scripts/gen-third-party-notices.ts --check",
"verify-tool-catalog": "tsx scripts/gen-tool-catalog.ts --check",
"verify-translation-pairing": "tsx scripts/verify-translation-pairing.ts",
"verify-translation-prompt": "tsx scripts/verify-translation-prompt.ts",
"verify-type-equiv": "tsx scripts/verify-type-equiv.ts",
"verify-vendored-links": "tsx scripts/verify-vendored-links.ts",
"website:build": "pnpm run docs:build",
"website:dev": "pnpm run docs:dev"
},
"devDependencies": {
"@agentclientprotocol/sdk": "0.25.1",
@@ -29,6 +29,7 @@
"peerDependencies": {
"@deepseek-ai/dsh-atomic-write": "^0.0.1",
"@deepseek-ai/dsh-credentials": "^0.0.1",
"@deepseek-ai/dsh-environment": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-paths": "^0.0.1",
"cordis": "^4.0.0-rc.7"
@@ -41,6 +42,7 @@
"devDependencies": {
"@deepseek-ai/dsh-atomic-write": "workspace:^",
"@deepseek-ai/dsh-credentials": "workspace:^",
"@deepseek-ai/dsh-environment": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-paths": "workspace:^",
"cordis": "^4.0.0-rc.7"
@@ -1,12 +1,29 @@
/**
* File-backed credentials provider layering the live process environment over
* a `$DSH_HOME/.credentials.yaml` document. The environment is authoritative
* and read-only (a launch-time override must win, and must be visibly
* read-only rather than silently shadow writes); 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 — comments and
* the formatting of every untouched entry survive — external edits
* hot-publish through the seam, and each reload replaces the snapshot
* File-backed credentials provider over `$DSH_HOME/.credentials.yaml`, layered
* 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)
* ```
*
* The inherited environment wins because `DEEPSEEK_API_KEY=… dsh`, a CI
* secret, or a container `-e` is this run's explicit intent; it cannot be
* edited from inside, so it must be *visibly* read-only rather than silently
* shadow writes. Everything below it loses to the managed store, so a key the
* 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 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
* — comments and the formatting of every untouched entry survive — external
* edits hot-publish through the seam, and each reload replaces the snapshot
* wholesale so a deleted entry never lingers in memory.
*
* The document holds nothing but credentials, which is why it is a strict
@@ -25,8 +42,10 @@ import { dirname, join, resolve } from 'node:path'
import { Document, parseDocument } from 'yaml'
import { withFileLock, writeFileAtomic } from '@deepseek-ai/dsh-atomic-write'
import { resolveDshHome } from '@deepseek-ai/dsh-paths'
import { environmentOf } from '@deepseek-ai/dsh-environment'
import { Credentials, credentialRef } from '@deepseek-ai/dsh-credentials'
import type { CredentialInfo, CredentialRef, ResolvedCredential } from '@deepseek-ai/dsh-credentials'
import type { EnvironmentEntry } from '@deepseek-ai/dsh-environment'
/** Basename of the credentials document inside the harness home. */
export const CREDENTIALS_FILENAME = '.credentials.yaml'
@@ -169,6 +188,18 @@ export class CredentialsLocal extends Credentials {
this.spec = resolveSpec(config)
}
/** The inherited-environment value for a reference, or `undefined` when empty or unset. */
private inherited(ref: CredentialRef): string | undefined {
const entry = environmentOf(this.ctx).getFrom(ref, ['process'])
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'])
return entry !== undefined && entry.value.length > 0 ? entry : undefined
}
async* [Service.init](): AsyncGenerator<() => Promise<void> | void, void, void> {
yield async () => {
// Drain: refuse new operations, then settle the queued ones so disposal
@@ -214,20 +245,27 @@ export class CredentialsLocal extends Credentials {
}
override resolve(ref: CredentialRef): Promise<ResolvedCredential | undefined> {
const env = process.env[ref]
if (env !== undefined && env.length > 0) return Promise.resolve({ value: env, source: 'env' })
const inherited = this.inherited(ref)
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' })
return Promise.resolve(undefined)
}
override describe(ref: CredentialRef): Promise<CredentialInfo> {
const env = process.env[ref]
if (env !== undefined && env.length > 0) {
// Only the inherited environment is unwritable: it is the one layer this
// process cannot edit. A user `.env` value is writable in the sense that
// matters — storing a key replaces it as the effective one.
if (this.inherited(ref) !== undefined) {
return Promise.resolve({ configured: true, source: 'env', writable: false })
}
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 })
}
return Promise.resolve({ configured: false, writable: true })
}
@@ -303,13 +341,16 @@ export class CredentialsLocal extends Credentials {
})
}
/** Reject a write the live environment would shadow into apparent no-effect. */
/**
* Reject a write the inherited environment would shadow into apparent
* no-effect. Only that layer can shadow a write: everything else this
* provider resolves ranks below the document being written.
*/
private assertUnshadowed(ref: CredentialRef, verb: 'set' | 'unset'): void {
const env = process.env[ref]
if (env !== undefined && env.length > 0) {
if (this.inherited(ref) !== undefined) {
throw new Error(
`credentials-local: "${ref}" is supplied read-only by the process environment, so ${verb} would be`
+ ' shadowed; unset it in the launching environment (or in a loaded .env) instead',
`credentials-local: "${ref}" is supplied read-only by the launching environment, so ${verb} would be`
+ ' shadowed; unset it in the shell you start dsh from instead',
)
}
}
@@ -4,6 +4,7 @@ import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { credentialRef } from '@deepseek-ai/dsh-credentials'
import { createEnvironmentSnapshot, DSH_ENVIRONMENT_KEY } from '@deepseek-ai/dsh-environment'
import type { CredentialRef } from '@deepseek-ai/dsh-credentials'
import { CredentialsLocal, resolveSpec } from '../src/index.ts'
@@ -100,6 +101,74 @@ describe('layering and reads', () => {
})
})
describe('layer ladder', () => {
// inherited process env > .credentials.yaml > $DSH_HOME/.env, and the
// invoking directory's .env supplies no credential at all.
async function bootLayered(
path: string,
layers: Parameters<typeof createEnvironmentSnapshot>[0],
): Promise<Context> {
const ctx = new Context()
ctx.provide(DSH_ENVIRONMENT_KEY, createEnvironmentSnapshot(layers))
const fiber = ctx.plugin(CredentialsLocal, { path, watch: false })
cleanups.push(async () => { await fiber.dispose() })
await fiber
return ctx
}
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')
const ctx = await bootLayered(path, [
{ source: 'process', values: {} },
{ source: 'user-env', path: '/home/.dsh/.env', values: { DSH_CRED_TEST: 'older-user-env' } },
])
expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'stored', source: 'file' })
// The old dead end is gone: a key sitting in the user's .env no longer
// makes the stored one unwritable.
expect(await ctx.credentials.describe(KEY)).toEqual({ configured: true, source: 'file', writable: true })
await expect(ctx.credentials.set(KEY, 'rotated')).resolves.toBeUndefined()
expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'rotated', source: 'file' })
})
it('serves the user .env only when nothing is stored', async () => {
const dir = await tempDir()
const ctx = await bootLayered(join(dir, '.credentials.yaml'), [
{ source: 'process', values: {} },
{ source: 'user-env', path: '/home/.dsh/.env', values: { DSH_CRED_TEST: 'from-user-env' } },
])
expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'from-user-env', source: 'user-env' })
// Writable: storing a key replaces it as the effective one.
expect(await ctx.credentials.describe(KEY)).toEqual({ configured: true, source: 'user-env', writable: true })
})
it('ignores the invoking directory .env entirely', 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 })
})
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')
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' } },
])
expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'from-shell', source: 'env' })
expect(await ctx.credentials.describe(KEY)).toEqual({ configured: true, source: 'env', writable: false })
await expect(ctx.credentials.set(KEY, 'next')).rejects.toThrow(/launching environment/)
})
})
describe('document validation', () => {
// Every rejection below is a boot failure rather than a skipped entry: this
// document holds nothing but credentials, so an ignored key would read as
@@ -20,6 +20,9 @@
{
"path": "../../util/atomic-write"
},
{
"path": "../../util/environment"
},
{
"path": "../../util/paths"
},
+2
View File
@@ -28,6 +28,7 @@
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-credentials": "^0.0.1",
"@deepseek-ai/dsh-environment": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-settings": "^0.0.1",
@@ -40,6 +41,7 @@
},
"devDependencies": {
"@deepseek-ai/dsh-credentials": "workspace:^",
"@deepseek-ai/dsh-environment": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-settings": "workspace:^",
+20 -8
View File
@@ -16,6 +16,7 @@ import z from 'schemastery'
import { LlmError, resolveRetryPolicy, RetryPolicySchema } from '@deepseek-ai/dsh-llm'
import type { RetryPolicyConfig } from '@deepseek-ai/dsh-llm'
import { credentialRef } from '@deepseek-ai/dsh-credentials'
import { environmentOf, type EnvironmentSnapshot } from '@deepseek-ai/dsh-environment'
import { deepEqualJson, installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings'
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
import {
@@ -62,7 +63,7 @@ export interface Config {
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, then the public API. */
/** Endpoint base; falls back to $DEEPSEEK_BASE_URL from a trusted environment layer, then the public API. */
baseURL?: string
/** Deployment thinking policy; `disabled` limits every conversation request to `off`. */
thinking?: 'enabled' | 'disabled'
@@ -103,6 +104,9 @@ export const Config: z<Config> = z.object({
/** Public API default; the internal endpoint comes from $DEEPSEEK_BASE_URL. */
export const PUBLIC_BASE_URL = 'https://api.deepseek.com'
/** Environment variable naming this provider's endpoint, honored only from trusted layers. */
const BASE_URL_ENV = 'DEEPSEEK_BASE_URL'
/**
* One resolution's complete request facts. Connection and credential facts
* are one value on purpose: a snapshot the resolver rejects keeps the whole
@@ -142,9 +146,13 @@ function resolveModels(models: readonly DeepSeekCatalogModel[] | undefined): Dee
* every default and bound is re-judged here — for the composition entry at
* 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.
* @returns validated connection facts plus the credential reference.
*/
export function resolveAdapterOptions(config: Config): ResolvedDeepSeekOptions {
export function resolveAdapterOptions(config: Config, environment?: EnvironmentSnapshot): ResolvedDeepSeekOptions {
if (config.thinking === 'disabled'
&& config.reasoningEffort !== undefined
&& config.reasoningEffort !== 'off') {
@@ -169,7 +177,9 @@ export function resolveAdapterOptions(config: Config): ResolvedDeepSeekOptions {
return {
...config.apiKey !== undefined && config.apiKey.length > 0 ? { apiKey: config.apiKey } : {},
apiKeyEnv: credentialRef(config.apiKeyEnv ?? DEFAULT_API_KEY_ENV),
baseURL: config.baseURL ?? process.env.DEEPSEEK_BASE_URL ?? PUBLIC_BASE_URL,
baseURL: config.baseURL
?? environment?.getFrom(BASE_URL_ENV, ['process', 'user-env'])?.value
?? PUBLIC_BASE_URL,
defaults: {
thinking: config.thinking,
reasoningEffort: config.reasoningEffort,
@@ -190,7 +200,7 @@ export function apply(ctx: Context, config: Config): void {
const raw = current()
if (raw === lastRaw && lastGood !== undefined) return lastGood
try {
const next = resolveAdapterOptions(raw)
const next = resolveAdapterOptions(raw, environmentOf(ctx))
lastRaw = raw
lastGood = next
return next
@@ -217,10 +227,12 @@ export function apply(ctx: Context, config: Config): void {
const hit = await credentials.resolve(ref)
if (hit !== undefined) return hit.value
} else {
// Without the seam, keep the historical ambient fallback so a plain
// cordis.yml composition works from the environment alone.
const ambient = process.env[ref]
if (ambient !== undefined && ambient.length > 0) return ambient
// 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
}
throw new LlmError(
`llm-deepseek: no API key for provider route "${PROVIDER}"; store ${ref} through the credentials`
@@ -1,5 +1,6 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import { createEnvironmentSnapshot } from '@deepseek-ai/dsh-environment'
import LlmService, { createUserMessage,
CONTEXT_WINDOW_EXCEEDED_CODE,
errorChain,
@@ -12,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, resolveAdapterOptions } from '@deepseek-ai/dsh-llm-deepseek'
import { DeepSeekAdapter, PUBLIC_BASE_URL, 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'
@@ -906,6 +907,25 @@ describe('plugin registration and config', () => {
expect(server.requests).toHaveLength(1)
})
it('takes DEEPSEEK_BASE_URL from the launching shell or the user .env, never from the project', () => {
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.
const project = createEnvironmentSnapshot([
{ source: 'project-env', path: '/work/.env', values: { DEEPSEEK_BASE_URL: 'https://attacker.example' } },
])
expect(resolveAdapterOptions({}, project).baseURL).toBe(PUBLIC_BASE_URL)
// An explicitly configured endpoint outranks every environment layer, so a
// stale shell value cannot rewrite a deployment's own gateway.
const shell = createEnvironmentSnapshot([
{ source: 'process', values: { DEEPSEEK_BASE_URL: 'https://stale.example' } },
])
expect(resolveAdapterOptions({ baseURL: 'https://gateway.internal' }, shell).baseURL).toBe('https://gateway.internal')
})
it('defaults to the public base URL without config or env', async () => {
vi.stubEnv('DEEPSEEK_API_KEY', 'k')
vi.stubEnv('DEEPSEEK_BASE_URL', undefined)
+3
View File
@@ -23,6 +23,9 @@
{
"path": "../../credentials/credentials"
},
{
"path": "../../util/environment"
},
{
"path": "../../settings/settings"
},
+2
View File
@@ -28,6 +28,7 @@
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-credentials": "^0.0.1",
"@deepseek-ai/dsh-environment": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-settings": "^0.0.1",
@@ -40,6 +41,7 @@
},
"devDependencies": {
"@deepseek-ai/dsh-credentials": "workspace:^",
"@deepseek-ai/dsh-environment": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-llm-deepseek": "workspace:^",
+4 -3
View File
@@ -29,6 +29,7 @@
*/
import type { Context } from 'cordis'
import { environmentOf } from '@deepseek-ai/dsh-environment'
import { getBuiltinProviders } from '@earendil-works/pi-ai/providers/all'
import { LlmError } from '@deepseek-ai/dsh-llm'
import type { AdapterRegistrationHandle } from '@deepseek-ai/dsh-llm'
@@ -99,9 +100,9 @@ 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, read exactly the named variable so a plain
// cordis.yml composition works from the environment alone.
: process.env[ref]
// 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
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`
+3
View File
@@ -8,6 +8,9 @@
"src"
],
"references": [
{
"path": "../../util/environment"
},
{
"path": "../../../vendor/cosmokit"
},
+3
View File
@@ -27,12 +27,14 @@
],
"license": "BSD-3-Clause",
"dependencies": {
"dotenv": "^17.2.0",
"js-yaml": "^4.2.0"
},
"peerDependencies": {
"@cordisjs/plugin-hmr": "^1.0.15",
"@cordisjs/plugin-include": "^1.0.4",
"@cordisjs/plugin-loader": "^1.0.0-rc.5",
"@deepseek-ai/dsh-environment": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-paths": "^0.0.1",
"@deepseek-ai/dsh-system-prompt": "^0.0.1",
@@ -48,6 +50,7 @@
"@cordisjs/plugin-include": "workspace:^",
"@cordisjs/plugin-loader": "workspace:^",
"@cordisjs/plugin-timer": "workspace:^",
"@deepseek-ai/dsh-environment": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-paths": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
+67 -9
View File
@@ -9,11 +9,13 @@
import { pathToFileURL } from 'node:url'
import { readFileSync } from 'node:fs'
import { basename, dirname, resolve } from 'node:path'
import { parse as parseDotenv } from 'dotenv'
import * as yaml from 'js-yaml'
import { Context, type FiberState } from 'cordis'
import Loader, { type Entry, type EntryOptions } from '@cordisjs/plugin-loader'
import Include, { applyEntryPatches, entryListSchema, type PatchOptions } from '@cordisjs/plugin-include'
import { dshHomePath, resolveDshHome } from '@deepseek-ai/dsh-paths'
import { createEnvironmentSnapshot, isBootstrapOnly, type EnvironmentSnapshot } from '@deepseek-ai/dsh-environment'
import type {} from '@cordisjs/plugin-hmr'
// Side-effect type import: resolves `ctx.get('systemPrompt')` to the service.
import type {} from '@deepseek-ai/dsh-system-prompt'
@@ -66,12 +68,57 @@ export function loadEnv(
}
/**
* Load the dsh product CLI's user environment: the invoking directory's `.env`
* Parse one directory's `.env` without applying it, rejecting any bootstrap
* variable it declares. A discovered file must not decide how this process
* launches, where its code and model-visible instructions come from, or how it
* reaches the network, so a violation fails the launch BEFORE anything is
* materialized — reporting it afterwards would leave the process already
* running under the value it refused.
* @param binName - the diagnostic prefix on the thrown error.
* @param dir - the directory whose `.env` to read.
* @param warn - sink for the one-line unreadable-file diagnostic.
* @returns the parsed entries, or `undefined` when the file is absent or unreadable.
* @throws when the file declares a name {@link isBootstrapOnly} rejects.
*/
function readEnvLayer(
binName: string, dir: string, warn: (line: string) => void,
): { path: string; values: Record<string, string> } | undefined {
const path = resolve(dir, '.env')
let content: string
try {
content = readFileSync(path, 'utf8')
} catch (error) {
if ((error as NodeJS.ErrnoException | null)?.code !== 'ENOENT') {
warn(`${binName}: failed to load .env: ${String(error)}\n`)
}
// ENOENT (no .env) is fine — rely on the ambient environment.
return undefined
}
const values = parseDotenv(content)
for (const name of Object.keys(values)) {
if (!isBootstrapOnly(name)) continue
throw new Error(
`${binName}: ${path} sets "${name}", which only the launching environment may set`
+ ' (it decides how this process starts, where its code and instructions load from, or how it'
+ ` reaches the network); export ${name} instead of putting it in a .env file`,
)
}
return { path, values }
}
/**
* Load the dsh product CLI's user environment and return it as a snapshot that
* remembers which layer supplied each value: the invoking directory's `.env`
* over the Harness home's `.env`, both under the inherited process
* environment. `process.loadEnvFile` never replaces a name that is already
* set, so loading the project file first and the user file second is what
* makes the layering `user < project < inherited`; the app-boot tests pin all
* three layers because that ordering is the whole contract.
* environment.
*
* Each layer is parsed and checked before anything is applied, then applied in
* the order that makes the layering `user < project < inherited` —
* `process.loadEnvFile` never replaces a name already set. Values do reach
* `process.env`, because a user's own `--config` tree and third-party
* libraries read it; the returned snapshot is the authority for everything the
* harness itself resolves, since `process.env` alone cannot say whether a
* value came from the launching shell or from a file inside the workspace.
*
* The Harness home is resolved from the inherited environment *before* either
* file loads, so a project `.env` can never redirect which user document is
@@ -82,17 +129,28 @@ export function loadEnv(
* These are ordinary environment values with ordinary environment reach. A
* secret the Harness should own and isolate belongs in the credentials
* document, which is never materialized here.
* @param binName - the diagnostic prefix on the warn lines.
* @param binName - the diagnostic prefix on the diagnostics.
* @param cwd - the invoking directory whose `.env` is the project layer.
* @param warn - sink for the one-line misconfiguration diagnostics.
* @returns this run's frozen environment snapshot.
* @throws when either file declares a bootstrap-only variable.
*/
export function loadLayeredEnv(
binName: string, cwd: string = process.cwd(),
warn: (line: string) => void = line => void process.stderr.write(line),
): void {
): EnvironmentSnapshot {
const home = resolveDshHome()
loadEnv(binName, cwd, warn)
loadEnv(binName, home, warn)
const inherited = { ...process.env } as Record<string, string>
// Parse both layers first: a rejection must not leave one file applied.
const project = readEnvLayer(binName, cwd, warn)
const user = home === resolve(cwd) ? undefined : readEnvLayer(binName, home, warn)
if (project !== undefined) process.loadEnvFile(project.path)
if (user !== undefined) process.loadEnvFile(user.path)
return createEnvironmentSnapshot([
{ source: 'process', values: inherited },
...project === undefined ? [] : [{ source: 'project-env' as const, path: project.path, values: project.values }],
...user === undefined ? [] : [{ source: 'user-env' as const, path: user.path, values: user.values }],
])
}
/**
+55 -9
View File
@@ -87,7 +87,7 @@ describe('loadEnv', () => {
})
describe('loadLayeredEnv', () => {
const NAMES = ['DSH_APP_BOOT_LAYERED_SHARED', 'DSH_APP_BOOT_LAYERED_USER', 'DSH_APP_BOOT_LAYERED_PROJECT'] as const
const NAMES = ['APP_BOOT_LAYERED_SHARED', 'APP_BOOT_LAYERED_USER', 'APP_BOOT_LAYERED_PROJECT'] as const
function clear(): void {
for (const name of NAMES) Reflect.deleteProperty(process.env, name)
@@ -99,18 +99,18 @@ describe('loadLayeredEnv', () => {
writeFileSync(join(home, '.env'), [
`${NAMES[0]}=user`,
`${NAMES[1]}=user-only`,
'DSH_APP_BOOT_LAYERED_INHERITED=user-loses',
'APP_BOOT_LAYERED_INHERITED=user-loses',
'',
].join('\n'))
writeFileSync(join(project, '.env'), [
`${NAMES[0]}=project`,
`${NAMES[2]}=project-only`,
'DSH_APP_BOOT_LAYERED_INHERITED=project-loses',
'APP_BOOT_LAYERED_INHERITED=project-loses',
'',
].join('\n'))
clear()
vi.stubEnv('DSH_HOME', home)
vi.stubEnv('DSH_APP_BOOT_LAYERED_INHERITED', 'inherited')
vi.stubEnv('APP_BOOT_LAYERED_INHERITED', 'inherited')
const warn = vi.fn()
try {
loadLayeredEnv(NAME, project, warn)
@@ -119,7 +119,7 @@ describe('loadLayeredEnv', () => {
expect(process.env[NAMES[0]]).toBe('project')
expect(process.env[NAMES[1]]).toBe('user-only')
expect(process.env[NAMES[2]]).toBe('project-only')
expect(process.env['DSH_APP_BOOT_LAYERED_INHERITED']).toBe('inherited')
expect(process.env['APP_BOOT_LAYERED_INHERITED']).toBe('inherited')
expect(warn).not.toHaveBeenCalled()
} finally {
clear()
@@ -127,18 +127,64 @@ describe('loadLayeredEnv', () => {
}
})
it('resolves the harness home before the project file can redirect it', () => {
it.each([
['a harness switch', 'DSH_PERMISSION_MODE=danger-full-access\n'],
['the executable search path', 'PATH=/tmp/evil\n'],
['a module preload', 'NODE_OPTIONS=--require /tmp/evil.js\n'],
['a skill root', 'DSH_AGENTS_HOME=/tmp/injected\n'],
['a network proxy', 'HTTPS_PROXY=http://attacker.example\n'],
['a lowercase network proxy', 'https_proxy=http://attacker.example\n'],
])('refuses to launch when a .env sets %s, before applying anything', (_case, content) => {
const home = tmp()
const project = tmp()
writeFileSync(join(project, '.env'), `${NAMES[1]}=applied-anyway\n${content}`)
clear()
vi.stubEnv('DSH_HOME', home)
try {
expect(() => loadLayeredEnv(NAME, project, vi.fn())).toThrow(/only the launching environment may set/)
// Rejected BEFORE materialization: reporting the violation after the
// file was applied would leave the process running under what it refused.
expect(process.env[NAMES[1]]).toBeUndefined()
} finally {
clear()
vi.unstubAllEnvs()
}
})
it('reports each layer with its absolute path', () => {
const home = tmp()
const project = tmp()
writeFileSync(join(home, '.env'), `${NAMES[1]}=u\n`)
writeFileSync(join(project, '.env'), `${NAMES[2]}=p\n`)
clear()
vi.stubEnv('DSH_HOME', home)
try {
const snapshot = loadLayeredEnv(NAME, project, vi.fn())
expect(snapshot.layers).toEqual([
{ source: 'process' },
{ source: 'project-env', path: join(project, '.env') },
{ source: 'user-env', path: join(home, '.env') },
])
expect(snapshot.get(NAMES[1])).toEqual({ value: 'u', source: 'user-env', path: join(home, '.env') })
// getFrom is a refusal, not a demotion: an omitted layer is invisible.
expect(snapshot.getFrom(NAMES[2], ['process', 'user-env'])).toBeUndefined()
} finally {
clear()
vi.unstubAllEnvs()
}
})
it('resolves the harness home from the inherited environment, never from a file', () => {
const home = tmp()
const decoy = tmp()
const project = tmp()
writeFileSync(join(home, '.env'), `${NAMES[1]}=real-home\n`)
writeFileSync(join(decoy, '.env'), `${NAMES[1]}=decoy-home\n`)
writeFileSync(join(project, '.env'), `DSH_HOME=${decoy}\n`)
writeFileSync(join(project, '.env'), `${NAMES[2]}=set-by-project\n`)
clear()
vi.stubEnv('DSH_HOME', home)
try {
loadLayeredEnv(NAME, project, vi.fn())
expect(process.env[NAMES[1]]).toBe('real-home')
expect(process.env[NAMES[2]]).toBe('set-by-project')
} finally {
clear()
vi.unstubAllEnvs()
+3
View File
@@ -26,6 +26,9 @@
{
"path": "../../core/system-prompt"
},
{
"path": "../../util/environment"
},
{
"path": "../../util/paths"
}
@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# 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
+42
View File
@@ -0,0 +1,42 @@
# dsh-environment
English | [中文](README.zh.md)
This run's environment as one immutable snapshot that remembers **which layer supplied each value**. Consumers resolve user-facing values against it instead of `process.env`, because the layers are not equally trusted and a flattened view cannot tell them apart.
| 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 |
| `$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.
## Resolving
`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.
```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
```
`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.
## Bootstrap variables
`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.
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.
- **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.
+42
View File
@@ -0,0 +1,42 @@
# dsh-environment
[English](README.md) | 中文
把本次运行的环境冻结为一份不可变快照,并记住**每个值来自哪一层**。消费方用它而不是 `process.env` 解析面向用户的值,因为各层的可信程度并不相同,而压平后的视图无法区分它们。
| 层 | 来源 id | 它是什么 |
|---|---|---|
| 继承的进程环境 | `process` | 启动 shell、CI 任务或容器传入的东西——本次运行的明确意图 |
| `<invocation cwd>/.env` | `project-env` | 项目目录里恰好有的东西;在该工作区里工作的模型可以写它 |
| `$DSH_HOME/.env` | `user-env` | 用户自己的机器级默认值 |
这些值同样会进入 `process.env`——用户自己的 `--config` 树和第三方库要读它——但那份压平的视图不是 harness 解析任何值的依据。
## 解析
`get(name)` 按可信度从高到低搜索所有层。`getFrom(name, sources)` 只搜索调用方信任的层。
**省略某一层是拒绝,不是降级。** base URL 决定已解析的 API key 被发往何处,因此 LLM 适配器请求的是 `['process', 'user-env']`:后续任何重新排序都无法让项目文件重定向凭据,因为那一层根本不会被查询。
```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
```
当产品 CLI(命令行界面)启动了这棵树时,`environmentOf(ctx)` 返回启动器的快照;否则返回只含继承环境的那一层。该回退并不削弱规则:SDK 宿主或裸 `cordis.yml` 从未发现过任何文件,因此它拥有的一切确实就是它被启动时的环境。
## bootstrap 变量
`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` 不是绕过手段。
整个 `DSH_*` 命名空间被拒绝,而不是只拒绝一份经过审查的子集:harness 自己的开关——权限模式、agents home、内置 skill(技能)根目录——恰恰是敌意项目最想要的,而后来新增的开关不能因为忘记登记就变得可设置。
## Known Limitations and Deferred Work
- **快照不是子进程边界**:每一层同样会被物化进 `process.env`,因此普通的项目变量仍会按 [`dsh-subprocess`](../../subprocess/subprocess/README.md) 的清洗规则抵达子进程。bootstrap 变量完全不能来自文件,但项目 `.env` 仍可以为 agent 运行的工具设置诸如 `GIT_SSH_COMMAND` 之类的变量。
- **没有按工作区划分的层**:项目层是*调用*目录,在启动时固定。之后在 Web UI 中选择的工作区不贡献任何内容,这是刻意的:跟随它等于让模型自己的工作区在会话中途改变 harness 的环境。
+37
View File
@@ -0,0 +1,37 @@
{
"name": "@deepseek-ai/dsh-environment",
"description": "Immutable launch-time environment snapshot with per-layer provenance for the DeepSeek Harness",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-invariants": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
"@deepseek-ai/dsh-invariants": "workspace:^",
"cordis": "^4.0.0-rc.7"
}
}
+178
View File
@@ -0,0 +1,178 @@
/**
* The launch-time environment as one immutable snapshot that remembers which
* layer supplied each value. The harness resolves user-facing values against
* this rather than against `process.env`, because the layers differ in how
* much they are trusted: an inherited variable is this run's explicit intent,
* a file discovered under the invoking directory is whatever the project
* happens to contain, and a consumer that cannot tell them apart cannot make
* that distinction.
*
* Values still reach `process.env` as well — a user's own `--config` tree and
* third-party libraries read it — but that flattened view is not the
* authority for anything the harness itself resolves.
* @module @deepseek-ai/dsh-environment
*/
import type { Context } from 'cordis'
/**
* Which layer supplied a value, from most to least trusted: the environment
* this process inherited, the invoking directory's `.env`, the Harness home's
* `.env`.
*/
export type EnvironmentSource = 'process' | 'project-env' | 'user-env'
/** Layer order, most trusted first — the default search order of {@link EnvironmentSnapshot.get}. */
export const ENVIRONMENT_SOURCES: readonly EnvironmentSource[] = ['process', 'project-env', 'user-env']
/** One resolved variable and the layer it came from. */
export interface EnvironmentEntry {
/** The value as the layer supplied it; may be empty, which each owner judges for itself. */
value: string
/** The layer that supplied it. */
source: EnvironmentSource
/** Absolute path of the file that supplied it; absent for `process`. */
path?: string
}
/** One environment layer's identity, for diagnostics. */
export interface EnvironmentLayer {
source: EnvironmentSource
/** Absolute path of the file behind this layer; absent for `process`. */
path?: string
}
/**
* The frozen environment of one launch. Construct through
* {@link createEnvironmentSnapshot}; nothing mutates it afterwards, so a
* later `chdir`, workspace switch, or resumed session observes the same
* values a consumer resolved at boot.
*/
export interface EnvironmentSnapshot {
/**
* Resolve one name across every layer, most trusted first.
* @param name - the variable name.
* @returns the winning entry, or `undefined` when no layer supplies it.
*/
get(name: string): EnvironmentEntry | undefined
/**
* Resolve one name across only the layers the caller trusts for this
* decision. Omitting a layer is a refusal, not a demotion: a routing field
* that must never come from a project directory omits `project-env` so no
* ordering change can let it back in.
* @param name - the variable name.
* @param sources - the layers to search, in the caller's own priority order.
* @returns the first matching entry, or `undefined`.
*/
getFrom(name: string, sources: readonly EnvironmentSource[]): EnvironmentEntry | undefined
/** The layers this snapshot was built from, most trusted first. */
readonly layers: readonly EnvironmentLayer[]
}
/** One layer's raw contents, as {@link createEnvironmentSnapshot} receives them. */
export interface EnvironmentLayerInput {
source: EnvironmentSource
/** Absolute path of the file behind this layer; omit for `process`. */
path?: string
values: Readonly<Record<string, string>>
}
/**
* Build the snapshot from each layer's contents.
* @param layers - the layers in any order; the result searches them by {@link ENVIRONMENT_SOURCES}.
* @returns the immutable snapshot.
*/
export function createEnvironmentSnapshot(layers: readonly EnvironmentLayerInput[]): EnvironmentSnapshot {
// Copied per layer so a later mutation of `process.env` — or of a caller's
// own object — cannot change what this snapshot reports.
const bySource = new Map<EnvironmentSource, { path?: string; values: Map<string, string> }>()
for (const layer of layers) {
bySource.set(layer.source, {
...layer.path === undefined ? {} : { path: layer.path },
values: new Map(Object.entries(layer.values)),
})
}
const getFrom = (name: string, sources: readonly EnvironmentSource[]): EnvironmentEntry | undefined => {
for (const source of sources) {
const layer = bySource.get(source)
const value = layer?.values.get(name)
if (value === undefined) continue
return { value, source, ...layer?.path === undefined ? {} : { path: layer.path } }
}
return undefined
}
return {
get: name => getFrom(name, ENVIRONMENT_SOURCES),
getFrom,
layers: ENVIRONMENT_SOURCES
.filter(source => bySource.has(source))
.map((source): EnvironmentLayer => {
const path = bySource.get(source)?.path
return { source, ...path === undefined ? {} : { path } }
}),
}
}
/** Context slot the launcher fills with this run's snapshot before any config entry mounts. */
export const DSH_ENVIRONMENT_KEY = 'launcherEnvironment'
/**
* The snapshot to resolve against, whatever booted this tree: the launcher's
* when the product CLI provided one, otherwise the inherited environment
* alone.
*
* The fallback does not weaken the layer rules — it applies the same rules to
* a host that has exactly one layer. An SDK embedder or a bare `cordis.yml`
* never discovered a project or user file, so everything it has really is the
* environment it was launched with, and `getFrom(..., ['process'])` is exactly
* right for it.
* @param ctx - the consuming plugin's context.
* @returns the snapshot to resolve user-facing values against.
*/
export function environmentOf(ctx: Context): EnvironmentSnapshot {
return ctx.get(DSH_ENVIRONMENT_KEY)
?? createEnvironmentSnapshot([{ source: 'process', values: process.env as Record<string, string> }])
}
declare module 'cordis' {
interface Context {
/** Launcher-owned snapshot of this run's environment; absent in compositions the product CLI did not boot. */
launcherEnvironment?: EnvironmentSnapshot
}
}
/** Exact names no discovered file may set. */
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',
// Network reach and trust.
'SSL_CERT_FILE', 'SSL_CERT_DIR',
'HTTP_PROXY', 'HTTPS_PROXY', 'ALL_PROXY', 'NO_PROXY',
])
/** Name prefixes no discovered file may set. */
const BOOTSTRAP_PREFIXES = ['DSH_', 'XDG_', 'DYLD_']
/**
* 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 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.
* @param name - the variable name.
* @returns true when only the inherited environment may supply it.
*/
export function isBootstrapOnly(name: string): boolean {
const upper = name.toUpperCase()
return BOOTSTRAP_NAMES.has(upper) || BOOTSTRAP_PREFIXES.some(prefix => upper.startsWith(prefix))
}
@@ -0,0 +1,30 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-environment`.
* @module @deepseek-ai/dsh-environment/invariant
*/
/* jscpd:ignore-start */
import type { Context } from 'cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-environment'
/** Cordis companion plugin name. */
export const name = 'environment-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/**
* No runtime invariant: the snapshot is frozen before any fiber starts and this package owns no
* event stream or mutable runtime data; its lookup and rejection rules are enforced by unit tests.
*/
const install: InvariantInstaller = () => {}
/**
* Register this package's invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns the installed registration's disposer after setup succeeds.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
/* jscpd:ignore-end */
@@ -0,0 +1,118 @@
import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import {
createEnvironmentSnapshot, DSH_ENVIRONMENT_KEY, ENVIRONMENT_SOURCES, environmentOf, isBootstrapOnly,
} from '../src/index.ts'
const layered = createEnvironmentSnapshot([
{ source: 'process', values: { SHARED: 'from-process', ONLY_PROCESS: 'p' } },
{ source: 'project-env', path: '/work/.env', values: { SHARED: 'from-project', ONLY_PROJECT: 'j' } },
{ source: 'user-env', path: '/home/.dsh/.env', values: { SHARED: 'from-user', ONLY_USER: 'u' } },
])
describe('createEnvironmentSnapshot', () => {
it('resolves across every layer, most trusted first, and reports the winning source', () => {
expect(layered.get('SHARED')).toEqual({ value: 'from-process', source: 'process' })
expect(layered.get('ONLY_PROJECT')).toEqual({ value: 'j', source: 'project-env', path: '/work/.env' })
expect(layered.get('ONLY_USER')).toEqual({ value: 'u', source: 'user-env', path: '/home/.dsh/.env' })
expect(layered.get('ABSENT')).toBeUndefined()
})
it('treats an omitted layer as invisible, not merely lower', () => {
// The point of getFrom: a routing field that must never come from a
// project directory cannot be reached by reordering, only by listing it.
expect(layered.getFrom('ONLY_PROJECT', ['process', 'user-env'])).toBeUndefined()
expect(layered.getFrom('SHARED', ['user-env', 'process'])).toEqual({
value: 'from-user', source: 'user-env', path: '/home/.dsh/.env',
})
expect(layered.getFrom('SHARED', [])).toBeUndefined()
})
it('lists its layers in trust order with their paths', () => {
expect(layered.layers).toEqual([
{ source: 'process' },
{ source: 'project-env', path: '/work/.env' },
{ source: 'user-env', path: '/home/.dsh/.env' },
])
expect(createEnvironmentSnapshot([{ source: 'process', values: {} }]).layers).toEqual([{ source: 'process' }])
})
it('copies each layer, so a later mutation of the source object cannot change it', () => {
const values: Record<string, string> = { KEY: 'first' }
const snapshot = createEnvironmentSnapshot([{ source: 'process', values }])
values.KEY = 'second'
values.LATE = 'added'
expect(snapshot.get('KEY')).toEqual({ value: 'first', source: 'process' })
expect(snapshot.get('LATE')).toBeUndefined()
})
it('keeps an empty value as a present value, for its owner to judge', () => {
const snapshot = createEnvironmentSnapshot([{ source: 'process', values: { EMPTY: '' } }])
expect(snapshot.get('EMPTY')).toEqual({ value: '', source: 'process' })
})
it('orders lookups by ENVIRONMENT_SOURCES regardless of construction order', () => {
const reversed = createEnvironmentSnapshot([
{ source: 'user-env', path: '/u', values: { K: 'u' } },
{ source: 'process', values: { K: 'p' } },
])
expect(ENVIRONMENT_SOURCES).toEqual(['process', 'project-env', 'user-env'])
expect(reversed.get('K')).toEqual({ value: 'p', source: 'process' })
})
})
describe('environmentOf', () => {
it('returns the launcher snapshot when the product CLI provided one', () => {
const ctx = new Context()
ctx.provide(DSH_ENVIRONMENT_KEY, layered)
expect(environmentOf(ctx)).toBe(layered)
})
it('falls back to the inherited environment as the only layer', () => {
vi.stubEnv('DSH_ENV_SPEC_FALLBACK', 'ambient')
try {
const snapshot = environmentOf(new Context())
expect(snapshot.get('DSH_ENV_SPEC_FALLBACK')).toEqual({ value: 'ambient', source: 'process' })
// A host that discovered no files has exactly one layer, so the trusted
// lookups every consumer makes still find what it was launched with.
expect(snapshot.getFrom('DSH_ENV_SPEC_FALLBACK', ['process', 'user-env'])?.value).toBe('ambient')
expect(snapshot.layers).toEqual([{ source: 'process' }])
} finally {
vi.unstubAllEnvs()
}
})
})
describe('isBootstrapOnly', () => {
it.each([
'PATH', 'HOME', 'USERPROFILE', 'SHELL',
'NODE_OPTIONS', 'NODE_PATH', 'NODE_EXTRA_CA_CERTS',
'LD_PRELOAD', 'LD_LIBRARY_PATH',
'SSL_CERT_FILE', 'SSL_CERT_DIR',
'HTTP_PROXY', 'HTTPS_PROXY', 'ALL_PROXY', 'NO_PROXY',
])('rejects %s, which decides how the process starts or reaches the network', (name) => {
expect(isBootstrapOnly(name)).toBe(true)
})
it.each([
['DSH_HOME', 'the harness home'],
['DSH_PERMISSION_MODE', 'the permission mode'],
['DSH_AGENTS_HOME', 'a model-visible instruction root'],
['DSH_ANYTHING_ADDED_LATER', 'a switch that does not exist yet'],
['XDG_CONFIG_HOME', 'a state root'],
['DYLD_INSERT_LIBRARIES', 'a library preload'],
])('rejects the whole namespace: %s (%s)', (name) => {
expect(isBootstrapOnly(name)).toBe(true)
})
it('matches case-insensitively, so a lowercase proxy name is not a bypass', () => {
expect(isBootstrapOnly('https_proxy')).toBe(true)
expect(isBootstrapOnly('dsh_permission_mode')).toBe(true)
})
it('allows ordinary variables, including provider credentials and endpoints', () => {
for (const name of ['DEEPSEEK_API_KEY', 'DEEPSEEK_BASE_URL', 'EXA_API_KEY', 'MY_PROJECT_FLAG', 'PATHS']) {
expect(isBootstrapOnly(name)).toBe(false)
}
})
})
+15
View File
@@ -0,0 +1,15 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": [
{
"path": "../../support/invariants"
}
]
}
@@ -29,6 +29,7 @@
"peerDependencies": {
"@deepseek-ai/dsh-agent": "^0.0.1",
"@deepseek-ai/dsh-credentials": "^0.0.1",
"@deepseek-ai/dsh-environment": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"@deepseek-ai/dsh-web": "^0.0.1",
@@ -41,6 +42,7 @@
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-credentials": "workspace:^",
"@deepseek-ai/dsh-credentials-local": "workspace:^",
"@deepseek-ai/dsh-environment": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-web": "workspace:^",
@@ -9,6 +9,7 @@ import type { Context } from 'cordis'
import z from 'schemastery'
import type {} from '@deepseek-ai/dsh-agent'
import { credentialRef } from '@deepseek-ai/dsh-credentials'
import { environmentOf } from '@deepseek-ai/dsh-environment'
import type {} from '@deepseek-ai/dsh-session'
import type {} from '@deepseek-ai/dsh-web'
import {
@@ -80,8 +81,10 @@ export function apply(ctx: Context, config: Config): void {
resolveApiKey: async () => {
const credentials = ctx.get('credentials')
if (credentials !== undefined) return (await credentials.resolve(apiKeyEnv))?.value
const ambient = process.env[apiKeyEnv]
return ambient !== undefined && ambient.length > 0 ? ambient : undefined
// 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
},
apiKeyEnv,
baseURL: config.baseURL ?? DEEPSEEK_DEFAULT_BASE_URL,
@@ -8,6 +8,9 @@
"src"
],
"references": [
{
"path": "../../util/environment"
},
{
"path": "../../../vendor/cosmokit"
},
+2
View File
@@ -27,6 +27,7 @@
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-environment": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-web": "^0.0.1",
"cordis": "^4.0.0-rc.7"
@@ -35,6 +36,7 @@
"schemastery": "^3.18.0"
},
"devDependencies": {
"@deepseek-ai/dsh-environment": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-web": "workspace:^",
"cordis": "^4.0.0-rc.7"
+5 -1
View File
@@ -9,6 +9,7 @@
*/
import type { Context } from 'cordis'
import { environmentOf } from '@deepseek-ai/dsh-environment'
import z from 'schemastery'
import type {} from '@deepseek-ai/dsh-web'
import {
@@ -58,7 +59,10 @@ 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({
apiKey: config.apiKey ?? process.env.EXA_API_KEY ?? '',
// 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 ?? '',
baseURL: config.baseURL ?? EXA_DEFAULT_BASE_URL,
searchType: config.searchType ?? EXA_DEFAULT_SEARCH_TYPE,
highlightsPerResult: config.highlightsPerResult ?? EXA_DEFAULT_HIGHLIGHTS_PER_RESULT,
@@ -8,6 +8,9 @@
"src"
],
"references": [
{
"path": "../../util/environment"
},
{
"path": "../../../vendor/cosmokit"
},
@@ -27,6 +27,7 @@
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-environment": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-web": "^0.0.1",
"cordis": "^4.0.0-rc.7"
@@ -35,6 +36,7 @@
"schemastery": "^3.18.0"
},
"devDependencies": {
"@deepseek-ai/dsh-environment": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-web": "workspace:^",
"cordis": "^4.0.0-rc.7"
@@ -8,6 +8,7 @@
*/
import type { Context } from 'cordis'
import { environmentOf } from '@deepseek-ai/dsh-environment'
import z from 'schemastery'
import type {} from '@deepseek-ai/dsh-web'
import { PerplexitySearchProvider, PERPLEXITY_DEFAULT_BASE_URL, PERPLEXITY_DEFAULT_MAX_TOKENS, PERPLEXITY_DEFAULT_MODEL } from './provider.ts'
@@ -52,7 +53,10 @@ 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({
apiKey: config.apiKey ?? process.env.PERPLEXITY_API_KEY ?? '',
// 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 ?? '',
baseURL: config.baseURL ?? PERPLEXITY_DEFAULT_BASE_URL,
model: config.model ?? PERPLEXITY_DEFAULT_MODEL,
maxTokens: config.maxTokens ?? PERPLEXITY_DEFAULT_MAX_TOKENS,
@@ -8,6 +8,9 @@
"src"
],
"references": [
{
"path": "../../util/environment"
},
{
"path": "../../../vendor/cosmokit"
},
+45
View File
@@ -243,6 +243,9 @@ importers:
'@deepseek-ai/dsh-credentials-local':
specifier: workspace:^
version: link:../../packages/credentials/credentials-local
'@deepseek-ai/dsh-environment':
specifier: workspace:^
version: link:../../packages/util/environment
'@deepseek-ai/dsh-frontend':
specifier: workspace:^
version: link:../web
@@ -2638,6 +2641,9 @@ importers:
'@deepseek-ai/dsh-credentials':
specifier: workspace:^
version: link:../credentials
'@deepseek-ai/dsh-environment':
specifier: workspace:^
version: link:../../util/environment
'@deepseek-ai/dsh-invariants':
specifier: workspace:^
version: link:../../support/invariants
@@ -3609,6 +3615,9 @@ importers:
'@deepseek-ai/dsh-credentials':
specifier: workspace:^
version: link:../../credentials/credentials
'@deepseek-ai/dsh-environment':
specifier: workspace:^
version: link:../../util/environment
'@deepseek-ai/dsh-invariants':
specifier: workspace:^
version: link:../../support/invariants
@@ -3637,6 +3646,9 @@ importers:
'@deepseek-ai/dsh-credentials':
specifier: workspace:^
version: link:../../credentials/credentials
'@deepseek-ai/dsh-environment':
specifier: workspace:^
version: link:../../util/environment
'@deepseek-ai/dsh-invariants':
specifier: workspace:^
version: link:../../support/invariants
@@ -5673,6 +5685,9 @@ importers:
packages/ui/app-boot:
dependencies:
dotenv:
specifier: ^17.2.0
version: 17.4.2
js-yaml:
specifier: ^4.2.0
version: 4.2.0
@@ -5689,6 +5704,9 @@ importers:
'@cordisjs/plugin-timer':
specifier: workspace:^
version: link:../../../vendor/timer
'@deepseek-ai/dsh-environment':
specifier: workspace:^
version: link:../../util/environment
'@deepseek-ai/dsh-invariants':
specifier: workspace:^
version: link:../../support/invariants
@@ -5988,6 +6006,15 @@ importers:
specifier: ^4.0.0-rc.7
version: link:../../../vendor/cordis
packages/util/environment:
devDependencies:
'@deepseek-ai/dsh-invariants':
specifier: workspace:^
version: link:../../support/invariants
cordis:
specifier: ^4.0.0-rc.7
version: link:../../../vendor/cordis
packages/util/native-command:
devDependencies:
'@deepseek-ai/dsh-invariants':
@@ -6129,6 +6156,9 @@ importers:
'@deepseek-ai/dsh-credentials-local':
specifier: workspace:^
version: link:../../credentials/credentials-local
'@deepseek-ai/dsh-environment':
specifier: workspace:^
version: link:../../util/environment
'@deepseek-ai/dsh-invariants':
specifier: workspace:^
version: link:../../support/invariants
@@ -6148,6 +6178,9 @@ importers:
specifier: ^3.18.0
version: link:../../../vendor/schemastery
devDependencies:
'@deepseek-ai/dsh-environment':
specifier: workspace:^
version: link:../../util/environment
'@deepseek-ai/dsh-invariants':
specifier: workspace:^
version: link:../../support/invariants
@@ -6164,6 +6197,9 @@ importers:
specifier: ^3.18.0
version: link:../../../vendor/schemastery
devDependencies:
'@deepseek-ai/dsh-environment':
specifier: workspace:^
version: link:../../util/environment
'@deepseek-ai/dsh-invariants':
specifier: workspace:^
version: link:../../support/invariants
@@ -6420,6 +6456,9 @@ importers:
'@deepseek-ai/dsh-credentials':
specifier: workspace:^
version: link:../../packages/credentials/credentials
'@deepseek-ai/dsh-environment':
specifier: workspace:^
version: link:../../packages/util/environment
'@deepseek-ai/dsh-fs':
specifier: workspace:^
version: link:../../packages/fs/fs
@@ -9776,6 +9815,10 @@ packages:
dompurify@3.4.11:
resolution: {integrity: sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==}
dotenv@17.4.2:
resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==}
engines: {node: '>=12'}
dts-resolver@3.0.0:
resolution: {integrity: sha512-1T1f+z+4tl9XD+m+0HBgWoL/nm0bOIffyWaUuUSBlFg/86IWvfx+wjNaO/ybU0AJzG9/Mi5hBUgGV6zCmWEN7Q==}
engines: {node: ^22.18.0 || >=24.0.0}
@@ -14828,6 +14871,8 @@ snapshots:
optionalDependencies:
'@types/trusted-types': 2.0.7
dotenv@17.4.2: {}
dts-resolver@3.0.0(oxc-resolver@11.20.0):
optionalDependencies:
oxc-resolver: 11.20.0
+1
View File
@@ -24,6 +24,7 @@
"@deepseek-ai/dsh-compact-basic": "workspace:^",
"@deepseek-ai/dsh-compact-tool-result-prune": "workspace:^",
"@deepseek-ai/dsh-credentials": "workspace:^",
"@deepseek-ai/dsh-environment": "workspace:^",
"@deepseek-ai/dsh-fs": "workspace:^",
"@deepseek-ai/dsh-fs-local": "workspace:^",
"@deepseek-ai/dsh-fs-policy": "workspace:^",
+1
View File
@@ -569,6 +569,7 @@ function docSyncLeafGates(options: {
pnpmScript('markdown-links', 'verify-md-links', { label: 'markdown links' }),
pnpmScript('doc-refs', 'verify-doc-refs', { label: 'doc refs' }),
pnpmScript('package-paths', 'verify-package-paths', { label: 'package paths' }),
pnpmScript('config-source-ownership', 'verify-config-source-ownership', { label: 'config source ownership' }),
pnpmScript('package-readme-model-experience', 'verify-package-readme-model-experience', { label: 'package README model experience' }),
pnpmScript('mermaid', 'verify-mermaid'),
pnpmScript('agent-note-classification', 'verify-agent-note-classification', { label: 'agent note classification' }),
+117
View File
@@ -0,0 +1,117 @@
/**
* Gate: every user-facing value has one owner, and no shipped file smuggles a
* second one in.
*
* Two rules, both about the same failure — a value reaching the harness
* through a path nobody ranked:
*
* 1. Production package source does not read `process.env` directly. A
* credential belongs to `ctx.credentials`, a user-configurable value to the
* environment snapshot plus its owner's resolve step, and a real
* process-launch fact to the app bootstrap. Each remaining read is listed
* below with the reason it is one of those.
* 2. Shipped Cordis configuration does not inline a credential or an endpoint
* from the environment. Doing so re-creates the layer the snapshot exists
* to rank: `apiKey: !!js process.env.X` and `baseURL: !!js process.env.X`
* bypass both the credential seam and the endpoint ladder, and a project
* file could then decide where a key is sent.
* @module scripts/verify-config-source-ownership
*/
import { globSync, readFileSync } from 'node:fs'
import { resolve, sep } from 'node:path'
const ROOT = resolve(import.meta.dirname, '..')
/**
* Production package sources allowed to read `process.env`, each with the
* reason it is a process fact rather than a user-configurable value. Adding a
* row is a deliberate act: state which of the three owners it belongs to and
* why it cannot go there.
*/
const ENV_READ_ALLOWLIST: Readonly<Record<string, string>> = {
// The environment plane itself.
'packages/util/environment/src/index.ts': 'defines the snapshot; the inherited environment is its input',
'packages/ui/app-boot/src/index.ts': 'the app bootstrap that builds the snapshot and reads $DSH_SNAPSHOT',
'packages/util/paths/src/index.ts': 'resolves $DSH_HOME before any snapshot exists',
// Process-launch facts owned by the boundary that spawns or is spawned.
'packages/subprocess/subprocess/src/index.ts': 'scrubs the parent environment for children',
'packages/workflow/workflow-workerthread/src/host.ts': 'passes the parent environment to a worker thread',
'packages/ui/tui/src/index.ts': 'reads $COLORTERM, a terminal capability of this process',
'packages/lsp/lsp-local/src/index.ts': 'passes the parent environment to a language server it spawns',
'packages/cordis/repository-plugin/src/index.ts': 'resolves an MCP manifest against the spawning environment',
// Bootstrap-only DSH_* switches, which no discovered file may set.
'packages/skill/skill-local/src/index.ts': 'reads $DSH_AGENTS_HOME and $DSH_BUNDLED_SKILL_DIR, both bootstrap-only',
'packages/web/web/src/index.ts': 'reads $DSH_WEB_SEARCH_PROVIDER and $DSH_WEB_FETCH_PROVIDER, both bootstrap-only',
'packages/host/directory-picker-auto/src/index.ts': 'reads launch facts (display, SSH) of this process',
'packages/host/directory-picker-auto/src/resolve.ts': 'reads launch facts (display, SSH) of this process',
// Telemetry identity and consent, resolved once per process at bootstrap.
'packages/telemetry/session-telemetry-otel/src/user-id.ts': 'derives a machine identity from process facts',
'packages/sdk/telemetry/src/consent-resolver.ts': 'reads the SDK bootstrap consent switch',
'packages/sdk/telemetry/src/anonymous-id.ts': 'derives a machine identity from process facts',
// SDK and example bins: their own app bootstrap, outside the product CLI.
'packages/sdk/sdk-client/src/client.ts': 'SDK host bootstrap',
'packages/sdk/helper/src/features/builtin/provider.ts': 'SDK scaffolding reads the developer environment',
'packages/sdk/helper/src/features/builtin/app.ts': 'SDK scaffolding reads the developer environment',
'packages/sdk/helper/src/package-managers/package-manager.ts': 'detects the invoking package manager',
'packages/sdk/create-sdk/src/create-wizard.ts': 'SDK scaffolding reads the developer environment',
'packages/examples/jsonrpc-demo/src/bin.ts': 'demo bin bootstrap',
'packages/examples/acp-demo/src/bin.ts': 'demo bin bootstrap',
// Test and replay infrastructure.
'packages/support/loader-smoke/src/index.ts': 'test launcher composing a child environment',
'packages/support/llm-replay/src/index.ts': 'replay fixture switch',
'packages/support/acp-snapshot/src/launcher.ts': 'snapshot launcher composing a child environment',
// Browser bundle: `process.env` is replaced at build time, never read at runtime.
'packages/client/runtime/src/client/contract/store.ts': 'build-time constant folded by the bundler',
}
/** Shipped Cordis configuration these rules apply to. */
const SHIPPED_CONFIG_GLOBS = ['apps/*/config/*.yml', 'examples/*/*.cordis.yml', 'examples/*/cordis.yml']
/** Config keys that must never be inlined from the environment. */
const INLINE_DENY = /^\s*(apiKey|baseURL|apiKeyEnv|authToken|headers)\s*:\s*!!js\b/
const failures: string[] = []
for (const file of globSync('packages/*/*/src/**/*.ts', { cwd: ROOT })) {
const rel = file.split(sep).join('/')
if (!readFileSync(resolve(ROOT, rel), 'utf8').includes('process.env')) continue
if (rel in ENV_READ_ALLOWLIST) continue
failures.push(
`${rel}: reads process.env directly. A credential belongs to ctx.credentials, a user-configurable`
+ ' value to environmentOf(ctx) plus its owner\'s resolve step, and a process-launch fact to the app'
+ ' bootstrap. If it is genuinely one of those, add it to ENV_READ_ALLOWLIST with the reason.',
)
}
for (const glob of SHIPPED_CONFIG_GLOBS) {
for (const file of globSync(glob, { cwd: ROOT })) {
const rel = file.split(sep).join('/')
readFileSync(resolve(ROOT, rel), 'utf8').split('\n').forEach((line, index) => {
if (!INLINE_DENY.test(line)) return
failures.push(
`${rel}:${String(index + 1)}: inlines a credential or endpoint from the environment.`
+ ' The adapter resolves apiKeyEnv through ctx.credentials and the endpoint through the'
+ ' environment snapshot; inlining here bypasses both ladders.',
)
})
}
}
if (failures.length > 0) {
process.stderr.write('verify-config-source-ownership: configuration source ownership violated:\n')
for (const failure of failures) process.stderr.write(` ${failure}\n`)
process.exit(1)
}
const allowed = Object.keys(ENV_READ_ALLOWLIST).length
process.stdout.write(
`verify-config-source-ownership: no unregistered process.env reads (${String(allowed)} allowlisted)`
+ ' and no credential or endpoint inlined in shipped configuration.\n',
)
@@ -33,6 +33,7 @@ const NO_MODEL_EXPERIENCE_SECTION: Readonly<Record<string, string>> = {
'packages/core/scope': 'The package is a model-agnostic registration and lifecycle primitive; model-facing consumers own any context selection.',
'packages/util/brand': 'The package is a type-only primitive erased at compile time.',
'packages/util/paths': 'The package only resolves harness-owned host paths; model-facing consumers own any rendered use.',
'packages/util/environment': 'The package only resolves host environment values; model-facing consumers own any rendered use.',
}
/**
+1
View File
@@ -72,6 +72,7 @@
{ "path": "./vendor/hmr" },
{ "path": "./vendor/logger-console" },
{ "path": "./packages/util/brand" },
{ "path": "./packages/util/environment" },
{ "path": "./packages/util/native-command" },
{ "path": "./packages/util/paths" },
{ "path": "./packages/util/timeout" },