From 121ee6ac735b480643fa07b2d6490eb7966a4a42 Mon Sep 17 00:00:00 2001 From: kyugao Date: Wed, 5 Aug 2026 18:47:00 +0800 Subject: [PATCH] fix(runtime): align AppServer conversation scope --- .../runtime/coordination/lineup-runtime.ts | 23 +++++++++++++++---- .../runtime/coordination/runtime-envelope.ts | 8 ++++--- tauri/src/runtime/coordination/tool-router.ts | 21 +++++++++-------- 迭代/03.sdk_and_coreapp/03.sdk_and_coreapp.md | 4 ++-- 4 files changed, 38 insertions(+), 18 deletions(-) diff --git a/tauri/src/runtime/coordination/lineup-runtime.ts b/tauri/src/runtime/coordination/lineup-runtime.ts index 12262e4..2dc2eea 100644 --- a/tauri/src/runtime/coordination/lineup-runtime.ts +++ b/tauri/src/runtime/coordination/lineup-runtime.ts @@ -664,7 +664,7 @@ export class LineUpRuntime { v: 1, id: this.newID("msg"), type, - conversation_id: this.requireConversationID(), + conversation_id: this.wireConversationID(), sender: { kind: "human", id: this.session.uid }, target: { kind: "agent", id: this.session.agent_uid }, timestamp: this.now(), @@ -682,7 +682,7 @@ export class LineUpRuntime { v: 1, id: this.newID("msg"), type, - conversation_id: this.requireConversationID(), + conversation_id: this.wireConversationID(), sender: { kind: "human", id: this.session.uid }, target: { kind: "agent", id: this.session.agent_uid }, timestamp: this.now(), @@ -745,6 +745,15 @@ export class LineUpRuntime { private processTransportMessage(message: TransportMessage): void { const payload = decodeTransportPayload(message.payload); + // AppServer sync is channel-wide, so a channel can contain messages from + // other users. They are not part of this Runtime's conversation and must + // never reach scope routing or the Chat projection. + if (message.from_uid !== this.session.uid + && message.from_uid !== this.session.agent_uid + && !message.from_uid.startsWith("agent_")) { + runtimeLog("message.ignored", { stage: "sender" }); + return; + } if (message.from_uid === this.session.uid) { const own = parseEnvelopeJSON(payload); if (own.ok && CONTROL_ECHO_TYPES.has(own.envelope.type)) return; @@ -754,7 +763,7 @@ export class LineUpRuntime { } const scopeResolution = resolveIncomingScope(payload, { - app_scope: "chat", conversation_id: this.requireConversationID(), + app_scope: "chat", conversation_id: this.requireConversationID(), conversation_ids: [this.wireConversationID()], acceptsAppScope: scope => Boolean(this.apps.get(scope)?.enabled), }); if (scopeResolution.disposition === "rejected") { @@ -762,7 +771,7 @@ export class LineUpRuntime { this.emit({ type: "scope-rejected", reason: scopeResolution.reason, raw: payload }); return; } - const route = this.toolRouter.route(payload, { app_scope: scopeResolution.scope.app_scope, conversation_id: this.requireConversationID(), inventory_revision: this.inventoryRevision }); + const route = this.toolRouter.route(payload, { app_scope: scopeResolution.scope.app_scope, conversation_id: this.requireConversationID(), conversation_ids: [this.wireConversationID()], inventory_revision: this.inventoryRevision }); if (route.disposition === "rejected") { runtimeLog("tool.rejected", { reason: route.code }); this.emit({ type: "scope-rejected", reason: route.code === "scope_mismatch" ? "scope_mismatch" : "invalid_scope", raw: payload }); @@ -1072,6 +1081,12 @@ export class LineUpRuntime { if (!conversationID) throw new Error("LineUpRuntime has no active conversation."); return conversationID; } + + /** AppServer's network conversation key; local persistence keeps Runtime's legacy key. */ + private wireConversationID(): string { + if (!this.session.active) throw new Error("LineUpRuntime has no active conversation."); + return `lineup:${this.session.channel_type}:${this.session.channel_id}:${this.session.uid}`; + } } function boundedFailure(value: string): string { diff --git a/tauri/src/runtime/coordination/runtime-envelope.ts b/tauri/src/runtime/coordination/runtime-envelope.ts index f0bd0e4..27db954 100644 --- a/tauri/src/runtime/coordination/runtime-envelope.ts +++ b/tauri/src/runtime/coordination/runtime-envelope.ts @@ -45,8 +45,10 @@ export type ScopeResolution = */ export function resolveIncomingScope( raw: string, - expected: { app_scope: AppScope; conversation_id: string; acceptsAppScope?: (scope: AppScope) => boolean }, + expected: { app_scope: AppScope; conversation_id: string; conversation_ids?: readonly string[]; acceptsAppScope?: (scope: AppScope) => boolean }, ): ScopeResolution { + const matchesConversation = (value: unknown): boolean => typeof value === "string" + && (value === expected.conversation_id || expected.conversation_ids?.includes(value) === true); const legacyScope: RuntimeScope = { app_scope: expected.app_scope, conversation_id: expected.conversation_id }; let parsed: unknown; try { parsed = JSON.parse(raw); } catch { @@ -59,7 +61,7 @@ export function resolveIncomingScope( const candidate = parsed as { scope?: unknown; conversation_id?: unknown }; if (candidate.scope === undefined) { // Existing v1 envelope: conversation_id remains authoritative. - if (typeof candidate.conversation_id === "string" && candidate.conversation_id !== expected.conversation_id) { + if (typeof candidate.conversation_id === "string" && !matchesConversation(candidate.conversation_id)) { return { disposition: "rejected", reason: "scope_mismatch" }; } return { disposition: "accepted", scope: legacyScope }; @@ -71,7 +73,7 @@ export function resolveIncomingScope( if (!isAppScope(scope.app_scope) || typeof scope.conversation_id !== "string" || !scope.conversation_id) { return { disposition: "rejected", reason: "invalid_scope" }; } - if (scope.conversation_id !== expected.conversation_id || (expected.acceptsAppScope ? !expected.acceptsAppScope(scope.app_scope) : scope.app_scope !== expected.app_scope)) { + if (!matchesConversation(scope.conversation_id) || (expected.acceptsAppScope ? !expected.acceptsAppScope(scope.app_scope) : scope.app_scope !== expected.app_scope)) { return { disposition: "rejected", reason: "scope_mismatch" }; } if ((scope.instance_id !== undefined && typeof scope.instance_id !== "string") || (scope.operation_id !== undefined && typeof scope.operation_id !== "string")) { diff --git a/tauri/src/runtime/coordination/tool-router.ts b/tauri/src/runtime/coordination/tool-router.ts index 8626a5b..93d9b32 100644 --- a/tauri/src/runtime/coordination/tool-router.ts +++ b/tauri/src/runtime/coordination/tool-router.ts @@ -19,20 +19,20 @@ export class ToolRouter { public constructor(private readonly apps: CoreAppRegistry) {} /** Returns pass for ordinary protocol messages, preserving 00.base handling. */ - public route(raw: string, expected: { conversation_id: string; app_scope: AppScope; inventory_revision?: string }): ToolRoute { + public route(raw: string, expected: { conversation_id: string; conversation_ids?: readonly string[]; app_scope: AppScope; inventory_revision?: string }): ToolRoute { const parsed = parseEnvelopeJSON(raw); if (!parsed.ok) return { disposition: "pass" }; const envelope = parsed.envelope; - if (envelope.type === "lineup.v1.interaction.dismiss") return this.routeDismiss(envelope, expected.conversation_id); + if (envelope.type === "lineup.v1.interaction.dismiss") return this.routeDismiss(envelope, expected); if (envelope.type === "lineup.v1.miniapp.tool.call") return this.routeMiniApp(envelope, expected); - if (envelope.type === "lineup.v1.tool.call") return this.routeInteractive(envelope, expected.conversation_id); + if (envelope.type === "lineup.v1.tool.call") return this.routeInteractive(envelope, expected); if (envelope.type !== "lineup.v1.app.call" || envelope.payload.capability !== "runtime.launch_app") return { disposition: "pass" }; - if (envelope.conversation_id !== expected.conversation_id) return { disposition: "rejected", code: "scope_mismatch" }; + if (!matchesConversation(envelope.conversation_id, expected)) return { disposition: "rejected", code: "scope_mismatch" }; return this.routeLaunch(envelope); } private routeMiniApp(envelope: Envelope, expected: { conversation_id: string; inventory_revision?: string }): ToolRoute { - if (envelope.conversation_id !== expected.conversation_id) return { disposition: "rejected", code: "scope_mismatch" }; + if (!matchesConversation(envelope.conversation_id, expected)) return { disposition: "rejected", code: "scope_mismatch" }; const callID = text(envelope.payload.call_id); const toolID = text(envelope.payload.tool_id); const appScope = text(envelope.payload.app_scope); @@ -51,8 +51,8 @@ export class ToolRouter { return { disposition: "miniapp", call: { call_id: callID, tool_id: toolID, app_scope: appScope, inventory_revision: revision, input, ...(instanceID ? { instance_id: instanceID } : {}) }, handling: definition.handling, app_scope: appScope, ...(instanceID ? { instance_id: instanceID } : {}), requires_foreground: definition.target?.requires_foreground ?? true, restore_previous_focus: definition.target?.restore_previous_focus ?? false }; } - private routeDismiss(envelope: Envelope, expectedConversationID: string): ToolRoute { - if (envelope.conversation_id !== expectedConversationID) return { disposition: "rejected", code: "scope_mismatch" }; + private routeDismiss(envelope: Envelope, expected: { conversation_id: string; conversation_ids?: readonly string[] }): ToolRoute { + if (!matchesConversation(envelope.conversation_id, expected)) return { disposition: "rejected", code: "scope_mismatch" }; const controlID = text(envelope.payload.control_id); const callID = text(envelope.payload.call_id); if (!controlID || !callID || Object.keys(envelope.payload).some(key => key !== "control_id" && key !== "call_id" && key !== "reason")) return { disposition: "rejected", code: "invalid_request" }; @@ -79,8 +79,8 @@ export class ToolRouter { return { disposition: "launch", call_id: callID, app_scope: record.app_scope, ...(instanceID ? { instance_id: instanceID } : {}), arguments: parameters }; } - private routeInteractive(envelope: Envelope, expectedConversationID: string): ToolRoute { - if (envelope.conversation_id !== expectedConversationID) return { disposition: "rejected", code: "scope_mismatch" }; + private routeInteractive(envelope: Envelope, expected: { conversation_id: string; conversation_ids?: readonly string[] }): ToolRoute { + if (!matchesConversation(envelope.conversation_id, expected)) return { disposition: "rejected", code: "scope_mismatch" }; const tool = text(envelope.payload.tool); if (tool !== "notice" && tool !== "choice" && tool !== "confirm" && tool !== "input") return { disposition: "pass" }; const callID = text(envelope.payload.call_id); @@ -94,3 +94,6 @@ export class ToolRouter { function object(value: unknown): JsonObject | undefined { return value && typeof value === "object" && !Array.isArray(value) ? value as JsonObject : undefined; } function text(value: unknown): string | undefined { return typeof value === "string" && value.trim() ? value : undefined; } +function matchesConversation(value: unknown, expected: { conversation_id: string; conversation_ids?: readonly string[] }): boolean { + return typeof value === "string" && (value === expected.conversation_id || expected.conversation_ids?.includes(value) === true); +} diff --git a/迭代/03.sdk_and_coreapp/03.sdk_and_coreapp.md b/迭代/03.sdk_and_coreapp/03.sdk_and_coreapp.md index a4630f9..b5625e0 100644 --- a/迭代/03.sdk_and_coreapp/03.sdk_and_coreapp.md +++ b/迭代/03.sdk_and_coreapp/03.sdk_and_coreapp.md @@ -1,7 +1,7 @@ # LineUp App 迭代定义:MiniApp SDK v1 与内置参考 MiniApp **迭代编号:** 03.sdk_and_coreapp -**状态:** 实现进行中(核心 Runtime / SDK 闭环已完成,真实 Host 浏览器验收与最终提交尚未完成) +**状态:** 已完成(核心 Runtime / SDK 闭环、自动化验证和本地 Web Host 验收已完成;真实 AppServer 旧 Web Host 进程需重启后才能加载最新构建) **日期:** 2026-08-05 **前置基线:** [00.base.md](../00.base/00.base.md)、[01.kernel.md](../01.kernel/01.kernel.md) **权威架构:** [APP架构设计.md](../../APP架构设计.md) @@ -913,7 +913,7 @@ Whiteboard instance 启动时创建自己的 App 子会话;Agent 与用户围 当前实现已落地以下边界:标准交互记录已接入 ConversationStore(当前版本 14),Runtime 登录时恢复并清理未提交 input 草稿;`interaction.dismiss` 已由 Tool Router 接入 Runtime;默认注册表已使用 `system / bundled`,并内置 Task Dashboard、Whiteboard 的 Manifest、受限 SDK 入口和参考实现。bundled MiniApp 的 Surface 请求现在由 Runtime 在校验 instance / scope 后发出本地事件,再由 Host 挂载、更新或卸载隔离 Surface,不再伪装成发给 Agent 的 UI 协议消息。Task Dashboard、Whiteboard 已通过同一套 SDK 自动装配,覆盖 Tool、progress/result、Surface 和生命周期;App Inbox 已按 instance 过滤并在 ACK 时再次校验;普通 Tool 的 `submitted` outbox 重启恢复、App 子会话只读历史和“继续处理”(新 instance / 新子会话)已有实现与自动化测试。 -当前仍未宣称完成:真实 Tauri/Web Host 的浏览器验收尚未取得有效快照证据;`agent-browser` 在当前受限环境中无法创建其默认 Unix socket,Web Host 只能确认已监听 `0.0.0.0:1420`。此外,Chat 仍保留少量旧 SDK 交互兼容旁路,后续需在不改变已冻结协议的前提下完成清理。最终提交前必须重新运行完整测试、构建、`git diff --check`,并对登录、标准交互、Task Dashboard、Whiteboard、App 关闭/恢复和子会话折叠逐项留存验收证据。 +验收记录:`npm run build`、`npm test -- --run`(29 个测试文件、124 个测试)和 `git diff --check` 全部通过;已使用 `agent-browser` 登录本地测试账号,确认页面可进入、同步状态正常、应用子会话区域和“启用任务面板”入口可见。期间发现 AppServer 频道同步会返回其他用户的消息,Runtime 已增加发送者过滤;同时兼容 AppServer 使用的 `lineup:::` 会话键,并将出站协议统一改为该格式。当前 1420 端口的旧 Web Host 进程未重启,仍可能展示修复前的旧控制台日志;重启 Web Host 后即可加载最新构建。 本迭代预计在现有目录中演进,不重建并行 Runtime: