215 lines
17 KiB
TypeScript
215 lines
17 KiB
TypeScript
/**
|
|
* 受限 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;
|
|
/** Runtime-owned MiniApp Bridge request; the iframe never receives Runtime internals. */
|
|
request?: (instanceID: string, method: string, payload: JsonObject) => Promise<JsonObject | undefined>;
|
|
}>;
|
|
|
|
/** 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";
|
|
const BRIDGE_REQUEST_TYPE = "lineup.miniapp.v1.request";
|
|
const BRIDGE_RESPONSE_TYPE = "lineup.miniapp.v1.response";
|
|
|
|
/**
|
|
* 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, instance.app_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 (message.type === BRIDGE_REQUEST_TYPE) {
|
|
if (!hosted.ready || !this.callbacks.request) return;
|
|
void this.callbacks.request(hosted.instance.instance_id, message.method, message.payload)
|
|
.then(result => this.sendResponse(hosted, message.request_id, result))
|
|
.catch(error => this.sendError(hosted, message.request_id, error instanceof Error ? error.message : "bridge_request_failed"));
|
|
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 }, "*");
|
|
}
|
|
|
|
private sendResponse(hosted: HostedSurface, requestID: string, result: JsonObject | undefined): void {
|
|
hosted.frame.contentWindow?.postMessage({ v: BRIDGE_VERSION, type: BRIDGE_RESPONSE_TYPE, instance_id: hosted.instance.instance_id, request_id: requestID, ok: true, result: result ?? {} }, "*");
|
|
}
|
|
|
|
private sendError(hosted: HostedSurface, requestID: string, error: string): void {
|
|
hosted.frame.contentWindow?.postMessage({ v: BRIDGE_VERSION, type: BRIDGE_RESPONSE_TYPE, instance_id: hosted.instance.instance_id, request_id: requestID, ok: false, error: error.slice(0, 200) }, "*");
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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" }>;
|
|
type BridgeRequestMessage = Readonly<{ v: 1; type: typeof BRIDGE_REQUEST_TYPE; instance_id: string; request_id: string; method: string; payload: JsonObject }>;
|
|
|
|
export function parseReadyMessage(value: unknown): SurfaceReadyMessage | undefined {
|
|
const parsed = parseBridgeMessage(value);
|
|
return parsed?.type === READY_TYPE ? parsed : undefined;
|
|
}
|
|
|
|
function parseBridgeMessage(value: unknown): SurfaceReadyMessage | SurfaceEventMessage | BridgeRequestMessage | undefined {
|
|
if (!isPlainObject(value) || value.v !== BRIDGE_VERSION || (value.type !== READY_TYPE && value.type !== "lineup.surface.v1.event" && value.type !== BRIDGE_REQUEST_TYPE) || 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" };
|
|
if (value.type === BRIDGE_REQUEST_TYPE && typeof value.request_id === "string" && /^[A-Za-z0-9._:-]{1,128}$/.test(value.request_id)
|
|
&& typeof value.method === "string" && /^[a-z][a-z0-9._-]{0,63}$/.test(value.method) && isPlainObject(value.payload)
|
|
&& Object.keys(value).every(key => key === "v" || key === "type" || key === "instance_id" || key === "request_id" || key === "method" || key === "payload")) {
|
|
return { v: 1, type: BRIDGE_REQUEST_TYPE, instance_id: value.instance_id, request_id: value.request_id, method: value.method, payload: cloneObject(value.payload) };
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
/**
|
|
* Development bundles for the two shipped bundled MiniApps. The business
|
|
* state and DOM live in this opaque iframe; the parent only exposes the
|
|
* Runtime-owned request bridge above.
|
|
*/
|
|
export function surfaceDocument(instanceID: string, appID = "lineup.task-dashboard"): string {
|
|
const safeID = JSON.stringify(instanceID);
|
|
const isWhiteboard = appID === "lineup.whiteboard";
|
|
const title = isWhiteboard ? "Whiteboard" : "任务面板";
|
|
const appScript = isWhiteboard ? whiteboardSurfaceScript() : taskDashboardSurfaceScript();
|
|
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}textarea{min-height:4rem;background:#0e1713;color:#eff7f2;border:1px solid #456253;border-radius:7px;padding:7px}</style></head><body><main><h2 id="title">${title}</h2><progress id="progress" max="100"></progress><p id="status">等待状态</p><textarea id="editor" aria-label="小程序业务输入"></textarea><ul id="steps"></ul><div id="logs" aria-label="日志摘要"></div><button id="cancel" type="button">请求取消</button></main><script>"use strict";const instanceId=${safeID};${bridgeScript()}${appScript}</script></body></html>`;
|
|
}
|
|
|
|
function bridgeScript(): string {
|
|
return `const pending=new Map;let sequence=0;function bridge(method,payload={}){const request_id="req_"+(++sequence);return new Promise((resolve,reject)=>{pending.set(request_id,{resolve,reject});parent.postMessage({v:1,type:"lineup.miniapp.v1.request",instance_id:instanceId,request_id,method,payload},"*");setTimeout(()=>{const item=pending.get(request_id);if(item){pending.delete(request_id);item.reject(new Error("bridge_timeout"));}},10000)})}window.addEventListener("message",event=>{if(event.source!==parent)return;const d=event.data;if(!d||d.v!==1||d.instance_id!==instanceId)return;if(d.type==="lineup.miniapp.v1.response"&&typeof d.request_id==="string"){const item=pending.get(d.request_id);if(!item)return;pending.delete(d.request_id);d.ok?item.resolve(d.result||{}):item.reject(new Error(typeof d.error==="string"?d.error:"bridge_rejected"));return}if(d.type!=="lineup.surface.v1.state"||!d.state||typeof d.state!=="object"||Array.isArray(d.state))return;renderState(d.state)});function renderState(s){const title=document.getElementById("title"),progress=document.getElementById("progress"),status=document.getElementById("status"),steps=document.getElementById("steps");if(typeof s.title==="string")title.textContent=s.title;if(typeof s.percent==="number")progress.value=Math.max(0,Math.min(100,s.percent));if(typeof s.status==="string")status.textContent=s.status;steps.replaceChildren(...(Array.isArray(s.steps)?s.steps.slice(0,12).map(x=>{const n=document.createElement("li");n.textContent=typeof x==="string"?x:"";return n}):[]));}parent.postMessage({v:1,type:"lineup.surface.v1.ready",instance_id:instanceId},"*");`;
|
|
}
|
|
|
|
function taskDashboardSurfaceScript(): string {
|
|
return `const handled=new Set;const tasks=new Map;const editor=document.getElementById("editor");document.getElementById("cancel").onclick=()=>bridge("lifecycle.close",{reason:"用户关闭任务面板"});async function poll(){try{const result=await bridge("tools.list");for(const call of Array.isArray(result.calls)?result.calls:[]){if(handled.has(call.call_id)||!(call.status==="waiting_for_app"||call.status==="running"))continue;handled.add(call.call_id);await handleTask(call)}}catch(_){}}async function handleTask(call){try{await bridge("tools.reportProgress",{call_id:call.call_id,progress:{status:"running",percent:10}});if(call.tool_id==="task.create"){const title=typeof call.input?.title==="string"?call.input.title:"未命名任务";const task_id=typeof call.input?.task_id==="string"?call.input.task_id:"task-"+call.call_id;tasks.set(task_id,{title,status:"open"});await bridge("surface.patch",{data:{state:{title:"任务面板",status:"已创建:"+title,percent:100,steps:[...tasks.values()].map(x=>x.title)}}});await bridge("tools.complete",{call_id:call.call_id,result:{task_id}})}else if(call.tool_id==="task.list"){await bridge("tools.complete",{call_id:call.call_id,result:{tasks:[...tasks.entries()].map(([task_id,task])=>Object.assign({task_id},task))}})}else await bridge("tools.fail",{call_id:call.call_id,code:"tool_not_supported",message:"不支持的任务工具"})}catch(_){await bridge("tools.fail",{call_id:call.call_id,code:"app_bridge_failed",message:"任务面板处理失败"}).catch(()=>{})}}setInterval(poll,500);poll();`;
|
|
}
|
|
|
|
function whiteboardSurfaceScript(): string {
|
|
return `const handled=new Set;let board={title:"Whiteboard",status:"可编辑",elements:[]};const editor=document.getElementById("editor");document.getElementById("cancel").onclick=()=>bridge("lifecycle.close",{reason:"用户关闭画板"});editor.addEventListener("input",()=>{board={...board,text:editor.value};bridge("surface.patch",{data:{state:board}}).catch(()=>{})});async function poll(){try{const result=await bridge("tools.list");for(const call of Array.isArray(result.calls)?result.calls:[]){if(handled.has(call.call_id)||!(call.status==="waiting_for_app"||call.status==="running"))continue;handled.add(call.call_id);await handleBoard(call)}}catch(_){}}async function handleBoard(call){try{if(call.tool_id==="whiteboard.open"){await bridge("tools.reportProgress",{call_id:call.call_id,progress:{status:"opening",percent:40}});await bridge("tools.complete",{call_id:call.call_id,result:{status:"completed"}})}else if(call.tool_id==="whiteboard.export"){const artifact_id=typeof call.input?.artifact_id==="string"?call.input.artifact_id:"whiteboard-"+call.call_id;await bridge("tools.reportProgress",{call_id:call.call_id,progress:{status:"exporting",percent:80}});await bridge("surface.patch",{data:{state:{...board,artifact:{artifact_id}}}});await bridge("tools.complete",{call_id:call.call_id,result:{artifact_id}})}else await bridge("tools.fail",{call_id:call.call_id,code:"tool_not_supported",message:"不支持的画板工具"})}catch(_){await bridge("tools.fail",{call_id:call.call_id,code:"app_bridge_failed",message:"画板处理失败"}).catch(()=>{})}}setInterval(poll,500);poll();`;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
function cloneObject(value: Record<string, unknown>): JsonObject {
|
|
return JSON.parse(JSON.stringify(value)) as JsonObject;
|
|
}
|