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 { 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
@@ -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));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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://");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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