feat(runtime): establish miniapp kernel and sdk design

This commit is contained in:
2026-08-05 00:46:16 +08:00
parent d8aa087bc8
commit 9fb8cd1598
86 changed files with 4615 additions and 1364 deletions
@@ -0,0 +1,166 @@
/**
* 受限 Surface 的隔离 iframe Host。
*
* 只把 Host 已验证的 Bundle 文档装入 opaque-origin iframe,并通过严格 bridge 接收 ready
* 或声明性事件;Surface 不可获得父 DOM、登录态、Tauri API 或任意网络能力。
*/
import type { JsonObject } from "@/runtime/protocol/lineup-v1";
import type { SurfaceInstance } from "@/runtime/surfaces/surface-instance-manager";
import type { VerifiedSurfaceBundleCache } from "@/runtime/surfaces/surface-bundle-cache";
export type SurfaceHostCallbacks = Readonly<{
ready: (instanceID: string) => 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 = {
readonly instance: SurfaceInstance;
readonly frame: HTMLIFrameElement;
ready: boolean;
cancelSent: boolean;
};
const BRIDGE_VERSION = 1;
const READY_TYPE = "lineup.surface.v1.ready";
const STATE_TYPE = "lineup.surface.v1.state";
/**
* M2-03 Web Reference Host. The frame is deliberately an opaque sandbox
* origin: it receives no same-origin access to the chat page, its storage,
* the Tauri bridge, or browser network credentials. Its only connection to
* the parent is the narrow postMessage bridge below.
*/
export class IsolatedSurfaceHost {
private readonly surfaces = new Map<string, HostedSurface>();
public constructor(private readonly mountPoint: HTMLElement, private readonly callbacks: SurfaceHostCallbacks, private readonly documents?: VerifiedSurfaceDocumentResolver) {
window.addEventListener("message", this.receiveMessage);
}
public mount(instance: SurfaceInstance): void {
const current = this.surfaces.get(instance.instance_id);
if (current) {
current.frame.dataset.surfaceState = JSON.stringify(instance.state);
if (current.ready) this.sendState(current, instance.state);
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");
frame.className = "isolated-surface-frame";
frame.title = `LineUp Surface ${instance.app_id}`;
frame.setAttribute("sandbox", "allow-scripts");
frame.setAttribute("referrerpolicy", "no-referrer");
frame.setAttribute("aria-label", "受限扩展界面");
frame.srcdoc = resolvedDocument;
const hosted: HostedSurface = { instance, frame, ready: false, cancelSent: false };
this.surfaces.set(instance.instance_id, hosted);
this.mountPoint.append(frame);
}
public update(instance: SurfaceInstance): void {
const hosted = this.surfaces.get(instance.instance_id);
if (!hosted || hosted.instance.app_id !== instance.app_id || hosted.instance.version !== instance.version) return;
if (hosted.ready) this.sendState(hosted, instance.state);
}
public unmount(instanceID: string): void {
const hosted = this.surfaces.get(instanceID);
if (!hosted) return;
this.surfaces.delete(instanceID);
hosted.frame.remove();
}
public destroy(): void {
window.removeEventListener("message", this.receiveMessage);
for (const instanceID of [...this.surfaces.keys()]) this.unmount(instanceID);
}
private readonly receiveMessage = (event: MessageEvent<unknown>): void => {
const message = parseBridgeMessage(event.data);
if (!message) return;
const hosted = this.surfaces.get(message.instance_id);
// An opaque sandbox needs targetOrigin="*" to receive a postMessage, so
// the source Window identity and instance id are mandatory checks here.
if (!hosted || event.source !== hosted.frame.contentWindow) return;
if (message.type === READY_TYPE) {
if (hosted.ready) return;
hosted.ready = true;
this.sendState(hosted, hosted.instance.state);
this.callbacks.ready(hosted.instance.instance_id);
return;
}
if (!hosted.ready || hosted.cancelSent) return;
hosted.cancelSent = true;
this.callbacks.event(hosted.instance.instance_id, "cancel");
};
private sendState(hosted: HostedSurface, state: JsonObject): void {
// The sandbox has an opaque origin; Window identity was captured at mount
// and all incoming traffic is checked above. No privileged data is sent.
hosted.frame.contentWindow?.postMessage({ v: BRIDGE_VERSION, type: STATE_TYPE, instance_id: hosted.instance.instance_id, state }, "*");
}
}
/**
* 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 SurfaceEventMessage = Readonly<{ v: 1; type: "lineup.surface.v1.event"; instance_id: string; event: "cancel" }>;
export function parseReadyMessage(value: unknown): SurfaceReadyMessage | undefined {
const parsed = parseBridgeMessage(value);
return parsed?.type === READY_TYPE ? parsed : undefined;
}
function parseBridgeMessage(value: unknown): SurfaceReadyMessage | SurfaceEventMessage | undefined {
if (!isPlainObject(value) || value.v !== BRIDGE_VERSION || (value.type !== READY_TYPE && value.type !== "lineup.surface.v1.event") || typeof value.instance_id !== "string"
|| !/^[A-Za-z0-9._:-]{1,128}$/.test(value.instance_id)) return undefined;
if (value.type === READY_TYPE && Object.keys(value).every(key => key === "v" || key === "type" || key === "instance_id")) return { v: 1, type: READY_TYPE, instance_id: value.instance_id };
if (value.type === "lineup.surface.v1.event" && value.event === "cancel" && Object.keys(value).every(key => key === "v" || key === "type" || key === "instance_id" || key === "event")) return { v: 1, type: "lineup.surface.v1.event", instance_id: value.instance_id, event: "cancel" };
return undefined;
}
/** A static boot document for the future host-local Task Dashboard bundle. */
export function surfaceDocument(instanceID: string): string {
const safeID = JSON.stringify(instanceID);
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> {
if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
const prototype = Object.getPrototypeOf(value);
return prototype === Object.prototype || prototype === null;
}