diff --git a/tauri/src/main.ts b/tauri/src/main.ts index 15a1829..f30af70 100644 --- a/tauri/src/main.ts +++ b/tauri/src/main.ts @@ -27,8 +27,6 @@ import { CapabilityAuditLog } from "@/runtime/capabilities/capability-audit"; import { CapabilityExecutor, type PickedFile } from "@/runtime/capabilities/capability-executor"; import { LineUpRuntime } from "@/runtime/coordination/lineup-runtime"; import { RuntimeAppHost } from "@/runtime/app-management/runtime-app-host"; -import { TaskDashboardMiniApp } from "@/core-apps/task-dashboard/task-dashboard-miniapp"; -import { WhiteboardMiniApp } from "@/core-apps/whiteboard/whiteboard-miniapp"; // Vite 的开发标记和 M4 测试控制器需要扩展浏览器的全局类型。 declare global { @@ -131,12 +129,15 @@ const { messages, appSessions, loginView, chatView, presence, rendererContext, r const surfaceHost = new IsolatedSurfaceHost(messages, { ready(instanceID) { surfaceInstances.ready(instanceID, new Date().toISOString()); }, event(instanceID) { queueSurfaceCancel(instanceID); }, + async request(instanceID, method, payload) { + return runtime.handleMiniAppBridgeRequest(instanceID, method, payload); + }, }); // These are the two shipped reference MiniApps. They are mounted through the // same restricted SDK as any future bundled MiniApp; this adapter only wires // lifecycle to the SDK object and never hands them Runtime internals. -const bundledMiniApps = new Map void }>(); +const bundledMiniApps = new Set(); function reconcileBundledMiniApps(): void { const active = new Set(); for (const instance of runtime.lifecycle.instances.list(runtime.currentConversationID())) { @@ -144,24 +145,11 @@ function reconcileBundledMiniApps(): void { if (!["starting", "foreground", "background", "suspended"].includes(instance.state)) continue; active.add(instance.instance_id); if (bundledMiniApps.has(instance.instance_id)) continue; - try { - const sdk = runtime.openBundledMiniApp(instance.app_scope, instance.instance_id); - if (instance.app_scope === "task-dashboard") { - const miniapp = new TaskDashboardMiniApp(sdk); - miniapp.start(); - bundledMiniApps.set(instance.instance_id, { stop: () => miniapp.stop() }); - } else { - const miniapp = new WhiteboardMiniApp(sdk); - miniapp.start(); - bundledMiniApps.set(instance.instance_id, { stop: () => { void miniapp.close(); } }); - } - } catch (reason) { - console.warn("[LineUp Host] bundled MiniApp mount rejected", instance.instance_id, reason); - } + const receipt = runtime.openBundledSurface(instance.app_scope, instance.instance_id); + if (receipt.disposition === "accepted") bundledMiniApps.add(instance.instance_id); } - for (const [instanceID, mounted] of bundledMiniApps) { + for (const instanceID of bundledMiniApps) { if (active.has(instanceID)) continue; - mounted.stop(); bundledMiniApps.delete(instanceID); } } diff --git a/tauri/src/runtime/app-management/miniapp-sdk.ts b/tauri/src/runtime/app-management/miniapp-sdk.ts index b7d2b8a..d0ae44d 100644 --- a/tauri/src/runtime/app-management/miniapp-sdk.ts +++ b/tauri/src/runtime/app-management/miniapp-sdk.ts @@ -1,5 +1,4 @@ import type { AppScope } from "@/runtime/app-management/app-registry"; -import type { AppWorkspaceSnapshot } from "@/runtime/app-management/app-lifecycle-manager"; import type { ScopedConversationItem } from "@/runtime/coordination/runtime-envelope"; import type { MiniAppToolCallRecord } from "@/runtime/coordination/miniapp-tool-state"; import type { JsonObject } from "@/runtime/protocol/lineup-v1"; @@ -42,6 +41,4 @@ export interface LineUpMiniAppSDK { readonly capabilities: { request(name: string, reason: string, input?: JsonObject): Promise; }; - /** Read-only host state projection for recovery and diagnostics. */ - readonly workspace: () => AppWorkspaceSnapshot; } diff --git a/tauri/src/runtime/coordination/lineup-runtime.test.ts b/tauri/src/runtime/coordination/lineup-runtime.test.ts index 51275da..cdde40d 100644 --- a/tauri/src/runtime/coordination/lineup-runtime.test.ts +++ b/tauri/src/runtime/coordination/lineup-runtime.test.ts @@ -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({ diff --git a/tauri/src/runtime/coordination/lineup-runtime.ts b/tauri/src/runtime/coordination/lineup-runtime.ts index 293f5f4..d5c0fb3 100644 --- a/tauri/src/runtime/coordination/lineup-runtime.ts +++ b/tauri/src/runtime/coordination/lineup-runtime.ts @@ -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 { + 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): void { console.info("[LineUp Runtime]", event, fields); diff --git a/tauri/src/runtime/surfaces/isolated-surface-host.ts b/tauri/src/runtime/surfaces/isolated-surface-host.ts index 108d3f6..e175b59 100644 --- a/tauri/src/runtime/surfaces/isolated-surface-host.ts +++ b/tauri/src/runtime/surfaces/isolated-surface-host.ts @@ -11,6 +11,8 @@ import type { VerifiedSurfaceBundleCache } from "@/runtime/surfaces/surface-bund export type SurfaceHostCallbacks = Readonly<{ ready: (instanceID: string) => void; event: (instanceID: string, event: "cancel") => void; + /** Runtime-owned MiniApp Bridge request; the iframe never receives Runtime internals. */ + request?: (instanceID: string, method: string, payload: JsonObject) => Promise; }>; /** Resolves only host-verified bytes to an iframe document; it has no bridge authority. */ @@ -28,6 +30,8 @@ type HostedSurface = { const BRIDGE_VERSION = 1; const READY_TYPE = "lineup.surface.v1.ready"; const STATE_TYPE = "lineup.surface.v1.state"; +const BRIDGE_REQUEST_TYPE = "lineup.miniapp.v1.request"; +const BRIDGE_RESPONSE_TYPE = "lineup.miniapp.v1.response"; /** * M2-03 Web Reference Host. The frame is deliberately an opaque sandbox @@ -98,6 +102,13 @@ export class IsolatedSurfaceHost { this.callbacks.ready(hosted.instance.instance_id); return; } + if (message.type === BRIDGE_REQUEST_TYPE) { + if (!hosted.ready || !this.callbacks.request) return; + void this.callbacks.request(hosted.instance.instance_id, message.method, message.payload) + .then(result => this.sendResponse(hosted, message.request_id, result)) + .catch(error => this.sendError(hosted, message.request_id, error instanceof Error ? error.message : "bridge_request_failed")); + return; + } if (!hosted.ready || hosted.cancelSent) return; hosted.cancelSent = true; this.callbacks.event(hosted.instance.instance_id, "cancel"); @@ -108,6 +119,14 @@ export class IsolatedSurfaceHost { // 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 }, "*"); } + + private sendResponse(hosted: HostedSurface, requestID: string, result: JsonObject | undefined): void { + hosted.frame.contentWindow?.postMessage({ v: BRIDGE_VERSION, type: BRIDGE_RESPONSE_TYPE, instance_id: hosted.instance.instance_id, request_id: requestID, ok: true, result: result ?? {} }, "*"); + } + + private sendError(hosted: HostedSurface, requestID: string, error: string): void { + hosted.frame.contentWindow?.postMessage({ v: BRIDGE_VERSION, type: BRIDGE_RESPONSE_TYPE, instance_id: hosted.instance.instance_id, request_id: requestID, ok: false, error: error.slice(0, 200) }, "*"); + } } /** @@ -131,24 +150,49 @@ export class CachedVerifiedSurfaceDocumentResolver implements VerifiedSurfaceDoc 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" }>; +type BridgeRequestMessage = Readonly<{ v: 1; type: typeof BRIDGE_REQUEST_TYPE; instance_id: string; request_id: string; method: string; payload: JsonObject }>; 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" +function parseBridgeMessage(value: unknown): SurfaceReadyMessage | SurfaceEventMessage | BridgeRequestMessage | undefined { + if (!isPlainObject(value) || value.v !== BRIDGE_VERSION || (value.type !== READY_TYPE && value.type !== "lineup.surface.v1.event" && value.type !== BRIDGE_REQUEST_TYPE) || 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" }; + if (value.type === BRIDGE_REQUEST_TYPE && typeof value.request_id === "string" && /^[A-Za-z0-9._:-]{1,128}$/.test(value.request_id) + && typeof value.method === "string" && /^[a-z][a-z0-9._-]{0,63}$/.test(value.method) && isPlainObject(value.payload) + && Object.keys(value).every(key => key === "v" || key === "type" || key === "instance_id" || key === "request_id" || key === "method" || key === "payload")) { + return { v: 1, type: BRIDGE_REQUEST_TYPE, instance_id: value.instance_id, request_id: value.request_id, method: value.method, payload: cloneObject(value.payload) }; + } return undefined; } -/** A static boot document for the future host-local Task Dashboard bundle. */ -export function surfaceDocument(instanceID: string, _appID = "lineup.task-dashboard"): string { +/** + * Development bundles for the two shipped bundled MiniApps. The business + * state and DOM live in this opaque iframe; the parent only exposes the + * Runtime-owned request bridge above. + */ +export function surfaceDocument(instanceID: string, appID = "lineup.task-dashboard"): string { const safeID = JSON.stringify(instanceID); - return `

