feat(mcp): open App views from channel replies (#111211)

* feat(mcp): add portable channel app actions

* test(gateway): keep origin reset private

* fix(mcp): require a resolved reply channel
This commit is contained in:
Jason (Json)
2026-07-19 01:44:15 -06:00
committed by GitHub
parent 53d600ab83
commit 9c7800467c
26 changed files with 690 additions and 23 deletions
+4
View File
@@ -882,6 +882,10 @@ Behavior and security boundaries:
- App-only tools (`_meta.ui.visibility: ["app"]`) stay out of model tool lists. Apps can call only app-visible tools on their owning server that also pass the effective OpenClaw tool policy for the run that created the view.
- Origin-bound App permissions such as camera, microphone, and geolocation are not granted while inner App documents use opaque origins for cross-App isolation.
- App HTML, complete tool arguments, and raw results live in a bounded ten-minute in-memory view lease and are not written to disk or copied into transcript preview metadata. The transcript stores only a bounded server/tool/resource descriptor tied to the original tool-call ID. After a Gateway restart, the Control UI can verify that descriptor against the authenticated session transcript and refetch the `ui://` resource; reconstructed views are read-only until a fresh run establishes current tool permissions.
- In channel conversations, the latest successful App view in a turn adds one **Open App**-style action to the final assistant reply. Telegram DMs use a native Mini App button; Slack and Discord render the same portable action as a link. Other channels keep the original reply text and append an understandable HTTPS link.
- Channel launch links are available only when Gateway Tailscale exposure has prepared a published HTTPS origin. `gateway.tailscale.mode: "serve"` is reachable only from the tailnet; `"funnel"` is reachable from the public internet. An externally managed Funnel preserved by `gateway.tailscale.preserveFunnel` is also treated as internet-reachable. See [Tailscale](/gateway/tailscale).
- Launch tickets are opaque, minted only while materializing the final channel reply, and expire after at most two minutes or when the underlying view lease expires, whichever comes first. The URL does not contain Gateway bearer credentials, session keys, view metadata, App HTML, tool input, or tool results.
- If no published origin or ticket capacity is available, the view or ticket has expired, or the transport cannot render native controls, the original assistant text remains available. The Control UI keeps its existing inline App canvas and does not receive a duplicate launch action.
- `openclaw security audit` warns while the bridge is enabled. Disable it with `openclaw config set mcp.apps.enabled false --strict-json` when it is not needed.
## Current limits
@@ -109,7 +109,13 @@ describe("buildDiscordInteractiveComponents", () => {
buttons: [
{
label: "Review",
action: { type, url: "https://example.com/review" } as MessagePresentationAction,
action: {
type,
url:
type === "web-app"
? "https://node.tailnet.ts.net/__openclaw__/mcp-app#opaque-ticket"
: "https://example.com/review",
} as MessagePresentationAction,
},
],
},
@@ -123,7 +129,10 @@ describe("buildDiscordInteractiveComponents", () => {
{
label: "Review",
style: "link",
url: "https://example.com/review",
url:
type === "web-app"
? "https://node.tailnet.ts.net/__openclaw__/mcp-app#opaque-ticket"
: "https://example.com/review",
},
],
},
@@ -627,7 +627,10 @@ describe("slackOutbound sendPayload", () => {
buttons: [
{
label: "Launch",
action: { type: "web-app", url: "https://example.com/app" },
action: {
type: "web-app",
url: "https://node.tailnet.ts.net/__openclaw__/mcp-app#opaque-ticket",
},
},
{ label: "View", action: { type: "url", url: "https://example.com/view" } },
],
@@ -652,7 +655,7 @@ describe("slackOutbound sendPayload", () => {
expect.objectContaining({
type: "button",
action_id: "openclaw:reply_link:1:1",
url: "https://example.com/app",
url: "https://node.tailnet.ts.net/__openclaw__/mcp-app#opaque-ticket",
}),
expect.objectContaining({
type: "button",
@@ -481,7 +481,15 @@ describe("telegramOutbound", () => {
blocks: [
{
type: "buttons" as const,
buttons: [{ label: "Launch", webApp: { url: "https://example.com/app" } }],
buttons: [
{
label: "Launch",
action: {
type: "web-app" as const,
url: "https://node.tailnet.ts.net/__openclaw__/mcp-app#opaque-ticket",
},
},
],
},
],
};
@@ -504,7 +512,14 @@ describe("telegramOutbound", () => {
const options = callOptionsAt(sendMessageTelegramMock, 0, "12345", "Open app:");
expect(options.buttons).toEqual([
[{ text: "Launch", web_app: { url: "https://example.com/app" } }],
[
{
text: "Launch",
web_app: {
url: "https://node.tailnet.ts.net/__openclaw__/mcp-app#opaque-ticket",
},
},
],
]);
});
@@ -15,6 +15,7 @@ import { resolveSessionAgentIds } from "../agent-scope.js";
import type { ToolOutcomeObservation } from "../agent-tools.before-tool-call.js";
import type { FailoverReason } from "../embedded-agent-helpers.js";
import { isStrictAgenticExecutionContractActive } from "../execution-contract.js";
import type { McpAppChannelView } from "../mcp-ui-resource.js";
import { runAgentCleanupStep } from "../run-cleanup-timeout.js";
import { resolveToolLoopDetectionConfig } from "../tool-loop-detection-config.js";
import { normalizeUsage } from "../usage.js";
@@ -270,6 +271,7 @@ export async function runPreparedEmbeddedLoop(
});
let authRetryPending = false;
let accumulatedReplayState = createEmbeddedRunReplayState();
let latestMcpAppChannelView: McpAppChannelView | undefined;
// Hoisted so the retry-limit error path can use the most recent API total.
let lastTurnTotal: number | undefined;
while (true) {
@@ -401,6 +403,9 @@ export async function runPreparedEmbeddedLoop(
resolveReplayInvalidForAttempt,
canRestartForLiveSwitch,
} = normalizedAttempt;
// Continuation retries remain one user turn, so keep the newest launch target.
latestMcpAppChannelView = attempt.latestMcpAppChannelView ?? latestMcpAppChannelView;
attempt.latestMcpAppChannelView = latestMcpAppChannelView;
const recovery = await recoverEmbeddedRunAttempt({
runInput: input,
preparedRuntime,
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import { completeEmbeddedAttemptResult } from "./attempt-result.js";
function completeResult(params?: {
latestMcpAppChannelView?: { viewId: string };
clientToolCallSlots?: Array<{
toolCallId: string;
name: string;
@@ -39,6 +40,7 @@ function completeResult(params?: {
getLastAssistantTextMessageIndex: () => undefined,
getLastCompactionTokensAfter: () => undefined,
getLastToolError: () => undefined,
getLatestMcpAppChannelView: () => params?.latestMcpAppChannelView,
getMessagingToolSentMediaUrls: () => [],
getMessagingToolSentTargets: () => [],
getMessagingToolSentTexts: () => [],
@@ -139,4 +141,12 @@ describe("attempt result projection", () => {
true,
);
});
it("projects the latest MCP App channel view without result data", () => {
expect(
completeResult({
latestMcpAppChannelView: { viewId: "view-latest" },
}).latestMcpAppChannelView,
).toEqual({ viewId: "view-latest" });
});
});
@@ -162,6 +162,7 @@ export function completeEmbeddedAttemptResult(
getLastAssistantTextMessageIndex,
getLastCompactionTokensAfter,
getLastToolError,
getLatestMcpAppChannelView,
getMessagingToolSentMediaUrls,
getMessagingToolSentTargets,
getMessagingToolSentTexts,
@@ -384,6 +385,7 @@ export function completeEmbeddedAttemptResult(
bootstrapPromptWarningSignaturesSeen: input.bootstrapPromptWarning.warningSignaturesSeen,
bootstrapPromptWarningSignature: input.bootstrapPromptWarning.signature,
assistantTexts,
latestMcpAppChannelView: getLatestMcpAppChannelView(),
lastAssistantTextMessageIndex: getLastAssistantTextMessageIndex(),
toolMetas: toolMetasNormalized,
acceptedSessionSpawns,
@@ -129,6 +129,7 @@ function createSubscriptionMock(): SubscriptionMock {
assistantTexts: [] as string[],
getCurrentAttemptAssistant: () => undefined,
getLastAssistantTextMessageIndex: () => undefined,
getLatestMcpAppChannelView: () => undefined,
toolMetas: [] as Array<{ toolName: string; meta?: string; asyncStarted?: boolean }>,
runToolLifecycle: async <T>(toolParams: { execute: () => Promise<T> }) =>
await toolParams.execute(),
@@ -0,0 +1,15 @@
import { describe, expect, it } from "vitest";
import { copyAttemptDeliveryState } from "./terminal-resolution.js";
describe("copyAttemptDeliveryState", () => {
it("keeps only the bounded latest MCP App view identity", () => {
expect(
copyAttemptDeliveryState({
latestMcpAppChannelView: { viewId: "view-latest" },
messagingToolSentTexts: [],
messagingToolSentMediaUrls: [],
messagingToolSentTargets: [],
} as never).latestMcpAppChannelView,
).toEqual({ viewId: "view-latest" });
});
});
@@ -550,6 +550,7 @@ function completeEmbeddedRun(
export function copyAttemptDeliveryState(attempt: EmbeddedRunAttemptResult) {
return {
latestMcpAppChannelView: attempt.latestMcpAppChannelView,
didSendViaMessagingTool: attempt.didSendViaMessagingTool,
didDeliverSourceReplyViaMessageTool: attempt.didDeliverSourceReplyViaMessageTool === true,
didSendDeterministicApprovalPrompt: attempt.didSendDeterministicApprovalPrompt,
@@ -21,6 +21,7 @@ import type {
MessagingToolSourceReplyPayload,
} from "../../embedded-agent-messaging.types.js";
import type { AgentHarnessRuntimeArtifactBinding } from "../../harness/runtime-artifact.types.js";
import type { McpAppChannelView } from "../../mcp-ui-resource.js";
import type { AgentRunTimeoutPhase } from "../../run-timeout-attribution.js";
import type { AgentRuntimePlan } from "../../runtime-plan/types.js";
import type { AgentMessage } from "../../runtime/index.js";
@@ -259,6 +260,7 @@ export type EmbeddedRunAttemptResult = {
messagesSnapshot: AgentMessage[];
beforeAgentFinalizeRevisionReason?: string;
assistantTexts: string[];
latestMcpAppChannelView?: McpAppChannelView;
lastAssistantTextMessageIndex?: number;
toolMetas: Array<{
toolName: string;
@@ -13,6 +13,7 @@ import type {
MessagingToolSend,
MessagingToolSourceReplyPayload,
} from "../embedded-agent-messaging.types.js";
import type { McpAppChannelView } from "../mcp-ui-resource.js";
import type { FallbackAttempt } from "../model-fallback.types.js";
import type { AgentRunTimeoutPhase } from "../run-timeout-attribution.js";
import type { ContextUsage } from "../usage.js";
@@ -204,6 +205,7 @@ export type EmbeddedAgentRunMeta = {
};
export type EmbeddedAgentRunResult = {
latestMcpAppChannelView?: McpAppChannelView;
payloads?: Array<{
text?: string;
mediaUrl?: string;
@@ -1419,6 +1419,66 @@ describe("handleToolExecutionEnd private result observer", () => {
});
});
describe("handleToolExecutionEnd MCP App channel view tracking", () => {
const result = (viewId: string, title: string) => ({
details: {
mcpAppPreview: {
view: { id: viewId, title },
mcpApp: { viewId },
},
},
});
it("retains only the latest successful bounded view identity", async () => {
const { ctx } = createTestContext();
await handleToolExecutionEnd(ctx, {
type: "tool_execution_end",
toolName: "mcp_first",
toolCallId: "mcp-first",
isError: false,
result: result("view-first", "First app"),
} as never);
await handleToolExecutionEnd(ctx, {
type: "tool_execution_end",
toolName: "mcp_failed",
toolCallId: "mcp-failed",
isError: true,
result: result("view-failed", "Failed app"),
} as never);
await handleToolExecutionEnd(ctx, {
type: "tool_execution_end",
toolName: "mcp_latest",
toolCallId: "mcp-latest",
isError: false,
result: result("view-latest", "Latest app"),
} as never);
expect(ctx.state.latestMcpAppChannelView).toEqual({ viewId: "view-latest" });
});
it("ignores mismatched or unbounded preview data", async () => {
const { ctx } = createTestContext();
const leaked = {
...result("view-safe", "Safe app"),
html: "private html",
sessionKey: "agent:secret",
bearerToken: "secret",
};
leaked.details.mcpAppPreview.view.id = "different-view";
await handleToolExecutionEnd(ctx, {
type: "tool_execution_end",
toolName: "mcp_invalid",
toolCallId: "mcp-invalid",
isError: false,
result: leaked,
} as never);
expect(ctx.state.latestMcpAppChannelView).toBeUndefined();
});
});
describe("handleToolExecutionEnd sessions_spawn terminal success tracking", () => {
it("records accepted sessions_spawn identifiers", async () => {
const { ctx } = createTestContext();
@@ -90,6 +90,7 @@ import {
import { inferToolMetaFromArgs } from "./embedded-agent-utils.js";
import { parseExecApprovalResultText } from "./exec-approval-result.js";
import { buildAgentHarnessQuestionPromptPayload } from "./harness/user-input-bridge.js";
import { readMcpAppChannelView } from "./mcp-ui-resource.js";
import type { AgentEvent } from "./runtime/index.js";
import {
createToolValidationErrorSummary,
@@ -1385,6 +1386,13 @@ export async function handleToolExecutionEnd(
isExecToolName(toolName) &&
readExecToolDetails(sanitizedResult)?.status === "approval-unavailable";
const isToolError = observerIsError && !approvalUnavailable;
if (!isToolError) {
const channelView = readMcpAppChannelView(result);
if (channelView) {
// A later successful app result supersedes the earlier launch target.
ctx.state.latestMcpAppChannelView = channelView;
}
}
try {
ctx.params.onAgentToolResult?.({
toolName,
@@ -23,6 +23,7 @@ import type {
BlockReplyChunking,
SubscribeEmbeddedAgentSessionParams,
} from "./embedded-agent-subscribe.types.js";
import type { McpAppChannelView } from "./mcp-ui-resource.js";
import type { AgentRunTimeoutPhase } from "./run-timeout-attribution.js";
import type { AgentMessage } from "./runtime/index.js";
import type { AgentSessionEvent } from "./sessions/index.js";
@@ -85,6 +86,7 @@ export type EmbeddedAgentSubscribeState = {
itemStartedCount: number;
itemCompletedCount: number;
lastToolError?: ToolErrorSummary;
latestMcpAppChannelView?: McpAppChannelView;
blockReplyBreak: "text_end" | "message_end";
reasoningMode: ReasoningLevel;
@@ -315,6 +317,7 @@ type ToolHandlerState = Pick<
| "itemStartedCount"
| "itemCompletedCount"
| "lastToolError"
| "latestMcpAppChannelView"
| "pendingMessagingTargets"
| "pendingMessagingTexts"
| "pendingMessagingMediaUrls"
+2
View File
@@ -1393,6 +1393,8 @@ export function subscribeEmbeddedAgentSession(params: SubscribeEmbeddedAgentSess
state.lastAssistantTextMessageIndex >= 0 ? state.lastAssistantTextMessageIndex : undefined,
toolMetas,
getAcceptedSessionSpawns: () => state.acceptedSessionSpawns.slice(),
getLatestMcpAppChannelView: () =>
state.latestMcpAppChannelView ? { ...state.latestMcpAppChannelView } : undefined,
runToolLifecycle: async <T>(toolParams: {
toolName: string;
toolCallId: string;
+18
View File
@@ -43,6 +43,24 @@ export type McpAppViewLease = {
releaseRuntimeLease?: () => void;
};
export type McpAppChannelView = {
viewId: string;
};
/** Retain only the bounded view identity needed for late channel materialization. */
export function readMcpAppChannelView(result: unknown): McpAppChannelView | undefined {
const details = asRecord(asRecord(result)?.details);
const preview = asRecord(details?.mcpAppPreview);
const view = asRecord(preview?.view);
const descriptor = asRecord(preview?.mcpApp);
const viewId = typeof descriptor?.viewId === "string" ? descriptor.viewId.trim() : "";
const projectedViewId = typeof view?.id === "string" ? view.id.trim() : "";
if (!viewId || projectedViewId !== viewId) {
return undefined;
}
return { viewId };
}
type McpAppViewStore = Map<string, McpAppViewLease>;
function getViewStore(): McpAppViewStore {
@@ -74,6 +74,7 @@ const state = vi.hoisted(() => ({
beforeAgentReplyRunMock: vi.fn(),
compactEmbeddedAgentSessionMock: vi.fn(),
getChannelPluginMock: vi.fn(),
materializeMcpAppChannelPresentationMock: vi.fn(),
queueEmbeddedAgentMessageMock: vi.fn(),
runEmbeddedAgentMock: vi.fn(),
}));
@@ -206,6 +207,11 @@ vi.mock("../../agents/embedded-agent-runner/runs.js", () => ({
},
}));
vi.mock("../../gateway/mcp-app-channel-action.js", () => ({
materializeMcpAppChannelPresentation: (params: unknown) =>
state.materializeMcpAppChannelPresentationMock(params),
}));
vi.mock("./queue.js", async (importOriginal) => ({
...(await importOriginal<typeof import("./queue.js")>()),
enqueueFollowupRun: vi.fn(),
@@ -238,6 +244,7 @@ beforeEach(() => {
state.beforeAgentReplyRunMock.mockReset();
state.queueEmbeddedAgentMessageMock.mockReturnValue(false);
state.getChannelPluginMock.mockReset();
state.materializeMcpAppChannelPresentationMock.mockReset();
vi.mocked(enqueueFollowupRun).mockReset().mockReturnValue(true);
vi.mocked(refreshQueuedFollowupSession).mockReset();
vi.mocked(scheduleFollowupDrain).mockReset();
@@ -730,6 +737,45 @@ describe("runReplyAgent active steering", () => {
});
});
describe("runReplyAgent MCP App channel action", () => {
it("materializes the latest view on the final channel payload", async () => {
const presentation = {
blocks: [
{
type: "buttons",
buttons: [
{
label: "Weather app",
action: {
type: "web-app",
url: "https://node.tailnet.ts.net/__openclaw__/mcp-app#opaque-ticket",
},
},
],
},
],
};
state.materializeMcpAppChannelPresentationMock.mockReturnValue(presentation);
state.runEmbeddedAgentMock.mockResolvedValue({
payloads: [{ text: "The forecast is sunny." }, { text: "NO_REPLY" }],
latestMcpAppChannelView: { viewId: "view-latest" },
meta: { agentMeta: { usage: { input: 1, output: 1 } } },
});
const { run } = createMinimalRun({
sessionCtx: { Provider: "telegram", OriginatingChannel: "telegram" },
runOverrides: { messageProvider: "telegram" },
});
await expect(run()).resolves.toEqual(
expect.objectContaining({ text: "The forecast is sunny.", presentation }),
);
expect(state.materializeMcpAppChannelPresentationMock).toHaveBeenCalledWith({
sessionKey: "main",
view: { viewId: "view-latest" },
});
});
});
describe("runReplyAgent heartbeat followup guard", () => {
it("drops heartbeat runs when reply-lane admission finds an active owner", async () => {
const runState: ReplyOperationRunState = {};
+8
View File
@@ -125,6 +125,7 @@ import { resolveEffectiveReplyRoute } from "./effective-reply-route.js";
import { createFollowupRunner } from "./followup-runner.js";
import { REPLY_RUN_STILL_SHUTTING_DOWN_TEXT } from "./get-reply-run-queue.js";
import type { InternalGetReplyOptions } from "./get-reply.types.js";
import { attachMcpAppChannelAction } from "./mcp-app-channel-action.js";
import { normalizeReplyPayload } from "./normalize-reply.js";
import { resolveOriginMessageProvider, resolveOriginMessageTo } from "./origin-routing.js";
import {
@@ -2497,6 +2498,13 @@ export async function runReplyAgent(params: {
}
}
replyPayloads = attachMcpAppChannelAction({
payloads: replyPayloads,
channel: replyToChannel,
sessionKey,
view: runResult.latestMcpAppChannelView,
});
const hasVisibleReplyPayload = replyPayloads.some(
(payload) =>
!isReplyPayloadStatusNotice(payload) &&
@@ -0,0 +1,116 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const materialize = vi.hoisted(() => vi.fn());
vi.mock("../../gateway/mcp-app-channel-action.js", () => ({
materializeMcpAppChannelPresentation: materialize,
}));
import { renderMessagePresentationFallbackText } from "../../interactive/payload.js";
import { attachMcpAppChannelAction } from "./mcp-app-channel-action.js";
const view = { viewId: "view-latest" };
const presentation = {
blocks: [
{
type: "buttons" as const,
buttons: [
{
label: "Open app",
action: {
type: "web-app" as const,
url: "https://node.tailnet.ts.net/__openclaw__/mcp-app#opaque-ticket",
},
},
],
},
],
};
beforeEach(() => {
materialize.mockReset();
materialize.mockReturnValue(presentation);
});
describe("attachMcpAppChannelAction", () => {
it("attaches one action to the latest visible reply and preserves original text", () => {
const payloads = attachMcpAppChannelAction({
payloads: [
{ text: "progress", isStatusNotice: true },
{ text: "First answer" },
{ text: "Final answer" },
],
channel: "telegram",
sessionKey: "agent:main:main",
view,
});
expect(payloads[1]).toEqual({ text: "First answer" });
const finalPayload = payloads[2];
expect(finalPayload).toEqual({ text: "Final answer", presentation });
if (!finalPayload) {
throw new Error("expected final payload");
}
expect(renderMessagePresentationFallbackText(finalPayload)).toBe(
"Final answer\n\n- Open app: https://node.tailnet.ts.net/__openclaw__/mcp-app#opaque-ticket",
);
});
it("keeps Control UI inline-only without minting a duplicate action", () => {
const payloads = [{ text: "Final answer" }];
expect(
attachMcpAppChannelAction({
payloads,
channel: "webchat",
sessionKey: "agent:main:main",
view,
}),
).toBe(payloads);
expect(materialize).not.toHaveBeenCalled();
});
it("does not mint without a resolved channel transport", () => {
const payloads = [{ text: "Final answer" }];
expect(
attachMcpAppChannelAction({
payloads,
sessionKey: "agent:main:main",
view,
}),
).toBe(payloads);
expect(materialize).not.toHaveBeenCalled();
});
it("preserves the original payloads when late materialization is unavailable", () => {
materialize.mockReturnValue(undefined);
const payloads = [{ text: "Final answer" }];
expect(
attachMcpAppChannelAction({
payloads,
channel: "telegram",
sessionKey: "agent:main:main",
view,
}),
).toBe(payloads);
});
it("does not mint for status, error, or non-text terminal payloads", () => {
const payloads = [
{ text: "status", isStatusNotice: true },
{ text: "error", isError: true },
{ mediaUrl: "https://example.test/image.png" },
];
expect(
attachMcpAppChannelAction({
payloads,
channel: "telegram",
sessionKey: "agent:main:main",
view,
}),
).toBe(payloads);
expect(materialize).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,49 @@
import type { McpAppChannelView } from "../../agents/mcp-ui-resource.js";
import { materializeMcpAppChannelPresentation } from "../../gateway/mcp-app-channel-action.js";
import { isReplyPayloadStatusNotice } from "../reply-payload.js";
import type { ReplyPayload } from "../types.js";
function isEligibleTerminalPayload(payload: ReplyPayload): boolean {
return Boolean(
payload.text?.trim() &&
payload.isError !== true &&
payload.isReasoning !== true &&
payload.isCommentary !== true &&
!isReplyPayloadStatusNotice(payload),
);
}
/** Attach one late-minted portable action to the final visible channel reply. */
export function attachMcpAppChannelAction(params: {
payloads: ReplyPayload[];
channel?: string;
sessionKey?: string;
view?: McpAppChannelView;
}): ReplyPayload[] {
if (!params.channel || params.channel === "webchat" || !params.sessionKey || !params.view) {
return params.payloads;
}
const index = params.payloads.findLastIndex(isEligibleTerminalPayload);
if (index < 0) {
return params.payloads;
}
const presentation = materializeMcpAppChannelPresentation({
sessionKey: params.sessionKey,
view: params.view,
});
if (!presentation) {
return params.payloads;
}
const payloads = params.payloads.slice();
const payload = payloads[index]!;
payloads[index] = {
...payload,
presentation: payload.presentation
? {
...payload.presentation,
blocks: [...payload.presentation.blocks, ...presentation.blocks],
}
: presentation,
};
return payloads;
}
+143
View File
@@ -0,0 +1,143 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
createTicket: vi.fn(),
getView: vi.fn(),
peekRuntime: vi.fn(),
}));
vi.mock("../agents/agent-bundle-mcp-runtime.js", () => ({
peekSessionMcpRuntime: mocks.peekRuntime,
}));
vi.mock("../agents/mcp-ui-resource.js", () => ({
getMcpAppViewLease: mocks.getView,
}));
vi.mock("./mcp-app-standalone.js", () => ({
createMcpAppStandaloneTicket: mocks.createTicket,
}));
import { materializeMcpAppChannelPresentation } from "./mcp-app-channel-action.js";
import { getMcpAppChannelOrigin, prepareMcpAppChannelOrigin } from "./mcp-app-channel-origin.js";
const nowMs = 1_800_000_000_000;
const runtime = { sessionId: "runtime-session", mcpAppsEnabled: true };
const view = {
viewId: "view-latest",
sessionId: runtime.sessionId,
expiresAtMs: nowMs + 60_000,
html: "do-not-emit-html",
toolInput: { privateInput: "do-not-emit-input" },
toolResult: { privateResult: "do-not-emit-result" },
};
function resetMcpAppChannelOrigin() {
prepareMcpAppChannelOrigin({ origin: "https://reset.test", reachability: "tailnet" })();
}
beforeEach(() => {
vi.clearAllMocks();
resetMcpAppChannelOrigin();
mocks.peekRuntime.mockReturnValue(runtime);
mocks.getView.mockReturnValue(view);
mocks.createTicket.mockReturnValue({
ticket: "opaque-ticket",
url: "/__openclaw__/mcp-app#opaque-ticket",
expiresAtMs: nowMs + 60_000,
});
});
describe("MCP App channel origin", () => {
it("stores one lifecycle-owned Serve or Funnel snapshot", () => {
const clearServe = prepareMcpAppChannelOrigin({
origin: "https://node.tailnet.ts.net",
reachability: "tailnet",
});
const clearFunnel = prepareMcpAppChannelOrigin({
origin: "https://public.example.ts.net/",
reachability: "internet",
});
expect(getMcpAppChannelOrigin()).toEqual({
origin: "https://public.example.ts.net",
reachability: "internet",
});
clearServe();
expect(getMcpAppChannelOrigin()).toBeDefined();
clearFunnel();
expect(getMcpAppChannelOrigin()).toBeUndefined();
});
it.each(["http://node.test", "https://%75@node.test", "https://node.test/path"])(
"rejects unsafe origin %s",
(origin) => {
expect(() => prepareMcpAppChannelOrigin({ origin, reachability: "tailnet" })).toThrow(
"absolute HTTPS origin",
);
},
);
});
describe("materializeMcpAppChannelPresentation", () => {
it("mints late and emits only one typed action with an opaque ticket", () => {
prepareMcpAppChannelOrigin({
origin: "https://node.tailnet.ts.net",
reachability: "tailnet",
});
const presentation = materializeMcpAppChannelPresentation({
sessionKey: "agent:main:do-not-emit-session",
view: { viewId: "view-latest", title: "do-not-emit-title" } as never,
nowMs,
});
expect(mocks.createTicket).toHaveBeenCalledOnce();
expect(presentation).toEqual({
blocks: [
{
type: "buttons",
buttons: [
{
label: "Open app",
action: {
type: "web-app",
url: "https://node.tailnet.ts.net/__openclaw__/mcp-app#opaque-ticket",
},
},
],
},
],
});
const serialized = JSON.stringify(presentation);
for (const privateValue of [
view.html,
"do-not-emit-input",
"do-not-emit-result",
"do-not-emit-session",
"do-not-emit-title",
view.viewId,
]) {
expect(serialized).not.toContain(privateValue);
}
});
it.each([
["missing origin", resetMcpAppChannelOrigin],
["missing view", () => mocks.getView.mockReturnValue(undefined)],
["expired view", () => mocks.getView.mockReturnValue({ ...view, expiresAtMs: nowMs })],
["ticket capacity", () => mocks.createTicket.mockReturnValue(undefined)],
])("omits the action for %s", (_name, arrange) => {
prepareMcpAppChannelOrigin({
origin: "https://node.tailnet.ts.net",
reachability: "tailnet",
});
arrange();
expect(
materializeMcpAppChannelPresentation({
sessionKey: "agent:main:main",
view: { viewId: "view-latest" },
nowMs,
}),
).toBeUndefined();
});
});
+51
View File
@@ -0,0 +1,51 @@
import { peekSessionMcpRuntime } from "../agents/agent-bundle-mcp-runtime.js";
import type { McpAppChannelView } from "../agents/mcp-ui-resource.js";
import { getMcpAppViewLease } from "../agents/mcp-ui-resource.js";
import type { MessagePresentation } from "../interactive/payload.js";
import { getMcpAppChannelOrigin } from "./mcp-app-channel-origin.js";
import { createMcpAppStandaloneTicket } from "./mcp-app-standalone.js";
/** Mint one short-lived launch action only after the final reply route is known. */
export function materializeMcpAppChannelPresentation(params: {
sessionKey: string;
view: McpAppChannelView;
nowMs?: number;
}): MessagePresentation | undefined {
const origin = getMcpAppChannelOrigin();
if (!origin) {
return undefined;
}
const runtime = peekSessionMcpRuntime({ sessionKey: params.sessionKey });
if (!runtime || runtime.mcpAppsEnabled !== true) {
return undefined;
}
const nowMs = params.nowMs ?? Date.now();
const view = getMcpAppViewLease(params.view.viewId, runtime);
if (!view || view.expiresAtMs <= nowMs) {
return undefined;
}
const ticket = createMcpAppStandaloneTicket({
sessionKey: params.sessionKey,
view,
nowMs,
});
if (!ticket) {
return undefined;
}
return {
blocks: [
{
type: "buttons",
buttons: [
{
label: "Open app",
action: {
type: "web-app",
url: new URL(ticket.url, origin.origin).href,
},
},
],
},
],
};
}
+34
View File
@@ -0,0 +1,34 @@
type McpAppChannelOrigin = {
origin: string;
reachability: "tailnet" | "internet";
};
let publishedOrigin: (McpAppChannelOrigin & { owner: symbol }) | undefined;
/** Install the process-lifecycle snapshot used by terminal channel replies. */
export function prepareMcpAppChannelOrigin(snapshot: McpAppChannelOrigin): () => void {
const url = new URL(snapshot.origin);
if (
url.protocol !== "https:" ||
url.username ||
url.password ||
url.pathname !== "/" ||
url.search ||
url.hash
) {
throw new Error("MCP App channel origin must be an absolute HTTPS origin");
}
const owner = Symbol("mcp-app-channel-origin");
publishedOrigin = { origin: url.origin, reachability: snapshot.reachability, owner };
return () => {
if (publishedOrigin?.owner === owner) {
publishedOrigin = undefined;
}
};
}
export function getMcpAppChannelOrigin(): McpAppChannelOrigin | undefined {
return publishedOrigin
? { origin: publishedOrigin.origin, reachability: publishedOrigin.reachability }
: undefined;
}
+46
View File
@@ -20,13 +20,19 @@ vi.mock("../infra/tailscale.js", () => ({
hasTailscaleFunnelRouteForPort: mocks.hasTailscaleFunnelRouteForPort,
}));
import { getMcpAppChannelOrigin, prepareMcpAppChannelOrigin } from "./mcp-app-channel-origin.js";
import { startGatewayTailscaleExposure } from "./server-tailscale.js";
function createLogger() {
return { info: vi.fn(), warn: vi.fn() };
}
function resetMcpAppChannelOrigin() {
prepareMcpAppChannelOrigin({ origin: "https://reset.test", reachability: "tailnet" })();
}
afterEach(() => {
resetMcpAppChannelOrigin();
for (const fn of Object.values(mocks)) {
fn.mockReset();
}
@@ -151,6 +157,46 @@ describe("startGatewayTailscaleExposure preserveFunnel", () => {
expect(mocks.disableTailscaleServe).not.toHaveBeenCalled();
});
it("prepares one tailnet-only Serve origin for the Gateway lifecycle", async () => {
mocks.getTailnetHostname.mockResolvedValue("node.tailnet.ts.net");
const cleanup = await startGatewayTailscaleExposure({
tailscaleMode: "serve",
port: 18789,
logTailscale: createLogger(),
});
expect(getMcpAppChannelOrigin()).toEqual({
origin: "https://node.tailnet.ts.net",
reachability: "tailnet",
});
await cleanup?.();
expect(getMcpAppChannelOrigin()).toBeUndefined();
expect(mocks.disableTailscaleServe).not.toHaveBeenCalled();
});
it("marks preserved Funnel as internet reachable without taking route ownership", async () => {
mocks.getTailnetHostname.mockResolvedValue("node.tailnet.ts.net");
mocks.hasTailscaleFunnelRouteForPort.mockResolvedValue(true);
const cleanup = await startGatewayTailscaleExposure({
tailscaleMode: "serve",
port: 18789,
preserveFunnel: true,
resetOnExit: true,
logTailscale: createLogger(),
});
expect(getMcpAppChannelOrigin()).toEqual({
origin: "https://node.tailnet.ts.net",
reachability: "internet",
});
await cleanup?.();
expect(getMcpAppChannelOrigin()).toBeUndefined();
expect(mocks.disableTailscaleServe).not.toHaveBeenCalled();
expect(mocks.disableTailscaleFunnel).not.toHaveBeenCalled();
});
it.each([
["only reports an IP", "100.64.0.8"],
["omits the DNS suffix", "node"],
+31 -17
View File
@@ -10,6 +10,7 @@ import {
hasTailscaleFunnelRouteForPort,
} from "../infra/tailscale.js";
import { resolveTailscalePublishedHost } from "../shared/tailscale-status.js";
import { prepareMcpAppChannelOrigin } from "./mcp-app-channel-origin.js";
export async function startGatewayTailscaleExposure(params: {
tailscaleMode: "off" | "serve" | "funnel";
@@ -25,28 +26,31 @@ export async function startGatewayTailscaleExposure(params: {
}
const serviceName =
params.tailscaleMode === "serve" ? params.serviceName?.trim() || undefined : undefined;
let effectiveMode = params.tailscaleMode;
let preservedFunnel = false;
let clearPublishedOrigin: (() => void) | undefined;
try {
if (params.tailscaleMode === "serve") {
if (params.preserveFunnel === true) {
const funnelCovers = await hasTailscaleFunnelRouteForPort(params.port);
if (funnelCovers) {
effectiveMode = "funnel";
preservedFunnel = true;
const resetSuffix = params.resetOnExit
? "; resetOnExit is a no-op because no Serve route was applied this run"
: "";
params.logTailscale.info(
`serve skipped: preserving externally configured Tailscale Funnel for port ${params.port}${resetSuffix}`,
);
// Skip the resetOnExit teardown deliberately: the Funnel route is
// owned by an external operator, so we must not run
// disableTailscaleServe on shutdown either.
return null;
}
}
if (serviceName) {
await enableTailscaleServe(params.port, undefined, serviceName);
} else {
await enableTailscaleServe(params.port);
if (!preservedFunnel) {
if (serviceName) {
await enableTailscaleServe(params.port, undefined, serviceName);
} else {
await enableTailscaleServe(params.port);
}
}
} else {
await enableTailscaleFunnel(params.port);
@@ -55,30 +59,40 @@ export async function startGatewayTailscaleExposure(params: {
if (host) {
const uiPath = params.controlUiBasePath ? `${params.controlUiBasePath}/` : "/";
const publicHost = resolveTailscalePublishedHost({
tailscaleMode: params.tailscaleMode,
tailscaleMode: effectiveMode,
tailnetHost: host,
serviceName,
serviceName: effectiveMode === "serve" ? serviceName : undefined,
});
if (publicHost) {
const serviceLabel = serviceName ? ` for ${serviceName}` : "";
params.logTailscale.info(
`${params.tailscaleMode} enabled${serviceLabel}: https://${publicHost}${uiPath} (WS via wss://${publicHost})`,
);
} else {
clearPublishedOrigin = prepareMcpAppChannelOrigin({
origin: `https://${publicHost}`,
reachability: effectiveMode === "funnel" ? "internet" : "tailnet",
});
if (!preservedFunnel) {
const serviceLabel = serviceName ? ` for ${serviceName}` : "";
params.logTailscale.info(
`${params.tailscaleMode} enabled${serviceLabel}: https://${publicHost}${uiPath} (WS via wss://${publicHost})`,
);
}
} else if (!preservedFunnel) {
params.logTailscale.info(`${params.tailscaleMode} enabled`);
}
} else {
} else if (!preservedFunnel) {
params.logTailscale.info(`${params.tailscaleMode} enabled`);
}
} catch (err) {
params.logTailscale.warn(`${params.tailscaleMode} failed: ${formatErrorMessage(err)}`);
}
if (!params.resetOnExit) {
if (!params.resetOnExit && !clearPublishedOrigin) {
return null;
}
return async () => {
clearPublishedOrigin?.();
if (!params.resetOnExit || preservedFunnel) {
return;
}
try {
if (params.tailscaleMode === "serve") {
if (serviceName) {