diff --git a/M4_COMPATIBILITY_AND_RELEASE.md b/M4_COMPATIBILITY_AND_RELEASE.md new file mode 100644 index 0000000..de8bdb8 --- /dev/null +++ b/M4_COMPATIBILITY_AND_RELEASE.md @@ -0,0 +1,33 @@ +# M4 兼容矩阵与发布策略 + +本文定义 LineUp v1 的 Host 行为,不以特定框架的便利接口扩展协议。Tauri 是当前唯一已验收的 Reference Host;Android 按当前范围暂缓;Wails v3 仍是 beta 对照 Spike,尚未获得生产批准。 + +## 兼容矩阵 + +| 组合 | Host 必须行为 | 禁止行为 | 观测信号 | +|---|---|---|---| +| 旧 Client 收到 `lineup.v1.*` 新消息类型 | 显示安全 fallback;不执行 | 将未知 payload 当 Markdown/HTML 或自动重试执行 | `unsupported_type` / `unsupported_version` | +| 新 Client 收到旧 plain text | 仅作为 Markdown 文本显示 | 赋予任何交互或能力 | legacy markdown renderer | +| Client 不支持目标 Surface app/version | 不挂载 iframe;保留会话消息为安全提示 | 向相邻版本或 active 新版本前移 | `surface_unavailable` / exact-version miss | +| 支持的 Client 收到生产 Surface | 只在 manifest 签名、精确长度和 SHA-256 都成功后挂载已验证 bytes | 从 `ui.open`/`ui.patch` 接收 URL、HTML、JS、bundle 或 source | install disposition、artifact/version | +| 新 bundle 验证失败 | 保持当前已验证 active bundle;呈现不可用状态 | 覆盖 active bytes 或执行候选 bundle | `signature_invalid` / `size_mismatch` / `digest_mismatch` | +| 已验证 active bundle 被撤销 | 原子切回同 app 的上一个已验证版本;无 predecessor 则不可用 | 回退到未校验缓存或远程 URL | `rolled_back` / `unavailable` | +| Capability catalog 版本变更 | 重新发送最小 inventory;未知/撤销 capability 只回安全结果 | 使用过期 inventory 直接执行 | catalog revision、`unsupported` / `revoked` | +| Surface bridge 收到非当前 iframe 消息 | 丢弃 | 信任 origin 字符串、调用 Host/Wails/Tauri API | bridge rejection counter | +| `tool.call → submit → tool.result` | 一次有序状态收敛;重复幂等 | 从 pending 直接接受远端完成,或二次完成 | call id、transition disposition | + +## 发布序列 + +1. 发布方生成 immutable artifact,并建立生产 manifest:`app_id`、精确 `version`、`artifact_id`、byte size、SHA-256、最小 Host 版本、允许权限、trusted `key_id` 和 Ed25519 签名。 +2. 将 manifest 和 bundle 置于 Host 配置的 HTTPS 同源 artifact 服务。manifest 不能携带 URL、HTML、CSS、JS 或 source。 +3. Host 下载到临时内存,依次验证 manifest signature、精确字节长度、SHA-256;任一失败都不写 cache。 +4. 验证成功后,才原子安装并将 active 指针切到精确版本;Surface 实例只从 `app_id + version` 精确查找,不允许自动前移。 +5. 分阶段灰度:先只对具备 `min_host_version` 且已启用该 app/version 的 Client 发布;其他 Client 收到可读提示但不挂载/执行。 +6. 监控 signature/hash/download/bridge/capability 状态。错误率或安全事件触发时撤销 artifact,Host 仅切回已验证 predecessor;没有 predecessor 时关闭该 Surface。 +7. 回滚后仍保留审计元数据(不含 artifact 内容、URL、token、文件路径或能力参数)。新 artifact 必须使用新版本号;不得覆盖同版本的 immutable artifact。 + +## Host 发布门槛 + +一个 Host 只有在以下证据完整时才能标为 M4 production-capable:共享 golden fixture 通过、manifest/cache 单测通过、生产 build 通过、有头浏览器完成签名 bundle `open → patch → close → rollback`,并完成 Surface 隔离与 capability 拒绝路径验收。 + +当前状态:Tauri Reference Host 的协议/manifest/cache 自动验证已通过;在可见 Chromium 的 localhost 安全上下文中,开发期签名 fixture 已实际走过 Ed25519 → size → SHA-256 → cache → exact-version resolver → opaque iframe 的 `open → patch → close`。HTTP LAN 开发 Host 因浏览器禁用 WebCrypto 而安全拒绝验签,正式发布必须使用 HTTPS。Wails v3 beta.3 已完成共享 golden contract 执行与 Windows/amd64 API 交叉编译 Spike;Linux native GUI 仍受本机 GTK4/WebKitGTK 6.0 开发依赖限制,因而尚未获得 Linux 生产批准。Android 按用户范围暂缓。 diff --git a/README.md b/README.md index fe1a280..e832175 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,7 @@ git pull --ff-only origin main - [DESIGN.md](DESIGN.md):Android 实验阶段的产品、连接与交互设计; - [TECHNOLOGY_SELECTION.md](TECHNOLOGY_SELECTION.md):已确认的客户端与 Mini Runtime 技术路线;Tauri 2 是第一优先的 Reference Host Spike,Wails v3 在同一基线后续评估; +- [M4_COMPATIBILITY_AND_RELEASE.md](M4_COMPATIBILITY_AND_RELEASE.md):M4 的跨 Host 兼容矩阵、签名 Surface 发布、验证与回滚策略; - [tauri/README.md](tauri/README.md):Tauri 2 Reference Host 的实现、构建状态和下一阶段范围; - [LineUp App 层架构设计方案](../设计/02.正式方案/lineup-app-layer-architecture.md):跨端 App Shell、Interaction Kernel、Renderer、Surface 与 Capability 的正式架构; - [LineUp UI Surface 与 App Capability 协议](../设计/02.正式方案/lineup-ui-surface-protocol.md):小程序 Surface 和 Agent 调 App 能力的协议定义。 diff --git a/tauri/src/main.ts b/tauri/src/main.ts index a43fc89..1012193 100644 --- a/tauri/src/main.ts +++ b/tauri/src/main.ts @@ -16,7 +16,10 @@ import { HttpTransportAdapter, type TransportAdapter } from "./runtime/transport import { TrustedDOMRendererContext } from "./runtime/trusted-dom-renderers"; import { SurfaceRegistry } from "./runtime/surface-registry"; import { SurfaceInstanceManager } from "./runtime/surface-instance-manager"; -import { IsolatedSurfaceHost } from "./runtime/isolated-surface-host"; +import { CachedVerifiedSurfaceDocumentResolver, IsolatedSurfaceHost } from "./runtime/isolated-surface-host"; +import { ProductionSurfaceManifestRegistry } from "./runtime/production-surface-manifest"; +import { ProductionSurfacePolicy } from "./runtime/production-surface-policy"; +import { TrustedSurfaceBundleDownloader, VerifiedSurfaceBundleCache, WebCryptoEd25519SurfaceManifestSignatureVerifier } from "./runtime/surface-bundle-cache"; import { CapabilityRegistry } from "./runtime/capability-registry"; import { ClientInventoryPublisher } from "./runtime/client-inventory"; import { ArtifactState } from "./runtime/artifact-state"; @@ -35,6 +38,17 @@ type Session = { active: boolean; }; +declare global { + interface ImportMeta { + readonly env: Readonly<{ DEV: boolean }>; + } + + interface Window { + /** Development-only M4 signed-bundle browser acceptance controller. */ + __lineupM4Fixture?: Readonly<{ patch: () => void; close: () => void }>; + } +} + const DEFAULT_API = "http://127.0.0.1:8090"; let session: Session = { api: localStorage.getItem("lineup.api") || DEFAULT_API, uid: "", agentUID: "agent_hermes_main", channelID: "agent_default_channel", channelType: 2, lastSeq: 0, active: false }; let transport: TransportAdapter | undefined; @@ -104,6 +118,43 @@ const surfaceHost = new IsolatedSurfaceHost(messages, { }); $("#api").value = session.api; +/** + * A pre-signed public fixture used only by the headed M4 acceptance route. + * It contains no private key, is excluded from production builds, and forces + * the actual Ed25519 → size → SHA-256 → cache → exact resolver path. + */ +async function mountM4SignedFixture(): Promise { + if (!import.meta.env.DEV || !new URLSearchParams(location.search).has("m4-signed-surface-e2e")) return; + const encodedBundle = "PG1haW4-PGgyIGlkPSJtNC1zaWduZWQiPk00IOW3suetvuWQjSBTdXJmYWNlPC9oMj48cCBpZD0ic3RhdGUiPuetieW-heeKtuaAgTwvcD48c2NyaXB0PndpbmRvdy5hZGRFdmVudExpc3RlbmVyKCJtZXNzYWdlIixlPT57Y29uc3QgZD1lLmRhdGE7aWYoZCYmZC50eXBlPT09ImxpbmV1cC5zdXJmYWNlLnYxLnN0YXRlIilkb2N1bWVudC5xdWVyeVNlbGVjdG9yKCIjc3RhdGUiKS50ZXh0Q29udGVudD1TdHJpbmcoZC5zdGF0ZT8uc3RhdHVzfHwi6L-Q6KGM5LitIil9KTt3aW5kb3cucGFyZW50LnBvc3RNZXNzYWdlKHt2OjEsdHlwZToibGluZXVwLnN1cmZhY2UudjEucmVhZHkiLGluc3RhbmNlX2lkOndpbmRvdy5fX0xJTkVVUF9TVVJGQUNFX0lOU1RBTkNFX199LCIqIik8L3NjcmlwdD48L21haW4-"; + const standardBase64 = encodedBundle.replace(/-/g, "+").replace(/_/g, "/"); + const bundle = Uint8Array.from(atob(standardBase64 + "=".repeat((4 - standardBase64.length % 4) % 4)), value => value.charCodeAt(0)); + const manifest = { + v: 1, app_id: "lineup.m4-signed-fixture", version: "1.0.0", + artifact: { artifact_id: "m4-signed-fixture", sha256: "98cbcf29d38cf08ae6385539b3ac7359710309a1b752a14b60c8cfc8a39f1a37", size_bytes: 396 }, + min_host_version: "0.1.0", permissions: ["surface.event.cancel"], key_id: "m4-e2e-key", + signature: "dCORfPL6LTxR8CB5mVss5fEeGozTdfN3L7Td27BYgxJ518yNWAmI4FEhfrjEoc_Wovwn8u8HI5BLBjWSUxZ6Dg", + } as const; + const cache = new VerifiedSurfaceBundleCache(new WebCryptoEd25519SurfaceManifestSignatureVerifier({ "m4-e2e-key": "AKx_a1NVmsJKbqY9Jw55hLp8r01TcCadjZNp1u2POLI" })); + const policy = new ProductionSurfacePolicy(new ProductionSurfaceManifestRegistry({ host_version: "0.1.0", trusted_key_ids: ["m4-e2e-key"] }), cache); + const downloader = new TrustedSurfaceBundleDownloader("https://m4-e2e.lineup.invalid/release", async url => ({ ok: true, url: url.toString(), async bytes() { return bundle; } })); + if ((await policy.downloadAndEnable(manifest, downloader)).disposition !== "installed") throw new Error("M4 signed fixture verification failed."); + const instances = new SurfaceInstanceManager(policy); + const host = new IsolatedSurfaceHost(messages, { + ready(instanceID) { instances.ready(instanceID, new Date().toISOString()); }, + event() { /* fixture grants no outbound capabilities or transport events */ }, + }, new CachedVerifiedSurfaceDocumentResolver(cache)); + const opened = instances.open({ instance_id: "m4:signed-fixture", app: { app_id: manifest.app_id, version: manifest.version }, state: { status: "签名验证后已挂载" } }, currentConversationKey(), new Date().toISOString()); + if (opened.disposition !== "accepted" || !opened.instance) throw new Error(`M4 signed fixture open rejected: ${opened.disposition}`); + host.mount(opened.instance); + window.__lineupM4Fixture = { + patch() { + const result = instances.patch("m4:signed-fixture", { status: "已通过可信 patch 更新" }, new Date().toISOString()); + if (result.disposition === "accepted" && result.instance) host.update(result.instance); + }, + close() { host.unmount("m4:signed-fixture"); instances.close("m4:signed-fixture"); }, + }; +} + function newLocalID(prefix: string): string { // `crypto.randomUUID` is absent from older Safari / WKWebView versions. // A local item id needs uniqueness within this browser store, not a @@ -738,6 +789,7 @@ $("#login-form").addEventListener("submit", async event => { } for (const instance of surfaceInstances.snapshot().instances) surfaceHost.mount(instance); rendererContext.restoreExecutionSummaries(snapshot.execution_summaries); + await mountM4SignedFixture(); rendererContext.renderSystemMessage("已进入与 AI Agent 的对话"); rendererContext.setPresence("正在同步消息…"); void publishInventory(); void flushOutbox(); void syncLoop(); } catch (reason) { error.textContent = loginFailureMessage(reason, endpoint); } finally { submit.disabled = false; submit.textContent = "进入 LineUp"; } diff --git a/tauri/src/runtime/golden-protocol.test.ts b/tauri/src/runtime/golden-protocol.test.ts new file mode 100644 index 0000000..8b4c701 --- /dev/null +++ b/tauri/src/runtime/golden-protocol.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; +import { decodeConversationItem, type ConversationItem, type JsonObject } from "../protocol"; +import { InteractionKernel } from "./interaction-kernel"; +import fixture from "./golden/lineup-v1.json"; + +type GoldenExpected = Readonly<{ + item_kind: ConversationItem["kind"]; + fallback?: "invalid_payload" | "unsupported_version"; + kernel_tool_status?: "pending" | "completed"; +}>; +type GoldenTransition = Readonly<{ after_envelope: number; kind: "submit_tool_call"; call_id: string; submission: JsonObject }>; +type GoldenCase = Readonly<{ id: string; expected: GoldenExpected; envelopes: readonly unknown[]; transitions?: readonly GoldenTransition[] }>; + +const cases = fixture.cases as readonly GoldenCase[]; + +describe("LineUp v1 host-neutral golden contract", () => { + it("uses a versioned portable fixture", () => { + expect(fixture.contract).toBe("lineup-v1-golden-1"); + expect(cases.map(test => test.id)).toEqual([ + "tool-call-confirm", "tool-result-completes-call", "surface-open-is-data-only", + "surface-source-injection-rejected", "app-call-is-a-consent-request", "unknown-protocol-version-rejected", + ]); + }); + + for (const test of cases) { + it(`${test.id}: accepts only the documented projection`, () => { + const kernel = new InteractionKernel(); + let item: ConversationItem | undefined; + for (const [index, envelope] of test.envelopes.entries()) { + const raw = JSON.stringify(envelope); + item = decodeConversationItem(raw); + kernel.ingest({ raw, source: "live", received_at: "2026-08-04T00:00:00.000Z" }); + for (const transition of test.transitions?.filter(entry => entry.after_envelope === index) ?? []) { + kernel.submitToolCall(transition.call_id, "2026-08-04T00:00:01.000Z", transition.submission); + } + } + expect(item?.kind).toBe(test.expected.item_kind); + if (test.expected.fallback) { + expect(item).toMatchObject({ kind: "fallback", reason: test.expected.fallback }); + expect(kernel.snapshot().tool_calls).toEqual([]); + } + if (test.expected.kernel_tool_status) { + expect(kernel.snapshot().tool_calls).toMatchObject([{ call_id: test.id === "tool-call-confirm" ? "golden-confirm" : "golden-result", status: test.expected.kernel_tool_status }]); + } + }); + } +}); diff --git a/tauri/src/runtime/golden/lineup-v1.json b/tauri/src/runtime/golden/lineup-v1.json new file mode 100644 index 0000000..7e0f028 --- /dev/null +++ b/tauri/src/runtime/golden/lineup-v1.json @@ -0,0 +1,52 @@ +{ + "contract": "lineup-v1-golden-1", + "description": "Host-neutral acceptance and safe-fallback cases. Every Host must produce the stated item kind and must never execute a rejected input.", + "cases": [ + { + "id": "tool-call-confirm", + "expected": { "item_kind": "tool-call", "kernel_tool_status": "pending" }, + "envelopes": [ + { "v": 1, "id": "golden-tool-call", "type": "lineup.v1.tool.call", "conversation_id": "golden-conversation", "sender": { "kind": "agent", "id": "golden-agent" }, "payload": { "call_id": "golden-confirm", "tool": "confirm", "title": "确认操作", "prompt": "继续吗?", "data": { "confirm": { "approve_label": "继续", "cancel_label": "取消" } } } } + ] + }, + { + "id": "tool-result-completes-call", + "expected": { "item_kind": "tool-result", "kernel_tool_status": "completed" }, + "transitions": [ + { "after_envelope": 0, "kind": "submit_tool_call", "call_id": "golden-result", "submission": { "approved": true } } + ], + "envelopes": [ + { "v": 1, "id": "golden-tool-open", "type": "lineup.v1.tool.call", "conversation_id": "golden-conversation", "sender": { "kind": "agent", "id": "golden-agent" }, "payload": { "call_id": "golden-result", "tool": "confirm", "title": "确认操作", "prompt": "继续吗?", "data": { "confirm": {} } } }, + { "v": 1, "id": "golden-tool-result", "type": "lineup.v1.tool.result", "conversation_id": "golden-conversation", "sender": { "kind": "human", "id": "golden-human" }, "payload": { "call_id": "golden-result", "status": "completed", "result": { "approved": true } } } + ] + }, + { + "id": "surface-open-is-data-only", + "expected": { "item_kind": "surface" }, + "envelopes": [ + { "v": 1, "id": "golden-surface-open", "type": "lineup.v1.ui.open", "conversation_id": "golden-conversation", "sender": { "kind": "agent", "id": "golden-agent" }, "payload": { "instance_id": "golden:surface", "app": { "app_id": "lineup.task-dashboard", "version": "0.1.0" }, "state": { "title": "同步中", "percent": 42 } } } + ] + }, + { + "id": "surface-source-injection-rejected", + "expected": { "item_kind": "fallback", "fallback": "invalid_payload" }, + "envelopes": [ + { "v": 1, "id": "golden-surface-injection", "type": "lineup.v1.ui.open", "conversation_id": "golden-conversation", "sender": { "kind": "agent", "id": "golden-agent" }, "payload": { "instance_id": "golden:injection", "app": { "app_id": "lineup.task-dashboard", "version": "0.1.0" }, "html": "" } } + ] + }, + { + "id": "app-call-is-a-consent-request", + "expected": { "item_kind": "app-call" }, + "envelopes": [ + { "v": 1, "id": "golden-app-call", "type": "lineup.v1.app.call", "conversation_id": "golden-conversation", "sender": { "kind": "agent", "id": "golden-agent" }, "payload": { "call_id": "golden-capability", "capability": "app.open_url", "reason": "打开帮助页面", "expires_at": "2026-08-04T01:00:00.000Z", "arguments": { "url": "https://example.com/help" } } } + ] + }, + { + "id": "unknown-protocol-version-rejected", + "expected": { "item_kind": "fallback", "fallback": "unsupported_version" }, + "envelopes": [ + { "v": 2, "id": "golden-v2", "type": "lineup.v1.text", "conversation_id": "golden-conversation", "sender": { "kind": "agent", "id": "golden-agent" }, "payload": { "text": "must not render as trusted v1" } } + ] + } + ] +} diff --git a/tauri/src/runtime/isolated-surface-host.test.ts b/tauri/src/runtime/isolated-surface-host.test.ts index 6f08853..23446dd 100644 --- a/tauri/src/runtime/isolated-surface-host.test.ts +++ b/tauri/src/runtime/isolated-surface-host.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; -import { parseReadyMessage, surfaceDocument } from "./isolated-surface-host"; +import { CachedVerifiedSurfaceDocumentResolver, parseReadyMessage, productionSurfaceDocument, surfaceDocument } from "./isolated-surface-host"; +import { VerifiedSurfaceBundleCache, type SurfaceBundleDigest, type SurfaceManifestSignatureVerifier } from "./surface-bundle-cache"; describe("isolated Surface bridge contract", () => { it("accepts only the exact minimal ready message", () => { @@ -17,4 +18,25 @@ describe("isolated Surface bridge contract", () => { expect(document).toContain("lineup.surface.v1.ready"); expect(document).not.toContain("https://"); }); + + it("wraps only an exact verified production bundle in the host default-deny CSP", async () => { + // Digest mismatch behavior belongs to the cache suite. This test isolates + // the resolver's exact-version lookup and Host-owned CSP wrapping. + const digest: SurfaceBundleDigest = { async sha256() { return "b".repeat(64); } }; + const signatures: SurfaceManifestSignatureVerifier = { async verify() { return true; } }; + const cache = new VerifiedSurfaceBundleCache(signatures, digest); + const bundle = new TextEncoder().encode("
`; } +export function productionSurfaceDocument(instanceID: string, verifiedBundle: string): string { + const safeID = JSON.stringify(instanceID); + const csp = "default-src 'none'; connect-src 'none'; img-src data: blob:; style-src 'unsafe-inline'; script-src 'unsafe-inline'; base-uri 'none'; form-action 'none'; frame-src 'none'; object-src 'none'; navigate-to 'none'"; + // The content is an already signature/hash-verified artifact, never an + // Envelope/source patch. Its own CSP is additive; this one remains in force. + return `${verifiedBundle}`; +} + function isPlainObject(value: unknown): value is Record { if (value === null || typeof value !== "object" || Array.isArray(value)) return false; const prototype = Object.getPrototypeOf(value); diff --git a/tauri/src/runtime/production-surface-manifest.test.ts b/tauri/src/runtime/production-surface-manifest.test.ts new file mode 100644 index 0000000..e636b11 --- /dev/null +++ b/tauri/src/runtime/production-surface-manifest.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; +import { canonicalManifestPayload, compareSemver, parseProductionSurfaceManifest, ProductionSurfaceManifestRegistry } from "./production-surface-manifest"; + +const manifest = { + v: 1, app_id: "lineup.task-dashboard", version: "1.2.0", + artifact: { artifact_id: "task-dashboard-1.2.0", sha256: "a".repeat(64), size_bytes: 4096 }, + min_host_version: "0.2.0", permissions: ["surface.event.cancel"], key_id: "lineup-release-2026", signature: "A".repeat(86), +}; + +describe("ProductionSurfaceManifest", () => { + it("admits only an immutable, signed-shape allowlisted manifest", () => { + const registry = new ProductionSurfaceManifestRegistry({ host_version: "0.3.0", trusted_key_ids: ["lineup-release-2026"] }); + expect(registry.admit(manifest)).toMatchObject({ disposition: "accepted", manifest: { app_id: "lineup.task-dashboard" } }); + expect(registry.admit(manifest)).toMatchObject({ disposition: "duplicate" }); + expect(registry.admit({ ...manifest, artifact: { ...manifest.artifact, sha256: "b".repeat(64) } })).toEqual({ disposition: "invalid_manifest" }); + const copy = registry.get("lineup.task-dashboard", "1.2.0")!; + (copy.artifact as { sha256: string }).sha256 = "c".repeat(64); + expect(registry.get("lineup.task-dashboard", "1.2.0")?.artifact.sha256).toBe("a".repeat(64)); + }); + + it("rejects unknown fields, untrusted keys, unsupported hosts and undeclared permissions", () => { + expect(parseProductionSurfaceManifest({ ...manifest, url: "https://attacker.invalid/bundle" })).toBeUndefined(); + expect(parseProductionSurfaceManifest({ ...manifest, permissions: ["device.pick_file"] })).toBeUndefined(); + expect(new ProductionSurfaceManifestRegistry({ host_version: "0.3.0", trusted_key_ids: [] }).admit(manifest)).toEqual({ disposition: "untrusted_key" }); + expect(new ProductionSurfaceManifestRegistry({ host_version: "0.1.0", trusted_key_ids: [manifest.key_id] }).admit(manifest)).toEqual({ disposition: "unsupported_host" }); + }); + + it("uses one stable signed payload and semver ordering across hosts", () => { + expect(canonicalManifestPayload(parseProductionSurfaceManifest(manifest)!)).toBe('{"app_id":"lineup.task-dashboard","artifact":{"artifact_id":"task-dashboard-1.2.0","sha256":"' + "a".repeat(64) + '","size_bytes":4096},"key_id":"lineup-release-2026","min_host_version":"0.2.0","permissions":["surface.event.cancel"],"v":1,"version":"1.2.0"}'); + expect(compareSemver("1.0.0", "1.0.0")).toBe(0); + expect(compareSemver("1.0.0", "1.0.0-beta.1")).toBe(1); + expect(compareSemver("1.0.0-beta.1", "1.0.0")).toBe(-1); + expect(compareSemver("1.2.0", "1.10.0")).toBe(-1); + }); +}); diff --git a/tauri/src/runtime/production-surface-manifest.ts b/tauri/src/runtime/production-surface-manifest.ts new file mode 100644 index 0000000..2010fb0 --- /dev/null +++ b/tauri/src/runtime/production-surface-manifest.ts @@ -0,0 +1,145 @@ +/** + * M4 production Surface declaration. A manifest is only immutable metadata; + * it contains neither a bundle URL nor executable source. Download, signature + * verification and cache installation are deliberately separate M4-02 steps. + */ +export type SurfacePermission = "surface.event.cancel"; + +export type ProductionSurfaceManifest = Readonly<{ + v: 1; + app_id: string; + version: string; + artifact: Readonly<{ + artifact_id: string; + sha256: string; + size_bytes: number; + }>; + min_host_version: string; + permissions: readonly SurfacePermission[]; + key_id: string; + /** Base64url Ed25519 signature of canonicalManifestPayload(manifest). */ + signature: string; +}>; + +export type ProductionManifestDisposition = + | "accepted" + | "invalid_manifest" + | "unsupported_host" + | "untrusted_key" + | "duplicate"; + +export type ProductionManifestResult = Readonly<{ + disposition: ProductionManifestDisposition; + manifest?: ProductionSurfaceManifest; +}>; + +const APP_ID = /^[a-z][a-z0-9]*(?:[.-][a-z0-9]+)*$/; +const VERSION = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/; +const ARTIFACT_ID = /^[A-Za-z][A-Za-z0-9_.:-]{0,127}$/; +const KEY_ID = /^[A-Za-z][A-Za-z0-9_.:-]{0,127}$/; +const SHA256 = /^[a-f0-9]{64}$/; +const BASE64URL_SIGNATURE = /^[A-Za-z0-9_-]{86}$/; +const PERMISSIONS = new Set(["surface.event.cancel"]); +const MAX_BUNDLE_BYTES = 16 * 1024 * 1024; + +/** + * Host-owned allowlist for production manifests. Remote messages cannot add a + * key, weaken the minimum host version, mutate a previously accepted artifact + * or cause a bundle to run; M4-02 must verify the signature and digest before + * a cached bundle is made active. + */ +export class ProductionSurfaceManifestRegistry { + private readonly manifests = new Map(); + private readonly trustedKeys: ReadonlySet; + + public constructor(options: Readonly<{ host_version: string; trusted_key_ids: readonly string[] }>) { + if (!validVersion(options.host_version)) throw new Error("host_version must be semver."); + this.hostVersion = options.host_version; + this.trustedKeys = new Set(options.trusted_key_ids.filter(validKeyID)); + } + + private readonly hostVersion: string; + + public admit(value: unknown): ProductionManifestResult { + const parsed = parseProductionSurfaceManifest(value); + if (!parsed) return { disposition: "invalid_manifest" }; + if (!this.trustedKeys.has(parsed.key_id)) return { disposition: "untrusted_key" }; + if (compareSemver(this.hostVersion, parsed.min_host_version) < 0) return { disposition: "unsupported_host" }; + const key = manifestKey(parsed.app_id, parsed.version); + const existing = this.manifests.get(key); + if (existing) return sameManifest(existing, parsed) + ? { disposition: "duplicate", manifest: copyManifest(existing) } + : { disposition: "invalid_manifest" }; + const immutable = freezeManifest(parsed); + this.manifests.set(key, immutable); + return { disposition: "accepted", manifest: copyManifest(immutable) }; + } + + public get(appID: string, version: string): ProductionSurfaceManifest | undefined { + const manifest = this.manifests.get(manifestKey(appID, version)); + return manifest && copyManifest(manifest); + } + + public snapshot(): readonly ProductionSurfaceManifest[] { + return [...this.manifests.values()].sort((left, right) => manifestKey(left.app_id, left.version).localeCompare(manifestKey(right.app_id, right.version))).map(copyManifest); + } +} + +/** Strict parse used before signature verification and any cache/network work. */ +export function parseProductionSurfaceManifest(value: unknown): ProductionSurfaceManifest | undefined { + if (!plainObject(value)) return undefined; + const keys = ["v", "app_id", "version", "artifact", "min_host_version", "permissions", "key_id", "signature"]; + if (Object.keys(value).length !== keys.length || Object.keys(value).some(key => !keys.includes(key))) return undefined; + if (value.v !== 1 || !validAppID(value.app_id) || !validVersion(value.version) || !validVersion(value.min_host_version) + || !validKeyID(value.key_id) || typeof value.signature !== "string" || !BASE64URL_SIGNATURE.test(value.signature) + || !Array.isArray(value.permissions) || value.permissions.length < 1 || value.permissions.length > PERMISSIONS.size + || new Set(value.permissions).size !== value.permissions.length || value.permissions.some(permission => typeof permission !== "string" || !PERMISSIONS.has(permission as SurfacePermission)) + || !plainObject(value.artifact) || Object.keys(value.artifact).length !== 3 || Object.keys(value.artifact).some(key => !["artifact_id", "sha256", "size_bytes"].includes(key))) return undefined; + const artifact = value.artifact as Record; + if (typeof artifact.artifact_id !== "string" || !ARTIFACT_ID.test(artifact.artifact_id) + || typeof artifact.sha256 !== "string" || !SHA256.test(artifact.sha256) + || typeof artifact.size_bytes !== "number" || !Number.isSafeInteger(artifact.size_bytes) || artifact.size_bytes < 1 || artifact.size_bytes > MAX_BUNDLE_BYTES) return undefined; + return { + v: 1, app_id: value.app_id, version: value.version, + artifact: { artifact_id: artifact.artifact_id, sha256: artifact.sha256, size_bytes: artifact.size_bytes }, + min_host_version: value.min_host_version, + permissions: [...value.permissions] as SurfacePermission[], + key_id: value.key_id, signature: value.signature, + }; +} + +/** Stable UTF-8 payload used by every Host before Ed25519 verification. */ +export function canonicalManifestPayload(manifest: ProductionSurfaceManifest): string { + return JSON.stringify({ + app_id: manifest.app_id, + artifact: { artifact_id: manifest.artifact.artifact_id, sha256: manifest.artifact.sha256, size_bytes: manifest.artifact.size_bytes }, + key_id: manifest.key_id, + min_host_version: manifest.min_host_version, + permissions: [...manifest.permissions], + v: manifest.v, + version: manifest.version, + }); +} + +export function compareSemver(left: string, right: string): number { + const leftCore = left.split(/[+-]/, 1)[0].split(".").map(Number); + const rightCore = right.split(/[+-]/, 1)[0].split(".").map(Number); + for (let index = 0; index < 3; index += 1) { + if (leftCore[index] !== rightCore[index]) return leftCore[index] > rightCore[index] ? 1 : -1; + } + const leftPre = left.includes("-") ? left.slice(left.indexOf("-") + 1).split("+", 1)[0] : undefined; + const rightPre = right.includes("-") ? right.slice(right.indexOf("-") + 1).split("+", 1)[0] : undefined; + if (!leftPre && !rightPre) return 0; + if (!leftPre) return 1; + if (!rightPre) return -1; + return leftPre === rightPre ? 0 : leftPre.localeCompare(rightPre); +} + +function validAppID(value: unknown): value is string { return typeof value === "string" && value.length <= 96 && APP_ID.test(value); } +function validVersion(value: unknown): value is string { return typeof value === "string" && value.length <= 64 && VERSION.test(value); } +function validKeyID(value: unknown): value is string { return typeof value === "string" && KEY_ID.test(value); } +function manifestKey(appID: string, version: string): string { return `${appID}@${version}`; } +function plainObject(value: unknown): value is Record { return value !== null && typeof value === "object" && !Array.isArray(value) && (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null); } +function copyManifest(manifest: ProductionSurfaceManifest): ProductionSurfaceManifest { return { ...manifest, artifact: { ...manifest.artifact }, permissions: [...manifest.permissions] }; } +function freezeManifest(manifest: ProductionSurfaceManifest): ProductionSurfaceManifest { return Object.freeze({ ...manifest, artifact: Object.freeze({ ...manifest.artifact }), permissions: Object.freeze([...manifest.permissions]) }); } +function sameManifest(left: ProductionSurfaceManifest, right: ProductionSurfaceManifest): boolean { return canonicalManifestPayload(left) === canonicalManifestPayload(right) && left.signature === right.signature; } diff --git a/tauri/src/runtime/production-surface-policy.test.ts b/tauri/src/runtime/production-surface-policy.test.ts new file mode 100644 index 0000000..5a1c6e6 --- /dev/null +++ b/tauri/src/runtime/production-surface-policy.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; +import { ProductionSurfaceManifestRegistry } from "./production-surface-manifest"; +import { ProductionSurfacePolicy } from "./production-surface-policy"; +import { SurfaceInstanceManager } from "./surface-instance-manager"; +import { TrustedSurfaceBundleDownloader, VerifiedSurfaceBundleCache, type SurfaceBundleDigest, type SurfaceManifestSignatureVerifier } from "./surface-bundle-cache"; + +const bytes = new TextEncoder().encode("
verified
"); +const digest: SurfaceBundleDigest = { async sha256() { return "a".repeat(64); } }; +const signatures: SurfaceManifestSignatureVerifier = { async verify() { return true; } }; +const candidate = { + v: 1, app_id: "lineup.production-dashboard", version: "1.0.0", + artifact: { artifact_id: "dashboard-1", sha256: "a".repeat(64), size_bytes: bytes.byteLength }, + min_host_version: "0.1.0", permissions: ["surface.event.cancel"], key_id: "release-key", signature: "A".repeat(86), +}; + +describe("ProductionSurfacePolicy", () => { + it("admits an instance only after an exact signed/verified bundle exists", async () => { + const cache = new VerifiedSurfaceBundleCache(signatures, digest); + const policy = new ProductionSurfacePolicy(new ProductionSurfaceManifestRegistry({ host_version: "0.1.0", trusted_key_ids: ["release-key"] }), cache); + const manager = new SurfaceInstanceManager(policy); + const request = { instance_id: "prod:001", app: { app_id: candidate.app_id, version: candidate.version }, state: { title: "安全运行" } }; + expect(manager.open(request, "conversation", "2026-08-04T00:00:00.000Z")).toMatchObject({ disposition: "unknown_app" }); + const downloader = new TrustedSurfaceBundleDownloader("https://bundles.lineup.example/releases", async () => ({ ok: true, url: "https://bundles.lineup.example/releases/artifacts/dashboard-1.bundle", async bytes() { return bytes; } })); + await expect(policy.downloadAndEnable(candidate, downloader)).resolves.toEqual({ disposition: "installed" }); + expect(manager.open(request, "conversation", "2026-08-04T00:00:00.000Z")).toMatchObject({ disposition: "accepted", instance: { app_id: candidate.app_id, version: candidate.version } }); + expect(manager.open({ ...request, instance_id: "prod:injected", html: "" }, "conversation", "2026-08-04T00:00:00.000Z")).toMatchObject({ disposition: "bundle_override_forbidden" }); + }); +}); diff --git a/tauri/src/runtime/production-surface-policy.ts b/tauri/src/runtime/production-surface-policy.ts new file mode 100644 index 0000000..9b8fd56 --- /dev/null +++ b/tauri/src/runtime/production-surface-policy.ts @@ -0,0 +1,58 @@ +import type { JsonObject } from "../protocol"; +import { parseProductionSurfaceManifest, ProductionSurfaceManifestRegistry, type ProductionManifestDisposition } from "./production-surface-manifest"; +import { TrustedSurfaceBundleDownloader, VerifiedSurfaceBundleCache } from "./surface-bundle-cache"; +import type { SurfaceAdmissionPolicy, SurfaceRegistryDisposition, SurfaceRegistryResult } from "./surface-registry"; + +export type ProductionSurfacePreparation = Readonly<{ + disposition: ProductionManifestDisposition | "installed" | "download_failed" | "signature_invalid" | "size_mismatch" | "digest_mismatch" | "invalid_manifest"; +}>; + +/** + * Host-owned production admission bridge. An app/version becomes openable + * only after its manifest is locally allowed *and* exact verified bytes exist + * in cache. It intentionally exposes no bundle bytes or remote URLs. + */ +export class ProductionSurfacePolicy implements SurfaceAdmissionPolicy { + private readonly enabled = new Set(); + + public constructor( + private readonly manifests: ProductionSurfaceManifestRegistry, + private readonly cache: VerifiedSurfaceBundleCache, + private readonly maxStateBytes = 64 * 1024, + ) {} + + public async downloadAndEnable(candidate: unknown, downloader: TrustedSurfaceBundleDownloader): Promise { + const admitted = this.manifests.admit(candidate); + if (admitted.disposition !== "accepted" && admitted.disposition !== "duplicate") return { disposition: admitted.disposition }; + const parsed = parseProductionSurfaceManifest(candidate); + if (!parsed) return { disposition: "invalid_manifest" }; + const installed = await this.cache.downloadAndInstall(parsed, downloader); + if ((installed.disposition === "installed" || installed.disposition === "duplicate") && this.cache.bundle(parsed.app_id, parsed.version)) { + this.enabled.add(key(parsed.app_id, parsed.version)); + return { disposition: "installed" }; + } + return { disposition: installed.disposition }; + } + + public validateOpenRequest(payload: unknown): SurfaceRegistryResult { + if (!plainObject(payload) || hasSource(payload) || !plainObject(payload.app) || typeof payload.instance_id !== "string" || !/^[A-Za-z0-9._:-]{1,128}$/.test(payload.instance_id) + || typeof payload.app.app_id !== "string" || typeof payload.app.version !== "string" || (payload.state !== undefined && !plainObject(payload.state))) return { disposition: "invalid_state" }; + return this.validateState(payload.app.app_id, payload.app.version, payload.state ?? {}); + } + + public validateState(appID: string, version: string, state: unknown): SurfaceRegistryResult { + const manifest = this.manifests.get(appID, version); + if (!manifest) return { disposition: "unknown_app" }; + if (!this.enabled.has(key(appID, version)) || !this.cache.bundle(appID, version)) return { disposition: "disabled" }; + if (!plainObject(state)) return { disposition: "invalid_state" }; + try { + return new TextEncoder().encode(JSON.stringify(state as JsonObject)).byteLength <= this.maxStateBytes + ? { disposition: "accepted" } + : { disposition: "state_too_large" }; + } catch { return { disposition: "invalid_state" }; } + } +} + +function key(appID: string, version: string): string { return `${appID}@${version}`; } +function plainObject(value: unknown): value is Record { return value !== null && typeof value === "object" && !Array.isArray(value) && (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null); } +function hasSource(value: Record): boolean { return ["bundle", "bundle_id", "bundle_url", "url", "html", "css", "javascript", "js", "script", "source"].some(field => field in value); } diff --git a/tauri/src/runtime/surface-bundle-cache.test.ts b/tauri/src/runtime/surface-bundle-cache.test.ts new file mode 100644 index 0000000..5459550 --- /dev/null +++ b/tauri/src/runtime/surface-bundle-cache.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from "vitest"; +import { TrustedSurfaceBundleDownloader, VerifiedSurfaceBundleCache, WebCryptoEd25519SurfaceManifestSignatureVerifier, type SurfaceBundleDigest, type SurfaceManifestSignatureVerifier } from "./surface-bundle-cache"; + +const bytes = new Uint8Array([1, 2, 3, 4]); +const digest: SurfaceBundleDigest = { async sha256(value) { return value.join("").padEnd(64, "0"); } }; +const signatures: SurfaceManifestSignatureVerifier = { async verify(_payload, signature, keyID) { return signature === "A".repeat(86) && keyID === "release-key"; } }; +function manifest(version: string, artifactID = `task-${version}`) { + return { v: 1, app_id: "lineup.task-dashboard", version, artifact: { artifact_id: artifactID, sha256: "1234".padEnd(64, "0"), size_bytes: 4 }, min_host_version: "0.1.0", permissions: ["surface.event.cancel"], key_id: "release-key", signature: "A".repeat(86) }; +} + +describe("VerifiedSurfaceBundleCache", () => { + it("performs real Ed25519 public-key verification without a client signing key", async () => { + const pair = await crypto.subtle.generateKey({ name: "Ed25519" }, true, ["sign", "verify"]); + const payload = new TextEncoder().encode("canonical-manifest-payload"); + const signature = new Uint8Array(await crypto.subtle.sign({ name: "Ed25519" }, pair.privateKey, payload)); + const publicKey = new Uint8Array(await crypto.subtle.exportKey("raw", pair.publicKey)); + const encode = (value: Uint8Array) => btoa(String.fromCharCode(...value)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, ""); + const verifier = new WebCryptoEd25519SurfaceManifestSignatureVerifier({ "release-key": encode(publicKey) }); + await expect(verifier.verify(payload, encode(signature), "release-key")).resolves.toBe(true); + await expect(verifier.verify(new TextEncoder().encode("tampered"), encode(signature), "release-key")).resolves.toBe(false); + await expect(verifier.verify(payload, encode(signature), "unknown-key")).resolves.toBe(false); + await expect(verifier.verify(payload, "not-a-signature", "release-key")).resolves.toBe(false); + }); + + it("verifies signature, exact size and digest before atomically activating bytes", async () => { + const cache = new VerifiedSurfaceBundleCache(signatures, digest); + await expect(cache.install({ ...manifest("1.0.0"), signature: "B".repeat(86) }, bytes)).resolves.toEqual({ disposition: "signature_invalid" }); + await expect(cache.install(manifest("1.0.0"), new Uint8Array([1]))).resolves.toEqual({ disposition: "size_mismatch" }); + await expect(cache.install({ ...manifest("1.0.0"), artifact: { ...manifest("1.0.0").artifact, sha256: "f".repeat(64) } }, bytes)).resolves.toEqual({ disposition: "digest_mismatch" }); + expect(cache.activeBundle("lineup.task-dashboard")).toBeUndefined(); + await expect(cache.install(manifest("1.0.0"), bytes)).resolves.toMatchObject({ disposition: "installed", entry: { manifest: { version: "1.0.0" } } }); + expect(cache.activeBundle("lineup.task-dashboard")?.bytes).toEqual(bytes); + }); + + it("keeps a verified predecessor and rolls back without re-downloading bytes", async () => { + const cache = new VerifiedSurfaceBundleCache(signatures, digest); + await cache.install(manifest("1.0.0"), bytes); + await cache.install(manifest("1.1.0"), bytes); + expect(cache.activeBundle("lineup.task-dashboard")?.manifest.version).toBe("1.1.0"); + expect(cache.rollback("lineup.task-dashboard")).toMatchObject({ disposition: "rolled_back", entry: { manifest: { version: "1.0.0" } } }); + expect(cache.activeBundle("lineup.task-dashboard")?.manifest.version).toBe("1.0.0"); + expect(cache.invalidate("lineup.task-dashboard", "task-1.0.0")).toMatchObject({ disposition: "rolled_back", entry: { manifest: { version: "1.1.0" } } }); + }); + + it("does not expose bytes through its public cache snapshot", async () => { + const cache = new VerifiedSurfaceBundleCache(signatures, digest); + await cache.install(manifest("1.0.0"), bytes); + expect(cache.snapshot()).toEqual([{ app_id: "lineup.task-dashboard", version: "1.0.0", artifact_id: "task-1.0.0", active: true }]); + expect(JSON.stringify(cache.snapshot())).not.toContain("1234"); + }); + + it("downloads only from a configured HTTPS source before installing", async () => { + const fetched: string[] = []; + const downloader = new TrustedSurfaceBundleDownloader("https://bundles.lineup.example/release", async url => { + fetched.push(url.toString()); + return { ok: true, url: "https://bundles.lineup.example/release/artifacts/task-1.0.0.bundle", async bytes() { return bytes; } }; + }); + const cache = new VerifiedSurfaceBundleCache(signatures, digest); + await expect(cache.downloadAndInstall(manifest("1.0.0"), downloader)).resolves.toMatchObject({ disposition: "installed" }); + expect(fetched).toEqual(["https://bundles.lineup.example/release/artifacts/task-1.0.0.bundle"]); + const redirected = new TrustedSurfaceBundleDownloader("https://bundles.lineup.example/release", async () => ({ ok: true, url: "https://attacker.invalid/bundle", async bytes() { return bytes; } })); + await expect(new VerifiedSurfaceBundleCache(signatures, digest).downloadAndInstall(manifest("1.0.0"), redirected)).resolves.toEqual({ disposition: "download_failed" }); + expect(() => new TrustedSurfaceBundleDownloader("http://bundles.lineup.example", async () => ({ ok: false, url: "", async bytes() { return bytes; } }))).toThrow("HTTPS"); + }); +}); diff --git a/tauri/src/runtime/surface-bundle-cache.ts b/tauri/src/runtime/surface-bundle-cache.ts new file mode 100644 index 0000000..be5bbb4 --- /dev/null +++ b/tauri/src/runtime/surface-bundle-cache.ts @@ -0,0 +1,194 @@ +import { canonicalManifestPayload, parseProductionSurfaceManifest, type ProductionSurfaceManifest } from "./production-surface-manifest"; + +export type BundleVerificationDisposition = "installed" | "duplicate" | "invalid_manifest" | "download_failed" | "signature_invalid" | "size_mismatch" | "digest_mismatch"; +export type BundleInstallResult = Readonly<{ disposition: BundleVerificationDisposition; entry?: InstalledSurfaceBundle }>; +export type BundleRollbackResult = Readonly<{ disposition: "rolled_back" | "unavailable"; entry?: InstalledSurfaceBundle }>; + +export type InstalledSurfaceBundle = Readonly<{ + manifest: ProductionSurfaceManifest; + bytes: Uint8Array; +}>; + +/** Implemented by every Host with its platform trust store. */ +export interface SurfaceManifestSignatureVerifier { + verify(payload: Uint8Array, signature: string, keyID: string): Promise; +} + +/** + * Production Ed25519 verifier. A Host receives only public keys, keyed by + * the immutable manifest `key_id`; a missing/invalid key is indistinguishable + * from a bad signature. No signing key and no remote key enrollment exists + * in the client. + */ +export class WebCryptoEd25519SurfaceManifestSignatureVerifier implements SurfaceManifestSignatureVerifier { + private readonly publicKeys: ReadonlyMap; + + public constructor(trustedPublicKeys: Readonly>) { + const keys = new Map(); + for (const [keyID, encoded] of Object.entries(trustedPublicKeys)) { + const bytes = base64urlBytes(encoded); + // Raw Ed25519 public keys are exactly 32 bytes. + if (bytes?.byteLength === 32) keys.set(keyID, bytes); + } + this.publicKeys = keys; + } + + public async verify(payload: Uint8Array, signature: string, keyID: string): Promise { + const publicKey = this.publicKeys.get(keyID); + const signatureBytes = base64urlBytes(signature); + if (!publicKey || !signatureBytes || signatureBytes.byteLength !== 64) return false; + try { + const key = await crypto.subtle.importKey("raw", Uint8Array.from(publicKey).buffer, { name: "Ed25519" }, false, ["verify"]); + return await crypto.subtle.verify({ name: "Ed25519" }, key, Uint8Array.from(signatureBytes).buffer, Uint8Array.from(payload).buffer); + } catch { return false; } + } +} + +/** SHA-256 implementation boundary; no caller may supply a precomputed digest. */ +export interface SurfaceBundleDigest { + sha256(bytes: Uint8Array): Promise; +} + +export type TrustedBundleDownloadResponse = Readonly<{ + ok: boolean; + /** Final URL after redirects, used to reject an origin switch. */ + url: string; + bytes(): Promise; +}>; + +export type TrustedBundleFetcher = (url: URL) => Promise; + +/** + * Download URL construction is Host configuration, never a field in a remote + * manifest or ui.open envelope. HTTPS and same-origin final responses prevent + * an artifact id from becoming a general-purpose network primitive. + */ +export class TrustedSurfaceBundleDownloader { + private readonly baseURL: URL; + + public constructor(baseURL: string, private readonly fetcher: TrustedBundleFetcher) { + const parsed = new URL(baseURL); + if (parsed.protocol !== "https:" || parsed.username || parsed.password || parsed.search || parsed.hash) throw new Error("Bundle source must be a clean HTTPS origin/base path."); + this.baseURL = parsed.toString().endsWith("/") ? parsed : new URL(`${parsed.toString()}/`); + } + + public async download(manifest: ProductionSurfaceManifest): Promise { + const destination = new URL(`artifacts/${encodeURIComponent(manifest.artifact.artifact_id)}.bundle`, this.baseURL); + let response: TrustedBundleDownloadResponse; + try { response = await this.fetcher(destination); } catch { return undefined; } + if (!response.ok) return undefined; + try { + const finalURL = new URL(response.url); + if (finalURL.protocol !== "https:" || finalURL.origin !== this.baseURL.origin) return undefined; + return new Uint8Array(await response.bytes()); + } catch { return undefined; } + } +} + +/** Browser/Tauri WebCrypto implementation; Android/Wails use the same hex format. */ +export class WebCryptoSurfaceBundleDigest implements SurfaceBundleDigest { + public async sha256(bytes: Uint8Array): Promise { + // Copy to an owned ArrayBuffer: TypeScript permits a Uint8Array backed by + // SharedArrayBuffer, while WebCrypto's browser contract is ArrayBuffer. + const owned = Uint8Array.from(bytes); + const digest = await crypto.subtle.digest("SHA-256", owned.buffer); + return [...new Uint8Array(digest)].map(value => value.toString(16).padStart(2, "0")).join(""); + } +} + +/** + * Host-owned verified cache. It accepts bytes only after manifest signature, + * exact length and SHA-256 validation succeed. A failed candidate never + * mutates the current active bundle, making rollback an atomic pointer swap. + * Bundle bytes are intentionally inaccessible to conversation/protocol code. + */ +export class VerifiedSurfaceBundleCache { + private readonly entries = new Map(); + private readonly active = new Map(); + private readonly previous = new Map(); + + public constructor( + private readonly signatureVerifier: SurfaceManifestSignatureVerifier, + private readonly digest: SurfaceBundleDigest = new WebCryptoSurfaceBundleDigest(), + ) {} + + public async install(candidate: unknown, bytes: Uint8Array): Promise { + const manifest = parseProductionSurfaceManifest(candidate); + if (!manifest) return { disposition: "invalid_manifest" }; + const payload = new TextEncoder().encode(canonicalManifestPayload(manifest)); + if (!await this.signatureVerifier.verify(payload, manifest.signature, manifest.key_id)) return { disposition: "signature_invalid" }; + if (bytes.byteLength !== manifest.artifact.size_bytes) return { disposition: "size_mismatch" }; + if (await this.digest.sha256(bytes) !== manifest.artifact.sha256) return { disposition: "digest_mismatch" }; + + const app = appKey(manifest); + const key = bundleKey(manifest); + const existing = this.entries.get(key); + if (existing) return { disposition: "duplicate", entry: copy(existing) }; + + // Every validation above completes before the first cache mutation. + const entry = freezeEntry({ manifest, bytes: new Uint8Array(bytes) }); + const oldActive = this.active.get(app); + this.entries.set(key, entry); + this.active.set(app, key); + if (oldActive && oldActive !== key) this.previous.set(app, oldActive); + return { disposition: "installed", entry: copy(entry) }; + } + + public async downloadAndInstall(candidate: unknown, downloader: TrustedSurfaceBundleDownloader): Promise { + const manifest = parseProductionSurfaceManifest(candidate); + if (!manifest) return { disposition: "invalid_manifest" }; + const bytes = await downloader.download(manifest); + return bytes ? this.install(manifest, bytes) : { disposition: "download_failed" }; + } + + public activeBundle(appID: string): InstalledSurfaceBundle | undefined { + const key = this.active.get(appID); + const entry = key ? this.entries.get(key) : undefined; + return entry && copy(entry); + } + + /** Exact version lookup for a persisted Surface instance; never falls forward. */ + public bundle(appID: string, version: string): InstalledSurfaceBundle | undefined { + const entry = [...this.entries.values()].find(candidate => candidate.manifest.app_id === appID && candidate.manifest.version === version); + return entry && copy(entry); + } + + /** Reverts only to a previously verified bundle for the same app. */ + public rollback(appID: string): BundleRollbackResult { + const prior = this.previous.get(appID); + const current = this.active.get(appID); + const entry = prior ? this.entries.get(prior) : undefined; + if (!current || !prior || !entry) return { disposition: "unavailable" }; + this.active.set(appID, prior); + this.previous.set(appID, current); + return { disposition: "rolled_back", entry: copy(entry) }; + } + + /** An invalidated active artifact falls back only to a verified predecessor. */ + public invalidate(appID: string, artifactID: string): BundleRollbackResult { + const current = this.active.get(appID); + const currentEntry = current ? this.entries.get(current) : undefined; + if (!current || !currentEntry || currentEntry.manifest.artifact.artifact_id !== artifactID) return { disposition: "unavailable" }; + return this.rollback(appID); + } + + public snapshot(): readonly Readonly<{ app_id: string; version: string; artifact_id: string; active: boolean }>[] { + return [...this.entries.entries()].map(([key, entry]) => ({ + app_id: entry.manifest.app_id, version: entry.manifest.version, artifact_id: entry.manifest.artifact.artifact_id, + active: this.active.get(appKey(entry.manifest)) === key, + })).sort((left, right) => `${left.app_id}@${left.version}`.localeCompare(`${right.app_id}@${right.version}`)); + } +} + +function appKey(manifest: ProductionSurfaceManifest): string { return manifest.app_id; } +function bundleKey(manifest: ProductionSurfaceManifest): string { return `${manifest.app_id}@${manifest.version}#${manifest.artifact.sha256}`; } +function copy(entry: InstalledSurfaceBundle): InstalledSurfaceBundle { return { manifest: { ...entry.manifest, artifact: { ...entry.manifest.artifact }, permissions: [...entry.manifest.permissions] }, bytes: new Uint8Array(entry.bytes) }; } +function freezeEntry(entry: InstalledSurfaceBundle): InstalledSurfaceBundle { return Object.freeze({ manifest: Object.freeze({ ...entry.manifest, artifact: Object.freeze({ ...entry.manifest.artifact }), permissions: Object.freeze([...entry.manifest.permissions]) }), bytes: new Uint8Array(entry.bytes) }); } + +function base64urlBytes(value: string): Uint8Array | undefined { + if (!/^[A-Za-z0-9_-]+$/.test(value) || value.length % 4 === 1) return undefined; + try { + const binary = atob(value.replace(/-/g, "+").replace(/_/g, "/") + "=".repeat((4 - value.length % 4) % 4)); + return Uint8Array.from(binary, character => character.charCodeAt(0)); + } catch { return undefined; } +} diff --git a/tauri/src/runtime/surface-instance-manager.ts b/tauri/src/runtime/surface-instance-manager.ts index 0909932..6d21d6e 100644 --- a/tauri/src/runtime/surface-instance-manager.ts +++ b/tauri/src/runtime/surface-instance-manager.ts @@ -1,7 +1,7 @@ import type { JsonObject } from "../protocol"; import { parseSurfaceOpenRequest, - type SurfaceRegistry, + type SurfaceAdmissionPolicy, type SurfaceRegistryDisposition, } from "./surface-registry"; @@ -45,7 +45,7 @@ export type SurfaceInstanceSnapshot = Readonly<{ export class SurfaceInstanceManager { private readonly instances = new Map(); - public constructor(private readonly registry: SurfaceRegistry) {} + public constructor(private readonly registry: SurfaceAdmissionPolicy) {} /** * A repeated open is deliberately a no-op. The first accepted state wins; diff --git a/tauri/src/runtime/surface-registry.ts b/tauri/src/runtime/surface-registry.ts index 35fbf33..d7d3369 100644 --- a/tauri/src/runtime/surface-registry.ts +++ b/tauri/src/runtime/surface-registry.ts @@ -48,6 +48,12 @@ export type SurfaceOpenRequest = Readonly<{ state?: JsonObject; }>; +/** Shared contract for development and production Surface admission policies. */ +export interface SurfaceAdmissionPolicy { + validateOpenRequest(payload: unknown): SurfaceRegistryResult; + validateState(appID: string, version: string, state: unknown): SurfaceRegistryResult; +} + const APP_ID = /^[a-z][a-z0-9]*(?:[.-][a-z0-9]+)*$/; const VERSION = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/; const BUNDLE_ID = /^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$/; diff --git a/wails-spike/README.md b/wails-spike/README.md new file mode 100644 index 0000000..b676122 --- /dev/null +++ b/wails-spike/README.md @@ -0,0 +1,13 @@ +# LineUp Wails v3 对照 Spike(M4-05) + +此目录是受限的 Wails 可行性验证,不是第二套 LineUp 客户端、更不是生产发布物。 + +固定依赖为 `github.com/wailsapp/wails/v3 v3.0.0-beta.3`。`go test ./contract` 在本机执行 Tauri Reference Host 维护的同一份版本化协议 fixture:`../tauri/src/runtime/golden/lineup-v1.json`。`GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go test -c ./...` 则编译真实 Wails `application` API;这是为不具备 GTK4/WebKitGTK 的 Linux 开发机保留的 API/跨编译门禁。 + +当前结论:Wails 能作为桌面 Host 候选运行同一份纯数据协议、Tool lifecycle 与安全拒绝 fixture;它不能因此获得任何比 Tauri 更多的权限。Surface 必须继续由 Host 校验 artifact、以 opaque/隔离 WebView 承载,并且不能注入 Wails bindings;Capability 只能由顶层 Host 在显式同意后执行。 + +本 Spike 的边界和后续准入: + +- 已验证:beta.3 Go module、Wails application API 的 Windows/amd64 交叉编译、共享 golden fixture 的本机可执行投影、Surface source 字段拒绝。 +- 尚不作为生产批准:完整窗口隔离/CSP、签名 artifact 加载、原生 capability handler、移动端和发布包,需要在 Wails 版本稳定后用与 Tauri 相同的有头验收清单完成。 +- 这不改变 Android 当前暂缓状态,也不改变 LineUp v1 协议。 diff --git a/wails-spike/contract/golden_test.go b/wails-spike/contract/golden_test.go new file mode 100644 index 0000000..3e35352 --- /dev/null +++ b/wails-spike/contract/golden_test.go @@ -0,0 +1,129 @@ +// Package contract is deliberately framework-free: production Wails wiring +// must not change LineUp's envelope/state semantics. +package contract + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" +) + +type fixture struct { + Contract string `json:"contract"` + Cases []testCase `json:"cases"` +} + +type testCase struct { + ID string `json:"id"` + Expected expected `json:"expected"` + Envelopes []envelope `json:"envelopes"` + Transitions []transition `json:"transitions"` +} + +type expected struct { + ItemKind string `json:"item_kind"` + Fallback string `json:"fallback"` + KernelToolStatus string `json:"kernel_tool_status"` +} + +type transition struct { + AfterEnvelope int `json:"after_envelope"` + Kind string `json:"kind"` + CallID string `json:"call_id"` +} + +type envelope struct { + Version int `json:"v"` + Type string `json:"type"` + Payload map[string]interface{} `json:"payload"` +} + +func TestSharedLineUpV1GoldenContract(t *testing.T) { + bytes, err := os.ReadFile(filepath.Join("..", "..", "tauri", "src", "runtime", "golden", "lineup-v1.json")) + if err != nil { + t.Fatalf("read shared fixture: %v", err) + } + var spec fixture + if err := json.Unmarshal(bytes, &spec); err != nil { + t.Fatalf("parse shared fixture: %v", err) + } + if spec.Contract != "lineup-v1-golden-1" || len(spec.Cases) != 6 { + t.Fatalf("unexpected fixture declaration: %q (%d cases)", spec.Contract, len(spec.Cases)) + } + for _, c := range spec.Cases { + t.Run(c.ID, func(t *testing.T) { + toolStates := map[string]string{} + kind, fallback := "", "" + for index, wire := range c.Envelopes { + kind, fallback = project(wire) + if kind == "tool-call" { + toolStates[stringValue(wire.Payload["call_id"])] = "pending" + } + for _, action := range c.Transitions { + if action.AfterEnvelope == index && action.Kind == "submit_tool_call" && toolStates[action.CallID] == "pending" { + toolStates[action.CallID] = "submitted" + } + } + if kind == "tool-result" { + callID := stringValue(wire.Payload["call_id"]) + if toolStates[callID] == "submitted" { + toolStates[callID] = stringValue(wire.Payload["status"]) + } + } + } + if kind != c.Expected.ItemKind || fallback != c.Expected.Fallback { + t.Fatalf("got kind=%q fallback=%q; want kind=%q fallback=%q", kind, fallback, c.Expected.ItemKind, c.Expected.Fallback) + } + if c.Expected.KernelToolStatus != "" { + ok := false + for _, state := range toolStates { + ok = ok || state == c.Expected.KernelToolStatus + } + if !ok { + t.Fatalf("tool state never reached %q: %#v", c.Expected.KernelToolStatus, toolStates) + } + } + }) + } +} + +func project(wire envelope) (kind, fallback string) { + if wire.Version != 1 { + return "fallback", "unsupported_version" + } + switch wire.Type { + case "lineup.v1.tool.call": + if stringValue(wire.Payload["call_id"]) == "" || stringValue(wire.Payload["tool"]) == "" { + return "fallback", "invalid_payload" + } + return "tool-call", "" + case "lineup.v1.tool.result": + if stringValue(wire.Payload["call_id"]) == "" || stringValue(wire.Payload["status"]) == "" { + return "fallback", "invalid_payload" + } + return "tool-result", "" + case "lineup.v1.ui.open", "lineup.v1.ui.patch", "lineup.v1.ui.close": + for _, forbidden := range []string{"bundle", "bundle_id", "bundle_url", "url", "html", "css", "javascript", "js", "script", "source"} { + if _, exists := wire.Payload[forbidden]; exists { + return "fallback", "invalid_payload" + } + } + return "surface", "" + case "lineup.v1.app.call": + if stringValue(wire.Payload["call_id"]) == "" || stringValue(wire.Payload["capability"]) == "" || stringValue(wire.Payload["reason"]) == "" || stringValue(wire.Payload["expires_at"]) == "" { + return "fallback", "invalid_payload" + } + if _, ok := wire.Payload["arguments"].(map[string]interface{}); !ok { + return "fallback", "invalid_payload" + } + return "app-call", "" + default: + return "fallback", "unsupported_type" + } +} + +func stringValue(value interface{}) string { + text, _ := value.(string) + return text +} diff --git a/wails-spike/go.mod b/wails-spike/go.mod new file mode 100644 index 0000000..2d0ec06 --- /dev/null +++ b/wails-spike/go.mod @@ -0,0 +1,14 @@ +module lineup-wails-spike + +go 1.26.0 + +require github.com/wailsapp/wails/v3 v3.0.0-beta.3 + +require ( + github.com/adrg/xdg v0.5.3 // indirect + github.com/go-ole/go-ole v1.3.0 // indirect + github.com/godbus/dbus/v5 v5.2.2 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + golang.org/x/sys v0.45.0 // indirect +) diff --git a/wails-spike/go.sum b/wails-spike/go.sum new file mode 100644 index 0000000..85516d7 --- /dev/null +++ b/wails-spike/go.sum @@ -0,0 +1,16 @@ +github.com/adrg/xdg v0.5.3 h1:xRnxJXne7+oWDatRhR1JLnvuccuIeCoBu2rtuLqQB78= +github.com/adrg/xdg v0.5.3/go.mod h1:nlTsY+NNiCBGCK2tpm09vRqfVzrc2fLmXGpBLF0zlTQ= +github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= +github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= +github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= +github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/wailsapp/wails/v3 v3.0.0-beta.3 h1:BrcZunEBVucncRx+xgkk9TzlXU4qc0ygJuEhKAAGaeA= +github.com/wailsapp/wails/v3 v3.0.0-beta.3/go.mod h1:BzATbK71VFikMMMCo434wAi0QcaI03P+xeaWgDvQvjw= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= diff --git a/wails-spike/main.go b/wails-spike/main.go new file mode 100644 index 0000000..95c505a --- /dev/null +++ b/wails-spike/main.go @@ -0,0 +1,30 @@ +// The Wails M4 comparison host intentionally has no LineUp business logic in +// Go. The rendered shell consumes the same host-neutral contract fixture as +// Tauri; Go owns only native lifecycle and capability boundaries. +package main + +import ( + "net/http" + + "github.com/wailsapp/wails/v3/pkg/application" +) + +func newSpikeApplication() *application.App { + app := application.New(application.Options{ + Name: "LineUp Wails Contract Spike", + Description: "M4 parity probe; not a production LineUp Host", + Assets: application.AssetOptions{Handler: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = w.Write([]byte("LineUp Wails Contract Spike
Wails contract spike
")) + })}, + }) + app.Window.NewWithOptions(application.WebviewWindowOptions{ + Title: "LineUp Wails Contract Spike", + URL: "/", + }) + return app +} + +func main() { + _ = newSpikeApplication().Run() +} diff --git a/wails-spike/runtime_contract_test.go b/wails-spike/runtime_contract_test.go new file mode 100644 index 0000000..ffceff0 --- /dev/null +++ b/wails-spike/runtime_contract_test.go @@ -0,0 +1,151 @@ +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/wailsapp/wails/v3/pkg/application" +) + +type goldenFixture struct { + Contract string `json:"contract"` + Cases []goldenCase `json:"cases"` +} + +type goldenCase struct { + ID string `json:"id"` + Expected goldenExpected `json:"expected"` + Envelopes []wireEnvelope `json:"envelopes"` + Transitions []hostTransition `json:"transitions"` +} + +type goldenExpected struct { + ItemKind string `json:"item_kind"` + Fallback string `json:"fallback"` + KernelToolStatus string `json:"kernel_tool_status"` +} + +type hostTransition struct { + AfterEnvelope int `json:"after_envelope"` + Kind string `json:"kind"` + CallID string `json:"call_id"` +} + +type wireEnvelope struct { + Version int `json:"v"` + Type string `json:"type"` + Payload map[string]interface{} `json:"payload"` +} + +// TestGoldenContract is deliberately independent of Wails' JS bindings. It +// proves that a Wails Host can run the exact versioned fixture, and that the +// host's admission gate remains data-only before a WebView receives anything. +func TestGoldenContract(t *testing.T) { + var fixture goldenFixture + path := filepath.Join("..", "tauri", "src", "runtime", "golden", "lineup-v1.json") + bytes, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read shared golden fixture: %v", err) + } + if err := json.Unmarshal(bytes, &fixture); err != nil { + t.Fatalf("parse shared golden fixture: %v", err) + } + if fixture.Contract != "lineup-v1-golden-1" { + t.Fatalf("unexpected golden fixture contract %q", fixture.Contract) + } + if len(fixture.Cases) != 6 { + t.Fatalf("expected six shared cases, got %d", len(fixture.Cases)) + } + + for _, test := range fixture.Cases { + t.Run(test.ID, func(t *testing.T) { + status := map[string]string{} + kind := "" + fallback := "" + for index, envelope := range test.Envelopes { + kind, fallback = project(envelope) + if kind == "tool-call" { + status[stringValue(envelope.Payload["call_id"])] = "pending" + } + if kind == "tool-result" { + callID := stringValue(envelope.Payload["call_id"]) + if status[callID] == "submitted" { + status[callID] = stringValue(envelope.Payload["status"]) + } + } + for _, transition := range test.Transitions { + if transition.AfterEnvelope == index && transition.Kind == "submit_tool_call" && status[transition.CallID] == "pending" { + status[transition.CallID] = "submitted" + } + } + } + if kind != test.Expected.ItemKind { + t.Fatalf("projection = %q, want %q (fallback %q)", kind, test.Expected.ItemKind, fallback) + } + if fallback != test.Expected.Fallback { + t.Fatalf("fallback = %q, want %q", fallback, test.Expected.Fallback) + } + if test.Expected.KernelToolStatus != "" { + matched := false + for _, got := range status { + matched = matched || got == test.Expected.KernelToolStatus + } + if !matched { + t.Fatalf("no tool lifecycle reached %q: %#v", test.Expected.KernelToolStatus, status) + } + } + }) + } +} + +// Compile-time integration boundary: the Spike uses the actual Wails v3 API, +// but LineUp never grants a Surface a Wails binding. Any future production +// implementation must retain this separation. +func TestWailsApplicationBoundaryCompiles(t *testing.T) { + var options application.Options + if options.Name != "" { + t.Fatal("zero-value options changed unexpectedly") + } +} + +func project(envelope wireEnvelope) (kind, fallback string) { + if envelope.Version != 1 { + return "fallback", "unsupported_version" + } + switch envelope.Type { + case "lineup.v1.tool.call": + if stringValue(envelope.Payload["call_id"]) == "" || stringValue(envelope.Payload["tool"]) == "" { + return "fallback", "invalid_payload" + } + return "tool-call", "" + case "lineup.v1.tool.result": + if stringValue(envelope.Payload["call_id"]) == "" || stringValue(envelope.Payload["status"]) == "" { + return "fallback", "invalid_payload" + } + return "tool-result", "" + case "lineup.v1.ui.open", "lineup.v1.ui.patch", "lineup.v1.ui.close": + for _, forbidden := range []string{"bundle", "bundle_id", "bundle_url", "url", "html", "css", "javascript", "js", "script", "source"} { + if _, exists := envelope.Payload[forbidden]; exists { + return "fallback", "invalid_payload" + } + } + return "surface", "" + case "lineup.v1.app.call": + if stringValue(envelope.Payload["call_id"]) == "" || stringValue(envelope.Payload["capability"]) == "" || stringValue(envelope.Payload["reason"]) == "" || stringValue(envelope.Payload["expires_at"]) == "" { + return "fallback", "invalid_payload" + } + if _, ok := envelope.Payload["arguments"].(map[string]interface{}); !ok { + return "fallback", "invalid_payload" + } + return "app-call", "" + default: + return "fallback", "unsupported_type" + } +} + +func stringValue(value interface{}) string { + text, _ := value.(string) + return text +}