feat(runtime): isolate bundled miniapps behind bridge

This commit is contained in:
2026-08-06 00:19:36 +08:00
parent a68b7cf3bf
commit 58e77462ad
6 changed files with 280 additions and 28 deletions
@@ -10,6 +10,7 @@ import {
type ConversationItem,
type Envelope,
type JsonObject,
type JsonValue,
type ToolCallRequest,
type MiniAppToolInvoke,
} from "@/runtime/protocol/lineup-v1";
@@ -327,6 +328,9 @@ export class LineUpRuntime {
subscribe: listener => {
const unsubscribe = this.onEvent(event => {
if (event.type !== "agent-message" || event.message.scope.app_scope !== appScope) return;
const inboxEntry = this.store.listAppInbox(appScope, instanceID)
.find(entry => entry.message_id === event.message.message_id);
if (!inboxEntry) return;
listener(event.message);
});
for (const message of scopedMessages()) listener(message);
@@ -379,10 +383,91 @@ export class LineUpRuntime {
capabilities: {
request: (name, reason, input = {}) => this.queueProtocolAction("app-result", "lineup.v1.app.call", { instance_id: instanceID, app_scope: appScope, capability: name, reason, arguments: input, expires_at: new Date(Date.parse(this.now()) + 15 * 60 * 1000).toISOString() }),
},
workspace: () => this.workspaceSnapshot(),
};
}
/** Host-only entry point used by the isolated bundled Surface bootstrap. */
public openBundledSurface(appScope: AppScope, instanceID: string): CommandReceipt {
const state: JsonObject = appScope === "whiteboard"
? { title: "Whiteboard", status: "可编辑", elements: [] }
: { title: "任务面板", status: "等待 Agent 任务", percent: 0, steps: [] };
return this.requestBundledSurface(appScope, instanceID, "open", { state });
}
/**
* The only parent-side endpoint exposed to an isolated bundled MiniApp.
* It returns plain data and CommandReceipt values; Runtime objects never
* cross the iframe boundary.
*/
public async handleMiniAppBridgeRequest(instanceID: string, method: string, payload: JsonObject): Promise<JsonObject> {
const instance = this.lifecycle.instances.get(instanceID);
const conversationID = this.currentConversationID();
if (!this.session.active || !instance || !conversationID || instance.conversation_id !== conversationID
|| !["starting", "foreground", "background", "suspended"].includes(instance.state)) {
throw new Error("invalid_miniapp_instance");
}
const text = (key: string): string | undefined => typeof payload[key] === "string" && String(payload[key]).trim() ? String(payload[key]) : undefined;
const callID = text("call_id");
if (method === "tools.list") {
return { calls: this.miniappTools.list().filter(call => call.instance_id === instanceID && call.conversation_id === conversationID) };
}
if (method === "inbox.list") {
return { messages: JSON.parse(JSON.stringify(this.listAppMessages(instance.app_scope, undefined, instanceID))) as JsonValue[] };
}
if (method === "inbox.acknowledge") {
const messageID = text("message_id");
if (!messageID) throw new Error("invalid_message_id");
return { acknowledged: this.store.acknowledgeAppInbox(instance.app_scope, messageID, instanceID) };
}
if (method === "tools.reportProgress") {
if (!callID || !isMiniAppToolProgress(payload.progress)) throw new Error("invalid_progress");
return this.bridgeReceipt(await this.reportMiniAppToolProgress(instanceID, callID, payload.progress));
}
if (method === "tools.complete") {
if (!callID || !isJsonObject(payload.result)) throw new Error("invalid_tool_result");
return this.bridgeReceipt(await this.completeMiniAppTool(instanceID, callID, payload.result));
}
if (method === "tools.fail") {
if (!callID || typeof payload.message !== "string") throw new Error("invalid_tool_failure");
return this.bridgeReceipt(await this.failMiniAppTool(instanceID, callID, { code: text("code"), message: payload.message }));
}
if (method === "tools.cancel") {
if (!callID) throw new Error("invalid_tool_call");
return this.bridgeReceipt(await this.cancelMiniAppTool(instanceID, callID, text("reason") ?? "app_cancelled"));
}
if (method === "lifecycle.foreground") {
this.lifecycle.foreground(instanceID, this.now()); this.persistWorkspace(); this.emit({ type: "app-lifecycle", workspace: this.workspaceSnapshot(), reason: "launched" });
return { disposition: "accepted" };
}
if (method === "lifecycle.background") {
this.lifecycle.background(instanceID, this.now()); this.persistWorkspace(); this.emit({ type: "app-lifecycle", workspace: this.workspaceSnapshot(), reason: "launched" });
return { disposition: "accepted" };
}
if (method === "lifecycle.close") {
this.closeAppInstance(instanceID, text("reason"));
return { disposition: "accepted" };
}
if (method === "surface.patch" || method === "surface.close") {
const event = method === "surface.patch" ? "patch" : "close";
const data = method === "surface.patch" && isJsonObject(payload.data) ? payload.data : {};
return this.bridgeReceipt(this.requestBundledSurface(instance.app_scope, instanceID, event, data));
}
if (method === "capabilities.request") {
const name = text("name");
const reason = text("reason");
if (!name || !reason || !isJsonObject(payload.input)) throw new Error("invalid_capability_request");
return this.bridgeReceipt(await this.queueProtocolAction("app-result", "lineup.v1.app.call", {
instance_id: instanceID, app_scope: instance.app_scope, capability: name, reason, arguments: payload.input,
expires_at: new Date(Date.parse(this.now()) + 15 * 60 * 1000).toISOString(),
}));
}
throw new Error("unsupported_bridge_method");
}
private bridgeReceipt(receipt: CommandReceipt): JsonObject {
return receipt.disposition === "accepted" ? { disposition: "accepted", ...(receipt.local_id ? { local_id: receipt.local_id } : {}) } : { disposition: "rejected", code: receipt.code };
}
/** @deprecated Use openApp("chat") so App selection remains registry-driven. */
public openChatApp(): ChatRuntimeSDK {
return this.openApp("chat");
@@ -1156,6 +1241,17 @@ function validProgress(progress: MiniAppToolProgress): boolean {
&& (progress.detail === undefined || (typeof progress.detail === "string" && progress.detail.length <= 500));
}
function isMiniAppToolProgress(value: unknown): value is MiniAppToolProgress {
return isJsonObject(value)
&& (value.percent === undefined || (typeof value.percent === "number" && Number.isFinite(value.percent)))
&& (value.status === undefined || typeof value.status === "string")
&& (value.detail === undefined || typeof value.detail === "string");
}
function isJsonObject(value: unknown): value is JsonObject {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
/** Deliberately excludes identities, conversation IDs, message bodies and Tool arguments. */
function runtimeLog(event: string, fields: Record<string, string | number>): void {
console.info("[LineUp Runtime]", event, fields);