设置、凭据与身份

进程里"用户改了配置"这件事有三种不同的形状:非敏感的偏好、敏感的密钥、以及一个不带账号信息的匿名身份。 三者拆成三条独立的 seam。对应实现:src/Tether.Settings/、src/Tether.Settings.Profile/、 src/Tether.Credentials/、src/Tether.Credentials.Local/、src/Tether.Identity/。

设置:profile 拥有的表单投影

public interface ISettings
{
    bool IsWritable { get; }
    string? DocumentPath { get; }
    ValueTask<IReadOnlyList<SettingsDescriptor>> DescribeAsync(CancellationToken ct = default);
    ValueTask<SettingsDescriptor> UpdateAsync(string ns, JsonElement patch, long? expectedRevision = null, CancellationToken ct = default);
    ValueTask<SettingsDescriptor> ReplaceAsync(string ns, JsonElement section, long? expectedRevision = null, CancellationToken ct = default);
    ValueTask<SettingsDescriptor> MutateAsync(string ns, IReadOnlyList<SettingsPathOperation> operations, long? expectedRevision = null, CancellationToken ct = default);
    void Configure(SettingsPresentation presentation, string? ownerFiberId = null);
}

表单命名空间是profile 组合 entry id——同一插件挂载两次产出两个表单。每个 entry 的表单只投影其 config 类型里的 Volatile<T> 字段;普通字段不进 schema、不进任何投影。applies 只剩 live。 vendored Models 页按命名空间选编辑器:llm-pi-ai 走 pi-ai 编辑器(providers.<route> profile 表), 所以发行组合里 llm-multi 的 entry id 就是 llm-pi-ai(与上游 base bundle 同名)。

  • Schema wire。 schema 是 Schemastery toJSON() 信封 { uid, refs },与上游 settings.describe 同形:浏览器 new Schema(json) 复水后按 dict/inner 解析设置路径、校验取值(JSON Schema 的 properties 复水后没有 dict,任何路径都解析不到)。CLR 类型映射为 string/boolean/number(整数带 step: 1)/array/dict/object;自由形状 JSON(JsonElement 等)反射不出结构,投影为 any——需要 结构的字段用 [ConfigSchema] 声明 Schemastery 节点(嵌套形状),例如 llm-multi 的 providers 由 MultiProviderSettingsSchema 声明,只列运行时解析器接受的字段。

  • 三层投影。 value(live volatile 快照)、base(profile 文档之下各组合层的 inherited 值)、 user(profile 文档自身的 pin);revision 按 uid+schema+override fingerprint 单调前进。 与上游 projectForm 一致,未设置的可选字段(null 快照)在 value 里缺席而不是 null——浏览器端 按”缺席 = 未设置”解读;secret 是唯一例外,见下条。

  • 脱敏投影。 [ConfigSecret] 字段列入 secrets[],schema 节点带上游 role('secret')(meta.role)标记,值在每个投影里 只剩 null 占位——set 布尔位仍暴露”配没配”,明文永不回放;写入照常落到 profile 文档。

  • 完整 pin。 Update/Replace/Mutate 经 IConfigEditor 把该 entry 的完整 config 写进 profile cordis.patch.yml:volatile 字段取新值、普通字段取写时现场值;与 inherited 全等即 unpin。

  • 乐观并发。 写操作接受 expectedRevision:不匹配就以 SettingsConflictException (wire settings/conflict)失败,UI 层据此做”先读后写”冲突恢复。

  • 遮蔽保护。 home patch / --patch 会遮蔽该 entry 的编辑在持久化前失败,文档原样不动;嵌套 include 行不可写。

  • 原子性与回滚。 写串行于 hmr 互斥与 profile 目录文件锁之下,writeFileAtomic 保持 0600; 写后 ReconcileProfilePatchesAsync(patches, requiredIds:[entry.id]) 生效——volatile 提交就地生效, 不重挂运行实例;reconcile 失败回写原文并再 reconcile 后原样报错。

  • 信号。 settings/document-updated 在表单 fingerprint 变化时推送一次(ns, revision); app-boot/config-reload 在每次成功 reconcile 末尾广播一次。两者都是非持久化事件,不进 session 词汇表。settings/changed、settings.json 分层与 settings-file 行已退役:profile 驱动的启动检测到 残留的 $TETHER_HOME/settings.json 时经启动告警通道(stderr 与启动日志)提示一次,不再读写。

  • 无 profile 组合。 headless/sdk/acp 不挂 settings/config-editor——两行保持 pending, 不提供 provider、不报错。

  • Configure(auto:false)。 声明自带表单页的插件(permission-presets、ui-theme 等)按其 ctx.FiberId 登记,表单不自动生成。

