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:
6
packages/util/environment/README.i18n.yaml
Normal file
6
packages/util/environment/README.i18n.yaml
Normal file
@@ -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
packages/util/environment/README.md
Normal file
42
packages/util/environment/README.md
Normal 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
packages/util/environment/README.zh.md
Normal file
42
packages/util/environment/README.zh.md
Normal 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
packages/util/environment/package.json
Normal file
37
packages/util/environment/package.json
Normal 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
packages/util/environment/src/index.ts
Normal file
178
packages/util/environment/src/index.ts
Normal 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))
|
||||
}
|
||||
30
packages/util/environment/src/invariant.ts
Normal file
30
packages/util/environment/src/invariant.ts
Normal file
@@ -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 */
|
||||
118
packages/util/environment/tests/environment.spec.ts
Normal file
118
packages/util/environment/tests/environment.spec.ts
Normal file
@@ -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
packages/util/environment/tsconfig.json
Normal file
15
packages/util/environment/tsconfig.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user