feat(steward): make the operator credential a first-class machine setting - #4505
Conversation
…ting The steward channel and the managed Turn host both authenticate with an operator-supplied API key and base URL, and both read that pair from the service environment only. Changing it therefore meant editing a launch file and restarting LoopX, and no product surface could show what was configured. Store it in its own owner-only file under the machine runtime root and resolve it ahead of the service environment, field by field, so one field stored here does not hide the other field's environment value. Deliberately not a machine-configuration namespace: that document is projected to the browser, read back by `describe`/`inspect`, and copied into per-transaction backups, so a secret there would be readable from four surfaces and copied by every unrelated settings change. The key is write-only. Every readback -- the Dashboard panel, the new `GET/POST /api/chat/operator-credential`, and `loopx machine-config credential status` -- returns the source and a truncated fingerprint instead, and a record this machine cannot read resolves to no credential rather than to the environment. An update merges, and clearing is explicit. Also compose the steward capability payload in its own owner so the model arguments, the availability verdict and the readback cannot disagree about which executor and credential they describe. That extraction leaves both hot control-plane modules at or below their metric ceilings. Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com>
e8040c7 to
0d8035f
Compare
huangruiteng
left a comment
There was a problem hiding this comment.
Approval conclusion (author-owned PR; GitHub blocks formal self-approval)
审阅的精确 head:0d8035fe810eec9b94433d317a7a2123fc412862
动机
管家通道和托管 Turn 宿主都靠同一对「操作者凭据」(API key + 非默认 endpoint 时的 base URL)做认证,而这对凭据此前只能从服务进程环境读取。后果有三个:换 key 必须改 ~/Library/LaunchAgents/com.loopx.chat.plist 再重启服务;没有任何 LoopX 面能告诉你当前生效的是哪把 key;为了让 dsh 可启动,还得把 DEEPSEEK_API_KEY 一直 export 进 Chat 服务。
最后一条尤其讽刺:托管宿主存在的意义就是「不要依赖个人 CLI 登录」,但凭据本身却只能靠手改启动文件维护。这正是要补的那一块机器配置。
改动思路
核心取舍是存在哪里。
machine/configuration.json 不是候选:它会被投影给浏览器、被 describe/inspect 回读、并被复制进每一笔事务备份和回滚计划。把密钥放进去,等于从四个面可读、并且被每一次无关的设置变更复制一份。所以凭据单独成文件:<runtime-root>/machine/credentials/operator_provider.json,目录 0700、文件 0600、原子写。这是本 PR 唯一新增的持久化状态。
第二取舍是优先级。机器存储优先于服务环境,按字段分别解析。理由是与 steward_executor 保持一致——同为「人在产品面上编辑的机器级设置」,不该有两套相反的优先级规则;而且如果环境永远赢,前端字段在像本机这样已经 export 了 key 的机器上就是装饰品。环境保留为 bootstrap 和逃生口:没有存过记录时,行为与改动前逐字节一致。
第三取舍是回读。key 是只写的:所有回读(Dashboard 面板、新的 chat 路由、machine-config credential status)返回的是每个字段的来源和一个 sha256 前 12 位指纹,绝不返回值本身。指纹的存在是为了让操作者能回答「我刚存的 key 就是正在跑的那把吗」,而不是为了让人再读一遍 key。
具体改动
新增 loopx/control_plane/operator_provider.py:记录归一化(拒绝未知字段、非 http(s) 的 URL、含空白的 key,且报错不回显值)、_secure_write(0700/0600 + 原子写)、operator_provider_projection(唯一回读)、operator_provider_environ(给既有解析链的覆盖层)、operator_provider_host_credential(只把两个变量交给子宿主进程)。
新增 loopx/chat_operator_provider_api.py:GET/POST /api/chat/operator-credential,通过既有的 ChatConfigurationRequestMixin 注册。响应体就是那份 projection,因此浏览器契约和 CLI 契约是同一个版本化载荷,不会分叉成两种写法。
loopx/capabilities/machine_configuration/cli.py 增加 machine-config credential status|set|clear;值从 JSON 文件或 stdin(--config-json -)读入,不进 shell history 和 argv。markdown 渲染只打印 configured/source/fingerprint。
loopx/chat_manager.py 新增 controller_runtime_root、operator_credential_resolution、operator_credential_pair,以及 manager_capabilities_projection——把「机器默认 + 凭据 + 会话」这套组合收成一个所有者,模型参数、可用性判定和回读无法再互相矛盾。
loopx/chat_server.py 因此从内联 7 行变成 3 行调用;loopx/chat_runtime.py 托管分支改为用解析后的环境取 profile/model,并把凭据对交给适配器;loopx/chat_dsh.py 新增 credential 字段,在 _run_segment 里先铺凭据再覆盖 STEWARD_SEGMENT_ENV,所以调用方无法借此放宽 DSH_PERMISSION_MODE=read-only;loopx/chat_endpoint_catalog.py 与 loopx/cli_commands/turn.py 同样改为解析机器凭据。
前端:machine-configuration-settings.tsx 顶部新增 OperatorCredentialSettings 面板(只写 key 输入 + endpoint 输入 + 清除按钮 + 红字回读),chat.ts 增加 zod 契约与读写函数,i18n.tsx 中英各一组文案,personal-workspace.css 加样式;loopx/web/chat 打包产物重建(CI 会 npm run build:chat 并比对,本地已验证重建后 git status -- loopx/web/chat 为空)。
文档:新增 docs/reference/operator-model-credential.md(位置、模式、回读契约、优先级、失败行为、权限边界),并从 dsh 连接器文档指过去。
对主干的风险
优先级反转:已经 export 了 key 的机器,存了新 key 后会静默改用新 key。这是 PR 的目的,但必须可见才安全——projection 报 source=machine_store 和指纹,通道回读新增 operator_credential_source,想退回环境就 credential clear。已作为 P3 记在评审里。
密钥泄进机器配置:若将来有人把 key 挪进 namespace,浏览器投影、inspect、每笔事务备份都会带上它,而今天没有任何测试会失败。因此 smoke 与单测各有一条 machine_configuration_never_carries_the_key 断言作为结构性守卫;这条也记为 P3,属于「边界变更需单独评审」。
不可读记录的处理:记录损坏时不回退到环境,而是解析为「无凭据」并报 invalid + 修复步骤,托管面沿用既有的 operator_credential_unconfigured 拒答。用一把操作者在产品面上已经看不见的凭据去跑,比拒答更糟。
热模块膨胀:chat_runtime.py / chat_server.py 都有行数天花板(1502 / 1514),仓库自带的 maintainability ratchet 会拦。本 PR 不是加行而是抽出组合逻辑:最终 1502 / 1506,ratchet ok。这也是我不把环境解析塞进这两个文件的直接原因。
回读 schema:manager_channel_binding 新增 operator_credential_source(有默认值 not_read),是唯一的读模型变化,属追加而非改名。
未覆盖:真实 dsh 段对真实 provider endpoint 的端到端认证不在本轮证据内;本 head 也没有用 GitHub CI 作证据(下述 main 已知红灯)。
验证与继承红灯:pytest 12 个相关套件 194 passed;examples/operator-provider-credential-smoke.py ok;dashboard-pwa-bundle-smoke.py ok;ratchet ok;loopx canary premerge --from-git-diff --git-diff-base origin/main --tier standard → passed,self_merge_allowed: true,0 failures / 0 manual holds。需要说明的是:仓库 main 当前在继承性检查上就是红的(Python Tests 在 f4ed58de9 失败、Full Public Smokes 在 75fcd5556 失败,kernel-static-checks 报的 1051 处类型错误分布在与本 PR 无关的文件里),因此本 PR 的远端 CI 红不是本次改动引入,但也不作为本轮的覆盖证据。
我的整体评价
正向且比例合适,建议合并。
它补的是产品面上真实缺失的一环:托管宿主的存在理由是不依赖个人 CLI 登录,而凭据却只能靠改 plist 维护。修法选在最小边界上——一个 0600 的独立文件,复用既有 environ 解析缝,不新开 execuor/model/authority 契约;把「密钥不进机器配置」做成结构性质而不是投影约定。
几处判断我认为是对的:只写 key 而不是「可读但脱敏」,避免了把密钥重新变成可外泄的状态;不可读记录 fail-closed 到「无凭据」而不是悄悄用环境里的另一把;子宿主只收两个变量,凭据变更无法顺手把整个服务环境带进段里。manager_capabilities_projection 这一步是把「模型参数 / 可用性判定 / 回读」的单一所有者说清楚,同时也让两个热模块分别降到天花板以内(1502 / 1506),属于有界且行为保持的重构,而不是为了堆功能。
需要留意的三点都已记为 P3:优先级在已配置机器上的可见行为变化、机器配置文档的密钥边界需要长期守卫、以及真实远端认证链路不在本轮证据内。
无阻断性发现。
审阅的精确 head:0d8035fe810eec9b94433d317a7a2123fc412862
English verdict: APPROVE - head 0d8035f makes the operator credential a machine setting stored in its own 0600 file under a 0700 directory, resolved field by field ahead of the service environment, with a write-only key: every readback (Dashboard panel, GET/POST /api/chat/operator-credential, loopx machine-config credential status) returns the field source and a truncated sha256 fingerprint instead, and an unreadable record resolves to no credential rather than to the environment. It is deliberately not a machine-configuration namespace, because that document is projected, inspected and copied into transaction backups. The same commit extracts manager_capabilities_projection so the model arguments, availability verdict and readback share one owner, leaving both hot control-plane modules inside their line ceilings (1502/1506). No blocking finding; three P3 notes cover the visible precedence change on an already-configured machine, the structural guard that keeps the key out of the machine-configuration document, and the untested live dsh segment. Validation: 194 passed across 12 relevant suites, the entry-point smoke ok, packaged bundle parity verified, and the repository pre-merge gate passed with self_merge_allowed: true; GitHub CI on this head is not used as evidence because main itself is currently red on inherited repo-wide static checks.
The packaged-browser smoke treats `.personal-capability-actions` as the capability editor's own action row and asserts on it with a strict locator. The operator credential panel renders on the same page, so reusing that class made the locator resolve to two elements and failed the packaged acceptance job. Give the panel its own action-row class (styled by the same rule) and record why, then rebuild the packaged chat bundle. Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com>
huangruiteng
left a comment
There was a problem hiding this comment.
Approval conclusion (author-owned PR; GitHub blocks formal self-approval)
审阅的精确 head:519041b0de04da8a09a2a3dc928d203f0a4c1b29
动机
管家通道和托管 Turn 宿主都靠同一对「操作者凭据」(API key + 非默认 endpoint 时的 base URL)做认证,而这对凭据此前只能从服务进程环境读取。后果有三个:换 key 必须改 ~/Library/LaunchAgents/com.loopx.chat.plist 再重启服务;没有任何 LoopX 面能告诉你当前生效的是哪把 key;为了让 dsh 可启动,还得把 DEEPSEEK_API_KEY 一直 export 进 Chat 服务。
最后一条尤其讽刺:托管宿主存在的意义就是「不要依赖个人 CLI 登录」,但凭据本身却只能靠手改启动文件维护。这正是要补的那一块机器配置。
改动思路
核心取舍是存在哪里。
machine/configuration.json 不是候选:它会被投影给浏览器、被 describe/inspect 回读、并被复制进每一笔事务备份和回滚计划。把密钥放进去,等于从四个面可读、并且被每一次无关的设置变更复制一份。所以凭据单独成文件:<runtime-root>/machine/credentials/operator_provider.json,目录 0700、文件 0600、原子写。这是本 PR 唯一新增的持久化状态。
第二取舍是优先级。机器存储优先于服务环境,按字段分别解析。理由是与 steward_executor 保持一致——同为「人在产品面上编辑的机器级设置」,不该有两套相反的优先级规则;而且如果环境永远赢,前端字段在像本机这样已经 export 了 key 的机器上就是装饰品。环境保留为 bootstrap 和逃生口:没有存过记录时,行为与改动前逐字节一致。
第三取舍是回读。key 是只写的:所有回读(Dashboard 面板、新的 chat 路由、machine-config credential status)返回的是每个字段的来源和一个 sha256 前 12 位指纹,绝不返回值本身。指纹的存在是为了让操作者能回答「我刚存的 key 就是正在跑的那把吗」,而不是为了让人再读一遍 key。
具体改动
新增 loopx/control_plane/operator_provider.py:记录归一化(拒绝未知字段、非 http(s) 的 URL、含空白的 key,且报错不回显值)、_secure_write(0700/0600 + 原子写)、operator_provider_projection(唯一回读)、operator_provider_environ(给既有解析链的覆盖层)、operator_provider_host_credential(只把两个变量交给子宿主进程)。
新增 loopx/chat_operator_provider_api.py:GET/POST /api/chat/operator-credential,通过既有的 ChatConfigurationRequestMixin 注册。响应体就是那份 projection,因此浏览器契约和 CLI 契约是同一个版本化载荷,不会分叉成两种写法。
loopx/capabilities/machine_configuration/cli.py 增加 machine-config credential status|set|clear;值从 JSON 文件或 stdin(--config-json -)读入,不进 shell history 和 argv。markdown 渲染只打印 configured/source/fingerprint。
loopx/chat_manager.py 新增 controller_runtime_root、operator_credential_resolution、operator_credential_pair,以及 manager_capabilities_projection——把「机器默认 + 凭据 + 会话」这套组合收成一个所有者,模型参数、可用性判定和回读无法再互相矛盾。
loopx/chat_server.py 因此从内联 7 行变成 3 行调用;loopx/chat_runtime.py 托管分支改为用解析后的环境取 profile/model,并把凭据对交给适配器;loopx/chat_dsh.py 新增 credential 字段,在 _run_segment 里先铺凭据再覆盖 STEWARD_SEGMENT_ENV,所以调用方无法借此放宽 DSH_PERMISSION_MODE=read-only;loopx/chat_endpoint_catalog.py 与 loopx/cli_commands/turn.py 同样改为解析机器凭据。
前端:machine-configuration-settings.tsx 顶部新增 OperatorCredentialSettings 面板(只写 key 输入 + endpoint 输入 + 清除按钮 + 红字回读),chat.ts 增加 zod 契约与读写函数,i18n.tsx 中英各一组文案,personal-workspace.css 加样式;loopx/web/chat 打包产物重建(CI 会 npm run build:chat 并比对,本地已验证重建后 git status -- loopx/web/chat 为空)。
文档:新增 docs/reference/operator-model-credential.md(位置、模式、回读契约、优先级、失败行为、权限边界),并从 dsh 连接器文档指过去。
对主干的风险
优先级反转:已经 export 了 key 的机器,存了新 key 后会静默改用新 key。这是 PR 的目的,但必须可见才安全——projection 报 source=machine_store 和指纹,通道回读新增 operator_credential_source,想退回环境就 credential clear。已作为 P3 记在评审里。
密钥泄进机器配置:若将来有人把 key 挪进 namespace,浏览器投影、inspect、每笔事务备份都会带上它,而今天没有任何测试会失败。因此 smoke 与单测各有一条 machine_configuration_never_carries_the_key 断言作为结构性守卫;这条也记为 P3,属于「边界变更需单独评审」。
不可读记录的处理:记录损坏时不回退到环境,而是解析为「无凭据」并报 invalid + 修复步骤,托管面沿用既有的 operator_credential_unconfigured 拒答。用一把操作者在产品面上已经看不见的凭据去跑,比拒答更糟。
热模块膨胀:chat_runtime.py / chat_server.py 都有行数天花板(1502 / 1514),仓库自带的 maintainability ratchet 会拦。本 PR 不是加行而是抽出组合逻辑:最终 1502 / 1506,ratchet ok。这也是我不把环境解析塞进这两个文件的直接原因。
回读 schema:manager_channel_binding 新增 operator_credential_source(有默认值 not_read),是唯一的读模型变化,属追加而非改名。
未覆盖:真实 dsh 段对真实 provider endpoint 的端到端认证不在本轮证据内;本 head 也没有用 GitHub CI 作证据(下述 main 已知红灯)。
验证与继承红灯:pytest 12 个相关套件 194 passed;examples/operator-provider-credential-smoke.py ok;dashboard-pwa-bundle-smoke.py ok;ratchet ok;loopx canary premerge --from-git-diff --git-diff-base origin/main --tier standard → passed,self_merge_allowed: true,0 failures / 0 manual holds。需要说明的是:仓库 main 当前在继承性检查上就是红的(Python Tests 在 f4ed58de9 失败、Full Public Smokes 在 75fcd5556 失败,kernel-static-checks 报的 1051 处类型错误分布在与本 PR 无关的文件里),因此本 PR 的远端 CI 红不是本次改动引入,但也不作为本轮的覆盖证据。
上一轮评审后的修复(exact head 对比)
上一轮评审的 head 是 0d8035fe8,之后只增加了一个提交 519041b0d:打包浏览器验收(examples/personal-workspace-browser-smoke.mjs)对 .personal-capability-actions 使用严格定位并断言它与 .personal-capability-raw-values 的文档顺序;我把凭据面板的 footer 复用了同一个 class,导致该定位解析到两个元素、build/dashboard-acceptance 失败。这是 CI 抓到的真实缺陷(本地那条 smoke 因环境原因在第一步就失败,掩盖了它)。
修复:面板改用 personal-operator-credential-actions,CSS 里与 .personal-capability-actions 共用同一条规则(外观不变),并在组件里写明原因;随后重建打包产物。重建过程中我先 git restore 了已提交的 loopx/web/chat,再只构建一次,并二次构建确认幂等(git status 不再变化),因此 CI 的「提交产物必须等于一次干净构建」检查成立。
修复后重跑:loopx canary premerge --from-git-diff --git-diff-base origin/main --tier standard → passed,self_merge_allowed: true,19 项 0 failures。整体判断与上一轮一致:无阻断性发现。
我的整体评价
正向且比例合适,建议合并。
它补的是产品面上真实缺失的一环:托管宿主的存在理由是不依赖个人 CLI 登录,而凭据却只能靠改 plist 维护。修法选在最小边界上——一个 0600 的独立文件,复用既有 environ 解析缝,不新开 execuor/model/authority 契约;把「密钥不进机器配置」做成结构性质而不是投影约定。
几处判断我认为是对的:只写 key 而不是「可读但脱敏」,避免了把密钥重新变成可外泄的状态;不可读记录 fail-closed 到「无凭据」而不是悄悄用环境里的另一把;子宿主只收两个变量,凭据变更无法顺手把整个服务环境带进段里。manager_capabilities_projection 这一步是把「模型参数 / 可用性判定 / 回读」的单一所有者说清楚,同时也让两个热模块分别降到天花板以内(1502 / 1506),属于有界且行为保持的重构,而不是为了堆功能。
需要留意的三点都已记为 P3:优先级在已配置机器上的可见行为变化、机器配置文档的密钥边界需要长期守卫、以及真实远端认证链路不在本轮证据内。
无阻断性发现。
审阅的精确 head:519041b0de04da8a09a2a3dc928d203f0a4c1b29
English verdict: APPROVE - head 519041b makes the operator credential a machine setting stored in its own 0600 file under a 0700 directory, resolved field by field ahead of the service environment, with a write-only key: every readback (Dashboard panel, GET/POST /api/chat/operator-credential, loopx machine-config credential status) returns the field source and a truncated sha256 fingerprint instead, and an unreadable record resolves to no credential rather than to the environment. It is deliberately not a machine-configuration namespace, because that document is projected, inspected and copied into transaction backups. The same commit extracts manager_capabilities_projection so the model arguments, availability verdict and readback share one owner, leaving both hot control-plane modules inside their line ceilings (1502/1506). No blocking finding; three P3 notes cover the visible precedence change on an already-configured machine, the structural guard that keeps the key out of the machine-configuration document, and the untested live dsh segment. Validation: 194 passed across 12 relevant suites, the entry-point smoke ok, packaged bundle parity verified, and the repository pre-merge gate passed with self_merge_allowed: true; GitHub CI on this head is not used as evidence because main itself is currently red on inherited repo-wide static checks.
…ylist The frontstage share bundle runs a public-boundary scan that rejects `api_key:`/`auth_token:`/`access_token:` assignments, because in a bundled asset that spelling almost always means a hard-coded credential. The new readback and write path used `api_key` as its field name, so the packaged bundle tripped the scan even though it carries no value. Name the field `provider_key` in the readback, the request body and the store, which describes it at least as precisely and keeps the denylist intact rather than widening it. The human-facing label stays "API key". Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com>
huangruiteng
left a comment
There was a problem hiding this comment.
Approval conclusion (author-owned PR; GitHub blocks formal self-approval)
审阅的精确 head:bfb8ef7788330d864d607330471fb06fafb854ed
动机
管家通道和托管 Turn 宿主都靠同一对「操作者凭据」(API key + 非默认 endpoint 时的 base URL)做认证,而这对凭据此前只能从服务进程环境读取。后果有三个:换 key 必须改 ~/Library/LaunchAgents/com.loopx.chat.plist 再重启服务;没有任何 LoopX 面能告诉你当前生效的是哪把 key;为了让 dsh 可启动,还得把 DEEPSEEK_API_KEY 一直 export 进 Chat 服务。
最后一条尤其讽刺:托管宿主存在的意义就是「不要依赖个人 CLI 登录」,但凭据本身却只能靠手改启动文件维护。这正是要补的那一块机器配置。
改动思路
核心取舍是存在哪里。
machine/configuration.json 不是候选:它会被投影给浏览器、被 describe/inspect 回读、并被复制进每一笔事务备份和回滚计划。把密钥放进去,等于从四个面可读、并且被每一次无关的设置变更复制一份。所以凭据单独成文件:<runtime-root>/machine/credentials/operator_provider.json,目录 0700、文件 0600、原子写。这是本 PR 唯一新增的持久化状态。
第二取舍是优先级。机器存储优先于服务环境,按字段分别解析。理由是与 steward_executor 保持一致——同为「人在产品面上编辑的机器级设置」,不该有两套相反的优先级规则;而且如果环境永远赢,前端字段在像本机这样已经 export 了 key 的机器上就是装饰品。环境保留为 bootstrap 和逃生口:没有存过记录时,行为与改动前逐字节一致。
第三取舍是回读。key 是只写的:所有回读(Dashboard 面板、新的 chat 路由、machine-config credential status)返回的是每个字段的来源和一个 sha256 前 12 位指纹,绝不返回值本身。指纹的存在是为了让操作者能回答「我刚存的 key 就是正在跑的那把吗」,而不是为了让人再读一遍 key。
具体改动
新增 loopx/control_plane/operator_provider.py:记录归一化(拒绝未知字段、非 http(s) 的 URL、含空白的 key,且报错不回显值)、_secure_write(0700/0600 + 原子写)、operator_provider_projection(唯一回读)、operator_provider_environ(给既有解析链的覆盖层)、operator_provider_host_credential(只把两个变量交给子宿主进程)。
新增 loopx/chat_operator_provider_api.py:GET/POST /api/chat/operator-credential,通过既有的 ChatConfigurationRequestMixin 注册。响应体就是那份 projection,因此浏览器契约和 CLI 契约是同一个版本化载荷,不会分叉成两种写法。
loopx/capabilities/machine_configuration/cli.py 增加 machine-config credential status|set|clear;值从 JSON 文件或 stdin(--config-json -)读入,不进 shell history 和 argv。markdown 渲染只打印 configured/source/fingerprint。
loopx/chat_manager.py 新增 controller_runtime_root、operator_credential_resolution、operator_credential_pair,以及 manager_capabilities_projection——把「机器默认 + 凭据 + 会话」这套组合收成一个所有者,模型参数、可用性判定和回读无法再互相矛盾。
loopx/chat_server.py 因此从内联 7 行变成 3 行调用;loopx/chat_runtime.py 托管分支改为用解析后的环境取 profile/model,并把凭据对交给适配器;loopx/chat_dsh.py 新增 credential 字段,在 _run_segment 里先铺凭据再覆盖 STEWARD_SEGMENT_ENV,所以调用方无法借此放宽 DSH_PERMISSION_MODE=read-only;loopx/chat_endpoint_catalog.py 与 loopx/cli_commands/turn.py 同样改为解析机器凭据。
前端:machine-configuration-settings.tsx 顶部新增 OperatorCredentialSettings 面板(只写 key 输入 + endpoint 输入 + 清除按钮 + 红字回读),chat.ts 增加 zod 契约与读写函数,i18n.tsx 中英各一组文案,personal-workspace.css 加样式;loopx/web/chat 打包产物重建(CI 会 npm run build:chat 并比对,本地已验证重建后 git status -- loopx/web/chat 为空)。
文档:新增 docs/reference/operator-model-credential.md(位置、模式、回读契约、优先级、失败行为、权限边界),并从 dsh 连接器文档指过去。
对主干的风险
优先级反转:已经 export 了 key 的机器,存了新 key 后会静默改用新 key。这是 PR 的目的,但必须可见才安全——projection 报 source=machine_store 和指纹,通道回读新增 operator_credential_source,想退回环境就 credential clear。已作为 P3 记在评审里。
密钥泄进机器配置:若将来有人把 key 挪进 namespace,浏览器投影、inspect、每笔事务备份都会带上它,而今天没有任何测试会失败。因此 smoke 与单测各有一条 machine_configuration_never_carries_the_key 断言作为结构性守卫;这条也记为 P3,属于「边界变更需单独评审」。
不可读记录的处理:记录损坏时不回退到环境,而是解析为「无凭据」并报 invalid + 修复步骤,托管面沿用既有的 operator_credential_unconfigured 拒答。用一把操作者在产品面上已经看不见的凭据去跑,比拒答更糟。
热模块膨胀:chat_runtime.py / chat_server.py 都有行数天花板(1502 / 1514),仓库自带的 maintainability ratchet 会拦。本 PR 不是加行而是抽出组合逻辑:最终 1502 / 1506,ratchet ok。这也是我不把环境解析塞进这两个文件的直接原因。
回读 schema:manager_channel_binding 新增 operator_credential_source(有默认值 not_read),是唯一的读模型变化,属追加而非改名。
未覆盖:真实 dsh 段对真实 provider endpoint 的端到端认证不在本轮证据内;本 head 也没有用 GitHub CI 作证据(下述 main 已知红灯)。
验证与继承红灯:pytest 12 个相关套件 194 passed;examples/operator-provider-credential-smoke.py ok;dashboard-pwa-bundle-smoke.py ok;ratchet ok;loopx canary premerge --from-git-diff --git-diff-base origin/main --tier standard → passed,self_merge_allowed: true,0 failures / 0 manual holds。需要说明的是:仓库 main 当前在继承性检查上就是红的(Python Tests 在 f4ed58de9 失败、Full Public Smokes 在 75fcd5556 失败,kernel-static-checks 报的 1051 处类型错误分布在与本 PR 无关的文件里),因此本 PR 的远端 CI 红不是本次改动引入,但也不作为本轮的覆盖证据。
上一轮评审后的修复(exact head 对比)
上一轮评审的 head 是 0d8035fe8,之后只增加了一个提交 519041b0d:打包浏览器验收(examples/personal-workspace-browser-smoke.mjs)对 .personal-capability-actions 使用严格定位并断言它与 .personal-capability-raw-values 的文档顺序;我把凭据面板的 footer 复用了同一个 class,导致该定位解析到两个元素、build/dashboard-acceptance 失败。这是 CI 抓到的真实缺陷(本地那条 smoke 因环境原因在第一步就失败,掩盖了它)。
修复:面板改用 personal-operator-credential-actions,CSS 里与 .personal-capability-actions 共用同一条规则(外观不变),并在组件里写明原因;随后重建打包产物。重建过程中我先 git restore 了已提交的 loopx/web/chat,再只构建一次,并二次构建确认幂等(git status 不再变化),因此 CI 的「提交产物必须等于一次干净构建」检查成立。
修复后重跑:loopx canary premerge --from-git-diff --git-diff-base origin/main --tier standard → passed,self_merge_allowed: true,19 项 0 failures。整体判断与上一轮一致:无阻断性发现。
上一轮评审后的修复(0d8035fe8 → 519041b → bfb8ef7)
519041b0d:打包浏览器验收对.personal-capability-actions用严格定位,我的凭据面板复用了这个 class,导致build/dashboard-acceptance解析到两个元素而失败(CI 抓到的真实缺陷;本地那条 smoke 因环境原因在第一步即失败,掩盖了它)。修复:面板改用personal-operator-credential-actions,CSS 与.personal-capability-actions共用同一规则,外观不变。bfb8ef778:build的 public boundary 扫描(export-frontstage-share-bundle.mjs)有一条api_key|auth_token|access_token\s*[:=]的 token 赋值拒绝规则。我的读模型与请求体字段名为api_key,打进 bundle 后正好命中该模式——虽然载荷不含任何密钥值。修复选择改名而不是放宽规则:字段改为provider_key(对人展示的标签仍是 "API key"),denylist 保持原样,扫描现在通过(已本地验证当前入口产物index-BreEWoWw.js与该 CSS 均不再命中)。重建产物前先git restore已提交的loopx/web/chat,重建两次确认幂等,因此 CI 的「提交产物 == 一次干净构建」成立。
修复后重跑:loopx canary premerge --from-git-diff --git-diff-base origin/main --tier standard → passed,self_merge_allowed: true,19 项 0 failures;pytest 相关套件 26 passed;凭据 smoke ok。整体判断与前两轮一致:无阻断性发现。
我的整体评价
正向且比例合适,建议合并。
它补的是产品面上真实缺失的一环:托管宿主的存在理由是不依赖个人 CLI 登录,而凭据却只能靠改 plist 维护。修法选在最小边界上——一个 0600 的独立文件,复用既有 environ 解析缝,不新开 execuor/model/authority 契约;把「密钥不进机器配置」做成结构性质而不是投影约定。
几处判断我认为是对的:只写 key 而不是「可读但脱敏」,避免了把密钥重新变成可外泄的状态;不可读记录 fail-closed 到「无凭据」而不是悄悄用环境里的另一把;子宿主只收两个变量,凭据变更无法顺手把整个服务环境带进段里。manager_capabilities_projection 这一步是把「模型参数 / 可用性判定 / 回读」的单一所有者说清楚,同时也让两个热模块分别降到天花板以内(1502 / 1506),属于有界且行为保持的重构,而不是为了堆功能。
需要留意的三点都已记为 P3:优先级在已配置机器上的可见行为变化、机器配置文档的密钥边界需要长期守卫、以及真实远端认证链路不在本轮证据内。
无阻断性发现。
审阅的精确 head:bfb8ef7788330d864d607330471fb06fafb854ed
English verdict: APPROVE - head bfb8ef7 makes the operator credential a machine setting stored in its own 0600 file under a 0700 directory, resolved field by field ahead of the service environment, with a write-only key: every readback (Dashboard panel, GET/POST /api/chat/operator-credential, loopx machine-config credential status) returns the field source and a truncated sha256 fingerprint instead, and an unreadable record resolves to no credential rather than to the environment. It is deliberately not a machine-configuration namespace, because that document is projected, inspected and copied into transaction backups. The same commit extracts manager_capabilities_projection so the model arguments, availability verdict and readback share one owner, leaving both hot control-plane modules inside their line ceilings (1502/1506). No blocking finding; three P3 notes cover the visible precedence change on an already-configured machine, the structural guard that keeps the key out of the machine-configuration document, and the untested live dsh segment. Validation: 194 passed across 12 relevant suites, the entry-point smoke ok, packaged bundle parity verified, and the repository pre-merge gate passed with self_merge_allowed: true; GitHub CI on this head is not used as evidence because main itself is currently red on inherited repo-wide static checks.
Motivation
The steward channel and the managed Turn host both authenticate with an
operator-supplied provider credential (API key plus, when the endpoint is not
the provider default, a base URL). Both read that pair from the service
environment only, so:
DEEPSEEK_API_KEYhad to be exported into the Chat service just to keep themanaged executor launchable.
This adds the missing machine setting, and the frontend that edits it.
Design
The credential is its own file,
machine/credentials/operator_provider.jsonunder the machine runtime root, mode
0600inside a0700directory, writtenatomically. It is deliberately not a machine-configuration namespace: that
document is projected to the browser, read back by
describe/inspect, andcopied into per-transaction backups and rollback plans, so a secret there would
be readable from four surfaces and copied by every unrelated settings change.
Resolution is field by field, and the machine store outranks the process
environment -- the same order as the
steward_executormachine setting, so thetwo machine-level settings a person edits do not follow two different
precedence rules. A store holding only a base URL does not hide an
environment-provided key.
Resolution overrides the environment while the operator definition of
codex-cli-vs-dshremains the existing one: a credential authenticates theconfiguration that runs and never selects one.
Changed surfaces
loopx/control_plane/operator_provider.py(new) -- the store, the redactedprojection, the resolution overlay, and the child-host pair.
loopx/chat_operator_provider_api.py(new) --GET/POST /api/chat/operator-credential, registered through the existing configurationroute registry.
loopx/capabilities/machine_configuration/cli.py--machine-config credential status|set|clear; the value is read from a JSON file or stdin soit never lands in shell history or argv.
loopx/chat_manager.py-- resolution of the credential pair and onemanager_capabilities_projectioncomposer, so the model arguments, theavailability verdict and the channel readback cannot disagree.
loopx/chat_runtime.py,loopx/chat_server.py,loopx/chat_dsh.py,loopx/chat_endpoint_catalog.py,loopx/cli_commands/turn.py-- resolve andquote the machine credential instead of the raw process environment; the dsh
segment receives exactly the credential pair and keeps its own sandbox mode.
same localized strings in English and Chinese. Packaged chat bundle rebuilt.
docs/reference/operator-model-credential.md(new) plus a pointer from thedsh connector doc.
Safety properties
route and the CLI all return the field's source and a truncated
sha256fingerprint, which is what lets an operator tell one key from another.
environment, and reports
invalidwith the repair step. Authenticating asurface with a credential the operator can no longer see is worse than
refusing.
Clearing is explicit, and clearing the last field removes the file.
operator_provider_host_credentialpasses only the two credential variablesto a managed child host, so a credential change cannot smuggle the rest of the
service environment into a segment.
Validation
pytest -q tests/test_operator_provider.py ... tests/test_loopx_turn_executor.py-> 194 passed
python3 examples/operator-provider-credential-smoke.py-> ok (unconfiguredmachine refuses the managed host; browser write reports the machine store and
never echoes the key; the stored key selects the managed host; the file is
owner-only; CLI status/clear; the machine-configuration document never carries
the key)
python3 examples/control_plane/control-plane-maintainability-ratchet-smoke.py-> ok
python3 examples/dashboard-pwa-bundle-smoke.py-> okloopx canary premerge --from-git-diff --git-diff-base origin/main --tier standard-> passed, 0 failures across 10 catalog canaries, 8 risk-profilesmokes and the public boundary scan