设置、凭据与身份
进程里"用户改了配置"这件事有三种不同的形状:非敏感的偏好、敏感的密钥、以及一个不带账号信息的匿名身份。
三者拆成三条独立的 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是 SchemasterytoJSON()信封{ 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 写进 profilecordis.patch.yml:volatile 字段取新值、普通字段取写时现场值;与 inherited 全等即 unpin。 -
乐观并发。 写操作接受
expectedRevision:不匹配就以SettingsConflictException(wiresettings/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。