feat(runtime): isolate bundled miniapps behind bridge
This commit is contained in:
@@ -237,6 +237,27 @@ describe("LineUpRuntime", () => {
|
||||
expect(transport.sent.filter(entry => entry.payload.includes("lineup.v1.miniapp.tool.result"))).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("isolates bundled MiniApp Inbox subscriptions by instance and does not expose global workspace", async () => {
|
||||
const conversationID = "user_1:2:channel_1";
|
||||
const runtime = new LineUpRuntime({ storage: new MemoryStorage(), createTransport: () => new FakeTransport() });
|
||||
await runtime.login(login);
|
||||
const first = runtime.lifecycle.start({ app_scope: "task-dashboard", instance_id: "task-dashboard:first", app_session_id: "task-dashboard:first-session", conversation_id: conversationID }, "2026-08-04T00:00:00.000Z");
|
||||
const second = runtime.lifecycle.start({ app_scope: "task-dashboard", instance_id: "task-dashboard:second", app_session_id: "task-dashboard:second-session", conversation_id: conversationID }, "2026-08-04T00:00:00.000Z");
|
||||
runtime.lifecycle.foreground(first.instance_id, "2026-08-04T00:00:00.000Z");
|
||||
runtime.lifecycle.foreground(second.instance_id, "2026-08-04T00:00:00.000Z");
|
||||
const sdk = runtime.openBundledMiniApp("task-dashboard", first.instance_id);
|
||||
const received: string[] = [];
|
||||
sdk.inbox.subscribe(message => received.push(message.message_id));
|
||||
const store = (runtime as unknown as { store: { enqueueAppInbox: (entry: unknown) => boolean } }).store;
|
||||
store.enqueueAppInbox({ message_id: "first-message", app_scope: "task-dashboard", instance_id: first.instance_id, conversation_id: conversationID, item: { kind: "markdown", id: "first-message", markdown: "first" }, received_at: "2026-08-04T00:00:00.000Z" });
|
||||
store.enqueueAppInbox({ message_id: "second-message", app_scope: "task-dashboard", instance_id: second.instance_id, conversation_id: conversationID, item: { kind: "markdown", id: "second-message", markdown: "second" }, received_at: "2026-08-04T00:00:00.000Z" });
|
||||
const emit = (runtime as unknown as { emit: (event: unknown) => void }).emit.bind(runtime);
|
||||
emit({ type: "agent-message", message: { message_id: "first-message", scope: { app_scope: "task-dashboard", conversation_id: conversationID }, item: { kind: "markdown", id: "first-message", markdown: "first" }, received_at: "2026-08-04T00:00:00.000Z" } });
|
||||
emit({ type: "agent-message", message: { message_id: "second-message", scope: { app_scope: "task-dashboard", conversation_id: conversationID }, item: { kind: "markdown", id: "second-message", markdown: "second" }, received_at: "2026-08-04T00:00:00.000Z" } });
|
||||
expect(received).toEqual(["first-message"]);
|
||||
expect("workspace" in sdk).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps bundled Surface requests local and scopes them to the current instance", async () => {
|
||||
const conversationID = "user_1:2:channel_1";
|
||||
const launch = JSON.stringify({
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user