Merge latest master into worktree/pr884-simplify
This commit is contained in:
@@ -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 .agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md
|
||||
2026-07-23-client-plugin-loading-model.md: b0873b7aa7bccd3d613f3113fa18207770952e5d
|
||||
2026-07-23-client-plugin-loading-model.zh.md: f3472dbfc5a78924e77337bf92ce5983c8492c4c
|
||||
2026-07-23-client-plugin-loading-model.md: 02347f2964942b89ec1f0a6ec483f4c2b2f9e68c
|
||||
2026-07-23-client-plugin-loading-model.zh.md: ea927d35860fbbba567c47cea0ee3a45133ce0f4
|
||||
|
||||
@@ -56,8 +56,8 @@ What happens between `dsh web` starting and the UI appearing? Three stages: the
|
||||
|
||||
**Host side — compose the graph.**
|
||||
|
||||
1. The composing app (`apps/cli`) ships the roster as ordinary rows in its `cordis.yml` config tree — client plugin packages are entry rows like every host plugin, and `--dev` appends the `client-hmr` row in code (`AppCLIEntry`) before the settle/sweep so the fail-loud triple covers it. A roster row that fails to import is caught by the boot's `assertEntriesLoaded`.
|
||||
2. The `dsh-client-modules` node half (the package is dual-face: its browser half is the module table) scans loader entries' package.json `dshClient` declarations and composes `window.__DSH_BOOT__`: `{ rev, entries: [{ id, url, rev, inject?, immediately? }] }`. The `inject` edges and the `immediately` mark come from manifests, never hand-copied. It refuses a declared plugin without a built `./client` bundle, and any malformed declaration field — activation-time fail loud (a FAILED fiber the sweep reports).
|
||||
1. The composing app (`apps/cli`) ships the roster as ordinary rows in its `cordis.yml` config tree — client plugin packages are entry rows like every host plugin, and `--dev` appends the `client-hmr` row in code (`AppCLIEntry`) before the host activation audit so the same check covers it. A roster row that fails to import is caught by `assertEntriesLoaded`; a row whose fiber rejects is reported with its original stack by `assertEntriesActivated` ([host boot decision](2026-07-24-web-config-tree-boot-and-transport-layering.md)).
|
||||
2. The `dsh-client-modules` node half (the package is dual-face: its browser half is the module table) scans loader entries' package.json `dshClient` declarations and composes `window.__DSH_BOOT__`: `{ rev, entries: [{ id, url, rev, inject?, immediately? }] }`. The `inject` edges and the `immediately` mark come from manifests, never hand-copied. It refuses declared plugins without built `./client` bundles and groups their package/path rows under one required source-build instruction; malformed declaration fields also fail activation, and the host audit reports either error from the FAILED fiber.
|
||||
3. Scanning is incremental per package — there is no full-rescan code path. Each cordis `internal/plugin` emission marks the fiber's entry name dirty (entry-less fibers drop O(1)); a microtask flush reconciles each dirty name against live loader entries, with package metadata (including the negative "not a client package" verdict) cached per name forever and bundle re-hashing reachable only through `rebuilt(id)`. The activation pass seeds the same dirty set from current entries and flushes synchronously, so first scan and steady state share one implementation. Each bundle's content hash is its `rev` (cache busting + HMR diff anchor), the row set hashes into `graph.rev`, and every row is fetch-served: `/plugins/<id>/client.js?rev=…`. The graph types are single-sourced in the modules package's `./impl` export — the webserver knows nothing about the graph (it is a plain route-registration plugin; modules registers the bundle route and taps the index render itself).
|
||||
|
||||
Why is the roster yml rows and not a scan? Because which plugins compose into a deployment is a composition decision, not a package property — a dshClient package existing in the repo does not mean this deployment mounts it, so discovery-by-scan cannot make that call; the node half scans only what the tree actually mounted.
|
||||
|
||||
@@ -56,8 +56,8 @@ vendored Loader 经其 `internal` seam 消费模块系统——唯一调用点
|
||||
|
||||
**host 侧——组合这张图。**
|
||||
|
||||
1. 负责组合的 app(`apps/cli`)把名册作为普通行放进它的 `cordis.yml` 配置树——client 插件包与每个 host 插件一样是 entry 行,`--dev` 由代码(`AppCLIEntry`)在 settle/sweep 之前追加 `client-hmr` 行,使 fail-loud 三件套一并覆盖它。名册行 import 失败由 boot 的 `assertEntriesLoaded` 捕获。
|
||||
2. `dsh-client-modules` 的 node 半(该包是双面的:浏览器半就是模块表)扫描 loader entry 的 package.json `dshClient` 声明,组合出 `window.__DSH_BOOT__`:`{ rev, entries: [{ id, url, rev, inject?, immediately? }] }`。`inject` 边与 `immediately` 标记都来自 manifest,永不人肉抄写。它拒绝声明了插件却没有已构建 `./client` bundle 的包,也拒绝任何畸形的声明字段——激活期大声失败(FAILED fiber,由 sweep 上报)。
|
||||
1. 负责组合的 app(`apps/cli`)把名册作为普通行放进它的 `cordis.yml` 配置树——client 插件包与每个 host 插件一样是 entry 行,`--dev` 由代码(`AppCLIEntry`)在 host 激活检查之前追加 `client-hmr` 行,使同一项检查覆盖它。名册行 import 失败由 `assertEntriesLoaded` 捕获;fiber reject 的行则由 `assertEntriesActivated` 报告原始 stack([host boot 决策](2026-07-24-web-config-tree-boot-and-transport-layering.md))。
|
||||
2. `dsh-client-modules` 的 node 半(该包是双面的:浏览器半就是模块表)扫描 loader entry 的 package.json `dshClient` 声明,组合出 `window.__DSH_BOOT__`:`{ rev, entries: [{ id, url, rev, inject?, immediately? }] }`。`inject` 边与 `immediately` 标记都来自 manifest,永不人肉抄写。它会拒绝没有已构建 `./client` bundle 的已声明插件,并把它们的 package/path 行归到一条源码构建要求下;畸形声明字段同样会让激活失败,host 检查会从 FAILED fiber 报告这两类错误。
|
||||
3. 扫描是单包增量——不存在全量重扫代码路径。每次 cordis `internal/plugin` 发射把该 fiber 的 entry 名标脏(无 entry 的 fiber O(1) 丢弃);微任务 flush 把每个脏名对账 live loader entries,包元数据(含「非 client 包」的否定结论)按名永久缓存,bundle 重哈希只经 `rebuilt(id)` 可达。激活趟从当前 entries 灌同一脏集合并同步 flush,初扫与稳态共享一条实现。每个 bundle 的内容哈希是其 `rev`(缓存失效 + HMR diff 锚点),行集合哈希进 `graph.rev`,每一行都经 fetch 供给:`/plugins/<id>/client.js?rev=…`。图类型单源在 modules 包的 `./client` 出口——webserver 对图一无所知(它是朴素路由注册插件;bundle 路由和 index 渲染 tap 都由 modules 自己注册)。
|
||||
|
||||
为什么名册是 yml 行而不是扫描?因为哪些插件组合进一次部署是组合决策,不是包属性——一个 dshClient 包存在于仓库里,不代表这次部署要挂载它,扫描发现无从替人做这个决定;node 半只扫描配置树实际挂载了的东西。
|
||||
|
||||
@@ -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 .agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md
|
||||
2026-07-24-web-config-tree-boot-and-transport-layering.md: a2080024d36d54162f4f4aa79896e51efd708f59
|
||||
2026-07-24-web-config-tree-boot-and-transport-layering.zh.md: 1c430bf2939f9f556f378bdeb78872a0091a592d
|
||||
2026-07-24-web-config-tree-boot-and-transport-layering.md: 88f94b1f58ae7a3451c7772f4a9ff7d6564254c0
|
||||
2026-07-24-web-config-tree-boot-and-transport-layering.zh.md: ea2a8f70a6c2d4207d4388a9303fbc6ce6e94238
|
||||
|
||||
@@ -12,9 +12,9 @@ English | [中文](2026-07-24-web-config-tree-boot-and-transport-layering.zh.md)
|
||||
|
||||
## Decision
|
||||
|
||||
**Composition is one flat assembled tree.** `apps/cli/config/base.cordis.yml` plus `apps/cli/config/web.cordis.yml` holds every row — the host runtime (32 rows), the `api-gateway` row, the `webserver` row, and the `dshClient` rows (the browser roster; the modules row is simultaneously a host row). No spine bundle: every plugin is one row and every config field is yml-editable. That stance later became repository-wide, with the rows both surfaces share factored into `apps/cli/config/base.cordis.yml` and each surface reduced to an overlay ([shared-base overlays](../simplification/2026-07-29-shared-base-config-overlays.md)). `--dev` appends the `dsh-client-hmr` row in code before the settle sweep — prod and dev differ by exactly that row. Row order carries no load semantics; activation is service-availability driven, and the boot compensates with a fail-loud triple: `assertEntriesLoaded` (import failures), `installFailLoud` (late apply rejections), and an all-ACTIVE sweep (PENDING fibers — cordis inject waiting has no timeout).
|
||||
**Composition is one flat assembled tree.** `apps/cli/config/base.cordis.yml` plus `apps/cli/config/web.cordis.yml` holds every row — the host runtime (32 rows), the `api-gateway` row, the `webserver` row, and the `dshClient` rows (the browser roster; the modules row is simultaneously a host row). No spine bundle: every plugin is one row and every config field is yml-editable. That stance later became repository-wide, with the rows both surfaces share factored into `apps/cli/config/base.cordis.yml` and each surface reduced to an overlay ([shared-base overlays](../simplification/2026-07-29-shared-base-config-overlays.md)). `--dev` appends the `dsh-client-hmr` row in code before the settle audit — prod and dev differ by exactly that row. Row order carries no load semantics; activation is service-availability driven. The shared audit rejects imports with no fiber, awaits only failed fibers to recover original activation errors, and reports services that leave a fiber `PENDING`; before throwing, it marks those exact rejection reasons through one process checkpoint so `installFailLoud` coalesces Loader's duplicate notification while unrelated unhandled rejections remain fatal. The Node app-boot artifact embeds `@cordisjs/plugin-include` while leaving `@cordisjs/plugin-loader` external, so the include's `EntryTree` and the host bind to one Loader peer instead of splitting a config tree across two Loader implementations.
|
||||
|
||||
**Boot glue is a class pair.** `AppCLIEntry` (apps/cli) and `AppWebEntry` (the shell kernel) hold only what must exist independently of cordis: argv facts, the composed patch set, the parsed boot manifest, the module system instance, loading-page handles — everything else lives in plugins. `AppCLIEntry.run()` is three stages: layered env (ambient > cwd `.env` > `$DSH_HOME/.env`, closing the defect above) → patch composition → Loader include boot plus the triple. `AppWebEntry.run()` mirrors it browser-side: parse `window.__DSH_BOOT__` into a `BootManifest` (two views: npm-package rows for the module table, cordis-plugin rows for entry composition; malformed wire throws), build the module system, render the loading page, prefetch the `immediately` tier in parallel with Context/Loader setup, **await the prefetch before creating entries** (materialization is `tree.import`'s synchronous require, unprotected by fiber inject waiting; cross-package require edges such as i18n → runtime/client need every immediately-tier factory registered first — an empirically found 10–25% boot race otherwise), adopt the modules entry, create the graph rows, settle, sweep.
|
||||
**Boot glue is a class pair.** `AppCLIEntry` (apps/cli) and `AppWebEntry` (the shell kernel) hold only what must exist independently of cordis: argv facts, the composed patch set, the parsed boot manifest, the module system instance, loading-page handles — everything else lives in plugins. `AppCLIEntry.run()` is three stages: layered env (ambient > cwd `.env` > `$DSH_HOME/.env`, closing the defect above) → patch composition → Loader include boot plus the activation audit. `AppWebEntry.run()` mirrors it browser-side: parse `window.__DSH_BOOT__` into a `BootManifest` (two views: npm-package rows for the module table, cordis-plugin rows for entry composition; malformed wire throws), build the module system, render the loading page, prefetch the `immediately` tier in parallel with Context/Loader setup, **await the prefetch before creating entries** (materialization is `tree.import`'s synchronous require, unprotected by fiber inject waiting; cross-package require edges such as i18n → runtime/client need every immediately-tier factory registered first — an empirically found 10–25% boot race otherwise), adopt the modules entry, create the graph rows, settle, sweep.
|
||||
|
||||
**Config sources have one declaration place each.** yml static values are engineering defaults; the profile json (`./.dsh-tmp-profile/config.json`, read-only, never created, cwd-anchored until the `$DSH_HOME` migration) is user config mapped through a static `PROFILE_MAPPINGS` table onto target rows (`provider`/`model` → the `api-gateway` row, `persistenceRoot` → the jsonl row); CLI flags map onto the `webserver` row with a field set disjoint from the json's; env values enter through yml `!!js` expressions, never through the mapping table. Patches replace a row's config wholesale, so the entry class re-reads the yml row's static values (bypass parse) and merges overrides on top. An unmapped json key fails loud. The resolved frontend `distIndex` rides the same patch channel — an assembly fact, not user config.
|
||||
|
||||
|
||||
@@ -12,9 +12,9 @@ Status: implemented
|
||||
|
||||
## 决策
|
||||
|
||||
**组合结果是一棵平铺配置树。** `apps/cli/config/base.cordis.yml` 与 `apps/cli/config/web.cordis.yml` 共同持有全部行——host runtime(32 行)、`api-gateway` 行、`webserver` 行、`dshClient` 行(浏览器 roster;modules 行同时是 host 行)。不做 spine bundle:每插件一行、每个 config 字段 yml 可改。这一立场后来推广到全仓:两个 surface 共享的配置项被抽取进 `apps/cli/config/base.cordis.yml`,各 surface 则收敛为一份 overlay([共享 base overlay](../simplification/2026-07-29-shared-base-config-overlays.md))。`--dev` 在 settle sweep 之前由代码追加 `dsh-client-hmr` 行——prod 与 dev 的全部差异就是这一行。行序无装载语义;激活由服务可用性驱动,boot 以 fail-loud 三件套补偿:`assertEntriesLoaded`(import 失败)、`installFailLoud`(迟到的 apply 拒绝)、all-ACTIVE sweep(PENDING fiber——cordis inject 等待没有超时)。
|
||||
**组合结果是一棵平铺配置树。** `apps/cli/config/base.cordis.yml` 与 `apps/cli/config/web.cordis.yml` 共同持有全部行——host runtime(32 行)、`api-gateway` 行、`webserver` 行、`dshClient` 行(浏览器 roster;modules 行同时是 host 行)。不做 spine bundle:每插件一行、每个 config 字段 yml 可改。这一立场后来推广到全仓:两个 surface 共享的配置项被抽取进 `apps/cli/config/base.cordis.yml`,各 surface 则收敛为一份 overlay([共享 base overlay](../simplification/2026-07-29-shared-base-config-overlays.md))。`--dev` 在 settle audit 之前由代码追加 `dsh-client-hmr` 行——prod 与 dev 的全部差异就是这一行。行序无装载语义;激活由服务可用性驱动。共享 audit 会拒绝没有 fiber 的 import、仅等待失败的 fiber 以恢复原始激活错误,并报告让 fiber 停在 `PENDING` 的服务;抛出错误前,审计会通过一个进程级检查点标记这些 rejection 的确切原因,从而让 `installFailLoud` 将 Loader 的重复通知合并为一次,而无关的未处理 rejection 仍然致命。Node app-boot 产物内嵌 `@cordisjs/plugin-include`,但将 `@cordisjs/plugin-loader` 保持为外部依赖,因此 include 的 `EntryTree` 与 host 会绑定到同一个 Loader peer,而不会让一棵配置树横跨两个 Loader 实现。
|
||||
|
||||
**boot 胶水是一对 class。** `AppCLIEntry`(apps/cli)与 `AppWebEntry`(壳内核)只持有独立于 cordis 必须提前存在的东西:argv 事实、合成的 patch 集、解析出的 boot manifest、模块系统实例、loading 页句柄——其余一律进插件。`AppCLIEntry.run()` 三段:分层 env(ambient > cwd `.env` > `$DSH_HOME/.env`,顺手关掉上述缺陷)→ patch 合成 → Loader include boot 加三件套。`AppWebEntry.run()` 在浏览器侧镜像它:把 `window.__DSH_BOOT__` 解析成 `BootManifest`(双视角:npm 包行给模块表、cordis 插件行给 entry 组合;畸形 wire 大声抛)、建模块系统、渲染 loading 页、immediately 层预取与 Context/Loader 准备并行、**create entry 之前等预取齐**(物化是 `tree.import` 的同步 require,不受 fiber inject 等待保护;i18n → runtime/client 这类跨包 require 边要求 immediately 层工厂全部注册完——否则有实测 10–25% 的 boot 竞态)、收编 modules entry、逐图行 create、settle、sweep。
|
||||
**boot 胶水是一对 class。** `AppCLIEntry`(apps/cli)与 `AppWebEntry`(壳内核)只持有独立于 cordis 必须提前存在的东西:argv 事实、合成的 patch 集、解析出的 boot manifest、模块系统实例、loading 页句柄——其余一律进插件。`AppCLIEntry.run()` 三段:分层 env(ambient > cwd `.env` > `$DSH_HOME/.env`,顺手关掉上述缺陷)→ patch 合成 → Loader include boot 加 activation audit。`AppWebEntry.run()` 在浏览器侧镜像它:把 `window.__DSH_BOOT__` 解析成 `BootManifest`(双视角:npm 包行给模块表、cordis 插件行给 entry 组合;畸形 wire 大声抛)、建模块系统、渲染 loading 页、immediately 层预取与 Context/Loader 准备并行、**create entry 之前等预取齐**(物化是 `tree.import` 的同步 require,不受 fiber inject 等待保护;i18n → runtime/client 这类跨包 require 边要求 immediately 层工厂全部注册完——否则有实测 10–25% 的 boot 竞态)、收编 modules entry、逐图行 create、settle、sweep。
|
||||
|
||||
**每个配置源有唯一声明位置。** yml 静态值是工程默认;profile json(`./.dsh-tmp-profile/config.json`,只读、绝不创建、暂锚 cwd 直至 `$DSH_HOME` 迁移)是用户配置,经静态 `PROFILE_MAPPINGS` 表映射到目标行(`provider`/`model` → `api-gateway` 行,`persistenceRoot` → jsonl 行);CLI flags 映射到 `webserver` 行、字段集与 json 不相交;env 值经 yml `!!js` 表达式进入,绝不进映射表。patch 整体替换行 config,故 entry 类旁路 parse 重读 yml 行静态值再叠加覆盖。未映射的 json 键 fail loud。解析出的前端 `distIndex` 走同一 patch 通道——装配事实,不是用户配置。
|
||||
|
||||
|
||||
@@ -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 .agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.md
|
||||
2026-07-29-request-level-llm-config-credentials.md: d84de8b74eb88720d782dfa20e193e6d180843dc
|
||||
2026-07-29-request-level-llm-config-credentials.zh.md: 67b2d2e7cf952a7cb385e7c9dd67bd3a05651beb
|
||||
2026-07-29-request-level-llm-config-credentials.md: f12a2496a767decc3ce2b065f6be03009aec8992
|
||||
2026-07-29-request-level-llm-config-credentials.zh.md: 99fd90013a24746962ca02a5f4f18cdccd53f71a
|
||||
|
||||
@@ -4,7 +4,7 @@ Status: implemented
|
||||
|
||||
English | [中文](2026-07-29-request-level-llm-config-credentials.zh.md)
|
||||
|
||||
> Scope: the first production consumers of `ctx.settings` (the two LLM adapter plugins) and the `packages/credentials/` capability family. The later [read-only credentials and static routes](../simplification/2026-07-31-read-only-credentials-and-static-llm-routes.md) decision removes speculative credential mutation, the atomic-write extraction, and settings-driven route lifecycle; this note owns the surviving request-resolution rationale.
|
||||
> Scope: the first production consumers of `ctx.settings` (the two LLM adapter plugins), the new `packages/credentials/` capability family, and the `packages/util/atomic-write` extraction. The follow-up wire surface (`settings.*`/`credentials.*` RPC, secret-role masking, the web settings form) is a separate PR and not part of this note's shipped scope.
|
||||
|
||||
## Problem
|
||||
|
||||
@@ -12,18 +12,18 @@ The [settings seam](2026-07-28-user-settings-seam.md) shipped without a producti
|
||||
|
||||
## Decision
|
||||
|
||||
**Per-request resolution, not fiber rebuilds.** The adapters take an options thunk and a per-stream credential resolver instead of rebuilding their fibers. Connection, credential, and request-transport facts are read for the stream, while an in-flight stream keeps the facts it started with. Model catalog/capability, context, reasoning-default, provider-route, and retry-policy facts are composition-fixed. A missing key is a request-time `MISSING_CREDENTIAL` failure while the route remains registered.
|
||||
**Per-request resolution, not fiber rebuilds.** The adapters take an options thunk (and a per-stream credential resolver) instead of frozen construction facts, resolving once per operation — the Pi pattern, with its tested semantics: two requests straddling a change see two configurations, one request resolves exactly once, and an in-flight stream keeps the facts it started with. This deletes the entire swap machinery a rebuild design needs (`DUPLICATE_ADAPTER` ordering, `NO_ADAPTER` windows, a deferred-activation state machine) and makes a missing key a *request-time* actionable failure (`MISSING_CREDENTIAL` naming every entry point) while the route stays registered and the catalog stays browsable. The one registration-captured fact — the retry policy the `ctx.llm` registry snapshots at `registerAdapter` (plus pi-ai's route *set*) — re-registers the same adapter instance in one synchronous section when it changes.
|
||||
|
||||
**Secrets are references, values live behind `ctx.credentials`.** Configuration can carry `apiKeyEnv: DEEPSEEK_API_KEY`; the read-only credential seam resolves it per operation. `credentials-local` checks the live process environment first, then parses `$DSH_HOME/.env` on demand, with no cache or mutation surface. Resolution order in the adapters is a non-empty literal `apiKey` first, then the seam, then — only without a mounted seam — the named raw environment variable.
|
||||
**Secrets are references, values live behind `ctx.credentials`.** Configuration (both planes) carries `apiKeyEnv: DEEPSEEK_API_KEY`; the three-package credential seam resolves it per operation. `credentials-local` layers the live process environment (read-only, wins — a launch-time override is operator intent and must be *visibly* read-only, so shadowed writes reject instead of appearing to succeed) over `$DSH_HOME/.env` (writable, byte-preserving line edits, a quoting ladder dotenv reads back verbatim, wholesale snapshot replacement on reload so a deleted entry never lingers — the Claude Code additive-reapply lesson). Resolution order in the adapters is literal `apiKey` first (preserving the historical `config.apiKey ?? env` observable semantics), then the seam, then — only without a mounted seam — the raw environment variable.
|
||||
|
||||
**Per-plugin namespaces, schema ≡ `Config`.** Each adapter registers its own namespace (`llm-deepseek`, `llm-pi-ai`) with its plugin `Config` schema and `cordis.yml` entry as the composition `base`. `resolveAdapterOptions` and `resolveProfiles` remain the explicit validation steps. A live snapshot that changes a fixed fact or fails another bound keeps the whole last-good generation, while a bad entry config fails load. pi-ai's `providers` is a non-empty dict keyed by its composition-owned routes; the user layer may override only live request facts for those routes.
|
||||
**Per-plugin namespaces, schema ≡ `Config`.** Each adapter registers its own namespace (`llm-deepseek`, `llm-pi-ai`) with its plugin `Config` schema and its `cordis.yml` entry as the composition `base` — a settings section is the same YAML shape as the entry config, and `resolveAdapterOptions`/`resolveProfiles` stay the one explicit resolve step for both. A live snapshot failing a beyond-schema bound keeps the last good facts (the seam's last-good philosophy extended one level up); the entry config itself still fails load. pi-ai's `providers` became a dict keyed by route so base and user layers merge per provider and the route set is structural; the array shape fails loud with migration directions, and an empty dict is the valid dormant posture — a composition ships the adapter bare and every route stays a user-plane decision.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **A bridge plugin (`dsh-llm-models`) owning one unified `models` dict** — with per-plugin namespaces there is nothing left to bridge, and the adapter-mapping rules it needed were pure invented indirection.
|
||||
- **Secrets in settings.yaml under `role('secret')` masking** — deleting the problem (references) beats mitigating it (mask + backfill + sync warnings); the coding-agent cohort is unanimous.
|
||||
- **Registry-level live retry policy** — making `providerRetryPolicy` re-read per call would silently change the `ctx.llm` capture contract every registration relies on; retry policy therefore stays fixed with the composition-owned route.
|
||||
- **Registry-level live retry policy** — making `providerRetryPolicy` re-read per call would silently change the `ctx.llm` capture contract every registration relies on; re-registering the route in place keeps that contract and stays observable.
|
||||
|
||||
## Consequences
|
||||
|
||||
Booting without a key remains valid: the first request fails with the named reference, and an externally supplied environment or dotenv value reaches the next request without restart. The demos mount `settings-local` and the read-only `credentials-local` provider by default and inline no `!!js` key plumbing. The credential-management RPC/UI and registration mutation are absent until a current consumer justifies their contracts. Settings-layer arrays still replace wholesale, and pi-ai provider routes remain composition decisions. The [credential-boundaries note](2026-07-30-credential-boundaries-and-atomic-registration.md) owns the surviving storage and request-generation safety decisions.
|
||||
Onboarding is restart-free end to end (pinned by the `missing-credential` headless snapshot and the credentials-rotation composition tests): boot keyless, browse the catalog, store the key, prompt again. The demos mount `settings-local` + `credentials-local` by default and inline no `!!js` key plumbing. `runLoaderSmoke` gained `expectedExitCode` so a designed failure surface can be pinned rather than masked. Deferred: the wire/UI surface must redact `role('secret')` fields before any RPC exposes `describe()`, settings-layer arrays still replace wholesale (the deepseek `models` list), and a settings section cannot remove a composition-provided pi-ai route (only override or extend). Review of this seam later reworked where the store lives and who may read it, made one request resolve one configuration generation, and made route replacement atomic ([credential boundaries note](2026-07-30-credential-boundaries-and-atomic-registration.md)).
|
||||
|
||||
@@ -4,7 +4,7 @@ Status: implemented
|
||||
|
||||
[English](2026-07-29-request-level-llm-config-credentials.md) | 中文
|
||||
|
||||
> 范围:`ctx.settings` 的第一批生产消费方(两个 LLM 适配器插件)与 `packages/credentials/` 能力族。后续的[只读凭据与静态路由](../simplification/2026-07-31-read-only-credentials-and-static-llm-routes.md)决策移除了超前加入的凭据变更、atomic-write 抽取与 settings 驱动的路由生命周期;本 note 负责保留至今的请求解析理由。
|
||||
> 范围:`ctx.settings` 的第一批生产消费方(两个 LLM 适配器插件)、新增的 `packages/credentials/` 能力族,以及 `packages/util/atomic-write` 的抽取。后续的 wire 面(`settings.*`/`credentials.*` RPC、secret 角色脱敏、web 设置表单)是单独的 PR,不在本 note 已交付范围内。
|
||||
|
||||
## 问题
|
||||
|
||||
@@ -12,18 +12,18 @@ Status: implemented
|
||||
|
||||
## 决策
|
||||
|
||||
**按请求解析,而非重建 fiber。**适配器接收 options thunk 与按流调用的凭据解析器,不再重建其 fiber。连接、凭据与请求传输事实按流读取,进行中的流则保持其起始事实。模型 catalog/能力、上下文、推理(reasoning)默认值、提供方路由与重试策略由组合固定。密钥缺失会在请求时以 `MISSING_CREDENTIAL` 失败,同时路由保持注册。
|
||||
**按请求解析,而非重建 fiber。**适配器改为接收一个 options thunk(外加按流调用的凭据解析器),不再持有冻结的构造期事实,每个操作解析一次——即 Pi 的模式,连同其经测试固定的语义:跨越一次变更的两个请求看到两份配置,一个请求恰好解析一次,进行中的流保持其起始事实。这删掉了重建式设计所需的整套切换机制(`DUPLICATE_ADAPTER` 顺序问题、`NO_ADAPTER` 窗口、延迟激活状态机),并把密钥缺失变成*请求时*可行动的失败(`MISSING_CREDENTIAL` 点名每个配置入口),同时路由保持注册、catalog 保持可浏览。唯一在注册期捕获的事实——`ctx.llm` 注册表在 `registerAdapter` 时快照的重试策略(外加 pi-ai 的路由*集合*)——在其变化时于一个同步区段内原地重新注册同一适配器实例。
|
||||
|
||||
**机密是引用,值藏在 `ctx.credentials` 背后。**配置可以携带 `apiKeyEnv: DEEPSEEK_API_KEY`;只读凭据 seam 按操作解析它。`credentials-local` 先检查活跃进程环境,再按需解析 `$DSH_HOME/.env`,既不缓存,也不提供变更接口。适配器内的解析顺序为:非空的字面 `apiKey` 优先,然后是 seam,最后仅在未挂载 seam 时读取点名的原始环境变量。
|
||||
**机密是引用,值藏在 `ctx.credentials` 背后。**配置(两个面)携带 `apiKeyEnv: DEEPSEEK_API_KEY`;三包凭据 seam 按操作解析它。`credentials-local` 把活跃进程环境(只读、优先——启动时覆盖是操作者意图,必须*可见地*只读,因此被遮蔽的写入直接拒绝而不是表面成功)叠加在 `$DSH_HOME/.env` 之上(可写、保字节行级编辑、dotenv 能逐字读回的引号阶梯、重载时整体替换快照使删除的条目绝不滞留——来自 Claude Code 增量重放(additive reapply)的教训)。适配器内的解析顺序为:字面 `apiKey` 优先(保留历史 `config.apiKey ?? env` 的可观察语义),然后是 seam,最后——仅在未挂载 seam 时——原始环境变量。
|
||||
|
||||
**按插件划分 namespace,schema ≡ `Config`。**每个适配器注册自己的 namespace(`llm-deepseek`、`llm-pi-ai`),采用其插件 `Config` schema,并以 `cordis.yml` 配置项为组合 `base`。`resolveAdapterOptions` 与 `resolveProfiles` 仍是显式校验步骤。存活快照若更改固定事实或违反其他约束,会整代沿用最后可用设置;错误的 entry 配置则会加载失败。pi-ai 的 `providers` 是以组合所拥有路由为键的非空字典;用户层只能覆盖这些路由的实时请求事实。
|
||||
**按插件划分 namespace,schema ≡ `Config`。**每个适配器注册自己的 namespace(`llm-deepseek`、`llm-pi-ai`),schema 用其插件 `Config` schema,组合 `base` 用其 `cordis.yml` 条目——settings 分节与 entry 配置是同一种 YAML 形状,`resolveAdapterOptions`/`resolveProfiles` 对两者仍是唯一的显式 resolve 步骤。存活快照若违反 schema 之外的约束,则保留最后可用事实(seam 的最后可用值哲学向上延伸一层);entry 配置本身仍会加载失败。pi-ai 的 `providers` 改为以路由为键的字典,base 层与用户层因此按提供方合并,路由集合也由结构直接表达;数组形状响亮失败并给出迁移指引,而空字典是合法的休眠姿态——组合可以裸挂该适配器,把每一条路由都留给用户面决定。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
- **由桥接插件(`dsh-llm-models`)持有统一的 `models` 字典**——有了按插件划分的 namespace,就没有什么可桥接的了;它所需的适配器映射规则纯属凭空发明的间接层。
|
||||
- **把机密放进 settings.yaml 并靠 `role('secret')` 脱敏**——删除问题本身(引用)胜过缓解问题(脱敏 + 回填 + 同步告警);编码 agent 同类产品在这一点上口径一致。
|
||||
- **注册表级的实时重试策略**:让 `providerRetryPolicy` 每次调用都重读,会静默改变所有注册都依赖的 `ctx.llm` 捕获契约;因此,重试策略与组合所拥有的路由一同保持固定。
|
||||
- **注册表级的实时重试策略**——让 `providerRetryPolicy` 每次调用都重读,会静默改变所有注册都依赖的 `ctx.llm` 捕获契约;原地重新注册路由既保住该契约,又保持可观察。
|
||||
|
||||
## 后果
|
||||
|
||||
无密钥启动仍然有效:第一次请求会失败并点名该引用,而从外部提供的环境变量或 dotenv 值无需重启即可作用于下一次请求。demo 默认挂载 `settings-local` 与只读的 `credentials-local` 提供方,不内联任何 `!!js` 密钥接线。在当前消费方为其契约提供依据之前,凭据管理 RPC/UI 与注册变更均不存在。settings 层的数组仍整体替换,pi-ai 提供方路由也仍由组合决定。[凭据边界 note](2026-07-30-credential-boundaries-and-atomic-registration.md)负责保留至今的存储与请求世代安全决策。
|
||||
上手流程端到端免重启(由 `missing-credential` headless 快照与凭据轮换组合测试固定):无密钥启动、浏览 catalog、存入密钥、再次发起提示。demo 默认挂载 `settings-local` + `credentials-local`,不再内联任何 `!!js` 密钥接线。`runLoaderSmoke` 新增 `expectedExitCode`,使按设计出现的失败面可以被固定而非被掩盖。延后事项:wire/UI 面在任何 RPC 暴露 `describe()` 之前必须对 `role('secret')` 字段脱敏;settings 层的数组仍整体替换(deepseek 的 `models` 列表);settings 分节无法移除组合提供的 pi-ai 路由(只能覆盖或扩展)。对该 seam 的评审随后改造了存储的所在位置与谁可以读取它,让一个请求解析出一个配置世代,并使路由替换成为原子操作([credential boundaries note](2026-07-30-credential-boundaries-and-atomic-registration.md))。
|
||||
|
||||
@@ -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 .agents/notes/implemented/architecture/2026-07-30-adapter-owned-max-token-defaults.md
|
||||
2026-07-30-adapter-owned-max-token-defaults.md: a522848fd4482f84859e587505b6a5e6f5c72d60
|
||||
2026-07-30-adapter-owned-max-token-defaults.zh.md: 8db6a06199fc1c4e73c86492d12dc86edafe8c7e
|
||||
@@ -0,0 +1,33 @@
|
||||
# Agent Note: Adapter-owned max-token defaults
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-30-adapter-owned-max-token-defaults.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
An LLM adapter could serialize an explicit `GenerateOptions.maxTokens`, but its Cordis configuration could not establish a reconstructable conversation default. Applying a fallback only inside provider serialization would make the wire request differ from the durable `request/header`; putting every provider's default in Agent Loop would instead transfer deployment and model policy into the provider-neutral driver.
|
||||
|
||||
## Decision
|
||||
|
||||
`LlmResolvedModelInfo.defaultMaxTokens` carries an optional adapter-configured per-request output cap for one exact provider/model route. `LlmService` validates it as a positive safe integer and materializes it into `LlmCallConfig.maxTokens` only when the caller omitted a value. A prepared call identifies materialized `maxTokens` and `reasoningEffort` fields as adapter defaults; explicit request or Agent options remain unmarked and therefore win without clamping.
|
||||
|
||||
The agent loop continues to prepare calls before logging `request/header`, so the effective config and its adapter-default provenance become durable request facts before dispatch. Before the next `agent/request` waterfall, the loop removes marked fields from the proposal; exact-model resolution then materializes the current route's defaults again. A provider/model switch therefore cannot mistake a previous adapter's default for an explicit override, while explicit conversation values persist. Direct `LlmService.stream()` calls resolve the same default at the final adapter boundary. The field is a request default rather than a hard model output limit; adapters that preserve provider-owned defaults omit it.
|
||||
|
||||
The native DeepSeek adapter exposes `maxTokens` in Cordis config with a 256,000-token default and maps the effective value to `max_tokens`. Its default context capacity is 1,000,000 tokens: both built-in V4 entries publish that exact capacity, while configured entries without capacity and unlisted pass-through ids inherit the same adapter-wide fallback.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Apply the default only in DeepSeek serialization.** Rejected because the provider wire would contain a model-visible value absent from the durable request header.
|
||||
|
||||
**Set `AgentOptions.maxTokens` in every shipped application.** Rejected because applications would duplicate adapter deployment policy, direct LLM calls would behave differently, and selecting another provider would retain a DeepSeek-specific cap.
|
||||
|
||||
**Represent 256,000 as a hard per-model maximum.** Rejected because the configured value is the desired request budget, not evidence that every configured endpoint rejects larger outputs. Explicit callers remain authoritative.
|
||||
|
||||
**Leave the provider default in control.** Rejected for the native DeepSeek deployment because the product requires a stable 256,000-token conversation budget across compatible endpoints.
|
||||
|
||||
## Consequences
|
||||
|
||||
DeepSeek conversations send `max_tokens: 256000` by default, and the same value plus its adapter provenance appear in the session request header. Deployments can change the adapter default through `llm-deepseek.config.maxTokens`; per-agent and per-request values override it. Changing the route rematerializes the new exact adapter's default instead of carrying DeepSeek's derived value forward. Other adapters retain their existing behavior until they intentionally publish `defaultMaxTokens`.
|
||||
|
||||
The 256,000-token output budget reserves a large part of the one-million-token context on endpoints that pre-allocate requested output. Deployments whose gateway or model supports a smaller budget must lower `maxTokens`; the explicit configuration is preferable to an undocumented provider fallback.
|
||||
@@ -0,0 +1,33 @@
|
||||
# Agent Note: 适配器持有的最大 token 默认值
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-30-adapter-owned-max-token-defaults.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
LLM(大语言模型)适配器可以序列化显式的 `GenerateOptions.maxTokens`,但无法通过 Cordis 配置建立可重建的对话默认值。仅在提供方序列化中应用回退,会导致协议请求与持久 `request/header` 不一致;若将各提供方默认值都放进 agent loop(智能体循环),则会把部署与模型策略转移到提供方无关的驱动器中。
|
||||
|
||||
## Decision
|
||||
|
||||
`LlmResolvedModelInfo.defaultMaxTokens` 携带一条确切提供方/模型路由的可选单次请求输出上限,该值由适配器配置。`LlmService` 将其校验为正安全整数,并且仅在调用方省略值时才填入 `LlmCallConfig.maxTokens`。准备后的调用会将已填入的 `maxTokens` 和 `reasoningEffort` 字段标记为适配器默认值;显式请求值或 Agent 选项不带该标记,因此优先且不会被自动调整。
|
||||
|
||||
agent loop 仍在记录 `request/header` 前准备调用,因此生效配置及其适配器默认值来源会在分派前成为持久请求事实。下一次 `agent/request` waterfall(瀑布式事件)前,agent loop 会从提议中移除带标记字段,随后精确模型解析会再次填入当前路由的默认值。因此,切换提供方/模型不会把前一个适配器的默认值误当成显式覆盖,而显式对话值则会保留。直接调用 `LlmService.stream()` 时,也会在最终适配器边界解析同一默认值。该字段是请求默认值,而非模型输出硬上限;保留提供方持有默认值的适配器会省略它。
|
||||
|
||||
原生 DeepSeek 适配器在 Cordis 配置中公开 `maxTokens`,默认值为 256,000 token,并将生效值映射为 `max_tokens`。其默认上下文容量为 1,000,000 token:两个内置 V4 配置项均公布这一精确容量;不含容量的已配置项和未列出的原样传递 id 则继承同一个适配器级回退值。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**仅在 DeepSeek 序列化中应用默认值。** 不予采纳,因为提供方协议会包含持久请求 header 中缺失的模型可见值。
|
||||
|
||||
**在每个已发布应用中设置 `AgentOptions.maxTokens`。** 不予采纳,因为应用会重复适配器部署策略,直接 LLM 调用的行为将不同,而且选择另一个提供方后仍会保留 DeepSeek 专用上限。
|
||||
|
||||
**将 256,000 表示为每模型硬上限。** 不予采纳,因为配置值是所需请求预算,无法证明每个已配置端点都会拒绝更大的输出。显式调用方仍具有最终决定权。
|
||||
|
||||
**由提供方默认值控制。** 对原生 DeepSeek 部署不予采纳,因为产品要求各兼容端点都采用稳定的 256,000 token 对话预算。
|
||||
|
||||
## Consequences
|
||||
|
||||
DeepSeek 对话默认发送 `max_tokens: 256000`,会话请求 header 中也会出现相同的值及其适配器来源。部署可以通过 `llm-deepseek.config.maxTokens` 更改适配器默认值;每个 agent 和每次请求的值都会覆盖它。更改路由会重新填入新的精确适配器默认值,而不是继续沿用 DeepSeek 派生出的值。其他适配器会保留现有行为,直至主动公布 `defaultMaxTokens`。
|
||||
|
||||
对于预分配请求输出的端点,256,000 token 的输出预算会占用 1,000,000 token 上下文中的很大部分。如果部署使用的 gateway 或模型仅支持较小预算,则必须调低 `maxTokens`;显式配置优于未记录的提供方回退值。
|
||||
@@ -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 .agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.md
|
||||
2026-07-30-client-locale-full-rollout.md: c080d9f240d4533ecd9694ceecfada8662c46425
|
||||
2026-07-30-client-locale-full-rollout.zh.md: 062d982e3d7ea62f3ca4c8fedb842e8336f0852c
|
||||
@@ -0,0 +1,45 @@
|
||||
# Agent Note: Full client copy rollout onto the typed locale seat, and the non-translation boundary
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-30-client-locale-full-rollout.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
After the typed locale standard seat landed (`locale:` on register → framework-injected typed `t`), only four early adopters rode it; every other client package still shipped hardcoded, mixed-language literals. Migrating the rest required mechanisms and boundary decisions the early adopters never touched: how registration-time text (nav rows, view-tab labels) refreshes on a language switch; how the zero-cordis ui-primitives atoms receive copy; and which strings deliberately stay untranslated — an unrecorded boundary invites a future agent to "complete" the localization.
|
||||
|
||||
## Decision
|
||||
|
||||
**Registration-time text rides a label thunk.** A list registration's `label` accepts `SlotLabel = string | (() => string)`; owners projecting ledger rows resolve through `resolveSlotLabel` (never reading `options.label` raw) and make the read point follow the locale revision (outlets subscribe to the revision themselves; off-ledger projections such as the ui-settings nav fold the revision into their cache key and subscribe to both sources). Thunks evaluate per read, so a language switch causes zero ledger churn — no re-registration, versions stay put, and every `locale/change` re-registration wiring is deleted.
|
||||
|
||||
**Component copy rides the standard `t` seat; deep children take `t` as a plain prop** typed `XxxProps['t']`. The dictionary canon is unchanged: `zh satisfies Record<string, string>` is the key source and `en satisfies Record<XxxKey, string>` locks bilingual balance.
|
||||
|
||||
**Zero-cordis atoms (ui-primitives) take copy as props**: `labels` on `TerminalBlock`/`JsonTree`, `copyLabel`/`copiedLabel` on `CodeBlock`, `codeLabels` on `MarkdownText`, `truncatedLabel` on `JsonBlock`, `label` on `ConnectionBanner`, `closeLabel` on `Modal` — defaults are the previous hardcoded strings, so a consumer passing nothing renders byte-identical output. Localized plugins pass dictionary-driven labels from their own `t` seat; call sites passing object props memoize them on the `t` identity (`MarkdownText` caches its component table on the `codeLabels` identity).
|
||||
|
||||
**The non-translation boundary (deliberate decisions, not debt):**
|
||||
|
||||
- **Error/failure strings stay English**: client-authored fallbacks (`command failed`, plan-toggle failures), RpcError messages, and wire `error.message (code)` pass-throughs render verbatim.
|
||||
- **Design literals stay out of the dictionaries**: tool-row variant titles (Think/Bash/…), SYSTEM/USER-style kind badges, the Plan chip wordmark, the whole StatsLine — identical in both languages.
|
||||
- **ui-trajectory is deferred wholesale** (a developer inspection surface, terminology-dense, ruled separately).
|
||||
- **Boot copy stays hardcoded** (AppRoot renders before the locale service exists).
|
||||
|
||||
**Derivation layers stay pure; localization happens at render.** ui-workspace's `relativeTime` returns structured `{unit, n}` composed with dictionary templates by the renderer; blank sessions and the Ungrouped bucket keep their stored titles, with the renderer substituting localized copy off the `blank` flag / absent `workspaceId`; **blank rows are excluded from search entirely** (a bilingual display title cannot match a single-language query stably). Dates use no Intl: format templates live in the dictionaries (message clock `clock.md`/`clock.ymd`, workspace hover `date.ymd`) and the formatters take `t` as a parameter, staying pure.
|
||||
|
||||
**Test and e2e doctrine**: `makeTranslate(...dicts)` (dsh-client-test-runtime) mirrors the service lookup chain (first-dict-wins, key fallback, `{name}` interpolation); component specs stub the `t` seat with it, typed against real props seats. Web e2e uniformly opens through `newEnglishPage` (pins `dsh.locale=en` before boot) and the built-boot snapshot pins the same — goldens are immune to localization migrations; the settings language-switch scenario deliberately bypasses the helper to cover the zh default.
|
||||
|
||||
The "apply layer subscribes to `locale/change` and re-registers for fresh labels" mechanism in the [settings/locale/theme layering note](../../proposed/architecture/2026-07-25-client-settings-locale-theme.md) is superseded by this decision (thunk + revision lifecycle).
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Keep labels as strings and re-register on switch** (the early adopters' original shape): boot already registers once per package, and `locale/change` listeners re-registering amplifies into a storm; ledger version churn also busts every version-keyed projection cache. Thunks move the refresh cost to read points that already follow the revision.
|
||||
- **A locale context/injection channel for ui-primitives**: breaks the zero-cordis boundary (atoms would depend on the runtime) and drags unlocalized consumers (ui-trajectory) along. Props let each consumer decide independently.
|
||||
- **Error strings in the dictionaries**: the error surface is a debugging surface — verbatim English is what gets searched and compared in reports; wire pass-throughs are untranslatable anyway, and half-translation manufactures mixed-language text.
|
||||
- **`toLocaleString()`/Intl for dates**: follows the browser/OS language, not the app locale, guaranteeing mixed text after a switch; the dictionary templates are tiny and isomorphic to the message clock.
|
||||
- **Blank rows matching search (against localized or stored titles)**: either choice yields "visible but unfindable" in one language; placeholder rows carry no information, so whole-row exclusion is the stable semantic.
|
||||
|
||||
## Consequences
|
||||
|
||||
- A language switch refreshes the whole UI instantly with zero re-registration; adopting a new package is three steps (dictionary + declare-merge + `locale: NS`), no hand-written glue.
|
||||
- Cost: list-label consumers must know `resolveSlotLabel` (a raw `options.label` read can now hold a function); the `SlotLabel` type catches most misuse statically.
|
||||
- ui-primitives' Chinese defaults still render Chinese under the English locale **until a consumer passes labels** — the unmigrated JsonTree consumer (ui-trajectory) showing its English defaults happens to match that package's all-English status quo.
|
||||
- Pinning e2e to English means the zh default is covered mainly by package-level component specs and the settings language-switch scenario; browser e2e no longer asserts zh copy.
|
||||
@@ -0,0 +1,45 @@
|
||||
# Agent Note: client 文案全量接入 typed locale 席位与不翻译边界
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-30-client-locale-full-rollout.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
typed locale 标准席位(`locale:` 注册声明 → 框架注入强类型 `t`)落地后,只有四个先行包接入;其余 client 包的文案仍是硬编码的中英混杂字面量。全量迁移需要几个先行包没有触及的机制与边界决定:注册期文本(导航行、视图 tab 的 label)在语言切换时如何刷新;zero-cordis 的 ui-primitives 原子组件如何拿到文案;哪些字符串**刻意不**本地化——没有记录的边界会诱使后来者"补完"翻译。
|
||||
|
||||
## Decision
|
||||
|
||||
**注册期文本走 label thunk。** ui-slots 的 list 注册项 `label` 接受 `SlotLabel = string | (() => string)`;owner 投影 ledger 行时必须经 `resolveSlotLabel` 解析(不裸读 `options.label`),并让读取点跟随 locale revision(outlet 自身订阅 revision;ledger 外的投影如 ui-settings 导航把 revision 并进缓存键、订阅双源)。thunk 每次读取时求值,语言切换零 ledger churn——没有重注册、version 不动,`locale/change` 重注册接线全部删除。
|
||||
|
||||
**组件文案走标准 `t` 席位;深层子组件用 prop 下传**,类型写 `XxxProps['t']`。字典规范形态不变:`zh satisfies Record<string, string>` 为 key 源、`en satisfies Record<XxxKey, string>` 锁双语平衡。
|
||||
|
||||
**zero-cordis 原子组件(ui-primitives)文案 props 化**:`TerminalBlock`/`JsonTree` 的 `labels`、`CodeBlock` 的 `copyLabel`/`copiedLabel`、`MarkdownText` 的 `codeLabels`、`JsonBlock` 的 `truncatedLabel`、`ConnectionBanner` 的 `label`、`Modal` 的 `closeLabel`——默认值即原硬编码字符串,不传 props 的消费者渲染逐字节不变。已本地化的插件从自己的 `t` 席位传字典驱动的 label;传对象 props 的调用点按 `t` 身份 memo(`MarkdownText` 的组件表按 `codeLabels` 身份缓存)。
|
||||
|
||||
**不翻译边界(刻意决定,不是欠账):**
|
||||
|
||||
- **错误/失败类字符串一律英文**:client 自产的兜底串(`command failed`、plan 切换失败)、RpcError message、wire 透出的 `error.message (code)` 原样呈现。
|
||||
- **设计字面量不进字典**:tool 行 variant 标题(Think/Bash/…)、SYSTEM/USER 类 kind 徽标、Plan chip 字标、StatsLine 全部指标——中英界面显示一致。
|
||||
- **ui-trajectory 整包缓做**(开发者检查面,术语密集,单独裁决)。
|
||||
- **boot 文案保持硬编码**(AppRoot 渲染早于 locale 服务可用)。
|
||||
|
||||
**派生层保持纯函数,本地化只在渲染层**:ui-workspace 的 `relativeTime` 返回结构化 `{unit, n}` 由渲染组合字典模板;blank 会话/未分组桶的存储标题不变,渲染按 `blank` 标志/`workspaceId` 缺席替换本地化文案;**搜索态 blank 行一律排除**(双语标题无法与单语查询稳定匹配)。日期不引 Intl:格式模板进字典(消息时钟 `clock.md`/`clock.ymd`,workspace hover `date.ymd`),格式化函数吃 `t` 参数保持纯。
|
||||
|
||||
**测试与 e2e 口径**:`makeTranslate(...dicts)`(dsh-client-test-runtime)镜像服务查找链(首个命中字典胜出、key 兜底、`{name}` 插值),组件测试的 `t` 桩统一用它并以真实 props 席位定型。web e2e 统一 `newEnglishPage`(boot 前钉 `dsh.locale=en`),built-boot snapshot 同样钉 en——golden 对语言迁移免疫;settings 语言切换用例刻意绕开该 helper 覆盖 zh 默认态。
|
||||
|
||||
[settings/locale/theme 分层 Note](../../proposed/architecture/2026-07-25-client-settings-locale-theme.md) 中"apply 层订阅 `locale/change` 重注册刷新 label"的机制已被本决定取代(thunk + revision 生命周期)。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **label 保持 string、语言切换时重注册**(先行包的旧形态):boot 每包一次注册已很重,`locale/change` 监听者重注册会放大成风暴;ledger version 抖动还会击穿一切按 version 缓存的投影。thunk 把刷新成本移到读取点,读取点本来就跟随 revision。
|
||||
- **给 ui-primitives 造 locale context/注入通道**:破坏 zero-cordis 边界(原子组件从此依赖运行时),且强迫未本地化消费者(ui-trajectory)陪跑。props 化让每个消费者独立决定。
|
||||
- **错误串进字典**:错误面是排障面,英文原样最利于搜索与上报比对;且 wire 透出串本就不可译,半译反而制造混合语言。
|
||||
- **日期用 `toLocaleString()`/Intl**:跟随浏览器/OS 语言而非应用语言,切换后必然产生混合文本;字典模板量小且与消息时钟同构。
|
||||
- **blank 行参与搜索(匹配本地化标题或存储标题)**:任一选择都在某个语言下"看得见搜不到";占位行本无信息量,整体排除语义最稳。
|
||||
|
||||
## Consequences
|
||||
|
||||
- 语言切换全 UI 即时刷新且零重注册;新包接入 = 字典 + declare-merge + `locale: NS` 三步,无手写胶水。
|
||||
- 代价:list label 的消费方必须知道 `resolveSlotLabel`(裸读 `options.label` 拿到函数);类型上 `SlotLabel` 已挡住多数误用。
|
||||
- ui-primitives 的中文默认值在英文语言下依旧是中文,**直到消费点传 label**——未迁移包(ui-trajectory 的 JsonTree)显示英文默认恰好符合其整包英文现状。
|
||||
- e2e 英文钉死意味着 zh 默认态主要靠包级组件测试与 settings 语言切换用例覆盖,浏览器 e2e 不再验证 zh 文案。
|
||||
@@ -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 .agents/notes/implemented/architecture/2026-07-30-command-row-copy-contract.md
|
||||
2026-07-30-command-row-copy-contract.md: f6d5199389b3907780c501894e2861e6add85e77
|
||||
2026-07-30-command-row-copy-contract.zh.md: 4afaf31640c07e88765060681739f262f322769e
|
||||
@@ -0,0 +1,35 @@
|
||||
# Agent Note: Command row copy is split between the row and the handler
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-30-command-row-copy-contract.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The web command row renders `title · summary` from one logged [command lifecycle pair](../../proposed/architecture/2026-07-27-session-projection-and-command-log.md): the title was the dispatched line rebuilt from `command/run` (`/permission workspace-write`) and the summary was `command/done`'s verbatim `text` (`Permission preset: workspace-write.`). Both halves were written without knowing about the other, so the row said the command name twice and its argument twice — the single worst case being the row a user gets for every Access-chip pick.
|
||||
|
||||
## Decision
|
||||
|
||||
The row's two halves have disjoint jobs, and each side is written to its own half alone.
|
||||
|
||||
The row title is the bare command name — no `/`, no arguments. The `/` belongs to the composer's input grammar, not to a settled record, and the argument is not the row's to report: the summary already says what the command did. `GenericCommandCard` keeps the `命令` fallback for a cross-window node whose `command/run` page fell out of the client's window.
|
||||
|
||||
A command handler's settlement `text` therefore never labels its value with the command's own name, because the surface that renders it has already said it. `/permission` returns `preset workspace-write`, bare `current preset workspace-write (available: …)`, and for a bad argument `unknown preset "bogus" (available: …)`. Read as a row this is `permission · preset workspace-write`; read as a standalone line — the TUI appends the same text as a notice — it still states which preset now applies.
|
||||
|
||||
The rule bans the *label*, not the vocabulary. `Permission preset: workspace-write.` lost because `Permission preset:` is a caption for a value whose caption is already the title. A domain noun that happens to contain the command's name is not a caption and stays: `/plan` keeps `Plan mode off.` and `Plan mode on. Use /plan off to leave.` (`plan · Plan mode off.` names the mode, and the tail is an instruction, not an echo), and `/goal` keeps `Goal cleared.`. A handler that finds itself writing `<Command> <noun>:` in front of its own value is the case this rule catches.
|
||||
|
||||
The log is unchanged: `command/run` keeps the structured `name`/`args` split, so a richer registered command row can still render arguments from the same node without a second data channel.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Keep the dispatched line as the title and only shorten the settlement text.** The argument would still appear on both sides of the separator (`permission workspace-write · preset workspace-write`), which is the repetition complained about.
|
||||
|
||||
**Drop the settlement text from the collapsed row instead of the arguments.** It inverts the row's value: the outcome is what a durable record is for, and an error text would then have nowhere to land.
|
||||
|
||||
**Have the row strip a leading command name from the settlement text.** Presentation would silently rewrite handler-authored text, and every handler that phrased its outcome differently would defeat the heuristic.
|
||||
|
||||
**Ban the command's name from its settlement text outright, rewriting `/plan` and `/goal` to match.** The broader ban costs more than it buys: `Plan mode off.` and `Goal cleared.` are the clearest sentences those outcomes have, in the row and as standalone TUI notices both, and the shortenings that satisfy a name ban (`off.`, `cleared.`) read as fragments. Captions are the redundancy worth removing.
|
||||
|
||||
## Consequences
|
||||
|
||||
Every command row gets shorter, and the rule scales: a new command's author writes its outcome without knowing which surface renders it, and no surface has to de-duplicate. The cost is that the dispatched arguments leave the collapsed row — while a command is still executing the row shows only its name and `执行中…` — and that the no-caption rule is a convention the reviewer enforces, not a gate. The `/permission` texts are pinned by the permission package's command tests, and the assembled row copy by the [seeded-history](../../../../apps/web/tests/snapshots/seeded-history/command-row.expected.md) web golden, which reaches a real settled command row keylessly because `/permission` runs entirely on the host.
|
||||
@@ -0,0 +1,35 @@
|
||||
# Agent Note: Command row copy is split between the row and the handler
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-30-command-row-copy-contract.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
Web 命令行由一对落库的[命令生命周期事件](../../proposed/architecture/2026-07-27-session-projection-and-command-log.md)渲染出 `标题 · 摘要`:标题是由 `command/run` 重建的分派命令行(`/permission workspace-write`),摘要是 `command/done` 的原样 `text`(`Permission preset: workspace-write.`)。两半各自成文、互不知情,于是一行里命令名出现两次、参数也出现两次——最糟的一例正是用户每次用 Access chip 切换权限时得到的那一行。
|
||||
|
||||
## Decision
|
||||
|
||||
命令行两半的职责互不重叠,各自只按自己那一半来写。
|
||||
|
||||
行标题就是裸命令名——没有 `/`,也没有参数。`/` 属于编辑器的输入语法,不属于一条已落定的记录;参数也不该由这一行来报告:摘要已经说清了这条命令做了什么。对于 `command/run` 那一页已滑出客户端窗口的跨窗口节点,`GenericCommandCard` 仍保留 `命令` 兜底标题。
|
||||
|
||||
因此,命令 handler 的落定 `text` 绝不用命令自身的名字给自己的值加标签——渲染它的界面已经说过一次了。`/permission` 返回 `preset workspace-write`,裸调用时返回 `current preset workspace-write (available: …)`,参数非法时返回 `unknown preset "bogus" (available: …)`。作为一行读是 `permission · preset workspace-write`;作为独立一句读——TUI 把同一段 text 作为通知追加——它依然说明了当下生效的是哪个预设。
|
||||
|
||||
这条规则禁的是*标签*,不是用词。`Permission preset: workspace-write.` 之所以出局,是因为 `Permission preset:` 是给一个值加的题头,而这个题头正是标题本身。恰好含有命令名的领域名词不是题头,因此保留:`/plan` 仍返回 `Plan mode off.` 与 `Plan mode on. Use /plan off to leave.`(`plan · Plan mode off.` 说的是那个模式,句尾是一条指引,不是回声),`/goal` 仍返回 `Goal cleared.`。真正被这条规则拦下的,是 handler 在自己的值前面写出 `<命令名> <名词>:` 的那一类。
|
||||
|
||||
日志本身未变:`command/run` 保留结构化的 `name`/`args` 拆分,因此更丰富的已注册命令行仍可从同一个节点渲染参数,无需第二条数据通道。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**保留分派命令行作标题,只缩短落定文案。** 参数仍会出现在分隔点两侧(`permission workspace-write · preset workspace-write`),而这正是被指出的重复。
|
||||
|
||||
**从折叠行中去掉落定文案,而不是去掉参数。** 这颠倒了这一行的价值:持久记录存在的意义就是结果,而错误文案将无处落脚。
|
||||
|
||||
**由这一行从落定文案里剥掉开头的命令名。** 呈现层会悄悄改写 handler 写就的文案,而任何换一种措辞表达结果的 handler 都会让这套启发式失效。
|
||||
|
||||
**彻底禁止命令名出现在自己的落定文案里,并把 `/plan`、`/goal` 一并改写。** 这种更宽的禁令代价大于收益:无论在行上还是作为独立的 TUI 通知,`Plan mode off.` 与 `Goal cleared.` 都是这些结果最清楚的句子,而满足"禁名字"所需的缩写(`off.`、`cleared.`)读起来只是残句。值得去掉的冗余是题头。
|
||||
|
||||
## Consequences
|
||||
|
||||
每一条命令行都变短了,而且这条规则可扩展:新命令的作者写结果时无需知道由哪个界面渲染,任何界面也都不必再去重。代价是分派参数离开了折叠行——命令仍在执行时,行上只有名字和 `执行中…`——以及"不加题头"这条规则是靠评审执行的约定,而非门禁。`/permission` 的文案由 permission 包的命令测试钉住,装配后的行文案由 [seeded-history](../../../../apps/web/tests/snapshots/seeded-history/command-row.expected.md) web 预期输出钉住:因为 `/permission` 完全在 host 上执行,它能无密钥地抵达一条真实的落定命令行。
|
||||
@@ -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 .agents/notes/implemented/architecture/2026-07-30-config-plane-boundaries.md
|
||||
2026-07-30-config-plane-boundaries.md: 8a29dcc934126d6e3dfa9c0a6a308ef006017a5a
|
||||
2026-07-30-config-plane-boundaries.zh.md: c858b7dd5b6fcd61936c33f1f09d7d2e89a3cfc7
|
||||
@@ -0,0 +1,41 @@
|
||||
# Agent Note: what the configuration plane exposes, and who may overwrite what
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-30-config-plane-boundaries.zh.md)
|
||||
|
||||
> Scope: the review round over the [web configuration plane](2026-07-30-web-config-plane.md) — which namespaces reach the wire, which callers reach them, and how an editor holding a partial, possibly stale view writes without destroying what it cannot see.
|
||||
|
||||
## Problem
|
||||
|
||||
The plane worked and was reachable by more callers, and with more authority, than its design claimed.
|
||||
|
||||
`trustedHosts` gated only writes, so a declared LAN client could call `settings.describe` — every exposed namespace's configuration — and `credentials.describe`, which reports whether an arbitrary environment-variable name is configured and where it resolves from. That fence is a DNS-rebinding defense and says so; treating it as an authorization boundary for reads was a category error. Separately, the proxy served every registered namespace: the settings seam is deliberately general, so the first plugin to call `settings.register()` for its own configuration would silently become remotely readable and writable, without passing anywhere near a review of the web surface.
|
||||
|
||||
The editor was worse than reachable — it was destructive. It reads the redacted descriptor, which by construction omits `role('secret')` fields. Clearing one field rebuilt the whole user section from that redacted copy and sent `settings.replace`, so a stored literal `apiKey` the wire had never returned was deleted as a side effect. Reproduced directly: `{baseURL, reasoning}` in, `apiKey` gone. Row removal took the same path. And nothing carried a version, so two tabs editing one namespace silently overwrote each other; the seam's per-namespace write queue orders writes but cannot tell a fresh writer from one replaying a stale snapshot.
|
||||
|
||||
Three smaller defects sat beside them. `llm/adapters-updated` documented contained observer failures but only caught synchronous ones, so an async listener's rejection escaped as an unhandled rejection. llm-deepseek's retry-policy swap disposed its registration before re-registering, publishing an empty route set between the two — an observer saw the provider disappear and come back, despite a comment claiming no such window. And a transport rejection during the page's credential enrichment escaped `load()`, stranding the page in `loading` with no error shown.
|
||||
|
||||
## Decision
|
||||
|
||||
**Reading configuration is as privileged as writing it.** `settings.describe` and `credentials.describe` join the loopback-only set, so the whole configuration plane stays same-origin until real authentication exists. The model catalog (`llm.providers`, `llm.models`) deliberately does not: it carries provider ids, display names, and model lists — no endpoints, no key state — and a LAN client's model picker needs it. The boundary is asserted over a real HTTP server rather than a hand-assembled request, because the `Host` header a browser actually sends is what decides it.
|
||||
|
||||
**The plane serves exactly the namespaces a registered model provider addresses.** `ctx.llm.listConfigurableProviders()` is the allow-list, so the product boundary is enforced rather than inferred from today's plugin set, and a future namespace becomes web-configurable only by joining that directory. An unregistered namespace and an unexposed one answer identically (`settings-not-exposed`), so probing cannot enumerate the registry.
|
||||
|
||||
**A caller with a partial view names the field it means.** `Settings.mutate(ns, ops)` applies `set`/`unset` path ops to the section as it stands at the front of the write queue. The client builds ops by diffing its opening snapshot against its draft, so it mentions only fields it can see: a secret absent from both sides produces no op and survives by construction, not by care. `replace` remains the deliberate wholesale reset.
|
||||
|
||||
**Staleness is detected, not ordered away.** Each namespace carries a monotonic `revision` over its RAW section; writes may carry `expectedRevision`, and a mismatch rejects with `SettingsConflictError` → `settings-conflict` on the wire, both revisions attached. The editor captures the revision it opened at and, on conflict, tells the user to reopen rather than replaying its snapshot.
|
||||
|
||||
**The raw layer gets its own event.** `settings/updated` stays gated on the resolved value — that is what a consumer means by change. `settings/document-updated (ns, revision)` fires on any raw-section change, because a configuration surface must learn that a field went from inherited to overridden (same resolved value, different meaning) and that its held revision is stale. The host frame `host/settings-changed` now rides this event, and a change to an exposed provider namespace also emits `host/models-changed`: that namespace holds the provider's catalog, which no route change announces.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **A deployment-declared namespace allowlist on the proxy config** — more general, but it moves the product boundary to whoever writes cordis.yml, and an empty default would break the shipped page until every deployment opted in. The provider directory already states exactly which namespaces are model configuration.
|
||||
- **Opt-in metadata at `settings.register()`** — the most honest semantics (the namespace's owner declares its own exposure), and the largest change: the seam's public interface, both LLM plugins, and their docs. Recorded as the shape to adopt if a non-LLM namespace ever needs the plane.
|
||||
- **Distinguishing "unregistered" from "registered but unexposed"** — better diagnostics, and a namespace-enumeration oracle. The uniform answer is deliberate.
|
||||
- **Detecting conflicts by diffing instead of a revision** — comparing the submitted base against storage would work for whole-section writes, but the editor holds a REDACTED section: it cannot produce a comparable base, which is the same reason it cannot safely `replace`. A counter needs neither.
|
||||
- **Fixing the redaction gaps in this round** — `redactSecrets` walks only `object`/`dict`/`array`, so a secret behind a union, intersection, or transform is returned verbatim with an empty `secrets` list; `schema.toJSON()` carries a secret field's `.default(...)`; write-rejection messages return schema text that may quote the input; the client rehydrates the envelope through schemastery's `new Function`; and pi-ai's plain-string `headers` dict can legitimately hold `Authorization`. All confirmed, all deliberately left for a fail-closed `describeForWire()` that refuses a schema it cannot prove safe. They are recorded as `TODO(settings-wire-redaction)` and in the owning READMEs' Known Limitations rather than half-fixed here.
|
||||
|
||||
## Consequences
|
||||
|
||||
A LAN client on a `trustedHosts` deployment can no longer render the settings page at all; loopback is the configuration surface. A plugin that registers a settings namespace is not web-configurable until it also registers a configurable provider — deliberate, and the reason `settings-not-exposed` names the boundary in its message. `SettingsDescriptor` gained a required `revision`, so any programmatic constructor of a descriptor-shaped value must supply it, and `settings/document-updated` is a new event any provider-side listener may now observe. Clients that ignore `expectedRevision` keep last-write-wins semantics unchanged. Deferred: the fail-closed wire describe (with the `headers` and envelope-sanitization work it carries), and a non-executable browser schema protocol.
|
||||
@@ -0,0 +1,41 @@
|
||||
# Agent Note:配置面暴露什么,以及谁有权覆盖什么
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-30-config-plane-boundaries.md) | 中文
|
||||
|
||||
> 范围:针对 [Web 配置面](2026-07-30-web-config-plane.md)的评审轮——哪些 namespace 能抵达协议、哪些调用方能抵达它们,以及一个只持有局部、且可能过期视图的编辑器该如何写入,才不会毁掉它看不见的东西。
|
||||
|
||||
## 问题
|
||||
|
||||
这个面能用,但能触达它的调用方、以及它们所拥有的权限,都比设计声称的更多。
|
||||
|
||||
`trustedHosts` 只拦住了写入,因此一个已声明的 LAN 客户端可以调用 `settings.describe`——拿到每个已暴露 namespace 的配置——以及 `credentials.describe`,后者会报告任意一个环境变量名是否已配置、又从何处解析。那道 fence 是 DNS 重绑定防御,它自己也是这么写的;把它当作读取的授权边界,是一次范畴错误。另一件事是:代理服务于每一个已注册的 namespace。settings seam 是刻意做成通用的,因此第一个为自身配置调用 `settings.register()` 的插件,就会悄无声息地变成可远程读写,而完全不必经过任何针对 Web 表层的评审。
|
||||
|
||||
编辑器比"可触达"更糟——它是破坏性的。它读到的是脱敏后的 descriptor,后者按构造省略了 `role('secret')` 字段。清空其中一个字段,会用这份脱敏副本重建整个用户分节并发出 `settings.replace`,于是一个协议从未回传过的已存字面 `apiKey` 被顺带删除。这一点被直接复现:输入 `{baseURL, reasoning}`,输出时 `apiKey` 消失。删除整行走的是同一条路径。而且没有任何东西携带版本,因此两个标签页编辑同一个 namespace 会静默互相覆盖;seam 的逐 namespace 写队列只排定写入次序,分辨不出一个新写方与一个重放过期快照的写方。
|
||||
|
||||
另有三个较小的缺陷与之并列。`llm/adapters-updated` 的文档写着观察者失败会被收容,却只捕获同步失败,于是异步 listener 的 rejection 作为 unhandled rejection 逃逸。llm-deepseek 的重试策略换路由先释放注册、再重新注册,在两者之间发布了一个空路由集——观察者会看到该提供方消失又回来,尽管注释宣称不存在这样的空窗。还有,页面做凭据增强时的传输层 rejection 会逃出 `load()`,把页面卡在 `loading` 且不显示任何错误。
|
||||
|
||||
## 决策
|
||||
|
||||
**读配置与写配置同样特权。**`settings.describe` 与 `credentials.describe` 加入仅限回环的集合,因此在真正的认证层出现之前,整个配置面都保持同源。模型目录(`llm.providers`、`llm.models`)刻意不在其中:它携带的是提供方 id、显示名与模型列表——没有端点、没有密钥状态——而 LAN 客户端的模型选择器正需要它。这条边界由一台真实 HTTP 服务器来断言,而不是手工拼装的请求,因为真正决定它的,是浏览器实际发出的那个 `Host` 头。
|
||||
|
||||
**这个面恰好服务于已注册模型提供方所指向的那些 namespace。**`ctx.llm.listConfigurableProviders()` 就是允许列表,于是产品边界是被执行的,而不是从今天的插件集合里推断出来的;将来的 namespace 只有加入该目录才会变得可在 Web 上配置。未注册的 namespace 与未暴露的 namespace 得到完全相同的答复(`settings-not-exposed`),因此探测无法枚举注册表。
|
||||
|
||||
**持有局部视图的调用方,点名它真正要改的字段。**`Settings.mutate(ns, ops)` 会把 `set`/`unset` 路径 op 施加在写入排到队首那一刻的分节上。客户端通过对比自己打开时的快照与草稿来构造 op,因此它只提及自己看得见的字段:两侧都没有的机密不会产生任何 op,它的留存是构造使然,而非小心使然。`replace` 仍是那个刻意的整体重置。
|
||||
|
||||
**过期是被检测出来的,而不是靠排序绕过去的。**每个 namespace 都带有一个针对其**原始**分节的单调 `revision`;写入可携带 `expectedRevision`,不匹配即以 `SettingsConflictError` 拒绝——在协议上是 `settings-conflict`,并附上两个 revision。编辑器记住自己打开时的 revision,冲突时请用户重新打开,而不是把自己的快照重放上去。
|
||||
|
||||
**原始层拥有自己的事件。**`settings/updated` 仍以解析值为门槛——那才是消费方所说的"变化"。`settings/document-updated (ns, revision)` 则在任何原始分节变化时触发,因为配置界面必须知道某个字段从继承变成了覆盖(解析值相同,含义不同),也必须知道自己持有的 revision 已经过期。host 帧 `host/settings-changed` 现在搭乘这个事件;而已暴露提供方 namespace 的变更还会额外发出 `host/models-changed`:该 namespace 正持有这个提供方的目录,而没有任何路由变更会宣告它。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
- **在代理配置上做部署声明式的 namespace 白名单**——更通用,但它把产品边界交给了写 cordis.yml 的人,而空的默认值会让已交付的页面在每个部署显式开启之前直接失效。提供方目录本就精确地说明了哪些 namespace 属于模型配置。
|
||||
- **在 `settings.register()` 处 opt-in metadata**——语义最正(由 namespace 的属主自行声明其暴露与否),改动也最大:seam 的公共接口、两个 LLM 插件,以及它们的文档。记录为:一旦某个非 LLM 的 namespace 确实需要这个面,就采用这个形状。
|
||||
- **区分"未注册"与"已注册但未暴露"**——诊断更好,同时也是一台 namespace 枚举预言机。统一答复是刻意为之。
|
||||
- **用 diff 而非 revision 来检测冲突**——对整分节写入而言,拿提交时的基线与存储比对是可行的,但编辑器持有的是**脱敏后**的分节:它给不出可比对的基线,这与它不能安全地 `replace` 是同一个原因。计数器两者都不需要。
|
||||
- **本轮就修掉脱敏的缺口**——`redactSecrets` 只遍历 `object`/`dict`/`array`,因此藏在 union、intersection 或 transform 之后的机密会被原样返回,且 `secrets` 列表为空;`schema.toJSON()` 会带上 secret 字段的 `.default(...)`;写入拒绝的消息返回的是可能引用了输入的 schema 文本;客户端通过 schemastery 的 `new Function` 重建信封;而 pi-ai 那个纯字符串的 `headers` 字典完全可以合法地放下 `Authorization`。全部经确认属实,也全部刻意留给一个 fail-closed 的 `describeForWire()`——它会拒绝自己无法证明安全的 schema。它们被记录为 `TODO(settings-wire-redaction)` 以及各属主 README 的 Known Limitations,而不是在这里做一半。
|
||||
|
||||
## 影响
|
||||
|
||||
`trustedHosts` 部署下的 LAN 客户端已经完全无法渲染设置页;配置表层就是回环。注册了 settings namespace 的插件,在它同时注册可配置提供方之前不会变得可在 Web 上配置——这是刻意的,也正是 `settings-not-exposed` 要在消息里点明这条边界的原因。`SettingsDescriptor` 新增了必填的 `revision`,因此以编程方式构造 descriptor 形状值的地方都必须提供它;`settings/document-updated` 是一个新事件,provider 侧的任何 listener 现在都可以观察它。忽略 `expectedRevision` 的客户端,其后写胜出的语义完全不变。延后事项:fail-closed 的协议 describe(连同它所承载的 `headers` 与信封净化工作),以及一套客户端无法执行的浏览器 schema 协议。
|
||||
@@ -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 .agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.md
|
||||
2026-07-30-credential-boundaries-and-atomic-registration.md: 2a808048bccc619a311e61340b5aeb0f3113030d
|
||||
2026-07-30-credential-boundaries-and-atomic-registration.zh.md: 4c2147ea0f0d30624500aeda92fb02932a5b0960
|
||||
2026-07-30-credential-boundaries-and-atomic-registration.md: 6fe5f554acbfd804db9625fcaa794d513c8799c4
|
||||
2026-07-30-credential-boundaries-and-atomic-registration.zh.md: 3eb3b022064124aad2a389abba3063af4e2110fa
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
# Agent Note: credential boundaries and whole-generation LLM requests
|
||||
# Agent Note: credential boundaries, whole-snapshot requests, and atomic route registration
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-30-credential-boundaries-and-atomic-registration.zh.md)
|
||||
|
||||
> Scope: storage and request-boundary corrections to the [request-level LLM configuration seam](2026-07-29-request-level-llm-config-credentials.md). The later [read-only credentials and static routes](../simplification/2026-07-31-read-only-credentials-and-static-llm-routes.md) decision removes credential writes, the shared atomic writer, and mutable registration; this note owns the surviving secret boundary and whole-request generation rules.
|
||||
> Scope: the third review round over the [request-level LLM configuration seam](2026-07-29-request-level-llm-config-credentials.md) — where a stored credential lives and who can read it, how one request's facts stay one generation, and how a route set changes without a window. Companion to the [settings write-path note](2026-07-30-settings-write-path-integrity.md), whose provider fixes this round applies to `credentials-local` and whose writer lock it promotes into `dsh-atomic-write`.
|
||||
|
||||
## Problem
|
||||
|
||||
Review found the credential path leaking across boundaries it had drawn. The shipped surfaces hoisted `$DSH_HOME/.env` into `process.env` before cordis booted, so on the next run `credentials-local` classified every key it had stored itself as a read-only ambient launch override: `describe()` reported `source: 'env'` with `writable: false`, `set`/`unset` rejected as shadowed, and a key stored from the web page or TUI became unrotatable and undeletable while the adapter kept using the value captured at launch. The store's own write path repeated the settings-local defects that same review round fixed (two independent chains, whole-file render from a stale cache), plus editor bugs of its own: a physical line inside another key's quoted multi-line value read as an assignment, CRLF endings degraded to LF, a multi-line entry reported `writable: true` while `set` always threw, and `credentials/updated` was emitted bare after the commit, so one broken observer made a durable write look failed. On the read side, the file's `0600` mode stops other OS users but not the model, whose bash and filesystem tools run as the same user.
|
||||
|
||||
Request-path defects sat beside them. DeepSeek's per-request resolution kept connection facts in a last-good snapshot but re-read the literal `apiKey` from the raw configuration, so a settings generation the resolver rejected could still put its key on the previous generation's endpoint. Both adapters also read their full settings snapshot during asynchronous model-capability resolution and again at stream dispatch; a change between those reads could pair one generation's reasoning/capability facts with the next generation's endpoint and key. pi-ai handed the SDK `undefined` when a configured `apiKeyEnv` resolved to nothing, letting pi-ai's own environment discovery authenticate with an unrelated provider key — another tenant, silently billed.
|
||||
Two request-path defects sat beside them. DeepSeek's per-request resolution kept connection facts in a last-good snapshot but re-read the literal `apiKey` from the raw configuration, so a settings generation the resolver rejected could still put its key on the previous generation's endpoint. pi-ai handed the SDK `undefined` when a configured `apiKeyEnv` resolved to nothing, letting pi-ai's own environment discovery authenticate with an unrelated provider key — another tenant, silently billed. And its route swap disposed the old registration before creating the new one: a route another adapter owned dropped every existing route, after which the facts cache could equal the registry's, so restoring the working configuration never re-applied.
|
||||
|
||||
## Decision
|
||||
|
||||
@@ -18,19 +18,19 @@ Request-path defects sat beside them. DeepSeek's per-request resolution kept con
|
||||
|
||||
**The stored credential has no boundary against the model, and the READMEs say so.** `0600` under a `0700` directory stops other OS users; the model's bash and filesystem tools run as that same user, and the shipped default confines nothing. What the harness does hold to is narrower and stated as exactly that: no surface hoists the document into `process.env`, and the model is never handed a resolved path to it, so reaching the value takes a deliberate read of a path it was not given. An OS-keychain provider — a store the model's processes cannot read at all — is recorded as the real answer rather than implied by a partial one.
|
||||
|
||||
**One request cannot straddle settings generations.** Model catalog/capability, context, reasoning-default, provider-route, and retry-policy facts are captured from composition. Only connection, credential, and request-transport facts resolve live, once at stream dispatch. A live snapshot that changes any fixed fact is rejected whole, so its endpoint and key cannot combine with capability facts resolved before the change. DeepSeek's accepted snapshot carries the literal key and reference beside the endpoint, and `resolveApiKey` receives that snapshot instead of re-reading configuration. pi-ai defers to provider-native discovery only for a profile naming no credential; a configured reference that misses fails with `MISSING_CREDENTIAL` naming the route and reference.
|
||||
**One request, one generation.** DeepSeek's resolved snapshot carries the credential facts (literal key and reference) beside the endpoint, and `resolveApiKey` receives that snapshot instead of re-reading configuration. A rejected generation now contributes nothing at all. pi-ai defers to provider-native discovery only for a profile naming no credential; a configured reference that misses fails with `MISSING_CREDENTIAL` naming the route and the reference. The boot-time credential probe is deleted: it could run before the credentials service mounted and reported every failure as a missing key, while the first request already gives the accurate error.
|
||||
|
||||
**Provider routes are composition-owned.** `registerAdapter` binds one non-empty route set to its calling fiber and returns a disposer. Settings cannot create or remove routes or change their captured retry policy, so the registry needs no replacement lifecycle and a bad settings snapshot leaves the composition registration untouched.
|
||||
**Route replacement is a registry operation, not a caller sequence.** `registerAdapter` returns a handle carrying `replace(providers)`: the candidate set is validated in full first (conflicts, names, provider metadata), then swapped in one synchronous section. A refused replacement leaves the previous routes registered and serving, and the caller's facts cache only advances after the registry actually holds the new set, so reverting to a working configuration re-applies. pi-ai's registration facts are sorted by provider, so a settings document that merely reorders its keys is no longer a route change.
|
||||
|
||||
**Credential resolution has no publication lifecycle.** The seam is read-only and consumers resolve for each operation, so external changes need no cache invalidation or event. `installSettingsSection` only switches the consumer's source thunk between the live scope and composition entry; committed values are read directly through that thunk.
|
||||
**Contained publication for committed credential writes.** `Credentials.notifyUpdated` fans `credentials/updated` out one listener at a time; sync throws and async rejections are logged without changing the committed operation's outcome, and `INVARIANT`-coded failures rethrow after every listener ran — the same shape the settings seam uses for `settings/updated`. `installSettingsSection`'s cleanup now distinguishes its two triggers: a provider detaching still falls back to the composition entry and re-derives, while the consumer's own unload returns immediately instead of re-registering routes during teardown.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **A sandbox read-denial naming `$DSH_HOME/.env`** — implemented as a `readDenyPaths` policy field (a trailing SBPL `deny file-read* file-write*`, a `/dev/null` bwrap bind) and withdrawn on its own evidence. bwrap must create that bind's mount point inside a tree its profile has already made read-only, so it refuses the entire confinement whenever the parent directory is absent — every host that has not stored a credential yet, including a fresh install; Landlock cannot subtract from its own `/` read grant, so every confined call would report `partial` for a file it never hid. A protection that breaks confinement where it works and misreports it where it does not is worse than a documented absence. Denying the whole harness home was rejected earlier for a separate reason: it also covers `sessions/`, and `DSH_SESSION_JSONL` is a documented model-visible capability.
|
||||
- **Removing `DSH_HOME` from the model's bash environment** — considered as defense in depth and rejected as theater with a real cost: the default home is a documented convention the agent can reconstruct, while the variable is how legitimate tooling finds harness state. There is no boundary here for it to complement; hiding the pointer would only make the absence harder to see.
|
||||
- **Shipping the OS-keychain provider in this round** — it is the only design where the model's processes genuinely cannot read the secret, and it is a sibling package with three platform backends. Sizing it against the rest of this review round would have delayed every other fix; it is recorded as the deferred answer, not as a maybe.
|
||||
- **A mutable adapter-registration handle** — it can make replacement ownership structural, but current provider routes are known at composition and no shipped consumer needs mutation. Static registration removes the lifecycle instead.
|
||||
- **A `replaceRegistration(previous, next)` service method** — the review's shape, but it makes the caller carry the previous handle and lets it pass a mismatched one. Hanging `replace` on the registration handle makes ownership structural: only the registration that holds routes can replace them.
|
||||
|
||||
## Consequences
|
||||
|
||||
The local provider performs a direct environment-then-dotenv read for each resolution; mutation, description, writer locking, and change events are absent. `LlmAdapter` registrants receive an ordinary disposer. Each adapter captures model/capability defaults from composition and resolves one live connection snapshot at dispatch, so a rejected settings generation cannot contribute only an endpoint or key. An OS-keychain provider remains the path to isolating secrets from same-user model tools.
|
||||
`update()`-adjacent behavior gained documented failure modes: a credential write can now fail on the lock deadline or on an unparsable on-disk document, and `describe()` reports `writable: false` for multi-line entries it will not rewrite. `LlmAdapter` registrants keep working unchanged (the handle is still callable as the disposer), and `DeepSeekConnectionOptions` gained credential fields, so a programmatic constructor of the adapter must supply `apiKeyEnv`. Deferred: the OS-keychain credential provider, and per-value revision checks for two writers editing one reference (last-write-wins remains the documented resolution).
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
# Agent Note: 凭据边界与 LLM(大语言模型)请求的同代一致性
|
||||
# Agent Note: 凭据边界、按整份快照发起的请求与原子路由注册
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-30-credential-boundaries-and-atomic-registration.md) | 中文
|
||||
|
||||
> 范围:对[请求级 LLM 配置 seam](2026-07-29-request-level-llm-config-credentials.md)的存储与请求边界修正。后续的[只读凭据与静态路由](../simplification/2026-07-31-read-only-credentials-and-static-llm-routes.md)决策移除了凭据写入、共享原子写入器与可变注册;本 note 负责保留至今的机密边界与整次请求同代规则。
|
||||
> 范围:对[请求级 LLM(大语言模型)配置 seam](2026-07-29-request-level-llm-config-credentials.md)的第三轮评审——存下来的凭据落在哪里、谁能读到它,一次请求的事实如何保持为同一代,以及一组路由如何在不留空窗的前提下更换。本 note 与 [settings 写路径 note](2026-07-30-settings-write-path-integrity.md) 配套:本轮把那篇 note 的提供方修复套用到 `credentials-local`,并把其中的写锁提升进 `dsh-atomic-write`。
|
||||
|
||||
## 问题
|
||||
|
||||
@@ -14,7 +14,7 @@ Status: implemented
|
||||
|
||||
在读取一侧,文件的 `0600` 权限挡得住其他 OS 用户,却挡不住模型:它的 bash 与文件系统工具就以同一个用户身份运行。
|
||||
|
||||
与之并排的还有请求路径缺陷。DeepSeek 的按请求解析把连接事实保存在最后可用快照里,却仍从原始配置重新读取字面 `apiKey`,于是被 resolver 拒绝的那一代设置,照样能把自己的密钥送到上一代的端点上。两个适配器还会在异步模型能力解析期间读取一次完整 settings 快照,并在流派发时再次读取;若两次读取之间发生变更,一代的推理(reasoning)/能力事实就可能与下一代的端点和密钥拼接在一起。配置了 `apiKeyEnv` 却解析不到值时,pi-ai 会把 `undefined` 交给 SDK,让 pi-ai 自己的环境发现拿一个毫不相干的提供方密钥完成鉴权——那是另一个租户,账单还悄悄记在它头上。
|
||||
与之并排的还有两个请求路径缺陷。DeepSeek 的按请求解析把连接事实保存在最后可用快照里,却仍从原始配置重新读取字面 `apiKey`,于是被 resolver 拒绝的那一代设置,照样能把自己的密钥送到上一代的端点上。配置了 `apiKeyEnv` 却解析不到值时,pi-ai 会把 `undefined` 交给 SDK,让 pi-ai 自己的环境发现拿一个毫不相干的提供方密钥完成鉴权——那是另一个租户,账单还悄悄记在它头上。而且它的路由替换是先释放旧注册、再创建新注册:只要有一条路由已被别的适配器占有,现有路由就会被全部丢掉,此后事实缓存可能与注册表中的事实相等,于是把配置改回可用状态也不会重新生效。
|
||||
|
||||
## 决策
|
||||
|
||||
@@ -22,19 +22,19 @@ Status: implemented
|
||||
|
||||
**存下的凭据对模型没有边界,而 README 就是这么写的。**`0700` 目录下的 `0600` 挡得住其他 OS 用户;模型的 bash 与文件系统工具正是以同一用户身份运行,而已交付的默认值不约束任何东西。harness 真正守住的更窄,也就照这个宽度写下来:没有任何一个面会把该文档提升进 `process.env`,模型也从不会拿到它的解析后路径,因此要拿到这个值,需要刻意去读一条并未交给它的路径。OS 钥匙串(keychain)提供方——一个模型的进程根本读不到的存储——被记录为真正的答案,而不是靠一个残缺的方案去暗示它。
|
||||
|
||||
**一次请求不得跨 settings 代取值。**模型 catalog/能力、上下文、推理默认值、提供方路由与重试策略均从组合中捕获。只有连接、凭据与请求传输事实实时解析,并且只在流派发时解析一次。存活快照若更改任何固定事实,就会整代被拒绝,因此它的端点与密钥无法同变更前解析出的能力事实组合。DeepSeek 已接受的快照在端点旁一并携带字面密钥与引用,`resolveApiKey` 接收这份快照,而不再重新读取配置。只有当一个 profile 完全没有点名凭据时,pi-ai 才交给提供方原生的发现流程;配置了引用却解析不到,就以 `MISSING_CREDENTIAL` 失败,并点名路由与引用。
|
||||
**一次请求,一代设置。**DeepSeek 解析出的快照在端点旁一并携带凭据事实(字面密钥与引用),`resolveApiKey` 接收这份快照,而不再重新读取配置。被拒绝的那一代如今完全不再贡献任何东西。只有当一个 profile 完全没有点名凭据时,pi-ai 才交给提供方原生的发现流程;配置了引用却解析不到,就以 `MISSING_CREDENTIAL` 失败,并点名该路由与该引用。启动时的凭据探测被删除:它可能在凭据服务挂载之前就运行,并把每一种失败都报成密钥缺失,而第一次请求本就会给出准确的错误。
|
||||
|
||||
**提供方路由归组合所有。**`registerAdapter` 把一组非空路由绑定到调用方 fiber,并返回释放器。settings 无法创建或移除路由,也无法更改注册时捕获的重试策略,因此注册表无需替换生命周期,错误的 settings 快照也不会影响组合注册。
|
||||
**路由替换是注册表的操作,不是调用方的一串步骤。**`registerAdapter` 返回一个携带 `replace(providers)` 的句柄:候选集合先被完整校验(冲突、名称、提供方元数据),再在一个同步区段内完成替换。被拒绝的替换会让先前的路由保持注册并继续服务,而调用方的事实缓存只有在注册表确实持有新集合之后才会推进,因此改回可用配置时会重新生效。pi-ai 的注册事实按提供方排序,因此仅仅调换键顺序的设置文档不再算作路由变更。
|
||||
|
||||
**凭据解析没有发布生命周期。**该 seam 只读,消费方每个操作都会解析,因此外部变更无需缓存失效或事件。`installSettingsSection` 只在存活 scope 与组合配置项之间切换消费方的来源 thunk;已提交值直接通过该 thunk 读取。
|
||||
**已提交的凭据写入采用收容式发布。**`Credentials.notifyUpdated` 逐个监听器扇出 `credentials/updated`;同步抛错与异步 rejection 都只记日志,不改变已提交操作的结果,而带 `INVARIANT` 代码的失败会在每个监听器都运行完之后重抛——与 settings seam 处理 `settings/updated` 的形状相同。`installSettingsSection` 的清理现在会区分它的两个触发来源:提供方脱离时仍回退到组合的 entry 配置并重新推导,而消费方自身卸载时立即返回,不再在拆卸过程中重新注册路由。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
- **用沙箱点名拒读 `$DSH_HOME/.env`**——已按 `readDenyPaths` 策略字段实现过(末尾一条 SBPL `deny file-read* file-write*`、一条 `/dev/null` 的 bwrap bind),又被它自己的证据推翻。bwrap 必须在自己 profile 已经置为只读的目录树内部创建该 bind 的挂载点,因此只要父目录不存在,它就会拒绝整次约束——那是每一台还没有存过凭据的主机,包括全新安装;Landlock 无法从它自己对 `/` 的读取授权中减去任何东西,于是每一次受限调用都会为一个它其实从未藏起的文件报 `partial`。一项在生效之处破坏约束、在不生效之处误报的保护,比一条写明的「没有保护」更糟。至于拒掉整个 harness home,早先另有理由被否:它同时覆盖 `sessions/`,而 `DSH_SESSION_JSONL` 是一项成文的、模型可见的能力。
|
||||
- **把 `DSH_HOME` 从模型的 bash 环境中移除**——作为纵深防御考虑过,最终按「有真实代价的表演」不予采纳:默认 home 是 agent(智能体)能自行重建的成文约定,而这个变量正是正当工具链定位 harness 状态的途径。这里并不存在一条需要它来补强的边界,藏起指针只会让这份缺席更难被看见。
|
||||
- **本轮就交付 OS 钥匙串提供方**——只有这个设计能让模型的进程真正读不到机密,而它是一个带三种平台后端的兄弟包(package)。把它与本轮评审的其余工作放在一起评估体量,会拖慢其他每一项修复;它被记录为那个延后的答案,而不是一个「也许」。
|
||||
- **可变的适配器注册句柄**:它可以使替换所有权成为结构关系,但当前提供方路由在组合时便已知,并且没有已交付的消费方需要变更能力。静态注册直接移除了这项生命周期。
|
||||
- **做成 `replaceRegistration(previous, next)` 服务方法**——这是评审给出的形状,但它要求调用方自行携带上一个句柄,也允许它传入一个不匹配的句柄。把 `replace` 挂在注册句柄上,让归属关系变成结构性的:只有持有路由的那一项注册才能替换它们。
|
||||
|
||||
## 后果
|
||||
|
||||
本地提供方每次解析都会依次直接读取环境与 dotenv;修改、描述、写入锁和变更事件均不存在。`LlmAdapter` 注册方收到普通释放器。每个适配器从组合中捕获模型/能力默认值,并在派发时解析一份实时连接快照,因此被拒绝的 settings 代不可能只贡献端点或密钥。OS 钥匙串提供方仍是将机密与同一用户身份下的模型工具隔离的实现路径。
|
||||
`update()` 邻近的行为多了成文的失败模式:凭据写入现在可能因锁截止时间到期、或磁盘文档无法解析而失败,`describe()` 对它不会改写的多行条目报告 `writable: false`。`LlmAdapter` 的注册方无需改动即可继续工作(句柄本身仍可当作释放器调用),`DeepSeekConnectionOptions` 则新增了凭据字段,因此以编程方式构造该适配器必须提供 `apiKeyEnv`。延后事项:OS 钥匙串凭据提供方,以及针对两个写方编辑同一引用的逐值修订号检查(后写胜出仍是成文的解决方式)。
|
||||
|
||||
@@ -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 .agents/notes/implemented/architecture/2026-07-30-settings-write-path-integrity.md
|
||||
2026-07-30-settings-write-path-integrity.md: f6b39ebc323e635945d2eae049c0d29e94c390b6
|
||||
2026-07-30-settings-write-path-integrity.zh.md: de60cc05751893d1875f2a68a8b357c5572940ad
|
||||
2026-07-30-settings-write-path-integrity.md: 7bd50adc8a812759c3ae3f80a50978d75baa712a
|
||||
2026-07-30-settings-write-path-integrity.zh.md: da07745ef1de1b694fc3fd1ee3d04322cdadc992
|
||||
|
||||
@@ -14,7 +14,7 @@ Review found the provider's write path could destroy state it never observed, an
|
||||
|
||||
**One operation chain, and every write is a read-modify-write.** Watcher refreshes and persists from every namespace queue share a single settled chain, and `persistSection` begins by reconciling the on-disk text into the seam — publishing any unobserved difference first — before rendering against that fresh text. A write can no longer resurrect a stale document, and an on-disk document that turned invalid fails the write loud rather than being overwritten (the reload path keeps its warn-and-keep-last-good policy; the shared `reconcileFromDisk` throws and each caller picks its policy). The watcher's `ready` signal queues one extra reconcile, closing the startup gap between the initial load and the watcher becoming active.
|
||||
|
||||
**Writes hold a `wx`-created `<file>.lock` sibling.** The read-render-rename cycle runs under a cross-process writer lock with exponential backoff and a 2 s acquisition deadline. Readers never lock because rename commits atomically. A contender never removes a lock it did not create: age cannot distinguish an abandoned lock from a slow live holder, and deleting by age can also remove a successor acquired between inspection and deletion. Contention therefore rejects at the deadline, leaving an abandoned lock for explicit operator recovery.
|
||||
**Writes hold a `wx`-created `<file>.lock` sibling.** The read-render-rename cycle runs under a cross-process writer lock with exponential backoff and a 2 s acquisition deadline. A contender times out without removing the existing lock because age cannot distinguish a crashed owner from a paused live writer; orphan recovery is an operator action. Readers never lock — the rename commit is atomic — so contention is writer-only. The retry and deadline constants are protocol invariants, not deployment config.
|
||||
|
||||
**Observer disposal is quiescent.** Watchers carry an `active` flag checked when a queued invocation would start, so a disposer that ran while the invocation waited prevents the start entirely; started invocations register in a service-level `pendingTails` set that the dispose drain awaits beside the write queues. The `settings/updated` fan-out contains a returned thenable's rejection through the same listener diagnostic as a sync throw, and the event contract now states that the `INVARIANT` rethrow serves synchronous listeners only — invariant companions must stay sync, which the shipped companion already is.
|
||||
|
||||
@@ -24,8 +24,7 @@ Review found the provider's write path could destroy state it never observed, an
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **`proper-lockfile` instead of a hand-rolled lock** — the dependency-over-hand-rolling policy was weighed: the library is barely maintained, its stale/retry policy is broader than this one-file protocol needs, and the shipped lock is a small exclusive-create/backoff loop with deterministic tests. The policy favors dependencies that delete owned code; this one would replace explained local behavior with an opaque peer.
|
||||
- **Age-based stale-lock takeover** — age is not ownership. A slow holder may legitimately cross the threshold, and an inspector can delete a successor's newly acquired lock after the old holder releases. Failing closed preserves mutual exclusion; recovery is explicit because only the operator can establish that no writer remains.
|
||||
- **`proper-lockfile` instead of a hand-rolled lock** — the dependency-over-hand-rolling policy was weighed: the library is barely maintained, its ownership and retry policy is broader than this one-file protocol needs, and the shipped lock is a small exclusive-create loop with deterministic contention tests. The policy favors dependencies that delete owned code; this one would replace a narrow protocol with an opaque peer.
|
||||
- **Revision/CAS instead of a lock** — rename cannot express compare-and-swap, so a CAS needs a version sidecar or content re-hash and a retry loop in every writer; the lock achieves the same serialization with one primitive and keeps readers free.
|
||||
- **Merging external edits into the in-flight write's own section** — the seam merges patches over the state visible at call time, so a same-namespace external edit racing a write still resolves last-write-wins; folding it in would need three-way merge semantics no consumer has asked for. The write publishes the external state first, so the loser is at least observed before being superseded.
|
||||
- **Declaring async `settings/updated` listeners unsupported** — the typed signature is `void` and lint flags misused promises, but an unlinted JS plugin can still register an async listener; a contract note cannot un-throw an unhandled rejection, so containment is the only defense that holds at runtime.
|
||||
@@ -33,4 +32,4 @@ Review found the provider's write path could destroy state it never observed, an
|
||||
|
||||
## Consequences
|
||||
|
||||
`update()` has documented failure modes for lock-deadline expiry and an invalid on-disk document, and rejection messages carry `$`-rooted paths. An abandoned lock blocks writes until an operator confirms that no writer owns it and removes the sidecar. Remaining, documented in the provider README: same-namespace concurrent edits stay last-write-wins (no per-value merge or revision check), a watcher event the OS never delivers leaves the cache stale until the next signal or write, and comments inside replaced arrays or attached inline to changed scalar values go with the value they described. The [user-settings seam note](2026-07-28-user-settings-seam.md)'s deferred-lockfile alternative is superseded by this note.
|
||||
`update()` has documented failure modes for the lock deadline and an invalid on-disk document, and rejection messages carry `$`-rooted paths. A crashed holder can leave a lock that requires verified operator removal; automatic age-based takeover would permit overlapping writers. Remaining, documented in the provider README: same-namespace concurrent edits stay last-write-wins (no per-value merge or revision check), a watcher event the OS never delivers leaves the cache stale until the next signal or write, and comments inside replaced arrays or attached inline to changed scalar values go with the value they described. The [user-settings seam note](2026-07-28-user-settings-seam.md)'s deferred-lockfile alternative is superseded by this note. The same defect classes exist in `dsh-credentials-local` (two chains over one `.env`, cached whole-file write-back, post-persist emit) and in the `llm/adapters-updated` fan-out on the stacked branches; those fixes belong to the PRs that introduce the packages and follow this template on merge-up.
|
||||
|
||||
@@ -18,7 +18,7 @@ YAML 写入则整体替换 namespace 节点,把分节内的每条注释都删
|
||||
|
||||
**单一操作链,且每次写入都是读-改-写。**watcher 的刷新与来自各 namespace 队列的持久化共享同一条结算链;`persistSection` 会先把磁盘上的文本对账进 seam——任何未被观察到的差异都先发布出去——然后才对照这份新鲜文本渲染。写入不再可能复活一份陈旧文档;磁盘上已变非法的文档会让写入响亮失败,而不是被覆盖(重载路径保持其“告警并保留最后可用值”策略;共享的 `reconcileFromDisk` 抛错,各调用方自选策略)。watcher 的 `ready` 信号会额外排入一次对账,弥合初始加载与 watcher 生效之间的启动缺口。
|
||||
|
||||
**写入持有以 `wx` 创建的同目录 `<file>.lock`。**读-渲染-rename 循环在一把跨进程写锁下运行,采用指数退避与 2 s 获取期限。读方从不加锁,因为 rename 会原子提交。竞争者绝不移除并非由自己创建的锁:锁龄无法区分遗留锁与仍存活的慢速持有者,按锁龄删除还可能移除检查与删除之间由后继者取得的新锁。因此,竞争会在期限到达时拒绝写入,把遗留锁留给操作者显式恢复。
|
||||
**写入持有以 `wx` 创建的同目录 `<file>.lock`。**读-渲染-rename 循环在一把跨进程写锁下运行,采用指数退避与 2 s 获取期限。竞争者会超时,但不会移除现有锁,因为锁龄无法区分已经崩溃的所有者与被暂停但仍存活的写入方;遗留锁恢复须由操作者执行。读方从不加锁——rename 提交是原子的——因此竞争只发生在写方之间。重试与期限常量是协议不变式,而非部署配置。
|
||||
|
||||
**观察者 dispose 达到完全停稳。**watcher 携带一个 `active` 标志,排队的调用即将启动时先检查它,因此在调用等待期间已经运行过的释放器能让这次启动彻底不发生;已启动的调用会登记进服务级的 `pendingTails` 集合,dispose 排空除了等待各写队列,还会等待该集合。`settings/updated` 扇出会把监听器返回的 thenable 的 rejection 收容进与同步抛错相同的监听器诊断;事件契约现已写明 `INVARIANT` 重抛只服务同步监听器——不变式配套插件必须保持同步,而已交付的那个配套插件本就是同步的。
|
||||
|
||||
@@ -28,8 +28,7 @@ YAML 写入则整体替换 namespace 节点,把分节内的每条注释都删
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
- **用 `proper-lockfile` 取代手写锁**——按“依赖优先于手写”政策做过权衡:该库几乎无人维护,其陈旧/重试策略比这个单文件协议所需的更宽泛,而已交付的锁只是一个较小的独占创建/退避循环,带确定性测试。该政策偏向能删除自有代码的依赖;这个依赖只会把解释清楚的本地行为换成一个不透明的等价物。
|
||||
- **按锁龄接管陈旧锁**——锁龄不等于所有权。慢速持有者可能合理地跨过阈值,而旧持有者释放后,检查方还可能删除后继者新取得的锁。以失败收口可以保住互斥性;恢复必须显式进行,因为只有操作者才能确认已无写入方存活。
|
||||
- **用 `proper-lockfile` 取代手写锁**——按“依赖优先于手写”政策做过权衡:该库几乎无人维护,其所有权与重试策略比这个单文件协议所需的更宽泛,而已交付的锁只是一个小型独占创建循环,带确定性的竞争测试。该政策偏向能删除自有代码的依赖;这个依赖只会把一个窄协议换成不透明的等价物。
|
||||
- **用修订号/CAS 取代锁**——rename 表达不了 compare-and-swap,因此 CAS 需要一个版本伴随文件或内容重哈希,外加每个写方里的一个重试循环;锁用一个原语实现同样的串行化,还让读方完全免锁。
|
||||
- **把外部编辑合并进正在进行的写入自身的分节**——seam 是在调用时刻可见的状态之上合并 patch 的,因此与写入竞态的同 namespace 外部编辑仍按后写胜出解决;要把外部编辑并进来,需要三方合并语义,而没有任何消费方提出过这种需求。写入会先发布外部状态,落败一方至少在被取代之前被观察到。
|
||||
- **宣布不支持异步 `settings/updated` 监听器**——类型签名是 `void`,lint 也会标记误用的 promise,但未经 lint 的 JS 插件仍能注册异步监听器;契约里的一句说明无法收回已经抛出的 unhandled rejection,收容是唯一在运行时守得住的防线。
|
||||
@@ -37,4 +36,6 @@ YAML 写入则整体替换 namespace 节点,把分节内的每条注释都删
|
||||
|
||||
## 后果
|
||||
|
||||
`update()` 对锁获取期限到达与磁盘文档非法都有成文的失败模式,rejection 消息携带以 `$` 为根的路径。遗留锁会阻塞写入,直到操作者确认没有写入方拥有它并移除伴随文件。仍然存在、且已记录在提供方 README 中的有:同 namespace 并发编辑仍是后写胜出(没有逐值合并,也没有修订号检查);OS 从未投递的 watcher 事件会让缓存保持陈旧,直到下一个信号或下一次写入;被替换数组内部的注释、以及行内附着在被改标量值上的注释,会随其描述的值一起消失。[用户设置 seam note](2026-07-28-user-settings-seam.md)里“延后锁文件”那条替代方案已被本 note 取代。
|
||||
`update()` 对锁获取期限与磁盘文档非法都有成文的失败模式,rejection 消息携带以 `$` 为根的路径。持有者崩溃后可能留下锁,需要操作者核实后移除;若按锁龄自动接管,则会允许多个写入方重叠。仍然存在、且已记录在提供方 README 中的有:同 namespace 并发编辑仍是后写胜出(没有逐值合并,也没有修订号检查);OS 从未投递的 watcher 事件会让缓存保持陈旧,直到下一个信号或下一次写入;被替换数组内部的注释、以及行内附着在被改标量值上的注释,会随其描述的值一起消失。
|
||||
|
||||
[用户设置 seam note](2026-07-28-user-settings-seam.md)里“延后锁文件”那条替代方案已被本 note 取代。同类缺陷还存在于 `dsh-credentials-local`(两条链共用一个 `.env`、按缓存整文件写回、持久化之后才发事件)与堆叠分支上的 `llm/adapters-updated` 扇出;这些修复归引入相应包(package)的那些 PR(Pull Request)所有,向上合并时按本模板处理。
|
||||
|
||||
@@ -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 .agents/notes/implemented/architecture/2026-07-30-web-config-plane.md
|
||||
2026-07-30-web-config-plane.md: 95ede6264026f7b32e95749d00fe841f57dbf867
|
||||
2026-07-30-web-config-plane.zh.md: 6e06b69218a405055621cbd40781f9fbda9f9e6b
|
||||
@@ -0,0 +1,36 @@
|
||||
# Agent Note: the web configuration plane
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-30-web-config-plane.zh.md)
|
||||
|
||||
> Scope: the wire face and web UI deferred from the [request-level LLM configuration note](2026-07-29-request-level-llm-config-credentials.md) — the `settings.*`/`credentials.*`/`llm.*` RPC domains with pushed invalidations, layered+redacted `describe()`, the llm configurable-provider directory and topology event, the standalone `dsh-client-schema-form` model layer, and the Models settings page with its hand-written provider editor. The `deepseek` → `deepseek-official` provider-route rename rides along as the enabling breaking change.
|
||||
|
||||
## Problem
|
||||
|
||||
PR1 made LLM adapter configuration restart-free at the seam, but the only writer was a text editor on `settings.yaml`: the web client had no wire access to settings, credentials, or provider topology, so "store a key, prompt again" still meant leaving the product. Three gaps blocked a config page rather than one: `describe()` returned only the merged effective value (a form cannot tell a user override from a composition default, and serializing it would have shipped `role('secret')` values to every browser), nothing enumerated the providers an adapter *could* run (a bare-mounted `llm-pi-ai` was invisible until configured), and the two adapters both wanted a `deepseek` route key, so the directory could not attribute routes to owning namespaces unambiguously. Hand-maintaining a form per provider was rejected outright — the schemas already exist as schemastery `Config` values, and a second source of field truth drifts.
|
||||
|
||||
## Decision
|
||||
|
||||
**Wire domains on the compiled RPC map, rejections as codes, invalidations as frames.** `settings.describe/update/replace`, `credentials.describe/set/unset`, `llm.providers`, and `llm.models` (claiming the reserved `host.listModels` surface) join `RpcMethodMap`, so the seven compiler-locked wiring sites keep contract, schema, handler, and client in lockstep. Seam rejections fold into `settings-rejected {ns}` / `credential-rejected {ref}` business errors (HTTP stays a carrier), and three `HostFrame`s — `host/settings-changed {ns}`, `host/credentials-changed {ref}`, `host/models-changed` — follow the `host/commands-changed` shape so every client converges without polling. Writes join `pickDirectory`/`openPath` in the connection guard's privileged set: loopback + same-origin or 403, because a LAN-exposed dsh web must not accept config mutation from another origin.
|
||||
|
||||
**`describe()` grows layers and structural secret redaction.** `SettingsDescriptor` carries `base`/`user` beside the effective value, so the form marks "overridden" by presence in the user layer, not value inequality (an override *equal* to the base is still an override). `describe({ redactSecrets: true })` — mandatory at every wire face — strips `role('secret')` subtrees from all three layers via a pure structural walk of the schema (object/dict/array containers; a secret-role subtree is one opaque leaf) and enumerates the stripped slots as `{path, set}`, so a page can render write-only inputs without ever receiving a value.
|
||||
|
||||
**The llm seam declares configurability and announces topology.** `registerConfigurableProviders()` is an all-or-nothing, fiber-scoped directory of `{provider, displayName, settingsNs, settingsPath}` — the addressing a config page needs to open the right settings subtree for a route that may not exist yet; `listConfigurableProviders()` merges with live routes in the wire handler so undeclared live routes still report active. The zero-payload `'llm/adapters-updated'` event fires from all four registration/unregistration commit points with contained listener dispatch (INVARIANT rethrow), following the settings/commands precedent. `llm-deepseek`'s route renamed to `deepseek-official` because the pi-ai catalog legitimately owns `deepseek` as an aggregator entry; pre-release stance, no alias.
|
||||
|
||||
**A hand-written editor over a schema model layer.** `dsh-client-schema-form` rehydrates the wire's `toJSON()` envelope into live schemastery nodes for validation, path resolution, and immutable draft editing — but no generic rendering: the first cut shipped a full schema-driven form renderer, and the resulting page was an unstyled schema dump (every advanced field flattened onto the card, raw field names as labels, the `retryPolicy` unsupported-fallback in the main flow). The user chose the hand-written direction over adding a hint/grouping system, and a second round removed the reference input entirely: the card's primary field is one **API key** input, a whole-section provider without a configured key opens as its setup card, and the collapsed 自定义设置 fold carries the curated per-family extras (`baseURL` for both families, plus `reasoningEffort` for deepseek / `reasoning` for pi-ai), with every other field owned by `settings.yaml`. Validation still runs the rehydrated schema before writing, so a hand-coded field that drifts from its schema fails loud on save rather than silently.
|
||||
|
||||
**The Models page is a three-domain join with seam-shaped apply semantics.** Rows are configured providers; the add card's select is the dormant directory remainder; badges come from route liveness. The key path stays reference-shaped without ever showing a reference: a typed key stores **write-only** through `credentials.set` under the profile's `apiKeyEnv`, deriving `<ROUTE>_API_KEY` when none exists (the pi-ai profile records the derivation), so `settings.yaml` never carries a key value and the wholesale `settings.replace` a removal needs can never drop a sibling's secret. An edit without removals lands as a minimal `settings.update` merge patch; clearing a fold field back to inherited or deleting a row replaces the whole user section, because merge semantics cannot express removal.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Serving JSON Schema over the wire** — schemastery's `toJSON()` envelope round-trips `role()`/meta and rehydrates into the validator the client already ships for drafts; converting to JSON Schema loses exactly the role annotations the credential control and secret redaction key on.
|
||||
- **A generic schema-driven form renderer** — implemented first, then replaced: field truth without visual hierarchy produced an ugly, unusable card, and making it good meant building a hint vocabulary (primary/advanced grouping, per-field descriptions, array item cards) rivaling the hand-written editor in cost while still fitting no mockup exactly. Two schemas exist today (the deepseek `Config` and the shared pi-ai profile), so hand-writing is two thin namespace-keyed layouts; the drift risk is bounded by save-time schema validation and by unknown fields staying untouched in the document.
|
||||
- **Masking secrets per-field with sentinel backfill on `replace`** — the PR1 decision (secrets are references) already deleted the stored-literal case for the product default; structural redaction plus a write-only credential path handles the residue without teaching every writer a sentinel protocol.
|
||||
- **Storing the typed key as a literal `apiKey` setting** — the v1 "one API key input" requirement could have written the literal into the profile, but every UI removal path rebuilds the user section from the *redacted* layers, so any reset or row deletion would silently drop stored sibling keys; deriving a reference keeps the input single-field while keeping `settings.yaml` secret-free and every replace safe.
|
||||
- **A `models` bridge plugin owning provider configuration** — same rejection as PR1: per-plugin namespaces plus a four-field directory declaration give the UI everything it needs; the bridge's unified dict re-imports the adapter-mapping indirection.
|
||||
- **Page-side polling instead of pushed frames** — the mux already carries `host/commands-changed`; three more frames cost one shape each and make a second tab, an external `settings.yaml` edit, and a settings-born route converge at event speed.
|
||||
|
||||
## Consequences
|
||||
|
||||
The whole loop is pinned keyless in the browser lane (`apps/web/tests/models-settings.e2e.ts`): the add card offers the dormant pi-ai catalog, adding `minimax-cn` with a typed key writes the reference-only profile into `settings.yaml`, stores the value into the harness home's `.env` under the derived `MINIMAX_CN_API_KEY`, registers the route live on the topology frame, and the customized fold merges `reasoning` beside the reference — zero model calls, ARIA goldens for the add-card and configured states, plus a scaffold `harnessHome` so tests never touch a real `~/.dsh` (the provider under test is one whose derived reference cannot collide with a developer's exported keys). The rename touched 239 files (fixtures, goldens, docs, python) in one commit with no compatibility alias. The renderer replacement cost one commit and no wire change: apply semantics, redaction, and the directory join were renderer-agnostic all along. Deferred: a per-row models preview (the picker already lists models), a page address for live routes that never declared configurability, and the documented reset edge — a `settings.replace` cannot re-supply a stored *literal* secret in the replaced subtree, which the reference-based default makes unreachable.
|
||||
@@ -0,0 +1,36 @@
|
||||
# Agent Note:web 配置平面
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-30-web-config-plane.md) | 中文
|
||||
|
||||
> 范围:[请求级 LLM 配置 note](2026-07-29-request-level-llm-config-credentials.md) 中延后的 wire 面与 web UI——带推送式失效的 `settings.*`/`credentials.*`/`llm.*` RPC 领域、分层且脱敏的 `describe()`、llm 可配置提供方目录与拓扑事件、独立的 `dsh-client-schema-form` 模型层,以及带手写提供方编辑器的 Models 设置页。`deepseek` → `deepseek-official` 提供方路由重命名作为解锁前提的破坏性变更一并搭车合入。
|
||||
|
||||
## 问题
|
||||
|
||||
PR1 让 LLM(大语言模型)适配器配置在 seam 层面免重启,但唯一的写入方还是直接编辑 `settings.yaml` 的文本编辑器:web 客户端没有触达设置、凭据或提供方拓扑的任何 wire 通道,「存入密钥、再次发起提示」于是仍意味着离开产品本身。挡住配置页的缺口不是一个,而是三个:`describe()` 只返回合并后的生效值(表单分不清用户覆盖与组合默认值,而且照原样序列化会把 `role('secret')` 的值发到每一个浏览器);没有任何东西枚举适配器*可以*运行的提供方(裸挂载的 `llm-pi-ai` 在配置之前完全不可见);两个适配器又都想要 `deepseek` 这个路由键,目录因此无法无歧义地把路由归到拥有它的 namespace 名下。为每个提供方手工维护一份表单被直接否决——schema 已经以 schemastery `Config` 值的形式存在,第二份字段真源注定漂移。
|
||||
|
||||
## 决策
|
||||
|
||||
**wire 领域挂上编译期 RPC 映射,拒绝落为错误码,失效落为帧。**`settings.describe/update/replace`、`credentials.describe/set/unset`、`llm.providers` 与 `llm.models`(认领预留的 `host.listModels` 面)一同加入 `RpcMethodMap`,七处由编译器锁定的接线位点因此让契约、schema、处理器与客户端保持步调一致。seam 侧的拒绝折叠为 `settings-rejected {ns}`/`credential-rejected {ref}` 业务错误(HTTP 仍只是载体),三个 `HostFrame`——`host/settings-changed {ns}`、`host/credentials-changed {ref}`、`host/models-changed`——沿用 `host/commands-changed` 的形状,因此每个客户端都无需轮询即可收敛。写入与 `pickDirectory`/`openPath` 一起进入连接守卫的特权集合:回环 + 同源,否则 403,因为暴露在局域网上的 dsh web 绝不能接受来自其他源的配置修改。
|
||||
|
||||
**`describe()` 增加分层与结构化 secret 脱敏。**`SettingsDescriptor` 在生效值之外携带 `base`/`user`,表单据此按「字段是否出现在用户层」来标记「已覆盖」,而非按值是否不等(与 base *相等*的覆盖仍然是覆盖)。`describe({ redactSecrets: true })`——在每个 wire 面都强制启用——经由对 schema 的纯结构遍历(object/dict/array 容器;secret 角色子树整体是一个不透明叶节点)从全部三层剥除 `role('secret')` 子树,并把剥除的槽位枚举为 `{path, set}`,页面因此不必收到任何值就能渲染只写输入框。
|
||||
|
||||
**llm seam 声明可配置性并公布拓扑。**`registerConfigurableProviders()` 是一个全有或全无、以 fiber 为作用域的目录,条目为 `{provider, displayName, settingsNs, settingsPath}`——这正是配置页要为一条可能尚不存在的路由打开正确设置子树时所需要的寻址;`listConfigurableProviders()` 在 wire 处理器里与存活路由合并,未声明的存活路由因此仍报告为激活。零负载的 `'llm/adapters-updated'` 事件从全部四个注册/注销提交点触发,listener 派发带异常隔离(INVARIANT 重抛),沿用 settings/commands 的先例。`llm-deepseek` 的路由重命名为 `deepseek-official`,因为 pi-ai catalog 名正言顺地拥有 `deepseek` 这个聚合器条目;依预发布立场,不设别名。
|
||||
|
||||
**架在 schema 模型层之上的手写编辑器。**`dsh-client-schema-form` 把 wire 的 `toJSON()` 信封还原(rehydrate)为活的 schemastery 节点,用于校验、路径解析与不可变草稿编辑——但不做通用渲染:第一版交付了完整的 schema 驱动表单渲染器,得到的却是一个未加样式、把 schema 原样倾倒出来的页面(每个进阶字段都平铺到卡片上、原始字段名直接充当标签、`retryPolicy` 的「不支持」回退落在主流程里)。用户没有再加一套提示/分组系统,而是选择了手写方向,第二轮又把引用输入框整个移除:卡片的主字段是一个 **API 密钥**输入框,未配置密钥的整分节提供方会以其设置卡片的形式打开,收起的「自定义设置」折叠区承载按家族精选的额外字段(两个家族都有 `baseURL`,另加 deepseek 的 `reasoningEffort`/pi-ai 的 `reasoning`),其余每个字段都归 `settings.yaml` 所有。校验仍会在写入前运行还原出的 schema,因此偏离其 schema 的手写字段会在保存时大声失败,而非静默失败。
|
||||
|
||||
**Models 页是一次三领域联接,应用语义与 seam 同形。**每一行是一个已配置的提供方;「新增」卡片的选择框是可配置提供方目录中剩余的休眠条目;徽标来自路由存活状态。密钥通道保持引用形态,却从不展示任何引用:键入的密钥经 `credentials.set` **只写**存入 profile 的 `apiKeyEnv` 之下,引用不存在时便派生 `<ROUTE>_API_KEY`(pi-ai profile 会记录该派生),因此 `settings.yaml` 从不携带密钥值,删除所需的整体 `settings.replace` 也绝不可能丢掉兄弟条目的机密。不含删除的编辑以一次最小的 `settings.update` 合并 patch 落地;把折叠区字段清回继承值或删除整行则经 `settings.replace` 替换整个用户分节,因为合并语义表达不了删除。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
- **在 wire 上改发 JSON Schema**——schemastery 的 `toJSON()` 信封能往返保留 `role()`/meta,并还原成客户端为草稿校验本就自带的那个校验器;转换成 JSON Schema 丢掉的恰恰是凭据控件与 secret 脱敏所依赖的角色注解。
|
||||
- **通用的 schema 驱动表单渲染器**——先实现、后被替换:如实呈现字段却缺失视觉层级,产出的卡片丑陋且不可用;要把它做好,就意味着构建一套提示词汇(主要/进阶分组、逐字段描述、数组项卡片),成本堪比手写编辑器,却仍无法与任何设计稿完全吻合。今天存在两份 schema(deepseek 的 `Config` 与共享的 pi-ai profile),手写因此就是两套以 namespace 为键的薄布局;漂移风险由保存时的 schema 校验以及未知字段在文档中的原样保留共同约束。
|
||||
- **逐字段脱敏机密并在 `replace` 时回填哨兵值**——PR1 的决策(机密是引用)已经为产品默认形态删掉了「存储字面量」这种情况;结构化脱敏加上只写的凭据通道足以处理残余情形,无需让每个写入方都学会一套哨兵协议。
|
||||
- **把键入的密钥存成字面 `apiKey` 设置**——v1「单个 API 密钥输入框」的需求本可以把字面量直接写进 profile,但 UI 的每条删除路径都会从*脱敏后的*各层重建用户分节,任何重置或整行删除都会静默丢掉已存储的兄弟密钥;派生引用让输入保持单字段,同时让 `settings.yaml` 不含机密、每一次 replace 都安全。
|
||||
- **由 `models` 桥接插件持有提供方配置**——与 PR1 相同的否决理由:按插件划分的 namespace 加上四字段的目录声明已经给了 UI 需要的一切;桥接层的统一字典会把适配器映射那层间接重新引进来。
|
||||
- **页面侧轮询而非推送帧**——mux 已经承载 `host/commands-changed`;再加三个帧各自只多一个形状的成本,就让第二个标签页、外部的 `settings.yaml` 编辑和由设置催生的路由都以事件速度收敛。
|
||||
|
||||
## 后果
|
||||
|
||||
整条闭环以无密钥方式固定在浏览器测试通道(`apps/web/tests/models-settings.e2e.ts`):「新增」卡片提供休眠的 pi-ai catalog,携键入的密钥添加 `minimax-cn` 会把只含引用的 profile 写入 `settings.yaml`、把密钥值存入 harness 家目录 `.env` 中派生的 `MINIMAX_CN_API_KEY` 之下、路由随拓扑帧注册为存活,「自定义设置」折叠区则把 `reasoning` 合并到引用旁边——全程零模型调用,「新增」卡片态与已配置态各有 ARIA golden,另有脚手架式的 `harnessHome`,测试绝不触碰真实的 `~/.dsh`(受测提供方是派生引用不可能与开发者已导出密钥相撞的那一个)。这次重命名在一次提交中触及 239 个文件(fixture(测试前置数据)、golden、文档、python),未保留兼容别名。替换渲染器只花了一次提交,且没有任何 wire 变更:应用语义、脱敏与目录联接从一开始就与渲染器无关。延后事项:每行的模型预览(选择器已能列出模型)、为从未声明可配置性的存活路由提供页面地址,以及已记录在案的重置边界情形——`settings.replace` 无法在被替换的子树里重新补上已存储的*字面量*机密,而基于引用的默认形态让这种情况根本无从出现。
|
||||
Reference in New Issue
Block a user