feat(m2): ship isolated task dashboard surface
This commit is contained in:
+80
-7
@@ -13,6 +13,9 @@ import { InteractionKernel } from "./runtime/interaction-kernel";
|
||||
import { RendererRegistry } from "./runtime/renderer-registry";
|
||||
import { HttpTransportAdapter, type TransportAdapter } from "./runtime/transport-adapter";
|
||||
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 = {
|
||||
api: string;
|
||||
@@ -32,6 +35,10 @@ let flushingOutbox = false;
|
||||
const interactionKernel = new InteractionKernel();
|
||||
const conversationStore = new ConversationStore(localStorage);
|
||||
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");
|
||||
if (!app) throw new Error("LineUp host root is missing");
|
||||
@@ -49,7 +56,7 @@ app.innerHTML = `
|
||||
</form>
|
||||
</section>
|
||||
<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>
|
||||
<form id="message-form" class="composer"><textarea id="message-input" rows="1" placeholder="问问你的 AI 搭档…"></textarea><button id="send" type="submit">发送</button></form>
|
||||
</section>
|
||||
@@ -64,6 +71,10 @@ const messages = $<HTMLElement>("#messages");
|
||||
const loginView = $<HTMLElement>("#login-view");
|
||||
const chatView = $<HTMLElement>("#chat-view");
|
||||
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;
|
||||
|
||||
function newLocalID(prefix: string): string {
|
||||
@@ -91,11 +102,12 @@ function normalizeAPIAddress(value: string): string {
|
||||
return parsed.toString().replace(/\/$/, "");
|
||||
}
|
||||
|
||||
function loginFailureMessage(reason: unknown): string {
|
||||
if (reason instanceof Error && reason.message === "LINEUP_LOGIN_TIMEOUT") return "连接 AppServer 超时(10 秒)。请确认 Tailscale 已连接,并核对上方显示的服务地址。";
|
||||
if (reason instanceof DOMException && reason.name === "AbortError") return "连接 AppServer 超时(10 秒)。请确认 Tailscale 已连接,且服务地址和端口可访问。";
|
||||
function loginFailureMessage(reason: unknown, endpoint?: string): string {
|
||||
const destination = endpoint ? ` ${endpoint}` : "上方填写的服务地址";
|
||||
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 : "";
|
||||
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 || "登录失败,请稍后重试。";
|
||||
}
|
||||
|
||||
@@ -150,9 +162,57 @@ rendererRegistry.register("app-call", (item, context) => context.renderAppCall(i
|
||||
rendererRegistry.register("fallback", (item, context) => context.renderFallback(item));
|
||||
|
||||
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 的内容。");
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* 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();
|
||||
}
|
||||
|
||||
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> {
|
||||
if (flushingOutbox || !session.active || !transport || !session.uid) return;
|
||||
flushingOutbox = true;
|
||||
@@ -281,7 +352,7 @@ async function syncLoop(): Promise<void> {
|
||||
const payload = decodeBase64(message.payload);
|
||||
if (message.from_uid === session.uid) {
|
||||
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
|
||||
// rendered as raw JSON user messages or replayed as a new action.
|
||||
continue;
|
||||
@@ -350,6 +421,7 @@ $<HTMLFormElement>("#login-form").addEventListener("submit", async event => {
|
||||
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 };
|
||||
const snapshot = conversationStore.open(currentConversationKey());
|
||||
surfaceInstances.restore(snapshot.surfaces);
|
||||
interactionKernel.restoreToolCalls(snapshot.tool_calls, new Date().toISOString());
|
||||
interactionKernel.restoreTasks(snapshot.tasks);
|
||||
executionProgress.restore(snapshot.execution_summaries);
|
||||
@@ -364,9 +436,10 @@ $<HTMLFormElement>("#login-form").addEventListener("submit", async event => {
|
||||
for (const stored of snapshot.items) {
|
||||
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.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"; }
|
||||
});
|
||||
|
||||
|
||||
+15
-1
@@ -217,6 +217,8 @@ const SUPPORTED_TYPES = new Set([
|
||||
]);
|
||||
const ID_MAX_LENGTH = 256;
|
||||
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]$/;
|
||||
|
||||
function object(value: unknown): JsonObject | null {
|
||||
@@ -313,13 +315,25 @@ function validSurfacePayload(type: string, payload: JsonObject): boolean {
|
||||
const instanceID = text(payload.instance_id);
|
||||
if (!INSTANCE_ID.test(instanceID)) return false;
|
||||
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;
|
||||
}
|
||||
if (type === "lineup.v1.ui.patch") return object(payload.state) !== null;
|
||||
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 {
|
||||
return Boolean(identifier(payload.call_id) && identifier(payload.capability) && object(payload.arguments));
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseEnvelopeJSON } from "../protocol";
|
||||
import { decodeConversationItem, parseEnvelopeJSON } from "../protocol";
|
||||
import { InteractionKernel } from "./interaction-kernel";
|
||||
|
||||
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.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://");
|
||||
});
|
||||
});
|
||||
@@ -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" } });
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user