凭据:描述永远不含秘密

public interface ICredentials
{
    ValueTask<CredentialResolution> ResolveAsync(CredentialRef reference, CancellationToken ct = default);
    ValueTask<CredentialDescription> DescribeAsync(CredentialRef reference, CancellationToken ct = default);
    ValueTask SetAsync(CredentialRef reference, string value, CancellationToken ct = default);
    ValueTask UnsetAsync(CredentialRef reference, CancellationToken ct = default);
}

消费者按 CredentialRef 取值,每次调用都重新解析——密钥轮换与外部编辑在下一次取值时生效,进程内没有缓存的明文副本。

DescribeAsync 回答”这个凭据由哪个源提供、配置了没有”,但从不返回值本身。这是 UI 面(展示”已配置”徽标)与诊断面唯一能用的读接口。

本地实现按优先级叠三层:项目 .env、用户 .env、托管存储文件。写操作只落在托管层;当某个只读源(如环境变量)当前正在提供该凭据时,SetAsync / UnsetAsync 以 CREDENTIAL_SHADOWED 失败——你不能悄悄写一个立刻被遮蔽的值。

Credential records 与 Authorization

env-var CredentialRef 与持久 CredentialKey(<owner>/<id>)是两个严格分开的 key space。 record 是判别联合:api-key 保存直接 key 和/或 POSIX 环境变量映射,grant 保存由 owning adapter 解释的 opaque JSON object。读、描述、列举、排他 ModifyRecordAsync 与删除都走同一个 owner-only 本地存储 authority;描述、watcher、错误与 settlement 只携地址/kind,永不返回 secret。存在但类型 错误的 optional 字段按 BAD_RECORD fail closed,不能伪装成缺失。

IAuthorization 为每个 exact flow registration 提供单在途 attempt。prompt 明确声明 Confirm、Text、Secret 或 Select,response 必须匹配 exact kind/options;secret value 只留在 attempt-local memory。caller cancel、withdraw、flow unload 与 service unload 都撤销 attempt authority, 忽略取消的 flow 也只能在有界 grace 后得到 secret-free Cancelled settlement,晚到结果不能提交。 flow 提交走 AuthorizationAttemptContext.CommitAsync 的准入门(上游 session.commit): 取消后调用抛 AUTHORIZATION_CANCELLED 且不写 record;一旦准入,该 attempt 不再可取消, withdraw/caller cancel 与 grace 死线都不再生效,写入走到 Committed settlement。

Multi-provider adapter 只为自己声明的 CredentialKey 注册 API-key flow:通过 typed secret prompt 取得值,在 ICredentials.ModifyRecordAsync 的排他区间提交 CredentialRecord.ApiKey,再由 Authorization service 复核确有新 commit。浏览器 method chooser、OAuth grant UI 与 Models/Web 交互仍归 #80,不属于 core/provider seam。

存量凭据键迁移(Legacy Credential Key Migration)

为了对齐上游 KEY_SEGMENT_PATTERN(key 的 segment 首字符必须为小写字母),用量账号(usage)凭据 key 已从历史的裸 UUID 形态(usage/<uuid32>)统一收敛为规范的 usage/acct-<uuid32> 前缀。

为防存量包含数字开头 UUID 的配置文件在启动时解析失败打死宿主,系统提供了 ILegacyCredentialKeyMigrator 扩展点:

  • 透明识别与迁移:KnownLegacyCredentialKeyMigrators.UsageBareUuid 严格识别旧版 32 位 hex 键名并在 LocalCredentials.ParseDocument 前自动重写为 acct- 形状;
  • 排他原子持久化:首次读取检测到 NeedsMigration 时,在 .writer 租约保护下重读并通过原子写回(PersistAsync/AtomicFile),保证幂等与字节级安全。

匿名身份

public interface IAnonymousIdentity
{
    ValueTask<string> GetIdAsync(CancellationToken ct = default);
    bool IsPersistent { get; }
}

每个 Harness home 一个随机 id,首次使用时生成并尽力持久化到 $\{TETHER_HOME\}/identity。它不带任何账号信息,只服务于遥测去重与崩溃关联;删掉那个文件即重置。home 不可写时 IsPersistent 为 false,每次进程启动得到一个新 id。

下一步

在 GitHub 上编辑此页