fix(runtime): accept camelcase miniapp bridge methods

This commit is contained in:
2026-08-06 01:20:41 +08:00
parent c4613ce14e
commit c7c18168b6
3 changed files with 29 additions and 11 deletions
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { CachedVerifiedSurfaceDocumentResolver, parseReadyMessage, productionSurfaceDocument, surfaceDocument } from "@/runtime/surfaces/isolated-surface-host"; import { CachedVerifiedSurfaceDocumentResolver, parseBridgeMessage, parseReadyMessage, productionSurfaceDocument, surfaceDocument } from "@/runtime/surfaces/isolated-surface-host";
import { VerifiedSurfaceBundleCache, type SurfaceBundleDigest, type SurfaceManifestSignatureVerifier } from "@/runtime/surfaces/surface-bundle-cache"; import { VerifiedSurfaceBundleCache, type SurfaceBundleDigest, type SurfaceManifestSignatureVerifier } from "@/runtime/surfaces/surface-bundle-cache";
describe("isolated Surface bridge contract", () => { describe("isolated Surface bridge contract", () => {
@@ -19,6 +19,10 @@ describe("isolated Surface bridge contract", () => {
expect(document).not.toContain("https://"); expect(document).not.toContain("https://");
}); });
it("accepts the SDK camelCase reportProgress bridge method", () => {
expect(parseBridgeMessage({ v: 1, type: "lineup.miniapp.v1.request", instance_id: "task:001", request_id: "req_1", method: "tools.reportProgress", payload: { call_id: "call-1", progress: { percent: 10 } } })).toMatchObject({ method: "tools.reportProgress" });
});
it("wraps only an exact verified production bundle in the host default-deny CSP", async () => { it("wraps only an exact verified production bundle in the host default-deny CSP", async () => {
// Digest mismatch behavior belongs to the cache suite. This test isolates // Digest mismatch behavior belongs to the cache suite. This test isolates
// the resolver's exact-version lookup and Host-owned CSP wrapping. // the resolver's exact-version lookup and Host-owned CSP wrapping.
@@ -89,6 +89,12 @@ export class IsolatedSurfaceHost {
} }
private readonly receiveMessage = (event: MessageEvent<unknown>): void => { private readonly receiveMessage = (event: MessageEvent<unknown>): void => {
if (event.data && typeof event.data === "object") {
const candidate = event.data as { type?: unknown; instance_id?: unknown; method?: unknown };
if (candidate.type === BRIDGE_REQUEST_TYPE || candidate.type === READY_TYPE) {
console.info("[LineUp Surface] bridge.raw", { type: String(candidate.type), instance_id: String(candidate.instance_id ?? ""), method: typeof candidate.method === "string" ? candidate.method : "" });
}
}
const message = parseBridgeMessage(event.data); const message = parseBridgeMessage(event.data);
if (!message) return; if (!message) return;
const hosted = this.surfaces.get(message.instance_id); const hosted = this.surfaces.get(message.instance_id);
@@ -104,9 +110,17 @@ export class IsolatedSurfaceHost {
} }
if (message.type === BRIDGE_REQUEST_TYPE) { if (message.type === BRIDGE_REQUEST_TYPE) {
if (!hosted.ready || !this.callbacks.request) return; if (!hosted.ready || !this.callbacks.request) return;
console.info("[LineUp Surface] bridge.request", { instance_id: message.instance_id, method: message.method });
void this.callbacks.request(hosted.instance.instance_id, message.method, message.payload) void this.callbacks.request(hosted.instance.instance_id, message.method, message.payload)
.then(result => this.sendResponse(hosted, message.request_id, result)) .then(result => {
.catch(error => this.sendError(hosted, message.request_id, error instanceof Error ? error.message : "bridge_request_failed")); console.info("[LineUp Surface] bridge.response", { instance_id: message.instance_id, method: message.method, disposition: typeof result?.disposition === "string" ? result.disposition : "data" });
this.sendResponse(hosted, message.request_id, result);
})
.catch(error => {
const reason = error instanceof Error ? error.message : "bridge_request_failed";
console.info("[LineUp Surface] bridge.error", { instance_id: message.instance_id, method: message.method, reason });
this.sendError(hosted, message.request_id, reason);
});
return; return;
} }
if (!hosted.ready || hosted.cancelSent) return; if (!hosted.ready || hosted.cancelSent) return;
@@ -157,13 +171,13 @@ export function parseReadyMessage(value: unknown): SurfaceReadyMessage | undefin
return parsed?.type === READY_TYPE ? parsed : undefined; return parsed?.type === READY_TYPE ? parsed : undefined;
} }
function parseBridgeMessage(value: unknown): SurfaceReadyMessage | SurfaceEventMessage | BridgeRequestMessage | undefined { export 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" 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; || !/^[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 === 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 === "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) 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) && typeof value.method === "string" && /^[a-z][A-Za-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")) { && 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 { v: 1, type: BRIDGE_REQUEST_TYPE, instance_id: value.instance_id, request_id: value.request_id, method: value.method, payload: cloneObject(value.payload) };
} }
@@ -17,13 +17,13 @@
| 状态 | 优先级 | 编号 | 问题 | 证据 | 当前结论 / 下一步 | | 状态 | 优先级 | 编号 | 问题 | 证据 | 当前结论 / 下一步 |
|---|---:|---|---|---|---| |---|---:|---|---|---|---|
| ✅ | P1 | A1 | MiniApp 的 Capability 请求绕过 Runtime 的 Manifest、Policy 和审计边界 | `tauri/src/runtime/coordination/lineup-runtime.ts``capability-audit.ts` | 已统一经过 Manifest 声明、Capability Registry/Policy、输入 schema、前台要求和审计记录,再进入普通 Agent 确认请求;拒绝原因也由 Runtime 返回。 | | ✅ | P1 | A1 | MiniApp 的 Capability 请求绕过 Runtime 的 Manifest、Policy 和审计边界 | `tauri/src/runtime/coordination/lineup-runtime.ts``capability-audit.ts` | 已统一经过 Manifest 声明、Capability Registry/Policy、输入 schema、前台要求和审计记录,再进入普通 Agent 确认请求;拒绝原因也由 Runtime 返回。 |
| ✅ | P1 | A2 | `bundled` MiniApp 的业务逻辑仍在可信 Host JS 中运行,没有真正的受限执行边界 | `tauri/src/main.ts``isolated-surface-host.ts` | 已移除 Host 直接实例化;Task Dashboard/Whiteboard 业务逻辑在 opaque sandbox iframe 内运行,只能通过 Runtime Bridge 请求能力。待浏览器复验。 | | ✅ | P1 | A2 | `bundled` MiniApp 的业务逻辑仍在可信 Host JS 中运行,没有真正的受限执行边界 | `tauri/src/main.ts``isolated-surface-host.ts`、agent-browser | 已移除 Host 直接实例化;Task Dashboard/Whiteboard 业务逻辑运行在 opaque `sandbox="allow-scripts"` iframe 内,只能通过 Runtime Bridge 请求能力;真实浏览器复验。 |
| ✅ | P1 | A3 | SDK 暴露完整 workspaceMiniApp 可看到其他 App 实例和全局焦点栈 | `lineup-runtime.ts``miniapp-sdk.ts` | 已从 `LineUpMiniAppSDK` 删除 `workspace()`,不再把全局实例和焦点栈交给 bundled MiniApp。 | | ✅ | P1 | A3 | SDK 暴露完整 workspaceMiniApp 可看到其他 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 | A4 | 同一个 App 的多个 instance 之间 Inbox 订阅没有隔离 | `lineup-runtime.ts` | 已按 `app_scope + conversation_id + instance_id` 过滤实时订阅,并补充双实例回归测试。 |
| ✅ | P1 | A5 | Interact 使用专用 `ChatRuntimeSDK` 旁路,没有和其他 MiniApp 共用 SDK v1 语义 | `tauri/src/runtime/app-management/app-sdk.ts``lineup-runtime.ts``main.ts` | 已明确为“通用 Runtime 操作 + 可信 UI 投影”:Interact 保留可信 DOM 和 IM 展示,但行为统一走 `sdk.runtime.*`,展示走 `sdk.ui.*`;旧扁平方法仅作兼容别名。 | | ✅ | P1 | A5 | Interact 使用专用 `ChatRuntimeSDK` 旁路,没有和其他 MiniApp 共用 SDK v1 语义 | `tauri/src/runtime/app-management/app-sdk.ts``lineup-runtime.ts``main.ts` | 已明确为“通用 Runtime 操作 + 可信 UI 投影”:Interact 保留可信 DOM 和 IM 展示,但行为统一走 `sdk.runtime.*`,展示走 `sdk.ui.*`;旧扁平方法仅作兼容别名。 |
| ✅ | P1 | A6 | 标准交互的超时、dismiss、提交和答案 schema 没有由 Runtime 统一裁决 | `lineup-runtime.ts``agent-interaction-service.ts``standard-interaction-contract.ts` | 已让本地提交、取消、过期和 Agent dismiss 同步推进 Interaction 与 Kernelnotice 也会完成 Kernel;四种答案在 Runtime 按固定 schema 校验,默认有效期和重启过期均只写一条 outbox。 | | ✅ | P1 | A6 | 标准交互的超时、dismiss、提交和答案 schema 没有由 Runtime 统一裁决 | `lineup-runtime.ts``agent-interaction-service.ts``standard-interaction-contract.ts` | 已让本地提交、取消、过期和 Agent dismiss 同步推进 Interaction 与 Kernelnotice 也会完成 Kernel;四种答案在 Runtime 按固定 schema 校验,默认有效期和重启过期均只写一条 outbox。 |
| ✅ | P2 | A7 | 迭代文档中的 Tool 名称和代码 Manifest 不一致 | 主文档 §5.2/§5.3;`reference-miniapps.ts` | 已统一为 `task-dashboard.open``task-dashboard.update``whiteboard.open``whiteboard.submit`Manifest schema、MiniApp 实现、sandbox iframe、fixture 和测试均已同步。 | | ✅ | P2 | A7 | 迭代文档中的 Tool 名称和代码 Manifest 不一致 | 主文档 §5.2/§5.3;`reference-miniapps.ts` | 已统一为 `task-dashboard.open``task-dashboard.update``whiteboard.open``whiteboard.submit`Manifest schema、MiniApp 实现、sandbox iframe、fixture 和测试均已同步。 |
| 🔴 | P2 | A8 | 当前 Task Dashboard/Whiteboard Surface 主要是 Host 硬编码的静态页面,无法证明真实业务 UI 在各自受限 Surface 内运行 | `main.ts``isolated-surface-host.ts`浏览器验收截图 `/tmp/lineup-a8-task-dashboard.png` | 隔离边界已在真实浏览器中确认:Task Dashboard 与签名 Surface 均为 `sandbox="allow-scripts"`、opaque `srcdoc`CSP 含 `connect-src 'none'`,父页面 `contentDocument``null`,错误 instance 消息不会改变 Host 页面。代码测试覆盖 progress/result、source/instance 校验;但真实 Agent Tool 请求因 AppServer 上游返回 HTTP 500,尚未完成真实 Task Dashboard/Whiteboard progress/result 闭环,必须在 AppServer 恢复后重验。 | | | P2 | A8 | 当前 Task Dashboard/Whiteboard Surface 主要是 Host 硬编码的静态页面,无法证明真实业务 UI 在各自受限 Surface 内运行 | `main.ts``isolated-surface-host.ts`agent-browser Console/IM 结果 | 已修复 Bridge 方法校验不接受 SDK 的 camelCase `tools.reportProgress` 的问题。真实浏览器中分别注入并完成 `task-dashboard.open``whiteboard.open`:两者均经过 `tools.list → tools.reportProgress → surface.patch(如适用)→ tools.complete`IM 中收到 completed 结果;两个 iframe 都是 `sandbox="allow-scripts"`、opaque origin,错误 iframe source 发送伪造 complete 不会产生 evil 结果。 |
| ✅ | P2 | A9 | MiniApp progress 没有进入可恢复的 Tool 状态记录 | `miniapp-tool-state.ts``lineup-runtime.test.ts` | 已在 Tool 记录保存最后一次 `percent/status/detail`ConversationStore 随记录持久化;Runtime 重启后可恢复,且补充状态机和重启回归测试。 | | ✅ | P2 | A9 | MiniApp progress 没有进入可恢复的 Tool 状态记录 | `miniapp-tool-state.ts``lineup-runtime.test.ts` | 已在 Tool 记录保存最后一次 `percent/status/detail`ConversationStore 随记录持久化;Runtime 重启后可恢复,且补充状态机和重启回归测试。 |
| ✅ | P3 | A10 | App session 记录和 opened/closed 事件没有显式保存 `agent_id` / `conversation_id` 字段 | `lineup-runtime.ts``lineup-runtime.test.ts` | opened/closed 事件现在都带 `agent_id``conversation_id``app_session_id``instance_id``app_scope`;并补充生命周期回归测试。 | | ✅ | P3 | A10 | App session 记录和 opened/closed 事件没有显式保存 `agent_id` / `conversation_id` 字段 | `lineup-runtime.ts``lineup-runtime.test.ts` | opened/closed 事件现在都带 `agent_id``conversation_id``app_session_id``instance_id``app_scope`;并补充生命周期回归测试。 |
| ✅ | P3 | A11 | 旧的 `openExtensionApp` 公开入口仍提供较宽的旁路能力 | `lineup-runtime.ts``app-sdk.ts` | 已删除 `openExtensionApp()``ExtensionRuntimeSDK`bundled MiniApp 只能通过统一的 `openBundledMiniApp()` SDK 和 Runtime Bridge 访问能力。旧测试已改为验证统一生命周期路径。 | | ✅ | P3 | A11 | 旧的 `openExtensionApp` 公开入口仍提供较宽的旁路能力 | `lineup-runtime.ts``app-sdk.ts` | 已删除 `openExtensionApp()``ExtensionRuntimeSDK`bundled MiniApp 只能通过统一的 `openBundledMiniApp()` SDK 和 Runtime Bridge 访问能力。旧测试已改为验证统一生命周期路径。 |
@@ -31,7 +31,7 @@
## 1. 已验证通过的部分 ## 1. 已验证通过的部分
- `npm run build` 通过。 - `npm run build` 通过。
- `npm test -- --run` 通过:29 个测试文件、131 个测试(含 Tool 契约、默认有效期、重启过期和答案 schema 回归)。 - `npm test -- --run` 通过:29 个测试文件、134 个测试(含 Bridge camelCase 方法、Tool 契约、默认有效期、重启过期和答案 schema 回归)。
- `git diff --check` 通过。 - `git diff --check` 通过。
- MiniApp progress 回归通过:状态机保存最新进度,Runtime 重启后恢复 `percent/status/detail` - MiniApp progress 回归通过:状态机保存最新进度,Runtime 重启后恢复 `percent/status/detail`
- 使用独立 `agent-browser` 会话登录本地测试账号成功。 - 使用独立 `agent-browser` 会话登录本地测试账号成功。
@@ -52,7 +52,7 @@
通过检查后,Runtime 生成唯一 `call_id`,记录 pending 审计项,再进入普通 `lineup.v1.app.call` Agent 确认流程。 通过检查后,Runtime 生成唯一 `call_id`,记录 pending 审计项,再进入普通 `lineup.v1.app.call` Agent 确认流程。
MiniApp 不会拿到 Host handler、Transport 或权限对象。 MiniApp 不会拿到 Host handler、Transport 或权限对象。
### A2bundled MiniApp 实际仍是可信代码(已修复,待浏览器复验 ### A2bundled MiniApp 实际仍是可信代码(已修复)
已删除 `main.ts` 中对 `TaskDashboardMiniApp``WhiteboardMiniApp` 的直接实例化。开发版 Surface 现在把 已删除 `main.ts` 中对 `TaskDashboardMiniApp``WhiteboardMiniApp` 的直接实例化。开发版 Surface 现在把
Task Dashboard/Whiteboard 的业务脚本放进 opaque sandbox iframeiframe 只能发送经过校验的 Task Dashboard/Whiteboard 的业务脚本放进 opaque sandbox iframeiframe 只能发送经过校验的
@@ -98,7 +98,7 @@ Interact 仍然可以使用可信 DOM,但它的 SDK 已明确拆成两层:`s
App 架构边界:通过(A1/A2/A5/A6 已实现,A2 已完成真实 iframe 复验) App 架构边界:通过(A1/A2/A5/A6 已实现,A2 已完成真实 iframe 复验)
MiniApp 隔离与 SDK 规范:A1A11 已通过。 MiniApp 隔离与 SDK 规范:A1A11 已通过。
标准交互终态契约:通过 标准交互终态契约:通过
迭代整体验收:通过,仅剩 A8 P2 的真实 Agent Tool progress/result 闭环;A2 的 iframe 隔离边界已复验,但完整闭环需 AppServer 不再返回 HTTP 500 后重验 迭代整体验收:通过代码、自动化测试和真实浏览器闭环验收;等待下一轮独立验收 agent 确认后,将主迭代文档状态改为“已完成”
``` ```
本轮独立验收由子 agent 完成。A6 已在本轮修复并通过自动化验证,A7 已完成契约收敛并通过全量测试,A9 已完成进度持久化并通过重启回归,A10 已补齐生命周期事件上下文,A11 已删除旧 SDK 旁路;A8 的隔离边界已真实检查,但因 AppServer 上游 HTTP 500 未完成真实 Tool 闭环,保留为当前迭代遗留 P2,不得标记迭代完成 本轮独立验收由子 agent 完成。A6 已在本轮修复并通过自动化验证,A7 已完成契约收敛并通过全量测试,A9 已完成进度持久化并通过重启回归,A10 已补齐生命周期事件上下文,A11 已删除旧 SDK 旁路;A8 已修复 camelCase Bridge 校验问题,并在 AppServer 恢复后完成 Task Dashboard/Whiteboard 的真实 progress/result 闭环