跳到主要内容
路径文档

启动环境快照

一句话版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>/.envproject-envharness 被启动于其中的项目;产品信任它配置自己的 agent
$DSH_HOME/.envuser-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.tslaunchEnvironmentOfDSH_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 层没有)
}

三、快照两个方法:getgetFrom

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,之后外部变更影响不到快照
  1. 复制层内容:快照构造时把每一层的 values 拷贝进自己的 Map;之后别人改源对象(如往 process.env 加键)不会改变快照。测试验证了"先造快照再改源,快照不变"。
  2. 空值视为"存在":某层提供了空串,get 仍返回 { value: '', ... }——由 owner 自行判断空串是否算数(如 credentials-local 里 length > 0 才当值用)。
  3. 查找顺序与构造顺序无关:快照按规范 SOURCE_ORDER 搜索,你以什么顺序传层都效果一样。

五、与 env-vars(加载侧)、credentials(凭据解析侧)的边界

dsh-launch-environment 只管"提供只读快照"。它不加载 .env、不解析凭据——加载和凭据解析是别的包。

阶段职责
env-vars 加载侧packages/boot/app-bootloadLayeredEnv启动时读 process<cwd>/.env$DSH_HOME/.env构造快照并放进 ctx.launchEnvironment
快照读取侧launch-environment就是本文讲的这份 API
credentials 解析侧packages/credentials/credentials-localgetFrom 把环境当作受管 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 区分是谁提供的

下一步