第五部分 · 会话、身份与扩展
第 15 章:设置、凭证与匿名身份
与用户相关的三件事:可热更的配置、按名引用的秘密、与机器无关的 ID
三者有一个共同点:没有模型工具
设置(settings)、凭证(credentials)、匿名身份(identity)在 dsh 里有一个反直觉的共同点:它们都没有模型工具。模型不直接读设置、不直接碰凭证、不知道匿名 ID 的存在。三者都是宿主侧的接缝:设置走 ctx.settings(配置能力)、凭证走 ctx.credentials(引用能力)、身份是 getOrCreateAnonymousUserId(一个纯函数)。第 17 章会看到它们的 UI 入口——设置面板经 apiproxy RPC 读写,浏览器永远拿不到凭证的值,只能拿「引用」。这是「模型可见 ⟺ 已记录」的镜像原则:有些东西模型不应该看见。
settings:可热更的配置接缝
ctx.settings(packages/settings/settings)是一个 capability 接缝:Service Definition + settings-file provider(把配置文档读写到磁盘)+ 各插件的 settings 节。它解决的是「cordis.yml 里的配置是启动期的事实,但用户想运行期改」的问题——第 9 章见过一个实例:llm-deepseek 插件注册 llm-deepseek 命名空间,用户设置文档里的同名节覆盖 cordis.yml 入口配置且不需要重启(每请求经 options() thunk 重读,失败保留 lastGood)。
settings-file provider 的实现细节值得一提:文件锁 + reconcile(回写规范化)+ 注释保留的 diff(用户手写的注释不被重写抹掉)。mutate() 支持 path ops(供持有脱敏视图的调用者做定点修改,避免整段 replace 删掉没看见的 secret);redactSecrets 按 schema 的 role('secret') 字段脱敏,describe({redactSecrets: true}) 是 wire 表面的必选项——浏览器拿到的永远是脱敏视图。两个事件分工明确:settings/updated(值变才发)与 settings/document-updated(raw section 变就发)。设置文档是「人可读可改的配置」与「程序化的配置」之间的桥梁——schema 由各插件声明(llm-deepseek 的 Config schema 同时是 cordis.yml 入口与 settings 节的 schema),所以「配置即代码」的两端共享同一份校验。
credentials:按名引用的秘密
凭证接缝(packages/credentials/credentials)的原则一句话:配置只存环境变量名,值在操作时现场解析。provider(credentials-local)的分层查找:
进程 env > .credentials.yaml > 项目 .env > $DSH_HOME/.env
第 9 章的 DeepSeek adapter 就是这么拿 API key 的:resolveApiKey(connection) 每请求解析(端点与密钥同一快照——URL 与 secret 永远同代)。三条纪律让「引用」名副其实:resolve(ref) 每次操作解析一次、不跨操作缓存——key 轮换即时生效,无需重启;describe(ref) 报告 configured/source/writable,但绝不返回值——描述一个引用只回答「它从哪来」,永不回答「它是什么」;env 遮蔽写入被拒绝(assertUnshadowed——写一个会被更高优先级 env 覆盖的值等于静默无效,不如报错);YAML 解析错误信息不引用值(防泄密)。模型侧永远看不到凭证值——它只看到「引用」(如 env: DEEPSEEK_API_KEY)。读取凭证被列入第 17 章的 PRIVILEGED_METHODS(环回地址才允许)——「描述一个引用会暴露它来自哪里」也是一种侦察。
匿名身份:一个排他写的 UUID
匿名身份的实现小到可以整段读(packages/identity/anonymous-user-id/src/index.ts):
export function getOrCreateAnonymousUserId(options: AnonymousUserIdOptions = {}): AnonymousUserId {
const file = join(resolveDshHome(undefined, options.env ?? process.env), ANONYMOUS_USER_ID_FILE_NAME)
const cached = memo.get(file)
if (cached !== undefined) return cached
let id = readPersistedId(file)
if (id === undefined) {
const generate = options.randomUUID ?? randomUUID
const created = generate() as AnonymousUserId
try {
mkdirSync(dirname(file), { recursive: true })
writeFileSync(file, `${created}\n`, { encoding: 'utf8', flag: 'wx' })
id = created
} catch {
// A wx refusal (EEXIST) covers both a concurrent winner and a
// pre-existing corrupt file: the reread adopts a valid winner, and an
// invalid reread falls through to the overwrite path. ...
id = readPersistedId(file)
if (id === undefined) {
try {
writeFileSync(file, `${created}\n`, 'utf8')
} catch {
// Best-effort persistence: keep the fresh id in memory even when the
// home is unwritable, so this run still reports a consistent id.
}
id = created
}
}
}
memo.set(file, id)
return id
}
三个设计决定:
flag: 'wx'排他写:并发首次启动由 exclusive-create 裁决,输家重读赢家的 id(EEXIST 同时覆盖「并发赢家」与「已存在的损坏文件」两种情况)。- best-effort 持久化:只读 home 写失败也返回一个可用 id——遥测与反馈永不因写入失败被阻塞。
- 范围绑定 home 而非机器:id 随
$DSH_HOME走——换机器带 home 就是同一 id,同一机器换 home 就是不同 id。匿名性由「home 即身份边界」定义。 - 绝不派生宿主特征:id 是纯随机 UUID——不派生 hostname、网络地址或 git remote。身份与「这台机器是谁」完全无关,删文件即换新身份。
用途:遥测(session-telemetry 的匿名归属)与反馈(feedback 命令)共用同一个 id——「谁在反馈」与「谁在被遥测」是同一匿名主体,但不含任何可识别信息。
把第 4 章的「模型可见 ⟺ 已记录」与这一章的「模型不可见」放在一起看,dsh 对可见性的处理是两极化的:模型看到的一切都可重建、可审计;模型不该看到的一切(密钥、宿主配置、匿名身份)从结构上不进入模型视野。工具没有、事件没有、prompt 没有——不可见不是靠提示词约束,而是靠接口形状保证。
实践应用
- 配置热更靠接缝:settings 节覆盖入口配置、每请求重读、失败保留 lastGood——「运行期改配置不重启」不是魔法,是 thunk + 校验 + 兜底的三件套。
- 秘密按名引用:配置只存 env 变量名、操作时解析——值永不进日志、永不进模型上下文、永不进 UI;「引用与值分离」是凭证系统的最低要求。
- 排他写做并发裁决:
flag: 'wx'一把锁解决「并发首次启动」——输家重读赢家,比「先读后写」少一个竞态窗口。 - 匿名身份绑定 home 而非机器:把「匿名主体」定义为可迁移的目录而非物理机器——用户换机不换身份、同一机换 home 即换身份,语义清晰。
总结
这一章拆完了设置、凭证与匿名身份:可热更的 settings 接缝、按名引用的 credentials 分层、排他写的 anonymous user id——三者都贯彻「宿主侧的事不惊动模型」。第五部分还剩最后一章,也是最「dsh 特色」的一章:预设(preset)、自修改(cordis 工具族)与集成生态(hooks/MCP/审批)——模型亲手把插件装进自己的运行时。