mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 02:06:43 +00:00
feat(dashboard): pinned MCP apps — board rendering, lease re-mint, durable tool grants (#111524)
* feat(boards): mint pinned MCP app views * feat(ui): render pinned MCP apps on dashboards * fix(dashboard): reconcile MCP app rebase * fix(dashboard): align generated CI contracts * test(gateway): record board.widget.appView release train * fix(dashboard): revalidate pinned MCP app views
This commit is contained in:
@@ -12,6 +12,7 @@ Docs: https://docs.openclaw.ai
|
||||
- **Control UI user profiles:** let trusted-proxy users manage their own display name and avatar, resolve attributed chat and presence identities through uploaded avatars or a private cached Gravatar proxy, and keep other users' profiles admin-only.
|
||||
- **Trusted-proxy browser pairing:** optionally auto-approve new Control UI and WebChat devices from allowlisted proxy identities with non-admin scope caps, while keeping existing-device upgrades manual.
|
||||
- **Channel plugin ingress monitors:** add a shared plugin SDK monitor for durable admission, polling, pruning, claim identity validation, adoption handoff, and shutdown, and migrate IRC, Synology Chat, and Google Chat to the shared lifecycle.
|
||||
- **Dashboard MCP apps:** pin originating-session MCP app views as living dashboard widgets, renew their sandboxed view leases, and keep tool interactivity behind revision-bound grants with graceful stale-state recovery.
|
||||
- **External gateway supervision:** add `OPENCLAW_SUPERVISOR_MODE=external` for lifecycle owners such as OCM, preserving verified restart and deferral behavior without exposing native service authority, blocking native service mutation and self-update, and providing a versioned atomic restart-handoff consume contract. Thanks @shakkernerd.
|
||||
- **ClickClack guided setup:** configure ClickClack from `openclaw onboard` or `openclaw channels add clickclack` with URL, token, and workspace prompts, default-account env fallback, nonfatal live connection validation, and gateway-aware next steps that connect automatically when OpenClaw is already running. Thanks @shakkernerd.
|
||||
- **ClickClack command menus:** publish each bot's native OpenClaw commands to ClickClack composer autocomplete at gateway startup, with per-account opt-out and nonfatal compatibility handling for older tokens and servers. Thanks @shakkernerd.
|
||||
|
||||
@@ -235,6 +235,7 @@ enum class GatewayMethod(
|
||||
BoardUpdate("board.update"),
|
||||
BoardWidgetPut("board.widget.put"),
|
||||
BoardWidgetGrant("board.widget.grant"),
|
||||
BoardWidgetAppView("board.widget.appView"),
|
||||
BoardEvent("board.event"),
|
||||
AuditList("audit.list"),
|
||||
AuditActivityList("audit.activity.list"),
|
||||
|
||||
@@ -244,6 +244,7 @@ public struct BoardWidget: Codable, Sendable {
|
||||
public let position: Int
|
||||
public let grantstate: AnyCodable
|
||||
public let revision: Int
|
||||
public let instanceid: String?
|
||||
public let declaredsummary: [String]?
|
||||
public let frameurl: String?
|
||||
|
||||
@@ -257,6 +258,7 @@ public struct BoardWidget: Codable, Sendable {
|
||||
position: Int,
|
||||
grantstate: AnyCodable,
|
||||
revision: Int,
|
||||
instanceid: String? = nil,
|
||||
declaredsummary: [String]? = nil,
|
||||
frameurl: String? = nil)
|
||||
{
|
||||
@@ -269,6 +271,7 @@ public struct BoardWidget: Codable, Sendable {
|
||||
self.position = position
|
||||
self.grantstate = grantstate
|
||||
self.revision = revision
|
||||
self.instanceid = instanceid
|
||||
self.declaredsummary = declaredsummary
|
||||
self.frameurl = frameurl
|
||||
}
|
||||
@@ -283,6 +286,7 @@ public struct BoardWidget: Codable, Sendable {
|
||||
case position
|
||||
case grantstate = "grantState"
|
||||
case revision
|
||||
case instanceid = "instanceId"
|
||||
case declaredsummary = "declaredSummary"
|
||||
case frameurl = "frameUrl"
|
||||
}
|
||||
@@ -510,6 +514,40 @@ public struct BoardMcpAppDescriptor: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct BoardMcpAppPinDescriptor: Codable, Sendable {
|
||||
public let viewid: String
|
||||
public let servername: String
|
||||
public let toolname: String
|
||||
public let uiresourceuri: String
|
||||
public let originsessionkey: String
|
||||
public let toolcallid: String
|
||||
|
||||
public init(
|
||||
viewid: String,
|
||||
servername: String,
|
||||
toolname: String,
|
||||
uiresourceuri: String,
|
||||
originsessionkey: String,
|
||||
toolcallid: String)
|
||||
{
|
||||
self.viewid = viewid
|
||||
self.servername = servername
|
||||
self.toolname = toolname
|
||||
self.uiresourceuri = uiresourceuri
|
||||
self.originsessionkey = originsessionkey
|
||||
self.toolcallid = toolcallid
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case viewid = "viewId"
|
||||
case servername = "serverName"
|
||||
case toolname = "toolName"
|
||||
case uiresourceuri = "uiResourceUri"
|
||||
case originsessionkey = "originSessionKey"
|
||||
case toolcallid = "toolCallId"
|
||||
}
|
||||
}
|
||||
|
||||
public struct BoardWidgetHtmlContent: Codable, Sendable {
|
||||
public let kind: String
|
||||
public let html: String
|
||||
@@ -546,6 +584,24 @@ public struct BoardWidgetMcpAppContent: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct BoardWidgetMcpAppPutContent: Codable, Sendable {
|
||||
public let kind: String
|
||||
public let descriptor: BoardMcpAppPinDescriptor
|
||||
|
||||
public init(
|
||||
kind: String,
|
||||
descriptor: BoardMcpAppPinDescriptor)
|
||||
{
|
||||
self.kind = kind
|
||||
self.descriptor = descriptor
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case kind
|
||||
case descriptor
|
||||
}
|
||||
}
|
||||
|
||||
public struct BoardCanvasDocumentSource: Codable, Sendable {
|
||||
public let kind: String
|
||||
public let docid: String
|
||||
@@ -635,17 +691,20 @@ public struct BoardWidgetGrantParams: Codable, Sendable {
|
||||
public let name: String
|
||||
public let decision: AnyCodable
|
||||
public let revision: Int
|
||||
public let instanceid: String?
|
||||
|
||||
public init(
|
||||
sessionkey: String,
|
||||
name: String,
|
||||
decision: AnyCodable,
|
||||
revision: Int)
|
||||
revision: Int,
|
||||
instanceid: String? = nil)
|
||||
{
|
||||
self.sessionkey = sessionkey
|
||||
self.name = name
|
||||
self.decision = decision
|
||||
self.revision = revision
|
||||
self.instanceid = instanceid
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
@@ -653,6 +712,51 @@ public struct BoardWidgetGrantParams: Codable, Sendable {
|
||||
case name
|
||||
case decision
|
||||
case revision
|
||||
case instanceid = "instanceId"
|
||||
}
|
||||
}
|
||||
|
||||
public struct BoardWidgetAppViewParams: Codable, Sendable {
|
||||
public let sessionkey: String
|
||||
public let name: String
|
||||
public let revision: Int
|
||||
public let instanceid: String?
|
||||
|
||||
public init(
|
||||
sessionkey: String,
|
||||
name: String,
|
||||
revision: Int,
|
||||
instanceid: String? = nil)
|
||||
{
|
||||
self.sessionkey = sessionkey
|
||||
self.name = name
|
||||
self.revision = revision
|
||||
self.instanceid = instanceid
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case sessionkey = "sessionKey"
|
||||
case name
|
||||
case revision
|
||||
case instanceid = "instanceId"
|
||||
}
|
||||
}
|
||||
|
||||
public struct BoardWidgetAppViewResult: Codable, Sendable {
|
||||
public let viewid: String
|
||||
public let expiresatms: Int
|
||||
|
||||
public init(
|
||||
viewid: String,
|
||||
expiresatms: Int)
|
||||
{
|
||||
self.viewid = viewid
|
||||
self.expiresatms = expiresatms
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case viewid = "viewId"
|
||||
case expiresatms = "expiresAtMs"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1150,7 +1254,7 @@ public struct ErrorShape: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct GatewayErrorDetails: Codable, Sendable {
|
||||
public struct MissingScopeErrorDetails: Codable, Sendable {
|
||||
public let code: String
|
||||
public let missingscope: String
|
||||
public let requiredscopes: [String]
|
||||
@@ -1172,6 +1276,20 @@ public struct GatewayErrorDetails: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct McpAppViewExpiredErrorDetails: Codable, Sendable {
|
||||
public let code: String
|
||||
|
||||
public init(
|
||||
code: String)
|
||||
{
|
||||
self.code = code
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case code
|
||||
}
|
||||
}
|
||||
|
||||
public struct GatewaySuspendTaskBlocker: Codable, Sendable {
|
||||
public let taskid: String
|
||||
public let status: String
|
||||
@@ -15578,7 +15696,7 @@ public enum BoardWidgetContent: Codable, Sendable {
|
||||
|
||||
public enum BoardWidgetPutContent: Codable, Sendable {
|
||||
case html(BoardWidgetHtmlContent)
|
||||
case mcpApp(BoardWidgetMcpAppContent)
|
||||
case mcpApp(BoardWidgetMcpAppPutContent)
|
||||
case canvasDoc(BoardCanvasDocumentSource)
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
@@ -15590,7 +15708,7 @@ public enum BoardWidgetPutContent: Codable, Sendable {
|
||||
let discriminator = try container.decode(String.self, forKey: .discriminator)
|
||||
switch discriminator {
|
||||
case "html": self = try .html(BoardWidgetHtmlContent(from: decoder))
|
||||
case "mcp-app": self = try .mcpApp(BoardWidgetMcpAppContent(from: decoder))
|
||||
case "mcp-app": self = try .mcpApp(BoardWidgetMcpAppPutContent(from: decoder))
|
||||
case "canvas-doc": self = try .canvasDoc(BoardCanvasDocumentSource(from: decoder))
|
||||
default:
|
||||
throw DecodingError.dataCorruptedError(
|
||||
@@ -15641,6 +15759,64 @@ public enum BoardCommand: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public enum GatewayErrorDetails: Codable, Sendable {
|
||||
case missingScope(MissingScopeErrorDetails)
|
||||
case mcpAppViewExpired(McpAppViewExpiredErrorDetails)
|
||||
|
||||
public init(code: String, missingscope: String, requiredscopes: [String]) {
|
||||
self = .missingScope(
|
||||
MissingScopeErrorDetails(
|
||||
code: code,
|
||||
missingscope: missingscope,
|
||||
requiredscopes: requiredscopes
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
public var code: String {
|
||||
switch self {
|
||||
case .missingScope(let value): value.code
|
||||
case .mcpAppViewExpired(let value): value.code
|
||||
}
|
||||
}
|
||||
|
||||
public var missingscope: String {
|
||||
if case .missingScope(let value) = self { return value.missingscope }
|
||||
return ""
|
||||
}
|
||||
|
||||
public var requiredscopes: [String] {
|
||||
if case .missingScope(let value) = self { return value.requiredscopes }
|
||||
return []
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case discriminator = "code"
|
||||
}
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
let discriminator = try container.decode(String.self, forKey: .discriminator)
|
||||
switch discriminator {
|
||||
case "MISSING_SCOPE": self = try .missingScope(MissingScopeErrorDetails(from: decoder))
|
||||
case "MCP_APP_VIEW_EXPIRED": self = try .mcpAppViewExpired(McpAppViewExpiredErrorDetails(from: decoder))
|
||||
default:
|
||||
throw DecodingError.dataCorruptedError(
|
||||
forKey: .discriminator,
|
||||
in: container,
|
||||
debugDescription: "Unknown GatewayErrorDetails discriminator value"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
public func encode(to encoder: Encoder) throws {
|
||||
switch self {
|
||||
case .missingScope(let value): try value.encode(to: encoder)
|
||||
case .mcpAppViewExpired(let value): try value.encode(to: encoder)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public enum GatewaySuspendPrepareResult: Codable, Sendable {
|
||||
case busy(GatewaySuspendPrepareBusyResult)
|
||||
case ready(GatewaySuspendPrepareReadyResult)
|
||||
|
||||
@@ -22,6 +22,7 @@ export type ErrorCode = (typeof ErrorCodes)[keyof typeof ErrorCodes];
|
||||
/** Stable discriminants for structured method-level authorization failures. */
|
||||
export const GatewayErrorDetailCodes = {
|
||||
MISSING_SCOPE: "MISSING_SCOPE",
|
||||
MCP_APP_VIEW_EXPIRED: "MCP_APP_VIEW_EXPIRED",
|
||||
} as const;
|
||||
|
||||
/** Missing operator-scope details shared by WebSocket and HTTP responses. */
|
||||
@@ -31,8 +32,12 @@ export type MissingScopeErrorDetails = {
|
||||
requiredScopes: string[];
|
||||
};
|
||||
|
||||
export type McpAppViewExpiredErrorDetails = {
|
||||
code: typeof GatewayErrorDetailCodes.MCP_APP_VIEW_EXPIRED;
|
||||
};
|
||||
|
||||
/** Structured details emitted by method-level authorization failures. */
|
||||
export type GatewayErrorDetails = MissingScopeErrorDetails;
|
||||
export type GatewayErrorDetails = MissingScopeErrorDetails | McpAppViewExpiredErrorDetails;
|
||||
|
||||
type GatewayErrorLike = {
|
||||
code?: unknown;
|
||||
@@ -69,6 +74,11 @@ export function readMissingScopeErrorDetails(details: unknown): MissingScopeErro
|
||||
};
|
||||
}
|
||||
|
||||
export function isMcpAppViewExpiredError(error: unknown): boolean {
|
||||
const record = asRecord(error);
|
||||
return asRecord(record?.details)?.code === GatewayErrorDetailCodes.MCP_APP_VIEW_EXPIRED;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads a method-level missing-scope failure, preferring structured details.
|
||||
* The message fallback keeps clients compatible with gateways predating structured details.
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
export * from "./clawhub-trust-error-details.js";
|
||||
export * from "./system-agent-error-details.js";
|
||||
export { readMissingScopeError, readMissingScopeErrorDetails } from "./gateway-error-details.js";
|
||||
export {
|
||||
isMcpAppViewExpiredError,
|
||||
readMissingScopeError,
|
||||
readMissingScopeErrorDetails,
|
||||
} from "./gateway-error-details.js";
|
||||
export * from "./session-icon.js";
|
||||
export * from "./terminal-validators.js";
|
||||
export {
|
||||
@@ -15,7 +19,11 @@ export type { ProtocolValidator } from "./protocol-validator.js";
|
||||
export * from "./schema/worker-inference.js";
|
||||
export * from "./schema/skill-history.js";
|
||||
export * from "./schema/ui-command.js";
|
||||
export type { GatewayErrorDetails, MissingScopeErrorDetails } from "./schema/error-codes.js";
|
||||
export type {
|
||||
GatewayErrorDetails,
|
||||
McpAppViewExpiredErrorDetails,
|
||||
MissingScopeErrorDetails,
|
||||
} from "./schema/error-codes.js";
|
||||
export * from "./schema/board.js";
|
||||
export * from "./migration-api.js";
|
||||
export type * from "./public-session-catalog.js";
|
||||
@@ -24,6 +32,7 @@ import {
|
||||
BoardGetParamsSchema,
|
||||
BoardUpdateParamsSchema,
|
||||
BoardWidgetContentSchema,
|
||||
BoardWidgetAppViewParamsSchema,
|
||||
BoardWidgetGrantParamsSchema,
|
||||
BoardWidgetPutParamsSchema,
|
||||
AgentEventSchema,
|
||||
@@ -671,6 +680,7 @@ export const validateWorktreesListParams = lazyCompile(WorktreesListParamsSchema
|
||||
export const validateBoardGetParams = lazyCompile(BoardGetParamsSchema);
|
||||
export const validateBoardUpdateParams = lazyCompile(BoardUpdateParamsSchema);
|
||||
export const validateBoardWidgetContent = lazyCompile(BoardWidgetContentSchema);
|
||||
export const validateBoardWidgetAppViewParams = lazyCompile(BoardWidgetAppViewParamsSchema);
|
||||
export const validateBoardWidgetPutParams = lazyCompile(BoardWidgetPutParamsSchema);
|
||||
export const validateBoardWidgetGrantParams = lazyCompile(BoardWidgetGrantParamsSchema);
|
||||
export const validateBoardEventParams = lazyCompile(BoardEventParamsSchema);
|
||||
|
||||
@@ -2,6 +2,8 @@ import { Value } from "typebox/value";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
BoardSnapshotSchema,
|
||||
BoardWidgetAppViewParamsSchema,
|
||||
BoardWidgetAppViewResultSchema,
|
||||
BoardWidgetGrantParamsSchema,
|
||||
BoardWidgetPutParamsSchema,
|
||||
} from "./board.js";
|
||||
@@ -62,6 +64,12 @@ describe("BoardSnapshotSchema", () => {
|
||||
};
|
||||
|
||||
expect(Value.Check(BoardSnapshotSchema, snapshot)).toBe(true);
|
||||
expect(
|
||||
Value.Check(BoardSnapshotSchema, {
|
||||
...snapshot,
|
||||
widgets: [{ ...widget, instanceId: "widget-instance" }],
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -75,6 +83,59 @@ describe("BoardWidgetPutParamsSchema", () => {
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("requires an active source view for MCP App pins", () => {
|
||||
const pin = {
|
||||
sessionKey: "agent:main:main",
|
||||
name: "weather-app",
|
||||
content: {
|
||||
kind: "mcp-app",
|
||||
descriptor: {
|
||||
viewId: "mcp-app-source",
|
||||
serverName: "weather",
|
||||
toolName: "show",
|
||||
uiResourceUri: "ui://weather/app",
|
||||
originSessionKey: "agent:main:main",
|
||||
toolCallId: "call-1",
|
||||
},
|
||||
},
|
||||
};
|
||||
expect(Value.Check(BoardWidgetPutParamsSchema, pin)).toBe(true);
|
||||
expect(
|
||||
Value.Check(BoardWidgetPutParamsSchema, {
|
||||
...pin,
|
||||
content: {
|
||||
...pin.content,
|
||||
descriptor: { ...pin.content.descriptor, viewId: undefined },
|
||||
},
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("BoardWidgetAppView schemas", () => {
|
||||
it("binds lease requests to a board widget and returns its expiry", () => {
|
||||
expect(
|
||||
Value.Check(BoardWidgetAppViewParamsSchema, {
|
||||
sessionKey: "agent:main:main",
|
||||
name: "weather-app",
|
||||
revision: 3,
|
||||
instanceId: "widget-instance",
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
Value.Check(BoardWidgetAppViewParamsSchema, {
|
||||
sessionKey: "agent:main:main",
|
||||
name: "weather-app",
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
Value.Check(BoardWidgetAppViewResultSchema, {
|
||||
viewId: "mcp-app-fresh",
|
||||
expiresAtMs: 1_800_000,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("BoardWidgetGrantParamsSchema", () => {
|
||||
@@ -85,6 +146,7 @@ describe("BoardWidgetGrantParamsSchema", () => {
|
||||
name: "status",
|
||||
decision: "granted",
|
||||
revision: 1,
|
||||
instanceId: "widget-instance",
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
|
||||
@@ -44,6 +44,7 @@ export const BoardWidgetSchema = closedObject({
|
||||
Type.Literal("rejected"),
|
||||
]),
|
||||
revision: Type.Integer({ minimum: 1 }),
|
||||
instanceId: Type.Optional(NonEmptyString),
|
||||
declaredSummary: Type.Optional(Type.Array(Type.String())),
|
||||
frameUrl: Type.Optional(Type.String()),
|
||||
});
|
||||
@@ -124,6 +125,16 @@ export const BoardMcpAppDescriptorSchema = closedObject({
|
||||
});
|
||||
export type BoardMcpAppDescriptor = Static<typeof BoardMcpAppDescriptorSchema>;
|
||||
|
||||
export const BoardMcpAppPinDescriptorSchema = closedObject({
|
||||
viewId: NonEmptyString,
|
||||
serverName: NonEmptyString,
|
||||
toolName: NonEmptyString,
|
||||
uiResourceUri: NonEmptyString,
|
||||
originSessionKey: NonEmptyString,
|
||||
toolCallId: NonEmptyString,
|
||||
});
|
||||
export type BoardMcpAppPinDescriptor = Static<typeof BoardMcpAppPinDescriptorSchema>;
|
||||
|
||||
export const BoardWidgetHtmlContentSchema = closedObject({
|
||||
kind: Type.Literal("html"),
|
||||
html: Type.String({ maxLength: 262_144 }),
|
||||
@@ -132,6 +143,10 @@ export const BoardWidgetMcpAppContentSchema = closedObject({
|
||||
kind: Type.Literal("mcp-app"),
|
||||
descriptor: BoardMcpAppDescriptorSchema,
|
||||
});
|
||||
export const BoardWidgetMcpAppPutContentSchema = closedObject({
|
||||
kind: Type.Literal("mcp-app"),
|
||||
descriptor: BoardMcpAppPinDescriptorSchema,
|
||||
});
|
||||
export const BoardWidgetContentSchema = Type.Union([
|
||||
BoardWidgetHtmlContentSchema,
|
||||
BoardWidgetMcpAppContentSchema,
|
||||
@@ -146,7 +161,7 @@ export type BoardCanvasDocumentSource = Static<typeof BoardCanvasDocumentSourceS
|
||||
|
||||
export const BoardWidgetPutContentSchema = Type.Union([
|
||||
BoardWidgetHtmlContentSchema,
|
||||
BoardWidgetMcpAppContentSchema,
|
||||
BoardWidgetMcpAppPutContentSchema,
|
||||
BoardCanvasDocumentSourceSchema,
|
||||
]);
|
||||
export type BoardWidgetPutContent = Static<typeof BoardWidgetPutContentSchema>;
|
||||
@@ -181,9 +196,24 @@ export const BoardWidgetGrantParamsSchema = closedObject({
|
||||
name: BoardWidgetNameSchema,
|
||||
decision: Type.Union([Type.Literal("granted"), Type.Literal("rejected")]),
|
||||
revision: Type.Integer({ minimum: 1 }),
|
||||
instanceId: Type.Optional(NonEmptyString),
|
||||
});
|
||||
export type BoardWidgetGrantParams = Static<typeof BoardWidgetGrantParamsSchema>;
|
||||
|
||||
export const BoardWidgetAppViewParamsSchema = closedObject({
|
||||
sessionKey: NonEmptyString,
|
||||
name: BoardWidgetNameSchema,
|
||||
revision: Type.Integer({ minimum: 1 }),
|
||||
instanceId: Type.Optional(NonEmptyString),
|
||||
});
|
||||
export type BoardWidgetAppViewParams = Static<typeof BoardWidgetAppViewParamsSchema>;
|
||||
|
||||
export const BoardWidgetAppViewResultSchema = closedObject({
|
||||
viewId: NonEmptyString,
|
||||
expiresAtMs: Type.Integer({ minimum: 0 }),
|
||||
});
|
||||
export type BoardWidgetAppViewResult = Static<typeof BoardWidgetAppViewResultSchema>;
|
||||
|
||||
export const BoardEventParamsSchema = closedObject({
|
||||
sessionKey: NonEmptyString,
|
||||
widget: BoardWidgetNameSchema,
|
||||
|
||||
@@ -4,6 +4,8 @@ import {
|
||||
ErrorCodes,
|
||||
GatewayErrorDetailCodes,
|
||||
GatewayErrorDetailsSchema,
|
||||
isMcpAppViewExpiredError,
|
||||
McpAppViewExpiredErrorDetailsSchema,
|
||||
MissingScopeErrorDetailsSchema,
|
||||
missingScopeErrorShape,
|
||||
readMissingScopeError,
|
||||
@@ -25,6 +27,14 @@ describe("gateway error details", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("identifies MCP App lease expiry without message parsing", () => {
|
||||
const details = { code: GatewayErrorDetailCodes.MCP_APP_VIEW_EXPIRED };
|
||||
expect(Value.Check(McpAppViewExpiredErrorDetailsSchema, details)).toBe(true);
|
||||
expect(Value.Check(GatewayErrorDetailsSchema, details)).toBe(true);
|
||||
expect(isMcpAppViewExpiredError({ details })).toBe(true);
|
||||
expect(isMcpAppViewExpiredError(new Error("upstream token expired"))).toBe(false);
|
||||
});
|
||||
|
||||
it("builds a distinct forbidden missing-scope response", () => {
|
||||
expect(
|
||||
missingScopeErrorShape({
|
||||
|
||||
@@ -15,7 +15,9 @@ export {
|
||||
GatewayErrorDetailCodes,
|
||||
type ErrorCode,
|
||||
type GatewayErrorDetails,
|
||||
type McpAppViewExpiredErrorDetails,
|
||||
type MissingScopeErrorDetails,
|
||||
isMcpAppViewExpiredError,
|
||||
readMissingScopeError,
|
||||
readMissingScopeErrorDetails,
|
||||
} from "../gateway-error-details.js";
|
||||
@@ -27,8 +29,15 @@ export const MissingScopeErrorDetailsSchema = closedObject({
|
||||
requiredScopes: Type.Array(NonEmptyString, { minItems: 1 }),
|
||||
});
|
||||
|
||||
export const McpAppViewExpiredErrorDetailsSchema = closedObject({
|
||||
code: Type.Literal(GatewayErrorDetailCodes.MCP_APP_VIEW_EXPIRED),
|
||||
});
|
||||
|
||||
/** Structured details emitted by method-level authorization failures. */
|
||||
export const GatewayErrorDetailsSchema = MissingScopeErrorDetailsSchema;
|
||||
export const GatewayErrorDetailsSchema = Type.Union([
|
||||
MissingScopeErrorDetailsSchema,
|
||||
McpAppViewExpiredErrorDetailsSchema,
|
||||
]);
|
||||
|
||||
/** Builds the canonical gateway error payload while preserving optional retry metadata. */
|
||||
export function errorShape(
|
||||
|
||||
@@ -160,6 +160,7 @@ import {
|
||||
BoardFocusTabCommandSchema,
|
||||
BoardGetParamsSchema,
|
||||
BoardMcpAppDescriptorSchema,
|
||||
BoardMcpAppPinDescriptorSchema,
|
||||
BoardOpSchema,
|
||||
BoardSetChatDockCommandSchema,
|
||||
BoardSnapshotSchema,
|
||||
@@ -170,9 +171,12 @@ import {
|
||||
BoardTabUpdateOpSchema,
|
||||
BoardUpdateParamsSchema,
|
||||
BoardWidgetContentSchema,
|
||||
BoardWidgetAppViewParamsSchema,
|
||||
BoardWidgetAppViewResultSchema,
|
||||
BoardWidgetGrantParamsSchema,
|
||||
BoardWidgetHtmlContentSchema,
|
||||
BoardWidgetMcpAppContentSchema,
|
||||
BoardWidgetMcpAppPutContentSchema,
|
||||
BoardWidgetMoveOpSchema,
|
||||
BoardWidgetPutContentSchema,
|
||||
BoardWidgetPutParamsSchema,
|
||||
@@ -280,7 +284,11 @@ import {
|
||||
WorkerEnvironmentStateSchema,
|
||||
WorkerTunnelStatusSchema,
|
||||
} from "./environments.js";
|
||||
import { GatewayErrorDetailsSchema } from "./error-codes.js";
|
||||
import {
|
||||
GatewayErrorDetailsSchema,
|
||||
McpAppViewExpiredErrorDetailsSchema,
|
||||
MissingScopeErrorDetailsSchema,
|
||||
} from "./error-codes.js";
|
||||
import {
|
||||
ExecApprovalsGetParamsSchema,
|
||||
ExecApprovalsNodeGetParamsSchema,
|
||||
@@ -600,8 +608,10 @@ export const ProtocolSchemas = {
|
||||
BoardWidgetRemoveOp: BoardWidgetRemoveOpSchema,
|
||||
BoardOp: BoardOpSchema,
|
||||
BoardMcpAppDescriptor: BoardMcpAppDescriptorSchema,
|
||||
BoardMcpAppPinDescriptor: BoardMcpAppPinDescriptorSchema,
|
||||
BoardWidgetHtmlContent: BoardWidgetHtmlContentSchema,
|
||||
BoardWidgetMcpAppContent: BoardWidgetMcpAppContentSchema,
|
||||
BoardWidgetMcpAppPutContent: BoardWidgetMcpAppPutContentSchema,
|
||||
BoardCanvasDocumentSource: BoardCanvasDocumentSourceSchema,
|
||||
BoardWidgetContent: BoardWidgetContentSchema,
|
||||
BoardWidgetPutContent: BoardWidgetPutContentSchema,
|
||||
@@ -609,6 +619,8 @@ export const ProtocolSchemas = {
|
||||
BoardUpdateParams: BoardUpdateParamsSchema,
|
||||
BoardWidgetPutParams: BoardWidgetPutParamsSchema,
|
||||
BoardWidgetGrantParams: BoardWidgetGrantParamsSchema,
|
||||
BoardWidgetAppViewParams: BoardWidgetAppViewParamsSchema,
|
||||
BoardWidgetAppViewResult: BoardWidgetAppViewResultSchema,
|
||||
BoardEventParams: BoardEventParamsSchema,
|
||||
BoardChangedEvent: BoardChangedEventSchema,
|
||||
BoardFocusTabCommand: BoardFocusTabCommandSchema,
|
||||
@@ -628,6 +640,8 @@ export const ProtocolSchemas = {
|
||||
StateVersion: StateVersionSchema,
|
||||
Snapshot: SnapshotSchema,
|
||||
ErrorShape: ErrorShapeSchema,
|
||||
MissingScopeErrorDetails: MissingScopeErrorDetailsSchema,
|
||||
McpAppViewExpiredErrorDetails: McpAppViewExpiredErrorDetailsSchema,
|
||||
GatewayErrorDetails: GatewayErrorDetailsSchema,
|
||||
GatewaySuspendTaskBlocker: GatewaySuspendTaskBlockerSchema,
|
||||
GatewaySuspendBlocker: GatewaySuspendBlockerSchema,
|
||||
|
||||
@@ -568,6 +568,43 @@ function swiftUnionCaseName(value: boolean | number | string | null, fallback: s
|
||||
return safeName(String(value));
|
||||
}
|
||||
|
||||
function emitDiscriminatedUnionCompatibility(name: string): string[] {
|
||||
if (name !== "GatewayErrorDetails") {
|
||||
return [];
|
||||
}
|
||||
// GatewayErrorDetails shipped as the missing-scope struct before it gained an expiry variant.
|
||||
// Keep its initializer and property access source-compatible while the enum carries both cases.
|
||||
return [
|
||||
" public init(code: String, missingscope: String, requiredscopes: [String]) {",
|
||||
" self = .missingScope(",
|
||||
" MissingScopeErrorDetails(",
|
||||
" code: code,",
|
||||
" missingscope: missingscope,",
|
||||
" requiredscopes: requiredscopes",
|
||||
" )",
|
||||
" )",
|
||||
" }",
|
||||
"",
|
||||
" public var code: String {",
|
||||
" switch self {",
|
||||
" case .missingScope(let value): value.code",
|
||||
" case .mcpAppViewExpired(let value): value.code",
|
||||
" }",
|
||||
" }",
|
||||
"",
|
||||
" public var missingscope: String {",
|
||||
" if case .missingScope(let value) = self { return value.missingscope }",
|
||||
' return ""',
|
||||
" }",
|
||||
"",
|
||||
" public var requiredscopes: [String] {",
|
||||
" if case .missingScope(let value) = self { return value.requiredscopes }",
|
||||
" return []",
|
||||
" }",
|
||||
"",
|
||||
];
|
||||
}
|
||||
|
||||
function emitDiscriminatedUnion(name: string, schema: JsonSchema): string | undefined {
|
||||
const branches = schema.oneOf ?? schema.anyOf;
|
||||
if (!branches || branches.length < 2) {
|
||||
@@ -627,6 +664,7 @@ function emitDiscriminatedUnion(name: string, schema: JsonSchema): string | unde
|
||||
`public enum ${name}: Codable, Sendable {`,
|
||||
...resolvedCases.map((entry) => ` case ${entry.caseName}(${entry.branchName})`),
|
||||
"",
|
||||
...emitDiscriminatedUnionCompatibility(name),
|
||||
" private enum CodingKeys: String, CodingKey {",
|
||||
` case discriminator = "${discriminator}"`,
|
||||
" }",
|
||||
|
||||
@@ -454,6 +454,7 @@ export async function materializeBundleMcpToolsForRun(params: {
|
||||
(agentResult.details as Record<string, unknown>).mcpAppPreview = buildMcpAppCanvasPayload(
|
||||
{
|
||||
...view,
|
||||
...(params.runtime.sessionKey ? { originSessionKey: params.runtime.sessionKey } : {}),
|
||||
...(result["_meta"] !== undefined ? { resultMetaState: "unavailable" as const } : {}),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -157,6 +157,7 @@ describe("createBundleMcpToolRuntime", () => {
|
||||
_meta: { "ui/state": { selected: true } },
|
||||
},
|
||||
});
|
||||
sessionRuntime.sessionKey = "agent:main:main";
|
||||
sessionRuntime.mcpAppsEnabled = true;
|
||||
sessionRuntime.readResource = async () => ({
|
||||
contents: [
|
||||
@@ -183,6 +184,7 @@ describe("createBundleMcpToolRuntime", () => {
|
||||
toolName: "show",
|
||||
uiResourceUri: "ui://demo/app",
|
||||
toolCallId: "call-1",
|
||||
originSessionKey: "agent:main:main",
|
||||
resultMetaState: "unavailable",
|
||||
},
|
||||
},
|
||||
|
||||
@@ -62,6 +62,7 @@ describe("MCP App UI resources", () => {
|
||||
},
|
||||
],
|
||||
}));
|
||||
const authorizeAppToolCall = vi.fn(async () => true);
|
||||
const result = await fetchMcpAppView({
|
||||
runtime: sessionRuntime,
|
||||
serverName: "demo",
|
||||
@@ -69,6 +70,7 @@ describe("MCP App UI resources", () => {
|
||||
uiResourceUri: "ui://demo/app",
|
||||
toolInput: { city: "Paris" },
|
||||
toolResult: { content: [{ type: "text", text: "ok" }] },
|
||||
authorizeAppToolCall,
|
||||
});
|
||||
|
||||
expect(result?.viewId).toMatch(/^mcp-app-/u);
|
||||
@@ -76,6 +78,7 @@ describe("MCP App UI resources", () => {
|
||||
html: "<html>demo</html>",
|
||||
toolInput: { city: "Paris" },
|
||||
permissions: { geolocation: {} },
|
||||
authorizeAppToolCall,
|
||||
});
|
||||
expect(
|
||||
getMcpAppViewLease(
|
||||
|
||||
@@ -26,10 +26,12 @@ export type McpAppViewLease = {
|
||||
serverName: string;
|
||||
toolName: string;
|
||||
uiResourceUri: string;
|
||||
toolCallId?: string;
|
||||
html: string;
|
||||
csp?: McpAppCsp;
|
||||
permissions?: McpAppPermissions;
|
||||
allowedAppToolNames?: ReadonlySet<string>;
|
||||
authorizeAppToolCall?: () => boolean | Promise<boolean>;
|
||||
readOnly?: true;
|
||||
toolInput: unknown;
|
||||
toolResult: CallToolResult;
|
||||
@@ -222,6 +224,7 @@ export async function fetchMcpAppView(params: {
|
||||
toolInput: unknown;
|
||||
toolResult: CallToolResult;
|
||||
allowedAppToolNames?: ReadonlySet<string>;
|
||||
authorizeAppToolCall?: () => boolean | Promise<boolean>;
|
||||
readOnly?: true;
|
||||
viewId?: string;
|
||||
}): Promise<
|
||||
@@ -276,12 +279,14 @@ export async function fetchMcpAppView(params: {
|
||||
serverName: params.serverName,
|
||||
toolName: params.toolName,
|
||||
uiResourceUri: params.uiResourceUri,
|
||||
...(params.toolCallId ? { toolCallId: params.toolCallId } : {}),
|
||||
html,
|
||||
...(csp ? { csp } : {}),
|
||||
...(permissions ? { permissions } : {}),
|
||||
...(params.allowedAppToolNames
|
||||
? { allowedAppToolNames: new Set(params.allowedAppToolNames) }
|
||||
: {}),
|
||||
...(params.authorizeAppToolCall ? { authorizeAppToolCall: params.authorizeAppToolCall } : {}),
|
||||
...(params.readOnly ? { readOnly: true as const } : {}),
|
||||
toolInput: params.toolInput,
|
||||
toolResult: params.toolResult,
|
||||
@@ -362,6 +367,7 @@ export function buildMcpAppCanvasPayload(view: {
|
||||
toolName: string;
|
||||
uiResourceUri: string;
|
||||
toolCallId?: string;
|
||||
originSessionKey?: string;
|
||||
resultMetaState?: "unavailable";
|
||||
}) {
|
||||
assertBoundedViewDescriptor(view);
|
||||
@@ -380,6 +386,7 @@ export function buildMcpAppCanvasPayload(view: {
|
||||
toolName: view.toolName,
|
||||
uiResourceUri: view.uiResourceUri,
|
||||
...(view.toolCallId ? { toolCallId: view.toolCallId } : {}),
|
||||
...(view.originSessionKey ? { originSessionKey: view.originSessionKey } : {}),
|
||||
...(view.resultMetaState ? { resultMetaState: view.resultMetaState } : {}),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -47,6 +47,7 @@ function cloneWidget(widget: BoardWidget): BoardWidget {
|
||||
position: widget.position,
|
||||
grantState: widget.grantState,
|
||||
revision: widget.revision,
|
||||
...(widget.instanceId !== undefined ? { instanceId: widget.instanceId } : {}),
|
||||
...(widget.declaredSummary !== undefined
|
||||
? { declaredSummary: [...widget.declaredSummary] }
|
||||
: {}),
|
||||
|
||||
@@ -143,6 +143,11 @@ describe.each([
|
||||
},
|
||||
revision: 1,
|
||||
});
|
||||
expect(store.readWidgetMcpApp("agent:main:board", "app")).toMatchObject({
|
||||
revision: 1,
|
||||
grantGeneration: expect.stringMatching(/^[a-f0-9]{32}$/u),
|
||||
});
|
||||
expect(store.getSnapshot("agent:main:board").widgets[1]?.instanceId).toMatch(/^[a-f0-9]{32}$/u);
|
||||
});
|
||||
|
||||
it("preserves grants only for equal or narrower declarations", () => {
|
||||
@@ -196,6 +201,86 @@ describe.each([
|
||||
expect(wider.widgets[0]).toMatchObject({ revision: 4, grantState: "pending" });
|
||||
});
|
||||
|
||||
it("requires a fresh grant when an MCP app widget changes servers", () => {
|
||||
const store = createStore();
|
||||
const descriptor = {
|
||||
serverName: "server-a",
|
||||
toolName: "weather",
|
||||
uiResourceUri: "ui://weather",
|
||||
originSessionKey: "agent:main:board",
|
||||
toolCallId: "call-a",
|
||||
};
|
||||
const first = store.putWidget({
|
||||
sessionKey: "agent:main:board",
|
||||
name: "weather",
|
||||
content: { kind: "mcp-app", descriptor },
|
||||
declared: { tools: ["refresh"] },
|
||||
});
|
||||
store.grant("agent:main:board", "weather", "granted", 1, first.widgets[0]?.instanceId);
|
||||
|
||||
const differentServer = store.putWidget({
|
||||
sessionKey: "agent:main:board",
|
||||
name: "weather",
|
||||
content: {
|
||||
kind: "mcp-app",
|
||||
descriptor: { ...descriptor, serverName: "server-b", toolCallId: "call-b" },
|
||||
},
|
||||
declared: { tools: ["refresh"] },
|
||||
});
|
||||
expect(differentServer.widgets[0]).toMatchObject({ revision: 2, grantState: "pending" });
|
||||
|
||||
store.grant(
|
||||
"agent:main:board",
|
||||
"weather",
|
||||
"granted",
|
||||
2,
|
||||
differentServer.widgets[0]?.instanceId,
|
||||
);
|
||||
const sameServer = store.putWidget({
|
||||
sessionKey: "agent:main:board",
|
||||
name: "weather",
|
||||
content: {
|
||||
kind: "mcp-app",
|
||||
descriptor: { ...descriptor, serverName: "server-b", toolCallId: "call-c" },
|
||||
},
|
||||
declared: { tools: ["refresh"] },
|
||||
});
|
||||
expect(sameServer.widgets[0]).toMatchObject({ revision: 3, grantState: "granted" });
|
||||
});
|
||||
|
||||
it("rejects a delayed MCP App grant after remove and same-name replacement", () => {
|
||||
const store = createStore();
|
||||
const putApp = (serverName: string) =>
|
||||
store.putWidget({
|
||||
sessionKey: "agent:main:board",
|
||||
name: "app",
|
||||
content: {
|
||||
kind: "mcp-app",
|
||||
descriptor: {
|
||||
serverName,
|
||||
toolName: "tool",
|
||||
uiResourceUri: `ui://${serverName}`,
|
||||
originSessionKey: "agent:main:board",
|
||||
toolCallId: `call-${serverName}`,
|
||||
},
|
||||
},
|
||||
declared: { tools: ["refresh"] },
|
||||
});
|
||||
const original = putApp("server-a");
|
||||
store.applyOps("agent:main:board", [{ kind: "widget_remove", name: "app" }]);
|
||||
const replacement = putApp("server-b");
|
||||
|
||||
expect(replacement.widgets[0]).toMatchObject({ revision: 1, grantState: "pending" });
|
||||
expect(replacement.widgets[0]?.instanceId).not.toBe(original.widgets[0]?.instanceId);
|
||||
expect(() =>
|
||||
store.grant("agent:main:board", "app", "granted", 1, original.widgets[0]?.instanceId),
|
||||
).toThrow("instance changed");
|
||||
expect(
|
||||
store.grant("agent:main:board", "app", "granted", 1, replacement.widgets[0]?.instanceId)
|
||||
.widgets[0],
|
||||
).toMatchObject({ grantState: "granted" });
|
||||
});
|
||||
|
||||
it("rejects stale grant revisions before accepting the current one", () => {
|
||||
const store = createStore();
|
||||
store.putWidget({
|
||||
|
||||
@@ -26,7 +26,14 @@ type BoardWidgetMcpAppDocument = {
|
||||
descriptor: BoardMcpAppDescriptor;
|
||||
revision: number;
|
||||
};
|
||||
type StoredBoardWidgetMcpAppDocument = BoardWidgetMcpAppDocument & {
|
||||
grantState: "none" | "pending" | "granted" | "rejected";
|
||||
declaredTools: string[];
|
||||
grantGeneration?: string;
|
||||
};
|
||||
export type BoardWidgetDocument = BoardWidgetHtmlDocument | BoardWidgetMcpAppDocument;
|
||||
export type BoardWidgetMcpAppRead = StoredBoardWidgetMcpAppDocument;
|
||||
type StoredBoardWidgetDocument = BoardWidgetHtmlDocument | StoredBoardWidgetMcpAppDocument;
|
||||
|
||||
export interface BoardStore {
|
||||
getSnapshot(sessionKey: string): BoardSnapshot;
|
||||
@@ -37,14 +44,16 @@ export interface BoardStore {
|
||||
name: string,
|
||||
decision: "granted" | "rejected",
|
||||
revision: number,
|
||||
instanceId?: string,
|
||||
): BoardSnapshot;
|
||||
readWidgetHtml(sessionKey: string, name: string): BoardWidgetDocument | undefined;
|
||||
readWidgetMcpApp(sessionKey: string, name: string): BoardWidgetMcpAppRead | undefined;
|
||||
listSessionsWithBoards(): string[];
|
||||
}
|
||||
|
||||
type StoredBoard = {
|
||||
snapshot: BoardSnapshot;
|
||||
documents: Map<string, BoardWidgetDocument>;
|
||||
documents: Map<string, StoredBoardWidgetDocument>;
|
||||
};
|
||||
|
||||
const BOARD_MAX_WIDGETS = 48;
|
||||
@@ -72,17 +81,25 @@ function createBoardWidgetDocument(
|
||||
content: BoardWidgetContent,
|
||||
revision: number,
|
||||
grantState: BoardWidgetHtmlDocument["grantState"],
|
||||
): BoardWidgetDocument {
|
||||
declaredTools: readonly string[],
|
||||
instanceId: string,
|
||||
): StoredBoardWidgetDocument {
|
||||
if (content.kind === "html") {
|
||||
return {
|
||||
html: content.html,
|
||||
revision,
|
||||
sha256: createHash("sha256").update(content.html).digest("hex"),
|
||||
viewGeneration: randomBytes(16).toString("hex"),
|
||||
viewGeneration: instanceId,
|
||||
grantState,
|
||||
};
|
||||
}
|
||||
return { descriptor: { ...content.descriptor }, revision };
|
||||
return {
|
||||
descriptor: { ...content.descriptor },
|
||||
revision,
|
||||
grantState,
|
||||
declaredTools: [...declaredTools],
|
||||
grantGeneration: instanceId,
|
||||
};
|
||||
}
|
||||
|
||||
export function createBoardDeclaredSummary(
|
||||
@@ -95,9 +112,30 @@ export function createBoardDeclaredSummary(
|
||||
return lines.length > 0 ? lines : undefined;
|
||||
}
|
||||
|
||||
type BoardWidgetGrantScope = { kind: "html" } | { kind: "mcp-app"; serverName: string };
|
||||
|
||||
function grantScopeForContent(content: BoardWidgetContent): BoardWidgetGrantScope {
|
||||
return content.kind === "html"
|
||||
? { kind: "html" }
|
||||
: { kind: "mcp-app", serverName: content.descriptor.serverName };
|
||||
}
|
||||
|
||||
export function boardWidgetGrantScopeMatches(
|
||||
previous: BoardWidgetGrantScope,
|
||||
content: BoardWidgetContent,
|
||||
): boolean {
|
||||
const next = grantScopeForContent(content);
|
||||
if (previous.kind === "html" || next.kind === "html") {
|
||||
return previous.kind === next.kind;
|
||||
}
|
||||
return previous.serverName === next.serverName;
|
||||
}
|
||||
|
||||
export function createBoardWidgetPutSnapshot(
|
||||
prior: BoardSnapshot,
|
||||
params: BoardWidgetMaterializedPutParams,
|
||||
grantScopeMatches = true,
|
||||
instanceId?: string,
|
||||
): BoardSnapshot {
|
||||
if (
|
||||
params.content.kind === "html" &&
|
||||
@@ -129,6 +167,7 @@ export function createBoardWidgetPutSnapshot(
|
||||
// A grant follows new bytes only when every declared capability was already approved.
|
||||
// Any widening must return to pending before the widget can be served.
|
||||
const preservesGrant =
|
||||
grantScopeMatches &&
|
||||
existing?.grantState === "granted" &&
|
||||
(declaredSummary ?? []).every((entry) => existing.declaredSummary?.includes(entry));
|
||||
layout = insertBoardWidget(
|
||||
@@ -147,6 +186,7 @@ export function createBoardWidgetPutSnapshot(
|
||||
position: existing?.position ?? layout.widgets.length,
|
||||
grantState: preservesGrant ? "granted" : declaredSummary ? "pending" : "none",
|
||||
revision: widgetRevision,
|
||||
...(instanceId ? { instanceId } : {}),
|
||||
...(declaredSummary ? { declaredSummary } : {}),
|
||||
},
|
||||
{
|
||||
@@ -170,6 +210,7 @@ export function createBoardGrantSnapshot(
|
||||
name: string,
|
||||
decision: "granted" | "rejected",
|
||||
revision: number,
|
||||
instanceId?: string,
|
||||
): BoardSnapshot {
|
||||
const widget = current.widgets.find((candidate) => candidate.name === name);
|
||||
if (!widget) {
|
||||
@@ -181,6 +222,12 @@ export function createBoardGrantSnapshot(
|
||||
`board widget revision changed: ${name} is revision ${widget.revision}, not ${revision}`,
|
||||
);
|
||||
}
|
||||
if (
|
||||
widget.contentKind === "mcp-app" &&
|
||||
(!widget.instanceId || widget.instanceId !== instanceId)
|
||||
) {
|
||||
throw new BoardValidationError("conflict", `board widget instance changed: ${name}`);
|
||||
}
|
||||
if (widget.grantState !== "pending") {
|
||||
throw new BoardValidationError(
|
||||
"invalid_operation",
|
||||
@@ -229,13 +276,29 @@ export class InMemoryBoardStore implements BoardStore {
|
||||
putWidget(params: BoardWidgetMaterializedPutParams): BoardSnapshot {
|
||||
const current = this.boards.get(params.sessionKey);
|
||||
const prior = current?.snapshot ?? emptyBoardSnapshot(params.sessionKey);
|
||||
const snapshot = createBoardWidgetPutSnapshot(prior, params);
|
||||
const existingDocument = current?.documents.get(params.name);
|
||||
const grantScopeMatches = existingDocument
|
||||
? boardWidgetGrantScopeMatches(
|
||||
"html" in existingDocument
|
||||
? { kind: "html" }
|
||||
: { kind: "mcp-app", serverName: existingDocument.descriptor.serverName },
|
||||
params.content,
|
||||
)
|
||||
: true;
|
||||
const instanceId = randomBytes(16).toString("hex");
|
||||
const snapshot = createBoardWidgetPutSnapshot(prior, params, grantScopeMatches, instanceId);
|
||||
const documents = new Map(current?.documents ?? []);
|
||||
const widgetRevision = snapshot.widgets.find((widget) => widget.name === params.name)!.revision;
|
||||
const widget = snapshot.widgets.find((candidate) => candidate.name === params.name)!;
|
||||
documents.set(
|
||||
params.name,
|
||||
createBoardWidgetDocument(params.content, widgetRevision, widget.grantState),
|
||||
createBoardWidgetDocument(
|
||||
params.content,
|
||||
widgetRevision,
|
||||
widget.grantState,
|
||||
params.declared?.tools ?? [],
|
||||
instanceId,
|
||||
),
|
||||
);
|
||||
this.boards.set(params.sessionKey, { snapshot, documents });
|
||||
return cloneBoardSnapshot(snapshot);
|
||||
@@ -246,14 +309,21 @@ export class InMemoryBoardStore implements BoardStore {
|
||||
name: string,
|
||||
decision: "granted" | "rejected",
|
||||
revision: number,
|
||||
instanceId?: string,
|
||||
): BoardSnapshot {
|
||||
const current = this.boards.get(sessionKey);
|
||||
if (!current) {
|
||||
throw new BoardValidationError("not_found", `board widget not found: ${name}`);
|
||||
}
|
||||
const snapshot = createBoardGrantSnapshot(current.snapshot, name, decision, revision);
|
||||
const snapshot = createBoardGrantSnapshot(
|
||||
current.snapshot,
|
||||
name,
|
||||
decision,
|
||||
revision,
|
||||
instanceId,
|
||||
);
|
||||
const document = current.documents.get(name);
|
||||
if (document && "html" in document) {
|
||||
if (document) {
|
||||
document.grantState = decision;
|
||||
}
|
||||
this.boards.set(sessionKey, { snapshot, documents: current.documents });
|
||||
@@ -267,7 +337,24 @@ export class InMemoryBoardStore implements BoardStore {
|
||||
}
|
||||
return "html" in document
|
||||
? { ...document }
|
||||
: { descriptor: { ...document.descriptor }, revision: document.revision };
|
||||
: {
|
||||
descriptor: { ...document.descriptor },
|
||||
revision: document.revision,
|
||||
};
|
||||
}
|
||||
|
||||
readWidgetMcpApp(sessionKey: string, name: string): BoardWidgetMcpAppRead | undefined {
|
||||
const document = this.boards.get(sessionKey)?.documents.get(name);
|
||||
if (!document || "html" in document) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
descriptor: { ...document.descriptor },
|
||||
revision: document.revision,
|
||||
grantState: document.grantState,
|
||||
declaredTools: [...document.declaredTools],
|
||||
...(document.grantGeneration ? { grantGeneration: document.grantGeneration } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
listSessionsWithBoards(): string[] {
|
||||
|
||||
@@ -30,12 +30,14 @@ import {
|
||||
} from "../state/openclaw-agent-db.js";
|
||||
import { applyBoardOps, BoardValidationError, normalizeBoardLayout } from "./board-layout.js";
|
||||
import {
|
||||
boardWidgetGrantScopeMatches,
|
||||
cloneBoardSnapshot,
|
||||
createBoardDeclaredSummary,
|
||||
createBoardGrantSnapshot,
|
||||
createBoardWidgetPutSnapshot,
|
||||
type BoardStore,
|
||||
type BoardWidgetDocument,
|
||||
type BoardWidgetMcpAppRead,
|
||||
} from "./board-store.js";
|
||||
|
||||
type BoardDatabase = Pick<
|
||||
@@ -99,17 +101,31 @@ type SqliteBoardStoreOptions = {
|
||||
env?: NodeJS.ProcessEnv;
|
||||
};
|
||||
|
||||
function parseManifest(value: string): BoardWidgetMaterializedPutParams["declared"] {
|
||||
const parsed = JSON.parse(value) as { netOrigins?: unknown; tools?: unknown };
|
||||
type StoredBoardWidgetManifest = NonNullable<BoardWidgetMaterializedPutParams["declared"]> & {
|
||||
mcpAppGrantGeneration?: string;
|
||||
};
|
||||
|
||||
function parseManifest(value: string): StoredBoardWidgetManifest {
|
||||
const parsed = JSON.parse(value) as {
|
||||
mcpAppGrantGeneration?: unknown;
|
||||
netOrigins?: unknown;
|
||||
tools?: unknown;
|
||||
};
|
||||
const netOrigins = Array.isArray(parsed.netOrigins)
|
||||
? parsed.netOrigins.filter((entry): entry is string => typeof entry === "string")
|
||||
: undefined;
|
||||
const tools = Array.isArray(parsed.tools)
|
||||
? parsed.tools.filter((entry): entry is string => typeof entry === "string")
|
||||
: undefined;
|
||||
const mcpAppGrantGeneration =
|
||||
typeof parsed.mcpAppGrantGeneration === "string" &&
|
||||
/^[a-f0-9]{32}$/u.test(parsed.mcpAppGrantGeneration)
|
||||
? parsed.mcpAppGrantGeneration
|
||||
: undefined;
|
||||
return {
|
||||
...(netOrigins?.length ? { netOrigins } : {}),
|
||||
...(tools?.length ? { tools } : {}),
|
||||
...(mcpAppGrantGeneration ? { mcpAppGrantGeneration } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -127,7 +143,10 @@ function rowToTab(row: SelectedBoardTabRow): BoardTab {
|
||||
}
|
||||
|
||||
function rowToWidget(row: SelectedBoardWidgetRow): BoardWidget {
|
||||
const declaredSummary = createBoardDeclaredSummary(parseManifest(row.manifest));
|
||||
const manifest = parseManifest(row.manifest);
|
||||
const declaredSummary = createBoardDeclaredSummary(manifest);
|
||||
const instanceId =
|
||||
row.content_kind === "mcp-app" ? manifest.mcpAppGrantGeneration : row.view_generation;
|
||||
return {
|
||||
name: row.name,
|
||||
tabId: row.tab_id,
|
||||
@@ -138,6 +157,7 @@ function rowToWidget(row: SelectedBoardWidgetRow): BoardWidget {
|
||||
position: row.position,
|
||||
grantState: row.grant_state as BoardWidget["grantState"],
|
||||
revision: row.revision,
|
||||
...(instanceId ? { instanceId } : {}),
|
||||
...(declaredSummary ? { declaredSummary } : {}),
|
||||
};
|
||||
}
|
||||
@@ -293,7 +313,10 @@ function contentFields(
|
||||
viewGeneration: string,
|
||||
now: number,
|
||||
) {
|
||||
const manifest = JSON.stringify(params.declared ?? {});
|
||||
const manifest = JSON.stringify({
|
||||
...params.declared,
|
||||
...(params.content.kind === "mcp-app" ? { mcpAppGrantGeneration: viewGeneration } : {}),
|
||||
});
|
||||
if (params.content.kind === "html") {
|
||||
const sha256 = createHash("sha256").update(params.content.html).digest("hex");
|
||||
return {
|
||||
@@ -449,9 +472,26 @@ export class SqliteBoardStore implements BoardStore {
|
||||
);
|
||||
}
|
||||
const previous = readStoredBoard(transactionDatabase, resolved.sessionKey);
|
||||
const next = createBoardWidgetPutSnapshot(previous.snapshot, canonicalParams);
|
||||
const widget = next.widgets.find((candidate) => candidate.name === canonicalParams.name)!;
|
||||
const existing = previous.widgetRows.find((row) => row.name === canonicalParams.name);
|
||||
const grantScopeMatches = existing
|
||||
? existing.content_kind === "html"
|
||||
? boardWidgetGrantScopeMatches({ kind: "html" }, canonicalParams.content)
|
||||
: existing.descriptor_json !== null &&
|
||||
boardWidgetGrantScopeMatches(
|
||||
{
|
||||
kind: "mcp-app",
|
||||
serverName: parseDescriptor(existing.descriptor_json).serverName,
|
||||
},
|
||||
canonicalParams.content,
|
||||
)
|
||||
: true;
|
||||
const next = createBoardWidgetPutSnapshot(
|
||||
previous.snapshot,
|
||||
canonicalParams,
|
||||
grantScopeMatches,
|
||||
viewGeneration,
|
||||
);
|
||||
const widget = next.widgets.find((candidate) => candidate.name === canonicalParams.name)!;
|
||||
const now = Date.now();
|
||||
upsertTabs(transactionDatabase, previous, next);
|
||||
const db = getNodeSqliteKysely<BoardDatabase>(transactionDatabase.db);
|
||||
@@ -502,6 +542,7 @@ export class SqliteBoardStore implements BoardStore {
|
||||
name: string,
|
||||
decision: "granted" | "rejected",
|
||||
revision: number,
|
||||
instanceId?: string,
|
||||
): BoardSnapshot {
|
||||
const { database, resolved } = this.prepareWrite(sessionKey);
|
||||
return runOpenClawAgentWriteTransaction(
|
||||
@@ -513,7 +554,13 @@ export class SqliteBoardStore implements BoardStore {
|
||||
);
|
||||
}
|
||||
const previous = readStoredBoard(transactionDatabase, resolved.sessionKey);
|
||||
const next = createBoardGrantSnapshot(previous.snapshot, name, decision, revision);
|
||||
const next = createBoardGrantSnapshot(
|
||||
previous.snapshot,
|
||||
name,
|
||||
decision,
|
||||
revision,
|
||||
instanceId,
|
||||
);
|
||||
upsertTabs(transactionDatabase, previous, next);
|
||||
const row = previous.widgetRows.find((candidate) => candidate.name === name)!;
|
||||
const db = getNodeSqliteKysely<BoardDatabase>(transactionDatabase.db);
|
||||
@@ -587,6 +634,46 @@ export class SqliteBoardStore implements BoardStore {
|
||||
return result.found ? result.value : undefined;
|
||||
}
|
||||
|
||||
readWidgetMcpApp(sessionKey: string, name: string): BoardWidgetMcpAppRead | undefined {
|
||||
const resolved = this.resolve(sessionKey);
|
||||
const result = withOpenClawAgentDatabaseReadOnly(
|
||||
(database) => {
|
||||
if (!hasSession(database, resolved.sessionKey) || !boardTablesPresent(database)) {
|
||||
return undefined;
|
||||
}
|
||||
const db = getNodeSqliteKysely<BoardDatabase>(database.db);
|
||||
const row = executeSqliteQuerySync(
|
||||
database.db,
|
||||
db
|
||||
.selectFrom("board_widgets")
|
||||
.select(["content_kind", "descriptor_json", "revision", "grant_state", "manifest"])
|
||||
.where("session_key", "=", resolved.sessionKey)
|
||||
.where("name", "=", name)
|
||||
.limit(1),
|
||||
).rows[0];
|
||||
if (!row || row.content_kind !== "mcp-app" || row.descriptor_json === null) {
|
||||
return undefined;
|
||||
}
|
||||
const manifest = parseManifest(row.manifest);
|
||||
return {
|
||||
descriptor: parseDescriptor(row.descriptor_json),
|
||||
revision: row.revision,
|
||||
grantState: row.grant_state as BoardWidget["grantState"],
|
||||
declaredTools: manifest.tools ?? [],
|
||||
...(manifest.mcpAppGrantGeneration
|
||||
? { grantGeneration: manifest.mcpAppGrantGeneration }
|
||||
: {}),
|
||||
};
|
||||
},
|
||||
{
|
||||
agentId: resolved.agentId,
|
||||
...(resolved.path ? { path: resolved.path } : {}),
|
||||
env: this.options.env,
|
||||
},
|
||||
);
|
||||
return result.found ? result.value : undefined;
|
||||
}
|
||||
|
||||
listSessionsWithBoards(): string[] {
|
||||
const sessionKeys = new Set<string>();
|
||||
const agentIds = new Set(
|
||||
|
||||
@@ -35,6 +35,7 @@ describe("extractCanvasFromText", () => {
|
||||
toolName: "show",
|
||||
uiResourceUri: "ui://demo/app",
|
||||
toolCallId: "call-1",
|
||||
originSessionKey: "agent:main:main",
|
||||
resultMetaState: "unavailable",
|
||||
},
|
||||
},
|
||||
@@ -47,6 +48,7 @@ describe("extractCanvasFromText", () => {
|
||||
toolName: "show",
|
||||
uiResourceUri: "ui://demo/app",
|
||||
toolCallId: "call-1",
|
||||
originSessionKey: "agent:main:main",
|
||||
resultMetaState: "unavailable",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -15,6 +15,7 @@ type McpAppPreviewDescriptor = {
|
||||
toolName?: string;
|
||||
uiResourceUri?: string;
|
||||
toolCallId?: string;
|
||||
originSessionKey?: string;
|
||||
resultMetaState?: "unavailable";
|
||||
};
|
||||
|
||||
@@ -68,6 +69,7 @@ function coerceMcpAppDescriptor(
|
||||
const toolName = getRecordStringField(record, "toolName");
|
||||
const uiResourceUri = getRecordStringField(record, "uiResourceUri");
|
||||
const toolCallId = getRecordStringField(record, "toolCallId");
|
||||
const originSessionKey = getRecordStringField(record, "originSessionKey");
|
||||
const resultMetaState = record?.resultMetaState === "unavailable" ? "unavailable" : undefined;
|
||||
const hasCompleteDescriptor = Boolean(
|
||||
serverName &&
|
||||
@@ -86,6 +88,7 @@ function coerceMcpAppDescriptor(
|
||||
toolName,
|
||||
uiResourceUri,
|
||||
toolCallId,
|
||||
...(originSessionKey && originSessionKey.length <= 512 ? { originSessionKey } : {}),
|
||||
...(resultMetaState ? { resultMetaState } : {}),
|
||||
}
|
||||
: { viewId };
|
||||
|
||||
@@ -31,6 +31,13 @@ export type McpAppActiveView = {
|
||||
view: McpAppViewLease;
|
||||
};
|
||||
|
||||
export class McpAppViewExpiredError extends Error {
|
||||
constructor() {
|
||||
super("MCP App view expired or is not authorized for this session");
|
||||
this.name = "McpAppViewExpiredError";
|
||||
}
|
||||
}
|
||||
|
||||
export type McpAppOperation =
|
||||
| Pick<CallToolRequest, "method" | "params">
|
||||
| Pick<ListToolsRequest, "method" | "params">
|
||||
@@ -60,6 +67,29 @@ function isAllowedByView(view: McpAppViewLease, toolName: string): boolean {
|
||||
return view.allowedAppToolNames === undefined || view.allowedAppToolNames.has(toolName);
|
||||
}
|
||||
|
||||
export async function requireMcpAppViewAuthorization(view: McpAppViewLease): Promise<void> {
|
||||
if (view.authorizeAppToolCall && !(await view.authorizeAppToolCall())) {
|
||||
throw new Error("MCP App widget grant is no longer active");
|
||||
}
|
||||
}
|
||||
|
||||
export async function resolveMcpAppAllowedToolNames(active: McpAppActiveView): Promise<string[]> {
|
||||
if (active.view.readOnly === true) {
|
||||
return [];
|
||||
}
|
||||
const catalog = await active.runtime.getCatalog();
|
||||
return catalog.tools
|
||||
.filter(
|
||||
(tool) =>
|
||||
tool.serverName === active.view.serverName &&
|
||||
isAppCallableTool(tool) &&
|
||||
isAllowedByView(active.view, tool.toolName),
|
||||
)
|
||||
.map((tool) => tool.toolName)
|
||||
.filter((toolName, index, all) => all.indexOf(toolName) === index)
|
||||
.toSorted();
|
||||
}
|
||||
|
||||
async function requireCallableTool(
|
||||
runtime: SessionMcpRuntime,
|
||||
view: McpAppViewLease,
|
||||
@@ -68,6 +98,7 @@ async function requireCallableTool(
|
||||
if (view.readOnly === true) {
|
||||
throw new Error("MCP App view is read-only");
|
||||
}
|
||||
await requireMcpAppViewAuthorization(view);
|
||||
const catalog = await runtime.getCatalog();
|
||||
const tool = catalog.tools.find(
|
||||
(entry) => entry.serverName === view.serverName && entry.toolName === toolName,
|
||||
@@ -103,7 +134,7 @@ export async function resolveMcpAppActiveView(params: {
|
||||
})
|
||||
: undefined;
|
||||
if (!restored) {
|
||||
throw new Error("MCP App view expired or is not authorized for this session");
|
||||
throw new McpAppViewExpiredError();
|
||||
}
|
||||
return restored;
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ vi.mock("./session-utils.js", () => ({
|
||||
loadSessionEntry: mocks.loadSessionEntry,
|
||||
}));
|
||||
|
||||
import { restoreMcpAppView } from "./mcp-app-reconstruction.js";
|
||||
import { mintMcpAppViewFromTranscript, restoreMcpAppView } from "./mcp-app-reconstruction.js";
|
||||
|
||||
const runtime = { mcpAppsEnabled: true };
|
||||
const view = { id: "view-lease" };
|
||||
@@ -50,7 +50,9 @@ beforeEach(() => {
|
||||
storePath: "/tmp/sessions.json",
|
||||
});
|
||||
mocks.getOrCreateSessionMcpRuntime.mockResolvedValue(runtime);
|
||||
mocks.fetchMcpAppView.mockResolvedValue(undefined);
|
||||
mocks.fetchMcpAppView.mockImplementation(async (params: { viewId?: string }) => ({
|
||||
viewId: params.viewId ?? "mcp-app-fresh",
|
||||
}));
|
||||
mocks.getMcpAppViewLease.mockReturnValue(view);
|
||||
});
|
||||
|
||||
@@ -128,6 +130,55 @@ describe("MCP App transcript reconstruction", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("mints a fresh board lease from the stable transcript descriptor", async () => {
|
||||
mocks.visitSessionMessagesAsync.mockImplementation(
|
||||
async (_scope: unknown, visit: (message: unknown) => void) => {
|
||||
for (const message of [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "toolCall",
|
||||
id: "call-1",
|
||||
name: "demo__show",
|
||||
arguments: { city: "Paris" },
|
||||
},
|
||||
],
|
||||
},
|
||||
toolResult("mcp-app-original", "call-1"),
|
||||
]) {
|
||||
visit(message);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const authorizeAppToolCall = vi.fn(async () => true);
|
||||
await expect(
|
||||
mintMcpAppViewFromTranscript({
|
||||
cfg: {},
|
||||
sessionKey: "agent:main:main",
|
||||
descriptor: {
|
||||
serverName: "demo",
|
||||
toolName: "show",
|
||||
uiResourceUri: "ui://demo/app",
|
||||
originSessionKey: "agent:main:main",
|
||||
toolCallId: "call-1",
|
||||
},
|
||||
allowedAppToolNames: new Set(["refresh"]),
|
||||
authorizeAppToolCall,
|
||||
readOnly: false,
|
||||
}),
|
||||
).resolves.toEqual({ runtime, view });
|
||||
expect(mocks.fetchMcpAppView).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
allowedAppToolNames: new Set(["refresh"]),
|
||||
authorizeAppToolCall,
|
||||
toolCallId: "call-1",
|
||||
}),
|
||||
);
|
||||
expect(mocks.fetchMcpAppView.mock.calls.at(-1)?.[0]).not.toHaveProperty("viewId");
|
||||
});
|
||||
|
||||
it("rejects client-selected descriptors that do not match transcript ownership", async () => {
|
||||
await expect(
|
||||
restoreFromMessages(
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { type CallToolResult, ContentBlockSchema } from "@modelcontextprotocol/sdk/types.js";
|
||||
import type { BoardMcpAppDescriptor } from "../../packages/gateway-protocol/src/index.js";
|
||||
import { getOrCreateSessionMcpRuntime } from "../agents/agent-bundle-mcp-runtime.js";
|
||||
import type { SessionMcpRuntime } from "../agents/agent-bundle-mcp-types.js";
|
||||
import { resolveAgentDir, resolveAgentWorkspaceDir } from "../agents/agent-scope.js";
|
||||
@@ -23,6 +24,8 @@ type McpAppDescriptor = {
|
||||
resultMetaState?: "unavailable";
|
||||
};
|
||||
|
||||
type TranscriptLookup = { viewId: string } | { descriptor: BoardMcpAppDescriptor };
|
||||
|
||||
type ReconstructionData = {
|
||||
descriptor: McpAppDescriptor;
|
||||
toolInput: unknown;
|
||||
@@ -130,7 +133,26 @@ function readCallToolResult(message: Record<string, unknown>, details: Record<st
|
||||
} as CallToolResult;
|
||||
}
|
||||
|
||||
function readTranscriptResult(value: unknown, viewId: string): TranscriptResultRead | undefined {
|
||||
function matchesLookup(
|
||||
rawDescriptor: Record<string, unknown> | undefined,
|
||||
lookup: TranscriptLookup,
|
||||
): boolean {
|
||||
if ("viewId" in lookup) {
|
||||
return readString(rawDescriptor, "viewId") === lookup.viewId;
|
||||
}
|
||||
const descriptor = lookup.descriptor;
|
||||
return (
|
||||
readString(rawDescriptor, "serverName") === descriptor.serverName &&
|
||||
readString(rawDescriptor, "toolName") === descriptor.toolName &&
|
||||
readString(rawDescriptor, "uiResourceUri") === descriptor.uiResourceUri &&
|
||||
readString(rawDescriptor, "toolCallId") === descriptor.toolCallId
|
||||
);
|
||||
}
|
||||
|
||||
function readTranscriptResult(
|
||||
value: unknown,
|
||||
lookup: TranscriptLookup,
|
||||
): TranscriptResultRead | undefined {
|
||||
const message = asRecord(value);
|
||||
if (!message || readString(message, "role")?.toLowerCase() !== "toolresult") {
|
||||
return undefined;
|
||||
@@ -141,7 +163,7 @@ function readTranscriptResult(value: unknown, viewId: string): TranscriptResultR
|
||||
}
|
||||
const preview = asRecord(details.mcpAppPreview);
|
||||
const rawDescriptor = asRecord(preview?.mcpApp);
|
||||
if (readString(rawDescriptor, "viewId") !== viewId) {
|
||||
if (!matchesLookup(rawDescriptor, lookup)) {
|
||||
return undefined;
|
||||
}
|
||||
const descriptor = readDescriptor(rawDescriptor);
|
||||
@@ -166,13 +188,13 @@ function readTranscriptResult(value: unknown, viewId: string): TranscriptResultR
|
||||
/** Searches the full active transcript without retaining its messages in memory. */
|
||||
async function findMcpAppReconstructionDataByVisit(
|
||||
visitTranscript: TranscriptVisit,
|
||||
viewId: string,
|
||||
lookup: TranscriptLookup,
|
||||
): Promise<ReconstructionData | undefined> {
|
||||
let resultRead: TranscriptResultRead | undefined;
|
||||
let resultIndex = -1;
|
||||
let messageIndex = 0;
|
||||
await visitTranscript((message) => {
|
||||
const read = readTranscriptResult(message, viewId);
|
||||
const read = readTranscriptResult(message, lookup);
|
||||
if (read) {
|
||||
resultRead = read;
|
||||
resultIndex = messageIndex;
|
||||
@@ -222,14 +244,15 @@ function getRestoreInFlight(): Map<string, Promise<ReconstructionResult | undefi
|
||||
return created;
|
||||
}
|
||||
|
||||
async function restoreMcpAppViewOnce(params: {
|
||||
async function reconstructMcpAppView(params: {
|
||||
cfg: OpenClawConfig;
|
||||
sessionKey: string;
|
||||
viewId: string;
|
||||
lookup: TranscriptLookup;
|
||||
allowedAppToolNames: ReadonlySet<string>;
|
||||
authorizeAppToolCall?: () => boolean | Promise<boolean>;
|
||||
readOnly: boolean;
|
||||
viewId?: string;
|
||||
}): Promise<ReconstructionResult | undefined> {
|
||||
if (!params.viewId.startsWith("mcp-app-") || params.viewId.length > 128) {
|
||||
return undefined;
|
||||
}
|
||||
const agentId = resolveAgentIdFromSessionKey(params.sessionKey);
|
||||
const loaded = loadSessionEntry(params.sessionKey, { agentId });
|
||||
const sessionId = loaded.entry?.sessionId;
|
||||
@@ -249,7 +272,7 @@ async function restoreMcpAppViewOnce(params: {
|
||||
reason: "MCP App restart reconstruction",
|
||||
cache: "reuse",
|
||||
});
|
||||
}, params.viewId);
|
||||
}, params.lookup);
|
||||
if (!data) {
|
||||
return undefined;
|
||||
}
|
||||
@@ -263,7 +286,7 @@ async function restoreMcpAppViewOnce(params: {
|
||||
if (runtime.mcpAppsEnabled !== true) {
|
||||
return undefined;
|
||||
}
|
||||
await fetchMcpAppView({
|
||||
const fetched = await fetchMcpAppView({
|
||||
runtime,
|
||||
serverName: data.descriptor.serverName,
|
||||
toolName: data.descriptor.toolName,
|
||||
@@ -271,14 +294,49 @@ async function restoreMcpAppViewOnce(params: {
|
||||
toolCallId: data.descriptor.toolCallId,
|
||||
toolInput: data.toolInput,
|
||||
toolResult: data.toolResult,
|
||||
viewId: data.descriptor.viewId,
|
||||
...(params.viewId ? { viewId: params.viewId } : {}),
|
||||
allowedAppToolNames: params.allowedAppToolNames,
|
||||
...(params.authorizeAppToolCall ? { authorizeAppToolCall: params.authorizeAppToolCall } : {}),
|
||||
...(params.readOnly ? { readOnly: true as const } : {}),
|
||||
});
|
||||
const view = fetched ? getMcpAppViewLease(fetched.viewId, runtime) : undefined;
|
||||
return view ? { runtime, view } : undefined;
|
||||
}
|
||||
|
||||
async function restoreMcpAppViewOnce(params: {
|
||||
cfg: OpenClawConfig;
|
||||
sessionKey: string;
|
||||
viewId: string;
|
||||
}): Promise<ReconstructionResult | undefined> {
|
||||
if (!params.viewId.startsWith("mcp-app-") || params.viewId.length > 128) {
|
||||
return undefined;
|
||||
}
|
||||
return await reconstructMcpAppView({
|
||||
...params,
|
||||
lookup: { viewId: params.viewId },
|
||||
// A reconstructed preview can render and read its owning server resources,
|
||||
// but cannot call tools without a fresh run carrying current effective policy.
|
||||
allowedAppToolNames: new Set(),
|
||||
readOnly: true,
|
||||
});
|
||||
const view = getMcpAppViewLease(params.viewId, runtime);
|
||||
return view ? { runtime, view } : undefined;
|
||||
}
|
||||
|
||||
export async function mintMcpAppViewFromTranscript(params: {
|
||||
cfg: OpenClawConfig;
|
||||
sessionKey: string;
|
||||
descriptor: BoardMcpAppDescriptor;
|
||||
allowedAppToolNames: ReadonlySet<string>;
|
||||
authorizeAppToolCall?: () => boolean | Promise<boolean>;
|
||||
readOnly: boolean;
|
||||
}): Promise<ReconstructionResult | undefined> {
|
||||
return await reconstructMcpAppView({
|
||||
cfg: params.cfg,
|
||||
sessionKey: params.sessionKey,
|
||||
lookup: { descriptor: params.descriptor },
|
||||
allowedAppToolNames: params.allowedAppToolNames,
|
||||
...(params.authorizeAppToolCall ? { authorizeAppToolCall: params.authorizeAppToolCall } : {}),
|
||||
readOnly: params.readOnly,
|
||||
});
|
||||
}
|
||||
|
||||
export async function restoreMcpAppView(params: {
|
||||
|
||||
@@ -27,6 +27,7 @@ const CURRENT_TRAIN_METHODS = [
|
||||
"agents.workspace.get",
|
||||
"audit.list",
|
||||
"audit.activity.list",
|
||||
"board.widget.appView",
|
||||
"tts.speak",
|
||||
"environments.list",
|
||||
"environments.status",
|
||||
|
||||
@@ -132,6 +132,7 @@ const CORE_GATEWAY_METHOD_SPECS: readonly CoreGatewayMethodSpec[] = [
|
||||
{ name: "board.update", scope: "operator.write", since: "<=2026.7" },
|
||||
{ name: "board.widget.put", scope: "operator.write", since: "<=2026.7" },
|
||||
{ name: "board.widget.grant", scope: "operator.approvals", since: "<=2026.7" },
|
||||
{ name: "board.widget.appView", scope: "operator.read", since: "2026.7" },
|
||||
{ name: "board.event", scope: "operator.write", since: "<=2026.7" },
|
||||
{ name: "audit.list", scope: "operator.read", since: "2026.7" },
|
||||
{ name: "audit.activity.list", scope: "operator.read", since: "2026.7" },
|
||||
|
||||
@@ -393,7 +393,14 @@ export const coreGatewayHandlers: GatewayRequestHandlers = {
|
||||
loadHandlers: loadUiCommandHandlers,
|
||||
}),
|
||||
...createLazyCoreHandlers({
|
||||
methods: ["board.get", "board.update", "board.widget.put", "board.widget.grant", "board.event"],
|
||||
methods: [
|
||||
"board.get",
|
||||
"board.update",
|
||||
"board.widget.put",
|
||||
"board.widget.grant",
|
||||
"board.widget.appView",
|
||||
"board.event",
|
||||
],
|
||||
loadHandlers: loadBoardHandlers,
|
||||
}),
|
||||
...createLazyCoreHandlers({
|
||||
|
||||
@@ -26,10 +26,44 @@ vi.mock("./sessions.runtime.js", () => ({
|
||||
})),
|
||||
}));
|
||||
|
||||
function createHarness(readCanvasHtml?: Parameters<typeof createBoardHandlers>[2]) {
|
||||
const store = new InMemoryBoardStore();
|
||||
type BoardMcpAppDependencies = NonNullable<Parameters<typeof createBoardHandlers>[3]>;
|
||||
|
||||
function createMcpAppDependencies(): BoardMcpAppDependencies {
|
||||
let lease = 0;
|
||||
const runtime = { getCatalog: vi.fn() };
|
||||
return {
|
||||
resolveActiveView: vi.fn(async ({ viewId }: { viewId: string }) => ({
|
||||
runtime,
|
||||
view: {
|
||||
viewId,
|
||||
serverName: "server",
|
||||
toolName: "tool",
|
||||
uiResourceUri: "ui://resource",
|
||||
toolCallId: "call",
|
||||
},
|
||||
})),
|
||||
resolveAllowedToolNames: vi.fn(async () => ["server.refresh", "server.search"]),
|
||||
mintFromTranscript: vi.fn(async ({ readOnly }: { readOnly: boolean }) => {
|
||||
lease += 1;
|
||||
return {
|
||||
runtime,
|
||||
view: {
|
||||
viewId: `mcp-app-board-${lease}`,
|
||||
expiresAtMs: 10_000 + lease,
|
||||
...(readOnly ? { readOnly: true as const } : {}),
|
||||
},
|
||||
};
|
||||
}),
|
||||
} as unknown as BoardMcpAppDependencies;
|
||||
}
|
||||
|
||||
function createHarness(
|
||||
readCanvasHtml?: Parameters<typeof createBoardHandlers>[2],
|
||||
mcpApp: BoardMcpAppDependencies = createMcpAppDependencies(),
|
||||
store: InMemoryBoardStore = new InMemoryBoardStore(),
|
||||
) {
|
||||
const broadcast = vi.fn();
|
||||
const handlers = createBoardHandlers(store, undefined, readCanvasHtml);
|
||||
const handlers = createBoardHandlers(store, undefined, readCanvasHtml, mcpApp);
|
||||
const invoke = async (method: string, params: Record<string, unknown>) => {
|
||||
const respond = vi.fn<RespondFn>();
|
||||
await handlers[method]!({
|
||||
@@ -38,11 +72,14 @@ function createHarness(readCanvasHtml?: Parameters<typeof createBoardHandlers>[2
|
||||
client: null,
|
||||
isWebchatConnect: () => false,
|
||||
respond,
|
||||
context: { broadcast } as unknown as GatewayRequestContext,
|
||||
context: {
|
||||
broadcast,
|
||||
getRuntimeConfig: () => ({ mcp: { apps: { enabled: true } } }),
|
||||
} as unknown as GatewayRequestContext,
|
||||
});
|
||||
return respond;
|
||||
};
|
||||
return { store, broadcast, invoke };
|
||||
return { store, broadcast, invoke, mcpApp };
|
||||
}
|
||||
|
||||
describe("board gateway methods", () => {
|
||||
@@ -61,15 +98,21 @@ describe("board gateway methods", () => {
|
||||
it("registers every contract method with its required scope", () => {
|
||||
expect(
|
||||
Object.fromEntries(
|
||||
["board.get", "board.update", "board.widget.put", "board.widget.grant", "board.event"].map(
|
||||
(method) => [method, resolveCoreOperatorGatewayMethodScope(method)],
|
||||
),
|
||||
[
|
||||
"board.get",
|
||||
"board.update",
|
||||
"board.widget.put",
|
||||
"board.widget.grant",
|
||||
"board.widget.appView",
|
||||
"board.event",
|
||||
].map((method) => [method, resolveCoreOperatorGatewayMethodScope(method)]),
|
||||
),
|
||||
).toEqual({
|
||||
"board.get": "operator.read",
|
||||
"board.update": "operator.write",
|
||||
"board.widget.put": "operator.write",
|
||||
"board.widget.grant": "operator.approvals",
|
||||
"board.widget.appView": "operator.read",
|
||||
"board.event": "operator.write",
|
||||
});
|
||||
});
|
||||
@@ -109,8 +152,9 @@ describe("board gateway methods", () => {
|
||||
serverName: "server",
|
||||
toolName: "tool",
|
||||
uiResourceUri: "ui://resource",
|
||||
originSessionKey: "origin",
|
||||
originSessionKey: "agent:main:main",
|
||||
toolCallId: "call",
|
||||
viewId: "mcp-app-source",
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -234,6 +278,376 @@ describe("board gateway methods", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("captures MCP App tools at pin time and mints grant-bound fresh leases", async () => {
|
||||
const { invoke, mcpApp, store } = createHarness();
|
||||
const descriptor = {
|
||||
viewId: "mcp-app-source",
|
||||
serverName: "server",
|
||||
toolName: "tool",
|
||||
uiResourceUri: "ui://resource",
|
||||
originSessionKey: "agent:main:main",
|
||||
toolCallId: "call",
|
||||
};
|
||||
|
||||
const put = await invoke("board.widget.put", {
|
||||
sessionKey: "agent:main:main",
|
||||
name: "server-app",
|
||||
content: { kind: "mcp-app", descriptor },
|
||||
});
|
||||
expect(put).toHaveBeenCalledWith(
|
||||
true,
|
||||
expect.objectContaining({
|
||||
widgets: [
|
||||
expect.objectContaining({
|
||||
name: "server-app",
|
||||
grantState: "pending",
|
||||
declaredSummary: ["Tool access: server.refresh", "Tool access: server.search"],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(mcpApp.resolveActiveView).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ sessionKey: "agent:main:main", viewId: "mcp-app-source" }),
|
||||
);
|
||||
const originalGrantGeneration = store.readWidgetMcpApp(
|
||||
"agent:main:main",
|
||||
"server-app",
|
||||
)?.grantGeneration;
|
||||
expect(originalGrantGeneration).toMatch(/^[a-f0-9]{32}$/u);
|
||||
|
||||
const readOnly = await invoke("board.widget.appView", {
|
||||
sessionKey: "agent:main:main",
|
||||
name: "server-app",
|
||||
revision: 1,
|
||||
instanceId: originalGrantGeneration,
|
||||
});
|
||||
expect(readOnly).toHaveBeenCalledWith(true, {
|
||||
viewId: "mcp-app-board-1",
|
||||
expiresAtMs: 10_001,
|
||||
});
|
||||
expect(mcpApp.mintFromTranscript).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
sessionKey: "agent:main:main",
|
||||
allowedAppToolNames: new Set(),
|
||||
readOnly: true,
|
||||
}),
|
||||
);
|
||||
|
||||
await invoke("board.widget.grant", {
|
||||
sessionKey: "agent:main:main",
|
||||
name: "server-app",
|
||||
decision: "granted",
|
||||
revision: 1,
|
||||
instanceId: originalGrantGeneration,
|
||||
});
|
||||
const interactive = await invoke("board.widget.appView", {
|
||||
sessionKey: "agent:main:main",
|
||||
name: "server-app",
|
||||
revision: 1,
|
||||
instanceId: originalGrantGeneration,
|
||||
});
|
||||
expect(interactive).toHaveBeenCalledWith(true, {
|
||||
viewId: "mcp-app-board-2",
|
||||
expiresAtMs: 10_002,
|
||||
});
|
||||
expect(mcpApp.mintFromTranscript).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
allowedAppToolNames: new Set(["server.refresh", "server.search"]),
|
||||
readOnly: false,
|
||||
}),
|
||||
);
|
||||
const authorizeAppToolCall = vi
|
||||
.mocked(mcpApp.mintFromTranscript)
|
||||
.mock.calls.at(-1)?.[0].authorizeAppToolCall;
|
||||
if (!authorizeAppToolCall) {
|
||||
throw new Error("interactive board lease must carry a grant check");
|
||||
}
|
||||
expect(await authorizeAppToolCall()).toBe(true);
|
||||
|
||||
await invoke("board.update", {
|
||||
sessionKey: "agent:main:main",
|
||||
ops: [{ kind: "widget_remove", name: "server-app" }],
|
||||
});
|
||||
expect(await authorizeAppToolCall()).toBe(false);
|
||||
|
||||
await invoke("board.widget.put", {
|
||||
sessionKey: "agent:main:main",
|
||||
name: "server-app",
|
||||
content: { kind: "mcp-app", descriptor },
|
||||
});
|
||||
const replacementGrantGeneration = store.readWidgetMcpApp(
|
||||
"agent:main:main",
|
||||
"server-app",
|
||||
)?.grantGeneration;
|
||||
const staleGrant = await invoke("board.widget.grant", {
|
||||
sessionKey: "agent:main:main",
|
||||
name: "server-app",
|
||||
decision: "granted",
|
||||
revision: 1,
|
||||
instanceId: originalGrantGeneration,
|
||||
});
|
||||
expect(staleGrant.mock.calls[0]?.[0]).toBe(false);
|
||||
await invoke("board.widget.grant", {
|
||||
sessionKey: "agent:main:main",
|
||||
name: "server-app",
|
||||
decision: "granted",
|
||||
revision: 1,
|
||||
instanceId: replacementGrantGeneration,
|
||||
});
|
||||
expect(replacementGrantGeneration).not.toBe(originalGrantGeneration);
|
||||
expect(await authorizeAppToolCall()).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps validated zero-tool MCP Apps message-capable without granting tools", async () => {
|
||||
const mcpApp = createMcpAppDependencies();
|
||||
vi.mocked(mcpApp.resolveAllowedToolNames).mockResolvedValue([]);
|
||||
const { invoke, store } = createHarness(undefined, mcpApp);
|
||||
await invoke("board.widget.put", {
|
||||
sessionKey: "agent:main:main",
|
||||
name: "message-app",
|
||||
content: {
|
||||
kind: "mcp-app",
|
||||
descriptor: {
|
||||
viewId: "mcp-app-source",
|
||||
serverName: "server",
|
||||
toolName: "tool",
|
||||
uiResourceUri: "ui://resource",
|
||||
originSessionKey: "agent:main:main",
|
||||
toolCallId: "call",
|
||||
},
|
||||
},
|
||||
});
|
||||
const widget = store.getSnapshot("agent:main:main").widgets[0]!;
|
||||
expect(widget.grantState).toBe("none");
|
||||
|
||||
await invoke("board.widget.appView", {
|
||||
sessionKey: "agent:main:main",
|
||||
name: "message-app",
|
||||
revision: widget.revision,
|
||||
instanceId: widget.instanceId,
|
||||
});
|
||||
|
||||
expect(mcpApp.mintFromTranscript).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ allowedAppToolNames: new Set(), readOnly: false }),
|
||||
);
|
||||
expect(vi.mocked(mcpApp.mintFromTranscript).mock.calls.at(-1)?.[0]).not.toHaveProperty(
|
||||
"authorizeAppToolCall",
|
||||
);
|
||||
});
|
||||
|
||||
it("canonicalizes equivalent board and MCP App origin session keys", async () => {
|
||||
class AliasBoardStore extends InMemoryBoardStore {
|
||||
private legacyOrigin = false;
|
||||
|
||||
private canonical(sessionKey: string): string {
|
||||
return sessionKey === "main-alias" ? "agent:main:main" : sessionKey;
|
||||
}
|
||||
|
||||
useLegacyOrigin(): void {
|
||||
this.legacyOrigin = true;
|
||||
}
|
||||
|
||||
override getSnapshot(sessionKey: string) {
|
||||
return super.getSnapshot(this.canonical(sessionKey));
|
||||
}
|
||||
|
||||
override putWidget(params: Parameters<InMemoryBoardStore["putWidget"]>[0]) {
|
||||
return super.putWidget({ ...params, sessionKey: this.canonical(params.sessionKey) });
|
||||
}
|
||||
|
||||
override readWidgetMcpApp(sessionKey: string, name: string) {
|
||||
const current = super.readWidgetMcpApp(this.canonical(sessionKey), name);
|
||||
return current && this.legacyOrigin
|
||||
? {
|
||||
...current,
|
||||
descriptor: { ...current.descriptor, originSessionKey: "main-alias" },
|
||||
}
|
||||
: current;
|
||||
}
|
||||
}
|
||||
const store = new AliasBoardStore();
|
||||
const mcpApp = createMcpAppDependencies();
|
||||
const { invoke } = createHarness(undefined, mcpApp, store);
|
||||
|
||||
await invoke("board.widget.put", {
|
||||
sessionKey: "main-alias",
|
||||
name: "aliased-app",
|
||||
content: {
|
||||
kind: "mcp-app",
|
||||
descriptor: {
|
||||
viewId: "mcp-app-source",
|
||||
serverName: "server",
|
||||
toolName: "tool",
|
||||
uiResourceUri: "ui://resource",
|
||||
originSessionKey: "main-alias",
|
||||
toolCallId: "call",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(mcpApp.resolveActiveView).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ sessionKey: "agent:main:main" }),
|
||||
);
|
||||
expect(store.readWidgetMcpApp("agent:main:main", "aliased-app")?.descriptor).toMatchObject({
|
||||
originSessionKey: "agent:main:main",
|
||||
});
|
||||
|
||||
store.useLegacyOrigin();
|
||||
const widget = store.getSnapshot("agent:main:main").widgets[0]!;
|
||||
await invoke("board.widget.appView", {
|
||||
sessionKey: "agent:main:main",
|
||||
name: "aliased-app",
|
||||
revision: widget.revision,
|
||||
instanceId: widget.instanceId,
|
||||
});
|
||||
expect(mcpApp.mintFromTranscript).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
sessionKey: "agent:main:main",
|
||||
descriptor: expect.objectContaining({ originSessionKey: "agent:main:main" }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps pre-validation MCP App rows read-only even if their legacy grant says granted", async () => {
|
||||
class LegacyBoardStore extends InMemoryBoardStore {
|
||||
override readWidgetMcpApp(sessionKey: string, name: string) {
|
||||
const current = super.readWidgetMcpApp(sessionKey, name);
|
||||
if (!current) {
|
||||
return undefined;
|
||||
}
|
||||
const { grantGeneration: _grantGeneration, ...legacy } = current;
|
||||
return legacy;
|
||||
}
|
||||
}
|
||||
const mcpApp = createMcpAppDependencies();
|
||||
const store = new LegacyBoardStore();
|
||||
const { invoke } = createHarness(undefined, mcpApp, store);
|
||||
await invoke("board.widget.put", {
|
||||
sessionKey: "agent:main:main",
|
||||
name: "legacy-app",
|
||||
content: {
|
||||
kind: "mcp-app",
|
||||
descriptor: {
|
||||
viewId: "mcp-app-source",
|
||||
serverName: "server",
|
||||
toolName: "tool",
|
||||
uiResourceUri: "ui://resource",
|
||||
originSessionKey: "agent:main:main",
|
||||
toolCallId: "call",
|
||||
},
|
||||
},
|
||||
});
|
||||
await invoke("board.widget.grant", {
|
||||
sessionKey: "agent:main:main",
|
||||
name: "legacy-app",
|
||||
decision: "granted",
|
||||
revision: 1,
|
||||
instanceId: store.getSnapshot("agent:main:main").widgets[0]?.instanceId,
|
||||
});
|
||||
|
||||
await invoke("board.widget.appView", {
|
||||
sessionKey: "agent:main:main",
|
||||
name: "legacy-app",
|
||||
revision: 1,
|
||||
instanceId: store.getSnapshot("agent:main:main").widgets[0]?.instanceId,
|
||||
});
|
||||
expect(mcpApp.mintFromTranscript).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
allowedAppToolNames: new Set(),
|
||||
readOnly: true,
|
||||
}),
|
||||
);
|
||||
expect(vi.mocked(mcpApp.mintFromTranscript).mock.calls.at(-1)?.[0]).not.toHaveProperty(
|
||||
"authorizeAppToolCall",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects pre-validation MCP App rows that point at another session", async () => {
|
||||
const store = new InMemoryBoardStore();
|
||||
store.putWidget({
|
||||
sessionKey: "agent:main:board",
|
||||
name: "legacy-cross-session",
|
||||
content: {
|
||||
kind: "mcp-app",
|
||||
descriptor: {
|
||||
serverName: "server",
|
||||
toolName: "tool",
|
||||
uiResourceUri: "ui://resource",
|
||||
originSessionKey: "agent:main:origin",
|
||||
toolCallId: "call",
|
||||
},
|
||||
},
|
||||
});
|
||||
const mcpApp = createMcpAppDependencies();
|
||||
const { invoke } = createHarness(undefined, mcpApp, store);
|
||||
|
||||
const response = await invoke("board.widget.appView", {
|
||||
sessionKey: "agent:main:board",
|
||||
name: "legacy-cross-session",
|
||||
revision: 1,
|
||||
instanceId: store.getSnapshot("agent:main:board").widgets[0]?.instanceId,
|
||||
});
|
||||
|
||||
expect(response).toHaveBeenCalledWith(
|
||||
false,
|
||||
undefined,
|
||||
expect.objectContaining({ code: "INVALID_REQUEST" }),
|
||||
);
|
||||
expect(mcpApp.mintFromTranscript).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects app-view requests for a replaced widget revision", async () => {
|
||||
const { invoke, mcpApp } = createHarness();
|
||||
await invoke("board.widget.put", {
|
||||
sessionKey: "agent:main:main",
|
||||
name: "server-app",
|
||||
content: {
|
||||
kind: "mcp-app",
|
||||
descriptor: {
|
||||
viewId: "mcp-app-source",
|
||||
serverName: "server",
|
||||
toolName: "tool",
|
||||
uiResourceUri: "ui://resource",
|
||||
originSessionKey: "agent:main:main",
|
||||
toolCallId: "call",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const response = await invoke("board.widget.appView", {
|
||||
sessionKey: "agent:main:main",
|
||||
name: "server-app",
|
||||
revision: 2,
|
||||
});
|
||||
expect(response.mock.calls[0]?.[0]).toBe(false);
|
||||
expect(mcpApp.mintFromTranscript).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects cross-session MCP App pins before resolving the source view", async () => {
|
||||
const { invoke, mcpApp } = createHarness();
|
||||
const response = await invoke("board.widget.put", {
|
||||
sessionKey: "agent:main:other",
|
||||
name: "server-app",
|
||||
content: {
|
||||
kind: "mcp-app",
|
||||
descriptor: {
|
||||
viewId: "mcp-app-source",
|
||||
serverName: "server",
|
||||
toolName: "tool",
|
||||
uiResourceUri: "ui://resource",
|
||||
originSessionKey: "agent:main:main",
|
||||
toolCallId: "call",
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(response).toHaveBeenCalledWith(
|
||||
false,
|
||||
undefined,
|
||||
expect.objectContaining({ code: "INVALID_REQUEST" }),
|
||||
);
|
||||
expect(mcpApp.resolveActiveView).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("materializes canvas document sources before storing and broadcasting", async () => {
|
||||
const readCanvasDocument = vi.fn(async () => ({
|
||||
html: "<!doctype html><p>same wrapped bytes</p>",
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
errorShape,
|
||||
formatValidationErrors,
|
||||
type BoardEventParams,
|
||||
type BoardWidgetAppViewParams,
|
||||
type BoardUpdateParams,
|
||||
type BoardWidgetGrantParams,
|
||||
type BoardWidgetMaterializedPutParams,
|
||||
@@ -11,6 +12,7 @@ import {
|
||||
validateBoardGetParams,
|
||||
validateBoardUpdateParams,
|
||||
validateBoardWidgetContent,
|
||||
validateBoardWidgetAppViewParams,
|
||||
validateBoardWidgetGrantParams,
|
||||
validateBoardWidgetPutParams,
|
||||
} from "../../../packages/gateway-protocol/src/index.js";
|
||||
@@ -20,10 +22,23 @@ import type { BoardStore } from "../../boards/board-store.js";
|
||||
import { readCanvasDocumentHtmlSource } from "../../canvas/documents.js";
|
||||
import { boardStore } from "../board-store.js";
|
||||
import { buildBoardWidgetFrameUrl, createBoardViewTicket } from "../board-view-ticket.js";
|
||||
import { resolveMcpAppActiveView, resolveMcpAppAllowedToolNames } from "../mcp-app-operations.js";
|
||||
import { mintMcpAppViewFromTranscript } from "../mcp-app-reconstruction.js";
|
||||
import type { GatewayRequestHandlers } from "./types.js";
|
||||
|
||||
type NoticeAppender = typeof appendBoardEventNotice;
|
||||
type CanvasDocumentReader = typeof readCanvasDocumentHtmlSource;
|
||||
type McpAppDependencies = {
|
||||
resolveActiveView: typeof resolveMcpAppActiveView;
|
||||
resolveAllowedToolNames: typeof resolveMcpAppAllowedToolNames;
|
||||
mintFromTranscript: typeof mintMcpAppViewFromTranscript;
|
||||
};
|
||||
|
||||
const defaultMcpAppDependencies: McpAppDependencies = {
|
||||
resolveActiveView: resolveMcpAppActiveView,
|
||||
resolveAllowedToolNames: resolveMcpAppAllowedToolNames,
|
||||
mintFromTranscript: mintMcpAppViewFromTranscript,
|
||||
};
|
||||
|
||||
function invalidParams(
|
||||
method: string,
|
||||
@@ -55,6 +70,7 @@ export function createBoardHandlers(
|
||||
store: BoardStore,
|
||||
appendNotice: NoticeAppender = appendBoardEventNotice,
|
||||
readCanvasDocument: CanvasDocumentReader = readCanvasDocumentHtmlSource,
|
||||
mcpApp: McpAppDependencies = defaultMcpAppDependencies,
|
||||
): GatewayRequestHandlers {
|
||||
return {
|
||||
"board.get": ({ params, respond }) => {
|
||||
@@ -111,7 +127,10 @@ export function createBoardHandlers(
|
||||
}
|
||||
try {
|
||||
const requestParams = params as BoardWidgetPutParams;
|
||||
const boardSessionKey = store.getSnapshot(requestParams.sessionKey).sessionKey;
|
||||
const { declared: requestDeclared, ...requestWithoutDeclared } = requestParams;
|
||||
let content: BoardWidgetMaterializedPutParams["content"];
|
||||
let declared = requestDeclared;
|
||||
if (requestParams.content.kind === "canvas-doc") {
|
||||
const document = await readCanvasDocument(requestParams.content.docId);
|
||||
if (document.cspSandbox !== "scripts") {
|
||||
@@ -121,6 +140,44 @@ export function createBoardHandlers(
|
||||
);
|
||||
}
|
||||
content = { kind: "html", html: document.html };
|
||||
} else if (requestParams.content.kind === "mcp-app") {
|
||||
const descriptor = requestParams.content.descriptor;
|
||||
const originSessionKey = store.getSnapshot(descriptor.originSessionKey).sessionKey;
|
||||
if (originSessionKey !== boardSessionKey) {
|
||||
throw new BoardValidationError(
|
||||
"invalid_operation",
|
||||
"MCP App widgets can only be pinned to their originating session board",
|
||||
);
|
||||
}
|
||||
const active = await mcpApp.resolveActiveView({
|
||||
sessionKey: originSessionKey,
|
||||
viewId: descriptor.viewId,
|
||||
cfg: context.getRuntimeConfig(),
|
||||
});
|
||||
const { view } = active;
|
||||
if (
|
||||
view.serverName !== descriptor.serverName ||
|
||||
view.toolName !== descriptor.toolName ||
|
||||
view.uiResourceUri !== descriptor.uiResourceUri ||
|
||||
view.toolCallId !== descriptor.toolCallId
|
||||
) {
|
||||
throw new BoardValidationError(
|
||||
"invalid_operation",
|
||||
"MCP App pin descriptor does not match the active view",
|
||||
);
|
||||
}
|
||||
const allowedTools = await mcpApp.resolveAllowedToolNames(active);
|
||||
content = {
|
||||
kind: "mcp-app",
|
||||
descriptor: {
|
||||
serverName: descriptor.serverName,
|
||||
toolName: descriptor.toolName,
|
||||
uiResourceUri: descriptor.uiResourceUri,
|
||||
originSessionKey,
|
||||
toolCallId: descriptor.toolCallId,
|
||||
},
|
||||
};
|
||||
declared = allowedTools.length > 0 ? { tools: allowedTools } : undefined;
|
||||
} else {
|
||||
content = requestParams.content;
|
||||
}
|
||||
@@ -128,7 +185,12 @@ export function createBoardHandlers(
|
||||
invalidParams("board.widget.put content", validateBoardWidgetContent.errors, respond);
|
||||
return;
|
||||
}
|
||||
const boardParams: BoardWidgetMaterializedPutParams = { ...requestParams, content };
|
||||
const boardParams: BoardWidgetMaterializedPutParams = {
|
||||
...requestWithoutDeclared,
|
||||
sessionKey: boardSessionKey,
|
||||
content,
|
||||
...(declared ? { declared } : {}),
|
||||
};
|
||||
const snapshot = store.putWidget(boardParams);
|
||||
context.broadcast("board.changed", {
|
||||
sessionKey: snapshot.sessionKey,
|
||||
@@ -152,6 +214,7 @@ export function createBoardHandlers(
|
||||
boardParams.name,
|
||||
boardParams.decision,
|
||||
boardParams.revision,
|
||||
boardParams.instanceId,
|
||||
);
|
||||
context.broadcast("board.changed", {
|
||||
sessionKey: snapshot.sessionKey,
|
||||
@@ -162,6 +225,72 @@ export function createBoardHandlers(
|
||||
respondBoardError(error, respond);
|
||||
}
|
||||
},
|
||||
"board.widget.appView": async ({ params, respond, context }) => {
|
||||
if (!validateBoardWidgetAppViewParams(params)) {
|
||||
invalidParams("board.widget.appView", validateBoardWidgetAppViewParams.errors, respond);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const boardParams = params as BoardWidgetAppViewParams;
|
||||
const snapshot = store.getSnapshot(boardParams.sessionKey);
|
||||
const widget = snapshot.widgets.find((candidate) => candidate.name === boardParams.name);
|
||||
const document = store.readWidgetMcpApp(snapshot.sessionKey, boardParams.name);
|
||||
if (
|
||||
!widget ||
|
||||
widget.contentKind !== "mcp-app" ||
|
||||
!document ||
|
||||
document.revision !== widget.revision ||
|
||||
document.revision !== boardParams.revision ||
|
||||
widget.instanceId !== boardParams.instanceId
|
||||
) {
|
||||
throw new BoardValidationError(
|
||||
"not_found",
|
||||
`board MCP App widget not found: ${boardParams.name}`,
|
||||
);
|
||||
}
|
||||
const originSessionKey = store.getSnapshot(document.descriptor.originSessionKey).sessionKey;
|
||||
if (originSessionKey !== snapshot.sessionKey) {
|
||||
throw new BoardValidationError(
|
||||
"invalid_operation",
|
||||
"Pinned MCP App source does not belong to this board session",
|
||||
);
|
||||
}
|
||||
// Pins created before server-side source validation have no generation and stay read-only.
|
||||
const sourceValidated =
|
||||
Boolean(document.grantGeneration) && document.grantGeneration === widget.instanceId;
|
||||
const requiresToolGrant = document.declaredTools.length > 0;
|
||||
const interactive =
|
||||
sourceValidated && (!requiresToolGrant || document.grantState === "granted");
|
||||
const minted = await mcpApp.mintFromTranscript({
|
||||
cfg: context.getRuntimeConfig(),
|
||||
sessionKey: originSessionKey,
|
||||
descriptor: { ...document.descriptor, originSessionKey },
|
||||
allowedAppToolNames: new Set(interactive ? document.declaredTools : []),
|
||||
...(interactive && requiresToolGrant
|
||||
? {
|
||||
authorizeAppToolCall: () => {
|
||||
const current = store.readWidgetMcpApp(snapshot.sessionKey, boardParams.name);
|
||||
return (
|
||||
current?.revision === document.revision &&
|
||||
current.grantState === "granted" &&
|
||||
current.grantGeneration === document.grantGeneration
|
||||
);
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
readOnly: !interactive,
|
||||
});
|
||||
if (!minted) {
|
||||
throw new Error("Pinned MCP App source is no longer available");
|
||||
}
|
||||
respond(true, {
|
||||
viewId: minted.view.viewId,
|
||||
expiresAtMs: minted.view.expiresAtMs,
|
||||
});
|
||||
} catch (error) {
|
||||
respondBoardError(error, respond);
|
||||
}
|
||||
},
|
||||
"board.event": ({ params, respond }) => {
|
||||
if (!validateBoardEventParams(params)) {
|
||||
invalidParams("board.event", validateBoardEventParams.errors, respond);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { expectDefined } from "@openclaw/normalization-core";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { GatewayErrorDetailCodes } from "../../../packages/gateway-protocol/src/index.js";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
completeDeferredSessionMcpRuntimeRetirement: vi.fn(),
|
||||
@@ -27,6 +28,7 @@ vi.mock("../mcp-app-standalone.js", () => ({
|
||||
createMcpAppStandaloneTicket: mocks.createMcpAppStandaloneTicket,
|
||||
}));
|
||||
|
||||
import { resolveMcpAppAllowedToolNames } from "../mcp-app-operations.js";
|
||||
import { mcpAppHandlers } from "./mcp-app.js";
|
||||
|
||||
const view = {
|
||||
@@ -37,6 +39,7 @@ const view = {
|
||||
uiResourceUri: "ui://demo/app",
|
||||
html: "<html>demo</html>",
|
||||
allowedAppToolNames: new Set(["shared", "app-only"]) as ReadonlySet<string> | undefined,
|
||||
authorizeAppToolCall: undefined as (() => boolean | Promise<boolean>) | undefined,
|
||||
readOnly: undefined as boolean | undefined,
|
||||
toolInput: { city: "Paris" },
|
||||
toolResult: { content: [{ type: "text", text: "ok" }] },
|
||||
@@ -110,6 +113,7 @@ describe("MCP App gateway bridge", () => {
|
||||
view.toolCallCount = 0;
|
||||
view.activeRequests = 0;
|
||||
view.allowedAppToolNames = new Set(["shared", "app-only"]);
|
||||
view.authorizeAppToolCall = undefined;
|
||||
view.readOnly = undefined;
|
||||
mocks.getMcpAppViewLease.mockReset().mockReturnValue(view);
|
||||
mocks.completeDeferredSessionMcpRuntimeRetirement.mockReset().mockResolvedValue(false);
|
||||
@@ -278,6 +282,20 @@ describe("MCP App gateway bridge", () => {
|
||||
expect(activeRuntime.pendingMcpAppModelContext).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rechecks a board widget grant before updating model context", async () => {
|
||||
view.authorizeAppToolCall = vi.fn(async () => false);
|
||||
const respond = await invoke("mcp.app.updateModelContext", {
|
||||
sessionKey: "agent:main:main",
|
||||
viewId: "cv_app",
|
||||
content: [{ type: "text", text: "blocked" }],
|
||||
});
|
||||
|
||||
expect(respond.mock.calls[0]?.[0]).toBe(false);
|
||||
expect(view.authorizeAppToolCall).toHaveBeenCalledOnce();
|
||||
const activeRuntime = mocks.peekSessionMcpRuntime.mock.results[0]?.value;
|
||||
expect(activeRuntime.pendingMcpAppModelContext).toBeUndefined();
|
||||
});
|
||||
|
||||
it("filters model-only tools from app discovery and execution", async () => {
|
||||
const params = { sessionKey: "agent:main:main", viewId: "cv_app" };
|
||||
const listed = await invoke("mcp.app.listTools", params);
|
||||
@@ -303,6 +321,37 @@ describe("MCP App gateway bridge", () => {
|
||||
expect(denied.mock.calls[0]?.[0]).toBe(false);
|
||||
});
|
||||
|
||||
it("rechecks a board widget grant before every App tool call", async () => {
|
||||
view.authorizeAppToolCall = vi.fn(async () => false);
|
||||
const denied = await invoke("mcp.app.callTool", {
|
||||
sessionKey: "agent:main:main",
|
||||
viewId: "cv_app",
|
||||
toolName: "shared",
|
||||
});
|
||||
|
||||
expect(denied.mock.calls[0]?.[0]).toBe(false);
|
||||
expect(view.authorizeAppToolCall).toHaveBeenCalledOnce();
|
||||
expect(mocks.peekSessionMcpRuntime.mock.results[0]?.value.callTool).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("captures only app-visible tools allowed by the originating view", async () => {
|
||||
const activeRuntime = runtime();
|
||||
const activeView = {
|
||||
...view,
|
||||
allowedAppToolNames: new Set(["app-only", "model-only"]),
|
||||
};
|
||||
|
||||
await expect(
|
||||
resolveMcpAppAllowedToolNames({ runtime: activeRuntime as never, view: activeView as never }),
|
||||
).resolves.toEqual(["app-only"]);
|
||||
await expect(
|
||||
resolveMcpAppAllowedToolNames({
|
||||
runtime: activeRuntime as never,
|
||||
view: { ...activeView, readOnly: true } as never,
|
||||
}),
|
||||
).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it("rejects views that are not backed by the transcript", async () => {
|
||||
mocks.getMcpAppViewLease.mockReturnValue(undefined);
|
||||
const respond = await invoke("mcp.app.view", {
|
||||
@@ -310,6 +359,9 @@ describe("MCP App gateway bridge", () => {
|
||||
viewId: "expired",
|
||||
});
|
||||
expect(respond.mock.calls[0]?.[0]).toBe(false);
|
||||
expect(respond.mock.calls[0]?.[2]).toMatchObject({
|
||||
details: { code: GatewayErrorDetailCodes.MCP_APP_VIEW_EXPIRED },
|
||||
});
|
||||
expect(mocks.restoreMcpAppView).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
sessionKey: "agent:main:main",
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
import { ErrorCodes, errorShape } from "../../../packages/gateway-protocol/src/index.js";
|
||||
import {
|
||||
ErrorCodes,
|
||||
errorShape,
|
||||
GatewayErrorDetailCodes,
|
||||
} from "../../../packages/gateway-protocol/src/index.js";
|
||||
import { updateMcpAppModelContext } from "../../agents/mcp-app-model-context.js";
|
||||
import { buildMcpAppSandboxPath } from "../../agents/mcp-app-sandbox.js";
|
||||
import { formatErrorMessage } from "../../infra/errors.js";
|
||||
import { logWarn } from "../../logger.js";
|
||||
import {
|
||||
executeMcpAppOperation,
|
||||
McpAppViewExpiredError,
|
||||
type McpAppOperation,
|
||||
requireMcpAppViewAuthorization,
|
||||
resolveMcpAppActiveView,
|
||||
withMcpAppActiveView,
|
||||
} from "../mcp-app-operations.js";
|
||||
@@ -43,7 +49,17 @@ async function handle(
|
||||
try {
|
||||
respond(true, await operation());
|
||||
} catch (error) {
|
||||
respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, formatErrorMessage(error)));
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(
|
||||
ErrorCodes.UNAVAILABLE,
|
||||
formatErrorMessage(error),
|
||||
error instanceof McpAppViewExpiredError
|
||||
? { details: { code: GatewayErrorDetailCodes.MCP_APP_VIEW_EXPIRED } }
|
||||
: undefined,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,10 +119,11 @@ export const mcpAppHandlers: GatewayRequestHandlers = {
|
||||
sessionKey: requireString(params, "sessionKey"),
|
||||
viewId: requireString(params, "viewId"),
|
||||
});
|
||||
return await withMcpAppActiveView(active, "read", () => {
|
||||
return await withMcpAppActiveView(active, "read", async () => {
|
||||
if (active.view.readOnly === true || active.view.allowedAppToolNames === undefined) {
|
||||
throw new Error("MCP App view is not authorized to update model context");
|
||||
}
|
||||
await requireMcpAppViewAuthorization(active.view);
|
||||
updateMcpAppModelContext(active.runtime, active.view, params);
|
||||
return {};
|
||||
});
|
||||
|
||||
@@ -78,6 +78,17 @@ function deferred(): {
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
function deferredValue<T>(): {
|
||||
promise: Promise<T>;
|
||||
resolve: (value: T) => void;
|
||||
} {
|
||||
let resolve: (value: T) => void = () => undefined;
|
||||
const promise = new Promise<T>((resolvePromise) => {
|
||||
resolve = resolvePromise;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
async function settleCells(view: OpenClawBoardView): Promise<OpenClawBoardWidgetCell[]> {
|
||||
await view.updateComplete;
|
||||
const cells = [...view.querySelectorAll("openclaw-board-widget-cell")];
|
||||
@@ -108,6 +119,7 @@ async function mount(
|
||||
afterEach(() => {
|
||||
document.body.replaceChildren();
|
||||
vi.unstubAllGlobals();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("openclaw-board-view", () => {
|
||||
@@ -410,6 +422,178 @@ describe("openclaw-board-view", () => {
|
||||
await vi.waitFor(() => expect(grant).toHaveBeenCalledWith("alpha", "rejected"));
|
||||
});
|
||||
|
||||
it("renders MCP App widgets through the bridge element while approval is pending", async () => {
|
||||
if (!customElements.get("mcp-app-view")) {
|
||||
customElements.define("mcp-app-view", class extends HTMLElement {});
|
||||
}
|
||||
const widgetAppView = vi.fn(async () => ({
|
||||
status: "ready" as const,
|
||||
viewId: "mcp-app-board",
|
||||
expiresAtMs: Date.now() + 60_000,
|
||||
}));
|
||||
const refreshWidgetAppView = vi.fn(async () => ({
|
||||
status: "ready" as const,
|
||||
viewId: "mcp-app-renewed",
|
||||
expiresAtMs: Date.now() + 60_000,
|
||||
}));
|
||||
const source = snapshot({
|
||||
sessionKey: "agent:main:main",
|
||||
widgets: [boardWidget({ contentKind: "mcp-app", grantState: "pending" })],
|
||||
});
|
||||
const view = await mount({
|
||||
snapshot: source,
|
||||
callbacks: callbacks({ widgetAppView, refreshWidgetAppView }),
|
||||
});
|
||||
|
||||
await vi.waitFor(() => expect(view.querySelector("mcp-app-view")).not.toBeNull());
|
||||
const app = view.querySelector<HTMLElement & { sessionKey: string; viewId: string }>(
|
||||
"mcp-app-view",
|
||||
);
|
||||
expect(app?.sessionKey).toBe("agent:main:main");
|
||||
expect(app?.viewId).toBe("mcp-app-board");
|
||||
expect(view.querySelector('[data-test-id="board-pending"]')).not.toBeNull();
|
||||
expect(view.querySelector("iframe")).toBeNull();
|
||||
app?.dispatchEvent(
|
||||
new CustomEvent("openclaw-mcp-app-view-expired", { bubbles: true, composed: true }),
|
||||
);
|
||||
await vi.waitFor(() =>
|
||||
expect(view.querySelector<HTMLElement & { viewId: string }>("mcp-app-view")?.viewId).toBe(
|
||||
"mcp-app-renewed",
|
||||
),
|
||||
);
|
||||
expect(refreshWidgetAppView).toHaveBeenCalledWith("alpha", 1);
|
||||
});
|
||||
|
||||
it("renews a mounted MCP App lease before it expires", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(1_000);
|
||||
if (!customElements.get("mcp-app-view")) {
|
||||
customElements.define("mcp-app-view", class extends HTMLElement {});
|
||||
}
|
||||
const widgetAppView = vi.fn(async () => ({
|
||||
status: "ready" as const,
|
||||
viewId: "mcp-app-board",
|
||||
expiresAtMs: 11_000,
|
||||
}));
|
||||
const refreshWidgetAppView = vi.fn(async () => ({
|
||||
status: "ready" as const,
|
||||
viewId: "mcp-app-renewed",
|
||||
expiresAtMs: 71_000,
|
||||
}));
|
||||
const source = snapshot({ widgets: [boardWidget({ contentKind: "mcp-app" })] });
|
||||
const view = await mount({
|
||||
snapshot: source,
|
||||
callbacks: callbacks({ widgetAppView, refreshWidgetAppView }),
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
await settleCells(view);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(4_999);
|
||||
expect(refreshWidgetAppView).not.toHaveBeenCalled();
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
await settleCells(view);
|
||||
|
||||
expect(refreshWidgetAppView).toHaveBeenCalledWith("alpha", 1);
|
||||
expect(view.querySelector<HTMLElement & { viewId: string }>("mcp-app-view")?.viewId).toBe(
|
||||
"mcp-app-renewed",
|
||||
);
|
||||
view.remove();
|
||||
await vi.advanceTimersByTimeAsync(60_000);
|
||||
expect(refreshWidgetAppView).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not loop automatic renewal for a lease already inside the refresh window", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(1_000);
|
||||
const widgetAppView = vi.fn(async () => ({
|
||||
status: "ready" as const,
|
||||
viewId: "mcp-app-near-expiry",
|
||||
expiresAtMs: 5_000,
|
||||
}));
|
||||
const refreshWidgetAppView = vi.fn(async () => ({
|
||||
status: "ready" as const,
|
||||
viewId: "mcp-app-renewed",
|
||||
expiresAtMs: 5_000,
|
||||
}));
|
||||
const source = snapshot({ widgets: [boardWidget({ contentKind: "mcp-app" })] });
|
||||
const view = await mount({
|
||||
snapshot: source,
|
||||
callbacks: callbacks({ widgetAppView, refreshWidgetAppView }),
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(60_000);
|
||||
|
||||
expect(refreshWidgetAppView).not.toHaveBeenCalled();
|
||||
view.remove();
|
||||
});
|
||||
|
||||
it("does not schedule renewal when an in-flight MCP App load resolves after disconnect", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(1_000);
|
||||
const pending = deferredValue<{
|
||||
status: "ready";
|
||||
viewId: string;
|
||||
expiresAtMs: number;
|
||||
}>();
|
||||
const widgetAppView = vi.fn(() => pending.promise);
|
||||
const refreshWidgetAppView = vi.fn(async () => ({
|
||||
status: "ready" as const,
|
||||
viewId: "mcp-app-renewed",
|
||||
expiresAtMs: 71_000,
|
||||
}));
|
||||
const source = snapshot({ widgets: [boardWidget({ contentKind: "mcp-app" })] });
|
||||
const view = await mount({
|
||||
snapshot: source,
|
||||
callbacks: callbacks({ widgetAppView, refreshWidgetAppView }),
|
||||
});
|
||||
expect(widgetAppView).toHaveBeenCalledWith("alpha", 1);
|
||||
|
||||
view.remove();
|
||||
pending.resolve({ status: "ready", viewId: "late-view", expiresAtMs: 11_000 });
|
||||
await vi.advanceTimersByTimeAsync(60_000);
|
||||
|
||||
expect(refreshWidgetAppView).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows stale MCP Apps with retry and remove without breaking the board", async () => {
|
||||
if (!customElements.get("mcp-app-view")) {
|
||||
customElements.define("mcp-app-view", class extends HTMLElement {});
|
||||
}
|
||||
const widgetAppView = vi.fn(async () => ({
|
||||
status: "stale" as const,
|
||||
error: "origin transcript pruned",
|
||||
}));
|
||||
const refreshWidgetAppView = vi.fn(async () => ({
|
||||
status: "ready" as const,
|
||||
viewId: "mcp-app-retried",
|
||||
expiresAtMs: Date.now() + 60_000,
|
||||
}));
|
||||
const applyOps = vi.fn(async () => undefined);
|
||||
const source = snapshot({ widgets: [boardWidget({ contentKind: "mcp-app" })] });
|
||||
const view = await mount({
|
||||
snapshot: source,
|
||||
callbacks: callbacks({ applyOps, widgetAppView, refreshWidgetAppView }),
|
||||
});
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(view.querySelector('[data-test-id="board-mcp-app-stale"]')).not.toBeNull(),
|
||||
);
|
||||
const buttons = view.querySelectorAll<HTMLButtonElement>(
|
||||
'[data-test-id="board-mcp-app-stale"] button',
|
||||
);
|
||||
expect([...buttons].map((button) => button.textContent?.trim())).toEqual(["Retry", "Remove"]);
|
||||
buttons[1]?.click();
|
||||
await vi.waitFor(() =>
|
||||
expect(applyOps).toHaveBeenCalledWith([{ kind: "widget_remove", name: "alpha" }]),
|
||||
);
|
||||
buttons[0]?.click();
|
||||
await vi.waitFor(() =>
|
||||
expect(view.querySelector<HTMLElement & { viewId: string }>("mcp-app-view")?.viewId).toBe(
|
||||
"mcp-app-retried",
|
||||
),
|
||||
);
|
||||
expect(refreshWidgetAppView).toHaveBeenCalledWith("alpha", 1);
|
||||
});
|
||||
|
||||
it("renders declared approval details instead of the generic copy", async () => {
|
||||
const source = snapshot({
|
||||
widgets: [
|
||||
|
||||
@@ -207,6 +207,16 @@ class OpenClawBoardView extends OpenClawLightDomElement {
|
||||
frameLoadFailed: async (name) => {
|
||||
await this.callbacks?.frameLoadFailed?.(name);
|
||||
},
|
||||
widgetAppView: async (name, revision) =>
|
||||
(await this.callbacks?.widgetAppView?.(name, revision)) ?? {
|
||||
status: "stale",
|
||||
error: "MCP App view unavailable",
|
||||
},
|
||||
refreshWidgetAppView: async (name, revision) =>
|
||||
(await this.callbacks?.refreshWidgetAppView?.(name, revision)) ?? {
|
||||
status: "stale",
|
||||
error: "MCP App view unavailable",
|
||||
},
|
||||
};
|
||||
|
||||
private beginGesture(
|
||||
@@ -563,10 +573,10 @@ class OpenClawBoardView extends OpenClawLightDomElement {
|
||||
.widget=${widget}
|
||||
.rect=${rect}
|
||||
.tabs=${tabs}
|
||||
.sessionKey=${sessionKey}
|
||||
.widgetFrameUrl=${this.widgetFrameUrl}
|
||||
.callbacks=${this.cellCallbacks}
|
||||
.sessions=${this.sessions}
|
||||
.sessionKey=${sessionKey}
|
||||
.dragging=${widget.name === this.gestureName}
|
||||
.focusTabIndex=${widget.name === focusName ? 0 : -1}
|
||||
.positionInSet=${(logicalPosition.get(widget.name) ?? 0) + 1}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { html, nothing, type PropertyValues, type TemplateResult } from "lit";
|
||||
import { property, state } from "lit/decorators.js";
|
||||
import type { GatewaySessionRow } from "../../api/types.ts";
|
||||
import { ensureCustomElementDefined } from "../../app/lazy-custom-element.ts";
|
||||
import { t } from "../../i18n/index.ts";
|
||||
import type { BoardGridDirection, BoardGridRect } from "../../lib/board/grid.ts";
|
||||
import { toCssPlacement } from "../../lib/board/grid.ts";
|
||||
import type { BoardWidgetAppViewState } from "../../lib/board/provider.ts";
|
||||
import type { BoardTab } from "../../lib/board/types.ts";
|
||||
import type {
|
||||
BoardGrantDecision,
|
||||
@@ -21,6 +23,8 @@ const BOARD_SIZE_PRESETS = {
|
||||
xl: { w: 12, h: 8 },
|
||||
} as const;
|
||||
const MAX_FRAME_REFRESH_ATTEMPTS = 3;
|
||||
const APP_VIEW_REFRESH_LEAD_MS = 5_000;
|
||||
const loadMcpAppView = () => import("../mcp-app-view-registration.ts");
|
||||
|
||||
export type BoardWidgetCellCallbacks = {
|
||||
grant: (name: string, decision: BoardGrantDecision) => Promise<void>;
|
||||
@@ -33,16 +37,18 @@ export type BoardWidgetCellCallbacks = {
|
||||
focus: (widget: BoardViewWidget, direction: BoardGridDirection) => void;
|
||||
focusChanged: (name: string) => void;
|
||||
frameLoadFailed: (name: string) => Promise<void>;
|
||||
widgetAppView: (name: string, revision: number) => Promise<BoardWidgetAppViewState>;
|
||||
refreshWidgetAppView: (name: string, revision: number) => Promise<BoardWidgetAppViewState>;
|
||||
};
|
||||
|
||||
class OpenClawBoardWidgetCell extends OpenClawLightDomElement {
|
||||
@property({ attribute: false }) widget?: BoardViewWidget;
|
||||
@property({ attribute: false }) rect?: BoardGridRect;
|
||||
@property({ attribute: false }) tabs: readonly BoardTab[] = [];
|
||||
@property({ attribute: false }) sessionKey = "";
|
||||
@property({ attribute: false }) widgetFrameUrl?: BoardWidgetFrameUrl;
|
||||
@property({ attribute: false }) callbacks?: BoardWidgetCellCallbacks;
|
||||
@property({ attribute: false }) sessions: readonly GatewaySessionRow[] = [];
|
||||
@property({ type: String }) sessionKey = "";
|
||||
@property({ type: Boolean }) dragging = false;
|
||||
@property({ type: Number }) focusTabIndex = -1;
|
||||
@property({ type: Number }) positionInSet = 1;
|
||||
@@ -52,10 +58,24 @@ class OpenClawBoardWidgetCell extends OpenClawLightDomElement {
|
||||
@state() private actionError = "";
|
||||
@state() private actionPending = false;
|
||||
@state() private frameError = "";
|
||||
@state() private appViewState?: BoardWidgetAppViewState;
|
||||
@state() private appViewLoading = false;
|
||||
private frameFailureKey = "";
|
||||
private frameRefreshAttempts = 0;
|
||||
private frameProbeGeneration = 0;
|
||||
private lastFrameUrl = "";
|
||||
private appViewKey = "";
|
||||
private appViewGeneration = 0;
|
||||
private appViewRenewalTimer?: number;
|
||||
|
||||
override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
const widget = this.widget;
|
||||
if (widget?.contentKind === "mcp-app" && this.callbacks && !this.appViewKey) {
|
||||
this.appViewKey = this.currentAppViewKey(widget);
|
||||
void this.loadAppView(widget, this.callbacks, false);
|
||||
}
|
||||
}
|
||||
|
||||
override willUpdate(changed: PropertyValues<this>): void {
|
||||
const previousWidget = changed.get("widget");
|
||||
@@ -75,6 +95,105 @@ class OpenClawBoardWidgetCell extends OpenClawLightDomElement {
|
||||
}
|
||||
}
|
||||
}
|
||||
const widget = this.widget;
|
||||
if (!this.isConnected || !widget || widget.contentKind !== "mcp-app" || !this.callbacks) {
|
||||
this.cancelAppViewRenewal();
|
||||
this.appViewGeneration += 1;
|
||||
this.appViewKey = "";
|
||||
this.appViewState = undefined;
|
||||
this.appViewLoading = false;
|
||||
return;
|
||||
}
|
||||
const key = this.currentAppViewKey(widget);
|
||||
if (key !== this.appViewKey) {
|
||||
this.cancelAppViewRenewal();
|
||||
this.appViewGeneration += 1;
|
||||
this.appViewLoading = false;
|
||||
this.appViewKey = key;
|
||||
void this.loadAppView(widget, this.callbacks, false);
|
||||
}
|
||||
}
|
||||
|
||||
override disconnectedCallback(): void {
|
||||
this.cancelAppViewRenewal();
|
||||
this.appViewGeneration += 1;
|
||||
this.appViewKey = "";
|
||||
this.appViewState = undefined;
|
||||
this.appViewLoading = false;
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
private async loadAppView(
|
||||
widget: BoardViewWidget,
|
||||
callbacks: BoardWidgetCellCallbacks,
|
||||
force: boolean,
|
||||
): Promise<void> {
|
||||
if (this.appViewLoading) {
|
||||
return;
|
||||
}
|
||||
const key = this.currentAppViewKey(widget);
|
||||
const generation = ++this.appViewGeneration;
|
||||
this.cancelAppViewRenewal();
|
||||
this.appViewLoading = true;
|
||||
if (force) {
|
||||
this.appViewState = undefined;
|
||||
}
|
||||
const appView = await (force
|
||||
? callbacks.refreshWidgetAppView(widget.name, widget.revision)
|
||||
: callbacks.widgetAppView(widget.name, widget.revision));
|
||||
const current = this.widget;
|
||||
if (
|
||||
this.isConnected &&
|
||||
generation === this.appViewGeneration &&
|
||||
this.appViewKey === key &&
|
||||
current?.name === widget.name &&
|
||||
current.revision === widget.revision
|
||||
) {
|
||||
this.appViewState = appView;
|
||||
this.appViewLoading = false;
|
||||
this.scheduleAppViewRenewal(widget, callbacks, appView);
|
||||
}
|
||||
}
|
||||
|
||||
private currentAppViewKey(widget: BoardViewWidget): string {
|
||||
return `${this.sessionKey}\0${widget.name}\0${widget.revision}\0${widget.instanceId ?? ""}\0${widget.grantState}`;
|
||||
}
|
||||
|
||||
private cancelAppViewRenewal(): void {
|
||||
if (this.appViewRenewalTimer !== undefined) {
|
||||
window.clearTimeout(this.appViewRenewalTimer);
|
||||
this.appViewRenewalTimer = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleAppViewRenewal(
|
||||
widget: BoardViewWidget,
|
||||
callbacks: BoardWidgetCellCallbacks,
|
||||
appView: BoardWidgetAppViewState,
|
||||
): void {
|
||||
this.cancelAppViewRenewal();
|
||||
if (appView.status !== "ready") {
|
||||
return;
|
||||
}
|
||||
const key = this.appViewKey;
|
||||
const delayMs = appView.expiresAtMs - Date.now() - APP_VIEW_REFRESH_LEAD_MS;
|
||||
if (delayMs <= 0) {
|
||||
// A near-expiry response or clock skew must not create a tight remint loop.
|
||||
// The bridge's structured expiry event remains the renewal fallback.
|
||||
return;
|
||||
}
|
||||
this.appViewRenewalTimer = window.setTimeout(() => {
|
||||
this.appViewRenewalTimer = undefined;
|
||||
const current = this.widget;
|
||||
if (
|
||||
this.isConnected &&
|
||||
this.appViewKey === key &&
|
||||
current?.name === widget.name &&
|
||||
current.revision === widget.revision
|
||||
) {
|
||||
void this.loadAppView(current, callbacks, true);
|
||||
}
|
||||
}, delayMs);
|
||||
}
|
||||
|
||||
private resetFrameFailures(): void {
|
||||
@@ -323,7 +442,70 @@ class OpenClawBoardWidgetCell extends OpenClawLightDomElement {
|
||||
`;
|
||||
}
|
||||
|
||||
private renderStaleMcpApp(
|
||||
widget: BoardViewWidget,
|
||||
callbacks: BoardWidgetCellCallbacks,
|
||||
): TemplateResult {
|
||||
return html`
|
||||
<div class="board-widget__stale" data-test-id="board-mcp-app-stale">
|
||||
<strong>${t("board.widget.appStaleTitle")}</strong>
|
||||
<span>${t("board.widget.appStaleDetail")}</span>
|
||||
<div class="board-widget__grant-actions">
|
||||
<button
|
||||
class="btn btn--small btn--primary"
|
||||
type="button"
|
||||
?disabled=${this.appViewLoading}
|
||||
@click=${() => void this.loadAppView(widget, callbacks, true)}
|
||||
>
|
||||
${t("board.widget.retry")}
|
||||
</button>
|
||||
<button
|
||||
class="btn btn--small"
|
||||
type="button"
|
||||
?disabled=${this.busy || this.actionPending}
|
||||
@click=${() => void this.runAction(() => callbacks.remove(widget))}
|
||||
>
|
||||
${t("board.widget.remove")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderMcpApp(
|
||||
widget: BoardViewWidget,
|
||||
callbacks: BoardWidgetCellCallbacks,
|
||||
): TemplateResult {
|
||||
void ensureCustomElementDefined("mcp-app-view", loadMcpAppView).catch(() => undefined);
|
||||
const appView = this.appViewState;
|
||||
const accessNotice =
|
||||
widget.grantState === "pending"
|
||||
? this.renderPending(widget, callbacks)
|
||||
: widget.grantState === "rejected"
|
||||
? this.renderRejected(widget, callbacks)
|
||||
: nothing;
|
||||
const view =
|
||||
!appView || this.appViewLoading
|
||||
? html`<div class="board-widget__app-loading" data-test-id="board-mcp-app-loading">
|
||||
${t("board.widget.appLoading")}
|
||||
</div>`
|
||||
: appView.status === "stale"
|
||||
? this.renderStaleMcpApp(widget, callbacks)
|
||||
: html`<mcp-app-view
|
||||
class="board-widget__mcp-app-view"
|
||||
.sessionKey=${this.sessionKey}
|
||||
.viewId=${appView.viewId}
|
||||
.height=${Math.max(160, (this.rect?.h ?? 4) * 56 - 38)}
|
||||
.title=${widget.title || widget.name}
|
||||
@openclaw-mcp-app-view-expired=${() => void this.loadAppView(widget, callbacks, true)}
|
||||
></mcp-app-view>`;
|
||||
return html`<div class="board-widget__mcp-app">${accessNotice}${view}</div>`;
|
||||
}
|
||||
|
||||
private renderBody(widget: BoardViewWidget, callbacks: BoardWidgetCellCallbacks): TemplateResult {
|
||||
if (widget.contentKind === "mcp-app") {
|
||||
return this.renderMcpApp(widget, callbacks);
|
||||
}
|
||||
if (widget.grantState === "pending") {
|
||||
return this.renderPending(widget, callbacks);
|
||||
}
|
||||
@@ -425,6 +607,7 @@ class OpenClawBoardWidgetCell extends OpenClawLightDomElement {
|
||||
this.actionError !== "" ||
|
||||
widget.grantState === "pending" ||
|
||||
widget.grantState === "rejected";
|
||||
const contentScrollable = bodyScrollable || widget.contentKind === "mcp-app";
|
||||
return html`
|
||||
<section
|
||||
class=${`board-widget ${this.dragging ? "board-widget--dragging" : ""}`}
|
||||
@@ -461,7 +644,7 @@ class OpenClawBoardWidgetCell extends OpenClawLightDomElement {
|
||||
${readOnly ? nothing : this.renderMenu(widget, callbacks)}
|
||||
</header>
|
||||
<div
|
||||
class=${`board-widget__body ${bodyScrollable ? "board-widget__body--scrollable" : ""}`}
|
||||
class=${`board-widget__body ${contentScrollable ? "board-widget__body--scrollable" : ""}`}
|
||||
>
|
||||
${body}
|
||||
${this.actionError && widget.grantState !== "pending"
|
||||
|
||||
@@ -8,6 +8,7 @@ export type McpAppHostSandboxCsp = NonNullable<
|
||||
/** Bubbling event handled by the owning chat pane through its normal send path. */
|
||||
export const WIDGET_PROMPT_EVENT = "openclaw-widget-prompt";
|
||||
export type WidgetPromptEventDetail = { text: string };
|
||||
export const MCP_APP_VIEW_EXPIRED_EVENT = "openclaw-mcp-app-view-expired";
|
||||
|
||||
const WIDGET_PROMPT_MAX_CHARS = 4_000;
|
||||
const WIDGET_PROMPT_RATE_WINDOW_MS = 60_000;
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { GatewayErrorDetailCodes } from "@openclaw/gateway-protocol";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { i18n } from "../i18n/index.ts";
|
||||
import { WIDGET_PROMPT_EVENT, type WidgetPromptEventDetail } from "./mcp-app-security.ts";
|
||||
import {
|
||||
MCP_APP_VIEW_EXPIRED_EVENT,
|
||||
WIDGET_PROMPT_EVENT,
|
||||
type WidgetPromptEventDetail,
|
||||
} from "./mcp-app-security.ts";
|
||||
|
||||
const bridgeMocks = vi.hoisted(() => ({
|
||||
instances: [] as Array<Record<string, unknown>>,
|
||||
@@ -236,6 +241,54 @@ describe("mcp-app-view localization", () => {
|
||||
expect(received).toHaveLength(9);
|
||||
});
|
||||
|
||||
it("signals its board owner when the view lease has expired", async () => {
|
||||
const request = vi.fn(async () => {
|
||||
throw Object.assign(new Error("MCP App view expired"), {
|
||||
details: { code: GatewayErrorDetailCodes.MCP_APP_VIEW_EXPIRED },
|
||||
});
|
||||
});
|
||||
const view = document.createElement(MCP_APP_VIEW_ELEMENT_NAME) as McpAppViewElement;
|
||||
Reflect.set(view, "context", {
|
||||
gateway: {
|
||||
snapshot: { client: { request } },
|
||||
connection: { gatewayUrl: "ws://gateway.example:8443/openclaw" },
|
||||
},
|
||||
});
|
||||
view.sessionKey = "agent:main:main";
|
||||
view.viewId = "mcp-app-expired";
|
||||
const expired = vi.fn();
|
||||
view.addEventListener(MCP_APP_VIEW_EXPIRED_EVENT, expired);
|
||||
document.body.append(view);
|
||||
|
||||
await expect.poll(() => expired).toHaveBeenCalledOnce();
|
||||
await expect
|
||||
.poll(() => view.shadowRoot?.querySelector(".error")?.textContent)
|
||||
.toContain("MCP App view expired");
|
||||
});
|
||||
|
||||
it("does not renew the view for unrelated upstream expiry errors", async () => {
|
||||
const request = vi.fn(async () => {
|
||||
throw new Error("upstream token expired");
|
||||
});
|
||||
const view = document.createElement(MCP_APP_VIEW_ELEMENT_NAME) as McpAppViewElement;
|
||||
Reflect.set(view, "context", {
|
||||
gateway: {
|
||||
snapshot: { client: { request } },
|
||||
connection: { gatewayUrl: "ws://gateway.example:8443/openclaw" },
|
||||
},
|
||||
});
|
||||
view.sessionKey = "agent:main:main";
|
||||
view.viewId = "mcp-app-upstream-expired";
|
||||
const expired = vi.fn();
|
||||
view.addEventListener(MCP_APP_VIEW_EXPIRED_EVENT, expired);
|
||||
document.body.append(view);
|
||||
|
||||
await expect
|
||||
.poll(() => view.shadowRoot?.querySelector(".error")?.textContent)
|
||||
.toContain("upstream token expired");
|
||||
expect(expired).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not advertise or install message support for read-only views", async () => {
|
||||
const { bridge } = await mountBridge(`view-read-only-${crypto.randomUUID()}`, false);
|
||||
expect(bridge.capabilities).not.toHaveProperty("message");
|
||||
@@ -301,6 +354,13 @@ describe("mcp-app-view localization", () => {
|
||||
expect.objectContaining({ containerDimensions: { width: 720, height: 600 } }),
|
||||
);
|
||||
|
||||
view.height = 480;
|
||||
await view.updateComplete;
|
||||
expect(view.shadowRoot?.querySelector("iframe")?.style.height).toBe("480px");
|
||||
expect(bridge.setHostContext).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ containerDimensions: { width: 720, height: 480 } }),
|
||||
);
|
||||
|
||||
view.remove();
|
||||
await expect.poll(() => disconnect).toHaveBeenCalledOnce();
|
||||
expect(unsubscribe).toHaveBeenCalledOnce();
|
||||
|
||||
@@ -6,7 +6,8 @@ import {
|
||||
ListToolsRequestSchema,
|
||||
type ListToolsResult,
|
||||
} from "@modelcontextprotocol/sdk/types.js";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { isMcpAppViewExpiredError } from "@openclaw/gateway-protocol";
|
||||
import { LitElement, css, html, nothing, type PropertyValues } from "lit";
|
||||
import { property, state } from "lit/decorators.js";
|
||||
import { createRef, ref } from "lit/directives/ref.js";
|
||||
import { applicationContext, type ApplicationContext } from "../app/context.ts";
|
||||
@@ -15,6 +16,7 @@ import { openExternalUrlSafe } from "../lib/open-external-url.ts";
|
||||
import {
|
||||
buildMcpAppHostCapabilities,
|
||||
dispatchWidgetPrompt,
|
||||
MCP_APP_VIEW_EXPIRED_EVENT,
|
||||
resolveMcpAppSandboxUrl,
|
||||
type McpAppHostSandboxCsp,
|
||||
} from "./mcp-app-security.ts";
|
||||
@@ -39,6 +41,7 @@ type ScheduleFallback = (callback: () => void, delayMs: number) => number;
|
||||
type McpAppResources = {
|
||||
bridge: OpenClawAppBridge | null;
|
||||
cleanups: Set<() => void>;
|
||||
frameHeight: number;
|
||||
iframe: HTMLIFrameElement;
|
||||
transport: { close(): Promise<void> } | null;
|
||||
};
|
||||
@@ -151,9 +154,14 @@ export class McpAppView extends LitElement {
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
override updated() {
|
||||
override updated(changedProperties: PropertyValues<this>) {
|
||||
if (this.resources) {
|
||||
this.resources.iframe.title = this.title || t("mcpApp.title");
|
||||
if (changedProperties.has("height")) {
|
||||
this.resources.frameHeight = this.height;
|
||||
this.resources.iframe.style.height = `${this.height}px`;
|
||||
this.resources.bridge?.setHostContext(hostContext(this.mount.value, this.height));
|
||||
}
|
||||
}
|
||||
const nextKey = `${this.sessionKey}\0${this.viewId}`;
|
||||
const nextClient = this.context?.gateway.snapshot.client ?? null;
|
||||
@@ -169,11 +177,20 @@ export class McpAppView extends LitElement {
|
||||
if (!client || !this.sessionKey || !this.viewId) {
|
||||
throw new Error("MCP App gateway unavailable");
|
||||
}
|
||||
return await client.request(method, {
|
||||
sessionKey: this.sessionKey,
|
||||
viewId: this.viewId,
|
||||
...params,
|
||||
});
|
||||
try {
|
||||
return await client.request(method, {
|
||||
sessionKey: this.sessionKey,
|
||||
viewId: this.viewId,
|
||||
...params,
|
||||
});
|
||||
} catch (error) {
|
||||
if (isMcpAppViewExpiredError(error)) {
|
||||
this.dispatchEvent(
|
||||
new CustomEvent(MCP_APP_VIEW_EXPIRED_EVENT, { bubbles: true, composed: true }),
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private addResourceCleanup(resources: McpAppResources, cleanup: () => void): () => void {
|
||||
@@ -271,6 +288,7 @@ export class McpAppView extends LitElement {
|
||||
const resources: McpAppResources = {
|
||||
bridge: null,
|
||||
cleanups: new Set(),
|
||||
frameHeight: this.height,
|
||||
iframe,
|
||||
transport: null,
|
||||
};
|
||||
@@ -308,7 +326,6 @@ export class McpAppView extends LitElement {
|
||||
return;
|
||||
}
|
||||
|
||||
let frameHeight = this.height;
|
||||
const bridge = new OpenClawAppBridge(
|
||||
null,
|
||||
{ name: "OpenClaw", version: "1.0.0" },
|
||||
@@ -374,7 +391,7 @@ export class McpAppView extends LitElement {
|
||||
bridge.onsizechange = ({ height }) => {
|
||||
if (height !== undefined) {
|
||||
const nextHeight = Math.min(1200, Math.max(160, Math.round(height)));
|
||||
frameHeight = nextHeight;
|
||||
resources.frameHeight = nextHeight;
|
||||
iframe.style.height = `${nextHeight}px`;
|
||||
bridge.setHostContext(hostContext(mount, nextHeight));
|
||||
}
|
||||
@@ -411,7 +428,8 @@ export class McpAppView extends LitElement {
|
||||
if (generation !== this.setupGeneration) {
|
||||
return;
|
||||
}
|
||||
const updateHostContext = () => bridge.setHostContext(hostContext(mount, frameHeight));
|
||||
const updateHostContext = () =>
|
||||
bridge.setHostContext(hostContext(mount, resources.frameHeight));
|
||||
const hostContextCleanup = this.context?.theme.subscribe(updateHostContext);
|
||||
if (hostContextCleanup) {
|
||||
this.addResourceCleanup(resources, hostContextCleanup);
|
||||
|
||||
@@ -2612,6 +2612,10 @@ export const en: TranslationMap = {
|
||||
reject: "Reject",
|
||||
rejected: "Access rejected",
|
||||
rejectedDetail: "This widget stays inactive until it is removed or replaced.",
|
||||
appLoading: "Restoring app…",
|
||||
appStaleTitle: "This pinned app is stale",
|
||||
appStaleDetail: "Its server, resource, or originating transcript is no longer available.",
|
||||
retry: "Retry",
|
||||
frameResolverMissing: "Widget content is unavailable.",
|
||||
frameAuthorizationFailed: "Widget authorization failed after repeated refresh attempts.",
|
||||
errorTitle: "This widget could not load",
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import type { BoardWidget, BoardWidgetAppViewResult } from "@openclaw/gateway-protocol";
|
||||
import type { BoardWidgetAppViewState } from "./view-types.ts";
|
||||
|
||||
type AppViewRequest = () => Promise<BoardWidgetAppViewResult>;
|
||||
|
||||
export class BoardMcpAppViewCache {
|
||||
private readonly entries = new Map<
|
||||
string,
|
||||
Promise<BoardWidgetAppViewState> | BoardWidgetAppViewState
|
||||
>();
|
||||
|
||||
clear(): void {
|
||||
this.entries.clear();
|
||||
}
|
||||
|
||||
prune(widgets: readonly BoardWidget[]): void {
|
||||
const validKeys = new Set(
|
||||
widgets
|
||||
.filter((widget) => widget.contentKind === "mcp-app")
|
||||
.map((widget) => this.key(widget)),
|
||||
);
|
||||
for (const key of this.entries.keys()) {
|
||||
if (!validKeys.has(key)) {
|
||||
this.entries.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async resolve(
|
||||
widget: BoardWidget,
|
||||
request: AppViewRequest,
|
||||
force: boolean,
|
||||
): Promise<BoardWidgetAppViewState> {
|
||||
const key = this.key(widget);
|
||||
if (force) {
|
||||
this.entries.delete(key);
|
||||
}
|
||||
const cached = this.entries.get(key);
|
||||
if (cached) {
|
||||
const resolved = await cached;
|
||||
if (resolved.status !== "ready" || resolved.expiresAtMs > Date.now() + 5_000) {
|
||||
return resolved;
|
||||
}
|
||||
this.entries.delete(key);
|
||||
}
|
||||
const pending = request()
|
||||
.then<BoardWidgetAppViewState>((result) => ({ status: "ready", ...result }))
|
||||
.catch<BoardWidgetAppViewState>((error: unknown) => ({
|
||||
status: "stale",
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
}));
|
||||
this.entries.set(key, pending);
|
||||
const resolved = await pending;
|
||||
if (this.entries.get(key) === pending) {
|
||||
this.entries.set(key, resolved);
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
private key(widget: BoardWidget): string {
|
||||
return `${widget.name}\0${widget.revision}\0${widget.instanceId ?? ""}\0${widget.grantState}`;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
// @vitest-environment node
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { BoardMcpAppViewCache } from "./mcp-app-view-cache.ts";
|
||||
import {
|
||||
boardExists,
|
||||
boardProviderForSession,
|
||||
@@ -820,6 +821,7 @@ describe("board providers", () => {
|
||||
position: 0,
|
||||
grantState: "none" as const,
|
||||
revision: 1,
|
||||
instanceId: "canvas-instance",
|
||||
frameUrl: "/frame",
|
||||
},
|
||||
],
|
||||
@@ -864,6 +866,7 @@ describe("board providers", () => {
|
||||
name: "canvas-cv-1",
|
||||
decision: "granted",
|
||||
revision: 1,
|
||||
instanceId: "canvas-instance",
|
||||
});
|
||||
expect(request).toHaveBeenCalledWith("board.widget.put", {
|
||||
sessionKey: "agent:main:live",
|
||||
@@ -878,4 +881,140 @@ describe("board providers", () => {
|
||||
command: { kind: "focus_tab", tabId: "main" },
|
||||
});
|
||||
});
|
||||
|
||||
it("caches MCP App leases and re-mints them as expiry approaches", async () => {
|
||||
mockLocation.search = "";
|
||||
let now = 0;
|
||||
vi.spyOn(Date, "now").mockImplementation(() => now);
|
||||
const snapshot = {
|
||||
sessionKey: "agent:main:app",
|
||||
revision: 1,
|
||||
tabs: [{ tabId: "main", title: "Main", position: 0, chatDock: "right" as const }],
|
||||
widgets: [
|
||||
{
|
||||
name: "server-app",
|
||||
tabId: "main",
|
||||
contentKind: "mcp-app" as const,
|
||||
sizeW: 6,
|
||||
sizeH: 4,
|
||||
position: 0,
|
||||
grantState: "none" as const,
|
||||
revision: 1,
|
||||
instanceId: "app-instance",
|
||||
},
|
||||
],
|
||||
};
|
||||
let lease = 0;
|
||||
const request = vi.fn(async (method: string) => {
|
||||
if (method === "board.get") {
|
||||
return snapshot;
|
||||
}
|
||||
if (method === "board.widget.appView") {
|
||||
lease += 1;
|
||||
return { viewId: `mcp-app-${lease}`, expiresAtMs: lease === 1 ? 10_000 : 20_000 };
|
||||
}
|
||||
throw new Error(`unexpected method: ${method}`);
|
||||
});
|
||||
const provider = new GatewayBoardProvider("agent:main:app", {
|
||||
request: request as never,
|
||||
addEventListener: () => () => {},
|
||||
});
|
||||
await vi.waitFor(() => expect(provider.snapshot$.value.revision).toBe(1));
|
||||
|
||||
await expect(provider.widgetAppView("server-app", 1)).resolves.toMatchObject({
|
||||
status: "ready",
|
||||
viewId: "mcp-app-1",
|
||||
});
|
||||
expect(request).toHaveBeenCalledWith("board.widget.appView", {
|
||||
sessionKey: "agent:main:app",
|
||||
name: "server-app",
|
||||
revision: 1,
|
||||
instanceId: "app-instance",
|
||||
});
|
||||
await expect(provider.widgetAppView("server-app", 1)).resolves.toMatchObject({
|
||||
viewId: "mcp-app-1",
|
||||
});
|
||||
now = 6_000;
|
||||
await expect(provider.widgetAppView("server-app", 1)).resolves.toMatchObject({
|
||||
status: "ready",
|
||||
viewId: "mcp-app-2",
|
||||
});
|
||||
expect(request.mock.calls.filter(([method]) => method === "board.widget.appView")).toHaveLength(
|
||||
2,
|
||||
);
|
||||
});
|
||||
|
||||
it("does not reuse a cached MCP App lease for a same-name replacement", async () => {
|
||||
const cache = new BoardMcpAppViewCache();
|
||||
const widget = {
|
||||
name: "server-app",
|
||||
tabId: "main",
|
||||
contentKind: "mcp-app" as const,
|
||||
sizeW: 6,
|
||||
sizeH: 4,
|
||||
position: 0,
|
||||
grantState: "none" as const,
|
||||
revision: 1,
|
||||
instanceId: "instance-a",
|
||||
};
|
||||
const request = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ viewId: "view-a", expiresAtMs: Date.now() + 60_000 })
|
||||
.mockResolvedValueOnce({ viewId: "view-b", expiresAtMs: Date.now() + 60_000 });
|
||||
|
||||
await expect(cache.resolve(widget, request, false)).resolves.toMatchObject({
|
||||
viewId: "view-a",
|
||||
});
|
||||
await expect(
|
||||
cache.resolve({ ...widget, instanceId: "instance-b" }, request, false),
|
||||
).resolves.toMatchObject({ viewId: "view-b" });
|
||||
expect(request).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("contains MCP App re-mint failures as stale widget state and retries on demand", async () => {
|
||||
mockLocation.search = "";
|
||||
const snapshot = {
|
||||
sessionKey: "agent:main:stale-app",
|
||||
revision: 1,
|
||||
tabs: [{ tabId: "main", title: "Main", position: 0, chatDock: "right" as const }],
|
||||
widgets: [
|
||||
{
|
||||
name: "server-app",
|
||||
tabId: "main",
|
||||
contentKind: "mcp-app" as const,
|
||||
sizeW: 6,
|
||||
sizeH: 4,
|
||||
position: 0,
|
||||
grantState: "none" as const,
|
||||
revision: 1,
|
||||
instanceId: "stale-app-instance",
|
||||
},
|
||||
],
|
||||
};
|
||||
let attempts = 0;
|
||||
const request = vi.fn(async (method: string) => {
|
||||
if (method === "board.get") {
|
||||
return snapshot;
|
||||
}
|
||||
attempts += 1;
|
||||
if (attempts === 1) {
|
||||
throw new Error("origin transcript pruned");
|
||||
}
|
||||
return { viewId: "mcp-app-restored", expiresAtMs: Date.now() + 60_000 };
|
||||
});
|
||||
const provider = new GatewayBoardProvider("agent:main:stale-app", {
|
||||
request: request as never,
|
||||
addEventListener: () => () => {},
|
||||
});
|
||||
await vi.waitFor(() => expect(provider.snapshot$.value.revision).toBe(1));
|
||||
|
||||
await expect(provider.widgetAppView("server-app", 1)).resolves.toEqual({
|
||||
status: "stale",
|
||||
error: "origin transcript pruned",
|
||||
});
|
||||
await expect(provider.refreshWidgetAppView("server-app", 1)).resolves.toMatchObject({
|
||||
status: "ready",
|
||||
viewId: "mcp-app-restored",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,8 +2,10 @@ import type {
|
||||
BoardChangedEvent,
|
||||
BoardCommand,
|
||||
BoardCommandEvent,
|
||||
BoardMcpAppPinDescriptor,
|
||||
BoardOp,
|
||||
BoardSnapshot,
|
||||
BoardWidgetAppViewResult,
|
||||
} from "@openclaw/gateway-protocol";
|
||||
import type { GatewayBrowserClient } from "../../api/gateway.ts";
|
||||
import { t } from "../../i18n/index.ts";
|
||||
@@ -11,14 +13,15 @@ import {
|
||||
buildAgentMainSessionKey,
|
||||
normalizeSessionKeyForUiComparison,
|
||||
} from "../sessions/session-key.ts";
|
||||
import { BoardMcpAppViewCache } from "./mcp-app-view-cache.ts";
|
||||
import { applyMockBoardOp, normalizeMockBoardSnapshot } from "./mock-ops.ts";
|
||||
import type { BoardWidgetAppViewState } from "./view-types.ts";
|
||||
export type { BoardCommandEvent };
|
||||
export type { BoardViewCallbacks } from "./view-types.ts";
|
||||
export type { BoardViewCallbacks, BoardWidgetAppViewState } from "./view-types.ts";
|
||||
|
||||
type BoardGatewayClient = Pick<GatewayBrowserClient, "request" | "addEventListener">;
|
||||
|
||||
type BoardPinWidgetInput = {
|
||||
docId: string;
|
||||
type BoardPinPlacement = {
|
||||
title?: string;
|
||||
name?: string;
|
||||
tabId?: string;
|
||||
@@ -26,6 +29,9 @@ type BoardPinWidgetInput = {
|
||||
after?: string;
|
||||
};
|
||||
|
||||
type BoardPinWidgetInput = BoardPinPlacement & { docId: string };
|
||||
type BoardPinMcpAppInput = BoardPinPlacement & { descriptor: BoardMcpAppPinDescriptor };
|
||||
|
||||
type BoardSnapshotSignal = {
|
||||
readonly value: BoardSnapshot;
|
||||
subscribe(listener: () => void): () => void;
|
||||
@@ -36,13 +42,17 @@ type BoardEventStream = {
|
||||
};
|
||||
|
||||
export type BoardProvider = {
|
||||
readonly sessionKey: string;
|
||||
readonly canPinWidgets: boolean;
|
||||
readonly snapshot$: BoardSnapshotSignal;
|
||||
applyOps(ops: BoardOp[]): Promise<void>;
|
||||
grant(name: string, decision: "granted" | "rejected"): Promise<void>;
|
||||
pinWidget(input: BoardPinWidgetInput): Promise<void>;
|
||||
pinMcpApp(input: BoardPinMcpAppInput): Promise<void>;
|
||||
widgetFrameUrl(name: string, revision: number): string;
|
||||
refreshWidgetFrame(name: string): Promise<void>;
|
||||
widgetAppView(name: string, revision: number): Promise<BoardWidgetAppViewState>;
|
||||
refreshWidgetAppView(name: string, revision: number): Promise<BoardWidgetAppViewState>;
|
||||
readonly events: BoardEventStream;
|
||||
};
|
||||
|
||||
@@ -64,6 +74,15 @@ export function canvasWidgetNameForDocument(docId: string): string {
|
||||
return `${prefix}-${hashDocumentId(docId)}`;
|
||||
}
|
||||
|
||||
export function mcpAppWidgetNameForToolCall(toolCallId: string): string {
|
||||
const name = `mcp-app-${toolCallId.toLowerCase().replace(/[^a-z0-9._-]/gu, "-")}`;
|
||||
if (name === `mcp-app-${toolCallId}` && name.length <= 64) {
|
||||
return name;
|
||||
}
|
||||
const prefix = name.slice(0, 47).replace(/[._-]+$/gu, "") || "mcp-app-widget";
|
||||
return `${prefix}-${hashDocumentId(toolCallId)}`;
|
||||
}
|
||||
|
||||
class ValueSignal<T> {
|
||||
private readonly listeners = new Set<() => void>();
|
||||
|
||||
@@ -166,7 +185,7 @@ class NullProvider implements BoardProvider {
|
||||
readonly snapshot$: BoardSnapshotSignal;
|
||||
readonly events: BoardEventStream = new EventStream<BoardCommandEvent>();
|
||||
|
||||
constructor(sessionKey = "") {
|
||||
constructor(readonly sessionKey = "") {
|
||||
this.snapshot$ = new ValueSignal(emptySnapshot(sessionKey));
|
||||
}
|
||||
|
||||
@@ -178,11 +197,23 @@ class NullProvider implements BoardProvider {
|
||||
throw new Error("Session dashboard unavailable");
|
||||
}
|
||||
|
||||
async pinMcpApp(_input: BoardPinMcpAppInput): Promise<void> {
|
||||
throw new Error("Session dashboard unavailable");
|
||||
}
|
||||
|
||||
widgetFrameUrl(_name: string, _revision: number): string {
|
||||
return "";
|
||||
}
|
||||
|
||||
async refreshWidgetFrame(_name: string): Promise<void> {}
|
||||
|
||||
async widgetAppView(_name: string, _revision: number): Promise<BoardWidgetAppViewState> {
|
||||
return { status: "stale", error: "Session dashboard unavailable" };
|
||||
}
|
||||
|
||||
async refreshWidgetAppView(_name: string, _revision: number): Promise<BoardWidgetAppViewState> {
|
||||
return { status: "stale", error: "Session dashboard unavailable" };
|
||||
}
|
||||
}
|
||||
|
||||
class MockBoardProvider implements BoardProvider {
|
||||
@@ -255,6 +286,44 @@ class MockBoardProvider implements BoardProvider {
|
||||
);
|
||||
}
|
||||
|
||||
async pinMcpApp(input: BoardPinMcpAppInput): Promise<void> {
|
||||
const snapshot = this.snapshotSignal.value;
|
||||
const name = input.name ?? mcpAppWidgetNameForToolCall(input.descriptor.toolCallId);
|
||||
const title = boardWidgetTitle(input.title);
|
||||
const tabId = input.tabId ?? snapshot.tabs[0]?.tabId ?? "main";
|
||||
const tabs = snapshot.tabs.length
|
||||
? snapshot.tabs
|
||||
: [
|
||||
{
|
||||
tabId: "main",
|
||||
title: t("chat.board.defaultTab"),
|
||||
position: 0,
|
||||
chatDock: "right" as const,
|
||||
},
|
||||
];
|
||||
const existing = snapshot.widgets.find((widget) => widget.name === name);
|
||||
const widgets = snapshot.widgets.filter((widget) => widget.name !== name);
|
||||
widgets.push({
|
||||
name,
|
||||
tabId,
|
||||
...(title ? { title } : {}),
|
||||
contentKind: "mcp-app",
|
||||
sizeW: existing?.sizeW ?? 6,
|
||||
sizeH: existing?.sizeH ?? 4,
|
||||
position: existing?.position ?? widgets.filter((widget) => widget.tabId === tabId).length,
|
||||
grantState: "none",
|
||||
revision: (existing?.revision ?? 0) + 1,
|
||||
});
|
||||
this.snapshotSignal.set(
|
||||
normalizeMockBoardSnapshot({
|
||||
...snapshot,
|
||||
revision: snapshot.revision + 1,
|
||||
tabs,
|
||||
widgets,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
widgetFrameUrl(name: string, revision: number): string {
|
||||
return (
|
||||
this.snapshotSignal.value.widgets.find(
|
||||
@@ -265,6 +334,14 @@ class MockBoardProvider implements BoardProvider {
|
||||
|
||||
async refreshWidgetFrame(_name: string): Promise<void> {}
|
||||
|
||||
async widgetAppView(_name: string, _revision: number): Promise<BoardWidgetAppViewState> {
|
||||
return { status: "stale", error: "MCP App mock view unavailable" };
|
||||
}
|
||||
|
||||
async refreshWidgetAppView(name: string, revision: number): Promise<BoardWidgetAppViewState> {
|
||||
return await this.widgetAppView(name, revision);
|
||||
}
|
||||
|
||||
emitCommand(command: BoardCommand): void {
|
||||
this.eventStream.emit({ sessionKey: this.sessionKey, command });
|
||||
}
|
||||
@@ -285,6 +362,7 @@ export class GatewayBoardProvider implements BoardProvider {
|
||||
private stateGeneration = 0;
|
||||
private connected = false;
|
||||
private wakeRetryDelay: (() => void) | undefined;
|
||||
private readonly appViews = new BoardMcpAppViewCache();
|
||||
|
||||
constructor(
|
||||
readonly sessionKey: string,
|
||||
@@ -319,6 +397,7 @@ export class GatewayBoardProvider implements BoardProvider {
|
||||
this.clientGeneration += 1;
|
||||
this.stateGeneration += 1;
|
||||
this.changedWidgets.clear();
|
||||
this.appViews.clear();
|
||||
this.snapshotSignal.set(emptySnapshot(this.sessionKey));
|
||||
this.subscribe(client);
|
||||
if (connected) {
|
||||
@@ -348,6 +427,7 @@ export class GatewayBoardProvider implements BoardProvider {
|
||||
name,
|
||||
decision,
|
||||
revision: widget.revision,
|
||||
...(widget.instanceId ? { instanceId: widget.instanceId } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -375,6 +455,30 @@ export class GatewayBoardProvider implements BoardProvider {
|
||||
);
|
||||
}
|
||||
|
||||
async pinMcpApp(input: BoardPinMcpAppInput): Promise<void> {
|
||||
const name = input.name ?? mcpAppWidgetNameForToolCall(input.descriptor.toolCallId);
|
||||
const title = boardWidgetTitle(input.title);
|
||||
await this.mutate(
|
||||
"board.widget.put",
|
||||
{
|
||||
sessionKey: this.sessionKey,
|
||||
name,
|
||||
...(title ? { title } : {}),
|
||||
content: { kind: "mcp-app", descriptor: input.descriptor },
|
||||
...(input.tabId || input.size || input.after
|
||||
? {
|
||||
placement: {
|
||||
...(input.tabId ? { tabId: input.tabId } : {}),
|
||||
...(input.size ? { size: input.size } : {}),
|
||||
...(input.after ? { after: input.after } : {}),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
name,
|
||||
);
|
||||
}
|
||||
|
||||
widgetFrameUrl(name: string, revision: number): string {
|
||||
return (
|
||||
this.snapshotSignal.value.widgets.find(
|
||||
@@ -387,6 +491,42 @@ export class GatewayBoardProvider implements BoardProvider {
|
||||
return this.requestRefresh(name);
|
||||
}
|
||||
|
||||
async widgetAppView(name: string, revision: number): Promise<BoardWidgetAppViewState> {
|
||||
return await this.resolveWidgetAppView(name, revision, false);
|
||||
}
|
||||
|
||||
async refreshWidgetAppView(name: string, revision: number): Promise<BoardWidgetAppViewState> {
|
||||
return await this.resolveWidgetAppView(name, revision, true);
|
||||
}
|
||||
|
||||
private async resolveWidgetAppView(
|
||||
name: string,
|
||||
revision: number,
|
||||
force: boolean,
|
||||
): Promise<BoardWidgetAppViewState> {
|
||||
const widget = this.snapshotSignal.value.widgets.find(
|
||||
(candidate) =>
|
||||
candidate.name === name &&
|
||||
candidate.revision === revision &&
|
||||
candidate.contentKind === "mcp-app",
|
||||
);
|
||||
if (!widget) {
|
||||
return { status: "stale", error: "Dashboard MCP App widget unavailable" };
|
||||
}
|
||||
const client = this.client;
|
||||
return await this.appViews.resolve(
|
||||
widget,
|
||||
async () =>
|
||||
await client.request<BoardWidgetAppViewResult>("board.widget.appView", {
|
||||
sessionKey: this.sessionKey,
|
||||
name,
|
||||
revision,
|
||||
...(widget.instanceId ? { instanceId: widget.instanceId } : {}),
|
||||
}),
|
||||
force,
|
||||
);
|
||||
}
|
||||
|
||||
private subscribe(client: BoardGatewayClient): void {
|
||||
this.unsubscribe = client.addEventListener((event) => {
|
||||
if (event.event === "board.changed") {
|
||||
@@ -530,12 +670,14 @@ export class GatewayBoardProvider implements BoardProvider {
|
||||
previous &&
|
||||
!changedWidgets.has(widget.name) &&
|
||||
previous.revision === widget.revision &&
|
||||
previous.instanceId === widget.instanceId &&
|
||||
previous.frameUrl
|
||||
) {
|
||||
return { ...widget, frameUrl: previous.frameUrl };
|
||||
}
|
||||
return widget;
|
||||
});
|
||||
this.appViews.prune(widgets);
|
||||
this.snapshotSignal.set({ ...snapshot, widgets });
|
||||
}
|
||||
}
|
||||
@@ -561,11 +703,15 @@ function isMockBoardSession(sessionKey: string): boolean {
|
||||
return /^agent:[^:]+:[^:]+$/u.test(sessionKey);
|
||||
}
|
||||
|
||||
function boardProviderCacheKey(sessionKey: string): string {
|
||||
export function normalizeBoardSessionKeyForComparison(sessionKey: string): string {
|
||||
const normalized = normalizeSessionKeyForUiComparison(sessionKey);
|
||||
return normalized === "main" ? buildAgentMainSessionKey({ agentId: "main" }) : normalized;
|
||||
}
|
||||
|
||||
function boardProviderCacheKey(sessionKey: string): string {
|
||||
return normalizeBoardSessionKeyForComparison(sessionKey);
|
||||
}
|
||||
|
||||
export function boardProviderForSession(
|
||||
sessionKey: string,
|
||||
client?: BoardGatewayClient | null,
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import type { BoardOp, BoardSnapshot, BoardWidget } from "@openclaw/gateway-protocol";
|
||||
|
||||
export type BoardGrantDecision = "granted" | "rejected";
|
||||
export type BoardWidgetAppViewState =
|
||||
| { status: "ready"; viewId: string; expiresAtMs: number }
|
||||
| { status: "stale"; error: string };
|
||||
|
||||
/** Native Control UI card, derived from session state rather than the board store. */
|
||||
type BoardStoredWidget = BoardWidget & {
|
||||
@@ -22,6 +25,8 @@ export type BoardViewCallbacks = {
|
||||
grant: (name: string, decision: BoardGrantDecision) => Promise<void>;
|
||||
selectTab: (tabId: string) => void;
|
||||
frameLoadFailed?: (name: string) => Promise<void>;
|
||||
widgetAppView?: (name: string, revision: number) => Promise<BoardWidgetAppViewState>;
|
||||
refreshWidgetAppView?: (name: string, revision: number) => Promise<BoardWidgetAppViewState>;
|
||||
};
|
||||
|
||||
export type BoardWidgetFrameUrl = (name: string, revision: number) => string;
|
||||
|
||||
@@ -177,6 +177,7 @@ export type ToolCard = {
|
||||
toolName?: string;
|
||||
uiResourceUri?: string;
|
||||
toolCallId?: string;
|
||||
originSessionKey?: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
@@ -146,6 +146,9 @@ function coerceCanvasPreview(
|
||||
? { uiResourceUri: mcpApp.uiResourceUri }
|
||||
: {}),
|
||||
...(typeof mcpApp.toolCallId === "string" ? { toolCallId: mcpApp.toolCallId } : {}),
|
||||
...(typeof mcpApp.originSessionKey === "string"
|
||||
? { originSessionKey: mcpApp.originSessionKey }
|
||||
: {}),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
|
||||
@@ -3363,6 +3363,9 @@ class ChatPane extends OpenClawLightDomElement {
|
||||
this.persistBoardSessionView({ face: "dashboard", activeTabId: tabId });
|
||||
},
|
||||
frameLoadFailed: (name) => board.provider.refreshWidgetFrame(name),
|
||||
widgetAppView: (name, revision) => board.provider.widgetAppView(name, revision),
|
||||
refreshWidgetAppView: (name, revision) =>
|
||||
board.provider.refreshWidgetAppView(name, revision),
|
||||
} satisfies BoardViewCallbacks,
|
||||
widgetFrameUrl: (name, revision) => board.provider.widgetFrameUrl(name, revision),
|
||||
onDockChange: (dock) => this.handleBoardDockChange(dock),
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { BoardMcpAppPinDescriptor } from "@openclaw/gateway-protocol";
|
||||
import { normalizeBoardSessionKeyForComparison } from "../../../lib/board/provider.ts";
|
||||
import type { ToolPreview } from "../../../lib/chat/tool-cards.ts";
|
||||
|
||||
export function buildMcpAppPinDescriptor(
|
||||
preview: ToolPreview,
|
||||
boardSessionKey: string,
|
||||
): BoardMcpAppPinDescriptor | undefined {
|
||||
const descriptor = preview.mcpApp;
|
||||
const viewId = descriptor?.viewId?.trim();
|
||||
const serverName = descriptor?.serverName?.trim();
|
||||
const toolName = descriptor?.toolName?.trim();
|
||||
const uiResourceUri = descriptor?.uiResourceUri?.trim();
|
||||
const toolCallId = descriptor?.toolCallId?.trim();
|
||||
const originSessionKey = descriptor?.originSessionKey?.trim();
|
||||
if (
|
||||
!viewId ||
|
||||
!serverName ||
|
||||
!toolName ||
|
||||
!uiResourceUri ||
|
||||
!toolCallId ||
|
||||
!originSessionKey ||
|
||||
normalizeBoardSessionKeyForComparison(originSessionKey) !==
|
||||
normalizeBoardSessionKeyForComparison(boardSessionKey)
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
viewId,
|
||||
serverName,
|
||||
toolName,
|
||||
uiResourceUri,
|
||||
toolCallId,
|
||||
originSessionKey,
|
||||
};
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
import { render } from "lit";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { BoardProvider } from "../../../lib/board/provider.ts";
|
||||
import { buildMcpAppPinDescriptor } from "./widget-card-mcp-app.ts";
|
||||
import { renderToolPreview } from "./widget-card.ts";
|
||||
|
||||
describe("widget-card", () => {
|
||||
@@ -65,6 +66,7 @@ describe("widget-card", () => {
|
||||
subscribe: () => () => {},
|
||||
};
|
||||
const provider = {
|
||||
sessionKey: "agent:main:main",
|
||||
canPinWidgets: true,
|
||||
pinWidget,
|
||||
snapshot$: snapshotSignal,
|
||||
@@ -204,4 +206,76 @@ describe("widget-card", () => {
|
||||
);
|
||||
expect(app.querySelector("[data-pin-widget]")).toBeNull();
|
||||
});
|
||||
|
||||
it("pins complete MCP App descriptors only to their originating board", async () => {
|
||||
const pinMcpApp = vi.fn(async () => undefined);
|
||||
const provider = {
|
||||
sessionKey: "agent:main:main",
|
||||
canPinWidgets: true,
|
||||
pinMcpApp,
|
||||
snapshot$: {
|
||||
value: {
|
||||
sessionKey: "agent:main:main",
|
||||
revision: 0,
|
||||
tabs: [],
|
||||
widgets: [],
|
||||
},
|
||||
subscribe: () => () => {},
|
||||
},
|
||||
} as unknown as BoardProvider;
|
||||
const preview = {
|
||||
kind: "canvas" as const,
|
||||
surface: "assistant_message" as const,
|
||||
render: "url" as const,
|
||||
title: "Weather",
|
||||
mcpApp: {
|
||||
viewId: "mcp-app-source",
|
||||
serverName: "weather",
|
||||
toolName: "show",
|
||||
uiResourceUri: "ui://weather/app",
|
||||
toolCallId: "call-1",
|
||||
originSessionKey: "agent:main:main",
|
||||
},
|
||||
};
|
||||
expect(buildMcpAppPinDescriptor(preview, "agent:main:main")).toEqual({
|
||||
viewId: "mcp-app-source",
|
||||
serverName: "weather",
|
||||
toolName: "show",
|
||||
uiResourceUri: "ui://weather/app",
|
||||
toolCallId: "call-1",
|
||||
originSessionKey: "agent:main:main",
|
||||
});
|
||||
expect(buildMcpAppPinDescriptor(preview, "agent:main:other")).toBeUndefined();
|
||||
expect(buildMcpAppPinDescriptor(preview, "main")).toEqual(
|
||||
expect.objectContaining({ originSessionKey: "agent:main:main" }),
|
||||
);
|
||||
|
||||
const origin = document.createElement("div");
|
||||
render(
|
||||
renderToolPreview(preview, "chat_message", {
|
||||
boardProvider: provider,
|
||||
sessionKey: "agent:main:main",
|
||||
}),
|
||||
origin,
|
||||
);
|
||||
origin.querySelector<HTMLButtonElement>("[data-pin-widget]")?.click();
|
||||
await vi.waitFor(() =>
|
||||
expect(pinMcpApp).toHaveBeenCalledWith({
|
||||
descriptor: expect.objectContaining({ viewId: "mcp-app-source", toolCallId: "call-1" }),
|
||||
name: "mcp-app-call-1",
|
||||
title: "Weather",
|
||||
}),
|
||||
);
|
||||
|
||||
const otherProvider = { ...provider, sessionKey: "agent:main:other" } as BoardProvider;
|
||||
const other = document.createElement("div");
|
||||
render(
|
||||
renderToolPreview(preview, "chat_message", {
|
||||
boardProvider: otherProvider,
|
||||
sessionKey: "agent:main:main",
|
||||
}),
|
||||
other,
|
||||
);
|
||||
expect(other.querySelector("[data-pin-widget]")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { BoardMcpAppPinDescriptor } from "@openclaw/gateway-protocol";
|
||||
import { html, nothing } from "lit";
|
||||
import { keyed } from "lit/directives/keyed.js";
|
||||
import { ensureCustomElementDefined } from "../../../app/lazy-custom-element.ts";
|
||||
@@ -9,7 +10,11 @@ import {
|
||||
} from "../../../components/mcp-app-security.ts";
|
||||
import "../../../components/web-awesome.ts";
|
||||
import { t } from "../../../i18n/index.ts";
|
||||
import { canvasWidgetNameForDocument, type BoardProvider } from "../../../lib/board/provider.ts";
|
||||
import {
|
||||
canvasWidgetNameForDocument,
|
||||
mcpAppWidgetNameForToolCall,
|
||||
type BoardProvider,
|
||||
} from "../../../lib/board/provider.ts";
|
||||
import type { ToolPreview } from "../../../lib/chat/tool-cards.ts";
|
||||
import {
|
||||
isInternalCanvasEntryUrl,
|
||||
@@ -19,6 +24,7 @@ import {
|
||||
} from "../../../lib/chat/tool-display.ts";
|
||||
import { showToast } from "../../../lib/toast.ts";
|
||||
import type { SidebarContent } from "./chat-sidebar.ts";
|
||||
import { buildMcpAppPinDescriptor } from "./widget-card-mcp-app.ts";
|
||||
import { exportWidget } from "./widget-export.ts";
|
||||
import { installWidgetThemeObserver, postWidgetTheme } from "./widget-theme.ts";
|
||||
|
||||
@@ -62,6 +68,34 @@ async function pinCanvasWidget(
|
||||
}
|
||||
}
|
||||
|
||||
async function pinMcpAppWidget(
|
||||
event: Event,
|
||||
preview: ToolPreview,
|
||||
provider: BoardProvider,
|
||||
name: string,
|
||||
descriptor: BoardMcpAppPinDescriptor,
|
||||
): Promise<void> {
|
||||
const button = event.currentTarget;
|
||||
if (!(button instanceof HTMLButtonElement)) {
|
||||
return;
|
||||
}
|
||||
button.disabled = true;
|
||||
button.textContent = t("chat.toolCards.pinToDashboardPending");
|
||||
try {
|
||||
await provider.pinMcpApp({
|
||||
descriptor,
|
||||
name,
|
||||
...(preview.title?.trim() ? { title: preview.title.trim() } : {}),
|
||||
});
|
||||
button.textContent = t("chat.toolCards.pinnedToDashboard");
|
||||
button.dataset.pinned = "true";
|
||||
} catch (error) {
|
||||
button.disabled = false;
|
||||
button.textContent = t("chat.toolCards.pinToDashboard");
|
||||
button.title = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
}
|
||||
|
||||
function canvasWidgetName(preview: ToolPreview): string | undefined {
|
||||
if (preview.boardWidgetName) {
|
||||
return preview.boardWidgetName;
|
||||
@@ -427,17 +461,25 @@ function renderWidgetCard(
|
||||
}
|
||||
const label = preview.title?.trim() || t("chat.toolCards.canvas");
|
||||
const contentKind = preview.mcpApp ? "mcp-app" : "canvas-html";
|
||||
const pinName = canvasWidgetName(preview);
|
||||
const mcpDescriptor = options?.boardProvider
|
||||
? buildMcpAppPinDescriptor(preview, options.boardProvider.sessionKey)
|
||||
: undefined;
|
||||
const pinName = preview.mcpApp
|
||||
? mcpDescriptor
|
||||
? mcpAppWidgetNameForToolCall(mcpDescriptor.toolCallId)
|
||||
: undefined
|
||||
: canvasWidgetName(preview);
|
||||
const pinned = Boolean(
|
||||
pinName &&
|
||||
options?.boardProvider?.snapshot$.value.widgets.some((widget) => widget.name === pinName),
|
||||
);
|
||||
const pinAction =
|
||||
contentKind === "canvas-html" &&
|
||||
preview.sandbox === "scripts" &&
|
||||
options?.boardProvider?.canPinWidgets &&
|
||||
isManagedCanvasDocumentPreview(preview) &&
|
||||
pinName
|
||||
pinName &&
|
||||
((contentKind === "canvas-html" &&
|
||||
preview.sandbox === "scripts" &&
|
||||
isManagedCanvasDocumentPreview(preview)) ||
|
||||
(contentKind === "mcp-app" && mcpDescriptor))
|
||||
? html`<button
|
||||
class="chat-tool-card__widget-action"
|
||||
type="button"
|
||||
@@ -449,7 +491,9 @@ function renderWidgetCard(
|
||||
pinned ? "chat.toolCards.pinnedToDashboard" : "chat.toolCards.pinToDashboard",
|
||||
)}
|
||||
@click=${(event: Event) =>
|
||||
void pinCanvasWidget(event, preview, options.boardProvider!, pinName)}
|
||||
contentKind === "mcp-app" && mcpDescriptor
|
||||
? void pinMcpAppWidget(event, preview, options.boardProvider!, pinName, mcpDescriptor)
|
||||
: void pinCanvasWidget(event, preview, options.boardProvider!, pinName)}
|
||||
>
|
||||
${t(pinned ? "chat.toolCards.pinnedToDashboard" : "chat.toolCards.pinToDashboard")}
|
||||
</button>`
|
||||
|
||||
@@ -383,6 +383,47 @@ openclaw-board-widget-cell {
|
||||
}
|
||||
}
|
||||
|
||||
.board-widget__mcp-app {
|
||||
display: grid;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.board-widget__mcp-app > .board-widget__grant {
|
||||
border-bottom: 1px solid var(--board-line);
|
||||
height: auto;
|
||||
min-height: 0;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.board-widget__mcp-app-view {
|
||||
display: block;
|
||||
min-height: 160px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.board-widget__app-loading,
|
||||
.board-widget__stale {
|
||||
align-content: center;
|
||||
color: var(--muted, #8a919e);
|
||||
display: grid;
|
||||
font-size: 11px;
|
||||
gap: 8px;
|
||||
justify-items: center;
|
||||
min-height: 160px;
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.board-widget__stale strong {
|
||||
color: var(--text, #d7dae0);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.board-widget__stale > span {
|
||||
line-height: 1.45;
|
||||
max-width: 34ch;
|
||||
}
|
||||
|
||||
.board-widget__resize-handle {
|
||||
bottom: 1px;
|
||||
cursor: nwse-resize;
|
||||
|
||||
Reference in New Issue
Block a user