feat(m4): verify signed surface bundles across hosts

This commit is contained in:
2026-08-04 01:51:23 +08:00
parent 4fc83e5b13
commit d8aa087bc8
21 changed files with 1135 additions and 8 deletions
+33
View File
@@ -0,0 +1,33 @@
# M4 兼容矩阵与发布策略
本文定义 LineUp v1 的 Host 行为,不以特定框架的便利接口扩展协议。Tauri 是当前唯一已验收的 Reference HostAndroid 按当前范围暂缓;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 状态。错误率或安全事件触发时撤销 artifactHost 仅切回已验证 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 交叉编译 SpikeLinux native GUI 仍受本机 GTK4/WebKitGTK 6.0 开发依赖限制,因而尚未获得 Linux 生产批准。Android 按用户范围暂缓。
+1
View File
@@ -29,6 +29,7 @@ git pull --ff-only origin main
- [DESIGN.md](DESIGN.md):Android 实验阶段的产品、连接与交互设计; - [DESIGN.md](DESIGN.md):Android 实验阶段的产品、连接与交互设计;
- [TECHNOLOGY_SELECTION.md](TECHNOLOGY_SELECTION.md):已确认的客户端与 Mini Runtime 技术路线;Tauri 2 是第一优先的 Reference Host SpikeWails v3 在同一基线后续评估; - [TECHNOLOGY_SELECTION.md](TECHNOLOGY_SELECTION.md):已确认的客户端与 Mini Runtime 技术路线;Tauri 2 是第一优先的 Reference Host SpikeWails v3 在同一基线后续评估;
- [M4_COMPATIBILITY_AND_RELEASE.md](M4_COMPATIBILITY_AND_RELEASE.md):M4 的跨 Host 兼容矩阵、签名 Surface 发布、验证与回滚策略;
- [tauri/README.md](tauri/README.md)Tauri 2 Reference Host 的实现、构建状态和下一阶段范围; - [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 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 能力的协议定义。 - [LineUp UI Surface 与 App Capability 协议](../设计/02.正式方案/lineup-ui-surface-protocol.md):小程序 Surface 和 Agent 调 App 能力的协议定义。
+53 -1
View File
@@ -16,7 +16,10 @@ import { HttpTransportAdapter, type TransportAdapter } from "./runtime/transport
import { TrustedDOMRendererContext } from "./runtime/trusted-dom-renderers"; import { TrustedDOMRendererContext } from "./runtime/trusted-dom-renderers";
import { SurfaceRegistry } from "./runtime/surface-registry"; import { SurfaceRegistry } from "./runtime/surface-registry";
import { SurfaceInstanceManager } from "./runtime/surface-instance-manager"; 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 { CapabilityRegistry } from "./runtime/capability-registry";
import { ClientInventoryPublisher } from "./runtime/client-inventory"; import { ClientInventoryPublisher } from "./runtime/client-inventory";
import { ArtifactState } from "./runtime/artifact-state"; import { ArtifactState } from "./runtime/artifact-state";
@@ -35,6 +38,17 @@ type Session = {
active: boolean; 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"; 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 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; let transport: TransportAdapter | undefined;
@@ -104,6 +118,43 @@ const surfaceHost = new IsolatedSurfaceHost(messages, {
}); });
$<HTMLInputElement>("#api").value = session.api; $<HTMLInputElement>("#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<void> {
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 { function newLocalID(prefix: string): string {
// `crypto.randomUUID` is absent from older Safari / WKWebView versions. // `crypto.randomUUID` is absent from older Safari / WKWebView versions.
// A local item id needs uniqueness within this browser store, not a // A local item id needs uniqueness within this browser store, not a
@@ -738,6 +789,7 @@ $<HTMLFormElement>("#login-form").addEventListener("submit", async event => {
} }
for (const instance of surfaceInstances.snapshot().instances) surfaceHost.mount(instance); for (const instance of surfaceInstances.snapshot().instances) surfaceHost.mount(instance);
rendererContext.restoreExecutionSummaries(snapshot.execution_summaries); rendererContext.restoreExecutionSummaries(snapshot.execution_summaries);
await mountM4SignedFixture();
rendererContext.renderSystemMessage("已进入与 AI Agent 的对话"); rendererContext.setPresence("正在同步消息…"); void publishInventory(); void flushOutbox(); void syncLoop(); rendererContext.renderSystemMessage("已进入与 AI Agent 的对话"); rendererContext.setPresence("正在同步消息…"); void publishInventory(); void flushOutbox(); void syncLoop();
} catch (reason) { error.textContent = loginFailureMessage(reason, endpoint); } } catch (reason) { error.textContent = loginFailureMessage(reason, endpoint); }
finally { submit.disabled = false; submit.textContent = "进入 LineUp"; } finally { submit.disabled = false; submit.textContent = "进入 LineUp"; }
+47
View File
@@ -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 }]);
}
});
}
});
+52
View File
@@ -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": "<script>evil()</script>" } }
]
},
{
"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" } }
]
}
]
}
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest"; 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", () => { describe("isolated Surface bridge contract", () => {
it("accepts only the exact minimal ready message", () => { 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).toContain("lineup.surface.v1.ready");
expect(document).not.toContain("https://"); 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("<main><script>window.ok=1<\/script></main>");
await cache.install({
v: 1, app_id: "lineup.task-dashboard", version: "1.0.0",
artifact: { artifact_id: "dashboard-prod", sha256: "b".repeat(64), size_bytes: bundle.length },
min_host_version: "0.1.0", permissions: ["surface.event.cancel"], key_id: "release-key", signature: "A".repeat(86),
}, bundle);
const resolver = new CachedVerifiedSurfaceDocumentResolver(cache);
const document = resolver.documentFor({ instance_id: "surface:001", conversation_id: "conversation", app_id: "lineup.task-dashboard", version: "1.0.0", state: {}, phase: "opening", opened_at: "2026-08-04T00:00:00.000Z", updated_at: "2026-08-04T00:00:00.000Z" });
expect(document).toContain("default-src 'none'");
expect(document).toContain("window.__LINEUP_SURFACE_INSTANCE__=\"surface:001\"");
expect(document).toContain("window.ok=1");
expect(resolver.documentFor({ instance_id: "surface:002", conversation_id: "conversation", app_id: "lineup.task-dashboard", version: "9.9.9", state: {}, phase: "opening", opened_at: "2026-08-04T00:00:00.000Z", updated_at: "2026-08-04T00:00:00.000Z" })).toBeUndefined();
expect(productionSurfaceDocument("surface:003", "<script src=\"https://attacker.invalid/x.js\"><\/script>")).toContain("script-src 'unsafe-inline'");
});
}); });
+40 -4
View File
@@ -1,11 +1,17 @@
import type { JsonObject } from "../protocol"; import type { JsonObject } from "../protocol";
import type { SurfaceInstance } from "./surface-instance-manager"; import type { SurfaceInstance } from "./surface-instance-manager";
import type { VerifiedSurfaceBundleCache } from "./surface-bundle-cache";
export type SurfaceHostCallbacks = Readonly<{ export type SurfaceHostCallbacks = Readonly<{
ready: (instanceID: string) => void; ready: (instanceID: string) => void;
event: (instanceID: string, event: "cancel") => void; event: (instanceID: string, event: "cancel") => void;
}>; }>;
/** Resolves only host-verified bytes to an iframe document; it has no bridge authority. */
export interface VerifiedSurfaceDocumentResolver {
documentFor(instance: SurfaceInstance): string | undefined;
}
type HostedSurface = { type HostedSurface = {
readonly instance: SurfaceInstance; readonly instance: SurfaceInstance;
readonly frame: HTMLIFrameElement; readonly frame: HTMLIFrameElement;
@@ -26,7 +32,7 @@ const STATE_TYPE = "lineup.surface.v1.state";
export class IsolatedSurfaceHost { export class IsolatedSurfaceHost {
private readonly surfaces = new Map<string, HostedSurface>(); private readonly surfaces = new Map<string, HostedSurface>();
public constructor(private readonly mountPoint: HTMLElement, private readonly callbacks: SurfaceHostCallbacks) { public constructor(private readonly mountPoint: HTMLElement, private readonly callbacks: SurfaceHostCallbacks, private readonly documents?: VerifiedSurfaceDocumentResolver) {
window.addEventListener("message", this.receiveMessage); window.addEventListener("message", this.receiveMessage);
} }
@@ -37,15 +43,18 @@ export class IsolatedSurfaceHost {
if (current.ready) this.sendState(current, instance.state); if (current.ready) this.sendState(current, instance.state);
return; return;
} }
// Development uses the static dashboard. A production resolver can return
// only bytes that were already signature/hash verified by M4-02; a missing
// exact version is a safe no-op rather than a fallback to unverified code.
const resolvedDocument = this.documents ? this.documents.documentFor(instance) : surfaceDocument(instance.instance_id);
if (!resolvedDocument) return;
const frame = document.createElement("iframe"); const frame = document.createElement("iframe");
frame.className = "isolated-surface-frame"; frame.className = "isolated-surface-frame";
frame.title = `LineUp Surface ${instance.app_id}`; frame.title = `LineUp Surface ${instance.app_id}`;
frame.setAttribute("sandbox", "allow-scripts"); frame.setAttribute("sandbox", "allow-scripts");
frame.setAttribute("referrerpolicy", "no-referrer"); frame.setAttribute("referrerpolicy", "no-referrer");
frame.setAttribute("aria-label", "受限扩展界面"); frame.setAttribute("aria-label", "受限扩展界面");
// srcdoc comes only from this module's static source. No Envelope field, frame.srcdoc = resolvedDocument;
// manifest field, Agent content or remote response can alter it.
frame.srcdoc = surfaceDocument(instance.instance_id);
const hosted: HostedSurface = { instance, frame, ready: false, cancelSent: false }; const hosted: HostedSurface = { instance, frame, ready: false, cancelSent: false };
this.surfaces.set(instance.instance_id, hosted); this.surfaces.set(instance.instance_id, hosted);
this.mountPoint.append(frame); this.mountPoint.append(frame);
@@ -95,6 +104,25 @@ export class IsolatedSurfaceHost {
} }
} }
/**
* Converts an exact, verified bundle into a host-CSP-wrapped opaque document.
* The bundle itself is untrusted code: it gets no origin, network, navigation,
* forms or privileged bridge. A bundle-provided CSP can only further restrict
* this host policy, never relax it.
*/
export class CachedVerifiedSurfaceDocumentResolver implements VerifiedSurfaceDocumentResolver {
public constructor(private readonly cache: VerifiedSurfaceBundleCache) {}
public documentFor(instance: SurfaceInstance): string | undefined {
const entry = this.cache.bundle(instance.app_id, instance.version);
if (!entry || !entry.bytes.byteLength) return undefined;
try {
const source = new TextDecoder("utf-8", { fatal: true }).decode(entry.bytes);
return productionSurfaceDocument(instance.instance_id, source);
} catch { return undefined; }
}
}
type SurfaceReadyMessage = Readonly<{ v: 1; type: typeof READY_TYPE; instance_id: string }>; type SurfaceReadyMessage = Readonly<{ v: 1; type: typeof READY_TYPE; instance_id: string }>;
type SurfaceEventMessage = Readonly<{ v: 1; type: "lineup.surface.v1.event"; instance_id: string; event: "cancel" }>; type SurfaceEventMessage = Readonly<{ v: 1; type: "lineup.surface.v1.event"; instance_id: string; event: "cancel" }>;
@@ -117,6 +145,14 @@ export function surfaceDocument(instanceID: string): string {
return `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="referrer" content="no-referrer"><meta http-equiv="Content-Security-Policy" content="default-src 'none'; connect-src 'none'; img-src data:; style-src 'unsafe-inline'; script-src 'unsafe-inline'; base-uri 'none'; form-action 'none'; frame-src 'none'; object-src 'none'; navigate-to 'none'"><style>html,body{margin:0;background:#15231c;color:#e7f0eb;font:14px system-ui,sans-serif}main{padding:12px;display:grid;gap:10px}h2,p{margin:0}progress{width:100%}ul{margin:0;padding-left:20px}#logs{max-height:64px;overflow:auto;color:#b8cfc0;font-size:12px}button{justify-self:start;border:0;border-radius:7px;padding:8px 10px;background:#a9dfc4;color:#102018;font-weight:700}</style></head><body><main><h2 id="title">任务面板</h2><progress id="progress" max="100"></progress><p id="status">等待状态</p><ul id="steps"></ul><div id="logs" aria-label="日志摘要"></div><button id="cancel" type="button">请求取消</button></main><script>"use strict";const instanceId=${safeID},q=id=>document.getElementById(id),list=(id,items)=>q(id).replaceChildren(...(Array.isArray(items)?items.slice(0,12).map(x=>{const n=document.createElement(id==="logs"?"p":"li");n.textContent=typeof x==="string"?x:"";return n}):[]));q("cancel").onclick=()=>window.parent.postMessage({v:1,type:"lineup.surface.v1.event",instance_id:instanceId,event:"cancel"},"*");window.addEventListener("message",event=>{if(event.source!==window.parent)return;const d=event.data;if(!d||d.v!==1||d.type!=="lineup.surface.v1.state"||d.instance_id!==instanceId||!d.state||typeof d.state!=="object"||Array.isArray(d.state))return;const s=d.state;q("title").textContent=typeof s.title==="string"?s.title:"任务面板";q("progress").value=typeof s.percent==="number"?Math.max(0,Math.min(100,s.percent)):0;q("status").textContent=typeof s.status==="string"?s.status:"进行中";list("steps",s.steps);list("logs",s.logs);});window.parent.postMessage({v:1,type:"lineup.surface.v1.ready",instance_id:instanceId},"*");</script></body></html>`; return `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="referrer" content="no-referrer"><meta http-equiv="Content-Security-Policy" content="default-src 'none'; connect-src 'none'; img-src data:; style-src 'unsafe-inline'; script-src 'unsafe-inline'; base-uri 'none'; form-action 'none'; frame-src 'none'; object-src 'none'; navigate-to 'none'"><style>html,body{margin:0;background:#15231c;color:#e7f0eb;font:14px system-ui,sans-serif}main{padding:12px;display:grid;gap:10px}h2,p{margin:0}progress{width:100%}ul{margin:0;padding-left:20px}#logs{max-height:64px;overflow:auto;color:#b8cfc0;font-size:12px}button{justify-self:start;border:0;border-radius:7px;padding:8px 10px;background:#a9dfc4;color:#102018;font-weight:700}</style></head><body><main><h2 id="title">任务面板</h2><progress id="progress" max="100"></progress><p id="status">等待状态</p><ul id="steps"></ul><div id="logs" aria-label="日志摘要"></div><button id="cancel" type="button">请求取消</button></main><script>"use strict";const instanceId=${safeID},q=id=>document.getElementById(id),list=(id,items)=>q(id).replaceChildren(...(Array.isArray(items)?items.slice(0,12).map(x=>{const n=document.createElement(id==="logs"?"p":"li");n.textContent=typeof x==="string"?x:"";return n}):[]));q("cancel").onclick=()=>window.parent.postMessage({v:1,type:"lineup.surface.v1.event",instance_id:instanceId,event:"cancel"},"*");window.addEventListener("message",event=>{if(event.source!==window.parent)return;const d=event.data;if(!d||d.v!==1||d.type!=="lineup.surface.v1.state"||d.instance_id!==instanceId||!d.state||typeof d.state!=="object"||Array.isArray(d.state))return;const s=d.state;q("title").textContent=typeof s.title==="string"?s.title:"任务面板";q("progress").value=typeof s.percent==="number"?Math.max(0,Math.min(100,s.percent)):0;q("status").textContent=typeof s.status==="string"?s.status:"进行中";list("steps",s.steps);list("logs",s.logs);});window.parent.postMessage({v:1,type:"lineup.surface.v1.ready",instance_id:instanceId},"*");</script></body></html>`;
} }
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 `<!doctype html><meta charset="utf-8"><meta name="referrer" content="no-referrer"><meta http-equiv="Content-Security-Policy" content="${csp}"><script>"use strict";window.__LINEUP_SURFACE_INSTANCE__=${safeID};</script>${verifiedBundle}`;
}
function isPlainObject(value: unknown): value is Record<string, unknown> { function isPlainObject(value: unknown): value is Record<string, unknown> {
if (value === null || typeof value !== "object" || Array.isArray(value)) return false; if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
const prototype = Object.getPrototypeOf(value); const prototype = Object.getPrototypeOf(value);
@@ -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);
});
});
@@ -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<SurfacePermission>(["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<string, ProductionSurfaceManifest>();
private readonly trustedKeys: ReadonlySet<string>;
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<string, unknown>;
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<string, unknown> { 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; }
@@ -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("<main>verified</main>");
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: "<script>evil()</script>" }, "conversation", "2026-08-04T00:00:00.000Z")).toMatchObject({ disposition: "bundle_override_forbidden" });
});
});
@@ -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<string>();
public constructor(
private readonly manifests: ProductionSurfaceManifestRegistry,
private readonly cache: VerifiedSurfaceBundleCache,
private readonly maxStateBytes = 64 * 1024,
) {}
public async downloadAndEnable(candidate: unknown, downloader: TrustedSurfaceBundleDownloader): Promise<ProductionSurfacePreparation> {
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<string, unknown> { return value !== null && typeof value === "object" && !Array.isArray(value) && (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null); }
function hasSource(value: Record<string, unknown>): boolean { return ["bundle", "bundle_id", "bundle_url", "url", "html", "css", "javascript", "js", "script", "source"].some(field => field in value); }
@@ -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");
});
});
+194
View File
@@ -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<boolean>;
}
/**
* 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<string, Uint8Array>;
public constructor(trustedPublicKeys: Readonly<Record<string, string>>) {
const keys = new Map<string, Uint8Array>();
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<boolean> {
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<string>;
}
export type TrustedBundleDownloadResponse = Readonly<{
ok: boolean;
/** Final URL after redirects, used to reject an origin switch. */
url: string;
bytes(): Promise<Uint8Array>;
}>;
export type TrustedBundleFetcher = (url: URL) => Promise<TrustedBundleDownloadResponse>;
/**
* 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<Uint8Array | undefined> {
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<string> {
// 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<string, InstalledSurfaceBundle>();
private readonly active = new Map<string, string>();
private readonly previous = new Map<string, string>();
public constructor(
private readonly signatureVerifier: SurfaceManifestSignatureVerifier,
private readonly digest: SurfaceBundleDigest = new WebCryptoSurfaceBundleDigest(),
) {}
public async install(candidate: unknown, bytes: Uint8Array): Promise<BundleInstallResult> {
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<BundleInstallResult> {
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; }
}
@@ -1,7 +1,7 @@
import type { JsonObject } from "../protocol"; import type { JsonObject } from "../protocol";
import { import {
parseSurfaceOpenRequest, parseSurfaceOpenRequest,
type SurfaceRegistry, type SurfaceAdmissionPolicy,
type SurfaceRegistryDisposition, type SurfaceRegistryDisposition,
} from "./surface-registry"; } from "./surface-registry";
@@ -45,7 +45,7 @@ export type SurfaceInstanceSnapshot = Readonly<{
export class SurfaceInstanceManager { export class SurfaceInstanceManager {
private readonly instances = new Map<string, MutableSurfaceInstance>(); private readonly instances = new Map<string, MutableSurfaceInstance>();
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; * A repeated open is deliberately a no-op. The first accepted state wins;
+6
View File
@@ -48,6 +48,12 @@ export type SurfaceOpenRequest = Readonly<{
state?: JsonObject; 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 APP_ID = /^[a-z][a-z0-9]*(?:[.-][a-z0-9]+)*$/;
const VERSION = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/; const VERSION = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
const BUNDLE_ID = /^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$/; const BUNDLE_ID = /^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$/;
+13
View File
@@ -0,0 +1,13 @@
# LineUp Wails v3 对照 SpikeM4-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 bindingsCapability 只能由顶层 Host 在显式同意后执行。
本 Spike 的边界和后续准入:
- 已验证:beta.3 Go module、Wails application API 的 Windows/amd64 交叉编译、共享 golden fixture 的本机可执行投影、Surface source 字段拒绝。
- 尚不作为生产批准:完整窗口隔离/CSP、签名 artifact 加载、原生 capability handler、移动端和发布包,需要在 Wails 版本稳定后用与 Tauri 相同的有头验收清单完成。
- 这不改变 Android 当前暂缓状态,也不改变 LineUp v1 协议。
+129
View File
@@ -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
}
+14
View File
@@ -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
)
+16
View File
@@ -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=
+30
View File
@@ -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("<!doctype html><meta charset=\"utf-8\"><title>LineUp Wails Contract Spike</title><main>Wails contract spike</main>"))
})},
})
app.Window.NewWithOptions(application.WebviewWindowOptions{
Title: "LineUp Wails Contract Spike",
URL: "/",
})
return app
}
func main() {
_ = newSpikeApplication().Run()
}
+151
View File
@@ -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
}