feat(m2): ship isolated task dashboard surface

This commit is contained in:
2026-08-03 23:11:29 +08:00
parent 9fd321c41c
commit ccff57b2a1
9 changed files with 829 additions and 9 deletions
+80 -7
View File
@@ -13,6 +13,9 @@ import { InteractionKernel } from "./runtime/interaction-kernel";
import { RendererRegistry } from "./runtime/renderer-registry"; import { RendererRegistry } from "./runtime/renderer-registry";
import { HttpTransportAdapter, type TransportAdapter } from "./runtime/transport-adapter"; import { HttpTransportAdapter, type TransportAdapter } from "./runtime/transport-adapter";
import { TrustedDOMRendererContext } from "./runtime/trusted-dom-renderers"; import { TrustedDOMRendererContext } from "./runtime/trusted-dom-renderers";
import { SurfaceRegistry } from "./runtime/surface-registry";
import { SurfaceInstanceManager } from "./runtime/surface-instance-manager";
import { IsolatedSurfaceHost } from "./runtime/isolated-surface-host";
type Session = { type Session = {
api: string; api: string;
@@ -32,6 +35,10 @@ let flushingOutbox = false;
const interactionKernel = new InteractionKernel(); const interactionKernel = new InteractionKernel();
const conversationStore = new ConversationStore(localStorage); const conversationStore = new ConversationStore(localStorage);
const executionProgress = new ExecutionProgressState(); const executionProgress = new ExecutionProgressState();
const surfaceRegistry = new SurfaceRegistry();
const surfaceInstances = new SurfaceInstanceManager(surfaceRegistry);
const SURFACE_REGISTRY_STORAGE_KEY = "lineup.surface-registry.v1";
try { surfaceRegistry.restore(JSON.parse(localStorage.getItem(SURFACE_REGISTRY_STORAGE_KEY) ?? "null")); } catch { surfaceRegistry.restore(null); }
const app = document.querySelector<HTMLDivElement>("#app"); const app = document.querySelector<HTMLDivElement>("#app");
if (!app) throw new Error("LineUp host root is missing"); if (!app) throw new Error("LineUp host root is missing");
@@ -49,7 +56,7 @@ app.innerHTML = `
</form> </form>
</section> </section>
<section id="chat-view" class="chat-view" hidden> <section id="chat-view" class="chat-view" hidden>
<header class="chat-header"><div class="agent"><span class="avatar">✦</span><div><strong>AI Agent</strong><small id="presence">正在连接…</small></div></div><button id="logout" class="quiet">退出</button></header> <header class="chat-header"><div class="agent"><span class="avatar">✦</span><div><strong>AI Agent</strong><small id="presence">正在连接…</small></div></div><div><button id="surface-demo" class="quiet" type="button">启用任务面板</button><button id="logout" class="quiet">退出</button></div></header>
<section id="messages" class="messages" aria-live="polite"></section> <section id="messages" class="messages" aria-live="polite"></section>
<form id="message-form" class="composer"><textarea id="message-input" rows="1" placeholder="问问你的 AI 搭档…"></textarea><button id="send" type="submit">发送</button></form> <form id="message-form" class="composer"><textarea id="message-input" rows="1" placeholder="问问你的 AI 搭档…"></textarea><button id="send" type="submit">发送</button></form>
</section> </section>
@@ -64,6 +71,10 @@ const messages = $<HTMLElement>("#messages");
const loginView = $<HTMLElement>("#login-view"); const loginView = $<HTMLElement>("#login-view");
const chatView = $<HTMLElement>("#chat-view"); const chatView = $<HTMLElement>("#chat-view");
const presence = $<HTMLElement>("#presence"); const presence = $<HTMLElement>("#presence");
const surfaceHost = new IsolatedSurfaceHost(messages, {
ready(instanceID) { surfaceInstances.ready(instanceID, new Date().toISOString()); },
event(instanceID) { queueSurfaceCancel(instanceID); },
});
$<HTMLInputElement>("#api").value = session.api; $<HTMLInputElement>("#api").value = session.api;
function newLocalID(prefix: string): string { function newLocalID(prefix: string): string {
@@ -91,11 +102,12 @@ function normalizeAPIAddress(value: string): string {
return parsed.toString().replace(/\/$/, ""); return parsed.toString().replace(/\/$/, "");
} }
function loginFailureMessage(reason: unknown): string { function loginFailureMessage(reason: unknown, endpoint?: string): string {
if (reason instanceof Error && reason.message === "LINEUP_LOGIN_TIMEOUT") return "连接 AppServer 超时(10 秒)。请确认 Tailscale 已连接,并核对上方显示的服务地址"; const destination = endpoint ? ` ${endpoint}` : "上方填写的服务地址";
if (reason instanceof DOMException && reason.name === "AbortError") return "连接 AppServer 超时(10 秒)。请确认 Tailscale 已连接,且服务地址和端口可访问。"; if (reason instanceof Error && reason.message === "LINEUP_LOGIN_TIMEOUT") return `连接 AppServer 超时(10 秒)。请确认当前网络可以访问${destination}`;
if (reason instanceof DOMException && reason.name === "AbortError") return `连接 AppServer 超时(10 秒)。请确认当前网络可以访问${destination}`;
const message = reason instanceof Error ? reason.message : ""; const message = reason instanceof Error ? reason.message : "";
if (message === "Load failed" || message === "Failed to fetch") return "无法连接 AppServer。请确认 Tailscale 已连接,并检查地址是否为 http://100.121.118.116:8090。"; if (message === "Load failed" || message === "Failed to fetch") return `无法连接 AppServer。请确认当前网络可以访问${destination}`;
return message || "登录失败,请稍后重试。"; return message || "登录失败,请稍后重试。";
} }
@@ -150,9 +162,57 @@ rendererRegistry.register("app-call", (item, context) => context.renderAppCall(i
rendererRegistry.register("fallback", (item, context) => context.renderFallback(item)); rendererRegistry.register("fallback", (item, context) => context.renderFallback(item));
function renderIncoming(item: ConversationItem): void { function renderIncoming(item: ConversationItem): void {
if (item.kind === "surface") {
const now = new Date().toISOString();
if (item.envelope.type === "lineup.v1.ui.open") {
const result = surfaceInstances.open(item.envelope.payload, item.envelope.conversation_id, now);
if (result.instance && (result.disposition === "accepted" || result.disposition === "duplicate")) surfaceHost.mount(result.instance);
if (result.disposition === "accepted") conversationStore.replaceSurfaces(surfaceInstances.snapshot());
return;
}
if (item.envelope.type === "lineup.v1.ui.patch") {
const instanceID = item.envelope.payload.instance_id;
const result = typeof instanceID === "string" ? surfaceInstances.patch(instanceID, item.envelope.payload.state, now) : { disposition: "invalid_request" as const };
if (result.instance && result.disposition === "accepted") surfaceHost.update(result.instance);
if (result.disposition === "accepted") conversationStore.replaceSurfaces(surfaceInstances.snapshot());
return;
}
if (item.envelope.type === "lineup.v1.ui.close") {
const instanceID = item.envelope.payload.instance_id;
if (typeof instanceID === "string") surfaceInstances.close(instanceID);
if (typeof instanceID === "string") surfaceHost.unmount(instanceID);
conversationStore.replaceSurfaces(surfaceInstances.snapshot());
return;
}
}
if (!rendererRegistry.render(item, rendererContext)) rendererContext.renderSystemMessage("收到当前客户端没有可信 Renderer 的内容。"); if (!rendererRegistry.render(item, rendererContext)) rendererContext.renderSystemMessage("收到当前客户端没有可信 Renderer 的内容。");
} }
/**
* A Surface can legitimately arrive before its locally packaged app has been
* enabled. Keep the envelope in the conversation log, but do not silently
* lose that lifecycle transition forever: once the user enables this exact
* local app, replay only the durable Surface transcript for this conversation.
*
* This remains a local trust boundary. Replaying does not enable an app,
* download a bundle, or loosen validation; every transition still flows
* through SurfaceInstanceManager and SurfaceRegistry.
*/
function replayStoredSurfaceLifecycle(): void {
const snapshot = conversationStore.open(currentConversationKey());
for (const stored of snapshot.items) {
if (stored.item.kind === "surface") renderIncoming(stored.item);
}
}
$<HTMLButtonElement>("#surface-demo").addEventListener("click", () => {
const now = new Date().toISOString();
surfaceRegistry.enable("lineup.task-dashboard", "0.1.0", now);
localStorage.setItem(SURFACE_REGISTRY_STORAGE_KEY, JSON.stringify(surfaceRegistry.snapshot()));
replayStoredSurfaceLifecycle();
conversationStore.replaceSurfaces(surfaceInstances.snapshot());
});
/** /**
* Status only begins/ends a turn. Public operation labels can only come from * Status only begins/ends a turn. Public operation labels can only come from
* the separately validated adapter trace, never from Agent status/detail. * the separately validated adapter trace, never from Agent status/detail.
@@ -209,6 +269,17 @@ function queueToolAction(kind: "tool-result" | "tool-cancel", callID: string, pa
void flushOutbox(); void flushOutbox();
} }
function queueSurfaceCancel(instanceID: string): void {
if (!session.uid) return;
const createdAt = new Date().toISOString();
const envelope = makeProtocolEvent("lineup.v1.ui.event", { instance_id: instanceID, event: "cancel", data: {} }, currentConversationKey(), { kind: "human", id: session.uid }, { kind: "agent", id: session.agentUID });
try {
conversationStore.enqueueToolAction(newLocalID("surface"), "surface-event", JSON.stringify(envelope), createdAt);
rendererContext.renderSystemMessage("已请求取消任务。");
void flushOutbox();
} catch (reason) { rendererContext.renderSystemMessage(storageFailureMessage(reason)); }
}
async function flushOutbox(): Promise<void> { async function flushOutbox(): Promise<void> {
if (flushingOutbox || !session.active || !transport || !session.uid) return; if (flushingOutbox || !session.active || !transport || !session.uid) return;
flushingOutbox = true; flushingOutbox = true;
@@ -281,7 +352,7 @@ async function syncLoop(): Promise<void> {
const payload = decodeBase64(message.payload); const payload = decodeBase64(message.payload);
if (message.from_uid === session.uid) { if (message.from_uid === session.uid) {
const ownEnvelope = parseEnvelopeJSON(payload); const ownEnvelope = parseEnvelopeJSON(payload);
if (ownEnvelope.ok && (ownEnvelope.envelope.type === "lineup.v1.tool.result" || ownEnvelope.envelope.type === "lineup.v1.tool.cancel")) { if (ownEnvelope.ok && (ownEnvelope.envelope.type === "lineup.v1.tool.result" || ownEnvelope.envelope.type === "lineup.v1.tool.cancel" || ownEnvelope.envelope.type === "lineup.v1.ui.event")) {
// Tool action echoes advance the inclusive cursor but are never // Tool action echoes advance the inclusive cursor but are never
// rendered as raw JSON user messages or replayed as a new action. // rendered as raw JSON user messages or replayed as a new action.
continue; continue;
@@ -350,6 +421,7 @@ $<HTMLFormElement>("#login-form").addEventListener("submit", async event => {
transport = candidateTransport; transport = candidateTransport;
session = { ...session, uid: body.uid, agentUID: body.agent_uid || session.agentUID, channelID: body.channel_id || session.channelID, channelType: body.channel_type || session.channelType, lastSeq: 0, active: true }; session = { ...session, uid: body.uid, agentUID: body.agent_uid || session.agentUID, channelID: body.channel_id || session.channelID, channelType: body.channel_type || session.channelType, lastSeq: 0, active: true };
const snapshot = conversationStore.open(currentConversationKey()); const snapshot = conversationStore.open(currentConversationKey());
surfaceInstances.restore(snapshot.surfaces);
interactionKernel.restoreToolCalls(snapshot.tool_calls, new Date().toISOString()); interactionKernel.restoreToolCalls(snapshot.tool_calls, new Date().toISOString());
interactionKernel.restoreTasks(snapshot.tasks); interactionKernel.restoreTasks(snapshot.tasks);
executionProgress.restore(snapshot.execution_summaries); executionProgress.restore(snapshot.execution_summaries);
@@ -364,9 +436,10 @@ $<HTMLFormElement>("#login-form").addEventListener("submit", async event => {
for (const stored of snapshot.items) { for (const stored of snapshot.items) {
if (stored.item.kind !== "agent-status") renderIncoming(stored.item); if (stored.item.kind !== "agent-status") renderIncoming(stored.item);
} }
for (const instance of surfaceInstances.snapshot().instances) surfaceHost.mount(instance);
rendererContext.restoreExecutionSummaries(snapshot.execution_summaries); rendererContext.restoreExecutionSummaries(snapshot.execution_summaries);
rendererContext.renderSystemMessage("已进入与 AI Agent 的对话"); rendererContext.setPresence("正在同步消息…"); void flushOutbox(); void syncLoop(); rendererContext.renderSystemMessage("已进入与 AI Agent 的对话"); rendererContext.setPresence("正在同步消息…"); void flushOutbox(); void syncLoop();
} catch (reason) { error.textContent = loginFailureMessage(reason); } } catch (reason) { error.textContent = loginFailureMessage(reason, endpoint); }
finally { submit.disabled = false; submit.textContent = "进入 LineUp"; } finally { submit.disabled = false; submit.textContent = "进入 LineUp"; }
}); });
+15 -1
View File
@@ -217,6 +217,8 @@ const SUPPORTED_TYPES = new Set([
]); ]);
const ID_MAX_LENGTH = 256; const ID_MAX_LENGTH = 256;
const INSTANCE_ID = /^[A-Za-z0-9._:-]{1,128}$/; const INSTANCE_ID = /^[A-Za-z0-9._:-]{1,128}$/;
const SURFACE_APP_ID = /^[a-z][a-z0-9]*(?:[.-][a-z0-9]+)*$/;
const SURFACE_VERSION = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
const MESSAGE_TYPE = /^lineup\.v1\.[a-z][a-z0-9.]*[a-z0-9]$/; const MESSAGE_TYPE = /^lineup\.v1\.[a-z][a-z0-9.]*[a-z0-9]$/;
function object(value: unknown): JsonObject | null { function object(value: unknown): JsonObject | null {
@@ -313,13 +315,25 @@ function validSurfacePayload(type: string, payload: JsonObject): boolean {
const instanceID = text(payload.instance_id); const instanceID = text(payload.instance_id);
if (!INSTANCE_ID.test(instanceID)) return false; if (!INSTANCE_ID.test(instanceID)) return false;
if (type === "lineup.v1.ui.open") { if (type === "lineup.v1.ui.open") {
if (!object(payload.app)) return false; // Surface code is strictly host-local in M2. A wire message may identify
// an app/version and state, but can never inject HTML/CSS/JS or a bundle
// location. Exact enablement is checked by the M2 Instance Manager.
if (hasSurfaceBundleOverride(payload)) return false;
const app = object(payload.app);
if (!app || Object.keys(app).some(field => field !== "app_id" && field !== "version")) return false;
const appID = text(app.app_id);
const version = text(app.version);
if (!SURFACE_APP_ID.test(appID) || appID.length > 96 || !SURFACE_VERSION.test(version) || version.length > 64) return false;
return payload.state === undefined || object(payload.state) !== null; return payload.state === undefined || object(payload.state) !== null;
} }
if (type === "lineup.v1.ui.patch") return object(payload.state) !== null; if (type === "lineup.v1.ui.patch") return object(payload.state) !== null;
return type === "lineup.v1.ui.close"; return type === "lineup.v1.ui.close";
} }
function hasSurfaceBundleOverride(payload: JsonObject): boolean {
return ["bundle", "bundle_id", "bundle_url", "url", "html", "css", "javascript", "js", "script", "source"].some(field => field in payload);
}
function validAppCallPayload(payload: JsonObject): boolean { function validAppCallPayload(payload: JsonObject): boolean {
return Boolean(identifier(payload.call_id) && identifier(payload.capability) && object(payload.arguments)); return Boolean(identifier(payload.call_id) && identifier(payload.capability) && object(payload.arguments));
} }
+23 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { parseEnvelopeJSON } from "../protocol"; import { decodeConversationItem, parseEnvelopeJSON } from "../protocol";
import { InteractionKernel } from "./interaction-kernel"; import { InteractionKernel } from "./interaction-kernel";
function envelope(overrides: Record<string, unknown> = {}): string { function envelope(overrides: Record<string, unknown> = {}): string {
@@ -195,4 +195,26 @@ describe("InteractionKernel", () => {
expect(kernel.requestTaskCancel("task_kernel_001", "2026-08-03T09:05:00.000Z")).toMatchObject({ disposition: "accepted" }); expect(kernel.requestTaskCancel("task_kernel_001", "2026-08-03T09:05:00.000Z")).toMatchObject({ disposition: "accepted" });
expect(kernel.snapshot().tasks).toEqual([expect.objectContaining({ cancel_requested_at: "2026-08-03T09:05:00.000Z" })]); expect(kernel.snapshot().tasks).toEqual([expect.objectContaining({ cancel_requested_at: "2026-08-03T09:05:00.000Z" })]);
}); });
it("admits only app identity and state for a Surface open, never an Agent-provided bundle", () => {
const safe = decodeConversationItem(envelope({
id: "evt_surface_safe",
type: "lineup.v1.ui.open",
payload: { instance_id: "task:001", app: { app_id: "lineup.task-dashboard", version: "0.1.0" }, state: { title: "Build" } },
}));
const injectedBundle = decodeConversationItem(envelope({
id: "evt_surface_bundle",
type: "lineup.v1.ui.open",
payload: { instance_id: "task:002", app: { app_id: "lineup.task-dashboard", version: "0.1.0" }, bundle_url: "https://example.invalid/app.js" },
}));
const extraAppField = decodeConversationItem(envelope({
id: "evt_surface_extra_app",
type: "lineup.v1.ui.open",
payload: { instance_id: "task:003", app: { app_id: "lineup.task-dashboard", version: "0.1.0", source: "injected" } },
}));
expect(safe).toEqual(expect.objectContaining({ kind: "surface", id: "evt_surface_safe" }));
expect(injectedBundle).toEqual(expect.objectContaining({ kind: "fallback", reason: "invalid_payload" }));
expect(extraAppField).toEqual(expect.objectContaining({ kind: "fallback", reason: "invalid_payload" }));
});
}); });
@@ -0,0 +1,20 @@
import { describe, expect, it } from "vitest";
import { parseReadyMessage, surfaceDocument } from "./isolated-surface-host";
describe("isolated Surface bridge contract", () => {
it("accepts only the exact minimal ready message", () => {
expect(parseReadyMessage({ v: 1, type: "lineup.surface.v1.ready", instance_id: "task:001" })).toEqual({ v: 1, type: "lineup.surface.v1.ready", instance_id: "task:001" });
expect(parseReadyMessage({ v: 1, type: "lineup.surface.v1.ready", instance_id: "task:001", state: {} })).toBeUndefined();
expect(parseReadyMessage({ v: 1, type: "lineup.surface.v1.event", instance_id: "task:001" })).toBeUndefined();
expect(parseReadyMessage({ v: 1, type: "lineup.surface.v1.ready", instance_id: "<script>" })).toBeUndefined();
});
it("builds a host-local opaque-sandbox document with a default-deny CSP", () => {
const document = surfaceDocument("task:001");
expect(document).toContain("default-src 'none'");
expect(document).toContain("connect-src 'none'");
expect(document).toContain("base-uri 'none'");
expect(document).toContain("lineup.surface.v1.ready");
expect(document).not.toContain("https://");
});
});
+124
View File
@@ -0,0 +1,124 @@
import type { JsonObject } from "../protocol";
import type { SurfaceInstance } from "./surface-instance-manager";
export type SurfaceHostCallbacks = Readonly<{
ready: (instanceID: string) => void;
event: (instanceID: string, event: "cancel") => void;
}>;
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) {
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;
}
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", "受限扩展界面");
// srcdoc comes only from this module's static source. No Envelope field,
// 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 };
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 }, "*");
}
}
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>`;
}
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;
}
@@ -0,0 +1,68 @@
import { describe, expect, it } from "vitest";
import { SurfaceInstanceManager } from "./surface-instance-manager";
import { SurfaceRegistry } from "./surface-registry";
const APP = "lineup.task-dashboard";
const VERSION = "0.1.0";
const CONVERSATION = "conversation_surface_001";
const OPENED = "2026-08-03T10:30:00.000Z";
const UPDATED = "2026-08-03T10:31:00.000Z";
function createManager(): { registry: SurfaceRegistry; manager: SurfaceInstanceManager } {
const registry = new SurfaceRegistry();
registry.enable(APP, VERSION, OPENED);
return { registry, manager: new SurfaceInstanceManager(registry) };
}
function openPayload(state: object = { title: "Build", percent: 10 }): object {
return { instance_id: "task:001", app: { app_id: APP, version: VERSION }, state };
}
describe("SurfaceInstanceManager", () => {
it("opens only an explicitly enabled local app and makes same-instance open idempotent", () => {
const { manager } = createManager();
expect(manager.open(openPayload(), CONVERSATION, OPENED)).toMatchObject({ disposition: "accepted", instance: { phase: "opening", state: { title: "Build", percent: 10 } } });
expect(manager.open(openPayload({ title: "Ignored patch" }), CONVERSATION, UPDATED)).toMatchObject({ disposition: "duplicate", instance: { state: { title: "Build", percent: 10 } } });
});
it("rejects an unenabled app, an app-version mismatch, and injected bundle fields", () => {
const registry = new SurfaceRegistry();
const manager = new SurfaceInstanceManager(registry);
expect(manager.open(openPayload(), CONVERSATION, OPENED).disposition).toBe("disabled");
registry.enable(APP, VERSION, OPENED);
expect(manager.open({ ...openPayload(), app: { app_id: APP, version: "1.0.0" } }, CONVERSATION, OPENED).disposition).toBe("version_mismatch");
expect(manager.open({ ...openPayload(), bundle: "<script>" }, CONVERSATION, OPENED).disposition).toBe("bundle_override_forbidden");
});
it("moves to ready once and permits only state replacement through patch", () => {
const { manager } = createManager();
manager.open(openPayload(), CONVERSATION, OPENED);
expect(manager.ready("task:001", UPDATED)).toMatchObject({ disposition: "accepted", instance: { phase: "ready" } });
expect(manager.ready("task:001", UPDATED).disposition).toBe("duplicate");
expect(manager.patch("task:001", { title: "Build", percent: 60 }, UPDATED)).toMatchObject({ disposition: "accepted", instance: { state: { title: "Build", percent: 60 }, app_id: APP, version: VERSION } });
expect(manager.patch("task:001", { date: new Date() }, UPDATED).disposition).toBe("invalid_state");
});
it("closes instances and never restores disabled, malformed, or unknown snapshots", () => {
const { registry, manager } = createManager();
manager.open(openPayload(), CONVERSATION, OPENED);
const snapshot = manager.snapshot();
expect(manager.close("task:001").disposition).toBe("accepted");
expect(manager.get("task:001")).toBeUndefined();
manager.restore(snapshot);
expect(manager.get("task:001")).toMatchObject({ phase: "opening", state: { title: "Build", percent: 10 } });
registry.disable(APP, VERSION);
manager.restore(snapshot);
expect(manager.snapshot().instances).toEqual([]);
manager.restore({ version: 1, instances: [{ instance_id: "bad", conversation_id: CONVERSATION, app_id: "unknown.surface", version: VERSION, state: {}, phase: "ready", opened_at: OPENED, updated_at: UPDATED }] });
expect(manager.snapshot().instances).toEqual([]);
});
it("treats a conflicting reuse of an instance id as a safe no-op", () => {
const { manager } = createManager();
manager.open(openPayload(), CONVERSATION, OPENED);
expect(manager.open(openPayload(), "another_conversation", UPDATED)).toMatchObject({ disposition: "conflict", instance: { conversation_id: CONVERSATION } });
expect(manager.patch("missing", {}, UPDATED).disposition).toBe("missing");
expect(manager.close("missing").disposition).toBe("missing");
});
});
@@ -0,0 +1,171 @@
import type { JsonObject } from "../protocol";
import {
parseSurfaceOpenRequest,
type SurfaceRegistry,
type SurfaceRegistryDisposition,
} from "./surface-registry";
export type SurfaceInstancePhase = "opening" | "ready";
export type SurfaceInstance = Readonly<{
instance_id: string;
conversation_id: string;
app_id: string;
version: string;
state: JsonObject;
phase: SurfaceInstancePhase;
opened_at: string;
updated_at: string;
}>;
type MutableSurfaceInstance = { -readonly [Field in keyof SurfaceInstance]: SurfaceInstance[Field] };
export type SurfaceInstanceTransition = Readonly<{
disposition:
| "accepted"
| "duplicate"
| "missing"
| "conflict"
| "invalid_request"
| SurfaceRegistryDisposition;
instance?: SurfaceInstance;
}>;
export type SurfaceInstanceSnapshot = Readonly<{
version: 1;
instances: readonly SurfaceInstance[];
}>;
/**
* Lifecycle-only M2-02 manager. It never creates a frame, executes Surface
* code, forwards events, persists state, or grants capabilities. A host can
* persist its snapshot and hand the restored records back after App Center is
* initialized.
*/
export class SurfaceInstanceManager {
private readonly instances = new Map<string, MutableSurfaceInstance>();
public constructor(private readonly registry: SurfaceRegistry) {}
/**
* A repeated open is deliberately a no-op. The first accepted state wins;
* later changes must use patch, so an Agent cannot smuggle a state update in
* a retry of an existing instance id.
*/
public open(payload: unknown, conversationID: string, now: string): SurfaceInstanceTransition {
if (!validConversationID(conversationID) || !validTimestamp(now)) return { disposition: "invalid_request" };
const request = parseSurfaceOpenRequest(payload);
if (!request) return { disposition: hasBundleField(payload) ? "bundle_override_forbidden" : "invalid_request" };
const current = this.instances.get(request.instance_id);
if (current) {
return current.app_id === request.app.app_id && current.version === request.app.version && current.conversation_id === conversationID
? { disposition: "duplicate", instance: copy(current) }
: { disposition: "conflict", instance: copy(current) };
}
const validation = this.registry.validateOpenRequest(payload);
if (validation.disposition !== "accepted") return validation;
const instance: MutableSurfaceInstance = {
instance_id: request.instance_id,
conversation_id: conversationID,
app_id: request.app.app_id,
version: request.app.version,
state: clone(request.state ?? {}),
phase: "opening",
opened_at: now,
updated_at: now,
};
this.instances.set(instance.instance_id, instance);
return { disposition: "accepted", instance: copy(instance) };
}
/** A frame may acknowledge readiness once; duplicate acknowledgements are harmless. */
public ready(instanceID: string, now: string): SurfaceInstanceTransition {
const instance = this.instances.get(instanceID);
if (!instance) return { disposition: "missing" };
if (!validTimestamp(now)) return { disposition: "invalid_request", instance: copy(instance) };
if (instance.phase === "ready") return { disposition: "duplicate", instance: copy(instance) };
instance.phase = "ready";
instance.updated_at = now;
return { disposition: "accepted", instance: copy(instance) };
}
/** Patch may replace state only; app identity and bundle are immutable after open. */
public patch(instanceID: string, state: unknown, now: string): SurfaceInstanceTransition {
const instance = this.instances.get(instanceID);
if (!instance) return { disposition: "missing" };
if (!validTimestamp(now)) return { disposition: "invalid_request", instance: copy(instance) };
const validation = this.registry.validateState(instance.app_id, instance.version, state);
if (validation.disposition !== "accepted") return validation;
instance.state = clone(state as JsonObject);
instance.updated_at = now;
return { disposition: "accepted", instance: copy(instance) };
}
/** Closing is terminal for this manager snapshot; a later open starts fresh. */
public close(instanceID: string): SurfaceInstanceTransition {
const instance = this.instances.get(instanceID);
if (!instance) return { disposition: "missing" };
this.instances.delete(instanceID);
return { disposition: "accepted", instance: copy(instance) };
}
public get(instanceID: string): SurfaceInstance | undefined {
const instance = this.instances.get(instanceID);
return instance ? copy(instance) : undefined;
}
public snapshot(): SurfaceInstanceSnapshot {
return {
version: 1,
instances: [...this.instances.values()]
.sort((left, right) => left.instance_id.localeCompare(right.instance_id))
.map(copy),
};
}
/**
* Restore is intentionally stricter than ordinary open: it accepts only
* valid, currently enabled local manifests. A disabled or upgraded app
* cannot reappear after a reload through an old persisted snapshot.
*/
public restore(snapshot: unknown): void {
this.instances.clear();
if (!isPlainObject(snapshot) || snapshot.version !== 1 || !Array.isArray(snapshot.instances)) return;
for (const raw of snapshot.instances) {
if (!isPlainObject(raw) || !validConversationID(raw.conversation_id) || !validTimestamp(raw.opened_at)
|| !validTimestamp(raw.updated_at) || (raw.phase !== "opening" && raw.phase !== "ready")) continue;
const opened = this.open({
instance_id: raw.instance_id,
app: { app_id: raw.app_id, version: raw.version },
state: raw.state,
}, raw.conversation_id, raw.opened_at);
if (opened.disposition !== "accepted") continue;
const instance = this.instances.get(raw.instance_id as string);
if (!instance) continue;
instance.phase = raw.phase;
instance.updated_at = raw.updated_at;
}
}
}
function validConversationID(value: unknown): value is string {
return typeof value === "string" && value.trim().length > 0 && value.length <= 256;
}
function validTimestamp(value: unknown): value is string {
return typeof value === "string" && !Number.isNaN(Date.parse(value));
}
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 hasBundleField(value: unknown): boolean {
if (!isPlainObject(value)) return false;
return ["bundle", "bundle_id", "bundle_url", "url", "html", "css", "javascript", "js", "script", "source"].some(field => field in value);
}
function clone<T>(value: T): T { return JSON.parse(JSON.stringify(value)) as T; }
function copy(instance: SurfaceInstance): SurfaceInstance { return { ...instance, state: clone(instance.state) }; }
@@ -0,0 +1,86 @@
import { describe, expect, it } from "vitest";
import {
DEVELOPMENT_SURFACE_MANIFESTS,
SurfaceRegistry,
parseSurfaceOpenRequest,
} from "./surface-registry";
const APP = "lineup.task-dashboard";
const VERSION = "0.1.0";
const TIME = "2026-08-03T10:00:00.000Z";
describe("SurfaceRegistry", () => {
it("requires explicit local enablement and an exact locally declared version", () => {
const registry = new SurfaceRegistry();
expect(registry.resolveEnabled(APP, VERSION).disposition).toBe("disabled");
expect(registry.enable(APP, VERSION, TIME)).toMatchObject({ disposition: "accepted", manifest: DEVELOPMENT_SURFACE_MANIFESTS[0] });
expect(registry.resolveEnabled(APP, VERSION)).toMatchObject({ disposition: "accepted", manifest: DEVELOPMENT_SURFACE_MANIFESTS[0] });
expect(registry.resolveEnabled(APP, "0.1.1").disposition).toBe("version_mismatch");
expect(registry.resolveEnabled("unknown.surface", VERSION).disposition).toBe("unknown_app");
});
it("immediately removes an app from the openable local set when disabled", () => {
const registry = new SurfaceRegistry();
registry.enable(APP, VERSION, TIME);
expect(registry.disable(APP, VERSION).disposition).toBe("accepted");
expect(registry.resolveEnabled(APP, VERSION).disposition).toBe("disabled");
});
it("only restores valid entries for locally known versions", () => {
const registry = new SurfaceRegistry();
registry.restore({
version: 1,
enabled: [
{ app_id: APP, version: VERSION, enabled_at: TIME },
{ app_id: APP, version: "9.9.9", enabled_at: TIME },
{ app_id: "unknown.surface", version: VERSION, enabled_at: TIME },
{ app_id: APP, version: VERSION, enabled_at: "not-a-time" },
],
});
expect(registry.snapshot()).toEqual({ version: 1, enabled: [{ app_id: APP, version: VERSION, enabled_at: TIME }] });
});
it("rejects malformed development declarations instead of silently enabling them", () => {
const registry = new SurfaceRegistry([{
...DEVELOPMENT_SURFACE_MANIFESTS[0],
app_id: "bad app id",
}]);
expect(registry.enable(APP, VERSION, TIME).disposition).toBe("unknown_app");
});
it("bounds state shape, nesting, keys, and encoded bytes before it is retained", () => {
const registry = new SurfaceRegistry();
registry.enable(APP, VERSION, TIME);
expect(registry.validateState(APP, VERSION, { title: "A", steps: [{ id: "one" }] }).disposition).toBe("accepted");
expect(registry.validateState(APP, VERSION, { deep: { a: { b: { c: { d: { e: { f: { g: { h: { i: { j: { k: { l: { m: { n: { o: { p: { q: "too deep" } } } } } } } } } } } } } } } } } }).disposition).toBe("invalid_state");
expect(registry.validateState(APP, VERSION, { payload: "x".repeat(40_000) }).disposition).toBe("state_too_large");
expect(registry.validateState(APP, VERSION, { date: new Date() }).disposition).toBe("invalid_state");
});
it("refuses Agent supplied replacement bundles and accepts only a bounded open request", () => {
const registry = new SurfaceRegistry();
registry.enable(APP, VERSION, TIME);
expect(registry.validateOpenRequest({
instance_id: "task:001",
app: { app_id: APP, version: VERSION },
state: { title: "Build" },
bundle_url: "https://example.invalid/surface.js",
}).disposition).toBe("bundle_override_forbidden");
expect(registry.validateOpenRequest({
instance_id: "task:001",
app: { app_id: APP, version: VERSION },
state: { title: "Build" },
}).disposition).toBe("accepted");
expect(parseSurfaceOpenRequest({
instance_id: "task:001",
app: { app_id: APP, version: VERSION },
state: { title: "Build" },
})).toEqual({ instance_id: "task:001", app: { app_id: APP, version: VERSION }, state: { title: "Build" } });
});
});
+242
View File
@@ -0,0 +1,242 @@
import type { JsonObject } from "../protocol";
/**
* M2-01 development-time Surface declaration. These records are authored by
* the host, bundled with it, and never accepted from a conversation payload.
* Remote bundle URLs, hashes and downloads deliberately belong to M4.
*/
export type SurfaceManifest = Readonly<{
app_id: string;
version: string;
bundle_id: string;
state_schema_version: number;
max_state_bytes: number;
max_bundle_bytes: number;
local_bundle_bytes: number;
development_only: true;
}>;
export type EnabledSurface = Readonly<{
app_id: string;
version: string;
enabled_at: string;
}>;
export type SurfaceRegistryDisposition =
| "accepted"
| "unknown_app"
| "version_mismatch"
| "disabled"
| "invalid_manifest"
| "invalid_state"
| "state_too_large"
| "bundle_override_forbidden";
export type SurfaceRegistryResult = Readonly<{
disposition: SurfaceRegistryDisposition;
manifest?: SurfaceManifest;
}>;
export type SurfaceRegistrySnapshot = Readonly<{
version: 1;
enabled: readonly EnabledSurface[];
}>;
export type SurfaceOpenRequest = Readonly<{
instance_id: string;
app: Readonly<{ app_id: string; version: string }>;
state?: JsonObject;
}>;
const APP_ID = /^[a-z][a-z0-9]*(?:[.-][a-z0-9]+)*$/;
const VERSION = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
const BUNDLE_ID = /^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$/;
const INSTANCE_ID = /^[A-Za-z0-9._:-]{1,128}$/;
const MAX_APP_ID_LENGTH = 96;
const MAX_VERSION_LENGTH = 64;
const MAX_BUNDLE_ID_LENGTH = 128;
const MAX_STATE_DEPTH = 16;
const MAX_STATE_KEYS = 512;
const MAX_DECLARED_STATE_BYTES = 64 * 1024;
const MAX_DECLARED_BUNDLE_BYTES = 512 * 1024;
/**
* The sole surface known in the development build. Declaring it here does
* not make it visible to an Agent or openable: App Center must explicitly
* enable this exact app/version first.
*/
export const DEVELOPMENT_SURFACE_MANIFESTS: readonly SurfaceManifest[] = Object.freeze([
Object.freeze({
app_id: "lineup.task-dashboard",
version: "0.1.0",
bundle_id: "lineup.task-dashboard.dev.v1",
state_schema_version: 1,
max_state_bytes: 32 * 1024,
max_bundle_bytes: 256 * 1024,
local_bundle_bytes: 0,
development_only: true,
}),
]);
/**
* Local App Center enablement boundary. It is intentionally free of DOM,
* storage, transport, iframe and capability concerns so it can be persisted
* by a future host adapter without changing its security rules.
*/
export class SurfaceRegistry {
private readonly manifests = new Map<string, SurfaceManifest>();
private readonly enabled = new Map<string, EnabledSurface>();
public constructor(manifests: readonly SurfaceManifest[] = DEVELOPMENT_SURFACE_MANIFESTS) {
for (const candidate of manifests) {
const manifest = normalizeManifest(candidate);
if (!manifest || this.manifests.has(key(manifest.app_id, manifest.version))) continue;
this.manifests.set(key(manifest.app_id, manifest.version), manifest);
}
}
public enable(appID: string, version: string, enabledAt: string): SurfaceRegistryResult {
const result = this.find(appID, version);
if (result.disposition !== "accepted" || !result.manifest || !validTimestamp(enabledAt)) return result.disposition === "accepted"
? { disposition: "invalid_manifest" }
: result;
this.enabled.set(key(appID, version), { app_id: appID, version, enabled_at: enabledAt });
return result;
}
public disable(appID: string, version: string): SurfaceRegistryResult {
const result = this.find(appID, version);
if (result.disposition !== "accepted") return result;
this.enabled.delete(key(appID, version));
return result;
}
public isEnabled(appID: string, version: string): boolean {
return this.enabled.has(key(appID, version));
}
/** Resolves only an exact, locally enabled declaration; no inventory leaks. */
public resolveEnabled(appID: string, version: string): SurfaceRegistryResult {
const result = this.find(appID, version);
if (result.disposition !== "accepted") return result;
return this.enabled.has(key(appID, version)) ? result : { disposition: "disabled" };
}
/** Validates a trusted host state before Instance Manager can retain it. */
public validateState(appID: string, version: string, state: unknown): SurfaceRegistryResult {
const result = this.resolveEnabled(appID, version);
if (result.disposition !== "accepted" || !result.manifest) return result;
const measured = measureState(state);
if (!measured.valid) return { disposition: "invalid_state" };
return measured.bytes <= result.manifest.max_state_bytes
? result
: { disposition: "state_too_large" };
}
/**
* Rejects payload attempts to replace the locally packaged bundle before
* they can reach Instance Manager. This validates shape only; M2-02 owns
* lifecycle semantics and M2-04 owns the dashboard state schema.
*/
public validateOpenRequest(payload: unknown): SurfaceRegistryResult {
if (!isPlainObject(payload)) return { disposition: "invalid_state" };
if (hasBundleOverride(payload)) return { disposition: "bundle_override_forbidden" };
const instanceID = payload.instance_id;
const app = payload.app;
if (typeof instanceID !== "string" || !INSTANCE_ID.test(instanceID) || !isPlainObject(app)
|| !validAppID(app.app_id) || !validVersion(app.version)) return { disposition: "invalid_state" };
if (payload.state !== undefined && !isPlainObject(payload.state)) return { disposition: "invalid_state" };
return this.validateState(app.app_id, app.version, payload.state ?? {});
}
public snapshot(): SurfaceRegistrySnapshot {
return {
version: 1,
enabled: [...this.enabled.values()]
.sort((left, right) => key(left.app_id, left.version).localeCompare(key(right.app_id, right.version)))
.map(entry => ({ ...entry })),
};
}
/** Restoring never adds an unknown, mismatched, malformed or duplicate app. */
public restore(snapshot: unknown): void {
this.enabled.clear();
if (!isPlainObject(snapshot) || snapshot.version !== 1 || !Array.isArray(snapshot.enabled)) return;
for (const raw of snapshot.enabled) {
if (!isPlainObject(raw) || !validAppID(raw.app_id) || !validVersion(raw.version) || !validTimestamp(raw.enabled_at)) continue;
const result = this.find(raw.app_id, raw.version);
if (result.disposition !== "accepted") continue;
this.enabled.set(key(raw.app_id, raw.version), { app_id: raw.app_id, version: raw.version, enabled_at: raw.enabled_at });
}
}
private find(appID: string, version: string): SurfaceRegistryResult {
if (!validAppID(appID)) return { disposition: "unknown_app" };
const matchingApp = [...this.manifests.values()].some(manifest => manifest.app_id === appID);
if (!matchingApp) return { disposition: "unknown_app" };
if (!validVersion(version) || !this.manifests.has(key(appID, version))) return { disposition: "version_mismatch" };
return { disposition: "accepted", manifest: this.manifests.get(key(appID, version))! };
}
}
function normalizeManifest(value: SurfaceManifest): SurfaceManifest | undefined {
if (!value || typeof value !== "object" || !validAppID(value.app_id) || !validVersion(value.version)
|| !validBundleID(value.bundle_id) || !Number.isSafeInteger(value.state_schema_version) || value.state_schema_version < 1
|| !validBound(value.max_state_bytes, MAX_DECLARED_STATE_BYTES) || !validBound(value.max_bundle_bytes, MAX_DECLARED_BUNDLE_BYTES)
|| !Number.isSafeInteger(value.local_bundle_bytes) || value.local_bundle_bytes < 0 || value.local_bundle_bytes > value.max_bundle_bytes
|| value.development_only !== true) return undefined;
return Object.freeze({ ...value });
}
function validAppID(value: unknown): value is string { return typeof value === "string" && value.length <= MAX_APP_ID_LENGTH && APP_ID.test(value); }
function validVersion(value: unknown): value is string { return typeof value === "string" && value.length <= MAX_VERSION_LENGTH && VERSION.test(value); }
function validBundleID(value: unknown): value is string { return typeof value === "string" && value.length <= MAX_BUNDLE_ID_LENGTH && BUNDLE_ID.test(value); }
function validBound(value: unknown, maximum: number): value is number { return typeof value === "number" && Number.isSafeInteger(value) && value > 0 && value <= maximum; }
function validTimestamp(value: unknown): value is string { return typeof value === "string" && !Number.isNaN(Date.parse(value)); }
function key(appID: string, version: string): string { return `${appID}@${version}`; }
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 hasBundleOverride(payload: Record<string, unknown>): boolean {
return ["bundle", "bundle_id", "bundle_url", "url", "html", "css", "javascript", "js", "script", "source"].some(field => field in payload);
}
function measureState(value: unknown): { valid: boolean; bytes: number } {
let keys = 0;
const visit = (candidate: unknown, depth: number): boolean => {
if (depth > MAX_STATE_DEPTH || candidate === null || typeof candidate === "string" || typeof candidate === "boolean") return depth <= MAX_STATE_DEPTH;
if (typeof candidate === "number") return Number.isFinite(candidate);
if (Array.isArray(candidate)) return candidate.every(entry => visit(entry, depth + 1));
if (!isPlainObject(candidate)) return false;
const entries = Object.entries(candidate);
keys += entries.length;
return keys <= MAX_STATE_KEYS && entries.every(([entryKey, entryValue]) => entryKey.length <= 128 && visit(entryValue, depth + 1));
};
if (!isPlainObject(value) || !visit(value, 0)) return { valid: false, bytes: 0 };
try {
return { valid: true, bytes: new TextEncoder().encode(JSON.stringify(value as JsonObject)).byteLength };
} catch {
return { valid: false, bytes: 0 };
}
}
/** A narrow helper for the M2-02 manager; it does not mutate registry state. */
export function parseSurfaceOpenRequest(payload: unknown): SurfaceOpenRequest | undefined {
if (!isPlainObject(payload) || hasBundleOverride(payload) || typeof payload.instance_id !== "string" || !INSTANCE_ID.test(payload.instance_id)
|| !isPlainObject(payload.app) || !validAppID(payload.app.app_id) || !validVersion(payload.app.version)
|| (payload.state !== undefined && !isPlainObject(payload.state))) return undefined;
return {
instance_id: payload.instance_id,
app: { app_id: payload.app.app_id, version: payload.app.version },
...(payload.state === undefined ? {} : { state: payload.state as JsonObject }),
};
}
/** Exposed only for focused state-limit tests; no renderer should need it. */
export function isSurfaceStateJson(value: unknown): value is JsonObject {
return measureState(value).valid && isPlainObject(value);
}