From f46fb44ac9534315b6965865bb3e990715bb2417 Mon Sep 17 00:00:00 2001 From: whyour Date: Sat, 29 Aug 2026 19:44:04 +0800 Subject: [PATCH] feat(ql3): add secret-backed console automation --- docs/QINGLONG_3_0_ARCHITECTURE_RFC.md | 2 + ...-local-console-secret-backed-automation.md | 67 ++ docs/adr/README.md | 1 + docs/operations/ql3-alpha-candidate.md | 3 + docs/operations/ql3-local-web-console.md | 25 +- .../adopted-profile/localAdoptedProfile.ts | 8 + .../ql3-local-api/assets/console/console.js | 463 +++++++++++++- .../ql3-local-api/assets/console/index.html | 43 ++ .../src/admission/localApiAdmission.ts | 48 ++ .../localApiProductSurface.ts | 28 + .../ql3-local-api/src/secret/secretRoutes.ts | 596 ++++++++++++++++++ .../src/transport/httpSurface.ts | 81 ++- .../ql3-local-api/test/admission.test.cjs | 57 ++ .../ql3-local-api/test/consoleAssets.test.cjs | 13 + .../ql3-local-api/test/httpSurface.test.cjs | 68 +- .../ql3-local-api/test/secretRoutes.test.cjs | 313 +++++++++ .../test/sqliteHttp.integration.test.cjs | 121 +++- .../src/application-runtime/activation.ts | 4 + .../src/application-runtime/contract.ts | 4 + .../src/profile/localProfile.ts | 5 + .../src/runtime/runtimeDatabase.ts | 29 +- .../src/security/secretMetadataRepository.ts | 133 ++++ .../test/secretMetadataRepository.test.cjs | 115 ++++ .../src/secret/localSecret.ts | 54 +- scripts/ql3-cluster-dependency-audit.cjs | 22 +- test/back/ql3PackageBoundaryAudit.test.cjs | 8 +- 26 files changed, 2272 insertions(+), 39 deletions(-) create mode 100644 docs/adr/ADR-0519-local-console-secret-backed-automation.md create mode 100644 packages/ql3-local-api/src/secret/secretRoutes.ts create mode 100644 packages/ql3-local-api/test/secretRoutes.test.cjs create mode 100644 packages/ql3-local-sqlite/src/security/secretMetadataRepository.ts create mode 100644 packages/ql3-local-sqlite/test/secretMetadataRepository.test.cjs diff --git a/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md b/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md index e48ce55e..425cc499 100644 --- a/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md +++ b/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md @@ -1,5 +1,7 @@ # QingLong 3.0 Architecture RFC +- D-424/ADR-0519(已实现并通过本地门,当前为源码候选):Local Console 新增 current-only Secret metadata list,以及要求 User credential、`secret.manage`、exact plaintext digest owner-private proof 和事务内 credential/Policy/RoleBinding fence 的 Secret create/rotate。既有 Local administration service 继续负责 AES-256-GCM 与 immutable version;HTTP/Console/audit/Task 均不返回或持久化 plaintext、ciphertext、key ID。Task 编辑器把 `ENV=secret[@version]` 解析成 pinned `SecretRef`,省略版本时立即固定当前版本,执行时仍由既有 materializer 解密。Fresh/adopted Profile 共用现有 SQLite connection/close fence;无新 package、migration、connection、cache、daemon、timer 或 watcher。默认 headless 无 API/Console surface,Cluster 不复用 Local proof/SQLite custody。18-package clean build/test `3,052 total / 3,030 pass / 22 conditional skip / 0 fail`,Local API `76/76`、Local SQLite `250/250`;package boundary、122-module Edge source import 和精确 Cluster dependency audit compatible。默认 Edge 2,760,847 bytes/332 files/59 modules,opt-in Edge/Standalone Console 4,210,024/4,210,168 bytes、482 files/111 modules,三资产 102,182 bytes,资源门均通过。D-424 必须等 exact commit 的远端普通主 CI、Kubernetes 与显式双架构 milestone 完成后才升级为阶段实物;在此之前,D-423 run `33245745837` 仍是最新可下载、已验真的 `3.0.0-alpha.2` 产物。 + - D-423/ADR-0518(已实现并交付同源双架构 Console Alpha Candidate):Local Console 复用既有 immutable Trigger revision、固定 Task revision/content digest、semantic cron validation、durable schedule cursor 与原子 Run admission,新增有界 `GET /api/v3/projects/:projectId/triggers[/:triggerId]` 和强认证 `PUT /api/v3/projects/:projectId/triggers/:triggerId`。列表只返回低敏摘要,精确读取才返回完整 spec/Task digest;读取走 `task.read`,mutation 要求 User credential、`task.update`、两分钟一次性 owner-private exact-content proof、credential reconfirm,并由 request-scoped SQLite Trigger repository 在同一事务重验 Policy/RoleBinding/Task pin/credential fence、追加 immutable revision、初始化或更新 schedule 与 durable audit。Console 仅支持冻结的 `qinglong/cron@v1` expression/timezone/`skip|fire_once`,编辑、启停均追加 revision,不提供删除或通用 provider 编辑。Fresh/adopted Profile 共用现有 database close fence;不新增 package、migration、connection、daemon、watcher、每 Trigger timer 或第二 scheduler。默认 headless 不携带 API/Console 资产与 listener,Cluster 不复用 Local POSIX proof/SQLite authority。18-package clean build/test `3,044 total / 3,022 pass / 22 conditional skip / 0 fail`,完整 backend `1,653 total / 1,651 pass / 2 Linux conditional skip / 0 fail`,Local API `70/70`;package boundary、122-module Edge import 与精确 Cluster dependency audit compatible。默认 Edge 2,754,742 bytes/331 files/58 modules,opt-in Edge/Standalone Console 4,150,439/4,150,583 bytes、479 files/101 modules,三资产 84,401 bytes。提交 `b970e2aede516c350b1cdb409e0d0d3038a5deee` 的普通主 CI run `33244982727` 为 41 success/3 expected skip/0 fail,Kubernetes run `33244982694` 成功;显式 Console milestone run `33245745837` 为 42 success/2 scope skip/0 fail,生成 187,914,706-byte amd64、185,146,322-byte arm64 Trial Kit 与 5,623-byte milestone,保留至 2026-09-28。下载索引通过 checksum 与离线 auditor,返回 `compatible=true`。因此 D-423 已是可下载、可验真、可 fresh 试运行并配置 cron 自动化的 `3.0.0-alpha.2` 阶段实物,但仍不是公开 release、2.x 生产升级、Cluster HA 交付或长期支持版本。 - RFC ID: QL-RFC-0001 diff --git a/docs/adr/ADR-0519-local-console-secret-backed-automation.md b/docs/adr/ADR-0519-local-console-secret-backed-automation.md new file mode 100644 index 00000000..66e0e033 --- /dev/null +++ b/docs/adr/ADR-0519-local-console-secret-backed-automation.md @@ -0,0 +1,67 @@ +# ADR-0519:Local Console Secret-backed 自动化 + +- 状态:Accepted(源码候选;阶段产物待远端门闭合) +- 日期:2026-08-29 +- 对应 RFC 切片:D-424 +- 关联:ADR-0069、ADR-0071、ADR-0512、ADR-0516、ADR-0517、ADR-0518 + +## 背景 + +D-423 已让部署者在 Local Console 创建 command Task 并配置 cron Trigger,但需要凭据的自动化仍只能借助受信 CLI。QingLong 3.0 已有 AES-256-GCM Secret custody、immutable version、pinned `SecretRef`、Task execution-time materialization 和 durable audit;缺口是一个不会暴露明文或复制密钥 authority 的有界产品入口。 + +该入口必须同时适配小路由设备和普通单节点:默认 headless 不承担 Console/API 成本,opt-in Console 不增加连接、迁移或后台生命周期。Cluster 必须继续使用自己的共享 Secret custody 和 HA authority,不能复用 Local SQLite/POSIX proof。 + +## 决策 + +### 1. 列表只暴露当前版本元数据 + +新增固定路由: + +```text +GET /api/v3/projects/:projectId/secrets +PUT /api/v3/projects/:projectId/secrets +``` + +GET 使用按名称排序的稳定 keyset;Edge 默认 16 条、Standalone 默认 32 条、硬上限 64 条。每个名称只返回当前 version、创建时间和 canonical pinned `SecretRef`。响应不包含 plaintext、ciphertext、nonce、tag、key ID、mutation ID 或历史版本内容。 + +Fresh 与 adopted Profile 在既有数据库 close fence 上暴露只读 metadata source;不开第二个 SQLite connection,不新增 table、migration、cache、watcher 或 timer。损坏、关闭或越界查询均失败关闭。 + +### 2. Secret mutation 继续使用既有加密 authority + +PUT body 精确包含 `name`、`plaintext`、`mutationId` 和 `expectedCurrentVersion`。请求只接受 User credential、`secret.manage` Policy 与绑定 exact plaintext digest、Project、credential ID/version、User subject 的两分钟一次性 owner-private proof。 + +proof 验证后再次确认 credential,并创建 request-scoped credential-fenced Secret repository。既有 Local administration service 在数据库事务外完成 AES-256-GCM 加密,在事务内重验 credential、Identity、RoleBinding、Policy 与 expected current version,再原子追加 immutable Secret version 和 durable audit。HTTP 只返回名称、当前版本、pinned reference 与幂等状态。 + +plaintext 仅在请求解析、proof 等待的页面内存与加密调用所需的短生命周期中存在;Console 在成功、取消、断开时清空输入和 pending body。它不进入 Cookie、Web Storage、URL、响应、audit 或日志。 + +### 3. Task 编辑器只保存 pinned SecretRef + +Console 使用逐行 `ENV_NAME=secret-name[@version]` 绑定。省略 version 时只能从当前有界目录解析并立即固定为当前版本;显式历史版本不得大于目录中的当前版本。未知名称、未来版本、重复环境变量、`QL3_` 保留前缀和不安全格式均拒绝。 + +保存时保留完整 authoring snapshot 中未展示的 public environment 与其他 config/labels,只替换 Console 可识别的 Secret bindings。Task spec 只持久化 canonical `SecretRef`;执行时继续由既有 runtime materializer 解密,不把明文写回 Task、Run、Event 或 Console。 + +### 4. Profile 与部署边界不扩张 + +- 默认 Edge/Standalone headless 不开放 Secret HTTP/Console surface,不携带静态资产或 listener;常驻增量仅是复用现有 SQLite connection 的小型 metadata repository。 +- `edge-application-api` 与 `standalone-application-api` 才装配 Secret metadata、presence 与 administration capability;仍是同一 Application 进程和数据库连接。 +- `@qinglong/local-api` 仅允许 `src/secret/secretRoutes.ts` 导入 Local administration,Local Application contract 仅允许 runtime-core 的 metadata contract;Cluster dependency audit 对其他路径继续拒绝。 +- Cluster 不使用 Local proof、keyring 或 SQLite authority。后续 Cluster Console Secret 管理必须走共享 custody、TLS、RBAC/Approval、PostgreSQL transaction 与 HA fence。 + +## 不采用的方案 + +- 不让浏览器读取、回显或下载 Secret 明文;轮换也只接受新值,不提供“显示现值”。 +- 不把 ciphertext、key ID 或 mutation metadata 当作“低敏列表字段”;这些都留在 custody 边界内。 +- 不把 Secret value 写入 Task environment、authoring lease、presence challenge 或 audit detail。 +- 不用单因子 Bearer 直接创建或轮换 Secret;长期自动化凭据需要本机 presence 与 credential transaction fence。 +- 不新建只有一两个文件的微包;该能力留在现有 runtime-core contract、Local SQLite repository、Local administration 和 Local API capability 内。 +- 不为低配设备增加 Secret cache、后台清理器或独立进程。 + +## 结果与验证边界 + +定向测试覆盖 current-only metadata、稳定分页、关闭/越界失败、无 ciphertext/key 泄漏、exact presence、plaintext digest 漂移、宽化 body 拒绝、admission delegation、HTTP 路由与真实 SQLite/loopback 创建。集成旅程验证数据库只保存不含 plaintext 的密文,Task spec 保存精确 pinned `SecretRef`,并出现 `secret.create` 与 `secret.list` durable audit。 + +本地 18-package clean build/test 为 `3,052 total / 3,030 pass / 22 conditional、platform 或 external-service skip / 0 fail`,其中 Local SQLite `250/250`、Local API `76/76`、Local Admin `96/96`、Local Application `55 total / 51 pass / 4 platform skip / 0 fail`。package boundary 保持 18 packages、`singleSourcePackages=[]`、`shallowSourcePackages=[]`;122-module Edge source import 与精确 Cluster dependency audit compatible。 + +三项 Console 静态资产合计 102,182 bytes,低于 192 KiB 总闭包与 96 KiB 单文件门。默认 Edge 为 2,760,847 bytes/332 files/3 packages/59 loaded modules,RSS delta 11,026,432 bytes;opt-in Edge/Standalone Console 为 4,210,024/4,210,168 bytes、482 files/12 packages/111 loaded modules,RSS delta 20,447,232/18,399,232 bytes,均低于既有门。Edge executor benchmark 继续通过。 + +这些证据只把 D-424 提升为可提交的源码候选。必须等待 exact source commit 的普通主 CI、Kubernetes deployment 和显式 Local Console 双架构 milestone 全绿,并下载复核 milestone checksum/auditor 后,才能把 D-424 称为新的阶段可用实物。在此之前,D-423 run `33245745837` 仍是最新可下载、可验真的 Console Trial Kit。 diff --git a/docs/adr/README.md b/docs/adr/README.md index ba2de369..91aabe94 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -522,6 +522,7 @@ | [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(D-422 双架构 milestone 已交付) | | [ADR-0518](./ADR-0518-local-console-cron-trigger-administration.md) | Local Console cron Trigger 管理 | Accepted(D-423 双架构 milestone 已交付) | +| [ADR-0519](./ADR-0519-local-console-secret-backed-automation.md) | Local Console Secret-backed 自动化 | Accepted(D-424 源码候选;阶段产物待远端门闭合) | ## 规则 diff --git a/docs/operations/ql3-alpha-candidate.md b/docs/operations/ql3-alpha-candidate.md index 00f2c8d9..ce565509 100644 --- a/docs/operations/ql3-alpha-candidate.md +++ b/docs/operations/ql3-alpha-candidate.md @@ -28,6 +28,7 @@ | 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;同源双架构 Console v5 Trial Kit 与 milestone 已生成并验真 | Cluster 不复用 Local proof;尚无通用 workflow 编辑器、2.x 升级或生产远程管理;仍不是正式发布 | | D-423 Console cron Trigger 管理切片 | 已复用既有 immutable Trigger、Task pin、durable schedule 与原子 audit authority;Console/API 可 list/read/create/update/enable/disable `qinglong/cron@v1`,真实 SQLite/loopback 与同源双架构 Console milestone 已通过 | Cluster 不复用 Local proof;不提供删除或通用 Trigger schema 编辑;仍不是正式发布或生产升级 | +| D-424 Console Secret-backed 自动化切片 | 源码候选已完成 current-only metadata、强认证 AES-256-GCM create/rotate 与 Task pinned `SecretRef` 绑定;真实 SQLite/loopback、本地全量包、资源与架构门已通过 | exact commit 的远端主 CI、Kubernetes 与双架构 milestone 尚未闭合;当前可下载实物仍是 D-423 | 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 创建能力已进入新的阶段实物,而不再只是候选源码。 @@ -35,6 +36,8 @@ D-422 已从“源码候选”升级为阶段实物:本地真实 SQLite/loopba D-423 已从“源码候选”升级为阶段实物:18-package clean build/test `3,044 total / 3,022 pass / 22 conditional skip / 0 fail`,完整 backend `1,653 total / 1,651 pass / 2 Linux conditional skip / 0 fail`,Local API `70/70`;18-package boundary、122-module Edge import 与 Cluster dependency audit 均 compatible。默认 headless Edge 为 2,754,742 bytes/331 files/58 modules,opt-in Edge/Standalone Console 为 4,150,439/4,150,583 bytes、479 files/101 modules,三资产合计 84,401 bytes。提交 `b970e2aede516c350b1cdb409e0d0d3038a5deee` 的普通主 CI [run 33244982727](https://github.com/whyour/qinglong/actions/runs/33244982727) 为 41 success/3 expected artifact-finalizer skip/0 fail,同源 Kubernetes deployment [run 33244982694](https://github.com/whyour/qinglong/actions/runs/33244982694) 成功;随后显式 Local Console milestone [run 33245745837](https://github.com/whyour/qinglong/actions/runs/33245745837) 为 42 success/2 scope skip/0 fail。阶段产物绑定新的 exact commit/run/artifact digest,没有沿用或改名复用 D-422 archive。 +D-424 当前严格标记为源码候选,而不是“开发完成即交付”:本地 18-package clean build/test 为 `3,052 total / 3,030 pass / 22 conditional skip / 0 fail`,Local SQLite `250/250`、Local API `76/76`,默认 Edge 与 opt-in Console 资源门、Edge benchmark、package/source boundary 和 Cluster dependency audit 均通过。它已经证明 Secret metadata 不泄漏 custody 字段、create/rotate 必须 exact local presence、SQLite 只保存密文、Task 只保存 pinned `SecretRef`。只有 exact source commit 的普通主 CI、Kubernetes deployment 与显式双架构 Console milestone 全绿,并下载复核 checksum/auditor 后,本段才会改为阶段实物;当前下载者应继续使用下面的 D-423 run `33245745837`。 + 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 绑定提交 `b970e2aede516c350b1cdb409e0d0d3038a5deee` 与 [GitHub Actions run 33245745837](https://github.com/whyour/qinglong/actions/runs/33245745837),保留至 2026-09-28: diff --git a/docs/operations/ql3-local-web-console.md b/docs/operations/ql3-local-web-console.md index 2e5643b1..066d7205 100644 --- a/docs/operations/ql3-local-web-console.md +++ b/docs/operations/ql3-local-web-console.md @@ -1,6 +1,6 @@ # QingLong 3.0 Local Web Console -Local Web Console 是 `@qinglong/local-api` 的 opt-in 操作界面,用来创建和编辑 command Task、配置 cron Trigger、查看 Task/Run/执行事件,并显式启动或取消一次运行。它由 Console Local Alpha Trial Kit 交付,但不进入默认 headless 变体,也不是 2.x Web UI 的完整替代品。 +Local Web Console 是 `@qinglong/local-api` 的 opt-in 操作界面,用来创建和编辑 command Task、管理加密 Secret 绑定、配置 cron Trigger、查看 Task/Run/执行事件,并显式启动或取消一次运行。它由 Console Local Alpha Trial Kit 交付,但不进入默认 headless 变体,也不是 2.x Web UI 的完整替代品。 ## 选择部署档位 @@ -11,7 +11,7 @@ Local Web Console 是 `@qinglong/local-api` 的 opt-in 操作界面,用来创 | 普通单节点服务器 | 选择 `standalone-application-api` | | Kubernetes/Cluster 节点 | 不使用本 Local Console;继续使用 Cluster Control/Console 路径 | -D-418 已闭合独立 Console image/Trial Kit;D-419 的 v5 quickstart 进一步安装可直接使用的 Owner credential presentation,并创建默认不自动运行的 `alpha-first-automation`。D-420 又把该 Run 的 latest Attempt 首个 32 KiB 日志带到 Console。D-421/D-422 依次增加 request-scoped strong-auth Task 创建与双 proof 无损编辑。D-423 继续开放既有 immutable Trigger/cron authority;绑定提交 `b970e2aede516c350b1cdb409e0d0d3038a5deee` 的最新双架构实物已由 [milestone run 33245745837](https://github.com/whyour/qinglong/actions/runs/33245745837) 生成并完成离线审计。 +D-418 已闭合独立 Console image/Trial Kit;D-419 的 v5 quickstart 进一步安装可直接使用的 Owner credential presentation,并创建默认不自动运行的 `alpha-first-automation`。D-420 又把该 Run 的 latest Attempt 首个 32 KiB 日志带到 Console。D-421/D-422 依次增加 request-scoped strong-auth Task 创建与双 proof 无损编辑。D-423 继续开放既有 immutable Trigger/cron authority;绑定提交 `b970e2aede516c350b1cdb409e0d0d3038a5deee` 的最新双架构实物已由 [milestone run 33245745837](https://github.com/whyour/qinglong/actions/runs/33245745837) 生成并完成离线审计。D-424 的 Secret-backed 自动化已通过本地门,但在新的远端 CI 与双架构 milestone 闭合前仍是源码候选,不能借用 D-423 archive 宣称交付。 ## 前置条件 @@ -57,25 +57,26 @@ ssh -L 5701:127.0.0.1:5701 router.example 2. 选择“创建任务”,填写 Task ID、名称、argv 可执行文件和逐行参数,再选择“保存并生成本机证明”。 3. 在部署设备上以 QingLong 数据目录 owner 读取 `/console-presence/<页面显示的 basename>`;把 JSON 的完整 `ql3p_…` proof 值粘贴回页面。文件为 `0600`、两分钟有效且只能用于这份 exact 操作一次。不要通过聊天、日志或 URL 转发 proof。 4. 编辑现有内建 command Task 时先选择“编辑任务”,完成第一次本机证明以读取完整定义并取得 10 分钟一次性编辑租约。保存新内容时页面会要求第二份 proof;第一份只授权读取,不能复用来保存。Task ID 只读,未展示的 command config 与 labels 会原样保留。 -5. 需要周期运行时进入“定时”,选择“新建定时”,填写 Trigger ID、已存在的 Task ID、cron expression、显式 timezone、`skip|fire_once` misfire policy 与 enabled。页面会先读取当前 Task revision/content digest,再要求一份绑定这次 exact Trigger 内容的本机 proof。 -6. 编辑、启用或停用 Trigger 都会追加 immutable revision;当前阶段没有删除。Task 已被其他操作更新时,旧 pin 会失败关闭,应刷新后重新确认,不能猜测重绑。 -7. 创建或更新 Task 成功后核对 revision/content fence,再选择“运行一次”。fresh Console Trial Kit 也可直接使用 `alpha-first-automation`;enabled Trigger 则由已有 durable Scheduler 自动产生 Run,不依赖浏览器保持打开。 -8. 在“运行”中选择 durable Run,按 Event sequence 判断实际进度;Bounded log 只显示 latest Attempt 的首个 32 KiB,后续内容仍需通过 API 分页读取。 -9. 日志 pending 时使用“刷新”显式重读;retired 表示内容已按保留策略清理,不代表 Run/Event 事实丢失。 -10. “请求取消”只提交 durable cancellation intent;界面出现 `cancelled|failed|succeeded|timed_out` 终态前,不要认为进程已经停止。 -11. 完成后选择“断开并清除凭据”,再关闭页面。 +5. 需要敏感环境变量时进入“凭据”,创建或轮换 Secret。每次操作都要求一份绑定 exact plaintext digest 的新 proof;Console 永不显示旧值,提交后立即清空输入。Task 编辑器使用 `ENV_NAME=secret-name` 或 `ENV_NAME=secret-name@version`,省略版本时也会在保存前固定为当前版本。 +6. 需要周期运行时进入“定时”,选择“新建定时”,填写 Trigger ID、已存在的 Task ID、cron expression、显式 timezone、`skip|fire_once` misfire policy 与 enabled。页面会先读取当前 Task revision/content digest,再要求一份绑定这次 exact Trigger 内容的本机 proof。 +7. 编辑、启用或停用 Trigger 都会追加 immutable revision;当前阶段没有删除。Task 已被其他操作更新时,旧 pin 会失败关闭,应刷新后重新确认,不能猜测重绑。 +8. 创建或更新 Task 成功后核对 revision/content fence,再选择“运行一次”。fresh Console Trial Kit 也可直接使用 `alpha-first-automation`;enabled Trigger 则由已有 durable Scheduler 自动产生 Run,不依赖浏览器保持打开。 +9. 在“运行”中选择 durable Run,按 Event sequence 判断实际进度;Bounded log 只显示 latest Attempt 的首个 32 KiB,后续内容仍需通过 API 分页读取。 +10. 日志 pending 时使用“刷新”显式重读;retired 表示内容已按保留策略清理,不代表 Run/Event 事实丢失。 +11. “请求取消”只提交 durable cancellation intent;界面出现 `cancelled|failed|succeeded|timed_out` 终态前,不要认为进程已经停止。 +12. 完成后选择“断开并清除凭据”,再关闭页面。 Credential 只存在当前页面内存,不进入 URL、Cookie 或 Web Storage。页面刷新会丢失 credential,需要重新输入;这是当前安全边界,不是缺陷。 ## 当前阶段可用边界 -D-423 阶段实物的可操作闭环是内建 argv command Task create/list/read/update/enable/disable/start、`qinglong/cron@v1` Trigger list/read/create/update/enable/disable,以及 Run list/read/events/steps/log/cancel。Task 编辑器只修改当前展示字段并保留完整快照中的其他 config/labels;其他 Task kind 或 Trigger schema 继续使用受信管理入口。页面暂不负责: +D-423 阶段实物的可操作闭环是内建 argv command Task create/list/read/update/enable/disable/start、`qinglong/cron@v1` Trigger list/read/create/update/enable/disable,以及 Run list/read/events/steps/log/cancel。D-424 源码候选在此基础上增加 Secret current metadata、create/rotate 与 Task pinned binding;只有新 milestone 生成后该能力才进入下载产物。Task 编辑器只修改当前展示字段并保留完整快照中的其他 config/labels;其他 Task kind 或 Trigger schema 继续使用受信管理入口。页面暂不负责: -- Identity、Policy、Secret、Plugin Package 或 AI 配置管理; +- Identity、Policy、Secret 明文读取/删除/历史浏览、Plugin Package 或 AI 配置管理; - Trigger 删除、通用 Trigger provider/schema 编辑或 Cluster Trigger 管理; - 日志整文件下载、终端、文件管理或 2.x 数据迁移; - LAN/public 暴露、TLS termination、多用户 Web session 或 Cluster 管理。 -三项静态资产总计 84,401 bytes,不依赖 CDN、网络字体或前端框架,仍低于 192 KiB 总闭包和单文件 96 KiB 门。`edge-application-api|standalone-application-api` 为 4,150,439 / 4,150,583 bytes、479 files、12 packages、101 loaded modules,仍低于 6 MiB/640-file 门;本机 RSS delta 为 17,088,512 / 17,055,744 bytes,低于 28 MiB。默认 headless Edge 为 2,754,742 bytes、331 files、3 packages、58 modules,RSS delta 11,108,352 bytes;它不携带 Console/API 资产、listener 或第二个 scheduler,但包含复用既有 SQLite Trigger mutation authority 的小幅装配代码增量。 +D-424 源码候选的三项静态资产总计 102,182 bytes,不依赖 CDN、网络字体或前端框架,仍低于 192 KiB 总闭包和单文件 96 KiB 门。`edge-application-api|standalone-application-api` 为 4,210,024 / 4,210,168 bytes、482 files、12 packages、111 loaded modules,仍低于 6 MiB/640-file 门;本机 RSS delta 为 20,447,232 / 18,399,232 bytes,低于 28 MiB。默认 headless Edge 为 2,760,847 bytes、332 files、3 packages、59 modules,RSS delta 11,026,432 bytes;它不携带 Console/API 资产、listener 或 Secret mutation surface,只增加复用现有 SQLite connection 的有界 metadata 装配。 停止 Local API 进程走与 Application 相同的 drain/shutdown 路径。Console 没有独立数据库、后台任务或需要额外清理的持久状态。 diff --git a/packages/ql3-local-admin/src/adopted-profile/localAdoptedProfile.ts b/packages/ql3-local-admin/src/adopted-profile/localAdoptedProfile.ts index ed3dac47..3510796d 100644 --- a/packages/ql3-local-admin/src/adopted-profile/localAdoptedProfile.ts +++ b/packages/ql3-local-admin/src/adopted-profile/localAdoptedProfile.ts @@ -25,8 +25,11 @@ type TriggerAdministrationForCredential = type LocalScheduleStore = ReadyLocalStorage['schedules']; type LocalDispatchStore = ReadyLocalStorage['dispatch']; type LocalSecretEnvelopeRepository = ReadyLocalStorage['localSecrets']; +type LocalSecretMetadataSource = ReadyLocalStorage['localSecretMetadata']; type LocalSecretAdministrationRepository = ReadyLocalStorage['localSecretAdministration']; +type LocalSecretAdministrationForCredential = + ReadyLocalStorage['localSecretAdministrationForCredential']; type ProjectPolicyRepository = ReadyLocalStorage['projectPolicy']; type SecurityAuditSink = ReadyLocalStorage['securityAudit']; type ApiCredentialRepository = ReadyLocalStorage['apiCredentials']; @@ -102,7 +105,9 @@ export type LocalAdoptedProfileBootstrapResult = readonly runAttemptLogRetention: ReadyLocalStorage['runAttemptLogRetention']; readonly runLostRetry: ReadyLocalStorage['runLostRetry']; readonly localSecrets: LocalSecretEnvelopeRepository; + readonly localSecretMetadata: LocalSecretMetadataSource; readonly localSecretAdministration: LocalSecretAdministrationRepository; + readonly localSecretAdministrationForCredential: LocalSecretAdministrationForCredential; readonly projectPolicy: ProjectPolicyRepository; readonly securityAudit: SecurityAuditSink; readonly apiCredentials: ApiCredentialRepository; @@ -255,7 +260,10 @@ export async function bootstrapLocalAdoptedProfileStorage( runAttemptLogRetention: readyStorage.runAttemptLogRetention, runLostRetry: readyStorage.runLostRetry, localSecrets: readyStorage.localSecrets, + localSecretMetadata: readyStorage.localSecretMetadata, localSecretAdministration: readyStorage.localSecretAdministration, + localSecretAdministrationForCredential: + readyStorage.localSecretAdministrationForCredential, projectPolicy: readyStorage.projectPolicy, securityAudit: readyStorage.securityAudit, apiCredentials: readyStorage.apiCredentials, diff --git a/packages/ql3-local-api/assets/console/console.js b/packages/ql3-local-api/assets/console/console.js index 75a278d0..e28b04e3 100644 --- a/packages/ql3-local-api/assets/console/console.js +++ b/packages/ql3-local-api/assets/console/console.js @@ -3,6 +3,8 @@ const PROJECT_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/; const TASK_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/; + const ENVIRONMENT_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/; + const SECRET_REF_PREFIX = 'qlsecret:v1:'; const CRON_FIELD_PATTERN = /^[0-9A-Za-z*?,/#LW-]+$/; const TOKEN_PATTERN = /^ql3c_[A-Za-z0-9][A-Za-z0-9._:-]{0,63}_[A-Za-z0-9_-]{43}$/; @@ -57,6 +59,11 @@ 'Trigger、Task 或授权在确认期间发生变化。请刷新后重新编辑。', invalid_trigger: '定时配置无效。请检查表达式、时区与 Task 状态。', trigger_unavailable: '定时配置暂时无法保存。请检查数据库状态。', + secret_query_unavailable: 'Secret 元数据暂时不可读取。请检查数据库状态。', + secret_fence_rejected: + 'Secret、凭据或授权在确认期间发生变化。请刷新后重新保存。', + invalid_secret: 'Secret 名称、版本或明文无效。', + secret_unavailable: 'Secret 暂时无法加密保存。请检查密钥与数据库状态。', run_cancellation_fence_rejected: '运行在确认期间发生变化,本次取消已安全拒绝。请刷新后重试。', request_unavailable: '本次请求没有完成,请确认服务仍在运行。', @@ -78,6 +85,7 @@ refresh: document.getElementById('refresh-button'), createTask: document.getElementById('create-task-button'), createTrigger: document.getElementById('create-trigger-button'), + createSecret: document.getElementById('create-secret-button'), dialog: document.getElementById('confirmation-dialog'), dialogTitle: document.getElementById('confirmation-title'), dialogCopy: document.getElementById('confirmation-copy'), @@ -94,6 +102,7 @@ taskDescription: document.getElementById('task-description-input'), taskCommand: document.getElementById('task-command-input'), taskArgs: document.getElementById('task-args-input'), + taskSecretBindings: document.getElementById('task-secret-bindings-input'), taskEnabled: document.getElementById('task-enabled-input'), taskEnabledLabel: document.getElementById('task-enabled-label'), triggerEditor: document.getElementById('trigger-editor-dialog'), @@ -108,6 +117,15 @@ triggerTimezone: document.getElementById('trigger-timezone-input'), triggerMisfire: document.getElementById('trigger-misfire-input'), triggerEnabled: document.getElementById('trigger-enabled-input'), + secretEditor: document.getElementById('secret-editor-dialog'), + secretEditorTitle: document.getElementById('secret-editor-title'), + secretEditorIntro: document.getElementById('secret-editor-intro'), + secretEditorNote: document.getElementById('secret-editor-note'), + secretEditorForm: document.getElementById('secret-editor-form'), + secretEditorClose: document.getElementById('secret-editor-close'), + secretEditorSave: document.getElementById('secret-editor-save'), + secretName: document.getElementById('secret-name-input'), + secretValue: document.getElementById('secret-value-input'), presenceDialog: document.getElementById('presence-dialog'), presenceForm: document.getElementById('presence-form'), presenceCopy: document.getElementById('presence-copy'), @@ -129,6 +147,8 @@ pendingPresence: null, authoringSnapshot: null, triggerSnapshot: null, + secretSnapshot: null, + secretCatalog: [], toastTimer: null, }; @@ -223,6 +243,138 @@ .join('')}-${hex.slice(8, 10).join('')}-${hex.slice(10).join('')}`; } + function encodeBase64UrlUtf8(value) { + const bytes = new TextEncoder().encode(value); + let binary = ''; + for (const byte of bytes) binary += String.fromCharCode(byte); + return window + .btoa(binary) + .replace(/\+/gu, '-') + .replace(/\//gu, '_') + .replace(/=+$/gu, ''); + } + + function decodeBase64UrlUtf8(value) { + if (typeof value !== 'string' || !/^[A-Za-z0-9_-]+$/u.test(value)) { + throw new TypeError('SecretRef 编码无效。'); + } + const padding = '='.repeat((4 - (value.length % 4)) % 4); + const binary = window.atob( + value.replace(/-/gu, '+').replace(/_/gu, '/') + padding, + ); + const bytes = Uint8Array.from(binary, (character) => + character.charCodeAt(0), + ); + return new TextDecoder('utf-8', { fatal: true }).decode(bytes); + } + + function createSecretRef(projectId, name, version) { + const payload = JSON.stringify({ projectId, name, version }); + const result = `${SECRET_REF_PREFIX}${encodeBase64UrlUtf8(payload)}`; + if (result.length > 512) throw new TypeError('SecretRef 过长。'); + return result; + } + + function parseSecretRef(value) { + if ( + typeof value !== 'string' || + value.length > 512 || + !value.startsWith(SECRET_REF_PREFIX) + ) { + throw new TypeError('SecretRef 无效。'); + } + const encoded = value.slice(SECRET_REF_PREFIX.length); + const parsed = JSON.parse(decodeBase64UrlUtf8(encoded)); + if ( + !parsed || + typeof parsed !== 'object' || + Array.isArray(parsed) || + Object.keys(parsed).sort().join(',') !== 'name,projectId,version' || + typeof parsed.projectId !== 'string' || + typeof parsed.name !== 'string' || + !Number.isSafeInteger(parsed.version) || + parsed.version < 1 || + createSecretRef(parsed.projectId, parsed.name, parsed.version) !== value + ) { + throw new TypeError('SecretRef 无效。'); + } + return Object.freeze(parsed); + } + + function isValidSecretName(value) { + return ( + typeof value === 'string' && + value.length > 0 && + new TextEncoder().encode(value).length <= 128 && + !/[\u0000-\u001f\u007f]/u.test(value) + ); + } + + function secretBindingsFromTask(task) { + const environment = task?.spec?.config?.environment; + if (environment === undefined) return ''; + if (!Array.isArray(environment) || environment.length > 256) { + throw new TypeError('Task 环境变量定义无效。'); + } + return environment + .filter((entry) => entry?.kind === 'secret') + .map((entry) => { + const reference = parseSecretRef(entry.secretRef); + if ( + !ENVIRONMENT_NAME_PATTERN.test(entry.name) || + entry.name.startsWith('QL3_') || + reference.projectId !== state.project || + !TASK_PATTERN.test(reference.name) + ) { + throw new TypeError( + '当前 Task 包含 Console 无法安全编辑的 Secret 绑定。', + ); + } + return `${entry.name}=${reference.name}@${reference.version}`; + }) + .join('\n'); + } + + function parseSecretBindings(value) { + const entries = []; + const names = new Set(); + const lines = value + .split(/\r?\n/u) + .map((entry) => entry.trim()) + .filter(Boolean); + if (lines.length > 256) throw new TypeError('Secret 绑定不能超过 256 条。'); + for (const line of lines) { + const match = + /^([A-Za-z_][A-Za-z0-9_]*)=([A-Za-z0-9][A-Za-z0-9._:-]{0,127})(?:@([1-9][0-9]{0,9}))?$/u.exec( + line, + ); + if (!match || match[1].startsWith('QL3_') || names.has(match[1])) { + throw new TypeError(`Secret 绑定无效:${line}`); + } + const available = state.secretCatalog.find( + (secret) => secret.name === match[2], + ); + const version = match[3] ? Number(match[3]) : available?.currentVersion; + if ( + !available || + !Number.isSafeInteger(version) || + version < 1 || + version > available.currentVersion + ) { + throw new TypeError(`找不到 Secret 当前版本:${match[2]}`); + } + names.add(match[1]); + entries.push( + Object.freeze({ + name: match[1], + kind: 'secret', + secretRef: createSecretRef(state.project, match[2], version), + }), + ); + } + return Object.freeze(entries); + } + async function api(path, options = {}) { if (!state.token) { throw new ConsoleRequestError('authentication_required', 401, null); @@ -393,11 +545,17 @@ nodes.taskEditorIntro.textContent = editing ? `完整定义已由本机证明读取,并绑定 revision ${snapshot.task.revision}。保存时还会生成一份只绑定新内容的证明。` : '定义会先绑定到一次本机证明,再以同一事务写入 Task revision 与安全审计。'; + const secretHint = + state.secretCatalog.length === 0 + ? '当前没有可绑定 Secret;可先到“凭据”创建。' + : `当前可绑定:${state.secretCatalog + .map((secret) => `${secret.name}@${secret.currentVersion}`) + .join('、')}`; nodes.taskEditorNote.textContent = editing ? `编辑租约将在 ${formatTime( snapshot.authoring.expiresAtMs, - )} 失效;关闭后重新选择“编辑任务”可取得新快照。` - : 'Alpha 当前从 Console 创建 qinglong/command@v1;高级 Task schema 仍使用受信任管理入口。'; + )} 失效。${secretHint}` + : `Console 创建 qinglong/command@v1。${secretHint}`; nodes.taskEnabledLabel.textContent = editing ? '保存后允许运行' : '创建后允许运行'; @@ -411,9 +569,11 @@ nodes.taskDescription.value = snapshot.task.description || ''; nodes.taskCommand.value = command.file; nodes.taskArgs.value = command.args.join('\n'); + nodes.taskSecretBindings.value = secretBindingsFromTask(snapshot.task); nodes.taskEnabled.checked = snapshot.task.enabled; } else { nodes.taskCommand.value = '/bin/echo'; + nodes.taskSecretBindings.value = ''; nodes.taskEnabled.checked = true; } nodes.taskEditor.returnValue = ''; @@ -462,6 +622,9 @@ .split(/\r?\n/u) .map((value) => value.trim()) .filter((value) => value.length > 0); + const secretEnvironment = parseSecretBindings( + nodes.taskSecretBindings.value, + ); if (!TASK_PATTERN.test(taskId)) { throw new TypeError('Task ID 格式无效。'); } @@ -469,11 +632,24 @@ throw new TypeError('名称、命令或参数数量无效。'); } const snapshot = state.authoringSnapshot; + const publicEnvironment = snapshot + ? (snapshot.task.spec.config.environment || []).filter( + (entry) => entry?.kind === 'public', + ) + : []; + const environment = Object.freeze([ + ...publicEnvironment, + ...secretEnvironment, + ]); + if (environment.length > 256) { + throw new TypeError('环境变量总数不能超过 256 条。'); + } const spec = snapshot ? Object.freeze({ ...snapshot.task.spec, config: Object.freeze({ ...snapshot.task.spec.config, + environment, command: Object.freeze({ ...snapshot.task.spec.config.command, kind: 'argv', @@ -486,6 +662,7 @@ schema: 'qinglong/command@v1', config: Object.freeze({ command: Object.freeze({ kind: 'argv', file, args }), + environment, }), }); return Object.freeze({ @@ -622,6 +799,205 @@ } } + function normalizeSecretMetadata(value) { + const secrets = Array.isArray(value?.secrets) ? value.secrets : null; + if ( + !secrets || + secrets.length > 64 || + secrets.some((secret) => { + if ( + !secret || + !isValidSecretName(secret.name) || + !Number.isSafeInteger(secret.currentVersion) || + secret.currentVersion < 1 || + !Number.isSafeInteger(secret.createdAtMs) || + secret.createdAtMs < 0 + ) { + return true; + } + const reference = parseSecretRef(secret.secretRef); + return ( + reference.projectId !== state.project || + reference.name !== secret.name || + reference.version !== secret.currentVersion + ); + }) + ) { + throw new ConsoleRequestError('response_unavailable', 503, null); + } + return Object.freeze(secrets.map((secret) => Object.freeze(secret))); + } + + async function loadSecretCatalog() { + const value = await api( + `/api/v3/projects/${state.project}/secrets?limit=64`, + ); + state.secretCatalog = normalizeSecretMetadata(value); + return Object.freeze({ + secrets: state.secretCatalog, + truncated: value.truncated === true, + }); + } + + function openSecretEditor(snapshot = null) { + state.secretSnapshot = snapshot; + nodes.secretEditorForm.reset(); + const rotating = snapshot !== null; + nodes.secretEditorTitle.textContent = rotating + ? '轮换加密凭据' + : '创建加密凭据'; + nodes.secretEditorIntro.textContent = rotating + ? `新值将写入 ${snapshot.name} 的 version ${ + snapshot.currentVersion + 1 + };已有 Task 仍固定使用旧版本。` + : '明文只在当前页面内存和本次 loopback 请求中短暂存在;服务端只持久化 AES-256-GCM 密文。'; + nodes.secretEditorNote.textContent = rotating + ? '轮换不会悄悄改变现有自动化;请编辑 Task 明确切换到新版本。' + : '保存需要一次性本机证明;API、审计、Console 与日志都不会返回明文。'; + nodes.secretName.readOnly = rotating; + if (rotating) { + nodes.secretName.setAttribute('aria-readonly', 'true'); + nodes.secretName.value = snapshot.name; + } else { + nodes.secretName.removeAttribute('aria-readonly'); + } + nodes.secretValue.value = ''; + nodes.secretEditor.returnValue = ''; + nodes.secretEditor.showModal(); + (rotating ? nodes.secretValue : nodes.secretName).focus(); + } + + function secretDraft() { + const name = nodes.secretName.value.trim(); + const plaintext = nodes.secretValue.value; + nodes.secretValue.value = ''; + if (!TASK_PATTERN.test(name)) { + throw new TypeError('Secret 名称格式无效。'); + } + if (!plaintext || new TextEncoder().encode(plaintext).length > 16 * 1024) { + throw new TypeError('Secret 新值必须为 1–16384 bytes。'); + } + return Object.freeze({ + name, + plaintext, + mutationId: newMutationId(), + expectedCurrentVersion: state.secretSnapshot?.currentVersion || 0, + }); + } + + async function saveSecretDraft() { + let body; + try { + body = secretDraft(); + } catch (error) { + showToast( + error instanceof Error ? error.message : 'Secret 定义无效。', + 'error', + ); + return; + } + nodes.secretEditorSave.disabled = true; + try { + const value = await api(`/api/v3/projects/${state.project}/secrets`, { + method: 'PUT', + body, + acceptStatus: 428, + }); + if (value.code === 'local_presence_required') { + showPresenceChallenge({ kind: 'secret-mutation', body }, value); + return; + } + throw new ConsoleRequestError('response_unavailable', 503, null); + } catch (error) { + body = null; + showToast(describeError(error), 'error'); + } finally { + nodes.secretEditorSave.disabled = false; + } + } + + async function renderSecrets() { + const value = await loadSecretCatalog(); + if (value.secrets.length === 0) { + empty('还没有加密 Secret。创建后可在命令 Task 中绑定固定版本。'); + return; + } + const fragment = document.createDocumentFragment(); + fragment.append( + listHeader('Encrypted Secret catalog', value.secrets.length), + ); + const list = element('div', 'record-list'); + for (const secret of value.secrets) { + const button = element('button', 'record'); + button.type = 'button'; + button.dataset.identity = secret.name; + if (state.selectedId === secret.name) { + button.setAttribute('aria-current', 'true'); + } + const main = element('span'); + main.append(element('span', 'record-title', secret.name)); + main.append( + recordMeta([`version ${secret.currentVersion}`, 'AES-256-GCM']), + ); + const side = element('span', 'record-side'); + const status = element('span', 'status', '已加密'); + status.dataset.tone = 'active'; + side.append(status); + side.append( + element('span', 'record-time', formatTime(secret.createdAtMs)), + ); + button.append(main, side); + button.addEventListener('click', () => selectSecret(secret.name)); + list.append(button); + } + fragment.append(list); + if (value.truncated) { + fragment.append( + element( + 'p', + 'privacy-note', + '当前只展示前 64 条;使用 API 可继续读取下一页。', + ), + ); + } + replace(nodes.ledger, fragment); + } + + function selectSecret(name) { + state.selectedId = name; + for (const row of nodes.ledger.querySelectorAll('.record')) { + if (row.dataset.identity === name) + row.setAttribute('aria-current', 'true'); + else row.removeAttribute('aria-current'); + } + const secret = state.secretCatalog.find((entry) => entry.name === name); + if (!secret) { + detailEmpty('Secret 元数据已变化,请刷新后重试。'); + return; + } + const fragment = document.createDocumentFragment(); + fragment.append(detailHeader('Encrypted Secret', secret.name, secret.name)); + const facts = element('div', 'facts'); + facts.append( + fact('当前版本', secret.currentVersion), + fact('存储', 'AES-256-GCM 密文'), + fact('Pinned ref', shortDigest(secret.secretRef)), + fact('版本时间', formatTime(secret.createdAtMs)), + ); + fragment.append(facts); + const actions = element('div', 'detail-actions'); + actions.append(actionButton('轮换新版本', () => openSecretEditor(secret))); + fragment.append(actions); + fragment.append( + element( + 'p', + 'privacy-note', + 'Task 只绑定固定版本;轮换后必须显式编辑 Task 才会采用新值。明文永不从此接口返回。', + ), + ); + replace(nodes.detail, fragment); + } + function showPresenceChallenge(action, challenge) { if ( challenge?.code !== 'local_presence_required' || @@ -634,13 +1010,20 @@ state.pendingPresence = Object.freeze({ ...action, challenge }); const authoringRead = action.kind === 'authoring'; const triggerMutation = action.kind === 'trigger-mutation'; + const secretMutation = action.kind === 'secret-mutation'; nodes.presenceCopy.textContent = authoringRead ? '读取完整 Task 定义需要部署设备上的一次性证明。返回的编辑租约不替代保存时的新内容证明。' : triggerMutation ? '使用部署 QingLong 的系统用户读取下面的私有文件。证明只绑定这次 Trigger 与 Task revision,且只能使用一次。' + : secretMutation + ? '使用部署 QingLong 的系统用户读取下面的私有文件。证明绑定这次 Secret 内容摘要、当前版本和 User Credential,且只能使用一次。' : '使用部署 QingLong 的系统用户读取下面的私有文件。证明只绑定这次 Task 内容,且只能使用一次。'; nodes.presenceSubmit.textContent = authoringRead ? '验证并加载定义' + : secretMutation + ? action.body.expectedCurrentVersion === 0 + ? '验证并加密创建' + : '验证并轮换版本' : action.mutation.body.expectedRevision === null ? '验证并创建' : '验证并更新'; @@ -653,6 +1036,7 @@ nodes.presenceError.hidden = true; nodes.taskEditor.close(); nodes.triggerEditor.close(); + nodes.secretEditor.close(); nodes.presenceDialog.returnValue = ''; nodes.presenceDialog.showModal(); nodes.presenceProof.focus(); @@ -735,6 +1119,15 @@ state.pendingPresence = null; nodes.presenceProof.value = ''; nodes.presenceDialog.close(); + try { + await loadSecretCatalog(); + } catch { + state.secretCatalog = []; + showToast( + 'Task 已加载,但 Secret 目录暂不可用;已有绑定仍会保留。', + 'error', + ); + } openTaskEditor(snapshot); showToast('完整 Task 定义已加载;保存仍需要新的本机证明。'); return; @@ -767,6 +1160,32 @@ await selectTrigger(pending.mutation.triggerId); return; } + if (pending.kind === 'secret-mutation') { + const value = await api(`/api/v3/projects/${state.project}/secrets`, { + method: 'PUT', + body: pending.body, + presence: proof, + }); + const rotated = pending.body.expectedCurrentVersion > 0; + state.pendingPresence = null; + state.secretSnapshot = null; + nodes.secretValue.value = ''; + nodes.presenceProof.value = ''; + nodes.presenceDialog.close(); + showToast( + value.status === 'existing' + ? '已找到同一 Secret 请求。' + : rotated + ? `Secret 已轮换到 version ${value.secret.currentVersion}。` + : 'Secret 已加密创建。', + ); + state.view = 'secrets'; + state.selectedId = pending.body.name; + updateNavigation(); + await refresh(); + selectSecret(pending.body.name); + return; + } const value = await api( `/api/v3/projects/${state.project}/tasks/${pending.mutation.taskId}`, { @@ -1326,6 +1745,7 @@ if (state.view === 'tasks') { nodes.createTask.hidden = false; nodes.createTrigger.hidden = true; + nodes.createSecret.hidden = true; nodes.kicker.textContent = 'Project task authority'; nodes.title.textContent = '任务调度台'; nodes.description.textContent = @@ -1333,13 +1753,23 @@ } else if (state.view === 'triggers') { nodes.createTask.hidden = true; nodes.createTrigger.hidden = false; + nodes.createSecret.hidden = true; nodes.kicker.textContent = 'Durable cron authority'; nodes.title.textContent = '定时触发器'; nodes.description.textContent = '配置内置 cron Trigger,绑定 Task 当前 revision;停用只追加历史,不删除证据。'; + } else if (state.view === 'secrets') { + nodes.createTask.hidden = true; + nodes.createTrigger.hidden = true; + nodes.createSecret.hidden = false; + nodes.kicker.textContent = 'Encrypted local custody'; + nodes.title.textContent = 'Secret 凭据库'; + nodes.description.textContent = + '只展示名称和当前版本;明文经本机证明后加密保存,Task 显式绑定固定版本。'; } else { nodes.createTask.hidden = true; nodes.createTrigger.hidden = true; + nodes.createSecret.hidden = true; nodes.kicker.textContent = 'Durable run evidence'; nodes.title.textContent = '运行事实账本'; nodes.description.textContent = @@ -1353,6 +1783,7 @@ try { if (state.view === 'tasks') await renderTasks(); else if (state.view === 'triggers') await renderTriggers(); + else if (state.view === 'secrets') await renderSecrets(); else await renderRuns(); setConnection('connected', `${state.project} · 已连接`); } catch (error) { @@ -1387,8 +1818,11 @@ state.pendingPresence = null; state.authoringSnapshot = null; state.triggerSnapshot = null; + state.secretSnapshot = null; + state.secretCatalog = []; if (nodes.taskEditor.open) nodes.taskEditor.close(); if (nodes.triggerEditor.open) nodes.triggerEditor.close(); + if (nodes.secretEditor.open) nodes.secretEditor.close(); if (nodes.presenceDialog.open) nodes.presenceDialog.close(); nodes.token.value = ''; nodes.token.disabled = false; @@ -1399,6 +1833,7 @@ nodes.refresh.hidden = true; nodes.createTask.hidden = true; nodes.createTrigger.hidden = true; + nodes.createSecret.hidden = true; setConnection('idle', '等待凭据'); nodes.kicker.textContent = 'Connection gate'; nodes.title.textContent = '先建立一条本机连接'; @@ -1435,8 +1870,16 @@ nodes.disconnect.addEventListener('click', disconnect); nodes.refresh.addEventListener('click', refresh); - nodes.createTask.addEventListener('click', () => openTaskEditor()); + nodes.createTask.addEventListener('click', async () => { + try { + await loadSecretCatalog(); + openTaskEditor(); + } catch (error) { + showToast(describeError(error), 'error'); + } + }); nodes.createTrigger.addEventListener('click', () => openTriggerEditor()); + nodes.createSecret.addEventListener('click', () => openSecretEditor()); nodes.taskEditorClose.addEventListener('click', () => { state.authoringSnapshot = null; nodes.taskEditor.close(); @@ -1453,10 +1896,21 @@ event.preventDefault(); await saveTriggerDraft(); }); + nodes.secretEditorClose.addEventListener('click', () => { + state.secretSnapshot = null; + nodes.secretValue.value = ''; + nodes.secretEditor.close(); + }); + nodes.secretEditorForm.addEventListener('submit', async (event) => { + event.preventDefault(); + await saveSecretDraft(); + }); nodes.presenceCancel.addEventListener('click', () => { state.pendingPresence = null; state.authoringSnapshot = null; state.triggerSnapshot = null; + state.secretSnapshot = null; + nodes.secretValue.value = ''; nodes.presenceProof.value = ''; nodes.presenceDialog.close(); }); @@ -1495,6 +1949,7 @@ nodes.dialog.open || nodes.taskEditor.open || nodes.triggerEditor.open || + nodes.secretEditor.open || nodes.presenceDialog.open ) { return; @@ -1504,6 +1959,8 @@ ? 'tasks' : event.key.toLowerCase() === 's' ? 'triggers' + : event.key.toLowerCase() === 'k' + ? 'secrets' : event.key.toLowerCase() === 'r' ? 'runs' : null; diff --git a/packages/ql3-local-api/assets/console/index.html b/packages/ql3-local-api/assets/console/index.html index 0ef17b4a..b15ac1f3 100644 --- a/packages/ql3-local-api/assets/console/index.html +++ b/packages/ql3-local-api/assets/console/index.html @@ -70,6 +70,9 @@ + @@ -97,6 +100,9 @@ + @@ -175,6 +181,10 @@ 参数 · 每行一个 +