fix(runtime): align AppServer conversation scope
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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")) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user