cleanup(config): consolidate source ownership rationale

This commit is contained in:
Tianyi Cui
2026-08-07 23:43:15 +08:00
parent b5fbcaccf8
commit 62ae990c27
7 changed files with 30 additions and 134 deletions
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/ui/app-boot/README.md
README.md: 25e0c10932a2bede7f6c6582043af436b6153f4a
README.zh.md: 649a60802660fdd8d4a6cd85dc64b5a65cee5a88
README.md: 359f05a83b41db6db5ede40db7317a0fb15de43b
README.zh.md: a916236e30b50cc884d9d5876f27fcb1aa6f0777
+1 -1
View File
@@ -37,7 +37,7 @@ A profile is a directory under `$DSH_HOME/profiles/<name>` (the Harness home res
User-level machine-local preferences also live in the Harness home:
- **`.env`** — the product CLI's ordinary environment layers: the invoking directory's file outranks the Harness-home file, and both sit below the inherited environment. `loadLayeredEnv` snapshots each value's source, rejects case-insensitive bootstrap-only process/module/runtime/Git/network variables and the `DSH_`/`XDG_`/`DYLD_`/`BASH_FUNC_` namespaces, and materializes accepted values into `process.env` for Loader expressions and third-party libraries. Managed credentials live separately in [`.credentials.yaml`](../../credentials/credentials-local/README.md); a credential left in either `.env` remains a lower-priority fallback.
- **`.env`** — the product CLI's ordinary environment layers: the invoking directory's file outranks the Harness-home file, and both sit below the inherited environment. `loadLayeredEnv` snapshots each value's source, rejects [bootstrap-only file variables](../../../.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.md#decision) case-insensitively, and materializes accepted values into `process.env` for Loader expressions and third-party libraries. Managed credentials live separately in [`.credentials.yaml`](../../credentials/credentials-local/README.md); a credential left in either `.env` remains a lower-priority fallback.
- **`cordis.patch.yml`** (home level) and **`profiles/<name>/cordis.patch.yml`** — the user patch layers, applied after every bundle layer (per-profile first, then the home-level file, which therefore outranks it): an id-targeted patch replaces the named entry's whole `config` (restate unchanged fields), `insert` adds entries, and `!!js` expressions interpolate at mount. A patch naming an entry id absent from the composed tree is a stderr warning. An empty or comments-only file throws (it parses to nothing, not to a list); disable the layer with `[]`.
Long-lived surfaces keep `cordis.patch.yml` live through `watchUserPatches`; one-shot runs read only the startup value. The watcher targets the exact path even when the file or immediate parent does not exist, serializes bursts, and recomposes the user patches inside the caller's layer order (bundle layers below, overlay/flag patches above). A rejected read, parse, or Loader candidate leaves the last good tree running and the HMR service broadcasts `hmr/config-update-failed(filename, Error)` after logging it; observer failures are contained. Disposing the context closes the watcher and drains an active refresh.
+1 -1
View File
@@ -37,7 +37,7 @@ profile 是位于 `$DSH_HOME/profiles/<name>` 下的目录(Harness home 由 [`
用户级的机器本地偏好同样位于 Harness home 中:
- **`.env`**:产品 CLI 的普通环境层;调用目录的文件优先于 Harness home 的文件,两者都低于继承环境。`loadLayeredEnv` 记录每个值的来源,按不区分大小写的方式拒绝文件中的 bootstrap-only 进程、模块、运行时、Git 与网络变量,以及整个 `DSH_``XDG_``DYLD_``BASH_FUNC_` 命名空间,并把其余值物化进 `process.env`,供 Loader 表达式和第三方库使用。受管凭据另存于 [`.credentials.yaml`](../../credentials/credentials-local/README.md);留在任一 `.env` 中的凭据仍是低优先级后备值。
- **`.env`**:产品 CLI 的普通环境层;调用目录的文件优先于 Harness home 的文件,两者都低于继承环境。`loadLayeredEnv` 记录每个值的来源,按不区分大小写的方式拒绝 [bootstrap-only 文件变量](../../../.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.md#decision),并把其余值物化进 `process.env`,供 Loader 表达式和第三方库使用。受管凭据另存于 [`.credentials.yaml`](../../credentials/credentials-local/README.md);留在任一 `.env` 中的凭据仍是低优先级后备值。
- **`cordis.patch.yml`**home 级)与 **`profiles/<name>/cordis.patch.yml`**:用户 patch 层,应用在所有组合包层之后(先应用逐 profile 的文件,再应用 home 级文件,因此后者优先级更高):按 id 定位的 patch 会替换对应条目的整个 `config`(未改字段也要重述),`insert` 会添加条目,`!!js` 表达式则在挂载时插值。如果 patch 指定的条目 id 不在组合后的树中,则输出一条 stderr 警告。空文件或仅含注释的文件会抛出异常(其解析结果为空,而不是列表);如需禁用该层,请使用 `[]`
长期运行的 surface 会持续应用 `cordis.patch.yml` 的变更,具体由 `watchUserPatches` 负责;一次性运行只读取启动时的值。即使该文件或其直接父目录不存在,watcher 仍会监视确切路径;它会串行处理突发变更,并按调用方的层次顺序重新组合用户 patch(组合包层在下、overlay/标志 patch 在上)。读取失败、解析失败或 Loader 候选被拒时,最后一个可用树会继续运行;HMR 服务记录错误后广播 `hmr/config-update-failed(filename, Error)`,并隔离 observer 失败。上下文 dispose 时会关闭 watcher,并等待进行中的刷新结束。
+12 -75
View File
@@ -94,17 +94,12 @@ const BOOTSTRAP_NAMES = new Set([
'PATH', 'HOME', 'USERPROFILE', 'SHELL',
'NODE_OPTIONS', 'NODE_PATH', 'NODE_EXTRA_CA_CERTS',
'LD_PRELOAD', 'LD_LIBRARY_PATH', 'LD_AUDIT',
// Interpreter start-up hooks: each of these makes a runtime execute a file
// of the setter's choosing on every invocation, before the program runs.
// `BASH_ENV` is the sharpest — the bash tool spawns `bash -c`, which sources
// it every time — but every runtime an agent shells out to has one.
// Interpreter startup hooks.
'BASH_ENV', 'ENV', 'SHELLOPTS', 'BASHOPTS',
'PERL5OPT', 'PERL5LIB', 'PYTHONSTARTUP', 'PYTHONPATH', 'RUBYOPT', 'RUBYLIB',
'JAVA_TOOL_OPTIONS', '_JAVA_OPTIONS', 'JDK_JAVA_OPTIONS',
'PYTHONHOME',
// Version-control hooks that run a command on the setter's behalf, and the
// config redirections that can define such a hook indirectly (a substituted
// git config file can set core.pager or a credential helper).
// Version-control command hooks and config redirects.
'GIT_SSH', 'GIT_SSH_COMMAND', 'GIT_EXTERNAL_DIFF', 'GIT_PAGER', 'GIT_EDITOR',
'GIT_ASKPASS', 'SSH_ASKPASS',
'GIT_CONFIG_GLOBAL', 'GIT_CONFIG_SYSTEM', 'GIT_CONFIG_COUNT',
@@ -113,8 +108,6 @@ const BOOTSTRAP_NAMES = new Set([
'SSL_CERT_FILE', 'SSL_CERT_DIR',
'HTTP_PROXY', 'HTTPS_PROXY', 'ALL_PROXY', 'NO_PROXY',
'REQUESTS_CA_BUNDLE', 'CURL_CA_BUNDLE',
// Turns off TLS verification outright, which is the sharpest form of
// "how the network is trusted".
'NODE_TLS_REJECT_UNAUTHORIZED',
])
@@ -122,30 +115,8 @@ const BOOTSTRAP_NAMES = new Set([
const BOOTSTRAP_PREFIXES = ['DSH_', 'XDG_', 'DYLD_', 'BASH_FUNC_']
/**
* Whether a variable may come only from the inherited process environment.
*
* The invoking project is trusted to *configure* the agent's work — its
* endpoints, its ordinary variables, even a credential. It is not trusted to
* change the harness itself, and that is what a bootstrap variable does: it
* decides how a process launches (`PATH`, `NODE_OPTIONS`, `LD_PRELOAD`), what
* code a runtime executes before the program it was asked to run (`BASH_ENV`
* and its per-language siblings, the Git hook commands), where model-visible
* instructions load from (`DSH_*` covers the Harness home, the agents home,
* and the bundled skill root), or how the network is reached and trusted
* (proxy and CA variables).
*
* The distinction is that these take effect with no user action, before any
* turn, outside the permission policy and the sandbox — `DSH_PERMISSION_MODE`
* would switch off the approvals that make trusting a project meaningful at
* all, and `BASH_ENV` runs a file of the project's choosing on every single
* `bash -c` the tool issues. Trusting a project's code to run under the
* agent's policy is not the same as letting it rewrite that policy.
*
* They are therefore rejected at load rather than ranked below another layer:
* a user who wrote one into a file believes it applies, and silently ignoring
* it is its own failure. The whole `DSH_*` namespace is denied rather than an
* audited subset, because a switch added later must not become settable by
* being forgotten.
* Whether a variable may come only from the inherited process environment
* because it changes process, runtime, VCS, or network bootstrap.
* @param name - the variable name.
* @returns true when only the inherited environment may supply it.
*/
@@ -155,12 +126,8 @@ function isBootstrapOnly(name: string): boolean {
}
/**
* 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.
* Parse one directory's `.env` without applying it, rejecting bootstrap-only
* names before any value is materialized.
* @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.
@@ -181,12 +148,7 @@ function readEnvLayer(
// ENOENT (no .env) is fine — rely on the ambient environment.
return undefined
}
// `node:util`'s parseEnv is the same parser `--env-file` and
// `process.loadEnvFile` use. Checking with a second dialect (npm dotenv)
// would leave the rejection rule and the thing it guards on independently
// maintained parsers: a name Node accepts but the checker does not would
// reach `process.env` unchecked, and `BASH_ENV` there runs a file of the
// project's choosing on every `bash -c` the bash tool issues.
// Parse once so validation and materialization use exactly the same entries.
const values = parseEnv(content) as Record<string, string>
for (const name of Object.keys(values)) {
if (!isBootstrapOnly(name)) continue
@@ -200,30 +162,10 @@ function readEnvLayer(
}
/**
* 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.
*
* Each layer is parsed once, checked, and only then applied — never replacing
* a name already set, which is what makes the layering `user < project <
* inherited`. The single parse is deliberate: the rejection rule and the
* values that reach `process.env` must come from the same parser, or a name
* one dialect accepts and the other misses would slip past the check. 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
* read. Only the product CLI layers these files: an SDK or example bin loads
* its own directory through {@link loadEnv} and must not inherit a developer's
* `$DSH_HOME`.
*
* 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.
* Load the product CLI's inherited > invoking-directory `.env` > Harness-home
* `.env` snapshot. The Harness home resolves before either file; both files
* are checked before either is applied, and accepted values are materialized
* without replacing inherited ones. The snapshot preserves source provenance.
* @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.
@@ -239,12 +181,7 @@ export function loadLayeredEnv(
// 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)
// Assign the entries this function already parsed and checked, rather than
// re-reading each file through `process.loadEnvFile`. One parse means the
// snapshot, the rejection rule, and `process.env` can never disagree about
// what a file contains. Skipping names already set reproduces the
// never-replace behavior that makes the layering `user < project <
// inherited`.
// Apply the checked values without replacing a higher-ranked name.
for (const layer of [project, user]) {
if (layer === undefined) continue
for (const [name, value] of Object.entries(layer.values)) {
+1 -13
View File
@@ -114,8 +114,6 @@ describe('loadLayeredEnv', () => {
const warn = vi.fn()
try {
loadLayeredEnv(NAME, project, warn)
// Both files load; the project layer wins the name they share, and the
// inherited environment wins over both.
expect(process.env[NAMES[0]]).toBe('project')
expect(process.env[NAMES[1]]).toBe('user-only')
expect(process.env[NAMES[2]]).toBe('project-only')
@@ -142,8 +140,6 @@ describe('loadLayeredEnv', () => {
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()
@@ -162,7 +158,6 @@ describe('loadLayeredEnv', () => {
const snapshot = loadLayeredEnv(NAME, project, vi.fn())
expect(snapshot.get(NAMES[1])).toEqual({ value: 'u', source: 'user-env', path: join(home, '.env') })
expect(snapshot.get(NAMES[2])).toEqual({ value: 'p', source: 'project-env', path: join(project, '.env') })
// getFrom is a refusal, not a demotion: an omitted layer is invisible.
expect(snapshot.getFrom(NAMES[2], ['process', 'user-env'])).toBeUndefined()
} finally {
clear()
@@ -190,9 +185,7 @@ describe('loadLayeredEnv', () => {
it('warns and continues when a layer exists but cannot be read', () => {
const home = tmp()
const project = tmp()
// A directory named `.env` is present-but-unreadable (EISDIR): unlike an
// absent file, it is a real misconfiguration, so it is reported rather
// than passed over in silence — and the other layers still load.
// A directory named `.env` is a present-but-unreadable layer.
mkdirSync(join(home, '.env'))
writeFileSync(join(project, '.env'), `${NAMES[2]}=project-only\n`)
clear()
@@ -238,8 +231,6 @@ describe('loadLayeredEnv', () => {
vi.stubEnv('DSH_HOME', home)
const warn = vi.fn()
try {
// No user `.env` exists, which is ordinary rather than a fault: the
// layer is simply absent, and nothing is reported.
const snapshot = loadLayeredEnv(NAME, project, warn)
expect(warn).not.toHaveBeenCalled()
expect(snapshot.get(NAMES[2])).toEqual({ value: 'project-only', source: 'project-env', path: join(project, '.env') })
@@ -270,9 +261,6 @@ describe('loadLayeredEnv', () => {
clear()
vi.stubEnv('DSH_HOME', both)
try {
// One file cannot be two layers. It is the project layer, because that
// is the more trusted of the two — reading it twice would otherwise
// put the same path at two different ranks.
const snapshot = loadLayeredEnv(NAME, both, vi.fn())
expect(snapshot.get(NAMES[2])).toEqual({ value: 'one-file', source: 'project-env', path: join(both, '.env') })
} finally {
+10 -31
View File
@@ -1,15 +1,8 @@
/**
* 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.
* Immutable launch-time environment snapshot with per-value source
* provenance. Harness consumers resolve through it instead of a flattened
* `process.env`; launchers may still materialize accepted values for config
* expressions and third-party libraries.
* @module @deepseek-ai/dsh-environment
*/
@@ -49,10 +42,8 @@ export interface EnvironmentSnapshot {
*/
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.
* Resolve one name only from `sources`, retaining canonical trust order;
* omitted layers are unreachable.
* @param name - the variable name.
* @param sources - the layers allowed in the canonical trust order.
* @returns the first matching entry, or `undefined`.
@@ -85,13 +76,8 @@ export interface EnvironmentLayerInput {
* @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. Windows environment
// names are case-insensitive, so lookups there fold case: otherwise a shell
// that set `deepseek_api_key` would be invisible to a consumer asking for
// `DEEPSEEK_API_KEY`, and a lower-ranked layer spelling it in caps would win
// a decision the launch had already made. POSIX names are case-sensitive and
// must stay exact.
// Copy every layer so later mutations cannot change the snapshot. Fold names
// on Windows so case variants cannot split precedence; POSIX remains exact.
const bySource = new Map<EnvironmentSource, { path?: string; values: Map<string, string> }>()
for (const layer of layers) {
bySource.set(layer.source, {
@@ -120,15 +106,8 @@ export function createEnvironmentSnapshot(layers: readonly EnvironmentLayerInput
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.
* Return the launcher's snapshot, or the inherited environment as the sole
* layer when the host provided none.
* @param ctx - the consuming plugin's context.
* @returns the snapshot to resolve user-facing values against.
*/
+3 -11
View File
@@ -1,8 +1,6 @@
/**
* Gate: shipped Cordis configuration does not use the ordinary inline form
* for a credential or endpoint from the environment. This narrow source-shape
* lint prevents checked-in composition from bypassing the credential seam and
* endpoint ladder; adapters remain responsible for actual value resolution.
* Gate for forbidden credential or endpoint environment inlines in shipped
* Cordis configuration.
* @module scripts/verify-config-source-ownership
*/
@@ -21,13 +19,7 @@ const SHIPPED_CONFIG_GLOBS = [
'python/*/src/**/cordis.yml',
]
/**
* Config keys that must never be inlined from the environment. Line-anchored
* on purpose: this is a tripwire for the shape people actually write, not a
* YAML analysis. A folded scalar or a block-literal spelling would slip past
* it, which is acceptable because the rule it guards is also stated in the
* owning Agent Note and enforced by the adapters' own resolution.
*/
/** Ordinary single-line forms this narrow source-shape check rejects; not full YAML analysis. */
const INLINE_DENY = /^\s*(apiKey|baseURL|apiKeyEnv|authToken|headers)\s*:\s*!!js\b/
/** Return every forbidden inline environment form in shipped configuration. */