启动环境快照
一句话版:
launchEnvironmentOf(ctx)返回本次启动冻结的LaunchEnvironmentSnapshot。它是不可变的,并且记住了每个值来自哪一层——继承的process环境、项目<cwd>/.env、还是用户$DSH_HOME/.env。插件用get(name)解析面向用户的值,用getFrom(name, sources)只从允许的层里选,省略某层是"拒绝"而不是"降级"。
这是 运行时自省 的延伸:不是检查"当前进程里挂了哪些插件",而是按下启动环境这一层。DSH 不想让你读压平的 process.env,因为三层环境的可信度不同,压平后无法区分"这是用户明确 export 的,还是项目里某人写的 .env 塞进来的"。读完你能在你自己的插件里正确读取环境配置,并理解它和 env-vars(加载侧)、credentials(凭据解析侧)的边界。
一、整体模型
| 层 | 来源 id | 它是什么 |
|---|---|---|
| 继承的进程环境 | process | 启动 shell、CI 任务或容器传入的东西——本次运行的明确意图 |
<invocation cwd>/.env | project-env | harness 被启动于其中的项目;产品信任它配置自己的 agent |
$DSH_HOME/.env | user-env | 用户自己的机器级默认值 |
信任顺序从高到低:继承环境 > 项目 .env > 用户 .env,写死在源码里:
// packages/util/launch-environment/src/index.ts
const SOURCE_ORDER: readonly LaunchEnvironmentSource[] = ['process', 'project-env', 'user-env']
这些值同样会进入 process.env——用户自己的 --config 树和第三方库要读它——但那份压平视图不是 harness 解析任何值的依据。插件解析要用快照 API,而不是 process.env。
二、入口:launchEnvironmentOf(ctx)
源码:
packages/util/launch-environment/src/index.ts的launchEnvironmentOf与DSH_LAUNCH_ENVIRONMENT_KEY。
上下文 slot key 是 launchEnvironment(字符串常量 DSH_LAUNCH_ENVIRONMENT_KEY)。取快照走 ctx.get(DSH_LAUNCH_ENVIRONMENT_KEY);没有快照时回退到只含 process 一层的快照(见下面的 ?? 分支):
// packages/util/launch-environment/src/index.ts
export function launchEnvironmentOf(ctx: Context): LaunchEnvironmentSnapshot {
return ctx.get(DSH_LAUNCH_ENVIRONMENT_KEY)
?? createLaunchEnvironmentSnapshot([{ source: 'process', values: process.env as Record<string, string> }])
}
回退不会削弱规则:当产品 CLI 启动这棵树时,launcher 先把快照放进 ctx.launchEnvironment;SDK 宿主或裸 cordis.yml 从未发现过任何文件,所以它拥有的确实就是它被启动时的环境(只有 process 一层,没有项目/用户 .env)。
slot 的类型在声明合并里暴露,插件可直接类型访问:
// packages/util/launch-environment/src/index.ts(末尾)
declare module '@deepseek-ai/cordis' {
interface Context {
/** Launcher-owned snapshot of this run's environment; absent in compositions the product CLI did not boot. */
launchEnvironment?: LaunchEnvironmentSnapshot
}
}
消费示例——取一个面向用户的值,拿不到就回退到自己的默认:
import { launchEnvironmentOf } from '@deepseek-ai/dsh-launch-environment'
import type { Context } from '@deepseek-ai/cordis'
export function apply(ctx: Context) {
const entry = launchEnvironmentOf(ctx).get('DEEPSEEK_BASE_URL')
const baseUrl = entry?.value ?? 'https://api.deepseek.com'
// entry?.source → 'process' | 'project-env' | 'user-env'(谁提供的)
// entry?.path → 提供它的 .env 的绝对路径(process 层没有)
}
三、快照两个方法:get 与 getFrom
LaunchEnvironmentSnapshot 是只读接口,只有两个方法。快照构造后任何东西都不会再改它——之后即使 chdir、切换工作区、或恢复某个会话,看到的都是启动时解析到的同一批值。
| 方法 | 语义 |
|---|---|
get(name) | 按规范信任顺序搜索所有层,返回胜出的 { value, source, path? },无层提供则 undefined |
getFrom(name, sources) | 只从 sources 列出的层里搜索,保留规范信任顺序;未列出的层不可达 |
每个条目长这样:
interface LaunchEnvironmentEntry {
value: string // 该层提供的值;可能为空串,由各 owner 自行判断
source: LaunchEnvironmentSource // 'process' | 'project-env' | 'user-env'
path?: string // 提供它的 .env 的绝对路径;process 层没有
}
源码对三层快照的测试最能说明行为(packages/util/launch-environment/tests/launch-environment.spec.ts):
const layered = createLaunchEnvironmentSnapshot([
{ source: 'process', values: { SHARED: 'from-process', ONLY_PROCESS: 'p' } },
{ source: 'project-env', path: '/work/.env', values: { SHARED: 'from-project' } },
{ source: 'user-env', path: '/home/.dsh/.env', values: { SHARED: 'from-user' } },
])
layered.get('SHARED') // { value: 'from-process', source: 'process' } ← 信任顺序胜出
layered.get('ABSENT') // undefined
layered.getFrom('SHARED', ['user-env', 'process'])
// { value: 'from-process', source: 'process' } ← 仍然按规范顺序,不按你排序
layered.getFrom('ONLY_PROCESS', []) // undefined
省略某层是"拒绝",不是"降级"
getFrom 不改变信任顺序,只排除层。这是它存在的意义:绝不能接受某一层的调用方,直接不把它列出来。一个路由字段如果永远不该来自项目目录,只能靠"不列出 project-env"实现——不能靠重新排序,因为重新排序仍然会慢吞吞地漏进它。
credentials-local 就是教科书式的例子(packages/credentials/credentials-local/src/index.ts):继承环境走 getFrom(ref, ['process']),.env 回退走 getFrom(ref, ['project-env', 'user-env'])——两层被刻意分开处理,谁也不许越界到另一侧:
// 继承环境第一,且只认 process
const entry = launchEnvironmentOf(this.ctx).getFrom(ref, ['process'])
// .env 回退在受管 store 之下,项目排用户前
const entry = launchEnvironmentOf(this.ctx).getFrom(ref, ['project-env', 'user-env'])
提供方适配器(如 llm 提供方读 API key)则三层全列:因为产品信任它所运行的项目。getFrom 机制是为那些并非如此的决策准备的。
Windows 大小写折叠
变量名按平台自身规则匹配:POSIX 精确匹配,Windows 不区分大小写。lookupKey 只在 win32 上把名字 toUpperCase():
// packages/util/launch-environment/src/index.ts
function lookupKey(name: string): string {
return process.platform === 'win32' ? name.toUpperCase() : name
}
为什么必要:Windows 上做大小写敏感的查找会选错层——shell 里的 deepseek_api_key 与项目 .env 里的 DEEPSEEK_API_KEY 对操作系统是同一个变量,当成两个就会让项目 .env 胜出。POSIX 不折叠。
四、三个不可变的构造细节
createLaunchEnvironmentSnapshot 还有三个防止误用的细节(都来自源码与测试):
// packages/util/launch-environment/src/index.ts(截选)
const bySource = new Map(...) // 每个层的内容被复制进 Map,之后外部变更影响不到快照
- 复制层内容:快照构造时把每一层的
values拷贝进自己的Map;之后别人改源对象(如往process.env加键)不会改变快照。测试验证了"先造快照再改源,快照不变"。 - 空值视为"存在":某层提供了空串,
get仍返回{ value: '', ... }——由 owner 自行判断空串是否算数(如 credentials-local 里length > 0才当值用)。 - 查找顺序与构造顺序无关:快照按规范
SOURCE_ORDER搜索,你以什么顺序传层都效果一样。
五、与 env-vars(加载侧)、credentials(凭据解析侧)的边界
dsh-launch-environment 只管"提供只读快照"。它不加载 .env、不解析凭据——加载和凭据解析是别的包。
| 阶段 | 包 | 职责 |
|---|---|---|
| env-vars 加载侧 | packages/boot/app-boot 的 loadLayeredEnv | 启动时读 process、<cwd>/.env、$DSH_HOME/.env,构造快照并放进 ctx.launchEnvironment |
| 快照读取侧 | launch-environment | 就是本文讲的这份 API |
| credentials 解析侧 | packages/credentials/credentials-local | 用 getFrom 把环境当作受管 store 之下的回退 |
加载侧 loadLayeredEnv 的要点(packages/boot/app-boot/src/index.ts):
- 先解析
.env再应用:任何一层报错都不能只留下一个文件被应用。 - 把已校验的值物化回
process.env时不覆盖更高排名的名字(if (process.env[name] === undefined))。 - 校验
.env不得声明 bootstrap-only 变量——启动 shell 决定"本进程怎么启动、代码/指令从哪加载、怎么上网"的变量只能由 launching environment export,写进.env会被拒绝。 - 没有按工作区划分的层:项目层是调用目录,启动时固定;以后在 Web UI 里选的工作区不贡献任何内容(否则模型自己的工作区能在会话中途改 harness 环境)。
与凭据解析侧的边界一句话:credentials 的归属权在受管 store;.env/环境只是它最低的回退层级。凭据包不会把 .env 当作第一来源,而是用 getFrom 精确圈定它们允许读取的层,其余一概不碰。
六、验证与试一试
# 看三层快照、SOURCE_ORDER、getFrom 的"拒绝非降级"行为
cd ~/.dsh/source/current && npx vitest run packages/util/launch-environment/tests/launch-environment.spec.ts
# 看一个真实消费方怎么解析 value + 只看 process 层
grep -n "launchEnvironmentOf.*getFrom" packages/credentials/credentials-local/src/index.ts
# 看有没有配置能回退到环境变量
grep -rn "launchEnvironmentOf(ctx).get" packages/llm packages/web | head
在会话里验证一个具体值来自哪一层(在配置里导出后观察 source/path):
# 让模型跑一个能打印来源的消费方;或直接查:
# launchEnvironmentOf(ctx).get('DEEPSEEK_BASE_URL')
# 返回 { value, source, path? } —— 用 source 区分是谁提供的
下一步
- 运行时不变量:DSH 怎么对运行时契约做持续自检
- 运行时自省与动态插件:用
cordis_inspect_list/query查契约,再走define → run/update - 写一个服务:能力,而非只是工具