增加新的规则

This commit is contained in:
sunjunzhao committed 2026-10-26 00:21:10 +08:00
1 parent bb054db0ea
commit d05ada27f5
9 files changed
+370 -18

No files matched your search

+12 -11
View File
@@ -1,6 +1,6 @@
--- ---
name: app-dev name: app-dev
description: 在独立仓库按 TickShield(刻盾云)平台契约开发、审查、发布生态应用(micro/service):listingKey 四位一体、app.manifest.json、tickshield-node-runtime 基础镜像、@aigrego/guard 四道闸与 tenant_id 租户隔离、micro 前端 portal-sdk 宿主桥与平台同款 UI 皮肤(--sk-* 令牌 + ts-skin 换肤跟随)、release-cli 发布与商店上架部署。凡用户要求「开发一个平台应用 / 新建 app / 写个 micro 应用 / 做个像 secret-manager / demo-app 那样的应用 / 在门户里加个自己的应用 / 接入 TickShield / 上架应用到商店 / 审查这个 app 能不能部署到平台 / 帮我看看接入还缺什么 / 审一下这个应用的 UI / 界面跟平台不一致 / 换肤不跟随 / 深色模式瞎了 / 配色不统一 / 把界面改成平台那样」,或带着 注册不上去 / 401 missing app credentials / 心跳 404 / host must equal listingKey / 四道闸 403 / 跨租户数据串了 / image registry not registered / invalid manifest skipped / 应用中心导入失败 / 应用装上了但 /svc 不通 / PROD 白屏 CSP 报错 / 切到暗夜皮肤应用还是白底 / 青瓷皮肤下按钮还是蓝色 / 应用跟平台长得不一样 等症状,或提到 listingKey / app.manifest.json / 四道闸 / TDT / tenant_id / portal-sdk / release-cli / tickshield-node-runtime / --sk- 令牌 / ts-skin / 皮肤 时,务必使用本 skill——即使用户没说「应用接入」四个字。Use whenever the user develops, scaffolds, audits, reviews (including UI/skin consistency), onboards, publishes or deploys a TickShield platform app (micro or service) from an independent repository. 边界:非平台场景的通用 Dockerfile 选型评审用 runtime-image;通用 Gitea Actions 工作流规范用 gitea-actions;CI 工作流的发布 job 改造/新建发布工作流用 app-publish;git 提交推送与日报用 push-and-report;平台基座本身(platform/gateway/packages/* 控制面)的开发与平台设计系统(DESIGN.md / skins/index.ts)的修改不归本 skill。 description: 在独立仓库按 TickShield(刻盾云)平台契约开发、审查、发布生态应用(micro/service):listingKey 四位一体、app.manifest.json、tickshield-node-runtime 基础镜像、@aigrego/guard 四道闸与 tenant_id 租户隔离、micro 前端 portal-sdk 宿主桥与平台同款 UI 皮肤(--sk-* 令牌 + ts-skin 换肤跟随)、release-cli 发布与商店上架部署。凡用户要求「开发一个平台应用 / 新建 app / 写个 micro 应用 / 做个像 secret-manager / demo-app 那样的应用 / 在门户里加个自己的应用 / 接入 TickShield / 上架应用到商店 / 审查这个 app 能不能部署到平台 / 帮我看看接入还缺什么 / 审一下这个应用的 UI / 界面跟平台不一致 / 换肤不跟随 / 深色模式瞎了 / 配色不统一 / 把界面改成平台那样」,或带着 注册不上去 / 401 missing app credentials / 心跳 404 / host must equal listingKey / 四道闸 403 / 跨租户数据串了 / image registry not registered / invalid manifest skipped / 应用中心导入失败 / 应用装上了但 /svc 不通 / PROD 白屏 CSP 报错 / 切到暗夜皮肤应用还是白底 / 青瓷皮肤下按钮还是蓝色 / 应用跟平台长得不一样 等症状,或提到 应用是否合规 / 能不能发布 / 体检 / 自检 / dist 超 64MB / 大产物 / 外置静态资源 / connect-src / CORS / wasm 核心 / listingKey / app.manifest.json / 四道闸 / TDT / tenant_id / portal-sdk / release-cli / tickshield-node-runtime / --sk- 令牌 / ts-skin / 皮肤 时,务必使用本 skill——即使用户没说「应用接入」四个字。Use whenever the user develops, scaffolds, audits, reviews (including UI/skin consistency), onboards, publishes or deploys a TickShield platform app (micro or service) from an independent repository. 边界:非平台场景的通用 Dockerfile 选型评审用 runtime-image;通用 Gitea Actions 工作流规范用 gitea-actions;CI 工作流的发布 job 改造/新建发布工作流用 app-publish;git 提交推送与日报用 push-and-report;平台基座本身(platform/gateway/packages/* 控制面)的开发与平台设计系统(DESIGN.md / skins/index.ts)的修改不归本 skill。
--- ---
# app-dev — TickShield 生态应用开发规范(独立仓库 → 可直接部署上架) # app-dev — TickShield 生态应用开发规范(独立仓库 → 可直接部署上架)
@@ -12,20 +12,20 @@ TickShield 生态应用的交付物只有两样:**镜像 + `app.manifest.json`
micro 应用还有第三条硬要求:**UI 与平台基座同一套视觉,且跟随皮肤切换**(light / dark / jade)。基准见 `references/ui-style.md`,令牌由 `scripts/sync-skin-tokens.sh` 从平台 `skins/index.ts` 生成,跟随机制是 `localStorage['ts-skin']` + `storage` 事件。这不是"美化建议",是接入审核的 H 节(写死色值 / 没有皮肤桥 = die)。 micro 应用还有第三条硬要求:**UI 与平台基座同一套视觉,且跟随皮肤切换**(light / dark / jade)。基准见 `references/ui-style.md`,令牌由 `scripts/sync-skin-tokens.sh` 从平台 `skins/index.ts` 生成,跟随机制是 `localStorage['ts-skin']` + `storage` 事件。这不是"美化建议",是接入审核的 H 节(写死色值 / 没有皮肤桥 = die)。
契约锚点:本 skill 基线 = TickShield 平台 v4.0 + APPSTORE-RUNTIME + FR-101 registries 白名单 + FR-102/TKT-557 导航与图标规范(2026-10)+ 平台设计系统 DESIGN.md / skins/index.ts(2026-10-22 快照)。与平台冲突时,以平台仓 `docs/reference/应用开发与上架指引.md`、`apps/portal/internal/modules/registry/manifest.go` 的校验代码、`apps/web/src/skins/index.ts` 的令牌定义为准,并回本仓库更新本 skill。`docs/reference/App出仓与集成指引.md`、`App开发者指南.html` 是**已废弃的历史文档**(@xgent 旧体系),禁止照搬。 契约锚点:本 skill 基线 = TickShield 平台 v4.0 + APPSTORE-RUNTIME + FR-101 registries 白名单 + FR-102/TKT-557 导航与图标规范(2026-10)+ 大产物外置 / per-app CSP(2026-10,embedCsp.connectSrc)+ 平台设计系统 DESIGN.md / skins/index.ts(2026-10-22 快照)。与平台冲突时,以平台仓 `docs/reference/应用开发与上架指引.md`、`apps/platform/internal/modules/registry/manifest.go`(校验)、`apps/platform/internal/modules/registry/csp.go` 与 `apps/platform/cmd/platform/main.go`(CSP 下发)、`apps/web/src/skins/index.ts`(令牌定义)为准,并回本仓库更新本 skill。`docs/reference/App出仓与集成指引.md`、`App开发者指南.html` 是**已废弃的历史文档**(@xgent 旧体系),禁止照搬。
## 总流程 ## 总流程
``` ```
第 0 步 备料分流(判模式 + 采集四要素) 第 0 步 备料分流(判模式 + 采集四要素)
新建:第 1 步 生成骨架(含 UI 皮肤层)→ 第 2 步 静态自检(scripts/check-app.sh) 新建:第 1 步 生成骨架(含 UI 皮肤层 + 仓库 AGENTS.md)→ 第 2 步 静态自检(scripts/check-app.sh)
审查:第 1 步 逐项审计(checklist.md A–H)→ 第 2 步 确认后修正 + 静态自检 审查:第 1 步 逐项审计(checklist.md A–I)→ 第 2 步 确认后修正 + 静态自检
汇合:第 3 步 联调验证(需 dev 栈,含换肤跟随)→ 第 4 步 发布指引 → 第 5 步 汇报 汇合:第 3 步 联调验证(需 dev 栈,含换肤跟随)→ 第 4 步 发布指引 → 第 5 步 汇报
``` ```
除非用户明确只要聊聊思路,完整走全部 5 步。 除非用户明确只要聊聊思路,完整走全部 5 步。
**调用形态分流(硬规则)**:裸调用(无任务输入)= **只读审计态**——对 app 做**全量审核(含 UI)**,只出审计报告与修复/新建方案,**永不主动改文件、永不执行任何写操作**;报告与方案交付即停,等用户确认后才进入对应模式的执行段。空仓库裸调用同理:只输出「接入差距评估 + 新建方案」供确认,不直接生成骨架。带任务输入(如「帮我开发 X 应用」「按报告修」「把界面改成平台那样」)= 任务即授权对应动作,按流程走;但发布等写平台操作仍需显式确认(见「人机权责」)。 **调用形态分流(硬规则)**:裸调用(无任务输入)= **只读审计态**——**第一步先执行 `bash <本skill目录>/scripts/check-app.sh .`**(不需要用户写任何任务提示词),把输出的 `RESULT: PASS|FAIL` 与 die/warn 明细作为报告第一段;再对照 `references/checklist.md`(A–I)做全量审核(含 UI),只出审计报告与修复/新建方案,**永不主动改文件、永不执行任何写操作**;报告与方案交付即停,等用户确认后才进入对应模式的执行段。空仓库裸调用同理:只输出「接入差距评估 + 新建方案」供确认,不直接生成骨架。带任务输入(如「帮我开发 X 应用」「按报告修」「把界面改成平台那样」)= 任务即授权对应动作,按流程走;但发布等写平台操作仍需显式确认(见「人机权责」)。
**UI 审核不单独开关**:裸调用就是审核,审的就是「接入契约 + UI 一致性」两件事,不给 `--ui` 之类的子命令——用户说「/app-dev」而没带任何要求时,默认动作是审,不是改。 **UI 审核不单独开关**:裸调用就是审核,审的就是「接入契约 + UI 一致性」两件事,不给 `--ui` 之类的子命令——用户说「/app-dev」而没带任何要求时,默认动作是审,不是改。
@@ -60,6 +60,7 @@ micro 应用还有第三条硬要求:**UI 与平台基座同一套视觉,且
- `web/app.css` 是外置样式(PROD CSP 下不靠内联 style),颜色全部 `var(--sk-*)`;构建脚本要把它拷进 `dist/`。 - `web/app.css` 是外置样式(PROD CSP 下不靠内联 style),颜色全部 `var(--sk-*)`;构建脚本要把它拷进 `dist/`。
- `.gitea/workflows/release.yaml`(照 `gitea-app-release.yaml`,提示配 secrets) - `.gitea/workflows/release.yaml`(照 `gitea-app-release.yaml`,提示配 secrets)
- `docs/PRD_app-<listingKey>.md` 草稿(照 `PRD_app-template.md`,文档状态标「草稿」) - `docs/PRD_app-<listingKey>.md` 草稿(照 `PRD_app-template.md`,文档状态标「草稿」)
- `AGENTS.md`(照 `references/templates/AGENTS.md`:仓库根硬规则,让后续任何 Agent 在不写任务提示词时也先跑 `check-app.sh`;已有 AGENTS.md 时只在末尾追加带标记的「TickShield 接入自检」段,不覆盖原文)
- `.aigrego-registry.env.example` + 在 `.gitignore` 追加 `.aigrego-registry.env` - `.aigrego-registry.env.example` + 在 `.gitignore` 追加 `.aigrego-registry.env`
生成后逐文件说明「这个文件哪几处必须改、哪几处永远别动」;UI 层额外说明一句:**颜色改 class 不改色值,皮肤跟随不要自己实现**(照 `src/skin/apply.ts` 用)。 生成后逐文件说明「这个文件哪几处必须改、哪几处永远别动」;UI 层额外说明一句:**颜色改 class 不改色值,皮肤跟随不要自己实现**(照 `src/skin/apply.ts` 用)。
@@ -68,13 +69,13 @@ micro 应用还有第三条硬要求:**UI 与平台基座同一套视觉,且
## 第 1~2 步(审查):逐项审计 → 修正 ## 第 1~2 步(审查):逐项审计 → 修正
1. 读 `references/checklist.md`,对照逐项审计,输出三档结论表:**通过 / 不符(die 级)/ 风险(warn 级)**,每项给证据(文件:行号)。 1. 读 `references/checklist.md`,对照逐项审计,输出三档结论表:**通过 / 不符(die 级)/ 风险(warn 级)**,每项给证据(文件:行号)。报告固定含一项「自检入口」:仓库根 `AGENTS.md` 是否写死「开工前先跑 `check-app.sh`」(缺失 = warn,用户确认后照 `references/templates/AGENTS.md` 补,这是「不写任务提示词也能体检」的落点)。
2. **UI 审核(H 节)单独成段**:先读 `references/ui-style.md` 建立基准,再按 H1–H12 审。要点是"换肤能不能跟随",不是"好不好看"——判据永远是「写死色值了吗 / 有皮肤桥吗 / 首帧前落令牌了吗 / 三套皮肤实机切过吗」。H11 需要 dev 栈实机切皮肤,跑不了就写「未验证」,不许拿静态检查绿冒充。 2. **UI 审核(H 节)单独成段**:先读 `references/ui-style.md` 建立基准,再按 H1–H12 审。要点是"换肤能不能跟随",不是"好不好看"——判据永远是「写死色值了吗 / 有皮肤桥吗 / 首帧前落令牌了吗 / 三套皮肤实机切过吗」。H11 需要 dev 栈实机切皮肤,跑不了就写「未验证」,不许拿静态检查绿冒充。
3. 修正清单列给用户,**经确认后**逐项修;修完跑静态自检。 3. 修正清单列给用户,**经确认后**逐项修;修完跑静态自检。
**裸调用到此为止**:审计报告 + 修复方案就是交付物,不执行任何修正;用户回复确认(如「按方案修」)后才进入第 2 步修正。裸调用的报告必须同时含「接入契约结论」与「UI 一致性结论」两段,缺一段等于没审。 **裸调用到此为止**:审计报告 + 修复方案就是交付物,不执行任何修正;用户回复确认(如「按方案修」)后才进入第 2 步修正。裸调用的报告必须同时含「接入契约结论」与「UI 一致性结论」两段,缺一段等于没审。
→ 验收:审计表覆盖 checklist 全部 die 级项(A–H 全覆盖);修正经用户逐批确认。 → 验收:审计表覆盖 checklist 全部 die 级项(A–I 全覆盖);修正经用户逐批确认。
## 第 2 步:静态自检 ## 第 2 步:静态自检
@@ -82,7 +83,7 @@ micro 应用还有第三条硬要求:**UI 与平台基座同一套视觉,且
bash <本skill目录>/scripts/check-app.sh <仓库根> bash <本skill目录>/scripts/check-app.sh <仓库根>
``` ```
die 级不过 → 修了再往下走;warn 级列进汇报。check-app.sh 只查静态契约(manifest 字段/Dockerfile 三要素/入口接线/保留路径/密钥卫生/**UI 令牌与皮肤桥**),不替代第 3 步真环境联调。UI 部分它只能证明"没写死色值、桥接代码在",**不能证明换肤真的跟随**——那一步必须实机切。 die 级不过 → 修了再往下走;warn 级列进汇报。**末尾有一行机器可读结论 `RESULT: PASS|FAIL`**(CI/Agent 直接引用它),并附 `die=N warn=N`。check-app.sh 覆盖 checklist A–I 的 `[S]` 项:manifest 字段(含 `embedCsp` 结构与去重)/Dockerfile 三要素/入口接线/保留路径/密钥卫生/**UI 令牌与皮肤桥**/**大产物体积与外置加载方式**;不替代第 3 步真环境联调。UI 部分它只能证明"没写死色值、桥接代码在",**不能证明换肤真的跟随**——那一步必须实机切。
→ 验收:脚本 exit 0(或仅剩 warn 且已列清单)。 → 验收:脚本 exit 0(或仅剩 warn 且已列清单)。
@@ -104,7 +105,6 @@ die 级不过 → 修了再往下走;warn 级列进汇报。check-app.sh 只
## 第 4 步:发布指引 ## 第 4 步:发布指引
发布是**写操作**:默认只给命令草稿,用户显式说「发布」才执行。详见 `references/publish-deploy.md`: 发布是**写操作**:默认只给命令草稿,用户显式说「发布」才执行。详见 `references/publish-deploy.md`:
```bash ```bash
export AIGREGO_PORTAL_URL=<平台地址> APP_KEY=<listingKey> APP_SECRET=<env注入,不落盘> export AIGREGO_PORTAL_URL=<平台地址> APP_KEY=<listingKey> APP_SECRET=<env注入,不落盘>
aigrego-release whoami # 先验凭证(放在构建之前) aigrego-release whoami # 先验凭证(放在构建之前)
@@ -113,7 +113,8 @@ aigrego-release publish --manifest app.manifest.json [--image <ref>] [--store] [
``` ```
- **manifest 进平台是双通道、同一份文件**:CLI `publish --manifest`(上)或平台侧 console「应用中心」按向导导入——同一条 submit 管道(同一套校验、治理字段变更同样 202 进审核队列)。不管走哪条,仓库里的 `app.manifest.json` 都必须最新且合法;走手动通道时把这份文件交付给平台管理员。 - **manifest 进平台是双通道、同一份文件**:CLI `publish --manifest`(上)或平台侧 console「应用中心」按向导导入——同一条 submit 管道(同一套校验、治理字段变更同样 202 进审核队列)。不管走哪条,仓库里的 `app.manifest.json` 都必须最新且合法;走手动通道时把这份文件交付给平台管理员。
- 治理字段(`scopes`/`aclManifest`/`exchangeTargets`/`dependencies`)变更 → 202 进审核队列(exit 0 但**不生效**),批准前不影响已安装租户;首个版本直接生效。 - **大产物分流(先量再发)**:dist tgz ≤64 MiB → 照旧 `publish --dist`;超限(已压缩 wasm/`.data` 几乎压不动)→ 核心放对象存储 `apps/<key>/<version>/`、dist 只留壳、manifest 声明 `embedCsp.connectSrc`。只放开 `connect-src`(fetch/XHR/WebSocket/wasm 流式加载);Worker/动态 import/外链 script/样式/字体**不行**。桶必须配 CORS(含 `Allow-Headers: Range`)。细节见 `references/contract.md §7.1`。
- 治理字段(`scopes`/`aclManifest`/`exchangeTargets`/`dependencies`/`mounts`/`embedCsp`)变更 → 202 进审核队列(exit 0 但**不生效**),批准前不影响已安装租户;首个版本直接生效。
- 商店上架/授权/安装/启停/卸载是平台侧 console 动作;镜像 host 必须在 registries 白名单(空表=全拒)。 - 商店上架/授权/安装/启停/卸载是平台侧 console 动作;镜像 host 必须在 registries 白名单(空表=全拒)。
- 输出「待平台侧操作」清单:auto_publish 开关、registries 登记、治理审核批准、授权安装——这些 Agent 做不到,不绕道不假装完成。 - 输出「待平台侧操作」清单:auto_publish 开关、registries 登记、治理审核批准、授权安装——这些 Agent 做不到,不绕道不假装完成。
@@ -217,7 +218,7 @@ die 消息统一三段式:是什么 + 为什么(后果) + 怎么办。
|---|---| |---|---|
| `references/contract.md` | 需要契约细节时:manifest 字段全集/注册心跳报文/四道闸 API/tdb 行为/令牌与交换/部署形态 | | `references/contract.md` | 需要契约细节时:manifest 字段全集/注册心跳报文/四道闸 API/tdb 行为/令牌与交换/部署形态 |
| `references/publish-deploy.md` | 第 4 步发布时:release-cli 全参数/商店链路/quickstart catalog 物料/验收矩阵 | | `references/publish-deploy.md` | 第 4 步发布时:release-cli 全参数/商店链路/quickstart catalog 物料/验收矩阵 |
| `references/checklist.md` | 审查模式必读:逐项审计清单 A–H(die/warn 分级 + 验证方法) | | `references/checklist.md` | 审查模式必读:逐项审计清单 A–I(die/warn 分级 + 验证方法) |
| `references/ui-style.md` | 写 micro 前端或审 UI 时必读:令牌表/视觉刻度/皮肤桥机制/组件基线/换肤验证步骤 | | `references/ui-style.md` | 写 micro 前端或审 UI 时必读:令牌表/视觉刻度/皮肤桥机制/组件基线/换肤验证步骤 |
| `references/troubleshooting.md` | 带症状来时:排障对照全表 | | `references/troubleshooting.md` | 带症状来时:排障对照全表 |
| `references/templates/` | 新建模式必读:全部可复制模板(`web/src/skin/` 与 `web/app.css` 是 UI 层) | | `references/templates/` | 新建模式必读:全部可复制模板(`web/src/skin/` 与 `web/app.css` 是 UI 层) |
+17
View File
@@ -16,6 +16,7 @@
| A8 | `navItems`/`footerNavItems` 每项含 `id/label/path`;`icon` 为 emoji/字符或 lucide 名(写错名静默兜底成字符,须眼见为实);`aclManifest.pages` 含 `key/label`,`audience` ∈ admin/viewer/all | die `[S]` | 对照契约表 §2.1 | | A8 | `navItems`/`footerNavItems` 每项含 `id/label/path`;`icon` 为 emoji/字符或 lucide 名(写错名静默兜底成字符,须眼见为实);`aclManifest.pages` 含 `key/label`,`audience` ∈ admin/viewer/all | die `[S]` | 对照契约表 §2.1 |
| A9 | `color` 为 `#RRGGBB`;`icon`≤64 / `cat`≤64 / `tagline`≤120 / `desc`≤2000 | warn `[S]` | 长度/格式 | | A9 | `color` 为 `#RRGGBB`;`icon`≤64 / `cat`≤64 / `tagline`≤120 / `desc`≤2000 | warn `[S]` | 长度/格式 |
| A10 | 治理字段变更知悉审核语义(202 进队列,批准前不生效) | log | 口头/文档确认 | | A10 | 治理字段变更知悉审核语义(202 进队列,批准前不生效) | log | 口头/文档确认 |
| A11 | 仓库根 `AGENTS.md` 含「开工前先跑 check-app.sh」硬规则(模板 `references/templates/AGENTS.md`)——没有它,Agent 只能靠用户每次明说才会体检 | warn `[S]` | grep AGENTS.md |
## B. 镜像与入口 ## B. 镜像与入口
@@ -104,3 +105,19 @@
| H10 | 无 `!important`(`prefers-reduced-motion` 兜底除外) | warn `[S]` | grep | | H10 | 无 `!important`(`prefers-reduced-motion` 兜底除外) | warn `[S]` | grep |
| H11 | 换肤实机验证:三套皮肤各切一次,应用当场跟随(强调色随皮肤变,青瓷下不能还是蓝色),刷新后保持 | die(上线前) | 截图留档 | | H11 | 换肤实机验证:三套皮肤各切一次,应用当场跟随(强调色随皮肤变,青瓷下不能还是蓝色),刷新后保持 | die(上线前) | 截图留档 |
| H12 | 无障碍底线:正文/状态文字对比度 ≥ 4.5:1;`prefers-reduced-motion` 有降级 | warn | 抽查三套皮肤 | | H12 | 无障碍底线:正文/状态文字对比度 ≥ 4.5:1;`prefers-reduced-motion` 有降级 | warn | 抽查三套皮肤 |
## I. 大产物与静态资源外置(dist tgz > 64 MiB 或核心为已压缩大文件时)
判定顺序:先量 dist → 超限才要求外置 → 外置必须有 `embedCsp` → 再查加载方式是否被 CSP 覆盖。全部 `[S]` 项由 `scripts/check-app.sh` 自动查。
| # | 项 | 档 | 验证方法 |
|---|---|---|---|
| I1 | dist 打包成 tgz 后 ≤64 MiB(平台请求体硬上限);未压缩体积超限但实测 tgz 达标 → 仅提示 | die(tgz 超限)/ warn(压缩率依赖) | 脚本实算 `tar czf -` |
| I2 | dist 内无单文件 >8 MiB 的已压缩资源(`.wasm`/`.data`/`.br`/`.gz`/`.zip`/`.7z`/`.mp4`);有则必须外置 | warn,tgz 超限时 die | 文件大小扫描 |
| I3 | 需要外置时 manifest 必须声明 `embedCsp.connectSrc`(非空) | die | 脚本联动 I1/I2 |
| I4 | `embedCsp` 结构合法:仅 `type=micro`;≤8 条;每项 `scheme://host[:port]`(可 `*.`);无路径/查询/裸 `*`/分号/空白/重复 | die | 正则 + 去重 |
| I5 | 声明与用点对得上:源码有外部 origin 的 `fetch`/`instantiateStreaming`,清单未声明 → warn;声明了但源码找不到使用点 → warn(死配置) | warn | grep 源码与构建产物 |
| I6 | 不使用 connect-src 不覆盖的跨域加载:`new Worker('https://…')`、`import('https://…')`、`importScripts('https://…')`、外链 `<script src>` / stylesheet | die | grep |
| I7 | 外置路径版本化(`apps/<key>/<version>/`)+ immutable 缓存;回滚时旧版本仍在桶里 | warn | 读 CI/构建脚本 |
| I8 | 桶 CORS 已配:GET/HEAD/OPTIONS、`Allow-Headers: Range`、`Expose-Headers: Content-Length,Content-Range,ETag`;匿名读自检通过 | die(上线前,人工确认) | 桶控制台 + CI 自检日志 |
| I9 | `embedCsp` 变更知悉审核语义(202 进队列,批准前不生效) | log | 阅读/口头确认 |
+16 -1
View File
@@ -159,7 +159,22 @@ logger.info('request', { method, path, status, ms }); // 每请求一行
- **ACL 前端收口**:握手 `init.acl`(admin → `bypass:true`;member → 仅 `defaultForMember` 页 PID 列表)驱动页面显隐与前端路由守卫——**只是 UX 闸,真闸是后端四道闸** - **ACL 前端收口**:握手 `init.acl`(admin → `bypass:true`;member → 仅 `defaultForMember` 页 PID 列表)驱动页面显隐与前端路由守卫——**只是 UX 闸,真闸是后端四道闸**
- i18n:只读宿主 `localStorage['ts-lang']`(zh-CN/en-US)+ 监听 storage 事件实时切换,不反写;zh-CN 字典为唯一事实来源 - i18n:只读宿主 `localStorage['ts-lang']`(zh-CN/en-US)+ 监听 storage 事件实时切换,不反写;zh-CN 字典为唯一事实来源
- 皮肤令牌 `--sk-*` 属门户自身,不辐射 iframe 内应用;micro 应用样式自管 - 皮肤令牌 `--sk-*` 属门户自身,不辐射 iframe 内应用;micro 应用样式自管
- 产物发布:`aigrego-release publish --dist <dir>`,包根必须直接含 `index.html`(≤64MB,原子替换);index.html 用**绝对路径** `/apps/<key>/...` 引用资源 - 产物发布:`aigrego-release publish --dist <dir>`,包根必须直接含 `index.html`(**tgz 请求体 ≤64 MiB**,原子替换);index.html 用**绝对路径** `/apps/<key>/...` 引用资源
### 7.1 大产物外置(dist 超 64 MiB 时)
- **硬边界**:平台 `POST /api/apps/:key/artifacts` 用 `http.MaxBytesReader` 卡 64 MiB(压缩后的 tgz),网关 `client_max_body_size` 同值;超限直接 413,改配置不解决(编译常量)。已压缩的 wasm / `.data` 核心再打 tgz 几乎不缩小。
- **分流**:dist tgz ≤64 MiB → 照旧 `publish --dist`;超限 → 核心放对象存储,dist 只留壳。
- **对象存储布局**:`<bucket>/apps/<key>/<version>/...`(版本化 + `Cache-Control: public, max-age=31536000, immutable`,旧版本保留供回滚)。
- **桶 CORS(必需)**:`Allow-Origin` 平台域(或 `*`)、`Allow-Methods: GET,HEAD,OPTIONS`、`Allow-Headers: Range`、`Expose-Headers: Content-Length,Content-Range,ETag`。带 `Range` 的 `fetch` 会触发预检,只开公共读不够。
- **manifest 声明**(仅 micro,治理字段 → 变更 202 进审核):
```json
"embedCsp": { "connectSrc": ["https://static.example.cn"] }
```
形态 `scheme://host[:port]`,可 `*.` 前缀,≤8 条,无路径/查询/裸 `*`/分号/空白。
- **CSP 语义**:平台只追加 `connect-src 'self' <origins>`,覆盖 `fetch`/XHR/WebSocket/wasm 流式加载;**不覆盖** `new Worker()`、动态 `import()`、`<script src>`、外链样式/字体——这些跨域仍会被浏览器拦(`check-app.sh` I6 会拦)。
- **生效路径**:门户 `/apps/<key>/<rest>`(PROD,platform 侧 5s 缓存)与应用子域/自定义站点(网关读 routes.lua 的 `csp` 表,2s 热载)。壳路由 `/apps/<key>` 不放宽。
- **发布**:CI 用钉版 `minio/mc` 镜像 `mc mirror` 到桶 + 匿名读自检;模板见 `references/templates/gitea-app-release.yaml` 的 `assets` job。
## 8. 令牌(TDT)与跨应用交换 ## 8. 令牌(TDT)与跨应用交换
+14 -1
View File
@@ -24,10 +24,23 @@ curl -sSL https://code.innev.cn/images/tickshield-dist/raw/branch/main/cli/aigre
`publish` 子动作: `publish` 子动作:
1. `--manifest app.manifest.json` → `POST /api/apps/submit`。治理字段有 diff ⇒ **202 进审核队列,批准前不影响已安装租户**;无 diff ⇒ 200 当场生效;**首个版本直接生效**。版本号以 manifest.version 为准(semver)。 1. `--manifest app.manifest.json` → `POST /api/apps/submit`。治理字段有 diff ⇒ **202 进审核队列,批准前不影响已安装租户**;无 diff ⇒ 200 当场生效;**首个版本直接生效**。版本号以 manifest.version 为准(semver)。
2. `--dist <dir|tgz>` → `POST /api/apps/:key/artifacts`。产物目录根的 `index.html` 必须存在(`tar czf -C <dir> .`,套一层目录会被拒);≤64MB、路径穿越/symlink 被拒、同卷 rename 原子替换;上传后 `/apps/<key>/*` 托管立即可读(2026-10 起拍平,旧 `/_/` 子路径产物必须重新构建上传)。 2. `--dist <dir|tgz>` → `POST /api/apps/:key/artifacts`。产物目录根的 `index.html` 必须存在(`tar czf -C <dir> .`,套一层目录会被拒);**tgz 请求体 ≤64 MiB**(网关同值,超过直接 413)、路径穿越/symlink 被拒、同卷 rename 原子替换;上传后 `/apps/<key>/*` 托管立即可读(2026-10 起拍平,旧 `/_/` 子路径产物必须重新构建上传)。超限走下方「大产物外置」。
3. `--image <ref>` → `POST /api/apps/:key/deploy`。只写 `deployDescriptor.image` 并滚动重启;**镜像必须先由应用方自己 push 到平台白名单 registry**;docker.sock 不可用时降级为仅记录。 3. `--image <ref>` → `POST /api/apps/:key/deploy`。只写 `deployDescriptor.image` 并滚动重启;**镜像必须先由应用方自己 push 到平台白名单 registry**;docker.sock 不可用时降级为仅记录。
4. `--store` → `POST /api/apps/:key/publish` 自行上架。**前置:平台已给该应用开 `auto_publish`**,否则 403;已上架返回 409 视为跳过。 4. `--store` → `POST /api/apps/:key/publish` 自行上架。**前置:平台已给该应用开 `auto_publish`**,否则 403;已上架返回 409 视为跳过。
### 大产物外置(dist tgz > 64 MiB)
1. dist 只保留前端壳(index.html + 它直接引用的 JS/CSS),核心资源放 `assets/` 目录(不进 dist)。
2. CI 用钉版 `minio/mc` 镜像 mirror:`apps/<key>/<version>/`,版本化路径 + immutable 缓存;旧版本保留供回滚。
3. 桶配 CORS:`GET/HEAD/OPTIONS`、`Allow-Headers: Range`、`Expose-Headers: Content-Length,Content-Range,ETag`;CI 做匿名读自检。
4. manifest 声明(仅 micro,治理字段 → 202 审核):
```json
"embedCsp": { "connectSrc": ["https://static.example.cn"] }
```
5. 页面用 `fetch('https://static.example.cn/apps/<key>/<version>/…')` 加载核心——这条走 `connect-src`,已放开。
**不要**用 `new Worker(外域 URL)` / 动态 `import(外域)` / 外链 `<script>`:这些不受 `connect-src` 覆盖,平台也未放开 `script-src`/`worker-src`。
6. 模板见 `references/templates/gitea-app-release.yaml` 的 `assets` job(未配 `TS_ASSETS_BUCKET` 时自动跳过)。
### 凭证与配置 ### 凭证与配置
复用应用服务凭证(`X-App-Key`/`X-App-Secret`),与容器自注册、`/oauth/token` 同一套,**无独立发布令牌**。来源优先级:参数 > 环境变量 > 配置文件。 复用应用服务凭证(`X-App-Key`/`X-App-Secret`),与容器自注册、`/oauth/token` 同一套,**无独立发布令牌**。来源优先级:参数 > 环境变量 > 配置文件。
@@ -0,0 +1,38 @@
# AGENTS.md — TickShield 生态应用仓库
本仓库是 TickShield(刻盾云)生态应用,交付物 = **镜像 + `app.manifest.json`**。
规范源:`.agents/skills/app-dev/SKILL.md`。未安装时先执行(项目级安装):
```bash
npx skills add gitea@livebook:images/skills.git
```
## 开工前硬规则(不需要用户写任务提示词)
任何任务开始前——包括用户只发一句空话、只说「看看」、或没提任何要求——**先执行**:
```bash
bash .agents/skills/app-dev/scripts/check-app.sh .
```
- `RESULT: PASS` → 再开始用户要求的任务。
- `RESULT: FAIL` → **先把 die 级清单和修复建议汇报给用户**,未经确认不要先做别的;不要为了让检查变绿而删文件或改契约。
- 每次改完 `app.manifest.json` / `Dockerfile` / 前端产物 / CI 后,重跑一次。
## 大产物红线(dist tgz > 64 MiB)
- `publish --dist` 的 tgz 请求体硬上限 **64 MiB**;超限直接 413,改配置不解决。已压缩的 wasm / `.data` 几乎压不动。
- 超限时:核心资源放公共读对象存储(路径 `apps/<key>/<version>/`),`dist` 只留前端壳,manifest 声明:
```json
"embedCsp": { "connectSrc": ["https://static.example.cn"] }
```
该字段仅 `type=micro` 可声明,**治理字段**(变更 202 进审核队列)。
- 平台只放开 `connect-src`(`fetch`/XHR/WebSocket/wasm 流式加载);`new Worker(外域)`、动态 `import(外域)`、外链 `<script>` / 样式 / 字体**仍会被 PROD CSP 拦**。
- 桶必须配 CORS:`GET/HEAD/OPTIONS`、`Allow-Headers: Range`、`Expose-Headers: Content-Length,Content-Range,ETag`;只有公共读不够(带 `Range` 的 fetch 会触发预检)。
## 其他硬约束(详见 skill)
- 前端资源全部用绝对路径 `/apps/<key>/...`;JS 外置,禁止内联 `<script>`。
- 业务 SQL 一律走 `@aigrego/guard` 的租户数据源;路由挂四道闸。
- 颜色只用 `var(--sk-*)` 令牌,读 `localStorage['ts-skin']` 跟随平台换肤,不反写。
- 密钥不落盘:`APP_SECRET` 只从环境变量/secrets 注入。
@@ -48,3 +48,39 @@ jobs:
# sh -c 'wget -qO /tmp/cli.mjs https://code.innev.cn/images/tickshield-dist/raw/branch/main/cli/aigrego-release.mjs && node /tmp/cli.mjs ...' # sh -c 'wget -qO /tmp/cli.mjs https://code.innev.cn/images/tickshield-dist/raw/branch/main/cli/aigrego-release.mjs && node /tmp/cli.mjs ...'
# micro 应用前端产物(先 bun 构建 web/ 再上传,包根必须直接含 index.html): # micro 应用前端产物(先 bun 构建 web/ 再上传,包根必须直接含 index.html):
# sh -c 'cd /src/web && bun build src/main.tsx --outfile dist/app.bundle.js && node /src/cli/aigrego-release.mjs publish --dist dist' # sh -c 'cd /src/web && bun build src/main.tsx --outfile dist/app.bundle.js && node /src/cli/aigrego-release.mjs publish --dist dist'
# 可选:大产物外置(dist tgz > 64 MiB 或核心为已压缩大文件时必须)。
# 前置:secrets MINIO_ACCESS_KEY / MINIO_SECRET_KEY;vars TS_ASSETS_BUCKET /
# TS_ASSETS_PUBLIC_BASE / MINIO_ENDPOINT;桶开公共读 + CORS(GET/HEAD/OPTIONS、
# Allow-Headers: Range、Expose-Headers: Content-Length,Content-Range,ETag)。
# 未配置 TS_ASSETS_BUCKET 时整 job 跳过;manifest 需声明 embedCsp.connectSrc(仅 micro,进审核)。
assets:
if: vars.TS_ASSETS_BUCKET != ''
runs-on: self-hosted
steps:
- name: Checkout tag source
run: |
rm -rf src && git clone --depth 1 --branch "$GITHUB_REF_NAME" . src
[ -d src/assets ] || { echo "缺 src/assets(外置核心资源目录)"; exit 1; }
- name: Mirror assets to minIO(钉版 mc / 版本化路径 / 匿名读自检)
run: |
[ -n "${{ secrets.MINIO_ACCESS_KEY }}" ] && [ -n "${{ secrets.MINIO_SECRET_KEY }}" ] \
|| { echo "缺少 minIO 写凭证:secrets MINIO_ACCESS_KEY / MINIO_SECRET_KEY"; exit 1; }
TAG="${GITHUB_REF_NAME#v}"
MC_IMAGE="${{ vars.TS_IMAGE_PREFIX || 'livebook:8418' }}/minio/mc:RELEASE.2025-04-16T18-13-26Z"
ENDPOINT="${{ vars.MINIO_ENDPOINT }}"
ENDPOINT="${ENDPOINT#*://}"
docker run --rm -v "$PWD/src/assets:/assets:ro" \
-e MC_HOST_tsminio="http://${{ secrets.MINIO_ACCESS_KEY }}:${{ secrets.MINIO_SECRET_KEY }}@${ENDPOINT}" \
"$MC_IMAGE" mirror --overwrite /assets \
"tsminio/${{ vars.TS_ASSETS_BUCKET }}/apps/${{ secrets.APP_KEY }}/${TAG}"
- name: 匿名读自检(CORS 仍需桶侧配置,这里只验公共读)
run: |
TAG="${GITHUB_REF_NAME#v}"
PROBE="$(cd src/assets && find . -type f | head -1 | sed 's#^\./##')"
URL="${{ vars.TS_ASSETS_PUBLIC_BASE }}/apps/${{ secrets.APP_KEY }}/${TAG}/${PROBE}"
CODE=$(curl -s -o /dev/null -w '%{http_code}' "$URL")
[ "$CODE" = 200 ] || { echo "匿名读自检失败:$URL → HTTP $CODE"; exit 1; }
echo "外置资源已发布:${{ vars.TS_ASSETS_PUBLIC_BASE }}/apps/${{ secrets.APP_KEY }}/${TAG}/"
+6 -1
View File
@@ -70,7 +70,12 @@ die 消息统一三段式:是什么 + 为什么(后果) + 怎么办/绕过
| 页面显隐不对 | acl 没接(旧宿主无 acl 字段时不设防) | 握手取 init.acl 驱动路由守卫;真闸在后端四道闸 | | 页面显隐不对 | acl 没接(旧宿主无 acl 字段时不设防) | 握手取 init.acl 驱动路由守卫;真闸在后端四道闸 |
| 调后端跨域报错 | 直接 fetch `/svc/...` | 一律 `sdk.callService(appKey, path)` 走宿主代发 | | 调后端跨域报错 | 直接 fetch `/svc/...` | 一律 `sdk.callService(appKey, path)` 走宿主代发 |
| 刷新丢路由 | 没接 `?r=` 深链 | initialRoute() 恢复 + routeSync 上报 | | 刷新丢路由 | 没接 `?r=` 深链 | initialRoute() 恢复 + routeSync 上报 |
| `publish --dist` 被拒 | 包根没有直接含 index.html / 超 64MB / 含 symlink | `tar czf -C <dir> .` 形态打包 | | `publish --dist` 被拒 | 包根没有直接含 index.html / **tgz 超 64 MiB** / 含 symlink | `tar czf -C <dir> .` 形态打包;超限走大产物外置 |
| 平台 413(`artifact exceeds 67108864 bytes`) | dist tgz 压后仍超 64 MiB(wasm/`.data` 几乎压不动) | 核心移出 dist,按 contract §7.1 外置 + 声明 `embedCsp` |
| 网关 413 但平台没日志 | 网关 `client_max_body_size` 先拦(同 64 MiB) | 同上,不是配置问题,是产物形态问题 |
| PROD 下 `fetch` 外部桶被 CSP 拦(`connect-src`) | manifest 未声明 `embedCsp.connectSrc`,或声明变更还在审核(202) | 补声明并等批准;确认平台侧 ≤5s / 网关 2s 已收敛 |
| 外部资源请求 OPTIONS 预检失败 | 桶没配 CORS(只开了公共读) | 配 `Allow-Headers: Range`、`Expose-Headers: Content-Range` 等 |
| `new Worker('https://…')` / 动态 `import` 外域被拦 | `connect-src` 不覆盖 Worker/模块脚本,平台也未放开 `script-src`/`worker-src` | 把这部分留在 dist 同源,或改成 `fetch` + 本地实例化 |
## 发布 / CI ## 发布 / CI
+120 -4
View File
@@ -8,6 +8,7 @@ ROOT="${1:-.}"
cd "$ROOT" || exit 2 cd "$ROOT" || exit 2
DIE=0; WARN=0 DIE=0; WARN=0
HAS_EMBED=0; EMBED_ORIGINS=""
die() { echo "DIE $*"; DIE=$((DIE + 1)); } die() { echo "DIE $*"; DIE=$((DIE + 1)); }
warn() { echo "WARN $*"; WARN=$((WARN + 1)); } warn() { echo "WARN $*"; WARN=$((WARN + 1)); }
ok() { echo "ok $*"; } ok() { echo "ok $*"; }
@@ -18,7 +19,7 @@ SEMVER_RE='^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$'
# ---------- A. app.manifest.json ---------- # ---------- A. app.manifest.json ----------
MF=app.manifest.json MF=app.manifest.json
if [ ! -f "$MF" ]; then if [ ! -f "$MF" ]; then
die "缺 $MF(应用契约唯一事实源)" die "缺 ${MF}(应用契约唯一事实源)"
LISTING_KEY="" LISTING_KEY=""
else else
TMP="$(mktemp)" TMP="$(mktemp)"
@@ -63,6 +64,24 @@ if (pages !== undefined) {
else if (p.audience !== undefined && !['admin', 'viewer', 'all'].includes(p.audience)) console.log('BAD_ACL_AUD=' + i + ':' + p.audience); else if (p.audience !== undefined && !['admin', 'viewer', 'all'].includes(p.audience)) console.log('BAD_ACL_AUD=' + i + ':' + p.audience);
}); });
} }
// 大产物外置:embedCsp.connectSrc 形态与平台校验保持一致(apps/platform/internal/pkg/appcsp)。
if (o.embedCsp !== undefined) {
console.log('HAS_EMBED=yes');
if ((o.type ?? 'service') !== 'micro') console.log('BAD_EMBED=non-micro');
const arr = o.embedCsp && o.embedCsp.connectSrc;
if (!Array.isArray(arr)) { console.log('BAD_EMBED=not-array'); }
else {
console.log('EMBED_COUNT=' + arr.length);
if (arr.length > 8) console.log('BAD_EMBED=too-many:' + arr.length);
const originRe = /^https?:\/\/(\*\.)?([A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?)(\.([A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?))*(:[0-9]{1,5})?$/;
const seen = new Set();
arr.forEach((s, i) => {
if (typeof s !== 'string' || s.length > 253 || !originRe.test(s)) console.log('BAD_EMBED=' + i + ':' + s);
else if (seen.has(s)) console.log('BAD_EMBED=dup:' + s);
else { seen.add(s); console.log('EMBED_ORIGIN=' + s); }
});
}
}
EOF EOF
elif command -v python3 >/dev/null 2>&1; then elif command -v python3 >/dev/null 2>&1; then
python3 - "$MF" >"$TMP" <<'EOF' python3 - "$MF" >"$TMP" <<'EOF'
@@ -110,6 +129,28 @@ if acl is not None:
print(f'BAD_ACL={i}') print(f'BAD_ACL={i}')
elif p.get('audience') is not None and p['audience'] not in ('admin', 'viewer', 'all'): elif p.get('audience') is not None and p['audience'] not in ('admin', 'viewer', 'all'):
print(f"BAD_ACL_AUD={i}:{p['audience']}") print(f"BAD_ACL_AUD={i}:{p['audience']}")
# 大产物外置:embedCsp.connectSrc 形态与平台校验保持一致(apps/platform/internal/pkg/appcsp)。
if 'embedCsp' in o:
print('HAS_EMBED=yes')
if (o.get('type') or 'service') != 'micro':
print('BAD_EMBED=non-micro')
arr = (o.get('embedCsp') or {}).get('connectSrc') if isinstance(o.get('embedCsp'), dict) else None
if not isinstance(arr, list):
print('BAD_EMBED=not-array')
else:
print(f'EMBED_COUNT={len(arr)}')
if len(arr) > 8:
print(f'BAD_EMBED=too-many:{len(arr)}')
origin_re = re.compile(r'^https?://(\*\.)?([A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?)(\.([A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?))*(:[0-9]{1,5})?$')
seen = set()
for i, s in enumerate(arr):
if not isinstance(s, str) or len(s) > 253 or not origin_re.match(s):
print(f'BAD_EMBED={i}:{s}')
elif s in seen:
print(f'BAD_EMBED=dup:{s}')
else:
seen.add(s)
print(f'EMBED_ORIGIN={s}')
EOF EOF
else else
warn "无 node/python3,跳过 manifest 语义检查(只查文本项)" warn "无 node/python3,跳过 manifest 语义检查(只查文本项)"
@@ -141,6 +182,9 @@ EOF
grep -q '^BAD_FNAV=' "$TMP" && die "footerNavItems 缺 id/label/path:$(grep '^BAD_FNAV=' "$TMP" | sed 's/^BAD_FNAV=//' | tr '\n' ' ')" grep -q '^BAD_FNAV=' "$TMP" && die "footerNavItems 缺 id/label/path:$(grep '^BAD_FNAV=' "$TMP" | sed 's/^BAD_FNAV=//' | tr '\n' ' ')"
grep -q '^BAD_ACL=' "$TMP" && die "aclManifest.pages 缺 key/label 或 pages 非数组" grep -q '^BAD_ACL=' "$TMP" && die "aclManifest.pages 缺 key/label 或 pages 非数组"
grep -q '^BAD_ACL_AUD=' "$TMP" && die "aclManifest audience 非法(仅 admin|viewer|all)" grep -q '^BAD_ACL_AUD=' "$TMP" && die "aclManifest audience 非法(仅 admin|viewer|all)"
grep -q '^BAD_EMBED=' "$TMP" && die "embedCsp 非法(仅 micro;≤8 条;scheme://host[:port],可 *.;禁止路径/裸 */分号/空白/重复):$(grep '^BAD_EMBED=' "$TMP" | sed 's/^BAD_EMBED=//' | tr '\n' ' ')"
[ "$(sed -n 's/^HAS_EMBED=//p' "$TMP" | head -1)" = "yes" ] && HAS_EMBED=1
EMBED_ORIGINS=$(sed -n 's/^EMBED_ORIGIN=//p' "$TMP" | tr '\n' ' ')
fi fi
rm -f "$TMP" rm -f "$TMP"
fi fi
@@ -175,7 +219,7 @@ else
grep -q 'assertTenantTables(' "$ENTRY" || die "缺启动自检 assertTenantTables(表缺 tenant_id 列会静默变全表扫描)" grep -q 'assertTenantTables(' "$ENTRY" || die "缺启动自检 assertTenantTables(表缺 tenant_id 列会静默变全表扫描)"
grep -q 'process.exit(1)' "$ENTRY" || warn "启动失败未 process.exit(1)(带病不启动约定)" grep -q 'process.exit(1)' "$ENTRY" || warn "启动失败未 process.exit(1)(带病不启动约定)"
POOLS=$(grep -c 'new pg.Pool' "$ENTRY") POOLS=$(grep -c 'new pg.Pool' "$ENTRY")
[ "$POOLS" -gt 2 ] && warn "new pg.Pool ×$POOLS:确认除 tdb createPool + dbCheck 探活池外无业务直连" [ "$POOLS" -gt 2 ] && warn "new pg.Pool ×${POOLS}:确认除 tdb createPool + dbCheck 探活池外无业务直连"
fi fi
grep -qE 'export (async )?function handler|export \{[^}]*handler|export default' "$ENTRY" \ grep -qE 'export (async )?function handler|export \{[^}]*handler|export default' "$ENTRY" \
|| die "$ENTRY 未导出 handler(APP_ENTRY 契约)" || die "$ENTRY 未导出 handler(APP_ENTRY 契约)"
@@ -222,6 +266,13 @@ if [ -n "${LISTING_KEY:-}" ]; then
fi fi
compgen -G ".gitea/workflows/*.yml" >/dev/null 2>&1 || compgen -G ".gitea/workflows/*.yaml" >/dev/null 2>&1 \ compgen -G ".gitea/workflows/*.yml" >/dev/null 2>&1 || compgen -G ".gitea/workflows/*.yaml" >/dev/null 2>&1 \
|| warn "缺 .gitea/workflows 发布流水线(模板见 references/templates/gitea-app-release.yaml)" || warn "缺 .gitea/workflows 发布流水线(模板见 references/templates/gitea-app-release.yaml)"
# A11 免提示自检入口:仓库根 AGENTS.md 里写死「开工前先跑 check-app.sh」,
# 后续任何 Agent 不依赖用户任务提示词也会先体检(模板 references/templates/AGENTS.md)。
if [ -f AGENTS.md ] && grep -q 'check-app\.sh' AGENTS.md; then
ok "AGENTS.md 含免提示自检硬规则"
else
warn "缺 AGENTS.md 自检硬规则(照 references/templates/AGENTS.md):Agent 不会自动体检,只能靠用户每次明说"
fi
# ---------- H. UI 与皮肤一致性(micro 前端,基准见 references/ui-style.md) ---------- # ---------- H. UI 与皮肤一致性(micro 前端,基准见 references/ui-style.md) ----------
# 分档口径:破坏换肤能力的(写死色值、没有皮肤桥)= die;风格细节偏离 = warn。 # 分档口径:破坏换肤能力的(写死色值、没有皮肤桥)= die;风格细节偏离 = warn。
@@ -254,7 +305,7 @@ if [ "$IS_MICRO" = 1 ] && [ -d web/src ]; then
# H4 令牌快照完整性 # H4 令牌快照完整性
TOK=web/src/skin/tokens.ts TOK=web/src/skin/tokens.ts
if [ ! -f "$TOK" ]; then if [ ! -f "$TOK" ]; then
die "缺 $TOK(令牌快照:bash scripts/sync-skin-tokens.sh <平台仓根>)" die "缺 ${TOK}(令牌快照:bash scripts/sync-skin-tokens.sh <平台仓根>)"
else else
SKIN_N=$(grep -c "key: '" "$TOK") SKIN_N=$(grep -c "key: '" "$TOK")
TOK_N=$(grep -c "'--sk-" "$TOK") TOK_N=$(grep -c "'--sk-" "$TOK")
@@ -308,8 +359,73 @@ if [ "$IS_MICRO" = 1 ] && [ -d web/src ]; then
[ "$IMP" -gt 0 ] && warn "业务样式用了 $IMP 处 !important(reduced-motion 兜底之外)" [ "$IMP" -gt 0 ] && warn "业务样式用了 $IMP 处 !important(reduced-motion 兜底之外)"
fi fi
# ---------- I. 大产物与静态资源外置(dist tgz > 64MiB 的核心出 dist) ----------
if [ "$IS_MICRO" = 1 ]; then
DIST=""
for cand in dist web/dist build web/build; do
if [ -d "$cand" ] && [ -f "$cand/index.html" ]; then DIST="$cand"; break; fi
done
NEED_EXTERNAL=0
if [ -z "$DIST" ]; then
warn "未找到含 index.html 的构建产物(dist/ web/dist/ 等):体积与外置检查跳过,发布前构建后复跑"
else
UNCOMP_KB=$(du -sk "$DIST" 2>/dev/null | awk '{print $1}')
TGZ_TMP=$(mktemp -t appcheck.XXXXXX)
if tar czf "$TGZ_TMP" -C "$DIST" . 2>/dev/null; then
TGZ_BYTES=$(wc -c < "$TGZ_TMP" | tr -d ' ')
rm -f "$TGZ_TMP"
if [ "$TGZ_BYTES" -gt 67108864 ]; then
NEED_EXTERNAL=1
die "dist 打 tgz 后 $((TGZ_BYTES / 1048576))MiB > 平台 64MiB 上限——核心必须外置到对象存储(contract §7.1)"
elif [ "$UNCOMP_KB" -gt 65536 ]; then
warn "dist 未压缩 ${UNCOMP_KB}KB > 64MiB,但 tgz 实测 $((TGZ_BYTES / 1024))KB 达标(压缩率依赖,发布前留意)"
fi
else
rm -f "$TGZ_TMP"
warn "dist 打包 tgz 失败,体积检查跳过"
fi
BIGFILES=$(find "$DIST" -type f \( -name '*.wasm' -o -name '*.data' -o -name '*.br' -o -name '*.gz' -o -name '*.zip' -o -name '*.7z' -o -name '*.mp4' \) -size +8192k 2>/dev/null | head -5)
[ -n "$BIGFILES" ] && warn "dist 内含 >8MiB 已压缩资源(tgz 几乎不缩小):$(echo "$BIGFILES" | tr '\n' ' ')"
fi
if [ "$NEED_EXTERNAL" = 1 ] && [ "$HAS_EMBED" = 0 ]; then
die "dist 超限但 manifest 未声明 embedCsp.connectSrc——外置后必须声明桶域名(仅 micro)"
fi
fi
# I5 声明与用点对得上;I7 外置上传 job(只提示,静态检查无法证明桶侧状态)。
if [ "$HAS_EMBED" = 1 ]; then
MISSING=0
for origin in $EMBED_ORIGINS; do
if ! grep -rqF "$origin" web/src web/index.html "$ENTRY" 2>/dev/null; then
MISSING=1
fi
done
[ "$MISSING" = 1 ] && warn "embedCsp 声明了来源但源码/入口/index.html 找不到使用点(死配置,或地址来自运行时配置——人工确认)"
if compgen -G ".gitea/workflows/*.y*ml" >/dev/null 2>&1; then
grep -rqE 'TS_ASSETS_BUCKET|mc +mirror' .gitea/workflows 2>/dev/null \
|| warn "未见外置资源上传 job(assets / mc mirror)——确认 CI 把核心发到 apps/<key>/<version>/(checklist I7)"
else
warn "缺 .gitea/workflows,无法确认外置资源上传与版本化路径(checklist I7)"
fi
fi
# I6:connect-src 不覆盖 Worker / 动态 import / 外链 script 与样式(PROD CSP 会拦)。
# 只对 micro 有意义——service 没有同源托管页面,Node 侧外呼不受浏览器 CSP 约束。
if [ "$IS_MICRO" = 1 ]; then
EXT_JS=$(grep -rnE "new Worker\([[:space:]]*['\"]https?://|import\([[:space:]]*['\"]https?://|importScripts\([[:space:]]*['\"]https?://" web/src "$ENTRY" 2>/dev/null | head -3)
HTML_JS=$(grep -nE "<script[^>]+src=['\"]https?://" web/index.html 2>/dev/null | head -3)
HTML_CSS=$(grep -nE "<link[^>]+rel=['\"]stylesheet['\"][^>]+href=['\"]https?://|<link[^>]+href=['\"]https?://[^>]+rel=['\"]stylesheet['\"]" web/index.html 2>/dev/null | head -3)
if [ -n "$EXT_JS$HTML_JS$HTML_CSS" ]; then
die "跨域加载用了 connect-src 不覆盖的方式(Worker/动态 import/外链 script|stylesheet),PROD CSP 会拦:$(printf '%s\n' "$EXT_JS" "$HTML_JS" "$HTML_CSS" | head -3 | tr '\n' ' ')"
fi
fi
# ---------- 汇总 ---------- # ---------- 汇总 ----------
echo "---" echo "---"
echo "die=$DIE warn=$WARN" echo "die=$DIE warn=$WARN"
[ "$DIE" -gt 0 ] && exit 1 if [ "$DIE" -gt 0 ]; then
echo "RESULT: FAIL"
exit 1
fi
echo "RESULT: PASS"
exit 0 exit 0
+111
View File
@@ -0,0 +1,111 @@
#!/usr/bin/env bash
# check-app.sh 自测:用临时 fixture 覆盖大产物 / embedCsp 规则,防止技能与平台校验漂移。
# 用法: bash scripts/selftest-check-app.sh 退出码 0=全部断言通过。
set -u
HERE=$(cd "$(dirname "$0")" && pwd)
CHECK="$HERE/check-app.sh"
[ -f "$CHECK" ] || { echo "找不到 $CHECK" >&2; exit 2; }
ROOT=$(mktemp -d)
trap 'rm -rf "$ROOT"' EXIT
N=0; BAD=0
# fixture <name>:建一个最小 micro 仓库骨架(manifest/Dockerfile/入口/前端)。
fixture() {
local dir="$ROOT/$1"
mkdir -p "$dir/web/src/skin" "$dir/dist"
cat >"$dir/app.manifest.json" <<'JSON'
{ "listingKey": "selftest-app", "name": "自测应用", "version": "1.0.0", "type": "micro" }
JSON
cat >"$dir/Dockerfile" <<'DOCKER'
FROM tickshield-node-runtime:base
RUN ln -s /opt/xgent/runtime/node_modules /app/node_modules
DOCKER
cat >"$dir/server.js" <<'JS'
import { createGuard } from '@aigrego/guard';
const guard = createGuard({ portalUrl: 'http://x', appKey: 'selftest-app', appSecret: 'x' });
export function handler(req, res) {}
export const routes = guard.routes([]);
JS
cat >"$dir/web/index.html" <<'HTML'
<!doctype html><html><body>
<script src="/apps/selftest-app/vendor/portal-sdk.js"></script>
<script src="/apps/selftest-app/app.js"></script>
</body></html>
HTML
cat >"$dir/web/src/main.tsx" <<'TS'
import { initSkin } from './skin/apply';
initSkin();
TS
printf "export const SKINS = [{ key: 'light' }, { key: 'dark' }, { key: 'jade' }];\n// SKIN_SNAPSHOT_DATE = '2026-10-22'\n" >"$dir/web/src/skin/tokens.ts"
printf ":root { font-family: system-ui, -apple-system, 'PingFang SC'; font-size: 13px; }\n" >"$dir/web/app.css"
echo '<!doctype html><html><body>app</body></html>' >"$dir/dist/index.html"
echo "$dir"
}
# set_embed <manifest> <json-array>:写入 embedCsp.connectSrc(node 优先,退化 python3)。
set_embed() {
local f="$1" origins="$2"
if command -v node >/dev/null 2>&1; then
node -e 'const fs=require("fs");const [p,src]=process.argv.slice(1);const o=JSON.parse(fs.readFileSync(p));o.embedCsp={connectSrc:JSON.parse(src)};fs.writeFileSync(p,JSON.stringify(o,null,2));' "$f" "$origins"
else
python3 - "$f" "$origins" <<'PY'
import json, sys
p, origins = sys.argv[1], json.loads(sys.argv[2])
o = json.load(open(p))
o['embedCsp'] = {'connectSrc': origins}
json.dump(o, open(p, 'w'), ensure_ascii=False, indent=2)
PY
fi
}
# expect <name> <dir> <contains-regex> <want:1|0>
expect() {
local name="$1" dir="$2" pat="$3" want="$4" out got
out=$(bash "$CHECK" "$dir" 2>&1)
N=$((N + 1))
if printf '%s\n' "$out" | grep -qE "$pat"; then got=1; else got=0; fi
if [ "$got" = "$want" ]; then
echo "ok $name"
else
BAD=$((BAD + 1))
echo "FAIL $name(期望命中=$want 模式=$pat)"
printf '%s\n' "$out" | sed 's/^/ /'
fi
}
# 1) dist tgz 超限且未声明 embedCsp → 两条 die + RESULT FAIL。
D=$(fixture over-limit)
dd if=/dev/urandom of="$D/dist/core.bin" bs=1048576 count=70 2>/dev/null
expect "tgz 超限诊断" "$D" 'dist 打 tgz 后 [0-9]+MiB > 平台 64MiB' 1
expect "超限未声明外置" "$D" 'dist 超限但 manifest 未声明 embedCsp' 1
expect "超限 RESULT FAIL" "$D" '^RESULT: FAIL$' 1
# 2) embedCsp 带路径 → 结构非法。
D=$(fixture embed-bad)
set_embed "$D/app.manifest.json" '["https://cdn.example.com/assets"]'
expect "embedCsp 路径非法" "$D" 'embedCsp 非法' 1
# 3) 外域 Worker → connect-src 不覆盖。
D=$(fixture worker-ext)
echo "new Worker('https://cdn.example.com/w.js');" >>"$D/web/src/main.tsx"
expect "外域 Worker 拦截" "$D" 'connect-src 不覆盖' 1
# 4) 合规声明 + 使用点 + 小 dist → 不误报。
D=$(fixture embed-ok)
set_embed "$D/app.manifest.json" '["https://cdn.example.com"]'
echo "fetch('https://cdn.example.com/apps/selftest-app/1.0.0/core.data');" >>"$D/web/src/main.tsx"
expect "合规声明不报非法" "$D" 'embedCsp 非法' 0
expect "合规声明不报超限" "$D" 'dist 打 tgz 后' 0
expect "合规声明不报死配置" "$D" '找不到使用点' 0
expect "输出始终带结论行" "$D" '^RESULT: (PASS|FAIL)$' 1
# 5) 免提示自检入口:无 AGENTS.md → warn;有且含 check-app.sh → 不再 warn。
expect "缺 AGENTS.md 提示" "$D" '缺 AGENTS.md 自检硬规则' 1
printf '# AGENTS.md\n开工前先跑 `bash .agents/skills/app-dev/scripts/check-app.sh .`。\n' >"$D/AGENTS.md"
expect "有 AGENTS.md 不提示" "$D" '缺 AGENTS.md 自检硬规则' 0
echo "---"
echo "selftest: $N 项,失败 $BAD"
[ "$BAD" -gt 0 ] && exit 1
exit 0