From 9dd0f5b8e00f7396164348cad9bdd9f61ed89fbe Mon Sep 17 00:00:00 2001 From: jinjunnn Date: Wed, 22 Jul 2026 09:05:19 -0400 Subject: [PATCH 1/2] =?UTF-8?q?design:=20upload=20consent=20UX=20=E8=AE=BE?= =?UTF-8?q?=E8=AE=A1=E7=A8=BF=20(approved)=20(#225)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- .../2026-07-22-upload-consent-ux/design.html | 564 ++++++++++++++++++ .../2026-07-22-upload-consent-ux/design.md | 211 +++++++ 2 files changed, 775 insertions(+) create mode 100644 docs/design/2026-07-22-upload-consent-ux/design.html create mode 100644 docs/design/2026-07-22-upload-consent-ux/design.md diff --git a/docs/design/2026-07-22-upload-consent-ux/design.html b/docs/design/2026-07-22-upload-consent-ux/design.html new file mode 100644 index 000000000000..4108c988ae3c --- /dev/null +++ b/docs/design/2026-07-22-upload-consent-ux/design.html @@ -0,0 +1,564 @@ + + + + + +alpha-code · 上云发送同意框设计稿 — upload consent (2026-07-22) + + + + +
+
上云发送同意框upload consent · 2026-07-22 draft-v1
+
+ + + + +
+
+ + +
+
+ +
+ 与上一稿的关系 — 视觉基线 = 2026-07-15-capability-authorize-dialog(accepted):同一 house + Dialog / Button / --a-* token 家族零改动。宿主入口 = 现产品 + cloud-dispatch-box.tsx「选择项目并派发」。行为增量 = 把现有「每个项目首次派发一律弹一次原生框」(ADR-021 §4) + 换成按内容条件触发的结构化同意框:只有这次发送真的含受隐私保护信息时才弹,不含则静默直发、绝不打扰。 + 本稿唯一新增 CSS 家族 = .alpha-upl-*(发送清单 / 隐私发现 / 用途·保留 / 撤回)。字段名与内部术语只在同目录 + design.md,不进画面。 +
+ +
+
+ + + + diff --git a/docs/design/2026-07-22-upload-consent-ux/design.md b/docs/design/2026-07-22-upload-consent-ux/design.md new file mode 100644 index 000000000000..cc9ef7c92746 --- /dev/null +++ b/docs/design/2026-07-22-upload-consent-ux/design.md @@ -0,0 +1,211 @@ +--- +type: design +slug: upload-consent-ux +date: 2026-07-22 +status: draft(等 owner 评审;实现票见 alpha-code#225 / 父需求 alpha-work#10) +relates: + - jinjunnn/alpha-code#225([Privacy] main-owned cloud upload manifest and consent token,UI 部分) + - jinjunnn/alpha-work#10(父需求:上云隐私边界) + - jinjunnn/alpha-platform#32(UploadManifestV1 + upload_consent 契约,已合并) + - 2026-07-15-capability-authorize-dialog(视觉与「同意时刻」基线) + - ADR-021(代码上云数据边界:diff-only + secrets 过滤 + 体积上限 + consent 挂钩) +--- + +# 上云发送同意框(upload consent)设计稿 + +> **与上一稿的关系** — 视觉基线 = `2026-07-15-capability-authorize-dialog` +> (status: accepted):同一 house `alpha-ui/Dialog`(`dialog.css`)+ `Button` +> (`button.css`)+ `--a-*` token 家族(`tokens.css`),**零改动**。宿主入口 = +> 现产品 `cloud-dispatch-box.tsx`「选择项目并派发」。**行为增量** = 把 ADR-021 §4 +> 现落地的「每个项目**首次**云 dispatch 一律弹一次原生框」换成 **按内容条件触发** +> 的结构化同意框:**只有这次发送真的含受隐私保护信息(清单 `consent_required=true`) +> 才弹**;不含则静默直发、绝不打扰。本稿唯一新增 CSS 家族 = `.alpha-upl-*` +> (发送清单 / 隐私发现 / 用途·保留 / 撤回)。字段名与内部术语只在本文件,不进画面 +> (设计宪法 §7,`docs/design/system/principles.md`)。 + +## 1. 背景与触发 + +`cloud-dispatch-box.tsx` 是 code-review pipeline 的 app-driven 派发入口:用户 +「选择项目并派发」→ main 侧取 `git diff`(工作树优先,回退最近一次 commit)→ +`window.api.cloud.dispatch(envelope, directory)` 出境 +(`cloud-dispatch-box.tsx:55-74`)。ADR-021 §2 已在 main 单点做**技术边界**校验 +(体积上限 / secrets 扫描 / `denied_paths` 默认注入,`cloud-envelope-guard.ts`), +§4 已落一个**每项目首次一律弹**的原生 consent(`alpha-cloud-consent.ts` + +`.alpha/prefs.json`)。但那道 consent 是 **blunt**:与这次上传**是否真含隐私信息 +无关**,只认「这个项目第一次发没发过」——既会对纯代码的首发无谓打扰,又无法针对 +「这次恰好带了 PII」提高告知。 + +alpha-platform#32 合入的 **`UploadManifestV1`** 补上了缺的那一维:main 在发送前为 +本次上传生成一份清单,绑定 `tenant / path scope / size / sha256 / purpose / +retention_class / consent_required`,并由 `upload_consent`(iss=alpha-web)令牌绑定 +`manifest_sha256`。**`consent_required` 就是本框的开关**:它由 main 侧对清单内容做 +隐私分类得出,renderer/agent 既读不到也改不动。本稿定义 `consent_required=true` +时那一刻的**同意 UI**;main 侧清单生成、分类、令牌绑定按票面独立推进。 + +## 2. 事实基线(file:line 证据) + +| # | 事实 | 锚点 | +|---|------|------| +| F1 | 派发入口:用户选目录 → main 取 `git diff` → `cloud.dispatch(envelope, directory)` 出境 | `cloud-dispatch-box.tsx:51-93` | +| F2 | 已有 `consent-declined` 出口(用户拒绝 → 中止派发,人话行内) | `cloud-dispatch-box.tsx:20`、`i18n/zh.ts:533`(`alpha.ext.cloudErrConsentDeclined`) | +| F3 | 失败一律**行内**(B11),不弹框;成功=inline done | `cloud-dispatch-box.tsx:127-140`(`.alpha-ext-card-err` / `data-ok`) | +| F4 | ADR-021 §4 现状:consent 挂在「首次 dispatch(per 项目)」,与内容无关 | `.claude/rules/adrs/ADR-021-cloud-data-boundary.md:23-26` | +| F5 | ADR-021 §2:main 单点前置硬校验(1MB 上限 / secrets 扫描 / `denied_paths` 默认注入) | 同上 :18-21;`cloud-envelope-guard.ts` | +| F6 | main 是授权/同意语义唯一真源;renderer 只发可序列化 DTO,运行时校验恒在 main | `ext-capability-authorization.ts:1-5,48-53` | +| F7 | `decidedAt` 等审计事实由 main 收到确认后打戳,renderer 无通道提供 | `ext-capability-authorization.ts:50-53` | +| F8 | house 同意框先例:`Dialog`(default 560 / `sm` 420)+ ghost 取消 + primary 确认;能力行/风险行 token 化 | `alpha-ui/dialog.css:1-46`、`ext-authz.tsx`、`2026-07-15-capability-authorize-dialog/` | +| F9 | 反馈层级:成功=toast、失败=inline、取消=静默 | v3-universal §5.6;本稿沿用 | +| F10 | 契约:`UploadManifestV1` 绑定 tenant/path scope/size/sha256/purpose/`retention_class`/`consent_required`;`upload_consent`(iss=alpha-web)绑 `manifest_sha256` | alpha-platform#32(merged) | + +## 3. 核心产品规则(owner 决策,驱动一切) + +**同意框仅在这次上传真正含受法律保护的隐私信息(清单 `consent_required=true`)时 +出现。** 不含隐私信息的上传**永不弹框**——不打扰用户。这条规则把「合规义务」与 +「界面摩擦」对齐:有法律告知义务 → 问一次;没有 → 静默直发。 + +### 3.1 决策表 —— 何时弹框 / 何时静默 + +| 这次上传的情况 | 清单 `consent_required` | 界面行为 | 状态 | 为什么 | +|---|---|---|---|---| +| 含受法律保护的隐私信息(PII / 凭据样式 / 受监管数据) | `true` | **弹同意框**:清单 + 用途 + 保留 + 撤回,等显式同意后才发送 | 情形A · sensitive-upload | 只在有法律告知义务时打断 | +| 仅普通代码/文本,未发现受保护信息 | `false` | **不弹框**,静默直发 + 一行**非阻断**透明告知 | 情形B · non-sensitive-silent | 无隐私风险不制造确认摩擦 | +| 分类不确定 / 分类器异常 / 清单不可读 | **fail-closed ⇒ 视为 `true`** | 按含隐私处理:弹框(宁可多问一次,绝不静默出境) | 情形A(fail-closed 分支) | 分类失败不得成为静默放行 | +| 无法确定发送范围(缺目录 / 读不到 / 范围空) | 清单建不出 | **不发送**,内联失败;**绝不**回退成整库上传 | 情形D · error(scope) | 缺范围 = 取消,不是「发全部」 | +| `consent_required=true` 但用户在框内取消 | `true`,未同意 | **零副作用**静默关闭,不发送、不签令牌、无云端记录 | 情形C · cancelled | 同意是发送前置;未同意 = 不发 | +| 用户已同意,但 main 绑定清单/令牌签发失败 | `true`,已同意 | 内联失败关闭,**不发送**;可重试 | 情形D · error(token) | 同意 ≠ 发送;须 main 绑定指纹 + 取令牌成功 | + +**读法**:开关是 `consent_required`,不是「项目是否首发」(淘汰 F4 的 blunt 触发)。 +两个「失败」行是同一 fail-closed 家族——**任何不确定都收敛到「不发送」而非「发更多」**。 + +### 3.2 敏感度分类 → 画面呈现(人话,画面内不出现字段名) + +| 清单内部(仅本文件出现) | 画面文案(情形A 的隐私发现横幅) | +|---|---| +| PII: email / phone | 「N 个文件含疑似邮箱与电话号码」 | +| PII: identity(身份证/护照样式) | 「N 个文件含疑似个人身份信息」 | +| secret-like credential | 「N 个文件含疑似凭据样式字符串」 | +| regulated(健康/金融等,若分类支持) | 「N 个文件含受监管的敏感信息」 | +| `retention_class` / `purpose` / `consent_required` | 分别渲染为「保留」「用途」两行人话,及是否弹框的开关——**枚举码本身永不上屏** | + +未知/新增分类(前向兼容):归入「含受保护信息」通用措辞,**从不静默降级为不弹**。 + +## 4. 主进程权威 · 信任模型(trust model) + +- **main 是唯一权威(F6/F7/F10)**。清单在 main 生成、隐私分类在 main 判定、 + `consent_required` 在 main 决定、`upload_consent` 令牌由 **alpha-web** 签发并绑定 + `manifest_sha256`。renderer/agent 只能表达一件事:「我对**画面上这份清单**点了同意」。 +- **展示什么同意什么(反 TOCTOU / 反越权)**。同意绑定的是清单指纹;renderer + **不能预先替用户勾同意**,也**不能在同意后把范围偷偷放大**——放大后指纹变化, + 令牌不再匹配,main 侧拒发。画面用一句人话承载这条契约:「**同意仅对上面列出的 + 内容有效**」(对应 authz 稿「确认即授权上述完整能力集」的同源克制)。 +- **agent 输入不能伪造或拓宽同意**(AC)。agent 只能请求「发送范围 X」;是否含隐私、 + 是否需同意、令牌是否签发,全在 main/alpha-web,agent 无通道注入 `consent_required=false` + 或跳过弹框。 +- **缺目录绝不隐式扩为整库同意**(AC,§3.1 的 scope 失败行)。范围无法确定时 main + 不构造清单、不发送;绝不回退成「那就发整个项目」。这是安全红线,归到情形D 的 + 内联失败,而非静默继续。 + +## 5. 状态与交互(四态 + preview) + +``` +[情形A sensitive] 派发 → main 建清单 → 分类=需同意 → 弹同意框(清单/发现/用途/保留/撤回) + → 「同意并发送」→ 按钮 loading(main 绑定清单 + 取 upload_consent 令牌 + 发送) + → 关框 + 成功 toast +[情形B silent] 派发 → main 建清单 → 分类=无需同意 → 不弹框,直接发送 + → 派发区一行非阻断「隐私检查已通过 · 未发现受保护信息」+ 进度 +[情形C cancelled] 情形A 里 取消/Esc/点背景/关闭 → 静默关框,零副作用(不发/不签/无记录) + → 派发区回原状,可再次派发 +[情形D error] 范围建不出 或 同意后令牌/绑定失败 → 内联失败(B11),不发送,fail-closed + → 「重试」重走 清单→分类→(如仍含隐私)同意框 +[preview] 情形A 默认给摘要(N 文件 / 总大小 / 有界范围);「查看清单」就地展开 + 逐文件路径+大小,含隐私文件带「含隐私信息」chip——同意前看清每个将出境的文件 +``` + +- **preview / cancellation** 是本框对「知情同意」的两根支柱:preview 让「同意什么」 + 可核实到单文件;cancellation 保证「不同意」零成本、零痕迹。二者都在 main 发送**之前**。 +- 计时:清单+分类在本地评估、发送前返回,派发到弹框应 <1s;同意后 loading 期 = + 真正的 main 绑定+取令牌+首包。 +- `prefers-reduced-motion` → 所有入场动画归 0(设计宪法 §8,mock 已含 media query)。 + +## 6. 设计决策 + +- **D1 宿主复用,不造新框**。同意视图是 house `Dialog` 的一个 body 组件 + (`.alpha-upl`),尺寸取 default(560px)以容纳清单+用途+保留(比 authz 的 `sm` + 内容更长)。**刻意不用原生对话框**(现 ADR-021 §4 用的是原生框):同意是发送 + 事务里一个可重驱阶段,需渲染结构化清单+可展开 preview,原生框做不到。 +- **D2 条件触发是唯一存在理由(§3)**。`consent_required` 决定弹不弹;分类不确定 + fail-closed 到弹。淘汰「每项目首发一律弹」的 blunt 触发。 +- **D3 隐私发现横幅置顶**。含隐私才有此框,所以「为什么打扰你」必须是最先读到的 + 东西:warning-subtle(house 克制色阶)+ 人话类别 + 计数,**不显字段名/正则/枚举码**。 +- **D4 范围有界、可核实**。范围行明确「只发这些、其余不发」+ 文件/大小计数; + preview 展开到单文件。缺范围 → 情形D,绝不扩权。 +- **D5 用途 + 保留 = 同意实质**。两行人话讲清「发去做什么、留多久、谁能删」; + `purpose`/`retention_class` 枚举翻译成句子,不上屏。 +- **D6 撤回常驻**。底部一句「设置 › 隐私 › 云端数据」入口,事后可撤回同意并请求 + 删除已发送内容——同意不是一锤子买卖。 +- **D7 反馈层级沿用 house(F9)**:成功=toast、失败=inline(B11,不因失败弹框)、 + 取消=静默中性态(不是红色错误)。 +- **D8 令牌绑定失败 = fail-closed**。同意本身不发送任何东西;必须 main 绑定清单 + 指纹并取得 `upload_consent` 才有字节出境。签发不通即在此关闭,不带未验证同意继续。 + +## 7. 视觉规范(全 token 复用) + +- Dialog:`.a-dialog-*` 原样(default 560px、`--a-surface-raised` + + `--a-shadow-overlay` + `--a-edge-light`);header 加一个 accent-subtle 圆角图标位 + (`.a-dialog-hicon`,仍纯 token,与既有 header 结构兼容)。 +- 新增 CSS 类(均只消费 `--a-*`): + `.alpha-upl`(body 容器)、`.alpha-upl-flag`(隐私发现横幅,warning-subtle)、 + `.alpha-upl-scope`(有界范围条,bg-subtle + 计数 chip)、 + `.alpha-upl-box` / `.alpha-upl-file`(发送清单,含隐私文件 `data-flag` + chip)、 + `.alpha-upl-more`(preview 展开/收起)、`.alpha-upl-meta` / `.alpha-upl-mrow` + (用途·保留)、`.alpha-upl-withdraw`(撤回)、`.alpha-upl-note`(同意契约句)、 + `.alpha-upl-fail`(同意后 fail-closed 内联)。 +- 图标:16 viewbox / 1.5 stroke 线性 SVG(与 alpha-ui 同规格)。 +- 光暗双主(设计宪法 §3):mock 用 `data-theme` 切换验证;产品运行时键控 + `document.documentElement.dataset.colorScheme`(见 `tokens.md` Theming)。 + +## 8. 文案与 i18n(建议 keys,`alpha.cloud.consent.*` —— 仅本文件出现) + +| key | zh(画面文案) | +|---|---| +| `title` | 发送到 Alpha 云前,请确认 | +| `subReview` | {pipeline} · 本次发送 | +| `introSensitive` | 「{name}」需要把你所选项目的本次改动发送到 Alpha 云执行。系统在其中发现了受隐私保护的信息,发送前需要你确认。 | +| `flagTitle` | 这次发送包含受隐私保护的信息 | +| `findEmailPhone` | {n} 个文件含疑似邮箱与电话号码 | +| `findIdentity` | {n} 个文件含疑似个人身份信息 | +| `findCredential` | {n} 个文件含疑似凭据样式字符串 | +| `scopeDiff` | 仅本次改动(未提交的工作树 diff) | +| `scopeHint` | 整个项目的其余文件不会被发送 | +| `scopeCount` | {n} 个文件 · {size} | +| `preview` / `previewCollapse` | 查看清单 / 收起清单 | +| `fileFlag` | 含隐私信息 | +| `purposeLabel` / `retentionLabel` | 用途 / 保留 | +| `purposeReview` | 代码审查 —— 分析这次改动并给出结构化意见。仅用于本次任务,不用于训练。 | +| `retention` | 任务完成后自动删除,最长保留 {days} 天。你可随时提前删除。 | +| `withdraw` | 随时可撤回:设置 › 隐私 › 云端数据 撤回同意并请求删除已发送内容。 | +| `coversNote` | 同意仅对上面列出的内容有效。取消不会发送任何内容,也不会留下云端记录。 | +| `cta` / `cancel` | 同意并发送 / 取消 | +| `silentPass` | 隐私检查已通过 · 未发现受保护信息,已直接发送 | +| `errScope` | 无法安全发送 —— 无法确定要发送的范围(所选目录读不到),已取消本次发送。 | +| `errToken` | 无法完成安全发送 —— 为这次发送准备安全凭据时失败。没有任何字节离开本机;修复后可重试。 | +| `toastSent` | 已发送 · 云端审查进行中 | + +取消/失败复用既有 `alpha.ext.cloudErrConsentDeclined` 的语义(F2),但呈现为 +中性「已取消」而非红色错误。 + +## 9. 范围外与开放问题 + +- 范围外:清单生成 / 隐私分类器 / `upload_consent` 令牌签发(main + alpha-web,#225 + 后端);隐式通道(platform-pays 每 prompt 出境)仍按 ADR-021 §3 定位为「告知不过滤」, + 不在本框;逐文件勾选剔除(违背「整份清单一次同意」,且部分剔除会改指纹——若需要 + 另开窄票议)。 +- Q1 「记住本项目的选择」?**默认不记**——`consent_required` 是 per-upload 内容判定, + 记忆会把 blunt 触发从后门放回来。若 owner 要「同一清单指纹 N 分钟内免再问」,需 + main 侧对指纹做短时缓存,建议另议。 +- Q2 静默态(情形B)那行「隐私检查已通过」是否要**可关**?稿采用**常显但非阻断** + (透明优先);若嫌噪,可降级为仅首次显示。 +- Q3 保留期与用途来自清单 `retention_class` / `purpose` 枚举 → 句子的映射表由谁拥有 + (main 还是 i18n)?建议同 authz 的能力词汇表:枚举在 shared,句子在 i18n。 +- Q4 PAGE-MAP.md 新行(「Upload consent / 上云同意」surface)在本稿获批后补, + 避免未批先入索引(遵 `docs/design/README.md` workflow)。 From c0e16db141b578cbb5f4924230f938c1e8631f3e Mon Sep 17 00:00:00 2001 From: jinjunnn Date: Wed, 22 Jul 2026 10:07:02 -0400 Subject: [PATCH 2/2] privacy(ui-mac): main-authoritative upload manifest + consent + dialog (#225) Implements aw#10 client-side upload consent in the alpha-code desktop app: main process is the sole authority for the upload manifest (tenant binding, canonical paths, per-file SHA-256, totals, retention, egress, consent_required) and issuance of the upload_consent token via alpha-web; renderer can only send an opaque request id + kind. Conditional consent (dialog only when genuine privacy content is present). Free-text PII classification lives here (server #33 handles high-confidence markers only). Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 1 + docs/contracts/platform-integration.md | 61 ++ docs/design/create-upload-manifest-v1.plan.md | 545 ++++++++++++++++++ .../src/contracts.test.ts | 54 ++ .../alpha-contracts-consumer/src/decode.ts | 56 +- .../alpha-contracts-consumer/src/types.ts | 40 ++ .../src/main/alpha-auth-identity.test.ts | 46 ++ .../ui-mac/src/main/alpha-auth-identity.ts | 13 + packages/ui-mac/src/main/alpha-auth.ts | 8 + .../src/main/alpha-cloud-consent.test.ts | 86 +-- .../ui-mac/src/main/alpha-cloud-consent.ts | 27 +- packages/ui-mac/src/main/alpha-cloud-jobs.ts | 34 +- .../ui-mac/src/main/alpha-cloud-schedules.ts | 10 +- .../src/main/alpha-upload-manifest.test.ts | 105 ++++ .../ui-mac/src/main/alpha-upload-manifest.ts | 237 ++++++++ packages/ui-mac/src/main/alpha-upload.test.ts | 196 +++++++ packages/ui-mac/src/main/alpha-upload.ts | 145 +++++ .../src/main/alpha-web-upload-consent.test.ts | 40 ++ .../src/main/alpha-web-upload-consent.ts | 44 ++ .../ui-mac/src/main/cloud-envelope-guard.ts | 13 +- packages/ui-mac/src/main/cloud-ipc.ts | 93 +-- .../ui-mac/src/main/cloud-schedule-config.ts | 9 + .../ui-mac/src/main/cloud-sidecar-config.ts | 9 + packages/ui-mac/src/main/sidecar.ts | 9 +- .../ui-mac/src/main/upload-boundaries.test.ts | 65 +++ packages/ui-mac/src/preload/index.ts | 5 +- packages/ui-mac/src/preload/types.ts | 40 +- .../src/preload/upload-surface.typecheck.ts | 29 + .../extensions/cloud-dispatch-box.tsx | 264 ++++++--- .../extensions/upload-consent-dialog.test.ts | 218 +++++++ .../extensions/upload-consent-dialog.tsx | 146 +++++ .../extensions/upload-consent-test-runtime.ts | 4 + .../renderer/extensions/upload-consent.css | 46 ++ packages/ui-mac/src/renderer/i18n/en.ts | 41 +- packages/ui-mac/src/renderer/i18n/zh.ts | 41 +- .../ui-mac/src/shared/alpha-config.test.ts | 1 + packages/ui-mac/src/shared/alpha-config.ts | 2 + 37 files changed, 2530 insertions(+), 253 deletions(-) create mode 100644 docs/design/create-upload-manifest-v1.plan.md create mode 100644 packages/ui-mac/src/main/alpha-auth-identity.test.ts create mode 100644 packages/ui-mac/src/main/alpha-auth-identity.ts create mode 100644 packages/ui-mac/src/main/alpha-upload-manifest.test.ts create mode 100644 packages/ui-mac/src/main/alpha-upload-manifest.ts create mode 100644 packages/ui-mac/src/main/alpha-upload.test.ts create mode 100644 packages/ui-mac/src/main/alpha-upload.ts create mode 100644 packages/ui-mac/src/main/alpha-web-upload-consent.test.ts create mode 100644 packages/ui-mac/src/main/alpha-web-upload-consent.ts create mode 100644 packages/ui-mac/src/main/cloud-schedule-config.ts create mode 100644 packages/ui-mac/src/main/cloud-sidecar-config.ts create mode 100644 packages/ui-mac/src/main/upload-boundaries.test.ts create mode 100644 packages/ui-mac/src/preload/upload-surface.typecheck.ts create mode 100644 packages/ui-mac/src/renderer/extensions/upload-consent-dialog.test.ts create mode 100644 packages/ui-mac/src/renderer/extensions/upload-consent-dialog.tsx create mode 100644 packages/ui-mac/src/renderer/extensions/upload-consent-test-runtime.ts create mode 100644 packages/ui-mac/src/renderer/extensions/upload-consent.css diff --git a/CHANGELOG.md b/CHANGELOG.md index f55a27235086..a74684d733f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ ## [Unreleased] ### Added +- **显式云文件上传现在按本次内容决定是否请求隐私同意**(#225,需 Alpha Web 签发端与 Cloud Jobs 上传闸部署后生效)——main 只读取并冻结用户明确选择的项目内文件,为每个文件绑定相对路径、字节数与 SHA-256,并在签发前执行 256 文件、100 MiB 与既有 256 KiB 控制信封限额。纯代码/普通文本不弹框,直接发送并显示一行透明提示;检测到电话(含裸中国手机号/E.164)、邮箱、身份证样式、私钥/凭据或敏感路径时,才按批准稿展示可展开逐文件清单、用途和保留说明,由用户逐次同意。取消、范围不明、分类异常、账号切换、Alpha Web 一次性凭据签发失败或清单指纹不匹配均不发送;renderer/agent、MCP、定时任务与模型附件都拿不到 manifest/token 通道,既有 git diff/code-review 派发保持可用。 - **设置现在由 Alpha 自有页面统一管理**(REQ-090 #443,需下个签名版本生效)——通用、快捷键与扩展存储检查集中到同一设置页;保存后的值会立即作用于运行中的应用,后续修改其它设置不会把刚保存的内容静默回滚。损坏的本机设置会显示安全默认值并允许直接修复,扩展缓存检查或回收进行中重开页面也会继续恢复状态。 - **权限确认改为 Alpha 自有的安全确认框**(REQ-090 #444,需下个签名版本生效)——旧的输入框内 permission dock 及专属换肤已移除;执行前逐项展示请求携带的主体/执行 Agent、action/capability、resources、scope 与 expiry,不再显示契约占位。你可以选择允许一次、按当前项目始终允许或拒绝;永久授权显式携带项目范围与不过期事实。提交未收到原子决定收据或与既有决定冲突时,工具保持暂停,界面保留请求与原决定供精确重试,绝不会假定已经获准;确认框复用 Alpha Dialog 的焦点、键盘与不可关闭合同。 - **实验室扩展可以「本次会话启用」了**(REQ-104 #408,需下个签名版本生效)——目录里标注「实验室」的连接器,已安装后在「已安装」列表行与详情页出现琥珀色会话开关:打开即对当前项目会话立即生效,状态行显示「本次会话已启用 · 会话结束自动关闭」;会话结束(应用重启、重新登录、引擎重启或崩溃)后自动关闭、开关归位,行保留(卸载才消失),下次使用需再次开启。这类扩展永远不会被持久开启:开启只登记在内存里,不写任何配置或账本,崩溃/重启后零残留。开关只在打开了项目会话时可用(无项目上下文时置灰并说明);开启前照常过安全检查——有安全公示、身份对不上或审核数据无法核实时如实拒绝并保持关闭,安全复审已过期时先弹确认框;开启成功但连接未建立时如实提示「已开启,但连接未成功」,绝不谎报。引擎中途重载(比如安装了别的扩展)会自动重新核验并接回本次会话已开启的实验室扩展,核验不过的当场回落关闭。实验室条目的「即将提供」占位说明同步下线,详情页「启用方式」一段改为讲清真实的会话语义。 diff --git a/docs/contracts/platform-integration.md b/docs/contracts/platform-integration.md index 7c6ed2a8a65b..83564cc29623 100644 --- a/docs/contracts/platform-integration.md +++ b/docs/contracts/platform-integration.md @@ -18,6 +18,7 @@ integration. Service wire formats remain owned by their producer repositories. | Surface | Owner | Desktop seam | | ---------------------------------------------------------------- | -------------------------------- | -------------------------------------------- | | Authorization code, refresh/session rotation, endpoint discovery | `alpha-web` | `alpha-auth.ts`, `alpha-endpoints.ts` | +| Manifest-bound `upload_consent` issuance | `alpha-web` | main-process upload issuer client | | Model gateway and model registry | `alpha-platform` | injected `alpha` provider | | Cloud Jobs HTTP/SSE, artifacts, schedules, MCP facade | `alpha-platform` | main-process clients and injected MCP server | | Account summary and billing transactions | `alpha-platform` account service | main-process account client | @@ -96,6 +97,66 @@ window exists. Cold-start callbacks defer activation until the next normal sidecar start. Logout clears token state and re-forks without platform credentials. +## Explicit cloud file upload and conditional consent + +Only the desktop's explicit Cloud Jobs file picker enters the upload-consent +protocol. Model prompts and attachments are not uploads under this contract. +Existing `input.diff` and `code-review` dispatch remain the v1 +`grandfathered` egress classes: they are neither disabled nor retrofitted with +an upload manifest. Cloud schedules, bounded-agent envelopes, and the MCP +sidecar have no upload-consent field or token channel. + +The main process is the sole upload authority. The renderer can request a +`code-review` file selection and can later confirm or cancel a main-issued +opaque request ID. It cannot provide paths, file bytes, a manifest, a consent +decision, or a token. Main asks the user for one project root and an explicit +set of files, resolves the canonical paths, rejects missing, outside-root, +symlinked, duplicate, non-regular, unreadable, and non-UTF-8 inputs, then reads +and freezes the exact content in memory. An empty or unverifiable selection is +cancelled; it never becomes a whole-project selection. + +For that immutable snapshot, main creates the vendored `UploadManifestV1` with +the access-token `sub` as `tenant_id`, normalized relative paths, byte sizes, +per-file SHA-256 summaries, total count and bytes, creation time, +`retention_class`, the required `explicit.file-upload` egress declaration, and +`consent_required`. It validates the schema plus count/total/path-uniqueness +invariants and hashes the exact JSON string later sent to the Cloud Jobs +gateway. Admission fails before issuance above 256 files, 100 MiB total, or +the existing 256 KiB control-envelope limit. The latter is normally the +tightest v1 bound because explicit UTF-8 contents travel inside that envelope. + +Client classification is intentionally broader than the platform fallback. +It detects email, bare mainland-China mobile numbers, E.164 numbers (including +sentence-final punctuation), Chinese identity-number shapes, private-key and +credential patterns, and credential-sensitive paths. Pure code and unrelated +numeric content do not become sensitive merely for containing numbers. +Classifier exceptions or malformed results fail closed as sensitive. When no +protected information is found, main dispatches immediately and the renderer +shows one non-blocking transparency line. When protected information is found, +the renderer uses the approved house Dialog/Button surface to show the bounded +file preview, findings, purpose, and retention; cancel mints nothing and sends +nothing. + +After confirmation, main reacquires a `cloud.dispatch` access token and +requires the same valid `sub`, then calls the Alpha Web-owned +`POST /auth/upload-consent` issuance seam. The request carries the exact +manifest JSON and its SHA-256. Alpha Web must return the vendored +`upload_consent` JWT branch (`iss=alpha-web`, `aud=alpha-platform-upload`, +`token_use=upload_consent`, `purpose=artifact.upload`). Main checks its subject, +expiry, manifest ID, manifest SHA-256, and egress declaration before sending +the frozen request to Cloud Jobs with `X-Alpha-Upload-Consent`. The desktop +does not call or describe an Alpha Platform issuance API. Deployment of the +real Alpha Web issuer remains an Alpha Web integration prerequisite; desktop +tests use a mocked issuer response. + +Pending consent is one-shot process memory, scoped to the requesting renderer, +and consumed before issuance begins. It is never stored in project prefs. +Tokens, manifests, and file bytes never cross preload; handler return values +are checked again at runtime. Upload errors log only a stable code and omit +bearers, issuer responses, and absolute paths. Any attempt to inject upload +control fields through an ordinary renderer or agent envelope fails with +`upload-main-gate-required`. + ## Managed cloud artifact persistence Cloud artifact bytes remain in the main process and stream to a unique `.part` diff --git a/docs/design/create-upload-manifest-v1.plan.md b/docs/design/create-upload-manifest-v1.plan.md new file mode 100644 index 000000000000..9db6de301fac --- /dev/null +++ b/docs/design/create-upload-manifest-v1.plan.md @@ -0,0 +1,545 @@ +# alpha-code #225 — L 级方案基线 + +> 标题:`[Privacy] Create main-owned cloud upload manifest and consent token` +> 父需求:`alpha-work#10 (Privacy)` +> 复杂度:L(跨仓 wire、安全信任边界、Electron IPC、用户可见确认、发布验证) +> 勘破基线:`origin/alpha@e6507b01`,工作树干净;本次只读,未修改任何文件。 +> Ready 判定:**暂不可升 Ready**。实现方案已收敛,但必须先取得下文列出的 `alpha-platform#32` commit-pinned 契约产物。 + +## ① 只读勘破:当前真实行为 + +### 1. 仓与上游边界 + +| 区域 | 归属 | 当前事实 | +|---|---|---| +| `packages/desktop`、`packages/app` | 上游 OpenCode 既有 | Electron 壳、共享 renderer、附件/文件引用等上游行为。 | +| `packages/ui-mac` | Alpha fork 自有 | 从上游桌面壳派生后加入 Alpha 登录、Cloud Jobs、MCP、自动化、权限与产品 UI;生产 Alpha 包实际从这里构建。 | +| `packages/ui-mac/src/main/attachment-picker.ts` | 上游派生,当前与上游文件字节相同 | main 产生 sender-bound picker token、20 MiB 读取预算;不是上传 consent。 | +| `alpha-cloud-*`、`cloud-ipc.ts`、`cloud-dispatch-box.tsx` | 本 epic 之前已增加 | Cloud Jobs、旧 B16 项目级出境提示、MCP/自动化云能力;均不是 #225 所需一次性 manifest consent。 | +| UploadManifestV1 / `upload_consent` | #225 待新增 | 仓内搜索无实现、无 schema pin、无 golden fixtures。 | + +Alpha 生产包由 [`packages/ui-mac/package.json:14`](/Users/tide/app/alpha-code/.worktrees/225/packages/ui-mac/package.json:14) 的 `build/package:mac/package:win` 构建,electron-builder 打包 `out/**/*`,见 [`electron-builder.config.ts:53`](/Users/tide/app/alpha-code/.worktrees/225/packages/ui-mac/electron-builder.config.ts:53)。因此 #225 的 main/preload 代码会自然进入 asar,不需要为普通 TS 模块另改打包清单。 + +### 2. 现有上传与云交互入口 + +#### A. App 即时 Cloud Jobs 派发(Alpha epic 既有) + +真实调用链: + +1. renderer 在 [`cloud-dispatch-box.tsx:51`](/Users/tide/app/alpha-code/.worktrees/225/packages/ui-mac/src/renderer/extensions/cloud-dispatch-box.tsx:51) 让用户选择项目目录。 +2. renderer 调 `window.api.cloud.gitDiff(directory)`;main 执行 `git diff`,再把完整 diff 字符串返回 renderer。 +3. renderer 构造 `input: { diff }`,经 `window.api.cloud.dispatch(envelope, directory)` 派发。 +4. preload 只是透传,见 [`preload/index.ts:227`](/Users/tide/app/alpha-code/.worktrees/225/packages/ui-mac/src/preload/index.ts:227)。 +5. main 的 [`cloud-ipc.ts:90`](/Users/tide/app/alpha-code/.worktrees/225/packages/ui-mac/src/main/cloud-ipc.ts:90) 接收 renderer 提供的完整 envelope 和可选 directory。 +6. [`alpha-cloud-jobs.ts:30`](/Users/tide/app/alpha-code/.worktrees/225/packages/ui-mac/src/main/alpha-cloud-jobs.ts:30) 使用 main-held platform access token,将 `JSON.stringify(envelope)` POST 到 Cloud Jobs。 + +当前缺口: + +- diff 内容先进入 renderer,main 没有内容权威快照; +- 没有文件级 path/size/digest; +- 没有 UploadManifestV1; +- 没有平台签发的 `upload_consent`; +- `directory` 是可选参数,省略时旧 consent 被直接跳过; +- renderer 可以自造 envelope,也可以用非空 `denied_paths` 替换默认规则。 + +#### B. 旧 B16 cloud consent(Alpha epic 既有,必须退出授权角色) + +[`alpha-cloud-consent.ts:1`](/Users/tide/app/alpha-code/.worktrees/225/packages/ui-mac/src/main/alpha-cloud-consent.ts:1) 只定义: + +```ts +{ version: 1, acceptedAt: string } +``` + +main 在 [`cloud-ipc.ts:52`](/Users/tide/app/alpha-code/.worktrees/225/packages/ui-mac/src/main/cloud-ipc.ts:52): + +- 读取 `/.alpha/prefs.json`; +- 若当前版本已同意,永久放行该项目后续派发; +- 否则显示 main 发起的原生 `dialog.showMessageBox`; +- 同意后持久化 `cloudConsent`; +- `directory` 缺失时不弹、不拒绝,直接派发。 + +这只是“项目首次告知”,不是内容级 consent。它无法绑定 tenant、具体文件、字节、purpose、retention,也没有过期、一次性或重放语义。#225 后历史 `cloudConsent` 字段可以作为未知 prefs 数据保留,但**绝不能再提供任何上传授权**;不做迁移或兼容 shim。 + +#### C. Agent → Cloud MCP(Alpha epic 既有旁路) + +[`sidecar.ts:361`](/Users/tide/app/alpha-code/.worktrees/225/packages/ui-mac/src/main/sidecar.ts:361) 在 platform 模式下注册远程 `mcp.cloud`,其 bearer 由 main 写入 `0600` 文件,再以 `{file:...}` 引用交给 sidecar。 + +这条路径: + +- 不经过 Electron `cloud-dispatch` IPC; +- agent 可以调用远程 cloud 工具; +- sidecar 当前持有的是 platform access token,不是 upload consent; +- main 无法在本地拦截远程 MCP 工具参数。 + +因此 #225 必须保证 upload consent 永不进入 sidecar/MCP;MCP upload 的禁止必须由 #33 服务端 gate 兜底。现有非上传 MCP Cloud Jobs 可以继续存在。 + +#### D. Scheduled Cloud Jobs(Alpha epic 既有旁路) + +[`alpha-cloud-schedules.ts:66`](/Users/tide/app/alpha-code/.worktrees/225/packages/ui-mac/src/main/alpha-cloud-schedules.ts:66) 构造固定 research envelope,保存时由 main 直接注册云 schedule。 + +当前它只发送任务文本,不选择本地文件。#225 不为它增加 upload manifest/token,也不允许 renderer 将即时 upload proof 复用到 scheduled job。 + +#### E. Composer 附件与文件引用(上游及 fork 既有,需明确排除) + +上游 renderer 会把图片/PDF读成 data URL,见 [`packages/app/src/components/prompt-input/attachments.ts:11`](/Users/tide/app/alpha-code/.worktrees/225/packages/app/src/components/prompt-input/attachments.ts:11)。Alpha Composer 也在 renderer 中处理图片/PDF,见 [`composer-attachments-core.ts:1`](/Users/tide/app/alpha-code/.worktrees/225/packages/ui-mac/src/renderer/alpha-ui/composer-attachments-core.ts:1)。 + +这些内容进入 Session/模型调用链,不进入 Cloud Jobs upload endpoint。根据本票给出的 #32/#33 地图,#225 的 UploadManifestV1 范围应限定为: + +> **Cloud Jobs 即时 HTTP 上传 payload,不含普通模型 prompt/attachment。** + +若父需求的“任何 Alpha 云上传”字面上也包括模型 prompt 附件,则当前 #32 契约不足以覆盖,必须另行登记需求;不得在 #225 中暗自扩大协议。 + +#### F. Artifact 下载(Alpha epic 既有,非本票上行) + +Cloud artifact 下载已由 main 持 bearer、流式落 `.part`、校验 size/sha256,再原子完成。它是云到本地的入站链,不是 #225 上传产生端。 + +### 3. main ↔ renderer IPC 与信任模型 + +主窗口在 [`windows.ts:164`](/Users/tide/app/alpha-code/.worktrees/225/packages/ui-mac/src/main/windows.ts:164) 明确设置: + +- `contextIsolation: true` +- `nodeIntegration: false` +- `sandbox: true` +- `webviewTag: false` +- off-origin navigation/popup 拒绝或外置 + +preload 仅通过 `contextBridge.exposeInMainWorld("api", api)` 暴露窄 API,见 [`preload/index.ts`](/Users/tide/app/alpha-code/.worktrees/225/packages/ui-mac/src/preload/index.ts)。 + +这些设置限制 renderer 能力,但**不使 IPC 参数可信**。当前 preload 仍允许 renderer 提供: + +- 完整 `CloudJobEnvelope`; +- 任意字符串 directory; +- `input` 中任意键值; +- 可省略 directory。 + +因此 #225 必须把 renderer 视为“只能提出动作意图”的不可信调用方。manifest、tenant、文件快照、purpose、retention、token 和最终 upload body 都不能由 renderer 提供。 + +### 4. 现有 consent/permission UI + +| UI | 当前作用 | 可否作为 #225 权威 | +|---|---|---| +| B16 原生 `dialog.showMessageBox` | 项目首次出境告知,写 `prefs.json` | 原生 main 对话框机制可复用;持久项目级授权语义必须删除。 | +| Alpha `PermissionDialog` | 展示 SessionV2 permission request,支持 once/always/reject | 不可复用为上传权威。它在普通 renderer 中运行,且 `always` 与上传的一次性、精确 scope 相冲突。 | +| 原生 open-file/open-directory picker | main 发起并返回选择结果 | 可作为用户文件选择入口,但 #225 应在同一 main 调用中消费选择,不把文件字节或授权 token交 renderer。 | + +### 5. 现有 path/size/digest 能力 + +- [`attachment-picker.ts:4`](/Users/tide/app/alpha-code/.worktrees/225/packages/ui-mac/src/main/attachment-picker.ts:4) 有 20 MiB 总预算、sender 绑定和一次读取,但会把字节交 renderer,也没有相对路径、SHA-256 或 consent 绑定。 +- [`cloud-envelope-guard.ts:13`](/Users/tide/app/alpha-code/.worktrees/225/packages/ui-mac/src/main/cloud-envelope-guard.ts:13) 只计算 JSON envelope 的 UTF-8 大小,上限仍是旧 1 MiB;它不计算实际上传文件字节。 +- 该 guard 会接受 renderer 提供的非空 `denied_paths` 并完全尊重,见 [`cloud-envelope-guard.ts:57`](/Users/tide/app/alpha-code/.worktrees/225/packages/ui-mac/src/main/cloud-envelope-guard.ts:57),不能承担 consent scope 权威。 +- [`alpha-workdir.ts:45`](/Users/tide/app/alpha-code/.worktrees/225/packages/ui-mac/src/main/alpha-workdir.ts:45) 有 realpath、symlink 和 `.alpha` 输出圈禁,但服务的是本地 managed run 输出路径;不能直接等同于项目上传枚举器。 +- 当前 Cloud Jobs 上行没有逐文件 SHA-256、实际传输字节数或发送前 immutable snapshot。 + +### 6. 打包与用户可见证据链 + +- L0 权威门是 [`scripts/alpha-check.sh`](/Users/tide/app/alpha-code/.worktrees/225/scripts/alpha-check.sh),执行 `ui-mac` typecheck 和全部 `src` 单测。 +- `ui-mac` 构建入口是 `electron-vite build`,打包入口为 `package:mac/package:win`。 +- 已有 RC packaged 证据格式见 [`docs/verification/2026-07-17-packaged-macos-rc-smoke.md`](/Users/tide/app/alpha-code/.worktrees/225/docs/verification/2026-07-17-packaged-macos-rc-smoke.md)。 +- #225 的 packaged 证据应追加到下一个 RC 的统一 smoke,不修改或覆盖历史验证文档,也不为单票重复建一轮 L3。 + +--- + +## ② 选定方案与被否决替代 + +### Ready 前的契约阻塞 + +当前给出的 #32 摘要尚不足以逐字实现: + +1. UploadManifestV1 字段表没有 `purpose` 或 `retention`,但 AC1 要求两者与 manifest 绑定。 +2. `upload_consent` 摘要没有 retention claim;#33 明确要求 retention、TTL、clock skew、expiry≤retention。 +3. 未给出 canonical JSON bytes:字段顺序、数组排序、数字表示、Unicode normalization、hash 编码。 +4. 未给出 consent issuance API 的 method/path/request/response。 +5. 未给出 `iss`、JWT header `alg/kid/typ`、轮换和失败码的确切值。 +6. 未定义 Cloud Jobs 中上传 payload 的唯一 wire 位置,因此当前 `input.diff` 是否属于上传旁路无法机械判定。 +7. 未给出 pipeline `kind/operation` 到 upload purpose/scope 的精确映射。 +8. 本仓没有 #32 发布的 schema 或 golden fixtures,也没有 immutable commit pin。 + +Ready 门: + +- pin `alpha-platform#32` 不可变 commit; +- vendor/pin 其 schema 与全部 upload golden fixtures,并记录来源; +- #32 明确上述字段和 issuance/upload HTTP wire; +- #33 确认同一 commit/fixtures。 + +不能依据摘要自行发明字段或 canonicalizer。 + +### 选定的权威流 + +```text +不可信 renderer + │ 只提交“即时 pipeline 上传意图” + ▼ +Electron main + ├─ 验证登录身份、pipeline/kind/operation allowlist + ├─ main 发起原生文件/目录选择 + ├─ 展开并校验精确 project-relative 文件集合 + ├─ 一次读取到 main-owned bounded snapshot,计算 size/sha256 + ├─ 生成 canonical UploadManifestV1 bytes + manifest_sha256 + ├─ main 原生对话框展示完整 scope/purpose/retention + ├─ 用户取消 → 丢弃 snapshot,零签发、零上传 + ├─ 用户确认 → main 调平台 consent issuance API + ├─ main 收取 opaque、一次性 upload_consent token + └─ 同一函数立即发送同一 manifest bytes + 同一 snapshot bytes + └─ 无论成功/超时/失败都销毁本地 proof;重试必须重新预览并取得新 token +``` + +### 1. IPC 形状 + +新增 upload IPC 只能接收意图,例如: + +- project directory hint; +- pipeline kind/operation; +- 选择模式; +- 其他非内容控制项。 + +preload 和 renderer 类型中明确**不得出现**: + +- `manifest` +- `manifest_id` +- `manifest_sha256` +- `tenant_id` +- `consent_token` +- `jti` +- 文件字节/data URL +- renderer 自由填写的 purpose/retention +- renderer 自由填写的最终文件列表 + +用户选择应由 upload IPC handler 内部直接打开原生 picker。picker 结果留在 main,不先返回 renderer。 + +现有通用 `cloud.dispatch(envelope, directory?)` 必须增加上传旁路检测: + +- 发现 #32 定义的任何上传内容时,返回稳定的 `upload-main-gate-required` 类错误; +- 不允许把 manifest/token 作为普通 envelope 字段传入; +- 当前 `input.diff` 若被 #32 判为上传内容,必须改为 main 内生成并通过唯一 upload payload 发送; +- 在 #32 尚未定义 diff 表示前,宁可禁用该 code-review 上传入口,也不得继续隐式绕过。 + +### 2. main-owned 文件 scope 与 snapshot + +main 按 #32 精确规则处理: + +1. project directory 必须存在、为绝对非根目录并可确认真实身份。 +2. 用户未选择、目录缺失、目录在枚举中消失、空目录都产生空 scope 并拒绝;绝不回退项目根。 +3. 目录选择立即展开为当时的具体 regular files;之后新增文件不进入 manifest。 +4. 每段路径均拒绝 symlink;拒绝 socket/device/FIFO 等非普通文件。 +5. 文件必须位于 canonical project root 内。 +6. 生成 contract 指定的 POSIX project-relative path;拒绝 absolute、盘符、反斜杠、NUL、`.`、`..`。 +7. Unicode normalization 和规范化后重复判定完全依 #32,不自行假定 NFC/NFD。 +8. 文件集合按 #32 canonical order 排列。 +9. 一次读取每个文件到 main-owned immutable `Buffer` snapshot,同时计算实际 `size_bytes` 和 SHA-256。 +10. 对 256 文件、100 MiB 总量及 256 KiB control envelope 在副作用前 fail-closed。 +11. 最终上传必须使用同一 snapshot,不在用户确认后重新读取磁盘。 + +100 MiB 是明确有界内存,首版直接保留 snapshot 最简单且能消除 preview→upload TOCTOU。改为临时文件 spool 只可作为后续内存优化参考,不是 #225 门控项。 + +### 3. tenant、purpose、retention + +- tenant 只从 main-held、登录流程取得的 platform access JWT 严格提取 `sub`;不得使用邮箱、显示名、renderer 值或路径。 +- 本地解析 `sub` 仅用于构造 manifest;最终权威仍是平台对 access token 签名的验证以及 `upload_consent.sub == manifest.tenant_id` 比较。解析失败即拒绝。 +- purpose 由 main 根据 #32 发布的 `autonomy + kind + operation` 映射表计算。#33 已禁止 bounded-agent file upload,首版只允许列明的 pipeline operation。 +- retention 由 main 从 #32 发布的受控 policy enum/期限中选择并展示;renderer 不能要求更宽或更长 retention。 +- 平台不支持的 retention 必须在 issuance 或 upload 前拒绝,客户端不得静默延长。 + +按当前字段摘要,不在 UploadManifestV1 擅加 purpose/retention 字段。最小兼容解释是: + +> token claims 绑定 purpose/retention,token 又通过 `manifest_sha256` 绑定完整 manifest bytes,因此“manifest + consent proof”整体绑定 purpose/retention。 + +但这只有在 #32 明确批准 retention claim 和该复合绑定语义后才成立。若 AC1 要求两字段物理存在于 manifest JSON,必须由 #32 更新 schema;#225 不能单仓扩展 v1。 + +### 4. 预览与取消 + +首版沿用 main 发起的原生 Electron message box,而不是普通 renderer Dialog: + +- 默认焦点与 Escape 均为“取消”; +- 正向按钮写明“同意本次上传”,不提供“始终允许”; +- detail 必须列出全部 canonical relative path、每项 size、file count、total bytes、purpose、retention; +- token、tenant、manifest hash 不显示; +- 需显式 checkbox/ack 后才接受; +- 对话框结果只在 main 内消费。 + +如果原生对话框无法完整呈现全部路径,必须以 `preview-too-large` 拒绝并要求减少选择;不得退化为“256 个文件”之类摘要 consent。单独开发可滚动的特权 consent window 仅作为未来可选参考,不属于当前 4 条 AC 的最简实现。 + +取消行为: + +- picker 取消:不生成 manifest、不签发、不派发; +- preview 取消:丢弃 manifest/snapshot,不调用 issuance; +- issuance 后发生网络取消/超时:token 作废且不重用;重试从重新选择、预览、确认开始; +- job 创建成功后的 job cancel 沿用现有 `cloud-cancel`,不改变 upload consent 已消费事实。 + +### 5. token 生命周期 + +- token 是平台签发的 opaque JWT,只存在 main 内存; +- 不写 `prefs.json`、electron-store、日志、crash report、run contract; +- 不进入 preload、renderer、sidecar、MCP、自动化或 Session; +- 本地 proof 在网络请求开始前原子标记 consumed,避免并发双发; +- 任一结果都删除本地引用; +- 不因 timeout、5xx、backend failure 自动复用; +- 不复用 platform access token、Job token 或旧 B16 boolean; +- 平台 #33 仍负责全局 `jti` 一次性、并发最多一个成功和 replay tombstone。 + +### 6. main 是唯一 consent 权威的论证 + +renderer 能做的只有请求 main 开始一次流程。它不能: + +- 选择最终文件集合:原生 picker 结果由 main 直接消费; +- 提供或修改 manifest:类型和 IPC 都不接收该字段; +- 修改 tenant:取自 main-held身份; +- 放大 purpose/retention:main 使用固定映射; +- 伪造用户确认:确认由 main 发起的原生对话框产生; +- 替换确认后的文件内容:上传使用确认前已形成的 main snapshot; +- 取得 token:token 不出 main; +- 调通用 dispatch 携带内容:生产 gate 在发送前拒绝; +- 通过 agent/MCP/schedule 获得 token:这些进程和接口从未收到 upload consent。 + +agent 即使调用远程 MCP,也只有 platform access token;服务端 upload gate 会因缺少 `upload_consent` 拒绝。客户端不宣称自己能拦截 MCP 网络流。 + +### 7. 被否决的替代 + +| 替代 | 否决理由 | +|---|---| +| 继续使用 `.alpha/prefs.json cloudConsent` | 项目级、长期、无文件/tenant/digest/retention,一次同意可无限放大。 | +| renderer 生成 manifest 或传 consent boolean | renderer 可换路径、大小、purpose,或直接伪造确认。 | +| 复用 Alpha `PermissionDialog` 的 `once/always` | 普通 renderer 不是本票权威;`always` 与一次性精确 scope 冲突。 | +| main 本地自签 JWT | #33 只接受平台注册信任根;客户端自签不被接受。 | +| 复用 platform access token / Job token | `aud/token_use/purpose` 错误,且没有 manifest hash 与 replay 语义。 | +| 目录名或 glob 作为 scope | 无法精确验证;新增文件会自动获得 consent,违反 AC3。 | +| preview 后重新读原文件 | 存在 TOCTOU,确认的摘要与发送内容可不同。 | +| 只展示 count/total,不展示全部 path | 用户没有看到实际目录展开结果,不是精确 path scope consent。 | +| 让现有通用 `cloud.dispatch` 接受可选 manifest/token | 保留旁路,renderer/MCP 可绕过 main admission。 | +| 为 #225 自建“更通用”的跨仓 consent 框架 | 会形成需与 #32/#33 逐点同步的新真相源;本票只 pin schema、serializer、fixtures 和单一即时上传路径。 | + +### 8. 红旗自检 + +- **阻塞**:#32 的 manifest 字段与 AC1 的 purpose/retention 表述不一致。 +- **阻塞**:无 canonical bytes、hash encoding、JWT header/claim、issuance API 和 upload payload wire。 +- **阻塞**:本仓没有可 pin 的 schema/golden fixtures。 +- **阻塞**:当前 `input.diff` 已传本地内容,但尚无契约说明如何放入 UploadManifestV1。 +- **范围红旗**:模型 prompt/attachment 也是本地内容出境,但不属于所给 Cloud Jobs manifest 契约;父票需明确排除或另票承载。 +- **UI 红旗**:原生对话框若不能完整显示某一 scope,必须拒绝,不能静默截断。 +- **禁止声明完成**:仅有 main-held access bearer、secret scan 或旧 PIPL 告知,不等于 upload consent。 + +--- + +## ③ 安全面与必守不变量 + +### 攻击/边界整类 + +| 类别 | 典型攻击 | 实现控制 | +|---|---|---| +| renderer 伪造 consent | 直接调用 confirm、提交假 token/manifest | 原生 main 对话框;IPC 不接收 proof;sender 只能发 intent。 | +| renderer 放大 scope | 替换 projectDir、加路径、把单文件改目录 | main 原生 picker、root 圈禁、完整 preview、main snapshot。 | +| agent 放大或伪造 | MCP/工具参数里塞 manifest、token、files | token 不进 sidecar;服务端同一 upload gate;客户端不开放 MCP upload。 | +| 缺失目录→项目根 | `undefined`、空字符串、ENOENT 被解释为 root | 缺失/空/不可确认全部返回零 scope 并拒绝。 | +| path traversal | absolute、`..`、盘符、UNC、反斜杠、NUL | #32 路径 validator,POSIX project-relative only。 | +| normalization collision | Windows 大小写、Unicode 等价、重复路径 | 依 #32 canonicalization 后再判重;任一重复整单拒绝。 | +| symlink escape | 文件、目录或中间组件指向项目外 | 对每段 `lstat`;任何 symlink 拒绝,不跟随授权。 | +| preview→send TOCTOU | 用户确认后原文件被改写/替换 | manifest 和上传都使用同一 main-owned snapshot。 | +| size 绕过 | 字符数代替 UTF-8 bytes、base64前后口径不同、stat 后增长 | 以实际 snapshot/实际解码后传输 bytes 为准;size/digest 同一次读取产生。 | +| 摘要错绑 | hash 普通 object,而发送另一序列化形式 | token hash 绑定实际发送的 canonical manifest byte array。 | +| purpose confusion | code-review token用于 research/agent | main 固定映射;token scope/purpose与 route action 精确比较。 | +| tenant confusion | 邮箱、显示名、renderer tenant | 仅 access JWT `sub`;平台再次比较认证 tenant。 | +| retention 放大 | renderer 要求“永久”、服务端静默延长 | main enum allowlist;平台不支持即拒;expiry≤retention。 | +| token replay | timeout 后重试、双击、并发提交 | main 预消费且不重用;平台 `jti` tombstone 保证全局单成功。 | +| token 泄漏 | renderer、日志、contract.json、错误详情 | opaque main-only;稳定错误码;静态 token-surface ratchet。 | +| preview 泄漏/欺骗 | 把本地路径写日志、只显示摘要、截断隐藏文件 | 路径仅出现在用户主动打开的原生预览;完整或拒绝;日志无路径。 | +| generic dispatch 旁路 | `input.files/csv/code/diff`、额外 payload | #32 定义的 content predicate 在发送前统一拒绝,只有 upload handler 能调用上传 transport。 | +| scheduled/MCP/bounded-agent upload | 把即时 consent 搬到其他入口 | token 不暴露;三类入口无 proof 参数;服务端拒绝。 | +| 版本降级/未来版本 | 缺失、未知或未来 schema | producer 只产 `schema_version:1`;consumer fixture/test 对其他版本拒绝。 | + +### 必守不变量 + +1. **Consent 唯一权威在 Electron main。** +2. **renderer/agent 只能提出意图,不能提供 manifest、scope、tenant、purpose、retention、token 或上传字节。** +3. **一个 consent 对应一个 immutable byte snapshot、一个 manifest_id、一个 manifest hash、一个 token。** +4. **preview 的文件集合与实际发送集合逐项相同;无法完整 preview 就拒绝。** +5. **空 scope、缺失目录、空目录、无选择永远表示“零文件获准”,绝不表示 root/wildcard。** +6. **目录只授权预览时展开的文件;后续新增文件不自动获得授权。** +7. **manifest canonical bytes 是 hash、issuance 和 upload 三处共同的同一字节数组。** +8. **purpose/retention 由 main 的严格映射产生,且与 token/route/manifest hash 联合绑定。** +9. **token 一次性、短期、main-only、不持久化、不自动重用。** +10. **任何 path/size/digest/tenant/purpose/retention/token/content 不匹配都 fail-closed。** +11. **错误响应和日志不回显 token、tenant、manifest、文件名或本地绝对路径。** +12. **旧 `cloudConsent`、access token、Job token 和 renderer permission receipt 均不构成 upload consent。** +13. **即时 HTTP 是首版唯一上传入口;MCP、scheduled、bounded-agent upload 不开放。** +14. **无兼容 shim、无 legacy 并行接受、无未知字段宽松解析。** + +--- + +## ④ 子票切分与验证基线 + +### 是否继续拆票 + +**不再拆 CODE 子票。** + +#225 已是 `alpha-work#10` 的实现票,而安全正确性要求 manifest snapshot、预览、token issuance、通用旁路拒绝和最终 upload 在同一变更中接通。拆成“先生成 manifest”“以后再接 gate”会产生可合并但无生产权威的中间状态。 + +不新建 PLAN/DECIDE: + +- 未决事实由 `alpha-platform#32` 在其现有契约票中发布; +- #225 等 commit-pinned 契约后直接实现; +- packaged 验证进入统一 RC checklist,不为 #225 单独重复一张 L3 票。 + +若 portfolio 已有 Privacy capability VERIFY 票,将 #225 的 L1/L3 行并入;没有则父票保持 open,等待下一 RC 证据,不妨碍 CODE 合并。 + +### 具体文件边界 + +| 文件 | 计划变化 | +|---|---| +| `packages/ui-mac/src/main/alpha-upload-manifest.ts`(新) | 严格 contract decode、路径枚举、immutable snapshot、size/digest、canonical bytes/hash;纯 main/electron-free。 | +| `packages/ui-mac/src/main/alpha-upload.ts`(新) | main 上传状态机:原生 picker、预览、取消、issuance、一次性 consume、最终发送。 | +| `packages/ui-mac/src/main/alpha-upload-manifest.test.ts`(新) | golden vectors、Windows/Unicode/duplicate/symlink/missing/limits/digest 测试。 | +| `packages/ui-mac/src/main/alpha-upload.test.ts`(新) | consent、取消、并发、重放、错误卫生、实际生产调用链测试。 | +| `packages/ui-mac/src/main/testvectors/upload-manifest-v1/*`(新) | 从 #32 immutable commit 原样 pin 的 schema/fixtures/SOURCE;禁止手编预期值。 | +| [`alpha-auth.ts`](/Users/tide/app/alpha-code/.worktrees/225/packages/ui-mac/src/main/alpha-auth.ts) | main-only 严格 tenant `sub` 提取;不加入 renderer `AuthState`。 | +| [`alpha-cloud-jobs.ts`](/Users/tide/app/alpha-code/.worktrees/225/packages/ui-mac/src/main/alpha-cloud-jobs.ts) | 加入 main-only issuance/upload transport;不得导出 token;使用 #32 control/payload limits。 | +| [`cloud-envelope-guard.ts`](/Users/tide/app/alpha-code/.worktrees/225/packages/ui-mac/src/main/cloud-envelope-guard.ts) | 对通用 dispatch 的 upload content/manifest/token fail-closed;不再以旧 1 MiB 规则替代 v1 256 KiB control limit。 | +| [`cloud-ipc.ts`](/Users/tide/app/alpha-code/.worktrees/225/packages/ui-mac/src/main/cloud-ipc.ts) | 注册唯一 upload handler;删除旧 per-project consent 放行和 optional-directory upload 行为;生产入口只调用 `alpha-upload`。 | +| [`alpha-cloud-consent.ts`](/Users/tide/app/alpha-code/.worktrees/225/packages/ui-mac/src/main/alpha-cloud-consent.ts) | 移除 `CLOUD_CONSENT_VERSION/hasCloudConsent/withCloudConsent` 授权语义;旧字段不迁移、不采信。 | +| [`alpha-workdir.ts`](/Users/tide/app/alpha-code/.worktrees/225/packages/ui-mac/src/main/alpha-workdir.ts) | generic prefs parser 与旧 cloud consent 解耦,继续服务 extension prefs。 | +| [`preload/types.ts`](/Users/tide/app/alpha-code/.worktrees/225/packages/ui-mac/src/preload/types.ts) | 新增窄 upload intent/result;不得声明 manifest/token/tenant/bytes。 | +| [`preload/index.ts`](/Users/tide/app/alpha-code/.worktrees/225/packages/ui-mac/src/preload/index.ts) | 仅透传 upload intent;无 proof 返回面。 | +| [`cloud-dispatch-box.tsx`](/Users/tide/app/alpha-code/.worktrees/225/packages/ui-mac/src/renderer/extensions/cloud-dispatch-box.tsx) | 改走 main upload flow;展示稳定取消/失败结果,不再持有 diff/file bytes。 | +| `docs/contracts/platform-integration.md` | 同变更更新 canonical contract pin、唯一上传入口、main authority、旁路禁止和错误语义。 | +| `docs/contracts/platform-endpoint-discovery.md` | 仅当 #32 发布新的独立 issuance base 时更新;若复用 cloud base 则不改。 | + +无需改: + +- `packages/desktop`、`packages/app` 等上游路径; +- Protocol/Server `HttpApi`; +- generated client; +- electron-builder files 清单。 + +### AC → 实现边界 → 验证 + +| AC | 实现边界 | L0 | L1 测试名 | L3 | +|---|---|---|---|---| +| AC1 manifest 绑定 tenant、path scope、size、purpose、retention、摘要 | `alpha-upload-manifest.ts`、`alpha-auth.ts`、#32 fixtures、token issuance request | `bash scripts/alpha-check.sh` | `upload manifest matches alpha-platform v1 canonical golden bytes and manifest_sha256`; `upload proof binds access-token sub, exact paths, actual byte sizes, digests, purpose and retention`; `upload transport sends the exact snapshotted bytes described by the manifest`; `future or missing schema_version is rejected` | RC stub 记录请求的 canonical hash/count/bytes 与本地 fixture 相同,不记录 token/tenant/path。 | +| AC2 renderer/agent 无法伪造或放大 | `cloud-ipc.ts`、`preload/*`、`cloud-envelope-guard.ts`、`alpha-upload.ts`、`sidecar.ts` 静态边界 | 同上 | `renderer upload intent cannot provide manifest tenant purpose retention token or bytes`; `forged manifest or consent fields on generic cloud dispatch are rejected before network`; `production cloud upload IPC calls main admission exactly once and cannot call raw dispatch`; `upload consent token never crosses preload renderer sidecar logs or persisted run state`; `concurrent or retried local proof is consumed at most once`; `MCP schedule and bounded-agent surfaces have no upload consent channel` | packaged app 中通过真实 `window.api` 尝试附加伪 proof,用户只见稳定拒绝码且 loopback stub 零请求。 | +| AC3 缺失目录绝不变整项目 consent | `alpha-upload-manifest.ts`、main picker、scope validator | 同上 | `missing project directory yields zero scope and never resolves to project root`; `cancelled or empty selection never issues consent`; `missing or empty selected directory authorizes zero files`; `directory consent contains only the files expanded for preview`; `files created after preview are not uploaded`; `absolute traversal backslash NUL symlink and normalized duplicates fail closed` | packaged fixture 选择空目录/随后新增文件,预览无隐式 root;stub 接收文件集合与预览一致。 | +| AC4 取消、预览、packaged 用户可见证据 | `alpha-upload.ts` 原生 dialog、renderer 错误呈现、RC smoke | 同上 | `picker cancellation performs no issuance or upload`; `preview cancellation performs no issuance or upload`; `native consent preview lists every path size purpose retention and defaults to cancel`; `unrenderable preview is rejected instead of summarized`; `cloud upload UI reports cancellation without creating a job id` | 下一 RC 的统一 packaged smoke 增加“完整预览截图 + Cancel + stub issuance/upload 计数均为 0”;再跑一次确认路径,证明成功 job 使用同一 manifest hash。 | + +### 分层执行 + +L0: + +```bash +bash scripts/alpha-check.sh +``` + +L1: + +```bash +bun run --cwd packages/ui-mac typecheck +bun run --cwd packages/ui-mac test +``` + +L1 必须包含一条从生产 IPC 注册入口到 `alpha-upload` 的断言,不能只测孤立 serializer。 + +L2/L3: + +- 本变更有用户可见原生 preview;截图由 packaged RC 取证即可,不重复建立一套开发态视觉证据。 +- 下一个 RC 按 [`distribution.md:45`](/Users/tide/app/alpha-code/.worktrees/225/docs/runbooks/distribution.md:45) 产包。 +- 原生 dialog 在所发平台矩阵执行;macOS/Windows 均发布时均需覆盖。 +- 使用非敏感 fixture 路径;截图、日志与验证文档不得出现真实 tenant、token 或用户私有文件名。 +- CODE 合并不等于父需求完成;父票仅在 AC1–AC4 对上述证据逐条 PASS 后由验收人手工关闭。 + +## CONTRACT ALIGNMENT + +### UploadManifestV1 + +按当前 `alpha-platform#32` 摘要,本票只能生成以下已发布字段,不添加私有扩展: + +```json +{ + "schema_version": 1, + "manifest_id": "", + "tenant_id": "", + "created_at": "", + "file_count": 0, + "total_bytes": 0, + "files": [ + { + "path": "", + "size_bytes": 0, + "sha256": "", + "media_type": "" + } + ] +} +``` + +逐字不变量: + +- `schema_version == 1` +- `file_count == files.length` +- `total_bytes == Σ files[].size_bytes` +- `files[].path` 唯一、规范化、POSIX relative +- 禁止 absolute、盘符、反斜杠、NUL、`.`、`..`、symlink、规范化后重复 +- 最多 256 files +- `total_bytes <= 100 MiB` +- `size_bytes` 与 `sha256` 基于实际发送/实际解码后的 bytes +- manifest control envelope 在发送/持久化前满足 256 KiB 全开销限制 +- `manifest_sha256` 必须计算实际发送的完整 canonical UTF-8 manifest bytes + +当前 schema 未包含 `purpose`、`retention`。#225 不得自行增加;#32 必须选择并发布以下两种之一: + +1. 把它们作为 UploadManifestV1 字段;或 +2. 明确规定由下述 token claims 经 `manifest_sha256` 对 manifest 作复合绑定。 + +### `upload_consent` token + +当前已知 JWT claims: + +```json +{ + "schema_version": 1, + "iss": "<#32-defined upload consent issuer>", + "aud": "alpha-platform-upload", + "sub": "", + "token_use": "upload_consent", + "purpose": "artifact.upload", + "scope": ["artifact.upload", "<#32-defined exact pipeline binding>"], + "iat": 0, + "exp": 0, + "jti": "", + "manifest_id": "", + "manifest_sha256": "", + "<#32-defined retention claim>": "" +} +``` + +JWT header 也必须由 #32 逐字发布: + +```json +{ + "alg": "", + "kid": "", + "typ": "<#32-defined token type>" +} +``` + +必须对齐的比较关系: + +- `token.sub == manifest.tenant_id` +- `token.manifest_id == manifest.manifest_id` +- `token.manifest_sha256 == SHA256(exact transmitted manifest bytes)` +- `token.purpose` 在 `token.scope` 中,且等于当前 upload route action +- pipeline kind/operation 与 scope 精确匹配 +- token retention 与用户预览值精确匹配 +- `exp <= retention deadline` +- 过期、未来版本、未知 `kid`、错误 issuer/audience/token_use/purpose 均拒绝 +- `jti` 首次成功 consume 后不可重放;并发最多一个成功 +- timeout/backend failure 后客户端重新取得新 consent,不复用旧 token +- token 不用于 MCP、scheduled upload 或 bounded-agent file upload + +### #32 必须随 pin 一并提供 + +- UploadManifestV1 JSON schema; +- canonicalization 与 hash encoding; +- consent issuance HTTP request/response; +- 即时 upload HTTP payload 的唯一位置; +- purpose/retention 精确字段与映射; +- JWT issuer/header/TTL/skew/rotation; +- 稳定错误码族; +- 正常、Windows/Unicode、遗漏目录、重复规范路径、超限、digest mismatch、过期 token、并发 replay golden fixtures。 + +在这些产物发布并 pin 前,#225 保持 **Not Ready**;发布后按本基线实施,不再重新设计信任边界。 diff --git a/packages/alpha-contracts-consumer/src/contracts.test.ts b/packages/alpha-contracts-consumer/src/contracts.test.ts index 05270a9057ef..7bd1f6cbfea2 100644 --- a/packages/alpha-contracts-consumer/src/contracts.test.ts +++ b/packages/alpha-contracts-consumer/src/contracts.test.ts @@ -9,7 +9,10 @@ import { decodeContract, decodeJsonContract, decodeTokenClaims, + decodeUploadConsentClaims, requireTokenPurpose, + type UploadConsentClaimsV1, + type UploadManifestV1, validateFixture, } from "./index" @@ -127,6 +130,57 @@ describe("identity, account, and artifact fail closed", () => { expect(validateFixture(invalid)).toBe(true) expect(() => decodeContract("ArtifactDescriptorV1", invalid.value, "artifact")).toThrow(ContractIncompatibleError) }) + + test("decodes the vendored alpha-web upload_consent branch and rejects a platform issuer", () => { + const value: UploadConsentClaimsV1 = { + schema_version: 1, + iss: "alpha-web", + aud: "alpha-platform-upload", + sub: "tenant-a", + token_use: "upload_consent", + purpose: "artifact.upload", + scope: ["artifact.upload"], + iat: 1, + exp: 2, + jti: "consent-1", + manifest_id: "manifest-1", + manifest_sha256: "a".repeat(64), + egress: [{ egress_class: "explicit.file-upload", enforcement: "required" }], + } + expect(decodeUploadConsentClaims(jwt(value))).toEqual(value) + expect(() => decodeUploadConsentClaims(jwt({ ...value, iss: "alpha-platform" }))).toThrow( + ContractIncompatibleError, + ) + }) +}) + +describe("UploadManifestV1 fail-closed invariants", () => { + const manifest = (): UploadManifestV1 => ({ + schema_version: 1, + manifest_id: "manifest-1", + tenant_id: "tenant-a", + created_at: "2026-07-22T12:00:00.000Z", + retention_class: "standard", + consent_required: false, + egress: [{ egress_class: "explicit.file-upload", enforcement: "required" }], + file_count: 1, + total_bytes: 4, + files: [{ path: "src/a.ts", size_bytes: 4, sha256: "a".repeat(64) }], + }) + + test("accepts the vendored explicit.file-upload manifest shape", () => { + expect(decodeContract("UploadManifestV1", manifest(), "cloud-http")).toEqual(manifest()) + }) + + test("rejects count total and path uniqueness drift not expressible in JSON Schema", () => { + expect(() => decodeContract("UploadManifestV1", { ...manifest(), file_count: 0 }, "cloud-http")).toThrow() + expect(() => decodeContract("UploadManifestV1", { ...manifest(), total_bytes: 5 }, "cloud-http")).toThrow() + const duplicate = manifest() + duplicate.file_count = 2 + duplicate.total_bytes = 8 + duplicate.files.push({ ...duplicate.files[0]! }) + expect(() => decodeContract("UploadManifestV1", duplicate, "cloud-http")).toThrow() + }) }) describe("published byte limits", () => { diff --git a/packages/alpha-contracts-consumer/src/decode.ts b/packages/alpha-contracts-consumer/src/decode.ts index b3b6f0d9e165..0319fad2280e 100644 --- a/packages/alpha-contracts-consumer/src/decode.ts +++ b/packages/alpha-contracts-consumer/src/decode.ts @@ -4,7 +4,7 @@ import artifactDescriptorSchema from "../vendor/alpha-platform/contracts/v1/arti import wireSchema from "../vendor/alpha-platform/contracts/v1/alpha-wire-contracts.schema.json" import limits from "../vendor/alpha-platform/contracts/v1/limits.json" import { ContractIncompatibleError, type ContractSurface } from "./error" -import type { ContractValues, RoutePurpose, TokenClaimsV1 } from "./types" +import type { ContractValues, RoutePurpose, TokenClaimsV1, UploadConsentClaimsV1 } from "./types" export const ALPHA_CONTRACT_VERSION = 1 as const export const CONTROL_ENVELOPE_MAX_BYTES = limits.CONTROL_ENVELOPE_MAX_BYTES @@ -17,6 +17,7 @@ ajv.addSchema(wireSchema) const validators = { TokenClaimsV1: ajv.compile({ $ref: "alpha-wire-contracts.schema.json#/$defs/TokenClaimsV1" }), + UploadManifestV1: ajv.compile({ $ref: "alpha-wire-contracts.schema.json#/$defs/UploadManifestV1" }), LedgerPageV1: ajv.compile({ $ref: "alpha-wire-contracts.schema.json#/$defs/LedgerPageV1" }), ModelCatalogV1: ajv.compile({ $ref: "alpha-wire-contracts.schema.json#/$defs/ModelCatalogV1" }), CloudJobRequestV1: ajv.compile({ $ref: "alpha-wire-contracts.schema.json#/$defs/CloudJobRequestV1" }), @@ -40,6 +41,13 @@ export function decodeContract( reason: "schema-validation", }) } + if (contract === "UploadManifestV1" && !uploadManifestInvariants(value)) { + throw new ContractIncompatibleError({ + surface, + received_version: receivedVersion(value, "schema_version"), + reason: "schema-validation", + }) + } return value as ContractValues[Name] } @@ -60,12 +68,8 @@ export function decodeJsonContract( } export function decodeTokenClaims(token: string): TokenClaimsV1 { - const parts = token.split(".") - if (parts.length !== 3) { - throw new ContractIncompatibleError({ surface: "identity", received_version: "missing", reason: "schema-validation" }) - } try { - const claims = decodeContract("TokenClaimsV1", JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8")), "identity") + const claims = decodeContract("TokenClaimsV1", decodeJwtPayload(token), "identity") if (claims.token_use !== "platform_access" || claims.iss !== "alpha-web" || claims.aud !== "alpha-platform-api") { throw new ContractIncompatibleError({ surface: "identity", @@ -80,6 +84,29 @@ export function decodeTokenClaims(token: string): TokenClaimsV1 { } } +export function decodeUploadConsentClaims(token: string): UploadConsentClaimsV1 { + try { + const claims = decodeContract("TokenClaimsV1", decodeJwtPayload(token), "identity") as unknown as UploadConsentClaimsV1 + if ( + claims.token_use !== "upload_consent" || + claims.iss !== "alpha-web" || + claims.aud !== "alpha-platform-upload" || + claims.purpose !== "artifact.upload" || + !claims.scope.includes("artifact.upload") + ) { + throw new ContractIncompatibleError({ + surface: "identity", + received_version: claims.schema_version, + reason: "schema-validation", + }) + } + return claims + } catch (error) { + if (error instanceof ContractIncompatibleError) throw error + throw new ContractIncompatibleError({ surface: "identity", received_version: "unknown", reason: "schema-validation" }) + } +} + export function requireTokenPurpose(token: string, purpose: RoutePurpose): TokenClaimsV1 { const claims = decodeTokenClaims(token) if (claims.purpose !== purpose || !claims.scope.includes(purpose)) { @@ -105,3 +132,20 @@ function receivedVersion(value: unknown, field: "schema_version" | "schemaVersio const version = (value as Record)[field] return typeof version === "number" ? version : ("unknown" as const) } + +function decodeJwtPayload(token: string) { + const parts = token.split(".") + if (parts.length !== 3 || !parts[1]) { + throw new ContractIncompatibleError({ surface: "identity", received_version: "missing", reason: "schema-validation" }) + } + return JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8")) +} + +function uploadManifestInvariants(value: unknown) { + const manifest = value as ContractValues["UploadManifestV1"] + return ( + manifest.file_count === manifest.files.length && + manifest.total_bytes === manifest.files.reduce((total, file) => total + file.size_bytes, 0) && + new Set(manifest.files.map((file) => file.path)).size === manifest.files.length + ) +} diff --git a/packages/alpha-contracts-consumer/src/types.ts b/packages/alpha-contracts-consumer/src/types.ts index f8ed3ceda414..92bde24b5ac5 100644 --- a/packages/alpha-contracts-consumer/src/types.ts +++ b/packages/alpha-contracts-consumer/src/types.ts @@ -14,6 +14,45 @@ export type TokenClaimsV1 = { edition?: string } +export type EgressDeclarationV1 = + | { egress_class: "explicit.file-upload"; enforcement: "required" } + | { egress_class: "input.diff"; enforcement: "grandfathered" } + | { egress_class: "code-review"; enforcement: "grandfathered" } + +export type UploadManifestV1 = { + schema_version: 1 + manifest_id: string + tenant_id: string + created_at: string + retention_class: "standard" | "short" | "none" + consent_required: boolean + egress: EgressDeclarationV1[] + file_count: number + total_bytes: number + files: Array<{ + path: string + size_bytes: number + sha256: string + media_type?: string + }> +} + +export type UploadConsentClaimsV1 = { + schema_version: 1 + iss: "alpha-web" + aud: "alpha-platform-upload" + sub: string + token_use: "upload_consent" + purpose: "artifact.upload" + scope: string[] + iat: number + exp: number + jti: string + manifest_id: string + manifest_sha256: string + egress: EgressDeclarationV1[] +} + export type LedgerEntryV1 = { schema_version: 1 id: string @@ -115,6 +154,7 @@ export type ArtifactListV1 = { export type ContractValues = { TokenClaimsV1: TokenClaimsV1 + UploadManifestV1: UploadManifestV1 LedgerPageV1: LedgerPageV1 ModelCatalogV1: ModelCatalogV1 CloudJobRequestV1: CloudJobRequestV1 diff --git a/packages/ui-mac/src/main/alpha-auth-identity.test.ts b/packages/ui-mac/src/main/alpha-auth-identity.test.ts new file mode 100644 index 000000000000..c2419ef72257 --- /dev/null +++ b/packages/ui-mac/src/main/alpha-auth-identity.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, test } from "bun:test" +import { parseAccessTokenIdentity } from "./alpha-auth-identity" +import { createMainUploadService } from "./alpha-upload" + +const jwt = (value: unknown) => + `${Buffer.from("{}").toString("base64url")}.${Buffer.from(JSON.stringify(value)).toString("base64url")}.signature` + +const claims = (sub: unknown) => ({ + schema_version: 1, + iss: "alpha-web", + aud: "alpha-platform-api", + sub, + token_use: "platform_access", + purpose: "cloud.dispatch", + scope: ["cloud.dispatch"], + iat: 1, + exp: 2, + jti: "access-1", +}) + +describe("main upload access identity", () => { + test("issuance is refused when access token sub is missing malformed or unparseable", async () => { + let issuances = 0 + for (const token of [jwt(claims(undefined)), jwt(claims(" tenant-a ")), "not-a-jwt"]) { + const service = createMainUploadService({ + identity: () => parseAccessTokenIdentity(token, "cloud.dispatch"), + issue: async () => { issuances++; return "must-not-issue" }, + dispatch: async () => ({ error: "must-not-dispatch" }), + log: () => {}, + }) + expect( + await service.prepare( + 1, + { kind: "code-review" }, + { projectDirectory: "/missing", files: ["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/missing/file"] }, + ), + ).toEqual({ status: "failed", error: "upload-selection-invalid" }) + } + expect(issuances).toBe(0) + }) + + test("a valid cloud.dispatch subject becomes the manifest tenant", () => { + const token = jwt(claims("tenant-a")) + expect(parseAccessTokenIdentity(token, "cloud.dispatch")).toEqual({ accessToken: token, tenantId: "tenant-a" }) + }) +}) diff --git a/packages/ui-mac/src/main/alpha-auth-identity.ts b/packages/ui-mac/src/main/alpha-auth-identity.ts new file mode 100644 index 000000000000..34bad22bba96 --- /dev/null +++ b/packages/ui-mac/src/main/alpha-auth-identity.ts @@ -0,0 +1,13 @@ +import { ContractIncompatibleError, requireTokenPurpose, type RoutePurpose } from "@alpha-code/contracts-consumer" + +export function parseAccessTokenIdentity(token: string, purpose: RoutePurpose) { + const claims = requireTokenPurpose(token, purpose) + if (claims.sub !== claims.sub.trim() || /[\u0000-\u001f\u007f]/.test(claims.sub)) { + throw new ContractIncompatibleError({ + surface: "identity", + received_version: claims.schema_version, + reason: "schema-validation", + }) + } + return { accessToken: token, tenantId: claims.sub } +} diff --git a/packages/ui-mac/src/main/alpha-auth.ts b/packages/ui-mac/src/main/alpha-auth.ts index 46b90050ab05..746597f3934c 100644 --- a/packages/ui-mac/src/main/alpha-auth.ts +++ b/packages/ui-mac/src/main/alpha-auth.ts @@ -29,6 +29,9 @@ import { ALPHA_PATHS, type AlphaEndpoints } from "../shared/alpha-config" import { isTokenExpired, shouldRefreshToken } from "./alpha-auth-clock" import { decodeEndpointDiscovery, resolveEndpoints, setDiscoveredEndpoints } from "./alpha-endpoints" import { reportContractFailure } from "./alpha-contract-health" +import { parseAccessTokenIdentity } from "./alpha-auth-identity" + +export { parseAccessTokenIdentity } from "./alpha-auth-identity" type StoredAuth = { mode: AuthMode @@ -224,6 +227,11 @@ export function getAccessToken(purpose: RoutePurpose): string | undefined { } } +export function getAccessTokenIdentity(purpose: RoutePurpose) { + const token = getAccessToken(purpose) + return token ? parseAccessTokenIdentity(token, purpose) : undefined +} + function base64url(buf: Buffer) { return buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "") } diff --git a/packages/ui-mac/src/main/alpha-cloud-consent.test.ts b/packages/ui-mac/src/main/alpha-cloud-consent.test.ts index e9640bcb99cc..5f4a6896088c 100644 --- a/packages/ui-mac/src/main/alpha-cloud-consent.test.ts +++ b/packages/ui-mac/src/main/alpha-cloud-consent.test.ts @@ -1,78 +1,16 @@ -// B16 云同意纯核单测(S25)+ prefs I/O 往返。 - -import { afterEach, beforeEach, describe, expect, test } from "bun:test" -import * as fs from "node:fs" -import * as os from "node:os" -import * as path from "node:path" - -import { CLOUD_CONSENT_VERSION, hasCloudConsent, parsePrefs, withCloudConsent } from "./alpha-cloud-consent" - -describe("parsePrefs — 缺失/损坏/非对象一律 {}(不误判为已同意)", () => { - test.each([[null], [undefined], [""], ["not json"], ["[]"], ["123"], ["null"]])("%p → {}", (input) => { - expect(parsePrefs(input as string | null)).toEqual({}) - }) - test("合法对象原样返回", () => { - expect(parsePrefs('{"cloudConsent":{"version":1,"acceptedAt":"x"},"other":true}')).toMatchObject({ +import { describe, expect, test } from "bun:test" +import { parsePrefs } from "./alpha-cloud-consent" + +describe("project prefs are not an upload consent authority", () => { + test.each([[null], [undefined], [""], ["not json"], ["[]"], ["123"], ["null"]])( + "%p parses to an empty object", + (input) => expect(parsePrefs(input as string | null)).toEqual({}), + ) + + test("a legacy cloudConsent record remains inert unknown preference data", () => { + expect(parsePrefs('{"cloudConsent":{"version":1,"acceptedAt":"x"},"other":true}')).toEqual({ + cloudConsent: { version: 1, acceptedAt: "x" }, other: true, - cloudConsent: { version: 1 }, }) }) }) - -describe("hasCloudConsent — 版本必须匹配", () => { - test("无记录 = 未同意", () => { - expect(hasCloudConsent({})).toBe(false) - }) - test("当前版本 = 已同意", () => { - expect(hasCloudConsent({ cloudConsent: { version: CLOUD_CONSENT_VERSION, acceptedAt: "t" } })).toBe(true) - }) - test("旧版本告知 = 视作未同意(重新弹)", () => { - expect(hasCloudConsent({ cloudConsent: { version: CLOUD_CONSENT_VERSION - 1, acceptedAt: "t" } })).toBe(false) - }) -}) - -describe("withCloudConsent — 合并保留其它字段", () => { - test("写入当前版本 + iso,保留 other", () => { - const merged = withCloudConsent({ other: 42 }, "2026-07-06T00:00:00.000Z") - expect(merged).toEqual({ other: 42, cloudConsent: { version: CLOUD_CONSENT_VERSION, acceptedAt: "2026-07-06T00:00:00.000Z" } }) - expect(hasCloudConsent(merged)).toBe(true) - }) -}) - -describe("prefs I/O 往返(.alpha/prefs.json,守卫复用)", () => { - let base = "" - const prevAlpha = process.env.ALPHA_GLOBAL_DIR - let readProjectPrefs: typeof import("./alpha-workdir").readProjectPrefs - let writeProjectPrefs: typeof import("./alpha-workdir").writeProjectPrefs - - beforeEach(async () => { - base = fs.mkdtempSync(path.join(os.tmpdir(), "alpha-prefs-")) - ;({ readProjectPrefs, writeProjectPrefs } = await import("./alpha-workdir")) - }) - afterEach(() => { - if (prevAlpha === undefined) delete process.env.ALPHA_GLOBAL_DIR - else process.env.ALPHA_GLOBAL_DIR = prevAlpha - fs.rmSync(base, { recursive: true, force: true }) - }) - - test("未写过 → {};写入后读回一致;.alpha 自忽略", () => { - expect(readProjectPrefs(base)).toEqual({}) - const w = writeProjectPrefs(base, withCloudConsent({}, "2026-07-06T00:00:00.000Z")) - expect(w.ok).toBe(true) - expect(hasCloudConsent(readProjectPrefs(base))).toBe(true) - expect(fs.readFileSync(path.join(base, ".alpha", ".gitignore"), "utf8")).toBe("*\n") - }) - - test("合并写不丢其它偏好字段", () => { - writeProjectPrefs(base, { theme: "dark" }) - writeProjectPrefs(base, withCloudConsent(readProjectPrefs(base), "t")) - const p = readProjectPrefs(base) - expect(p.theme).toBe("dark") - expect(hasCloudConsent(p)).toBe(true) - }) - - test("非法项目目录(根)拒写", () => { - expect(writeProjectPrefs("/", { a: 1 })).toMatchObject({ ok: false }) - expect(readProjectPrefs("relative/path")).toEqual({}) - }) -}) diff --git a/packages/ui-mac/src/main/alpha-cloud-consent.ts b/packages/ui-mac/src/main/alpha-cloud-consent.ts index b1af97e8bf0a..b3b07f4e80ba 100644 --- a/packages/ui-mac/src/main/alpha-cloud-consent.ts +++ b/packages/ui-mac/src/main/alpha-cloud-consent.ts @@ -1,34 +1,15 @@ -// B16(ADR-021 §4 显式通道挂钩)—— 首次云派发 per 项目的 PIPL 同意门·纯核。 -// -// 定位:显式通道(向云传项目 diff/任务文本)在**每个项目首次派发**时弹一次同意,记录于该项目 -// `.alpha/prefs.json` 的 cloudConsent(ADR-019 落点)。隐式通道(platform 代付每条 prompt 出境) -// 的义务是**告知**、不是阻断(ADR-021 §3),由 alpha-web 登录流承担,不在此。 -// -// 版本化:告知内容实质变更(出境范围扩大等)时 bump CLOUD_CONSENT_VERSION → 旧同意失效、重新弹。 -// fs I/O 在 alpha-workdir(readProjectPrefs/writeProjectPrefs),对话框在 cloud-ipc;本模块纯、可单测。 +// Generic project prefs parsing retained for alpha-workdir. Upload consent is deliberately absent: +// consent is one-shot, manifest-bound main-process state and is never persisted in project prefs. -export const CLOUD_CONSENT_VERSION = 1 - -export type CloudConsent = { version: number; acceptedAt: string } -export type ProjectPrefs = { cloudConsent?: CloudConsent; [k: string]: unknown } +export type ProjectPrefs = Record /** 安全解析 .alpha/prefs.json 文本;缺失/损坏/非对象 → {}(不抛,不误判为已同意)。 */ export function parsePrefs(json: string | null | undefined): ProjectPrefs { if (!json) return {} try { const p: unknown = JSON.parse(json) - return p && typeof p === "object" && !Array.isArray(p) ? (p as ProjectPrefs) : {} + return p && typeof p === "object" && !Array.isArray(p) ? Object.fromEntries(Object.entries(p)) : {} } catch { return {} } } - -/** 已同意 ⟺ 记录存在且版本 == 当前(旧版本告知 = 视作未同意,重新弹)。 */ -export function hasCloudConsent(prefs: ProjectPrefs): boolean { - return prefs.cloudConsent?.version === CLOUD_CONSENT_VERSION -} - -/** 合并写入(保留 prefs 其它字段;只覆盖 cloudConsent)。iso 由调用方注入(便于单测确定性)。 */ -export function withCloudConsent(prefs: ProjectPrefs, iso: string): ProjectPrefs { - return { ...prefs, cloudConsent: { version: CLOUD_CONSENT_VERSION, acceptedAt: iso } } -} diff --git a/packages/ui-mac/src/main/alpha-cloud-jobs.ts b/packages/ui-mac/src/main/alpha-cloud-jobs.ts index 902aa1da608a..90fd221767b6 100644 --- a/packages/ui-mac/src/main/alpha-cloud-jobs.ts +++ b/packages/ui-mac/src/main/alpha-cloud-jobs.ts @@ -29,6 +29,7 @@ import { import { getLogger } from "./logging" import { reportContractFailure } from "./alpha-contract-health" import type { CloudResult, CloudJobEnvelope, CloudDispatchResult, CloudJobStatus, CloudArtifactList } from "../preload/types" +import type { createExplicitUploadRequest } from "./alpha-upload-manifest" // Resolved by alpha-endpoints (env ALPHA_CLOUD_URL > userData pin > login discovery > default). const cloudBase = () => resolveEndpoints().cloud @@ -66,7 +67,7 @@ async function authed( } } -// ADR-021 §2(REQ-020 T1):上行硬校验单点 —— denied_paths 缺省注入 / 1MB 帽 / secrets 拒发, +// ADR-021 §2(REQ-020 T1):上行硬校验单点 —— denied_paths 缺省注入 / 256KiB 帽 / secrets 拒发, // 全部 loud(错误原样回 renderer 行内呈现)。MCP facade 路径由 B 侧 schema 校验兜底(双层)。 export const dispatchCloudJob = (envelope: CloudJobEnvelope): Promise> => { const guarded = guardCloudEnvelope(envelope) @@ -82,6 +83,37 @@ export const dispatchCloudJob = (envelope: CloudJobEnvelope): Promise + uploadConsent?: string +}): Promise> { + try { + const base = cloudBase() + if (!base) return { error: "no-cloud-endpoint" } + const response = await fetch(`${base}${ALPHA_PATHS.cloudJobs}`, { + method: "POST", + headers: { + authorization: `Bearer ${input.accessToken}`, + "content-type": "application/json", + ...(input.uploadConsent ? { "x-alpha-upload-consent": input.uploadConsent } : {}), + }, + body: JSON.stringify(input.body), + signal: AbortSignal.timeout(15000), + }) + if (response.status === 401) return { error: "unauthorized" } + if (!response.ok) return { error: `http-${response.status}` } + return decodeJsonContract("CloudJobAcceptedV1", await response.text(), "cloud-http") + } catch (error) { + if (isContractIncompatibleError(error)) { + reportContractFailure(error) + return { error: "contract-incompatible" } + } + getLogger().warn("alpha-cloud-jobs: explicit upload failed code=network") + return { error: "network" } + } +} + export const getCloudJobStatus = (jobId: string): Promise> => authed( `${ALPHA_PATHS.cloudJobs}/${encodeURIComponent(jobId)}`, diff --git a/packages/ui-mac/src/main/alpha-cloud-schedules.ts b/packages/ui-mac/src/main/alpha-cloud-schedules.ts index b9c6b0c6b38a..26e6488ab692 100644 --- a/packages/ui-mac/src/main/alpha-cloud-schedules.ts +++ b/packages/ui-mac/src/main/alpha-cloud-schedules.ts @@ -17,6 +17,7 @@ import { saveCloudRun } from "./alpha-workdir" import { mirrorRunArtifacts } from "./alpha-user-workspace" import { getLogger } from "./logging" import { getStore } from "./store" +import { cloudScheduleEnvelopeFor, cloudScheduleRegistrationFor } from "./cloud-schedule-config" // 与 alpha-cloud-jobs 同一 authed 通道(bearer 不进 renderer)。为避免循环依赖,这里复制其极简 // fetch 形状(同 endpoints/token 源)。 @@ -63,16 +64,11 @@ export type CloudScheduleView = { } -function envelopeFor(task: AutomationTask): Record { - // MVP:research 管线,任务描述 = 调研问题(表单已明示该映射)。 - return { autonomy: "pipeline", kind: "research", input: { question: task.prompt } } -} - /** 保存云档任务时注册/更新 B schedule;返回 schedule id 或可读错误。 */ export async function upsertCloudSchedule(task: AutomationTask): Promise<{ ok: true; scheduleId: string } | { ok: false; reason: string }> { const cron = scheduleToCron(task.schedule) if (!cron) return { ok: false, reason: "云档只支持 cron / 60 分钟内的间隔(once 与超长间隔请用本地档)" } - const body = { name: task.name, cron, envelope: envelopeFor(task), enabled: task.enabled } + const body = cloudScheduleRegistrationFor(task, cron) const r = task.cloudScheduleId ? await authed(`/v1/cloud/schedules/${encodeURIComponent(task.cloudScheduleId)}`, { method: "PATCH", body }) : await authed("/v1/cloud/schedules", { method: "POST", body }) @@ -154,7 +150,7 @@ async function doPull(): Promise<{ pulled: number } | { error: string }> { // REQ-093:下载成功即入 manifest(依赖注入,见 SaveRunDeps.register)。 register: (input) => registerDownloadedArtifact(task.target.projectDir, job.job_id, input), }, - { autonomy: "pipeline", kind: "research", input: { question: task.prompt } } as never, + cloudScheduleEnvelopeFor(task) as never, ).catch(() => ({ ok: false as const, reason: "save failed" })) // REQ-071/ADR-025:~/Alpha 目标任务的交付物镜像到可见区 Outputs(best-effort,真源不变)。 if (saved.ok && "files" in saved) mirrorRunArtifacts(task.target.projectDir, job.job_id, saved) diff --git a/packages/ui-mac/src/main/alpha-upload-manifest.test.ts b/packages/ui-mac/src/main/alpha-upload-manifest.test.ts new file mode 100644 index 000000000000..5475c03efeeb --- /dev/null +++ b/packages/ui-mac/src/main/alpha-upload-manifest.test.ts @@ -0,0 +1,105 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test" +import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { createHash } from "node:crypto" +import { + classifyUploadContent, + createExplicitUploadSnapshot, + UploadAdmissionError, +} from "./alpha-upload-manifest" + +describe("main-owned UploadManifestV1", () => { + let root = "" + + beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "alpha-upload-manifest-")) + mkdirSync(join(root, "src")) + }) + + afterEach(() => rmSync(root, { recursive: true, force: true })) + + test("binds tenant path size sha256 purpose retention consent and content summary", async () => { + const code = "export const answer = 42\n" + const contact = "Call me at 13800138000。\n" + writeFileSync(join(root, "src", "index.ts"), code) + writeFileSync(join(root, "contact.txt"), contact) + + const snapshot = await createExplicitUploadSnapshot( + { projectDirectory: root, files: [join(root, "src", "index.ts"), join(root, "contact.txt")] }, + "tenant-a", + { id: () => "manifest-1", now: () => new Date("2026-07-22T12:00:00.000Z") }, + ) + + expect(snapshot.manifest).toMatchObject({ + schema_version: 1, + manifest_id: "manifest-1", + tenant_id: "tenant-a", + created_at: "2026-07-22T12:00:00.000Z", + retention_class: "standard", + consent_required: true, + egress: [{ egress_class: "explicit.file-upload", enforcement: "required" }], + file_count: 2, + total_bytes: Buffer.byteLength(code) + Buffer.byteLength(contact), + }) + expect(snapshot.manifest.files.map((file) => file.path)).toEqual(["contact.txt", "src/index.ts"]) + expect(snapshot.manifest.files[0]?.sha256).toBe(createHash("sha256").update(contact).digest("hex")) + expect(snapshot.preview).toMatchObject({ + pipeline: "code-review", + purpose: "artifact.upload", + retentionClass: "standard", + fileCount: 2, + }) + expect(snapshot.preview.findings).toContainEqual({ kind: "contact", fileCount: 1 }) + expect(snapshot.manifestSha256).toBe(createHash("sha256").update(snapshot.manifestJson).digest("hex")) + }) + + test("classifies broad client-side PII without blindly flagging pure code or numbers", () => { + const sensitive = [ + "邮箱 hi@example.com。", + "裸手机号 13800138000。", + "国际电话 +14155552671!", + "身份证 11010519491231002X。", + "api_key=sk-abcdefghijklmnopqrstuvwxyz", + ] + sensitive.forEach((content) => expect(classifyUploadContent({ path: "notes.txt", content }).sensitive).toBe(true)) + expect(classifyUploadContent({ path: "src/math.ts", content: "const values = [1, 2, 3, 12345678901]\n" })).toEqual({ + sensitive: false, + findings: [], + }) + expect(classifyUploadContent({ path: ".env.production", content: "PUBLIC_FLAG=true\n" }).findings).toContain( + "protected", + ) + }) + + test("classifier uncertainty or error fails closed as sensitive", async () => { + writeFileSync(join(root, "notes.txt"), "plain text") + const snapshot = await createExplicitUploadSnapshot( + { projectDirectory: root, files: [join(root, "notes.txt")] }, + "tenant-a", + { classify: () => { throw new Error("classifier unavailable") } }, + ) + expect(snapshot.manifest.consent_required).toBe(true) + expect(snapshot.preview.findings).toEqual([{ kind: "unknown", fileCount: 1 }]) + }) + + test("missing directory outside-root file and symlink never expand to implicit project scope", async () => { + writeFileSync(join(root, "inside.txt"), "inside") + const outside = join(tmpdir(), `outside-${crypto.randomUUID()}.txt`) + writeFileSync(outside, "outside") + symlinkSync(join(root, "inside.txt"), join(root, "linked.txt")) + try { + await expect( + createExplicitUploadSnapshot({ projectDirectory: join(root, "missing"), files: [join(root, "inside.txt")] }, "tenant-a"), + ).rejects.toBeInstanceOf(UploadAdmissionError) + await expect( + createExplicitUploadSnapshot({ projectDirectory: root, files: [outside] }, "tenant-a"), + ).rejects.toMatchObject({ code: "upload-path-invalid" }) + await expect( + createExplicitUploadSnapshot({ projectDirectory: root, files: [join(root, "linked.txt")] }, "tenant-a"), + ).rejects.toMatchObject({ code: "upload-path-invalid" }) + } finally { + rmSync(outside, { force: true }) + } + }) +}) diff --git a/packages/ui-mac/src/main/alpha-upload-manifest.ts b/packages/ui-mac/src/main/alpha-upload-manifest.ts new file mode 100644 index 000000000000..e2f5f2242091 --- /dev/null +++ b/packages/ui-mac/src/main/alpha-upload-manifest.ts @@ -0,0 +1,237 @@ +import { createHash, randomUUID } from "node:crypto" +import { constants } from "node:fs" +import { lstat, open, realpath } from "node:fs/promises" +import { isAbsolute, relative, resolve, sep } from "node:path" +import { + CONTROL_ENVELOPE_MAX_BYTES, + decodeContract, + type CloudJobRequestV1, + type UploadManifestV1, +} from "@alpha-code/contracts-consumer" +import type { CloudJobEnvelope, UploadFindingKind, UploadPreview } from "../preload/types" +import { guardCloudEnvelope } from "./cloud-envelope-guard" + +export const MAX_UPLOAD_FILES = 256 +export const MAX_UPLOAD_BYTES = 100 * 1024 * 1024 + +export type UploadErrorCode = + | "not-authenticated" + | "upload-selection-invalid" + | "upload-file-limit" + | "upload-size-limit" + | "upload-control-limit" + | "upload-path-invalid" + | "upload-file-unreadable" + | "upload-not-text" + | "upload-consent-issuance-failed" + | "upload-consent-invalid" + | "upload-dispatch-failed" + | "upload-main-gate-required" + +export class UploadAdmissionError extends Error { + constructor(readonly code: UploadErrorCode) { + super(code) + this.name = "UploadAdmissionError" + } +} + +export type UploadSelection = { projectDirectory: string; files: string[] } +export type UploadSensitivity = { sensitive: boolean; findings: UploadFindingKind[] } +export type UploadClassifier = (input: { path: string; content: string }) => UploadSensitivity + +export type ExplicitUploadSnapshot = { + projectDirectory: string + manifest: UploadManifestV1 + manifestJson: string + manifestSha256: string + files: Array<{ path: string; content: string }> + preview: UploadPreview +} + +export async function createExplicitUploadSnapshot( + selection: UploadSelection, + tenantId: string, + deps: { now?: () => Date; id?: () => string; classify?: UploadClassifier } = {}, +): Promise { + if (!selection.files.length || selection.files.length > MAX_UPLOAD_FILES) { + throw new UploadAdmissionError(selection.files.length ? "upload-file-limit" : "upload-selection-invalid") + } + if (!tenantId || tenantId !== tenantId.trim()) throw new UploadAdmissionError("upload-selection-invalid") + + const root = await requireProjectRoot(selection.projectDirectory) + const selected = await Promise.all(selection.files.map((file) => inspectSelectedFile(root, selection.projectDirectory, file))) + if (new Set(selected.map((file) => file.path)).size !== selected.length) { + throw new UploadAdmissionError("upload-path-invalid") + } + const statedTotal = selected.reduce((total, file) => total + file.size, 0) + if (statedTotal > MAX_UPLOAD_BYTES) throw new UploadAdmissionError("upload-size-limit") + + const read = await Promise.all( + selected + .sort((a, b) => a.path.localeCompare(b.path)) + .map(async (file) => { + const handle = await open(file.absolutePath, constants.O_RDONLY | constants.O_NOFOLLOW) + const bytes = await (async () => { + try { + const before = await handle.stat() + if (!before.isFile() || before.dev !== file.dev || before.ino !== file.ino || before.size !== file.size) { + throw new UploadAdmissionError("upload-file-unreadable") + } + const content = await handle.readFile() + const after = await handle.stat() + if (after.dev !== before.dev || after.ino !== before.ino || after.size !== before.size) { + throw new UploadAdmissionError("upload-file-unreadable") + } + return content + } finally { + await handle.close() + } + })() + if (bytes.byteLength !== file.size) throw new UploadAdmissionError("upload-file-unreadable") + const content = bytes.toString("utf8") + if (!Buffer.from(content, "utf8").equals(bytes)) throw new UploadAdmissionError("upload-not-text") + const sensitivity = classifyFailClosed(deps.classify ?? classifyUploadContent, file.path, content) + return { + path: file.path, + content, + size: bytes.byteLength, + sha256: createHash("sha256").update(bytes).digest("hex"), + sensitivity, + } + }), + ) + const totalBytes = read.reduce((total, file) => total + file.size, 0) + if (totalBytes > MAX_UPLOAD_BYTES) throw new UploadAdmissionError("upload-size-limit") + + const findings = [...new Set(read.flatMap((file) => file.sensitivity.findings))] + const manifest = decodeContract( + "UploadManifestV1", + { + schema_version: 1, + manifest_id: (deps.id ?? randomUUID)(), + tenant_id: tenantId, + created_at: (deps.now ?? (() => new Date()))().toISOString(), + retention_class: "standard", + consent_required: read.some((file) => file.sensitivity.sensitive), + egress: [{ egress_class: "explicit.file-upload", enforcement: "required" }], + file_count: read.length, + total_bytes: totalBytes, + files: read.map((file) => ({ path: file.path, size_bytes: file.size, sha256: file.sha256 })), + }, + "cloud-http", + ) + if (manifest.file_count !== manifest.files.length || manifest.total_bytes !== totalBytes) { + throw new UploadAdmissionError("upload-selection-invalid") + } + const manifestJson = JSON.stringify(manifest) + if (Buffer.byteLength(manifestJson, "utf8") > CONTROL_ENVELOPE_MAX_BYTES) { + throw new UploadAdmissionError("upload-control-limit") + } + + return { + projectDirectory: root, + manifest, + manifestJson, + manifestSha256: createHash("sha256").update(manifestJson).digest("hex"), + files: read.map((file) => ({ path: file.path, content: file.content })), + preview: { + pipeline: "code-review", + fileCount: read.length, + totalBytes, + files: read.map((file) => ({ + path: file.path, + sizeBytes: file.size, + sensitive: file.sensitivity.sensitive, + })), + findings: findings.map((kind) => ({ + kind, + fileCount: read.filter((file) => file.sensitivity.findings.includes(kind)).length, + })), + purpose: "artifact.upload", + retentionClass: "standard", + }, + } +} + +export function createExplicitUploadRequest(snapshot: ExplicitUploadSnapshot, envelope: CloudJobEnvelope) { + const guarded = guardCloudEnvelope(envelope) + if (!guarded.ok) throw new UploadAdmissionError(toUploadCode(guarded.error)) + const body = { + ...guarded.envelope, + upload: { manifest: snapshot.manifestJson, files: snapshot.files }, + } + if (Buffer.byteLength(JSON.stringify(body), "utf8") > CONTROL_ENVELOPE_MAX_BYTES) { + throw new UploadAdmissionError("upload-control-limit") + } + return body satisfies CloudJobRequestV1 & { + upload: { manifest: string; files: Array<{ path: string; content: string }> } + } +} + +export function classifyUploadContent(input: { path: string; content: string }): UploadSensitivity { + const findings: UploadFindingKind[] = [] + const protectedPath = /(?:^|\/)(?:\.env(?:\.|$)|\.ssh(?:\/|$)|id_(?:rsa|dsa|ecdsa|ed25519)$|[^/]*\.(?:pem|key|p12|pfx)$|credentials?(?:\.[^/]*)?$|secrets?(?:\.[^/]*)?$)/i + const email = /\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/i + const phone = /(?:^|[^\d])(?:1[3-9]\d{9}|\+[1-9]\d{7,14})(?!\d)/ + const identity = /(?:^|[^\d])(?:\d{17}[\dXx]|\d{6}(?:19|20)\d{2}(?:0[1-9]|1[0-2])(?:0[1-9]|[12]\d|3[01])\d{3})(?![\dXx])/ + const credential = + /-----BEGIN [A-Z ]*PRIVATE KEY-----|\b(?:AKIA[0-9A-Z]{16}|(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{36,}|github_pat_[A-Za-z0-9_]{22,}|sk-[A-Za-z0-9_-]{20,}|AIza[0-9A-Za-z_-]{35})\b|\b(?:api[_-]?key|access[_-]?token|client[_-]?secret|password)\s*[:=]\s*["']?[^\s"']{8,}/i + + if (protectedPath.test(input.path)) findings.push("protected") + if (email.test(input.content) || phone.test(input.content)) findings.push("contact") + if (identity.test(input.content)) findings.push("identity") + if (credential.test(input.content)) findings.push("credential") + return { sensitive: findings.length > 0, findings } +} + +function classifyFailClosed(classify: UploadClassifier, path: string, content: string) { + try { + const result = classify({ path, content }) + if (typeof result.sensitive !== "boolean" || !Array.isArray(result.findings)) { + return { sensitive: true, findings: ["unknown" as const] } + } + return result + } catch { + return { sensitive: true, findings: ["unknown" as const] } + } +} + +async function requireProjectRoot(directory: string) { + if (!isAbsolute(directory)) throw new UploadAdmissionError("upload-selection-invalid") + try { + const root = await realpath(directory) + if (!(await lstat(root)).isDirectory()) throw new UploadAdmissionError("upload-selection-invalid") + return root + } catch (error) { + if (error instanceof UploadAdmissionError) throw error + throw new UploadAdmissionError("upload-selection-invalid") + } +} + +async function inspectSelectedFile(root: string, selectedRoot: string, selectedPath: string) { + if (!isAbsolute(selectedPath)) throw new UploadAdmissionError("upload-path-invalid") + try { + const pathFromSelection = relative(resolve(selectedRoot), resolve(selectedPath)) + if (!pathFromSelection || pathFromSelection.startsWith(`..${sep}`) || pathFromSelection === "..") { + throw new UploadAdmissionError("upload-path-invalid") + } + const absolutePath = await realpath(selectedPath) + if (absolutePath !== resolve(root, pathFromSelection)) throw new UploadAdmissionError("upload-path-invalid") + const path = pathFromSelection.split(sep).join("/") + if (!path || path.startsWith("../") || path === ".." || isAbsolute(path)) { + throw new UploadAdmissionError("upload-path-invalid") + } + const stat = await lstat(absolutePath) + if (!stat.isFile() || stat.isSymbolicLink()) throw new UploadAdmissionError("upload-path-invalid") + return { absolutePath, path, size: stat.size, dev: stat.dev, ino: stat.ino } + } catch (error) { + if (error instanceof UploadAdmissionError) throw error + throw new UploadAdmissionError("upload-file-unreadable") + } +} + +function toUploadCode(error: string): UploadErrorCode { + if (error === "upload-main-gate-required") return error + if (error.startsWith("envelope-too-large")) return "upload-control-limit" + return "upload-selection-invalid" +} diff --git a/packages/ui-mac/src/main/alpha-upload.test.ts b/packages/ui-mac/src/main/alpha-upload.test.ts new file mode 100644 index 000000000000..5f623804916b --- /dev/null +++ b/packages/ui-mac/src/main/alpha-upload.test.ts @@ -0,0 +1,196 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test" +import { mkdtempSync, realpathSync, rmSync, truncateSync, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { CONTROL_ENVELOPE_MAX_BYTES } from "@alpha-code/contracts-consumer" +import type { CloudDispatchResult } from "../preload/types" +import { assertSafeUploadResult, createMainUploadService } from "./alpha-upload" +import { MAX_UPLOAD_BYTES, MAX_UPLOAD_FILES } from "./alpha-upload-manifest" + +const accepted: CloudDispatchResult = { + schema_version: 1, + job_id: "job_upload_1", + status: "queued", + autonomy: "pipeline", + kind: "code-review", + urls: { status: "/status", events: "/events", result: "/result" }, +} + +const jwt = (value: unknown) => + `${Buffer.from("{}").toString("base64url")}.${Buffer.from(JSON.stringify(value)).toString("base64url")}.signature` + +describe("main upload admission and one-shot consent", () => { + let root = "" + + beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "alpha-upload-service-")) + }) + + afterEach(() => rmSync(root, { recursive: true, force: true })) + + test("non-sensitive explicit files dispatch silently and never request issuance", async () => { + writeFileSync(join(root, "clean.ts"), "export const answer = 42\n") + const calls = { issue: 0, dispatch: 0 } + const service = createMainUploadService({ + identity: () => ({ accessToken: "access-secret", tenantId: "tenant-a" }), + issue: async () => { calls.issue++; return "unused" }, + dispatch: async () => { calls.dispatch++; return accepted }, + log: () => {}, + id: () => "manifest-clean", + }) + const result = await service.prepare( + 1, + { kind: "code-review" }, + { projectDirectory: root, files: [join(root, "clean.ts")] }, + ) + expect(result).toMatchObject({ status: "sent", privacy: "clear", job: accepted, directory: realpathSync(root) }) + expect(calls).toEqual({ issue: 0, dispatch: 1 }) + expect(() => assertSafeUploadResult(result)).not.toThrow() + }) + + test("sensitive consent freezes exact scope and binds mocked alpha-web issuance response", async () => { + const selected = join(root, "contact.txt") + const original = "Call 13800138000。\n" + writeFileSync(selected, original) + const calls = { issue: 0, dispatch: 0 } + const dispatched: Array[0]["dispatch"]>[0]> = [] + const now = new Date("2026-07-22T12:00:00.000Z") + const service = createMainUploadService({ + identity: () => ({ accessToken: "access-secret", tenantId: "tenant-a" }), + issue: async (input) => { + calls.issue++ + const manifest = JSON.parse(input.manifestJson) as { manifest_id: string; egress: unknown } + return jwt({ + schema_version: 1, + iss: "alpha-web", + aud: "alpha-platform-upload", + sub: "tenant-a", + token_use: "upload_consent", + purpose: "artifact.upload", + scope: ["artifact.upload"], + iat: Math.floor(now.getTime() / 1000), + exp: Math.floor(now.getTime() / 1000) + 300, + jti: "consent-1", + manifest_id: manifest.manifest_id, + manifest_sha256: input.manifestSha256, + egress: manifest.egress, + }) + }, + dispatch: async (input) => { calls.dispatch++; dispatched.push(input); return accepted }, + log: () => {}, + now: () => now, + id: () => "manifest-sensitive", + }) + + const prepared = await service.prepare( + 7, + { kind: "code-review" }, + { projectDirectory: root, files: [selected] }, + ) + expect(prepared).toMatchObject({ status: "consent-required", preview: { fileCount: 1 } }) + expect(calls).toEqual({ issue: 0, dispatch: 0 }) + writeFileSync(selected, "expanded after preview: hi@example.com\n") + if (prepared.status !== "consent-required") throw new Error("expected consent") + expect(await service.confirm(8, prepared.requestId)).toEqual({ status: "failed", error: "upload-selection-invalid" }) + expect(calls).toEqual({ issue: 0, dispatch: 0 }) + const confirmed = await service.confirm(7, prepared.requestId) + expect(confirmed).toMatchObject({ status: "sent", privacy: "confirmed", job: accepted }) + expect(calls).toEqual({ issue: 1, dispatch: 1 }) + expect(dispatched[0]?.body.upload.files).toEqual([{ path: "contact.txt", content: original }]) + expect(dispatched[0]?.uploadConsent).toContain(".") + expect(() => assertSafeUploadResult(confirmed)).not.toThrow() + expect(await service.confirm(7, prepared.requestId)).toEqual({ status: "failed", error: "upload-selection-invalid" }) + }) + + test("cancelled consent and cancelled picker semantics never issue or upload", async () => { + writeFileSync(join(root, "contact.txt"), "hi@example.com") + const calls = { issue: 0, dispatch: 0 } + const service = createMainUploadService({ + identity: () => ({ accessToken: "access-secret", tenantId: "tenant-a" }), + issue: async () => { calls.issue++; return "unused" }, + dispatch: async () => { calls.dispatch++; return accepted }, + log: () => {}, + }) + const prepared = await service.prepare( + 2, + { kind: "code-review" }, + { projectDirectory: root, files: [join(root, "contact.txt")] }, + ) + if (prepared.status !== "consent-required") throw new Error("expected consent") + expect(service.cancel(2, prepared.requestId)).toEqual({ status: "cancelled" }) + expect(calls).toEqual({ issue: 0, dispatch: 0 }) + }) + + test("manifest exceeding 256 files or 100 MiB total or 256 KiB control envelope fails closed before issuance", async () => { + const small = join(root, "small.txt") + const huge = join(root, "huge.txt") + const control = join(root, "control.txt") + writeFileSync(small, "plain") + writeFileSync(huge, "") + truncateSync(huge, MAX_UPLOAD_BYTES + 1) + writeFileSync(control, "x".repeat(CONTROL_ENVELOPE_MAX_BYTES)) + const calls = { issue: 0, dispatch: 0 } + const service = createMainUploadService({ + identity: () => ({ accessToken: "access-secret", tenantId: "tenant-a" }), + issue: async () => { calls.issue++; return "unused" }, + dispatch: async () => { calls.dispatch++; return accepted }, + log: () => {}, + }) + expect( + await service.prepare( + 1, + { kind: "code-review" }, + { projectDirectory: root, files: Array.from({ length: MAX_UPLOAD_FILES + 1 }, () => small) }, + ), + ).toEqual({ status: "failed", error: "upload-file-limit" }) + expect( + await service.prepare(1, { kind: "code-review" }, { projectDirectory: root, files: [huge] }), + ).toEqual({ status: "failed", error: "upload-size-limit" }) + expect( + await service.prepare(1, { kind: "code-review" }, { projectDirectory: root, files: [control] }), + ).toEqual({ status: "failed", error: "upload-control-limit" }) + expect(calls).toEqual({ issue: 0, dispatch: 0 }) + }) + + test("wrong manifest binding fails closed and error logging contains only a stable code", async () => { + const selected = join(root, "contact.txt") + writeFileSync(selected, "hi@example.com") + const logs: string[] = [] + let dispatches = 0 + const service = createMainUploadService({ + identity: () => ({ accessToken: "secret-token", tenantId: "tenant-a" }), + issue: async (input) => { + const manifest = JSON.parse(input.manifestJson) as { manifest_id: string; egress: unknown } + return jwt({ + schema_version: 1, + iss: "alpha-web", + aud: "alpha-platform-upload", + sub: "tenant-a", + token_use: "upload_consent", + purpose: "artifact.upload", + scope: ["artifact.upload"], + iat: 1, + exp: 9999999999, + jti: "wrong-binding", + manifest_id: manifest.manifest_id, + manifest_sha256: "0".repeat(64), + egress: manifest.egress, + }) + }, + dispatch: async () => { dispatches++; return accepted }, + log: (message) => logs.push(message), + }) + const prepared = await service.prepare(1, { kind: "code-review" }, { projectDirectory: root, files: [selected] }) + if (prepared.status !== "consent-required") throw new Error("expected consent") + expect(await service.confirm(1, prepared.requestId)).toEqual({ status: "failed", error: "upload-consent-invalid" }) + expect(dispatches).toBe(0) + expect(logs).toEqual(["[alpha-upload] failed code=upload-consent-invalid"]) + expect(logs.join(" ")).not.toContain("secret-token") + expect(logs.join(" ")).not.toContain(root) + }) + + test("upload handler runtime assertion rejects credential or manifest fields", () => { + expect(() => assertSafeUploadResult({ status: "failed", error: "upload-consent-invalid", token: "secret" })).toThrow() + expect(() => assertSafeUploadResult({ status: "cancelled", nested: { manifest: {} } })).toThrow() + }) +}) diff --git a/packages/ui-mac/src/main/alpha-upload.ts b/packages/ui-mac/src/main/alpha-upload.ts new file mode 100644 index 000000000000..23739fa2c9ea --- /dev/null +++ b/packages/ui-mac/src/main/alpha-upload.ts @@ -0,0 +1,145 @@ +import { randomUUID } from "node:crypto" +import { decodeUploadConsentClaims } from "@alpha-code/contracts-consumer" +import type { CloudDispatchResult, CloudUploadIntent, CloudUploadResult } from "../preload/types" +import { + UploadAdmissionError, + createExplicitUploadRequest, + createExplicitUploadSnapshot, + type ExplicitUploadSnapshot, + type UploadClassifier, + type UploadErrorCode, + type UploadSelection, +} from "./alpha-upload-manifest" + +type AccessIdentity = { accessToken: string; tenantId: string } +type ExplicitUploadBody = ReturnType +type PendingUpload = { + senderId: number + tenantId: string + snapshot: ExplicitUploadSnapshot + body: ExplicitUploadBody +} + +export function createMainUploadService(deps: { + identity: () => AccessIdentity | undefined | Promise + issue: (input: { accessToken: string; manifestJson: string; manifestSha256: string }) => Promise + dispatch: (input: { + accessToken: string + body: ExplicitUploadBody + uploadConsent?: string + }) => Promise + log: (message: string) => void + now?: () => Date + id?: () => string + classify?: UploadClassifier +}) { + const pending = new Map() + + return { + prepare: async (senderId: number, intent: CloudUploadIntent, selection: UploadSelection): Promise => { + if (!intent || intent.kind !== "code-review") return fail(deps.log, "upload-selection-invalid") + try { + const identity = await deps.identity() + if (!identity) return fail(deps.log, "not-authenticated") + const snapshot = await createExplicitUploadSnapshot(selection, identity.tenantId, { + now: deps.now, + id: deps.id, + classify: deps.classify, + }) + const body = createExplicitUploadRequest(snapshot, { autonomy: "pipeline", kind: "code-review", input: {} }) + if (!snapshot.manifest.consent_required) { + const job = await deps.dispatch({ accessToken: identity.accessToken, body }) + if ("error" in job) return fail(deps.log, "upload-dispatch-failed") + return { status: "sent", privacy: "clear", job, directory: snapshot.projectDirectory } + } + + for (const [requestId, value] of pending) { + if (value.senderId === senderId) pending.delete(requestId) + } + const requestId = randomUUID() + pending.set(requestId, { senderId, tenantId: identity.tenantId, snapshot, body }) + return { status: "consent-required", requestId, preview: snapshot.preview } + } catch (error) { + return fail(deps.log, error instanceof UploadAdmissionError ? error.code : "upload-selection-invalid") + } + }, + confirm: async (senderId: number, requestId: string): Promise => { + const upload = typeof requestId === "string" ? pending.get(requestId) : undefined + if (!upload || upload.senderId !== senderId) return fail(deps.log, "upload-selection-invalid") + pending.delete(requestId) + try { + const identity = await deps.identity() + if (!identity || identity.tenantId !== upload.tenantId) return fail(deps.log, "not-authenticated") + const uploadConsent = await deps.issue({ + accessToken: identity.accessToken, + manifestJson: upload.snapshot.manifestJson, + manifestSha256: upload.snapshot.manifestSha256, + }) + const claims = decodeUploadConsentClaims(uploadConsent) + const now = Math.floor((deps.now ?? (() => new Date()))().getTime() / 1000) + if ( + claims.sub !== upload.tenantId || + claims.manifest_id !== upload.snapshot.manifest.manifest_id || + claims.manifest_sha256 !== upload.snapshot.manifestSha256 || + claims.exp <= claims.iat || + claims.exp <= now || + JSON.stringify(claims.egress) !== JSON.stringify(upload.snapshot.manifest.egress) + ) { + return fail(deps.log, "upload-consent-invalid") + } + const job = await deps.dispatch({ accessToken: identity.accessToken, body: upload.body, uploadConsent }) + if ("error" in job) return fail(deps.log, "upload-dispatch-failed") + return { status: "sent", privacy: "confirmed", job, directory: upload.snapshot.projectDirectory } + } catch (error) { + return fail( + deps.log, + error instanceof UploadAdmissionError ? error.code : "upload-consent-issuance-failed", + ) + } + }, + cancel: (senderId: number, requestId: string): CloudUploadResult => { + const upload = typeof requestId === "string" ? pending.get(requestId) : undefined + if (!upload || upload.senderId !== senderId) return { status: "cancelled" } + pending.delete(requestId) + return { status: "cancelled" } + }, + clear: (senderId: number) => { + for (const [requestId, upload] of pending) { + if (upload.senderId === senderId) pending.delete(requestId) + } + }, + } +} + +export function assertSafeUploadResult(result: T): T { + const inspect = (value: unknown): void => { + if (Array.isArray(value)) return value.forEach(inspect) + if (!value || typeof value !== "object") return + for (const [key, nested] of Object.entries(value)) { + if ( + [ + "proof", + "token", + "accessToken", + "manifest", + "manifestJson", + "manifestSha256", + "consentToken", + "uploadConsent", + "consent_token", + "upload_consent", + ].includes(key) + ) { + throw new UploadAdmissionError("upload-consent-invalid") + } + inspect(nested) + } + } + inspect(result) + return result +} + +function fail(log: (message: string) => void, code: UploadErrorCode): CloudUploadResult { + log(`[alpha-upload] failed code=${code}`) + return { status: "failed", error: code } +} diff --git a/packages/ui-mac/src/main/alpha-web-upload-consent.test.ts b/packages/ui-mac/src/main/alpha-web-upload-consent.test.ts new file mode 100644 index 000000000000..f2862f457be6 --- /dev/null +++ b/packages/ui-mac/src/main/alpha-web-upload-consent.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, test } from "bun:test" +import { requestUploadConsent } from "./alpha-web-upload-consent" + +describe("alpha-web upload_consent issuance seam", () => { + test("main requests a mocked alpha-web issuer response with the exact manifest binding", async () => { + const calls: Array<{ url: string; init?: RequestInit }> = [] + const fetcher = async (input: string | URL | Request, init?: RequestInit) => { + calls.push({ url: typeof input === "string" ? input : input.url, init }) + return new Response(JSON.stringify({ upload_consent: "header.claims.signature" }), { status: 200 }) + } + const manifestJson = '{"schema_version":1,"manifest_id":"manifest-1"}' + const result = await requestUploadConsent( + { accessToken: "access-secret", manifestJson, manifestSha256: "a".repeat(64) }, + { fetcher, web: "https://web.example" }, + ) + + expect(result).toBe("header.claims.signature") + expect(calls).toHaveLength(1) + expect(calls[0]?.url).toBe("https://web.example/auth/upload-consent") + expect(calls[0]?.init?.headers).toEqual({ + authorization: "Bearer access-secret", + "content-type": "application/json", + }) + const body = calls[0]?.init?.body + expect(JSON.parse(typeof body === "string" ? body : "null")).toEqual({ + schema_version: 1, + manifest: manifestJson, + manifest_sha256: "a".repeat(64), + }) + }) + + test("issuer failure is classified without returning its body", async () => { + await expect( + requestUploadConsent( + { accessToken: "access-secret", manifestJson: "{}", manifestSha256: "a".repeat(64) }, + { fetcher: async () => new Response("absolute /secret/path access-secret", { status: 503 }), web: "https://web.example" }, + ), + ).rejects.toMatchObject({ code: "upload-consent-issuance-failed" }) + }) +}) diff --git a/packages/ui-mac/src/main/alpha-web-upload-consent.ts b/packages/ui-mac/src/main/alpha-web-upload-consent.ts new file mode 100644 index 000000000000..405961a2ea8f --- /dev/null +++ b/packages/ui-mac/src/main/alpha-web-upload-consent.ts @@ -0,0 +1,44 @@ +import { CONTROL_ENVELOPE_MAX_BYTES } from "@alpha-code/contracts-consumer" +import { ALPHA_PATHS } from "../shared/alpha-config" +import { resolveEndpoints } from "./alpha-endpoints" +import { UploadAdmissionError } from "./alpha-upload-manifest" + +export async function requestUploadConsent(input: { + accessToken: string + manifestJson: string + manifestSha256: string +}, deps: { fetcher?: typeof fetch; web?: string } = {}) { + const response = await (deps.fetcher ?? fetch)(`${deps.web ?? resolveEndpoints().web}${ALPHA_PATHS.uploadConsent}`, { + method: "POST", + headers: { authorization: `Bearer ${input.accessToken}`, "content-type": "application/json" }, + body: JSON.stringify({ + schema_version: 1, + manifest: input.manifestJson, + manifest_sha256: input.manifestSha256, + }), + signal: AbortSignal.timeout(15000), + }) + if (!response.ok) throw new UploadAdmissionError("upload-consent-issuance-failed") + const text = await response.text() + if (Buffer.byteLength(text, "utf8") > CONTROL_ENVELOPE_MAX_BYTES) { + throw new UploadAdmissionError("upload-consent-invalid") + } + try { + const value: unknown = JSON.parse(text) + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new UploadAdmissionError("upload-consent-invalid") + } + if ( + Object.keys(value).length !== 1 || + !("upload_consent" in value) || + typeof value.upload_consent !== "string" || + !value.upload_consent + ) { + throw new UploadAdmissionError("upload-consent-invalid") + } + return value.upload_consent + } catch (error) { + if (error instanceof UploadAdmissionError) throw error + throw new UploadAdmissionError("upload-consent-invalid") + } +} diff --git a/packages/ui-mac/src/main/cloud-envelope-guard.ts b/packages/ui-mac/src/main/cloud-envelope-guard.ts index 7b0c3f059ebf..7cf8f60a057d 100644 --- a/packages/ui-mac/src/main/cloud-envelope-guard.ts +++ b/packages/ui-mac/src/main/cloud-envelope-guard.ts @@ -45,7 +45,7 @@ function scanValue(value: unknown, fieldPath: string, hits: SecretHit[]): void { return } if (value && typeof value === "object") { - for (const [k, v] of Object.entries(value as Record)) scanValue(v, `${fieldPath}.${k}`, hits) + for (const [k, v] of Object.entries(value)) scanValue(v, `${fieldPath}.${k}`, hits) } } @@ -60,6 +60,7 @@ export function scanEnvelopeSecrets(envelope: CloudJobEnvelope): SecretHit[] { export type GuardResult = { ok: true; envelope: CloudJobRequestV1 } | { ok: false; error: string } export function guardCloudEnvelope(envelope: CloudJobEnvelope): GuardResult { + if (containsReservedUploadControl(envelope)) return { ok: false, error: "upload-main-gate-required" } // ① denied_paths 缺省注入。「未显式声明」按无有效声明算:空数组零保护、也没有正当用例 // (真想送 .env 内容照样被 ③ 拦),一律视同未声明补默认。 const declared = envelope.constraints?.denied_paths @@ -98,3 +99,13 @@ export function guardCloudEnvelope(envelope: CloudJobEnvelope): GuardResult { return { ok: false, error: "contract-incompatible" } } } + +function containsReservedUploadControl(value: unknown): boolean { + if (Array.isArray(value)) return value.some(containsReservedUploadControl) + if (!value || typeof value !== "object") return false + return Object.entries(value).some( + ([key, nested]) => + ["upload", "manifest", "upload_consent", "consent_token", "consentToken"].includes(key) || + containsReservedUploadControl(nested), + ) +} diff --git a/packages/ui-mac/src/main/cloud-ipc.ts b/packages/ui-mac/src/main/cloud-ipc.ts index 853873b1aaf1..f3eb51b72920 100644 --- a/packages/ui-mac/src/main/cloud-ipc.ts +++ b/packages/ui-mac/src/main/cloud-ipc.ts @@ -7,21 +7,23 @@ import { finalizeArtifactWithQuota, registerDownloadedArtifact } from "./artifact-service" import { validateArtifactDescriptor } from "../shared/cloud-artifact-descriptor" -import { BrowserWindow, dialog, ipcMain, type IpcMainInvokeEvent } from "electron" +import { BrowserWindow, dialog, ipcMain, type IpcMainInvokeEvent, type OpenDialogOptions } from "electron" import { execFile } from "node:child_process" import * as fs from "node:fs" import * as path from "node:path" -import { dispatchCloudJob, getCloudJobStatus, cancelCloudJob, listCloudArtifacts, downloadCloudArtifactTo } from "./alpha-cloud-jobs" +import { dispatchCloudJob, dispatchExplicitCloudUpload, getCloudJobStatus, cancelCloudJob, listCloudArtifacts, downloadCloudArtifactTo } from "./alpha-cloud-jobs" import { isTerminalCloudEvent, subscribeCloudJobEvents } from "./alpha-cloud-events" -import { ensureAlphaScaffold, isSafeRunId, readProjectPrefs, safeResolveInAlpha, sanitizeArtifactName, saveCloudRun, writeProjectPrefs } from "./alpha-workdir" +import { ensureAlphaScaffold, isSafeRunId, safeResolveInAlpha, sanitizeArtifactName, saveCloudRun } from "./alpha-workdir" import { mirrorRunArtifacts } from "./alpha-user-workspace" -import { hasCloudConsent, withCloudConsent } from "./alpha-cloud-consent" import { getLogger } from "./logging" -import type { CloudJobEnvelope } from "../preload/types" +import type { CloudJobEnvelope, CloudUploadIntent } from "../preload/types" +import { getAccessTokenIdentity } from "./alpha-auth" +import { requestUploadConsent } from "./alpha-web-upload-consent" +import { assertSafeUploadResult, createMainUploadService } from "./alpha-upload" // REQ-020 T4(ADR-021 §1 diff-only):hub 的 code-review dispatch 入口只送 diff,不送全库。 // 工作树有变更 → `git diff HEAD`(含 staged);干净 → 回退最近一次 commit 的 diff(e2e 常在干净树上跑)。 -// 只读操作(git diff 无副作用);超 8MB 直接砍 buffer 报错 —— 上限最终由 dispatch 的 1MB 信封帽把关。 +// 只读操作(git diff 无副作用);超 8MB 直接砍 buffer 报错 —— 上限最终由 dispatch 的 256KiB 信封帽把关。 function gitDiff(directory: string): Promise<{ ok: true; diff: string; source: "worktree" | "last-commit" } | { ok: false; reason: string }> { const run = (args: string[]) => new Promise<{ ok: boolean; out: string }>((resolve) => { @@ -49,52 +51,53 @@ const subs = new Map void>() // 进行中的 artifact 下载:key = `${webContentsId}:${artifactId}` → abort(取消 IPC + 窗口销毁自动清)。 const downloads = new Map() -// B16(ADR-021 §4 显式通道):首次云派发 per 项目弹一次 PIPL 同意门。文案中文硬编码(main 无 i18n, -// ADR-022 先例)。诚实告知:出境内容(diff/任务文本)、去向(平台云)、可拒绝、per-项目记录。 -// 同意即写 .alpha/prefs.json;拒绝返回 false → 调用方回 consent-declined,不派发。 -async function ensureCloudConsent(event: IpcMainInvokeEvent, directory: string): Promise { - if (hasCloudConsent(readProjectPrefs(directory))) return true +const uploadService = createMainUploadService({ + identity: () => getAccessTokenIdentity("cloud.dispatch"), + issue: requestUploadConsent, + dispatch: dispatchExplicitCloudUpload, + log: (message) => getLogger().warn(message), +}) +const uploadCleanupSenders = new Set() + +async function pickExplicitUpload(event: IpcMainInvokeEvent) { const parent = BrowserWindow.fromWebContents(event.sender) ?? undefined - const opts = { - type: "warning" as const, - title: "云执行数据出境告知(首次派发)", - message: "此操作会把本项目的内容发送到 alpha 平台云执行。", - detail: - "将出境的内容:本次派发的代码差异(git diff)或任务文本 —— 用于在云端执行并返回结果。\n" + - "· 仅本次选择的内容出境,不上传整个项目;密钥/大文件由本机校验拦截(体积上限 + 密钥扫描)。\n" + - "· 登录为平台代付模式时,对话内容本就经平台代理(详见登录时的隐私告知与官网隐私说明)。\n" + - "· 本同意按项目记录一次(存于本项目 .alpha/prefs.json),可随时不再使用云派发。\n\n" + - "同意后本项目将不再重复询问。", - buttons: ["同意并派发", "取消"], - defaultId: 1, - cancelId: 1, - checkboxLabel: "我已知悉上述内容出境", - checkboxChecked: false, - } - const res = parent ? await dialog.showMessageBox(parent, opts) : await dialog.showMessageBox(opts) - if (res.response !== 0 || !res.checkboxChecked) { - getLogger().log(`[b16-consent] declined for project (response=${res.response}, ack=${res.checkboxChecked})`) - return false - } - const written = writeProjectPrefs(directory, withCloudConsent(readProjectPrefs(directory), new Date().toISOString())) - if (!written.ok) { - // 写失败不静默放行:无法留痕的同意等于没同意(反 placebo)。loud 报错,本次拒绝。 - getLogger().error(`[b16-consent] failed to persist consent: ${written.reason}`) - return false + const projectOptions: OpenDialogOptions = { title: "选择项目目录", properties: ["openDirectory"] } + const project = parent + ? await dialog.showOpenDialog(parent, projectOptions) + : await dialog.showOpenDialog(projectOptions) + if (project.canceled || project.filePaths.length !== 1) return null + const fileOptions: OpenDialogOptions = { + title: "选择本次上传的文件", + defaultPath: project.filePaths[0], + properties: ["openFile", "multiSelections"], } - getLogger().log("[b16-consent] granted + persisted for project") - return true + const files = parent ? await dialog.showOpenDialog(parent, fileOptions) : await dialog.showOpenDialog(fileOptions) + if (files.canceled || !files.filePaths.length) return null + return { projectDirectory: project.filePaths[0], files: files.filePaths } } export function registerCloudIpcHandlers() { - ipcMain.handle("cloud-dispatch", async (e: IpcMainInvokeEvent, envelope: CloudJobEnvelope, directory?: string) => { - // B16:有项目上下文(hub 派发)→ per-项目同意门;无 directory(无项目上下文的调用)→ 跳过 - // per-项目门(无可记录的项目同意),隐式通道告知由登录流承担。 - if (typeof directory === "string" && directory) { - if (!(await ensureCloudConsent(e, directory))) return { error: "consent-declined" } + // The existing input.diff/code-review route is grandfathered and does not mint an upload manifest. + ipcMain.handle("cloud-dispatch", (_event: IpcMainInvokeEvent, envelope: CloudJobEnvelope) => dispatchCloudJob(envelope)) + ipcMain.handle("cloud-upload", async (event: IpcMainInvokeEvent, intent: CloudUploadIntent) => { + const selection = await pickExplicitUpload(event) + if (!selection) return assertSafeUploadResult({ status: "cancelled" as const }) + if (!uploadCleanupSenders.has(event.sender.id)) { + const senderId = event.sender.id + uploadCleanupSenders.add(senderId) + event.sender.once("destroyed", () => { + uploadCleanupSenders.delete(senderId) + uploadService.clear(senderId) + }) } - return dispatchCloudJob(envelope) + return assertSafeUploadResult(await uploadService.prepare(event.sender.id, intent, selection)) }) + ipcMain.handle("cloud-upload-confirm", async (event: IpcMainInvokeEvent, requestId: string) => + assertSafeUploadResult(await uploadService.confirm(event.sender.id, requestId)), + ) + ipcMain.handle("cloud-upload-cancel", (event: IpcMainInvokeEvent, requestId: string) => + assertSafeUploadResult(uploadService.cancel(event.sender.id, requestId)), + ) ipcMain.handle("cloud-status", (_e: IpcMainInvokeEvent, jobId: string) => getCloudJobStatus(jobId)) ipcMain.handle("cloud-cancel", (_e: IpcMainInvokeEvent, jobId: string) => cancelCloudJob(jobId)) ipcMain.handle("cloud-artifacts", (_e: IpcMainInvokeEvent, jobId: string) => listCloudArtifacts(jobId)) diff --git a/packages/ui-mac/src/main/cloud-schedule-config.ts b/packages/ui-mac/src/main/cloud-schedule-config.ts new file mode 100644 index 000000000000..3410078040b6 --- /dev/null +++ b/packages/ui-mac/src/main/cloud-schedule-config.ts @@ -0,0 +1,9 @@ +import type { AutomationTask } from "../shared/automation-types" + +export function cloudScheduleEnvelopeFor(task: AutomationTask): Record { + return { autonomy: "pipeline", kind: "research", input: { question: task.prompt } } +} + +export function cloudScheduleRegistrationFor(task: AutomationTask, cron: string) { + return { name: task.name, cron, envelope: cloudScheduleEnvelopeFor(task), enabled: task.enabled } +} diff --git a/packages/ui-mac/src/main/cloud-sidecar-config.ts b/packages/ui-mac/src/main/cloud-sidecar-config.ts new file mode 100644 index 000000000000..96675785c4b9 --- /dev/null +++ b/packages/ui-mac/src/main/cloud-sidecar-config.ts @@ -0,0 +1,9 @@ +export function materializeCloudMcpConfig(url: string, secretRef: string) { + return { + type: "remote" as const, + url, + enabled: true, + headers: { Authorization: `Bearer ${secretRef}` }, + oauth: false, + } +} diff --git a/packages/ui-mac/src/main/sidecar.ts b/packages/ui-mac/src/main/sidecar.ts index be2b9beb4859..36cf6698f323 100644 --- a/packages/ui-mac/src/main/sidecar.ts +++ b/packages/ui-mac/src/main/sidecar.ts @@ -11,6 +11,7 @@ import { hasSecretFile, secretFileRef } from "./alpha-secret-files" import { applyCloudWebSearchDisable } from "./cloud-web-search" import { alphaGlobalRoot, alphaJsoncPath } from "./engine-config-truth" import { injectDisabledOverrides } from "./ext-disabled-injection" +import { materializeCloudMcpConfig } from "./cloud-sidecar-config" // ADR-006 bridge ("two runtime worlds"). opencode's ToolRegistry dynamically imports a project's // raw-TS tools (.opencode/tool/*.ts), and packages whose TS entry does `import "./x.js"` (e.g. @@ -371,13 +372,7 @@ function injectAlphaConfig(userDataPath: string, extPluginPath?: string) { if (mcpUrl && hasSecretFile(userDataPath, "ALPHA_CLOUD_TOKEN")) { config.mcp = { ...(config.mcp ?? {}), - cloud: { - type: "remote", - url: mcpUrl, - enabled: true, - headers: { Authorization: `Bearer ${secretFileRef(userDataPath, "ALPHA_CLOUD_TOKEN")}` }, - oauth: false, - }, + cloud: materializeCloudMcpConfig(mcpUrl, secretFileRef(userDataPath, "ALPHA_CLOUD_TOKEN")), } } diff --git a/packages/ui-mac/src/main/upload-boundaries.test.ts b/packages/ui-mac/src/main/upload-boundaries.test.ts new file mode 100644 index 000000000000..d7b84805eb1a --- /dev/null +++ b/packages/ui-mac/src/main/upload-boundaries.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, test } from "bun:test" +import type { AutomationTask } from "../shared/automation-types" +import { cloudScheduleRegistrationFor } from "./cloud-schedule-config" +import { materializeCloudMcpConfig } from "./cloud-sidecar-config" +import { guardCloudEnvelope } from "./cloud-envelope-guard" + +const hasUploadAuthorityReference = (value: unknown) => + /upload[_-]?consent|manifest_sha256|consent[_-]?token|x-alpha-upload-consent/i.test(JSON.stringify(value)) + +describe("upload authority is absent from non-explicit channels", () => { + test("MCP schedule and bounded-agent surfaces have no upload consent channel", () => { + const task: AutomationTask = { + id: "task-1", + name: "Daily research", + nlText: "daily", + schedule: { kind: "cron", expr: "0 9 * * *" }, + target: { projectDir: "/project", agent: "alpha-automation" }, + prompt: "Research release notes", + execution: "cloud", + permissionProfile: "readonly", + budget: { maxDurationMin: 15 }, + overlapPolicy: "skip", + catchUpPolicy: "skip", + notify: { system: true }, + enabled: true, + createdAt: "2026-07-22T00:00:00.000Z", + } + const savedSchedule = cloudScheduleRegistrationFor(task, "0 9 * * *") + const sidecar = materializeCloudMcpConfig("https://cloud.example/mcp", "{file:/safe/cloud-token}") + const boundedAgent = guardCloudEnvelope({ + autonomy: "bounded-agent", + objective: "Review the public release notes", + capabilities: ["web_search"], + }) + + expect(hasUploadAuthorityReference(savedSchedule)).toBe(false) + expect(hasUploadAuthorityReference(sidecar)).toBe(false) + expect(boundedAgent.ok).toBe(true) + expect(hasUploadAuthorityReference(boundedAgent)).toBe(false) + }) + + test("renderer or agent injection of upload authority hits upload-main-gate-required", () => { + expect( + guardCloudEnvelope({ + autonomy: "bounded-agent", + objective: "work", + capabilities: [], + upload_consent: "forged", + }), + ).toEqual({ ok: false, error: "upload-main-gate-required" }) + expect( + guardCloudEnvelope({ + autonomy: "pipeline", + kind: "code-review", + input: { manifest: { consent_required: false } }, + }), + ).toEqual({ ok: false, error: "upload-main-gate-required" }) + }) + + test("input.diff and code-review remain grandfathered", () => { + expect( + guardCloudEnvelope({ autonomy: "pipeline", kind: "code-review", input: { diff: "diff --git a/a b/a\n" } }).ok, + ).toBe(true) + }) +}) diff --git a/packages/ui-mac/src/preload/index.ts b/packages/ui-mac/src/preload/index.ts index 78813bbd9277..898b80a1818e 100644 --- a/packages/ui-mac/src/preload/index.ts +++ b/packages/ui-mac/src/preload/index.ts @@ -234,7 +234,10 @@ const api: ElectronAPI = { transactions: (limit) => ipcRenderer.invoke("account-transactions", limit), }, cloud: { - dispatch: (envelope, directory) => ipcRenderer.invoke("cloud-dispatch", envelope, directory), + dispatch: (envelope) => ipcRenderer.invoke("cloud-dispatch", envelope), + upload: (intent) => ipcRenderer.invoke("cloud-upload", intent), + confirmUpload: (requestId) => ipcRenderer.invoke("cloud-upload-confirm", requestId), + cancelUpload: (requestId) => ipcRenderer.invoke("cloud-upload-cancel", requestId), status: (jobId) => ipcRenderer.invoke("cloud-status", jobId), cancel: (jobId) => ipcRenderer.invoke("cloud-cancel", jobId), artifacts: (jobId) => ipcRenderer.invoke("cloud-artifacts", jobId), diff --git a/packages/ui-mac/src/preload/types.ts b/packages/ui-mac/src/preload/types.ts index 52268fe92723..5c87884f0023 100644 --- a/packages/ui-mac/src/preload/types.ts +++ b/packages/ui-mac/src/preload/types.ts @@ -309,6 +309,37 @@ export type CloudRunManifest = export type CloudJobEvent = { event: string; data: unknown; id?: string } /** Same shape as AccountResult; distinct alias for the cloud jobs surface. */ export type CloudResult = T | { error: string } +export type UploadFindingKind = "contact" | "identity" | "credential" | "protected" | "unknown" +export type UploadPreview = { + pipeline: "code-review" + fileCount: number + totalBytes: number + files: Array<{ path: string; sizeBytes: number; sensitive: boolean }> + findings: Array<{ kind: UploadFindingKind; fileCount: number }> + purpose: "artifact.upload" + retentionClass: "standard" +} +export type CloudUploadIntent = { kind: "code-review" } +export type CloudUploadResult = + | { status: "consent-required"; requestId: string; preview: UploadPreview } + | { status: "sent"; privacy: "clear" | "confirmed"; job: CloudDispatchResult; directory: string } + | { status: "cancelled" } + | { + status: "failed" + error: + | "not-authenticated" + | "upload-selection-invalid" + | "upload-file-limit" + | "upload-size-limit" + | "upload-control-limit" + | "upload-path-invalid" + | "upload-file-unreadable" + | "upload-not-text" + | "upload-consent-issuance-failed" + | "upload-consent-invalid" + | "upload-dispatch-failed" + | "upload-main-gate-required" + } /** B gateway /v1/models 的一条 live 模型(真相源 allowlist)。 */ export type PlatformLiveModel = { id: string; provider?: string; minPlan?: string } @@ -768,9 +799,12 @@ export type ElectronAPI = { // reaches the renderer). The MCP facade path (agent-triggered cloud.* tools) is wired separately via // sidecar.ts mcp.servers.cloud; this HTTP surface is for app-driven dispatch/status. cloud: { - /** directory:B16 显式通道 —— 提供项目目录时,首次派发弹 per-项目 PIPL 同意门(main 侧); - * 缺省则跳过 per-项目门(隐式告知由登录流承担)。 */ - dispatch: (envelope: CloudJobEnvelope, directory?: string) => Promise> + /** Grandfathered input.diff/code-review dispatch. Explicit files use upload below. */ + dispatch: (envelope: CloudJobEnvelope) => Promise> + /** Main picks, reads, classifies and freezes explicit files. Sensitive scope returns a preview only. */ + upload: (intent: CloudUploadIntent) => Promise + confirmUpload: (requestId: string) => Promise + cancelUpload: (requestId: string) => Promise status: (jobId: string) => Promise> cancel: (jobId: string) => Promise> artifacts: (jobId: string) => Promise> diff --git a/packages/ui-mac/src/preload/upload-surface.typecheck.ts b/packages/ui-mac/src/preload/upload-surface.typecheck.ts new file mode 100644 index 000000000000..a00a838995ad --- /dev/null +++ b/packages/ui-mac/src/preload/upload-surface.typecheck.ts @@ -0,0 +1,29 @@ +import type { CloudUploadIntent, CloudUploadResult } from "./types" + +type Forbidden = + | "proof" + | "token" + | "accessToken" + | "manifest" + | "manifestJson" + | "manifestSha256" + | "consentToken" + | "uploadConsent" + | "consent_token" + | "upload_consent" +type HasNoForbiddenKey = Value extends readonly (infer Item)[] + ? HasNoForbiddenKey + : Value extends object + ? Extract extends never + ? false extends { [Key in keyof Value]: HasNoForbiddenKey }[keyof Value] + ? false + : true + : false + : true + +const uploadSurfaceHasNoCredentialOrManifestFields: HasNoForbiddenKey = true +void uploadSurfaceHasNoCredentialOrManifestFields + +// @ts-expect-error the recursive guard must reject forbidden fields even when nested. +const forbiddenFieldProbe: HasNoForbiddenKey<{ nested: { token: string } }> = true +void forbiddenFieldProbe diff --git a/packages/ui-mac/src/renderer/extensions/cloud-dispatch-box.tsx b/packages/ui-mac/src/renderer/extensions/cloud-dispatch-box.tsx index 85d143e990c7..aef328a1cf6e 100644 --- a/packages/ui-mac/src/renderer/extensions/cloud-dispatch-box.tsx +++ b/packages/ui-mac/src/renderer/extensions/cloud-dispatch-box.tsx @@ -1,143 +1,245 @@ -// REQ-020 T4 —— code-review pipeline 详情页里的 dispatch 入口(diff-only,ADR-021 §1)。 -// 流程:选项目目录 → main 侧 git diff(工作树优先,干净树回退最近一次 commit)→ dispatch(经 -// ADR-021 §2 三校验,超限/含密钥 loud 拒发行内呈现)→ SSE 进度 → 终态 saveRun 落 -// <项目>/.alpha/runs//。app-driven 派发不经会话,cloud-run-watcher(只认 firehose tool -// part)看不见 —— 回流由本组件自己做。离开详情页即取消订阅:任务在云端继续,但不再自动回流 -// (可在会话内经 cloud_status/artifacts 取回)—— 如实受限,不装后台常驻。 - -import { createSignal, onCleanup, Show } from "solid-js" +// Code-review cloud entry. Explicit file upload is main-owned and conditionally consented; the +// existing input.diff route remains available as a grandfathered secondary action. + +import { onCleanup, Show } from "solid-js" +import { createStore } from "solid-js/store" import { t } from "../i18n" -import type { CloudDispatchResult, CloudJobEnvelope } from "../../preload/types" +import { pushToast } from "../alpha-ui/Toast" +import type { + CloudDispatchResult, + CloudJobEnvelope, + CloudUploadResult, + UploadPreview, +} from "../../preload/types" import type { CloudPipelineSpec } from "./catalog-types" +import { UploadConsentDialog } from "./upload-consent-dialog" type Phase = "idle" | "dispatching" | "running" | "done" | "failed" +type ConsentState = { requestId: string; preview: UploadPreview } -// dispatch 错误码 → 人话(guard 的 envelope-too-large/secrets-detected 自带中文说明,原样展示)。 function dispatchError(code: string): string { if (code === "not-authenticated" || code === "unauthorized") return t("alpha.ext.cloudErrAuth") if (code === "no-cloud-endpoint") return t("alpha.ext.cloudErrEndpoint") if (code === "network") return t("alpha.ext.cloudErrNetwork") - if (code === "consent-declined") return t("alpha.ext.cloudErrConsentDeclined") return code } -export function CloudDispatchBox(props: { spec: CloudPipelineSpec; ready: boolean }) { - const [phase, setPhase] = createSignal("idle") - const [err, setErr] = createSignal("") - const [jobId, setJobId] = createSignal("") - const [step, setStep] = createSignal("") - const [diffSource, setDiffSource] = createSignal<"worktree" | "last-commit" | null>(null) - const [savedDir, setSavedDir] = createSignal("") +function uploadError(code: Extract["error"]) { + if (code === "not-authenticated") return t("alpha.ext.cloudErrAuth") + if (code === "upload-file-limit") return t("alpha.cloud.consent.errFileLimit") + if (code === "upload-size-limit") return t("alpha.cloud.consent.errSizeLimit") + if (code === "upload-control-limit") return t("alpha.cloud.consent.errControlLimit") + if (code === "upload-not-text") return t("alpha.cloud.consent.errText") + if (code === "upload-consent-issuance-failed" || code === "upload-consent-invalid") { + return t("alpha.cloud.consent.errToken") + } + if (code === "upload-dispatch-failed") return t("alpha.cloud.consent.errDispatch") + return t("alpha.cloud.consent.errScope") +} +export function CloudDispatchBox(props: { spec: CloudPipelineSpec; ready: boolean }) { + const [state, setState] = createStore<{ + phase: Phase + err: string + jobId: string + step: string + diffSource: "worktree" | "last-commit" | null + savedDir: string + transparent: boolean + consent: ConsentState | null + consentBusy: boolean + }>({ + phase: "idle", + err: "", + jobId: "", + step: "", + diffSource: null, + savedDir: "", + transparent: false, + consent: null, + consentBusy: false, + }) + let triggerButton: HTMLButtonElement | undefined let unlisten: (() => void) | undefined + onCleanup(() => { unlisten?.() - const id = jobId() - if (id) void window.api.cloud.unsubscribe(id) + if (state.jobId) void window.api.cloud.unsubscribe(state.jobId) + if (state.consent) void window.api.cloud.cancelUpload(state.consent.requestId) }) const finish = async (directory: string, id: string, terminal: string, contract: CloudJobEnvelope) => { const saved = await window.api.cloud.saveRun(directory, id, contract) if (saved.ok) { - setSavedDir(saved.dir) - setPhase(terminal === "job.completed" ? "done" : "failed") - if (terminal !== "job.completed") setErr(t("alpha.ext.cloudRunFailed")) - } else { - setPhase("failed") - setErr(`${t("alpha.ext.cloudSaveFailed")}: ${saved.reason}`) + setState({ savedDir: saved.dir, phase: terminal === "job.completed" ? "done" : "failed" }) + if (terminal !== "job.completed") setState("err", t("alpha.ext.cloudRunFailed")) + return + } + setState({ phase: "failed", err: `${t("alpha.ext.cloudSaveFailed")}: ${saved.reason}` }) + } + + const beginJob = async ( + directory: string, + job: CloudDispatchResult, + contract: CloudJobEnvelope, + transparent: boolean, + ) => { + setState({ + phase: "running", + jobId: job.job_id, + step: "queued", + transparent, + err: "", + savedDir: "", + consent: null, + consentBusy: false, + }) + unlisten?.() + unlisten = window.api.cloud.onEvent((payload) => { + if (payload.jobId !== job.job_id) return + const messageType = + payload.data && typeof payload.data === "object" && "type" in payload.data + ? payload.data.type + : undefined + const name = payload.event === "message" && typeof messageType === "string" ? messageType : payload.event + if (name && name !== "job.snapshot") setState("step", name) + if (["job.completed", "job.failed", "job.cancelled"].includes(name)) { + void finish(directory, job.job_id, name, contract) + } + }) + await window.api.cloud.subscribe(job.job_id) + } + + const consumeUpload = async (result: CloudUploadResult) => { + if (result.status === "cancelled") { + setState({ phase: "idle", consent: null, consentBusy: false }) + return } + if (result.status === "failed") { + setState({ phase: "failed", err: uploadError(result.error), consent: null, consentBusy: false }) + return + } + if (result.status === "consent-required") { + setState({ phase: "idle", consent: result, consentBusy: false }) + return + } + pushToast({ kind: "success", title: t("alpha.cloud.consent.toastSent") }) + await beginJob( + result.directory, + result.job, + { autonomy: "pipeline", kind: "code-review", input: {} }, + result.privacy === "clear", + ) + } + + const runUpload = async () => { + setState({ phase: "dispatching", err: "", savedDir: "", diffSource: null, transparent: false }) + await consumeUpload(await window.api.cloud.upload({ kind: "code-review" })) + } + + const confirmUpload = async () => { + if (!state.consent || state.consentBusy) return + const requestId = state.consent.requestId + setState("consentBusy", true) + await consumeUpload(await window.api.cloud.confirmUpload(requestId)) + } + + const cancelUpload = () => { + if (!state.consent || state.consentBusy) return + const requestId = state.consent.requestId + setState({ consent: null, consentBusy: false, phase: "idle" }) + void window.api.cloud.cancelUpload(requestId) } - const run = async () => { - setErr("") - setSavedDir("") - setDiffSource(null) + const runLegacyDiff = async () => { + setState({ err: "", savedDir: "", diffSource: null, transparent: false }) const picked = await window.api.openDirectoryPicker({ title: t("alpha.ext.cloudPickProject") }) const directory = Array.isArray(picked) ? picked[0] : picked if (!directory) return - setPhase("dispatching") - const diffR = await window.api.cloud.gitDiff(directory) - if (!diffR.ok) { - setErr(diffR.reason === "no-diff" ? t("alpha.ext.cloudNoDiff") : diffR.reason) - setPhase("failed") + setState("phase", "dispatching") + const diff = await window.api.cloud.gitDiff(directory) + if (!diff.ok) { + setState({ err: diff.reason === "no-diff" ? t("alpha.ext.cloudNoDiff") : diff.reason, phase: "failed" }) return } - setDiffSource(diffR.source) + setState("diffSource", diff.source) const envelope: CloudJobEnvelope = { autonomy: "pipeline", kind: props.spec.pipelineKind, - input: { diff: diffR.diff }, + input: { diff: diff.diff }, budget: props.spec.budgetDefaults, - // denied_paths 不在此声明 —— 交给 main 侧 guard 缺省注入(ADR-021 §2,单点)。 constraints: { network: "restricted" }, } - const r = await window.api.cloud.dispatch(envelope, directory) - if ((r as { error?: string }).error) { - setErr(dispatchError((r as { error: string }).error)) - setPhase("failed") + const result = await window.api.cloud.dispatch(envelope) + if ("error" in result) { + setState({ err: dispatchError(result.error), phase: "failed" }) return } - const id = (r as CloudDispatchResult).job_id - setJobId(id) - setPhase("running") - setStep("queued") - unlisten?.() - unlisten = window.api.cloud.onEvent((p) => { - if (p.jobId !== id) return - const name = p.event === "message" ? String((p.data as { type?: string } | null)?.type ?? "") : p.event - if (name && name !== "job.snapshot") setStep(name) - if (name === "job.completed" || name === "job.failed" || name === "job.cancelled") { - void finish(directory, id, name, envelope) - } - }) - await window.api.cloud.subscribe(id) + await beginJob(directory, result, envelope, false) } + const busy = () => state.phase === "dispatching" || state.phase === "running" + return (
{t("alpha.ext.cloudDispatchTitle")}
-

{t("alpha.ext.cloudDispatchHint")}

+

{t("alpha.cloud.consent.uploadHint")}

- + + {t("alpha.ext.cloudNeedPlatformNote")} - + + {t("alpha.cloud.consent.silentPass")} + + - {t("alpha.ext.cloudDiffSource")}:{diffSource() === "worktree" ? t("alpha.ext.cloudDiffWorktree") : t("alpha.ext.cloudDiffLastCommit")} + {t("alpha.ext.cloudDiffSource")}: + {state.diffSource === "worktree" ? t("alpha.ext.cloudDiffWorktree") : t("alpha.ext.cloudDiffLastCommit")} - - - {jobId()} · {step()} - + + {state.jobId} · {state.step}
- +

- ✓ {t("alpha.ext.cloudDone")} · {savedDir()} + ✓ {t("alpha.ext.cloudDone")} · {state.savedDir}

- {/* B11:失败一律行内 */} - -

{err()}

+ +

{state.err}

- +

- {t("alpha.ext.cloudSaved")} {savedDir()} + {t("alpha.ext.cloudSaved")} {state.savedDir}

+ + void confirmUpload()} + restoreFocus={() => triggerButton} + />
) } diff --git a/packages/ui-mac/src/renderer/extensions/upload-consent-dialog.test.ts b/packages/ui-mac/src/renderer/extensions/upload-consent-dialog.test.ts new file mode 100644 index 000000000000..607559f61f1b --- /dev/null +++ b/packages/ui-mac/src/renderer/extensions/upload-consent-dialog.test.ts @@ -0,0 +1,218 @@ +import { GlobalRegistrator } from "@happy-dom/global-registrator" +import { afterAll, afterEach, beforeEach, describe, expect, test } from "bun:test" +import appPlugin from "@opencode-ai/app/vite" +import { mkdtempSync, rmSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { pathToFileURL } from "node:url" +import { build } from "vite" +import type { createComponent } from "solid-js" +import type { render } from "solid-js/web" +import type { CloudUploadResult, UploadPreview } from "../../preload/types" +import type { UploadConsentDialog } from "./upload-consent-dialog" +import type { CloudDispatchBox } from "./cloud-dispatch-box" +import type { CloudPipelineSpec } from "./catalog-types" +import { dict as zh } from "../i18n/zh" + +type Runtime = { + createComponent: typeof createComponent + render: typeof render + UploadConsentDialog: typeof UploadConsentDialog + CloudDispatchBox: typeof CloudDispatchBox +} + +const runtimeDirectory = mkdtempSync(join(tmpdir(), "alpha-upload-consent-render-")) +await build({ + configFile: false, + logLevel: "silent", + plugins: [appPlugin.at(-1)!], + build: { + emptyOutDir: true, + outDir: runtimeDirectory, + lib: { + entry: join(import.meta.dir, "upload-consent-test-runtime.ts"), + formats: ["es"], + fileName: () => "upload-consent-test-runtime.js", + }, + rollupOptions: { output: { inlineDynamicImports: true } }, + }, +}) + +const disposers: Array<() => void> = [] +GlobalRegistrator.register() +const runtime = (await import(pathToFileURL(join(runtimeDirectory, "upload-consent-test-runtime.js")).href)) as Runtime + +const preview: UploadPreview = { + pipeline: "code-review", + fileCount: 2, + totalBytes: 1550, + files: [ + { path: "src/contact.ts", sizeBytes: 1500, sensitive: true }, + { path: "src/clean.ts", sizeBytes: 50, sensitive: false }, + ], + findings: [ + { kind: "contact", fileCount: 1 }, + { kind: "credential", fileCount: 1 }, + ], + purpose: "artifact.upload", + retentionClass: "standard", +} + +beforeEach(() => document.body.replaceChildren()) +afterEach(() => disposers.splice(0).reverse().forEach((dispose) => dispose())) +afterAll(async () => { + await GlobalRegistrator.unregister() + rmSync(runtimeDirectory, { recursive: true, force: true }) +}) + +async function flush() { + await Promise.resolve() + await Promise.resolve() + await new Promise((resolve) => setTimeout(resolve, 0)) +} + +function mount(options: { busy?: boolean; onCancel?: () => void; onConfirm?: () => void } = {}) { + const host = document.createElement("div") + document.body.append(host) + disposers.push( + runtime.render( + () => + runtime.createComponent(runtime.UploadConsentDialog, { + state: { requestId: "request-1", preview }, + busy: options.busy ?? false, + onCancel: options.onCancel ?? (() => {}), + onConfirm: options.onConfirm ?? (() => {}), + }), + host, + ), + ) +} + +const spec: CloudPipelineSpec = { + kind: "cloud", + pipelineKind: "code-review", + inputContract: [], + budgetDefaults: { max_iter: 5, max_tokens: 10_000, max_wall_clock_sec: 60 }, + budgetLimits: { max_iter: 10, max_tokens: 20_000, max_wall_clock_sec: 120 }, + tier: "pipeline", + upstreamData: [], +} + +function mountDispatch(result: CloudUploadResult) { + Object.defineProperty(window, "api", { + configurable: true, + value: { + cloud: { + upload: async () => result, + confirmUpload: async () => result, + cancelUpload: async () => ({ status: "cancelled" }), + subscribe: async () => ({ ok: true }), + unsubscribe: async () => ({ ok: true }), + onEvent: () => () => {}, + saveRun: async () => ({ ok: false, reason: "not reached" }), + }, + }, + }) + const host = document.createElement("div") + document.body.append(host) + disposers.push( + runtime.render( + () => runtime.createComponent(runtime.CloudDispatchBox, { spec, ready: true }), + host, + ), + ) +} + +describe("approved explicit-upload consent dialog harness", () => { + test("renders protected findings bounded scope purpose retention and expandable file preview", async () => { + mount() + await flush() + + const dialog = document.querySelector("[role=dialog]") + expect(dialog?.textContent).toContain(zh["alpha.cloud.consent.title"]) + expect(dialog?.textContent).toContain("2 个文件 · 1.5 KB") + expect(dialog?.textContent).toContain(zh["alpha.cloud.consent.purposeLabel"]) + expect(dialog?.textContent).toContain(zh["alpha.cloud.consent.retentionLabel"]) + expect(document.querySelectorAll(".alpha-upl-file")).toHaveLength(0) + + document.querySelector(".alpha-upl-more")!.click() + await flush() + expect(document.querySelectorAll(".alpha-upl-file")).toHaveLength(2) + expect(document.querySelector(".alpha-upl-file[data-flag]")?.textContent).toContain("src/contact.ts") + expect(document.querySelectorAll(".alpha-upl-file .chip")).toHaveLength(1) + }) + + test("cancel is the safe autofocus action and Escape backdrop and button all cancel", async () => { + let cancellations = 0 + mount({ onCancel: () => cancellations++ }) + await flush() + + const cancel = document.querySelector('.a-btn[data-variant="ghost"]')! + expect(document.activeElement).toBe(cancel) + cancel.click() + document.querySelector(".a-dialog-backdrop")!.click() + document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true, cancelable: true })) + expect(cancellations).toBe(3) + }) + + test("busy confirmation is loading and cannot dismiss or double-submit", async () => { + let cancellations = 0 + let confirmations = 0 + mount({ busy: true, onCancel: () => cancellations++, onConfirm: () => confirmations++ }) + await flush() + + const primary = document.querySelector('.a-btn[data-variant="primary"]')! + expect(primary.disabled).toBe(true) + expect(primary.getAttribute("aria-busy")).toBe("true") + primary.click() + document.querySelector(".a-dialog-backdrop")!.click() + document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true, cancelable: true })) + expect(confirmations).toBe(0) + expect(cancellations).toBe(0) + }) +}) + +describe("four approved upload UI outcomes", () => { + test("sensitive opens the preview dialog", async () => { + mountDispatch({ status: "consent-required", requestId: "request-1", preview }) + document.querySelector('.alpha-ext-add[data-variant="primary"]')!.click() + await flush() + expect(document.querySelector("[role=dialog]")).not.toBeNull() + }) + + test("non-sensitive is silent and shows the non-blocking transparency line", async () => { + mountDispatch({ + status: "sent", + privacy: "clear", + directory: "/project", + job: { + schema_version: 1, + job_id: "job_clear", + status: "queued", + autonomy: "pipeline", + kind: "code-review", + urls: { status: "/status", events: "/events", result: "/result" }, + }, + }) + document.querySelector('.alpha-ext-add[data-variant="primary"]')!.click() + await flush() + expect(document.querySelector("[role=dialog]")).toBeNull() + expect(document.body.textContent).toContain(zh["alpha.cloud.consent.silentPass"]) + }) + + test("cancelled returns to neutral without dialog or inline error", async () => { + mountDispatch({ status: "cancelled" }) + document.querySelector('.alpha-ext-add[data-variant="primary"]')!.click() + await flush() + expect(document.querySelector("[role=dialog]")).toBeNull() + expect(document.querySelector(".alpha-ext-card-err")).toBeNull() + }) + + test("failure closes the dialog surface and renders an inline error", async () => { + mountDispatch({ status: "failed", error: "upload-consent-issuance-failed" }) + document.querySelector('.alpha-ext-add[data-variant="primary"]')!.click() + await flush() + expect(document.querySelector("[role=dialog]")).toBeNull() + expect(document.querySelector(".alpha-ext-card-err")?.textContent).toContain(zh["alpha.cloud.consent.errToken"]) + }) +}) diff --git a/packages/ui-mac/src/renderer/extensions/upload-consent-dialog.tsx b/packages/ui-mac/src/renderer/extensions/upload-consent-dialog.tsx new file mode 100644 index 000000000000..b13905baa567 --- /dev/null +++ b/packages/ui-mac/src/renderer/extensions/upload-consent-dialog.tsx @@ -0,0 +1,146 @@ +import { createStore } from "solid-js/store" +import { createEffect, For, Show } from "solid-js" +import type { UploadFindingKind, UploadPreview } from "../../preload/types" +import { Button } from "../alpha-ui/Button" +import { Dialog } from "../alpha-ui/Dialog" +import { t } from "../i18n" +import "./upload-consent.css" + +const icon = (path: string) => ( + +) + +function formatBytes(bytes: number) { + if (bytes < 1024) return `${bytes} B` + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(bytes < 10 * 1024 ? 1 : 0)} KB` + return `${(bytes / 1024 / 1024).toFixed(1)} MB` +} + +function findingText(kind: UploadFindingKind, count: number) { + const values = { n: count } + if (kind === "contact") return t("alpha.cloud.consent.findContact", values) + if (kind === "identity") return t("alpha.cloud.consent.findIdentity", values) + if (kind === "credential") return t("alpha.cloud.consent.findCredential", values) + if (kind === "protected") return t("alpha.cloud.consent.findProtected", values) + return t("alpha.cloud.consent.findUnknown", values) +} + +export function UploadConsentDialog(props: { + state: { requestId: string; preview: UploadPreview } | null + busy: boolean + onCancel: () => void + onConfirm: () => void + restoreFocus?: () => HTMLElement | null | undefined +}) { + const [view, setView] = createStore({ expanded: false }) + const preview = () => props.state?.preview + createEffect(() => { + void props.state?.requestId + setView("expanded", false) + }) + + return ( + + + + + } + > + + {(value) => ( +
+

+ {t("alpha.cloud.consent.introPrefix")} {t("alpha.cloud.consent.pipelineReview")} + {t("alpha.cloud.consent.introSuffix")} +

+ +
+ {icon("M8 1.8 14 13H2L8 1.8ZM8 5.4v3.4M8 11.2h.01")} + + {t("alpha.cloud.consent.flagTitle")} +
    + {(finding) =>
  • {findingText(finding.kind, finding.fileCount)}
  • }
    +
+
+
+ +
+ {icon("M2.5 4.2h4l1.2 1.5h5.8v7H2.5z")} + + {t("alpha.cloud.consent.scopeFiles")} + {t("alpha.cloud.consent.scopeHint")} + + {t("alpha.cloud.consent.scopeCount", { n: value().fileCount, size: formatBytes(value().totalBytes) })} +
+ +
+ + + {(file) => ( +
+ {icon("M4 1.8h5l3 3v9.4H4zM9 1.8v3h3")} + {file.path} + + {t("alpha.cloud.consent.fileFlag")} + + {formatBytes(file.sizeBytes)} +
+ )} +
+
+ +
+ +
+
+ {icon("M2.5 8h8M8 4.5 11.5 8 8 11.5M13.5 3v10")} + {t("alpha.cloud.consent.purposeLabel")} + {t("alpha.cloud.consent.pipelineReview")} — {t("alpha.cloud.consent.purposeReview")} +
+
+ {icon("M3 3.5h10M5 3.5V2h6v1.5M4.5 5.5l.7 8h5.6l.7-8")} + {t("alpha.cloud.consent.retentionLabel")} + {t("alpha.cloud.consent.retentionStandard")} +
+
+ +
+ {icon("M8 1.8a6.2 6.2 0 1 0 0 12.4A6.2 6.2 0 0 0 8 1.8M8 5v3.5M8 11h.01")} + {t("alpha.cloud.consent.withdraw")} +
+

+ {t("alpha.cloud.consent.coversTitle")} {t("alpha.cloud.consent.coversNote")} +

+
+ )} +
+
+ ) +} diff --git a/packages/ui-mac/src/renderer/extensions/upload-consent-test-runtime.ts b/packages/ui-mac/src/renderer/extensions/upload-consent-test-runtime.ts new file mode 100644 index 000000000000..cde9ccf61552 --- /dev/null +++ b/packages/ui-mac/src/renderer/extensions/upload-consent-test-runtime.ts @@ -0,0 +1,4 @@ +export { createComponent } from "solid-js" +export { render } from "solid-js/web" +export { UploadConsentDialog } from "./upload-consent-dialog" +export { CloudDispatchBox } from "./cloud-dispatch-box" diff --git a/packages/ui-mac/src/renderer/extensions/upload-consent.css b/packages/ui-mac/src/renderer/extensions/upload-consent.css new file mode 100644 index 000000000000..8145bc873141 --- /dev/null +++ b/packages/ui-mac/src/renderer/extensions/upload-consent.css @@ -0,0 +1,46 @@ +.alpha-upl{display:flex;flex-direction:column;gap:14px} +.alpha-upl-intro{margin:0;font-size:var(--a-text-base);line-height:var(--a-leading-normal);color:var(--a-text-secondary)} +.alpha-upl-intro b{color:var(--a-text);font-weight:var(--a-weight-medium)} +.alpha-upl-flag{display:flex;gap:11px;padding:12px 14px;border-radius:var(--a-radius-lg);background:var(--a-warning-subtle);line-height:var(--a-leading-snug)} +.alpha-upl-flag .fic{width:26px;height:26px;border-radius:7px;flex:none;display:grid;place-items:center;background:transparent;color:var(--a-warning)} +.alpha-upl-flag .fic svg{width:16px;height:16px} +.alpha-upl-flag .ft{display:flex;flex-direction:column;gap:5px;min-width:0} +.alpha-upl-flag .ft b{font-size:var(--a-text-sm);font-weight:var(--a-weight-semibold);color:var(--a-text)} +.alpha-upl-flag .ft ul{margin:0;padding:0;list-style:none;display:flex;flex-direction:column;gap:3px} +.alpha-upl-flag .ft li{display:flex;align-items:center;gap:7px;font-size:var(--a-text-xs);color:var(--a-text-secondary)} +.alpha-upl-flag .ft li::before{content:"";width:4px;height:4px;border-radius:50%;background:var(--a-warning);flex:none} +.alpha-upl-scope{display:flex;align-items:center;gap:10px;padding:10px 13px;border-radius:var(--a-radius-md);background:var(--a-bg-subtle);border:1px solid var(--a-border-faint);font-size:var(--a-text-sm)} +.alpha-upl-scope .sic{width:20px;height:20px;flex:none;color:var(--a-text-tertiary)} +.alpha-upl-scope .sic svg{width:16px;height:16px} +.alpha-upl-scope .stxt{min-width:0} +.alpha-upl-scope .stxt b{font-weight:var(--a-weight-medium)} +.alpha-upl-scope .stxt small{display:block;font-size:var(--a-text-2xs);color:var(--a-text-tertiary);margin-top:1px} +.alpha-upl-scope .scount{margin-left:auto;flex:none;font-size:var(--a-text-xs);color:var(--a-text-secondary);background:var(--a-bg-muted);border:1px solid var(--a-border-faint);padding:2px 8px;border-radius:var(--a-radius-full)} +.alpha-upl-box{border:1px solid var(--a-border-faint);border-radius:var(--a-radius-lg);overflow:hidden} +.alpha-upl-file{display:flex;align-items:center;gap:10px;padding:9px 13px;font-size:var(--a-text-sm)} +.alpha-upl-file + .alpha-upl-file{border-top:1px solid var(--a-border-faint)} +.alpha-upl-file .dic{width:18px;height:18px;flex:none;color:var(--a-text-tertiary)} +.alpha-upl-file .dic svg{width:15px;height:15px} +.alpha-upl-file .path{font-family:var(--a-font-mono);font-size:11.5px;color:var(--a-text-secondary);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0} +.alpha-upl-file .chip{flex:none;font-size:var(--a-text-2xs);font-weight:var(--a-weight-medium);padding:1px 7px;border-radius:var(--a-radius-full);background:var(--a-warning-subtle);color:var(--a-warning);letter-spacing:var(--a-tracking-wide)} +.alpha-upl-file .size{margin-left:auto;flex:none;font-family:var(--a-font-mono);font-size:10.5px;color:var(--a-text-tertiary)} +.alpha-upl-file[data-flag] .dic{color:var(--a-warning)} +.alpha-upl-more{display:flex;align-items:center;gap:8px;width:100%;padding:9px 13px;border:none;border-top:1px solid var(--a-border-faint);background:var(--a-bg-subtle);color:var(--a-text-secondary);font:inherit;font-size:var(--a-text-xs);cursor:pointer;text-align:left} +.alpha-upl-more:hover{background:var(--a-bg-muted);color:var(--a-text)} +.alpha-upl-more svg{width:12px;height:12px;transition:transform var(--a-dur-fast) var(--a-ease-out)} +.alpha-upl-more[data-open] svg{transform:rotate(90deg)} +.alpha-upl-more .grow{flex:1} +.alpha-upl-more .tot{font-family:var(--a-font-mono);font-size:10.5px;color:var(--a-text-tertiary)} +.alpha-upl-meta{display:flex;flex-direction:column;gap:2px;border:1px solid var(--a-border-faint);border-radius:var(--a-radius-lg);overflow:hidden} +.alpha-upl-mrow{display:flex;align-items:flex-start;gap:10px;padding:10px 13px;font-size:var(--a-text-sm)} +.alpha-upl-mrow + .alpha-upl-mrow{border-top:1px solid var(--a-border-faint)} +.alpha-upl-mrow .mic{width:18px;height:18px;flex:none;color:var(--a-text-tertiary);margin-top:1px} +.alpha-upl-mrow .mic svg{width:15px;height:15px} +.alpha-upl-mrow .k{flex:none;width:52px;color:var(--a-text-tertiary);font-size:var(--a-text-xs);padding-top:1px} +.alpha-upl-mrow .v{color:var(--a-text-secondary);line-height:var(--a-leading-snug)} +.alpha-upl-mrow .v b{color:var(--a-text);font-weight:var(--a-weight-medium)} +.alpha-upl-withdraw{display:flex;gap:9px;font-size:var(--a-text-xs);color:var(--a-text-tertiary);line-height:var(--a-leading-snug)} +.alpha-upl-withdraw svg{width:14px;height:14px;flex:none;margin-top:1px;color:var(--a-text-tertiary)} +.alpha-upl-note{margin:0;font-size:var(--a-text-xs);color:var(--a-text-tertiary);line-height:var(--a-leading-snug)} +.alpha-upl-note b{color:var(--a-text-secondary);font-weight:var(--a-weight-medium)} +@media (prefers-reduced-motion:reduce){.alpha-upl-more svg{transition:none}} diff --git a/packages/ui-mac/src/renderer/i18n/en.ts b/packages/ui-mac/src/renderer/i18n/en.ts index 7766708682eb..fa01a5ebd4ec 100644 --- a/packages/ui-mac/src/renderer/i18n/en.ts +++ b/packages/ui-mac/src/renderer/i18n/en.ts @@ -502,7 +502,7 @@ export const dict = { "alpha.ext.cloudToolArtifacts": "List and fetch job artifacts", "alpha.ext.cloudInjectTitle": "How it is injected", "alpha.ext.cloudInjectNote": "Injected by alpha at startup assembly after platform-mode sign-in (auth goes through the secure file channel; the token never lands in config plaintext). Nothing to install and nothing to uninstall here — signing out or switching to BYOK turns it off.", - "alpha.ext.boundaryCloud": "Data egress channel (ADR-021): only explicitly dispatched content goes up — diff-only first (never the whole repo), envelopes over 1MB are rejected, denied_paths (.env* / *.pem / .alpha/ / .git/) injected by default, and secret patterns in input/objective reject the dispatch naming the field. BYOK mode can skip the cloud entirely.", + "alpha.ext.boundaryCloud": "Data egress channel (ADR-021): only explicitly dispatched content goes up — diff-only first (never the whole repo), envelopes over 256 KiB are rejected, denied_paths (.env* / *.pem / .alpha/ / .git/) injected by default, and secret patterns in input/objective reject the dispatch naming the field. BYOK mode can skip the cloud entirely.", "alpha.ext.cloudPipelines": "Cloud pipelines", "alpha.ext.cloudPipelineKind": "pipeline", "alpha.ext.cloudTier": "Execution tier", @@ -515,7 +515,7 @@ export const dict = { "alpha.ext.enableCloud": "Enable", "alpha.ext.cloudEnabled": "Added to the availability list", "alpha.ext.metaCloudExec": "Runs in the cloud", - "alpha.ext.cloudDispatchTitle": "Dispatch from here (diff-only)", + "alpha.ext.cloudDispatchTitle": "Dispatch from here", "alpha.ext.cloudDispatchHint": "Takes the selected project's git diff (falls back to the last commit on a clean worktree), runs the ADR-021 checks, then dispatches; on completion artifacts flow back to /.alpha/runs/. Leaving this page keeps the job running in the cloud but stops the automatic flow-back.", "alpha.ext.cloudDispatchPick": "Pick a project & dispatch", "alpha.ext.cloudDispatching": "Dispatching…", @@ -533,6 +533,43 @@ export const dict = { "alpha.ext.cloudErrEndpoint": "No cloud endpoint discovered (auto-discovered after sign-in; override with ALPHA_CLOUD_URL)", "alpha.ext.cloudErrNetwork": "Network error — cloud endpoint unreachable", "alpha.ext.cloudErrConsentDeclined": "Cloud dispatch cancelled — data-egress consent was declined for this project", + "alpha.cloud.consent.title": "Confirm before sending to Alpha Cloud", + "alpha.cloud.consent.introPrefix": "The", + "alpha.cloud.consent.introSuffix": " job will send only the files you selected to Alpha Cloud. Protected information was found, so your confirmation is required.", + "alpha.cloud.consent.pipelineReview": "Code review", + "alpha.cloud.consent.flagTitle": "This upload contains protected information", + "alpha.cloud.consent.findContact": "{{n}} file(s) contain possible email addresses or phone numbers", + "alpha.cloud.consent.findIdentity": "{{n}} file(s) contain possible personal identity information", + "alpha.cloud.consent.findCredential": "{{n}} file(s) contain credential-like strings", + "alpha.cloud.consent.findProtected": "{{n}} file(s) have a protected or credential-sensitive path", + "alpha.cloud.consent.findUnknown": "{{n}} file(s) could not be classified with confidence", + "alpha.cloud.consent.scopeFiles": "Only the selected files", + "alpha.cloud.consent.scopeHint": "No other project files will be sent", + "alpha.cloud.consent.scopeCount": "{{n}} files · {{size}}", + "alpha.cloud.consent.preview": "View manifest", + "alpha.cloud.consent.previewCollapse": "Hide manifest", + "alpha.cloud.consent.fileFlag": "Protected info", + "alpha.cloud.consent.purposeLabel": "Purpose", + "alpha.cloud.consent.retentionLabel": "Retention", + "alpha.cloud.consent.purposeReview": "Analyze these files and return structured feedback. Used only for this job, not for training.", + "alpha.cloud.consent.retentionStandard": "Stored under Alpha's standard task-retention policy. You can request earlier deletion.", + "alpha.cloud.consent.withdraw": "You can withdraw later in Settings › Privacy › Cloud data and request deletion of sent content.", + "alpha.cloud.consent.coversTitle": "This consent applies only to the files listed above.", + "alpha.cloud.consent.coversNote": "Cancel sends nothing and creates no cloud record.", + "alpha.cloud.consent.cta": "Agree & send", + "alpha.cloud.consent.cancel": "Cancel", + "alpha.cloud.consent.silentPass": "Privacy check passed · no protected information found; sent directly", + "alpha.cloud.consent.toastSent": "Sent · cloud review in progress", + "alpha.cloud.consent.errScope": "Could not send safely — the selected scope could not be read or verified. Nothing was sent.", + "alpha.cloud.consent.errToken": "Could not prepare the one-time upload credential. Nothing was sent; retry when the service is available.", + "alpha.cloud.consent.errFileLimit": "Could not send safely — select at most 256 files.", + "alpha.cloud.consent.errSizeLimit": "Could not send safely — the selected files exceed 100 MiB.", + "alpha.cloud.consent.errControlLimit": "Could not send safely — the upload control envelope exceeds 256 KiB. Select fewer or smaller text files.", + "alpha.cloud.consent.errText": "Could not send safely — explicit upload currently accepts UTF-8 text files only.", + "alpha.cloud.consent.errDispatch": "The secure upload could not be dispatched. Retry after checking the cloud connection.", + "alpha.cloud.consent.uploadPick": "Select files & send", + "alpha.cloud.consent.uploadHint": "Main selects, reads, classifies and binds only the explicit files. Protected information requires one-time consent; clean files are sent without a dialog.", + "alpha.cloud.consent.legacyDiff": "Dispatch Git diff (legacy)", "alpha.ext.openDetail": "Details", "alpha.ext.back": "Back", "alpha.ext.detailAbout": "About", diff --git a/packages/ui-mac/src/renderer/i18n/zh.ts b/packages/ui-mac/src/renderer/i18n/zh.ts index 429eb1c56212..4f89f1327168 100644 --- a/packages/ui-mac/src/renderer/i18n/zh.ts +++ b/packages/ui-mac/src/renderer/i18n/zh.ts @@ -501,7 +501,7 @@ export const dict = { "alpha.ext.cloudToolArtifacts": "列出并取回任务产物(artifact)", "alpha.ext.cloudInjectTitle": "注入方式", "alpha.ext.cloudInjectNote": "登录平台模式后由 alpha 在启动装配时注入(鉴权走安全文件通道,token 不落配置明文);无需安装,也无法从这里卸载 —— 退出登录或切 BYOK 即熄灭。", - "alpha.ext.boundaryCloud": "数据出境通道(ADR-021):仅显式派发内容上行 —— diff-only 优先(绝不上传全库)、信封序列化 >1MB 拒发、denied_paths(.env* / *.pem / .alpha/ / .git/)缺省注入、input/objective 命中密钥模式即拒发并指出字段;BYOK 模式可整体不用云。", + "alpha.ext.boundaryCloud": "数据出境通道(ADR-021):仅显式派发内容上行 —— diff-only 优先(绝不上传全库)、信封序列化 >256 KiB 拒发、denied_paths(.env* / *.pem / .alpha/ / .git/)缺省注入、input/objective 命中密钥模式即拒发并指出字段;BYOK 模式可整体不用云。", "alpha.ext.cloudPipelines": "云 pipeline", "alpha.ext.cloudPipelineKind": "pipeline", "alpha.ext.cloudTier": "执行层", @@ -514,7 +514,7 @@ export const dict = { "alpha.ext.enableCloud": "启用", "alpha.ext.cloudEnabled": "已加入可用列表", "alpha.ext.metaCloudExec": "云端执行", - "alpha.ext.cloudDispatchTitle": "从这里派发(diff-only)", + "alpha.ext.cloudDispatchTitle": "从这里派发", "alpha.ext.cloudDispatchHint": "取所选项目的 git diff(工作树无变更时取最近一次 commit),经 ADR-021 三校验后派发;终态后产物回流 <项目>/.alpha/runs/。离开本页任务在云端继续,但不再自动回流。", "alpha.ext.cloudDispatchPick": "选择项目并派发", "alpha.ext.cloudDispatching": "派发中…", @@ -532,6 +532,43 @@ export const dict = { "alpha.ext.cloudErrEndpoint": "未发现云端点(登录后自动发现;可用 ALPHA_CLOUD_URL 覆盖)", "alpha.ext.cloudErrNetwork": "网络错误 —— 云端点不可达", "alpha.ext.cloudErrConsentDeclined": "已取消云派发 —— 本项目的数据出境告知未获同意", + "alpha.cloud.consent.title": "发送到 Alpha 云前,请确认", + "alpha.cloud.consent.introPrefix": "本次", + "alpha.cloud.consent.introSuffix": "任务只会把你明确选择的文件发送到 Alpha 云。系统发现其中含受保护信息,发送前需要你确认。", + "alpha.cloud.consent.pipelineReview": "代码审查", + "alpha.cloud.consent.flagTitle": "这次发送包含受隐私保护的信息", + "alpha.cloud.consent.findContact": "{{n}} 个文件含疑似邮箱或电话号码", + "alpha.cloud.consent.findIdentity": "{{n}} 个文件含疑似个人身份信息", + "alpha.cloud.consent.findCredential": "{{n}} 个文件含疑似凭据样式字符串", + "alpha.cloud.consent.findProtected": "{{n}} 个文件位于凭据敏感或受保护路径", + "alpha.cloud.consent.findUnknown": "{{n}} 个文件无法确定分类结果", + "alpha.cloud.consent.scopeFiles": "仅发送明确选择的文件", + "alpha.cloud.consent.scopeHint": "项目中的其它文件不会被发送", + "alpha.cloud.consent.scopeCount": "{{n}} 个文件 · {{size}}", + "alpha.cloud.consent.preview": "查看清单", + "alpha.cloud.consent.previewCollapse": "收起清单", + "alpha.cloud.consent.fileFlag": "含隐私信息", + "alpha.cloud.consent.purposeLabel": "用途", + "alpha.cloud.consent.retentionLabel": "保留", + "alpha.cloud.consent.purposeReview": "分析这些文件并给出结构化意见。仅用于本次任务,不用于训练。", + "alpha.cloud.consent.retentionStandard": "按 Alpha 标准任务保留策略保存;你可随时请求提前删除。", + "alpha.cloud.consent.withdraw": "可在 设置 › 隐私 › 云端数据 撤回同意并请求删除已发送内容。", + "alpha.cloud.consent.coversTitle": "同意仅对上面列出的文件有效。", + "alpha.cloud.consent.coversNote": "取消不会发送任何内容,也不会留下云端记录。", + "alpha.cloud.consent.cta": "同意并发送", + "alpha.cloud.consent.cancel": "取消", + "alpha.cloud.consent.silentPass": "隐私检查已通过 · 未发现受保护信息,已直接发送", + "alpha.cloud.consent.toastSent": "已发送 · 云端审查进行中", + "alpha.cloud.consent.errScope": "无法安全发送 —— 无法读取或确定所选范围,本次没有发送任何内容。", + "alpha.cloud.consent.errToken": "无法准备本次一次性上传凭据,没有发送任何内容;服务恢复后可重试。", + "alpha.cloud.consent.errFileLimit": "无法安全发送 —— 一次最多选择 256 个文件。", + "alpha.cloud.consent.errSizeLimit": "无法安全发送 —— 所选文件总量超过 100 MiB。", + "alpha.cloud.consent.errControlLimit": "无法安全发送 —— 上传控制信封超过 256 KiB,请减少或缩小文本文件。", + "alpha.cloud.consent.errText": "无法安全发送 —— 显式上传当前只接受 UTF-8 文本文件。", + "alpha.cloud.consent.errDispatch": "安全上传派发失败,请检查云连接后重试。", + "alpha.cloud.consent.uploadPick": "选择文件并发送", + "alpha.cloud.consent.uploadHint": "main 只读取、分类并绑定你明确选择的文件;含受保护信息时逐次确认,纯净文件不弹框。", + "alpha.cloud.consent.legacyDiff": "派发 Git diff(既有通道)", "alpha.ext.openDetail": "详情", "alpha.ext.back": "返回", "alpha.ext.detailAbout": "简介", diff --git a/packages/ui-mac/src/shared/alpha-config.test.ts b/packages/ui-mac/src/shared/alpha-config.test.ts index f66bf4f9a2db..04cf37387399 100644 --- a/packages/ui-mac/src/shared/alpha-config.test.ts +++ b/packages/ui-mac/src/shared/alpha-config.test.ts @@ -46,6 +46,7 @@ describe("ALPHA_PATHS", () => { test("the load-bearing routes are stable", () => { expect(ALPHA_PATHS.modelProxy).toBe("/v1") expect(ALPHA_PATHS.token).toBe("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/auth/token") + expect(ALPHA_PATHS.uploadConsent).toBe("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/auth/upload-consent") expect(ALPHA_PATHS.mcpGateway).toBe("/mcp") expect(ALPHA_PATHS.models).toBe("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/v1/models") }) diff --git a/packages/ui-mac/src/shared/alpha-config.ts b/packages/ui-mac/src/shared/alpha-config.ts index 62b31c28e297..b68f7385af9e 100644 --- a/packages/ui-mac/src/shared/alpha-config.ts +++ b/packages/ui-mac/src/shared/alpha-config.ts @@ -36,6 +36,8 @@ export const ALPHA_PATHS = { authorize: "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/auth/authorize", /** web: PKCE code → token exchange. */ token: "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/auth/token", + /** web: issue a manifest-bound upload_consent token after main-owned user consent. */ + uploadConsent: "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/auth/upload-consent", /** web: billing portal(用量 + 流水账单页). */ billing: "/billing", /** web: 钱包购买页 —— ?tab=recharge(钱包充值)| subscription(会员月卡). */