任务面板

等待状态

    `; + const isWhiteboard = appID === "lineup.whiteboard"; + const title = isWhiteboard ? "Whiteboard" : "任务面板"; + const appScript = isWhiteboard ? whiteboardSurfaceScript() : taskDashboardSurfaceScript(); + return `

    ${title}

    等待状态

      `; +} + +function bridgeScript(): string { + return `const pending=new Map;let sequence=0;function bridge(method,payload={}){const request_id="req_"+(++sequence);return new Promise((resolve,reject)=>{pending.set(request_id,{resolve,reject});parent.postMessage({v:1,type:"lineup.miniapp.v1.request",instance_id:instanceId,request_id,method,payload},"*");setTimeout(()=>{const item=pending.get(request_id);if(item){pending.delete(request_id);item.reject(new Error("bridge_timeout"));}},10000)})}window.addEventListener("message",event=>{if(event.source!==parent)return;const d=event.data;if(!d||d.v!==1||d.instance_id!==instanceId)return;if(d.type==="lineup.miniapp.v1.response"&&typeof d.request_id==="string"){const item=pending.get(d.request_id);if(!item)return;pending.delete(d.request_id);d.ok?item.resolve(d.result||{}):item.reject(new Error(typeof d.error==="string"?d.error:"bridge_rejected"));return}if(d.type!=="lineup.surface.v1.state"||!d.state||typeof d.state!=="object"||Array.isArray(d.state))return;renderState(d.state)});function renderState(s){const title=document.getElementById("title"),progress=document.getElementById("progress"),status=document.getElementById("status"),steps=document.getElementById("steps");if(typeof s.title==="string")title.textContent=s.title;if(typeof s.percent==="number")progress.value=Math.max(0,Math.min(100,s.percent));if(typeof s.status==="string")status.textContent=s.status;steps.replaceChildren(...(Array.isArray(s.steps)?s.steps.slice(0,12).map(x=>{const n=document.createElement("li");n.textContent=typeof x==="string"?x:"";return n}):[]));}parent.postMessage({v:1,type:"lineup.surface.v1.ready",instance_id:instanceId},"*");`; +} + +function taskDashboardSurfaceScript(): string { + return `const handled=new Set;const tasks=new Map;const editor=document.getElementById("editor");document.getElementById("cancel").onclick=()=>bridge("lifecycle.close",{reason:"用户关闭任务面板"});async function poll(){try{const result=await bridge("tools.list");for(const call of Array.isArray(result.calls)?result.calls:[]){if(handled.has(call.call_id)||!(call.status==="waiting_for_app"||call.status==="running"))continue;handled.add(call.call_id);await handleTask(call)}}catch(_){}}async function handleTask(call){try{await bridge("tools.reportProgress",{call_id:call.call_id,progress:{status:"running",percent:10}});if(call.tool_id==="task.create"){const title=typeof call.input?.title==="string"?call.input.title:"未命名任务";const task_id=typeof call.input?.task_id==="string"?call.input.task_id:"task-"+call.call_id;tasks.set(task_id,{title,status:"open"});await bridge("surface.patch",{data:{state:{title:"任务面板",status:"已创建:"+title,percent:100,steps:[...tasks.values()].map(x=>x.title)}}});await bridge("tools.complete",{call_id:call.call_id,result:{task_id}})}else if(call.tool_id==="task.list"){await bridge("tools.complete",{call_id:call.call_id,result:{tasks:[...tasks.entries()].map(([task_id,task])=>Object.assign({task_id},task))}})}else await bridge("tools.fail",{call_id:call.call_id,code:"tool_not_supported",message:"不支持的任务工具"})}catch(_){await bridge("tools.fail",{call_id:call.call_id,code:"app_bridge_failed",message:"任务面板处理失败"}).catch(()=>{})}}setInterval(poll,500);poll();`; +} + +function whiteboardSurfaceScript(): string { + return `const handled=new Set;let board={title:"Whiteboard",status:"可编辑",elements:[]};const editor=document.getElementById("editor");document.getElementById("cancel").onclick=()=>bridge("lifecycle.close",{reason:"用户关闭画板"});editor.addEventListener("input",()=>{board={...board,text:editor.value};bridge("surface.patch",{data:{state:board}}).catch(()=>{})});async function poll(){try{const result=await bridge("tools.list");for(const call of Array.isArray(result.calls)?result.calls:[]){if(handled.has(call.call_id)||!(call.status==="waiting_for_app"||call.status==="running"))continue;handled.add(call.call_id);await handleBoard(call)}}catch(_){}}async function handleBoard(call){try{if(call.tool_id==="whiteboard.open"){await bridge("tools.reportProgress",{call_id:call.call_id,progress:{status:"opening",percent:40}});await bridge("tools.complete",{call_id:call.call_id,result:{status:"completed"}})}else if(call.tool_id==="whiteboard.export"){const artifact_id=typeof call.input?.artifact_id==="string"?call.input.artifact_id:"whiteboard-"+call.call_id;await bridge("tools.reportProgress",{call_id:call.call_id,progress:{status:"exporting",percent:80}});await bridge("surface.patch",{data:{state:{...board,artifact:{artifact_id}}}});await bridge("tools.complete",{call_id:call.call_id,result:{artifact_id}})}else await bridge("tools.fail",{call_id:call.call_id,code:"tool_not_supported",message:"不支持的画板工具"})}catch(_){await bridge("tools.fail",{call_id:call.call_id,code:"app_bridge_failed",message:"画板处理失败"}).catch(()=>{})}}setInterval(poll,500);poll();`; } export function productionSurfaceDocument(instanceID: string, verifiedBundle: string): string { @@ -164,3 +208,7 @@ function isPlainObject(value: unknown): value is Record { const prototype = Object.getPrototypeOf(value); return prototype === Object.prototype || prototype === null; } + +function cloneObject(value: Record): JsonObject { + return JSON.parse(JSON.stringify(value)) as JsonObject; +} diff --git a/迭代/03.sdk_and_coreapp/04.acceptance_review.md b/迭代/03.sdk_and_coreapp/04.acceptance_review.md new file mode 100644 index 0000000..c1601cc --- /dev/null +++ b/迭代/03.sdk_and_coreapp/04.acceptance_review.md @@ -0,0 +1,102 @@ +# `03.sdk_and_coreapp` 第 4 次验收评审记录 + +> 评审编号:04 +> 评审日期:2026-08-05 +> 评审对象:[03.sdk_and_coreapp.md](03.sdk_and_coreapp.md) +> 架构基线:[APP架构设计.md](../../APP架构设计.md) +> 参考评审:[01.design_review.md](01.design_review.md)、[02.design_review.md](02.design_review.md)、[03.design_review.md](03.design_review.md) +> 评审方式:主 agent 交叉检查 + 独立子 agent 只读验收;检查构建、自动化测试、Runtime/MiniApp/Host 代码、SDK 契约、Manifest 和真实浏览器路径。 +> 总体结论:功能回归通过,但架构与 SDK 验收不通过。发现的 P1 问题必须在本迭代修复后,才能把迭代标记为真正完成。 + +## 问题清单(Outline) + +> **状态标记:** 🔴 未解决,必须处理;🟡 已提出修复方向,尚未实现;✅ 已解决;⚪ 可延期但必须保留记录。 +> +> P0~P3 必须在当前迭代处理;P4~P5 可以延期,但要写清延期原因和重新评估条件。 + +| 状态 | 优先级 | 编号 | 问题 | 证据 | 当前结论 / 下一步 | +|---|---:|---|---|---|---| +| 🔴 | P1 | A1 | MiniApp 的 Capability 请求绕过 Runtime 的 Manifest、Policy 和审计边界 | `tauri/src/runtime/coordination/lineup-runtime.ts:379-381` | 必须由 Runtime 校验 Manifest 声明、Capability Registry/Policy、输入 schema、前台要求和用户确认;不能直接拼接 `app.call` 发出。 | +| ✅ | P1 | A2 | `bundled` MiniApp 的业务逻辑仍在可信 Host JS 中运行,没有真正的受限执行边界 | `tauri/src/main.ts`、`isolated-surface-host.ts` | 已移除 Host 直接实例化;Task Dashboard/Whiteboard 业务逻辑改在 opaque sandbox iframe 内运行,只能通过 Runtime Bridge 请求能力。待浏览器复验。 | +| ✅ | P1 | A3 | SDK 暴露完整 workspace,MiniApp 可看到其他 App 实例和全局焦点栈 | `lineup-runtime.ts`、`miniapp-sdk.ts` | 已从 `LineUpMiniAppSDK` 删除 `workspace()`,不再把全局实例和焦点栈交给 bundled MiniApp。 | +| ✅ | P1 | A4 | 同一个 App 的多个 instance 之间 Inbox 订阅没有隔离 | `lineup-runtime.ts` | 已按 `app_scope + conversation_id + instance_id` 过滤实时订阅,并补充双实例回归测试。 | +| 🔴 | P1 | A5 | Interact 使用专用 `ChatRuntimeSDK` 旁路,没有和其他 MiniApp 共用 SDK v1 语义 | `tauri/src/core-apps/chat/chat-app-host.ts:67-75`、`main.ts:103-125` | 需要明确并实现统一 SDK 适配:Interact 可有可信 DOM,但交互、Tool、生命周期仍必须通过统一 Runtime 契约;不能让专用接口成为另一套协议。 | +| 🔴 | P1 | A6 | 标准交互的超时、dismiss、提交和答案 schema 没有由 Runtime 统一裁决 | `lineup-runtime.ts:507-558, 798-815`、`agent-interaction-service.ts:93-101` | 必须让 Interaction 与 Kernel Tool 共用一个终态迁移;超时/dismiss 要写唯一 Agent outbox;提交前按 `notice/choice/confirm/input` 校验答案,拒绝重复和竞态。 | +| 🔴 | P2 | A7 | 迭代文档中的 Tool 名称和代码 Manifest 不一致 | 主文档 §5.2/§5.3;`reference-miniapps.ts` | 统一契约、Manifest、fixture、Agent Inventory 和验收脚本中的名称。 | +| 🔴 | P2 | A8 | 当前 Task Dashboard/Whiteboard Surface 主要是 Host 硬编码的静态页面,无法证明真实业务 UI 在各自受限 Surface 内运行 | `main.ts:180-198`、`isolated-surface-host.ts` | 至少提供能代表业务边界的受限 Surface fixture,并验证 MiniApp 业务逻辑不能取得 Host DOM 或 Runtime 内部。 | +| 🔴 | P2 | A9 | MiniApp progress 没有进入可恢复的 Tool 状态记录 | `miniapp-tool-state.ts` | 如果契约要求进度可恢复,需持久化最后进度并覆盖 Runtime 重启;否则修改文档,明确 progress 只是一种可丢失事件。 | +| ⚪ | P3 | A10 | App session 记录和 opened/closed 事件没有显式保存 `agent_id` / `conversation_id` 字段 | `app-instance-manager.ts`、`lineup-runtime.ts:203-209,1052-1058` | Envelope 有部分上下文,但应与文档最低字段要求统一,避免未来多 Agent 或跨会话时产生歧义。 | +| ⚪ | P3 | A11 | 旧的 `openExtensionApp` 公开入口仍提供较宽的旁路能力 | `lineup-runtime.ts:286-300` | 清理旧兼容入口,或明确它只为历史测试保留并加上 instance/call 状态约束。 | + +## 1. 已验证通过的部分 + +- `npm run build` 通过。 +- `npm test -- --run` 通过:29 个测试文件、126 个测试。 +- `git diff --check` 通过。 +- 使用独立 `agent-browser` 会话登录本地测试账号成功。 +- 主 IM 页面、同步状态、应用子会话区域和“启用任务面板”入口可见。 +- 用户消息可以发送并显示“已发送”。 +- AppServer 频道中的其他用户消息已能被 Runtime 安全忽略;真实页面不再出现此前大量的 `scope_mismatch`。 + +这些证据只能说明基础功能路径可运行,不能证明 MiniApp 隔离、统一 SDK 和所有状态机契约已经满足架构要求。 + +## 2. P1 问题说明 + +### A1:Capability 请求没有经过 Runtime 的完整裁决 + +现在 bundled MiniApp 可以调用 `sdk.capabilities.request(name, reason, input)`,实现直接把参数包装成 +`lineup.v1.app.call`。这意味着 MiniApp 可以请求一个没有写入自己 Manifest、没有经过当前 Capability Policy +检查、也没有经过 Host 支持检查的能力。即使后续 Agent 还要确认,这条入口也已经让 MiniApp 伪造了系统能力请求。 + +正确的边界应该是:MiniApp 提出请求 → Runtime 检查 Manifest 和 Policy → 校验参数 → 检查前台/生命周期 → +进入统一的 Capability 状态机和用户确认流程 → 再由 Host 执行。 + +### A2:bundled MiniApp 实际仍是可信代码(已修复,待浏览器复验) + +已删除 `main.ts` 中对 `TaskDashboardMiniApp` 和 `WhiteboardMiniApp` 的直接实例化。开发版 Surface 现在把 +Task Dashboard/Whiteboard 的业务脚本放进 opaque sandbox iframe,iframe 只能发送经过校验的 +`lineup.miniapp.v1.request`,由 Runtime 执行 Inbox、Tool、生命周期和 Surface 操作;Host 不再把 Runtime 对象、 +Store 或 Transport 注入 MiniApp。 + +这和架构文档要求的“bundled 也不是 system,必须使用受限 Surface/Bridge”不一致。若继续保留这种实现,必须把 +它们改成明确的 Host 内建 System 代码并修改信任模型;不能继续同时声称它们是普通 bundled MiniApp。 + +### A3/A4:SDK 数据边界没有做到 instance 级别(已修复) + +`LineUpMiniAppSDK` 已不再提供 `workspace()`,因此 MiniApp 不能查看其他 instance 或 Runtime 焦点栈。 + +`inbox.list()`、ACK 和 `inbox.subscribe()` 现在都按 `app_scope + conversation_id + instance_id` 过滤;新增回归 +测试验证同一 App 的两个 instance 不会互收消息。 + +### A5:Interact 的 SDK 语义没有统一 + +Interact 使用 `ChatRuntimeSDK` 和 Host 注入的 `toolInteractions`,而 Task Dashboard/Whiteboard 使用 +`LineUpMiniAppSDK`。可信 DOM 可以是 Interact 与 bundled 的不同实现方式,但交互归属、Tool 状态、Runtime +动作和错误/恢复语义不能因此分成两套没有共同契约的接口。当前代码无法用一套 SDK golden fixture 证明三类 App +遵守相同的 Runtime 边界。 + +### A6:标准交互有两套状态,可能互相打架 + +- `expireToolCall()` 只修改 Kernel Tool 状态,没有调用 `AgentInteractionService.expire()`,也没有发送 expired outbox。 +- Agent dismiss 修改了 Interaction,但没有同步修改 Kernel Tool 状态。 +- submit 先改 Kernel,再改 Interaction;如果第二步失败,会留下两套状态不一致。 +- 提交结果是任意对象,没有按四种交互类型校验;例如 choice 的 UI 可以提交多选数组,而冻结契约要求的是单选结果。 + +这会导致 UI、持久化记录和 Agent 看到的最终结果不一致,属于当前迭代必须修复的正确性问题。 + +## 3. 验收结论 + +当前结论为(完成本轮 P1-1 后): + +```text +功能回归:通过 +基础构建与自动化测试:通过 +真实登录和消息发送:通过 +App 架构边界:部分通过(A2 已实现,待浏览器复验;A1/A5/A6 仍不通过) +MiniApp 隔离与 SDK 规范:A2/A3/A4 已通过,Capability/Interact/状态机仍不通过 +标准交互终态契约:不通过 +迭代整体验收:不通过,先继续处理 A1,再处理 A5/A6;A2~A4 需在最终浏览器复验中确认 +``` + +本次验收没有修改实现代码。下一步应先处理 P1,再重新运行自动化测试和真实浏览器验收;P2/P3 不能被静默 +删除,至少要在本迭代结束前有明确的修复或延期决定。