From 6239c4d69842e0371a5e323277c3f0296bf93145 Mon Sep 17 00:00:00 2001 From: whyour Date: Sat, 29 Aug 2026 13:18:43 +0800 Subject: [PATCH] feat(ql3): add secure console task editing --- docs/QINGLONG_3_0_ARCHITECTURE_RFC.md | 2 + ...rong-local-console-task-authoring-lease.md | 71 ++ docs/adr/README.md | 1 + docs/operations/ql3-alpha-candidate.md | 3 + docs/operations/ql3-local-web-console.md | 18 +- .../ql3-local-api/assets/console/console.css | 6 + .../ql3-local-api/assets/console/console.js | 212 +++++- .../ql3-local-api/assets/console/index.html | 14 +- .../src/admission/localApiAdmission.ts | 30 + .../localApiProductSurface.ts | 16 + .../src/task/taskAuthoringRoute.ts | 699 ++++++++++++++++++ .../ql3-local-api/src/task/taskPutRoute.ts | 128 ++++ .../src/transport/httpSurface.ts | 34 +- .../ql3-local-api/test/admission.test.cjs | 32 + .../ql3-local-api/test/consoleAssets.test.cjs | 10 +- .../ql3-local-api/test/httpSurface.test.cjs | 54 +- .../test/sqliteHttp.integration.test.cjs | 128 +++- .../test/taskAuthoringRoute.test.cjs | 355 +++++++++ .../ql3-local-api/test/taskPutRoute.test.cjs | 113 ++- 19 files changed, 1870 insertions(+), 56 deletions(-) create mode 100644 docs/adr/ADR-0517-strong-local-console-task-authoring-lease.md create mode 100644 packages/ql3-local-api/src/task/taskAuthoringRoute.ts create mode 100644 packages/ql3-local-api/test/taskAuthoringRoute.test.cjs diff --git a/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md b/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md index d8087714..9de5c24d 100644 --- a/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md +++ b/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md @@ -11,6 +11,8 @@ 最新增量证据(2026-08-29): +- D-422/ADR-0517(已实现源码候选,阶段实物待 CI/milestone):Local Console 不再用隐藏 spec 的 bounded Task read 拼装 update。新增 `POST /api/v3/projects/:projectId/tasks/:taskId/authoring`:User Bearer 先通过 `task.update`,再由 owner-private 两分钟一次性 proof 提升为 `local_console`,同时重跑 `task.read/task.update` 并要求 exact Policy fence 一致;完整定义读取后再次确认 credential,记录独立 durable `task.authoring.read` audit,并签发只保存摘要的 10 分钟一次性 lease。lease 绑定 Project/Task/revision/content digest/credential ID+version/User subject;Edge/Standalone 上限 8/32、请求时惰性清理,无数据库表、migration、timer、watcher、daemon、Pool 或新 package。Task update 必须先以 lease 验证旧快照,再用第二份 exact-content proof 确认保存,并继续由 D-421 的事务 authority 执行 expectedRevision、Policy、credential、RoleBinding 与 audit fence;create 拒绝夹带 lease。Console 只开放可严格识别的内建 argv command 编辑,Task ID 只读,未展示的 environment、Secret bundle、working directory、未来 config 字段与 labels 原样保留。真实 SQLite/loopback 已通过 authoring challenge→完整定义/lease→save challenge→revision 2→bounded read 隐藏 spec→新 fence 启动;真实 Chromium 完成双 proof 更新并验证隐藏 config/labels 不丢失,390×844 无横向溢出。Local API `64/64`,18-package clean build/test `3,038 total / 3,016 pass / 22 conditional skip / 0 fail`;三资产 69,723 bytes,默认 Edge 保持 2,737,205 bytes/329 files/58 modules,opt-in Edge/Standalone Console 为 4,077,890/4,078,034 bytes、473 files/12 packages/95 modules,RSS delta 16,269,312/16,318,464 bytes;package/source、122-module Edge import 与 Cluster dependency audit 均 compatible。默认 headless 与 Cluster 路径不变。远端主 CI 与新同源双架构 Console milestone 未闭合前,本项不得冒充现有 D-421 下载产物。 + - D-421/ADR-0516(已实现并交付同源双架构 Console Alpha Candidate):Local Web Task mutation 不再受限于短生命周期 CLI 的进程级 active credential。`PUT /api/v3/projects/:projectId/tasks/:taskId` 把 Bearer 限定为 session credential,第一次 exact request 只在 deployment root 内 `0700` 目录发布 `0600`、当前 UID、两分钟、一次性的私有 proof file;HTTP 只返回 authorization ID、canonical request digest、expiry 与 basename,不返回 proof 或宿主绝对路径。proof 绑定 Task 全内容、credential ID/version 与 User subject;Edge/Standalone pending 上限为 8/32,按请求惰性清理,不新增 timer/watcher/daemon。验证后 principal 提升为短期 `local_console`,既有 Task administration service 重跑 Policy;SQLite runtime 为每个请求建立独立 credential-fenced repository,并在 Task append 事务内复验 credential/Identity/pepper、actor、Project/RoleBinding fence,原子提交 allowed audit 与 mutation。并发测试证明两个 User repository 不共享 ambient authority,撤销 A 不影响 B,RoleBinding 漂移仍原子拒绝;真实 loopback HTTP→私有 proof file→SQLite create 与 Chromium 编辑器/proof ticket 已通过。Console 当前完成 command Task 创建;HTTP 与 CLI 支持完整 create/update,Web update 等待强认证 authoring read/lease,避免用不含 spec 的 bounded read 覆盖未知字段。三资产合计 62,632 bytes;Local API 56/56、18-package clean build/test 3,030 total/3,008 pass/22 条件跳过/0 fail,package/source、Local image、122-module Edge import 与 Cluster dependency audit 均 compatible。首个阶段提交 `884912d1` 暴露默认 Edge 静态加载管理 authority 的真实回归:x64 21,229,568-byte RSS 超过 20 MiB 门,failed-only 重跑再次失败;修复没有扩大预算,而是改为 proof 验证后的 request-time dynamic import。默认 Edge 恢复 2,737,205 bytes/329 files/58 modules,opt-in Edge/Standalone Console 为 4,041,294/4,041,438 bytes、472 files/12 packages/94 modules,均保有门禁余量;默认 headless 与 Cluster 路径不变。修复提交 `dc1686bd6fb3505174dd9a14098ae5c2c92a1a7f` 的主 CI run `33229592307` 为 41 success/3 expected skip/0 fail,Kubernetes run `33229592293` 成功;显式 Console milestone run `33230227006` 为 42 success/2 scope skip/0 fail,生成 187,797,970-byte amd64、185,029,586-byte arm64 Trial Kit 与 5,623-byte milestone,保留至 2026-09-28。下载索引通过 checksum 与离线 auditor,返回 `compatible=true`。因此 D-421 已是可下载、可验真、可 fresh 试运行的 `3.0.0-alpha.2` 阶段实物,但仍不是公开 release、生产升级或长期支持版本。 - D-420/ADR-0515(已实现,首份真实双架构 Console v5 Trial Kit 已交付):阶段可用的首个自动化从“原生 CI 能通过 API 看到 bounded log marker”推进为“部署者能在 Console 直接观察实际输出”。Local Run HTTP 详情只追加严格验证的 latest Attempt 低敏摘要(ID、序号、状态、时间和 `logAvailable`),不返回 executor handle、Artifact ID、路径、Worker、PID 或错误明细;共享 HIGH 风险 `executeBoundedRunReadProjection` 保持不变,避免 Local UI 字段漂移到内建 Run read/compare Tool。Console 使用既有 `artifact.read` Policy/Audit/credential re-confirm 链,每次固定读取首个 32 KiB base64 窗口,分别显示 available/pending/retired/not-found/unavailable、range 与 truncation,不新增轮询、WebSocket、timer、缓存或整文件下载。三资产增至 48,318 bytes;`edge-application-api|standalone-application-api` 为 3,960,535 / 3,960,679 bytes、467 files、12 packages、90 loaded modules,仍低于 6 MiB/640-file 门,默认 headless Edge 保持 2,669,390 bytes/325 files/58 modules,Cluster 零变化。Local API 49/49、完整 backend `1,650 total / 1,648 pass / 2 Linux conditional skip / 0 fail`、18-package clean build/test 退出 0;package/source、Local image、122-module Edge import 和 Cluster dependency audit 均 compatible。提交 `57953ec8` 的远端 QingLong 3.0 CI 为 41 success / 3 expected artifact-finalizer skip / 0 fail,独立 Kubernetes deployment live contract 为 1/1 success。现有 `task.put` 的进程级 active credential fence 不适合常驻并发 HTTP,Web Task 创建/修订必须以后续“每请求 credential fence + 同事务 Policy/Audit/mutation”切片完成,D-420 不用单因子 Bearer 绕过强认证。 diff --git a/docs/adr/ADR-0517-strong-local-console-task-authoring-lease.md b/docs/adr/ADR-0517-strong-local-console-task-authoring-lease.md new file mode 100644 index 00000000..de90c8bd --- /dev/null +++ b/docs/adr/ADR-0517-strong-local-console-task-authoring-lease.md @@ -0,0 +1,71 @@ +# ADR-0517:强认证 Local Console Task authoring lease + +- 状态:Accepted +- 日期:2026-08-29 +- 对应 RFC 切片:D-422 +- 关联:ADR-0256、ADR-0377、ADR-0512、ADR-0516 + +## 背景 + +ADR-0516 已让 Local Console 以 request-scoped credential fence 和本机一次性 proof 创建 Task,但 bounded Task read 有意隐藏完整 spec。直接用该投影编辑会静默删除环境变量、Secret bundle、工作目录或未来 schema 字段;把完整定义放进普通 Bearer read 又会扩大单因子凭据的读取能力。HTTP update 虽已存在,也必须防止调用方拿旧快照或其他 credential 读取的内容发起修改。 + +本切片要补齐可实际试用的 Web update,同时保持低配路由设备无后台成本、默认 headless 零增量、Cluster authority 独立。 + +## 决策 + +### 1. 完整定义只通过强认证 authoring read 交付 + +Local API 新增固定路由: + +```text +POST /api/v3/projects/:projectId/tasks/:taskId/authoring +``` + +请求无 body。Bearer 必须解析为 User credential,并先通过 `task.update` Policy。第一次请求只发布 ADR-0516 的 owner-private POSIX presence challenge;验证 proof 前不读取或返回完整定义。proof 验证后形成短生命周期 `local_console` principal,服务同时重新授权 `task.read` 与 `task.update`,并要求两项决定的 Project/RoleBinding fence 完全一致。 + +读取后再次确认 exact credential authority,再记录独立的 durable `task.authoring.read` allowed audit。不存在与跨 Project 继续按既有边界收敛;任何 Policy、credential、SQLite 或 audit 不可用均失败关闭。 + +### 2. 返回一次性、内容绑定的编辑租约 + +成功响应返回完整 `TaskDefinitionRecord`,以及 10 分钟内有效的不透明 authoring lease。服务只保存 lease presentation 的 SHA-256,不保存明文;绑定包含: + +- Project ID、Task ID、revision 与 content digest; +- credential ID/version; +- User subject。 + +lease 只允许消费一次。Edge 最多 8 个,Standalone 最多 32 个;过期项在请求进入时惰性清理,关闭 Local API 时清零 digest。实现不增加数据库表、migration、连接池、timer、watcher、daemon 或网络 listener。 + +### 3. update 需要 authoring lease 与第二份内容 proof + +`PUT /api/v3/projects/:projectId/tasks/:taskId` 的 create 继续不需要 authoring lease,并拒绝调用方夹带 lease。update 必须同时提交 exact lease。服务在发布保存 proof 前检查当前 revision/content/credential 仍与 lease 相同;调用方完成第二次本机 presence 后,再次读取当前定义并原子消费 lease,随后进入 ADR-0516 已有的 credential-fenced Task administration transaction。 + +authoring proof 证明“允许读取完整旧定义”,保存 proof 证明“操作者确认这份新内容”,两者不得复用。租约检查不能替代事务内 expectedRevision、Policy、credential、RoleBinding 与 audit fence;检查和写入之间的竞争仍由已有 CAS/事务 authority 拒绝。 + +### 4. Console 只修改它明确展示的字段 + +Console 对内建 `command + qinglong/command@v1 + argv` 提供“编辑任务”。Task ID 只读;名称、说明、file、逐行 args 与 enabled 可修改。保存时以强认证快照为基底,只替换这些可见字段,labels 与 config 中未展示的 environment、Secret bundle、working directory 及未来兼容字段原样保留。无法严格识别的 kind/schema 继续使用受信 CLI,不提供有损编辑器。 + +页面仍只把 credential、proof、完整快照与 lease 保存在当前内存,不使用 Cookie、Web Storage、URL、日志或遥测。刷新、断开、关闭编辑器或取消 presence 会丢弃内存状态。 + +### 5. 部署档位保持分层 + +- 默认 Edge/Standalone headless 不加载 Local API、Console 或 lease manager; +- opt-in Edge Console 的 8 项上限避免低内存设备被长会话拖住;Standalone 为 32; +- Cluster 不接受 Local POSIX proof 或内存 lease。多节点 Task authoring 必须由 Cluster Control 的 TLS、RBAC、共享持久 authority 与独立管理面设计完成。 + +## 不采用的方案 + +- 不扩大 bounded Task read:普通列表/详情不应泄漏完整执行配置。 +- 不让 Bearer 单独取得完整定义:这会把 strong User 降级为浏览器持有单因子 secret。 +- 不只用 lease 直接写:读取授权不等于确认新内容,保存仍需要第二份 exact proof。 +- 不让 Console 重新构造整个 command config:未展示字段会被静默删除。 +- 不建立持久 Web session、轮询或 lease 清理 timer:当前显式操作与惰性清理足够,低配设备不承担常驻成本。 +- 不抽象成 Local/Cluster 共用插件:单机 POSIX owner 与多节点身份、审批、HA 不是同一种 authority。 + +## 结果与验证边界 + +定向验证覆盖完整定义只在 proof 后返回、两次 credential confirm、read/update Policy fence 一致、durable audit、lease 的 credential/revision/content 绑定、一次性消费、过期与 Edge 容量;PUT 覆盖缺失/过期/漂移 lease 在生成保存 proof 前拒绝,以及 proof 后再检查并消费。真实 SQLite/loopback 旅程覆盖 authoring challenge → 完整 spec/labels → lease → 保存 challenge → revision 2 → bounded read 不泄漏 spec → 新 revision/content fence 启动。 + +本地 Local API 为 `64/64`;18-package clean build/test 为 `3,038 total / 3,016 pass / 22 conditional、platform 或 external-service skip / 0 fail`。package boundary 保持 18 packages 且无 single-source/shallow package,Cluster dependency 与 122-module Edge import audit 均 compatible。默认 Edge 为 2,737,205 bytes/329 files/58 modules;opt-in Edge/Standalone Console 为 4,077,890/4,078,034 bytes、473 files、12 packages、95 modules,RSS delta 16,269,312/16,318,464 bytes,均低于既有门。三资产合计 69,723 bytes。真实 Chromium 已完成双 proof 更新到 revision 2,并以 409 模拟断言验证未展示 config/labels 与 lease header 不得丢失;390×844 无横向溢出。 + +本 ADR 被合入、远端主 CI 及对应 Profile 门通过前只算源码候选;同源 amd64/arm64 Console Trial Kit 与 milestone index 重新生成并离线验真后,才升级为新的可下载阶段产物。既有 D-421 Console v5 milestone 仍是当前可下载实物,不能改名冒充 D-422。 diff --git a/docs/adr/README.md b/docs/adr/README.md index 3c6aaa68..f91fba58 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -520,6 +520,7 @@ | [ADR-0514](./ADR-0514-stage-usable-first-automation-journey.md) | 阶段可用的首个自动化旅程 | Accepted(Console v5 双架构实物已由 D-420 闭合) | | [ADR-0515](./ADR-0515-bounded-local-console-run-log.md) | Local Console 的有界 Run 日志观察面 | Accepted(首份实际 Console v5 双架构 milestone 已交付) | | [ADR-0516](./ADR-0516-request-scoped-local-console-task-mutation.md) | request-scoped Local Console Task mutation | Accepted(新双架构 Trial Kit 待本阶段 milestone) | +| [ADR-0517](./ADR-0517-strong-local-console-task-authoring-lease.md) | 强认证 Local Console Task authoring lease | Accepted(源码候选,待 CI 与新双架构 milestone) | ## 规则 diff --git a/docs/operations/ql3-alpha-candidate.md b/docs/operations/ql3-alpha-candidate.md index f456a172..af6e9e09 100644 --- a/docs/operations/ql3-alpha-candidate.md +++ b/docs/operations/ql3-alpha-candidate.md @@ -26,9 +26,12 @@ | D-419 Console 首自动化闭环 | quickstart 创建无网络/SecretRef/Trigger 的示例 Task;原生 CI 使用真实 Owner credential 完成 read、fenced start、`succeeded` 与 bounded log marker | 仍不提供 Web Task 编辑、2.x 升级或生产远程管理 | | D-420 Console Run 日志观察面 | 选择 Run 后经既有认证/Policy/Audit 链读取 latest Attempt 首个 32 KiB,展示 range、truncation、pending/retired 等明确状态 | 不自动轮询、不提供整文件下载;Web Task 创建/修订仍待独立强认证事务切片 | | D-421 Console Task 创建切片 | request-scoped credential fence、两分钟一次性本机 proof、同事务 Policy/Audit/Task mutation 已完成;Console 可创建 command Task;同源双架构 Console v5 Trial Kit 与 milestone 已生成并验真 | Web update 等待 authoring read/lease;Cluster 不复用 Local proof;仍不是生产或公开发布 | +| D-422 Console Task 安全编辑切片 | 源码候选已实现强认证完整定义读取、10 分钟一次性 authoring lease、第二份 exact save proof 与 revision/content/credential fence;Console 可无损编辑内建 argv command Task;本地完整 package/Profile/浏览器门已通过 | 待远端主 CI 和新同源双架构 Console milestone;现有 D-421 archive 不包含本能力 | D-421 已关闭 D-420 记录的“Web Task mutation 必须独立设计”缺口,而且没有改名复用 run `33173769047` 的旧 archive。修复提交 `dc1686bd6fb3505174dd9a14098ae5c2c92a1a7f` 的普通主 CI [run 33229592307](https://github.com/whyour/qinglong/actions/runs/33229592307) 为 41 success/3 expected artifact-finalizer skip/0 fail,同源 Kubernetes deployment [run 33229592293](https://github.com/whyour/qinglong/actions/runs/33229592293) 成功;随后显式 Local Console milestone [run 33230227006](https://github.com/whyour/qinglong/actions/runs/33230227006) 为 42 success/2 scope skip/0 fail。由此 Web 创建能力已进入新的阶段实物,而不再只是候选源码。 +D-422 当前仍按“源码候选”登记:本地真实 SQLite/loopback 已证明读取、租约、第二次 proof、更新与新围栏启动闭环;18-package clean build/test 为 `3,038 total / 3,016 pass / 22 conditional skip / 0 fail`,Local API `64/64`,默认 Edge 与 opt-in Edge/Standalone Console 资源门及真实 Chromium 双证明编辑均通过。但在远端主 CI 和新的显式双架构 milestone 闭合前,不声称现有下载物已经包含 Web 编辑。阶段产物必须绑定新的 exact commit/run/artifact digest,禁止沿用 D-421 的 archive 名称或证据。 + D-418 防止把“20 天代码和测试”冒充“用户已经能下载并完整操作”:源码与普通 CI 已具备生成、审计和实跑两种 Trial Kit 的能力,但只有显式 artifact run 生成且被同 run 的双架构 milestone 收录后,才是可下载阶段产物。操作说明见 [Local Alpha Trial Kit](./ql3-local-alpha-trial-kit.md) 与 [Local Web Console](./ql3-local-web-console.md)。 当前最新可交付 Local Console v5 绑定提交 `dc1686bd6fb3505174dd9a14098ae5c2c92a1a7f` 与 [GitHub Actions run 33230227006](https://github.com/whyour/qinglong/actions/runs/33230227006),保留至 2026-09-28: diff --git a/docs/operations/ql3-local-web-console.md b/docs/operations/ql3-local-web-console.md index d8ab04ed..29190bbf 100644 --- a/docs/operations/ql3-local-web-console.md +++ b/docs/operations/ql3-local-web-console.md @@ -55,24 +55,24 @@ ssh -L 5701:127.0.0.1:5701 router.example 1. 输入 Project ID 和 `ql3c_…` API credential,选择“连接本机”。 2. 选择“创建任务”,填写 Task ID、名称、argv 可执行文件和逐行参数,再选择“保存并生成本机证明”。 -3. 在部署设备上以 QingLong 数据目录 owner 读取 `/console-presence/<页面显示的 basename>`;把 JSON 的 `proof` 值粘贴回页面。文件为 `0600`、两分钟有效且只能用于这份 exact Task 一次。不要通过聊天、日志或 URL 转发 proof。 -4. 创建成功后核对 revision/content fence,再选择“运行一次”。fresh Console Trial Kit 也可直接使用 `alpha-first-automation`。 -5. 在“运行”中选择 durable Run,按 Event sequence 判断实际进度;Bounded log 只显示 latest Attempt 的首个 32 KiB,后续内容仍需通过 API 分页读取。 -6. 日志 pending 时使用“刷新”显式重读;retired 表示内容已按保留策略清理,不代表 Run/Event 事实丢失。 -7. “请求取消”只提交 durable cancellation intent;界面出现 `cancelled|failed|succeeded|timed_out` 终态前,不要认为进程已经停止。 -8. 完成后选择“断开并清除凭据”,再关闭页面。 +3. 在部署设备上以 QingLong 数据目录 owner 读取 `/console-presence/<页面显示的 basename>`;把 JSON 的完整 `ql3p_…` proof 值粘贴回页面。文件为 `0600`、两分钟有效且只能用于这份 exact 操作一次。不要通过聊天、日志或 URL 转发 proof。 +4. 编辑现有内建 command Task 时先选择“编辑任务”,完成第一次本机证明以读取完整定义并取得 10 分钟一次性编辑租约。保存新内容时页面会要求第二份 proof;第一份只授权读取,不能复用来保存。Task ID 只读,未展示的 command config 与 labels 会原样保留。 +5. 创建或更新成功后核对 revision/content fence,再选择“运行一次”。fresh Console Trial Kit 也可直接使用 `alpha-first-automation`。 +6. 在“运行”中选择 durable Run,按 Event sequence 判断实际进度;Bounded log 只显示 latest Attempt 的首个 32 KiB,后续内容仍需通过 API 分页读取。 +7. 日志 pending 时使用“刷新”显式重读;retired 表示内容已按保留策略清理,不代表 Run/Event 事实丢失。 +8. “请求取消”只提交 durable cancellation intent;界面出现 `cancelled|failed|succeeded|timed_out` 终态前,不要认为进程已经停止。 +9. 完成后选择“断开并清除凭据”,再关闭页面。 Credential 只存在当前页面内存,不进入 URL、Cookie 或 Web Storage。页面刷新会丢失 credential,需要重新输入;这是当前安全边界,不是缺陷。 ## 当前阶段可用边界 -当前可操作闭环是 command Task create/list/read/start 与 Run list/read/events/steps/log/cancel。HTTP `PUT` 也支持提供完整 exact definition 的 update;页面暂不负责: +当前可操作闭环是内建 argv command Task create/list/read/update/enable/disable/start 与 Run list/read/events/steps/log/cancel。编辑器只修改当前展示字段,并保留完整快照中的其他 config/labels;其他 kind/schema 继续使用受信管理入口。页面暂不负责: -- 编辑/启停现有 Task(bounded read 不返回完整 spec,不能据此安全覆盖;继续使用 `ql3-task`,后续由 authoring lease/read 切片补齐); - Identity、Policy、Secret、Plugin Package 或 AI 配置管理; - 日志整文件下载、终端、文件管理或 2.x 数据迁移; - LAN/public 暴露、TLS termination、多用户 Web session 或 Cluster 管理。 -三项静态资产总计 62,632 bytes,不依赖 CDN、网络字体或前端框架,仍低于 192 KiB 总闭包和单文件 96 KiB 门。`edge-application-api|standalone-application-api` 为 4,041,294 / 4,041,438 bytes、472 files、12 packages、94 loaded modules,仍低于 6 MiB/640-file 门;基础 headless Edge 保持 2,737,205 bytes、329 files、3 packages、58 modules,不携带这些资产。 +三项静态资产总计 69,723 bytes,不依赖 CDN、网络字体或前端框架,仍低于 192 KiB 总闭包和单文件 96 KiB 门。`edge-application-api|standalone-application-api` 为 4,077,890 / 4,078,034 bytes、473 files、12 packages、95 loaded modules,仍低于 6 MiB/640-file 门;本机 RSS delta 为 16,269,312 / 16,318,464 bytes,低于 28 MiB。基础 headless Edge 保持 2,737,205 bytes、329 files、3 packages、58 modules,不携带这些资产。 停止 Local API 进程走与 Application 相同的 drain/shutdown 路径。Console 没有独立数据库、后台任务或需要额外清理的持久状态。 diff --git a/packages/ql3-local-api/assets/console/console.css b/packages/ql3-local-api/assets/console/console.css index 34335ea9..6c49fc58 100644 --- a/packages/ql3-local-api/assets/console/console.css +++ b/packages/ql3-local-api/assets/console/console.css @@ -798,6 +798,12 @@ textarea:focus-visible, resize: vertical; } +.editor-grid input[readonly] { + color: var(--muted); + border-left: 4px solid var(--signal); + background: var(--fog); +} + .editor-wide { grid-column: 1 / -1; } diff --git a/packages/ql3-local-api/assets/console/console.js b/packages/ql3-local-api/assets/console/console.js index 07ff02c9..2234b3c3 100644 --- a/packages/ql3-local-api/assets/console/console.js +++ b/packages/ql3-local-api/assets/console/console.js @@ -5,7 +5,10 @@ const TASK_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/; const TOKEN_PATTERN = /^ql3c_[A-Za-z0-9][A-Za-z0-9._:-]{0,63}_[A-Za-z0-9_-]{43}$/; - const PRESENCE_PATTERN = /^[A-Za-z0-9_-]{43}$/; + const PRESENCE_PATTERN = + /^ql3p_[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}_[A-Za-z0-9_-]{43}$/; + const AUTHORING_LEASE_PATTERN = + /^ql3a_[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}_[A-Za-z0-9_-]{43}$/; const LOG_READ_BYTES = 32 * 1024; const TERMINAL = new Set(['succeeded', 'failed', 'cancelled', 'timed_out']); const STATUS_LABELS = Object.freeze({ @@ -39,7 +42,13 @@ strong_authentication_required: '当前凭据不能执行管理操作;请使用本机 User API Credential。', task_definition_fence_rejected: - 'Task 或授权在确认期间发生变化。请刷新后重新创建。', + 'Task 或授权在确认期间发生变化。请刷新后重新编辑。', + task_authoring_lease_required: + '更新 Task 前必须重新读取完整定义。请关闭编辑器后选择“编辑任务”。', + task_authoring_lease_rejected: + 'Task、凭据或编辑租约已经变化。请关闭编辑器后重新读取。', + task_authoring_unavailable: + '暂时无法建立安全编辑会话。请稍后重新读取 Task。', invalid_task_definition: 'Task 定义无效。请检查 ID、命令与参数。', task_definition_unavailable: 'Task 暂时无法保存。请检查数据库状态。', run_cancellation_fence_rejected: @@ -67,6 +76,9 @@ dialogCopy: document.getElementById('confirmation-copy'), dialogAccept: document.getElementById('confirmation-accept'), taskEditor: document.getElementById('task-editor-dialog'), + taskEditorTitle: document.getElementById('task-editor-title'), + taskEditorIntro: document.getElementById('task-editor-intro'), + taskEditorNote: document.getElementById('task-editor-note'), taskEditorForm: document.getElementById('task-editor-form'), taskEditorClose: document.getElementById('task-editor-close'), taskEditorSave: document.getElementById('task-editor-save'), @@ -76,8 +88,10 @@ taskCommand: document.getElementById('task-command-input'), taskArgs: document.getElementById('task-args-input'), taskEnabled: document.getElementById('task-enabled-input'), + taskEnabledLabel: document.getElementById('task-enabled-label'), presenceDialog: document.getElementById('presence-dialog'), presenceForm: document.getElementById('presence-form'), + presenceCopy: document.getElementById('presence-copy'), presenceFile: document.getElementById('presence-file'), presenceProof: document.getElementById('presence-proof-input'), presenceExpiry: document.getElementById('presence-expiry'), @@ -93,7 +107,8 @@ view: 'tasks', selectedId: null, pendingAction: null, - pendingTaskMutation: null, + pendingPresence: null, + authoringSnapshot: null, toastTimer: null, }; @@ -204,6 +219,9 @@ if (options.presence !== undefined) { headers['x-qinglong-local-presence'] = options.presence; } + if (options.authoringLease !== undefined) { + headers['x-qinglong-task-authoring-lease'] = options.authoringLease; + } let response; try { response = await fetch(path, { @@ -345,13 +363,74 @@ nodes.dialog.showModal(); } - function openTaskEditor() { + function openTaskEditor(snapshot = null) { + state.authoringSnapshot = snapshot; nodes.taskEditorForm.reset(); - nodes.taskCommand.value = '/bin/echo'; - nodes.taskEnabled.checked = true; + const editing = snapshot !== null; + nodes.taskEditorTitle.textContent = editing + ? '编辑命令任务' + : '创建命令任务'; + nodes.taskEditorIntro.textContent = editing + ? `完整定义已由本机证明读取,并绑定 revision ${snapshot.task.revision}。保存时还会生成一份只绑定新内容的证明。` + : '定义会先绑定到一次本机证明,再以同一事务写入 Task revision 与安全审计。'; + nodes.taskEditorNote.textContent = editing + ? `编辑租约将在 ${formatTime( + snapshot.authoring.expiresAtMs, + )} 失效;关闭后重新选择“编辑任务”可取得新快照。` + : 'Alpha 当前从 Console 创建 qinglong/command@v1;高级 Task schema 仍使用受信任管理入口。'; + nodes.taskEnabledLabel.textContent = editing + ? '保存后允许运行' + : '创建后允许运行'; + nodes.taskId.readOnly = editing; + if (editing) nodes.taskId.setAttribute('aria-readonly', 'true'); + else nodes.taskId.removeAttribute('aria-readonly'); + if (editing) { + const command = snapshot.task.spec.config.command; + nodes.taskId.value = snapshot.task.taskId; + nodes.taskName.value = snapshot.task.name; + nodes.taskDescription.value = snapshot.task.description || ''; + nodes.taskCommand.value = command.file; + nodes.taskArgs.value = command.args.join('\n'); + nodes.taskEnabled.checked = snapshot.task.enabled; + } else { + nodes.taskCommand.value = '/bin/echo'; + nodes.taskEnabled.checked = true; + } nodes.taskEditor.returnValue = ''; nodes.taskEditor.showModal(); - nodes.taskId.focus(); + (editing ? nodes.taskName : nodes.taskId).focus(); + } + + function authoringSnapshot(value, expectedTaskId) { + const task = value?.task; + const authoring = value?.authoring; + const command = task?.spec?.config?.command; + if ( + !task || + task.taskId !== expectedTaskId || + !Number.isSafeInteger(task.revision) || + task.revision < 1 || + typeof task.contentDigest !== 'string' || + !/^[a-f0-9]{64}$/u.test(task.contentDigest) || + task.kind !== 'command' || + task.spec?.schema !== 'qinglong/command@v1' || + command?.kind !== 'argv' || + typeof command.file !== 'string' || + !Array.isArray(command.args) || + command.args.length > 128 || + command.args.some((entry) => typeof entry !== 'string') || + !task.labels || + typeof task.labels !== 'object' || + Array.isArray(task.labels) || + !authoring || + !AUTHORING_LEASE_PATTERN.test(authoring.lease) || + !Number.isSafeInteger(authoring.expiresAtMs) || + authoring.revision !== task.revision || + authoring.contentDigest !== task.contentDigest + ) { + throw new ConsoleRequestError('response_unavailable', 503, null); + } + return Object.freeze({ task, authoring }); } function taskDraft() { @@ -369,28 +448,46 @@ if (!name || !file || args.length > 128) { throw new TypeError('名称、命令或参数数量无效。'); } - return Object.freeze({ - taskId, - body: Object.freeze({ - expectedRevision: null, - mutationId: newMutationId(), - name, - ...(description ? { description } : {}), - kind: 'command', - spec: Object.freeze({ + const snapshot = state.authoringSnapshot; + const spec = snapshot + ? Object.freeze({ + ...snapshot.task.spec, + config: Object.freeze({ + ...snapshot.task.spec.config, + command: Object.freeze({ + ...snapshot.task.spec.config.command, + kind: 'argv', + file, + args, + }), + }), + }) + : Object.freeze({ schema: 'qinglong/command@v1', config: Object.freeze({ command: Object.freeze({ kind: 'argv', file, args }), }), - }), - labels: Object.freeze({ 'qinglong.source': 'local-console' }), + }); + return Object.freeze({ + taskId, + ...(snapshot ? { authoringLease: snapshot.authoring.lease } : {}), + body: Object.freeze({ + expectedRevision: snapshot ? snapshot.task.revision : null, + mutationId: newMutationId(), + name, + ...(description ? { description } : {}), + kind: 'command', + spec, + labels: snapshot + ? snapshot.task.labels + : Object.freeze({ 'qinglong.source': 'local-console' }), enabled: nodes.taskEnabled.checked, occurredAtMs: Date.now(), }), }); } - function showPresenceChallenge(mutation, challenge) { + function showPresenceChallenge(action, challenge) { if ( challenge?.code !== 'local_presence_required' || typeof challenge.proofFileName !== 'string' || @@ -399,7 +496,16 @@ ) { throw new ConsoleRequestError('response_unavailable', 503, null); } - state.pendingTaskMutation = Object.freeze({ mutation, challenge }); + state.pendingPresence = Object.freeze({ ...action, challenge }); + const authoringRead = action.kind === 'authoring'; + nodes.presenceCopy.textContent = authoringRead + ? '读取完整 Task 定义需要部署设备上的一次性证明。返回的编辑租约不替代保存时的新内容证明。' + : '使用部署 QingLong 的系统用户读取下面的私有文件。证明只绑定这次 Task 内容,且只能使用一次。'; + nodes.presenceSubmit.textContent = authoringRead + ? '验证并加载定义' + : action.mutation.body.expectedRevision === null + ? '验证并创建' + : '验证并更新'; nodes.presenceFile.textContent = `console-presence/${challenge.proofFileName}`; nodes.presenceExpiry.textContent = `证明将在 ${formatTime( challenge.expiresAtMs, @@ -432,10 +538,13 @@ method: 'PUT', body: mutation.body, acceptStatus: 428, + ...(mutation.authoringLease + ? { authoringLease: mutation.authoringLease } + : {}), }, ); if (value.code === 'local_presence_required') { - showPresenceChallenge(mutation, value); + showPresenceChallenge({ kind: 'mutation', mutation }, value); return; } throw new ConsoleRequestError('response_unavailable', 503, null); @@ -446,8 +555,27 @@ } } + async function beginTaskAuthoring(task) { + try { + const value = await api( + `/api/v3/projects/${state.project}/tasks/${task.taskId}/authoring`, + { method: 'POST', acceptStatus: 428 }, + ); + if (value.code === 'local_presence_required') { + showPresenceChallenge( + { kind: 'authoring', taskId: task.taskId }, + value, + ); + return; + } + throw new ConsoleRequestError('response_unavailable', 503, null); + } catch (error) { + showToast(describeError(error), 'error'); + } + } + async function completeTaskMutation() { - const pending = state.pendingTaskMutation; + const pending = state.pendingPresence; const proof = nodes.presenceProof.value.trim(); if (!pending || !PRESENCE_PATTERN.test(proof)) { nodes.presenceError.textContent = @@ -459,20 +587,40 @@ nodes.presenceSubmit.disabled = true; nodes.presenceError.hidden = true; try { + if (pending.kind === 'authoring') { + const value = await api( + `/api/v3/projects/${state.project}/tasks/${pending.taskId}/authoring`, + { method: 'POST', presence: proof }, + ); + const snapshot = authoringSnapshot(value, pending.taskId); + state.pendingPresence = null; + nodes.presenceProof.value = ''; + nodes.presenceDialog.close(); + openTaskEditor(snapshot); + showToast('完整 Task 定义已加载;保存仍需要新的本机证明。'); + return; + } const value = await api( `/api/v3/projects/${state.project}/tasks/${pending.mutation.taskId}`, { method: 'PUT', body: pending.mutation.body, presence: proof, + ...(pending.mutation.authoringLease + ? { authoringLease: pending.mutation.authoringLease } + : {}), }, ); - state.pendingTaskMutation = null; + const updated = pending.mutation.body.expectedRevision !== null; + state.pendingPresence = null; + state.authoringSnapshot = null; nodes.presenceProof.value = ''; nodes.presenceDialog.close(); showToast( value.status === 'existing' ? '已找到同一 Task 请求。' + : updated + ? 'Task 已更新。' : 'Task 已创建。', ); state.selectedId = pending.mutation.taskId; @@ -575,6 +723,9 @@ ); fragment.append(facts); const actions = element('div', 'detail-actions'); + if (task.kind === 'command' && task.specSchema === 'qinglong/command@v1') { + actions.append(actionButton('编辑任务', () => beginTaskAuthoring(task))); + } if (task.enabled) { actions.append( actionButton('运行一次', () => { @@ -933,7 +1084,8 @@ state.token = null; state.selectedId = null; state.pendingAction = null; - state.pendingTaskMutation = null; + state.pendingPresence = null; + state.authoringSnapshot = null; if (nodes.taskEditor.open) nodes.taskEditor.close(); if (nodes.presenceDialog.open) nodes.presenceDialog.close(); nodes.token.value = ''; @@ -980,16 +1132,18 @@ nodes.disconnect.addEventListener('click', disconnect); nodes.refresh.addEventListener('click', refresh); - nodes.createTask.addEventListener('click', openTaskEditor); - nodes.taskEditorClose.addEventListener('click', () => - nodes.taskEditor.close(), - ); + nodes.createTask.addEventListener('click', () => openTaskEditor()); + nodes.taskEditorClose.addEventListener('click', () => { + state.authoringSnapshot = null; + nodes.taskEditor.close(); + }); nodes.taskEditorForm.addEventListener('submit', async (event) => { event.preventDefault(); await saveTaskDraft(); }); nodes.presenceCancel.addEventListener('click', () => { - state.pendingTaskMutation = null; + state.pendingPresence = null; + state.authoringSnapshot = null; nodes.presenceProof.value = ''; nodes.presenceDialog.close(); }); diff --git a/packages/ql3-local-api/assets/console/index.html b/packages/ql3-local-api/assets/console/index.html index f2748209..62391ffd 100644 --- a/packages/ql3-local-api/assets/console/index.html +++ b/packages/ql3-local-api/assets/console/index.html @@ -135,11 +135,11 @@

Task authoring

-

创建命令任务

+

创建命令任务

-

+

定义会先绑定到一次本机证明,再以同一事务写入 Task revision 与安全审计。

@@ -171,10 +171,10 @@
-

+

Alpha 当前从 Console 创建 qinglong/command@v1;高级 Task schema 仍使用受信任管理入口。

@@ -189,8 +189,8 @@

Local presence · 02:00

从部署设备取得证明

-

- 使用部署 QingLong 的系统用户读取下面的私有文件。证明只绑定这次 Task 内容,且只能使用一次。 +

+ 使用部署 QingLong 的系统用户读取下面的私有文件。证明只绑定这次操作,且只能使用一次。