feat(ui): render assistant directives and add embed tag (#64104)

* Add embed rendering for Control UI assistant output

* Add changelog entry for embed rendering

* Harden canvas path resolution and stage isolation

* Secure assistant media route and preserve UI avatar override

* Fix chat media and history regressions

* Harden embed iframe URL handling

* Fix embed follow-up review regressions

* Restore offloaded chat attachment persistence

* Harden hook and media routing

* Fix embed review follow-ups

* feat(ui): add configurable embed sandbox mode

* fix(gateway): harden assistant media and auth rotation

* fix(gateway): restore websocket pairing handshake flows

* fix(gateway): restore ws hello policy details

* Restore dropped control UI shell wiring

* Fix control UI reconnect cleanup regressions

* fix(gateway): restore media root and auth getter compatibility

* feat(ui): rename public canvas tag to embed

* fix(ui): address remaining media and gateway review issues

* fix(ui): address remaining embed and attachment review findings

* fix(ui): restore stop control and tool card inputs

* fix(ui): address history and attachment review findings

* fix(ui): restore prompt contribution wiring

* fix(ui): address latest history and directive reviews

* fix(ui): forward password auth for assistant media

* fix(ui): suppress silent transcript tokens with media

* feat(ui): add granular embed sandbox modes

* fix(ui): preserve relative media directives in history

* docs(ui): document embed sandbox modes

* fix(gateway): restrict canvas history hoisting to tool entries

* fix(gateway): tighten embed follow-up review fixes

* fix(ci): repair merged branch type drift

* fix(prompt): restore stable runtime prompt rendering

* fix(ui): harden local attachment preview checks

* fix(prompt): restore channel-aware approval guidance

* fix(gateway): enforce auth rotation and media cleanup

* feat(ui): gate external embed urls behind config

* fix(ci): repair rebased branch drift

* fix(ci): resolve remaining branch check failures
This commit is contained in:
Tak Hoffman
2026-04-11 07:32:53 -05:00
committed by GitHub
parent d83a85c70d
commit cc5c691f00
75 changed files with 8163 additions and 1832 deletions

View File

@@ -37,11 +37,13 @@ Docs: https://docs.openclaw.ai
- Gateway: add a `commands.list` RPC so remote gateway clients can discover runtime-native, text, skill, and plugin commands with surface-aware naming and serialized argument metadata. (#62656) Thanks @samzong.
- Models/providers: add per-provider `models.providers.*.request.allowPrivateNetwork` for trusted self-hosted OpenAI-compatible endpoints, keep the opt-in scoped to model request surfaces, and refresh cached WebSocket managers when request transport overrides change. (#63671) Thanks @qas.
- Feishu: standardize request user agents and register the bot as an AI agent so Feishu deployments identify OpenClaw consistently. (#63835) Thanks @evandance.
- Docs i18n: chunk raw doc translation, reject truncated tagged outputs, avoid ambiguous body-only wrapper unwrapping, and recover from terminated Pi translation sessions without changing the default `openai/gpt-5.4` path. (#62969, #63808) Thanks @hxy91819.
- Gateway: split startup and runtime seams so gateway lifecycle sequencing, reload state, and shutdown behavior stay easier to maintain without changing observed behavior. (#63975) Thanks @gumadeiras.
- Control UI/webchat: normalize assistant `MEDIA:`/reply/voice directives into structured bubble rendering, rename the unreleased rich web shortcode to `[embed ...]`, and surface session runtime roots so hosted web content is written to the correct document path instead of guessed local files.
- Matrix/partial streaming: add MSC4357 live markers to draft preview sends and edits so supporting Matrix clients can render a live/typewriter animation and stop it when the final edit lands. (#63513) Thanks @TigerInYourDream.
- Control UI/dreaming: simplify the Scene and Diary surfaces, preserve unknown phase state for partial status payloads, and stabilize waiting-entry recency ordering so Dreaming status and review lists stay clear and deterministic. (#64035) Thanks @davemorin.
- Agents: add an opt-in strict-agentic embedded Pi execution contract for GPT-5-family runs so plan-only or filler turns keep acting until they hit a real blocker. (#64241) Thanks @100yenadmin.
- Agents/OpenAI: add provider-owned OpenAI/Codex tool schema compatibility and surface embedded-run replay/liveness state for long-running runs. (#64300) Thanks @100yenadmin.
- Docs i18n: chunk raw doc translation, reject truncated tagged outputs, avoid ambiguous body-only wrapper unwrapping, and recover from terminated Pi translation sessions without changing the default `openai/gpt-5.4` path. (#62969, #63808) Thanks @hxy91819.
- Dreaming/memory-wiki: add ChatGPT import ingestion plus new `Imported Insights` and `Memory Palace` diary subtabs so Dreaming can inspect imported source chats, compiled wiki pages, and full source pages directly from the UI. (#64505)
### Fixes

View File

@@ -2895,6 +2895,8 @@ See [Plugins](/tools/plugin).
enabled: true,
basePath: "/openclaw",
// root: "dist/control-ui",
// embedSandbox: "scripts", // strict | scripts | trusted
// allowExternalEmbedUrls: false, // dangerous: allow absolute external http(s) embed URLs
// allowedOrigins: ["https://control.example.com"], // required for non-loopback Control UI
// dangerouslyAllowHostHeaderOriginFallback: false, // dangerous Host-header origin fallback mode
// allowInsecureAuth: false,

View File

@@ -0,0 +1,50 @@
# Rich Output Protocol
Assistant output can carry a small set of delivery/render directives:
- `MEDIA:` for attachment delivery
- `[[audio_as_voice]]` for audio presentation hints
- `[[reply_to_current]]` / `[[reply_to:<id>]]` for reply metadata
- `[embed ...]` for Control UI rich rendering
These directives are separate. `MEDIA:` and reply/voice tags remain delivery metadata; `[embed ...]` is the web-only rich render path.
## `[embed ...]`
`[embed ...]` is the only agent-facing rich render syntax for the Control UI.
Self-closing example:
```text
[embed ref="cv_123" title="Status" /]
```
Rules:
- `[view ...]` is no longer valid for new output.
- Embed shortcodes render in the assistant message surface only.
- Only URL-backed embeds are rendered. Use `ref="..."` or `url="..."`.
- Block-form inline HTML embed shortcodes are not rendered.
- The web UI strips the shortcode from visible text and renders the embed inline.
- `MEDIA:` is not an embed alias and should not be used for rich embed rendering.
## Stored Rendering Shape
The normalized/stored assistant content block is a structured `canvas` item:
```json
{
"type": "canvas",
"preview": {
"kind": "canvas",
"surface": "assistant_message",
"render": "url",
"viewId": "cv_123",
"url": "/__openclaw__/canvas/documents/cv_123/index.html",
"title": "Status",
"preferredHeight": 320
}
}
```
Stored/rendered rich blocks use this `canvas` shape directly. `present_view` is not recognized.

View File

@@ -138,6 +138,38 @@ Cron jobs panel notes:
- Gateway persists aborted partial assistant text into transcript history when buffered output exists
- Persisted entries include abort metadata so transcript consumers can tell abort partials from normal completion output
## Hosted embeds
Assistant messages can render hosted web content inline with the `[embed ...]`
shortcode. The iframe sandbox policy is controlled by
`gateway.controlUi.embedSandbox`:
- `strict`: disables script execution inside hosted embeds
- `scripts`: allows interactive embeds while keeping origin isolation; this is
the default and is usually enough for self-contained browser games/widgets
- `trusted`: adds `allow-same-origin` on top of `allow-scripts` for same-site
documents that intentionally need stronger privileges
Example:
```json5
{
gateway: {
controlUi: {
embedSandbox: "scripts",
},
},
}
```
Use `trusted` only when the embedded document genuinely needs same-origin
behavior. For most agent-generated games and interactive canvases, `scripts` is
the safer choice.
Absolute external `http(s)` embed URLs stay blocked by default. If you
intentionally want `[embed url="https://..."]` to load third-party pages, set
`gateway.controlUi.allowExternalEmbedUrls: true`.
## Tailnet access (recommended)
### Integrated Tailscale Serve (preferred)

View File

@@ -43,6 +43,7 @@ export function buildEmbeddedSystemPrompt(params: {
channel?: string;
/** Supported message actions for the current channel (e.g., react, edit, unsend) */
channelActions?: string[];
canvasRootDir?: string;
};
messageToolHints?: string[];
sandboxInfo?: EmbeddedSandboxInfo;

View File

@@ -3,6 +3,7 @@ import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import { resolveStateDir } from "../config/paths.js";
import { buildSystemPromptParams } from "./system-prompt-params.js";
async function makeTempDir(label: string): Promise<string> {
@@ -101,4 +102,12 @@ describe("buildSystemPromptParams repo root", () => {
expect(runtimeInfo.repoRoot).toBeUndefined();
});
it("includes the default profile canvas root in runtimeInfo", async () => {
const workspaceDir = await makeTempDir("canvas-root");
const { runtimeInfo } = buildParams({ workspaceDir });
expect(runtimeInfo.canvasRootDir).toBe(path.resolve(path.join(resolveStateDir(), "canvas")));
});
});

View File

@@ -1,7 +1,9 @@
import fs from "node:fs";
import path from "node:path";
import { resolveStateDir } from "../config/paths.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { findGitRoot } from "../infra/git-root.js";
import { resolveHomeRelativePath } from "../infra/home-dir.js";
import {
formatUserTime,
resolveUserTimeFormat,
@@ -23,6 +25,7 @@ export type RuntimeInfoInput = {
/** Supported message actions for the current channel (e.g., react, edit, unsend) */
channelActions?: string[];
repoRoot?: string;
canvasRootDir?: string;
};
export type SystemPromptRuntimeParams = {
@@ -47,11 +50,17 @@ export function buildSystemPromptParams(params: {
const userTimezone = resolveUserTimezone(params.config?.agents?.defaults?.userTimezone);
const userTimeFormat = resolveUserTimeFormat(params.config?.agents?.defaults?.timeFormat);
const userTime = formatUserTime(new Date(), userTimezone, userTimeFormat);
const stateDir = resolveStateDir(process.env);
const canvasRootDir = resolveCanvasRootDir({
config: params.config,
stateDir,
});
return {
runtimeInfo: {
agentId: params.agentId,
...params.runtime,
repoRoot,
canvasRootDir,
},
userTimezone,
userTime,
@@ -59,6 +68,18 @@ export function buildSystemPromptParams(params: {
};
}
function resolveCanvasRootDir(params: { config?: OpenClawConfig; stateDir: string }): string {
const configured = params.config?.canvasHost?.root?.trim();
if (configured) {
return path.resolve(
resolveHomeRelativePath(configured, {
env: process.env,
}),
);
}
return path.resolve(path.join(params.stateDir, "canvas"));
}
function resolveRepoRoot(params: {
config?: OpenClawConfig;
workspaceDir?: string;

View File

@@ -2,7 +2,6 @@ import { describe, expect, it } from "vitest";
import { SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js";
import { typedCases } from "../test-utils/typed-cases.js";
import { buildSubagentSystemPrompt } from "./subagent-system-prompt.js";
import { SYSTEM_PROMPT_CACHE_BOUNDARY } from "./system-prompt-cache-boundary.js";
import { buildAgentSystemPrompt, buildRuntimeLine } from "./system-prompt.js";
describe("buildAgentSystemPrompt", () => {
@@ -102,7 +101,7 @@ describe("buildAgentSystemPrompt", () => {
skillsPrompt:
"<available_skills>\n <skill>\n <name>demo</name>\n </skill>\n</available_skills>",
heartbeatPrompt: "ping",
toolNames: ["message", "memory_search", "cron"],
toolNames: ["message", "memory_search"],
docsPath: "/tmp/openclaw/docs",
extraSystemPrompt: "Subagent details",
ttsHint: "Voice (TTS) is enabled.",
@@ -120,16 +119,7 @@ describe("buildAgentSystemPrompt", () => {
expect(prompt).not.toContain("## Heartbeats");
expect(prompt).toContain("## Safety");
expect(prompt).toContain(
'For follow-up at a future time (for example "check back in 10 minutes", reminders, run-later work, or recurring tasks), use cron instead of exec sleep, yieldMs delays, or process polling.',
);
expect(prompt).toContain(
"Use exec/process only for commands that start now and continue running in the background.",
);
expect(prompt).toContain(
"For long-running work that starts now, start it once and rely on automatic completion wake when it is enabled and the command emits output or fails; otherwise use process to confirm completion, and use it for logs, status, input, or intervention.",
);
expect(prompt).toContain(
"Do not emulate scheduling with sleep loops, timeout loops, or repeated polling.",
"For long waits, avoid rapid poll loops: use exec with enough yieldMs or process(action=poll, timeout=<ms>).",
);
expect(prompt).toContain("You have no independent goals");
expect(prompt).toContain("Prioritize safety and human oversight");
@@ -159,89 +149,6 @@ describe("buildAgentSystemPrompt", () => {
);
});
it("tells the agent not to execute /approve through exec", () => {
const prompt = buildAgentSystemPrompt({
workspaceDir: "/tmp/openclaw",
});
expect(prompt).toContain(
"Never execute /approve through exec or any other shell/tool path; /approve is a user-facing approval command, not a shell command.",
);
});
it("adds stronger execution-bias guidance for actionable turns", () => {
const prompt = buildAgentSystemPrompt({
workspaceDir: "/tmp/openclaw",
});
expect(prompt).toContain("## Execution Bias");
expect(prompt).toContain(
"If the user asks you to do the work, start doing it in the same turn.",
);
expect(prompt).toContain(
"Commentary-only turns are incomplete when tools are available and the next action is clear.",
);
});
it("narrows silent reply guidance to true no-delivery cases", () => {
const prompt = buildAgentSystemPrompt({
workspaceDir: "/tmp/openclaw",
});
expect(prompt).toContain(
`Use ${SILENT_REPLY_TOKEN} ONLY when no user-visible reply is required.`,
);
expect(prompt).toContain(
"Never use it to avoid doing requested work or to end an actionable turn early.",
);
});
it("keeps manual /approve instructions for non-native approval channels", () => {
const prompt = buildAgentSystemPrompt({
workspaceDir: "/tmp/openclaw",
runtimeInfo: { channel: "signal" },
});
expect(prompt).toContain(
"When exec returns approval-pending, include the concrete /approve command from tool output",
);
expect(prompt).not.toContain("allow-once|allow-always|deny");
});
it("tells native approval channels not to duplicate plain chat /approve instructions", () => {
const prompt = buildAgentSystemPrompt({
workspaceDir: "/tmp/openclaw",
runtimeInfo: { channel: "telegram", capabilities: ["inlineButtons"] },
});
expect(prompt).toContain(
"When exec returns approval-pending on this channel, rely on native approval card/buttons when they appear and do not also send plain chat /approve instructions. Only include the concrete /approve command if the tool result says chat approvals are unavailable or only manual approval is possible.",
);
expect(prompt).toContain(
"Only include the concrete /approve command if the tool result says chat approvals are unavailable or only manual approval is possible.",
);
expect(prompt).not.toContain(
"When exec returns approval-pending, include the concrete /approve command from tool output",
);
});
it("treats webchat as a native approval surface", () => {
const prompt = buildAgentSystemPrompt({
workspaceDir: "/tmp/openclaw",
runtimeInfo: { channel: "webchat" },
});
expect(prompt).toContain(
"When exec returns approval-pending on this channel, rely on native approval card/buttons when they appear",
);
expect(prompt).toContain(
"Only include the concrete /approve command if the tool result says chat approvals are unavailable or only manual approval is possible.",
);
expect(prompt).not.toContain(
"When exec returns approval-pending, include the concrete /approve command from tool output",
);
});
it("omits skills in minimal prompt mode when skillsPrompt is absent", () => {
const prompt = buildAgentSystemPrompt({
workspaceDir: "/tmp/openclaw",
@@ -256,10 +163,10 @@ describe("buildAgentSystemPrompt", () => {
workspaceDir: "/tmp/openclaw",
});
expect(prompt).toContain("## Reply Tags");
expect(prompt).toContain("## Assistant Output Directives");
expect(prompt).toContain("[[reply_to_current]]");
expect(prompt).not.toContain("Tags are stripped before sending");
expect(prompt).toContain("Tags are removed before sending");
expect(prompt).toContain("Supported tags are stripped before user-visible rendering");
});
it("omits the heartbeat section when no heartbeat prompt is provided", () => {
@@ -329,19 +236,46 @@ describe("buildAgentSystemPrompt", () => {
expect(prompt).toContain("do not forward raw internal metadata");
});
it("does not include embed guidance in the default global prompt", () => {
const prompt = buildAgentSystemPrompt({
workspaceDir: "/tmp/openclaw",
});
expect(prompt).not.toContain("## Control UI Embed");
expect(prompt).not.toContain("Use `[embed ...]` only in Control UI/webchat sessions");
});
it("includes embed guidance only for webchat sessions", () => {
const prompt = buildAgentSystemPrompt({
workspaceDir: "/tmp/openclaw",
runtimeInfo: {
channel: "webchat",
canvasRootDir: "/Users/example/.openclaw-dev/canvas",
},
});
expect(prompt).toContain("## Control UI Embed");
expect(prompt).toContain("Use `[embed ...]` only in Control UI/webchat sessions");
expect(prompt).toContain('[embed ref="cv_123" title="Status" height="320" /]');
expect(prompt).toContain(
'[embed url="/__openclaw__/canvas/documents/cv_123/index.html" title="Status" height="320" /]',
);
expect(prompt).toContain(
"Never use local filesystem paths or `file://...` URLs in `[embed ...]`.",
);
expect(prompt).toContain(
"The active hosted embed root for this session is: `/Users/example/.openclaw-dev/canvas`.",
);
expect(prompt).not.toContain('[embed content_type="html" title="Status"]...[/embed]');
});
it("guides subagent workflows to avoid polling loops", () => {
const prompt = buildAgentSystemPrompt({
workspaceDir: "/tmp/openclaw",
});
expect(prompt).toContain(
'For follow-up at a future time (for example "check back in 10 minutes", reminders, run-later work, or recurring tasks), use cron instead of exec sleep, yieldMs delays, or process polling.',
);
expect(prompt).toContain(
"Use exec/process only for commands that start now and continue running in the background.",
);
expect(prompt).toContain(
"For long-running work that starts now, start it once and rely on automatic completion wake when it is enabled and the command emits output or fails; otherwise use process to confirm completion, and use it for logs, status, input, or intervention.",
"For long waits, avoid rapid poll loops: use exec with enough yieldMs or process(action=poll, timeout=<ms>).",
);
expect(prompt).toContain("Completion is push-based: it will auto-announce when done.");
expect(prompt).toContain("Do not poll `subagents list` / `sessions_list` in a loop");
@@ -350,25 +284,16 @@ describe("buildAgentSystemPrompt", () => {
);
});
it("uses structured tool definitions as the source of truth", () => {
it("lists available tools when provided", () => {
const prompt = buildAgentSystemPrompt({
workspaceDir: "/tmp/openclaw",
toolNames: ["exec", "sessions_list", "sessions_history", "sessions_send"],
});
expect(prompt).toContain(
"Structured tool definitions are the source of truth for tool names, descriptions, and parameters.",
);
expect(prompt).toContain(
"Tool names are case-sensitive. Call tools exactly as listed in the structured tool definitions.",
);
expect(prompt).toContain(
"TOOLS.md does not control tool availability; it is user guidance for how to use external tools.",
);
expect(prompt).not.toContain("Tool availability (filtered by policy):");
expect(prompt).not.toContain("- sessions_list:");
expect(prompt).not.toContain("- sessions_history:");
expect(prompt).not.toContain("- sessions_send:");
expect(prompt).toContain("Tool availability (filtered by policy):");
expect(prompt).toContain("sessions_list");
expect(prompt).toContain("sessions_history");
expect(prompt).toContain("sessions_send");
});
it("documents ACP sessions_spawn agent targeting requirements", () => {
@@ -378,8 +303,10 @@ describe("buildAgentSystemPrompt", () => {
});
expect(prompt).toContain("sessions_spawn");
expect(prompt).toContain("Set `agentId` explicitly unless `acp.defaultAgent` is configured");
expect(prompt).toContain("`subagents`/`agents_list`");
expect(prompt).toContain(
'runtime="acp" requires `agentId` unless `acp.defaultAgent` is configured',
);
expect(prompt).toContain("not agents_list");
});
it("guides harness requests to ACP thread-bound spawns", () => {
@@ -414,9 +341,8 @@ describe("buildAgentSystemPrompt", () => {
);
expect(prompt).not.toContain('runtime="acp" requires `agentId`');
expect(prompt).not.toContain("not ACP harness ids");
expect(prompt).toContain(
"If a task is more complex or takes longer, spawn a sub-agent. Completion is push-based: it will auto-announce when done.",
);
expect(prompt).toContain("- sessions_spawn: Spawn an isolated sub-agent session");
expect(prompt).toContain("- agents_list: List OpenClaw agent ids allowed for sessions_spawn");
});
it("omits ACP harness spawn guidance for sandboxed sessions and shows ACP block note", () => {
@@ -450,12 +376,8 @@ describe("buildAgentSystemPrompt", () => {
docsPath: "/tmp/openclaw/docs",
});
expect(prompt).toContain(
"Tool names are case-sensitive. Call tools exactly as listed in the structured tool definitions.",
);
expect(prompt).toContain(
"For long waits, avoid rapid poll loops: use Exec with enough yieldMs or process(action=poll, timeout=<ms>).",
);
expect(prompt).toContain("- Read: Read file contents");
expect(prompt).toContain("- Exec: Run shell commands");
expect(prompt).toContain(
"- If exactly one skill clearly applies: read its SKILL.md at <location> with `Read`, then follow it.",
);
@@ -465,25 +387,6 @@ describe("buildAgentSystemPrompt", () => {
);
});
it("adds update_plan guidance only when the tool is available", () => {
const promptWithPlan = buildAgentSystemPrompt({
workspaceDir: "/tmp/openclaw",
toolNames: ["exec", "update_plan"],
});
const promptWithoutPlan = buildAgentSystemPrompt({
workspaceDir: "/tmp/openclaw",
toolNames: ["exec"],
});
expect(promptWithPlan).toContain(
"For non-trivial multi-step work, keep a short plan updated with `update_plan`.",
);
expect(promptWithPlan).toContain(
"When you use `update_plan`, keep exactly one step `in_progress` until the work is done.",
);
expect(promptWithoutPlan).not.toContain("keep a short plan updated with `update_plan`");
});
it("includes docs guidance when docsPath is provided", () => {
const prompt = buildAgentSystemPrompt({
workspaceDir: "/tmp/openclaw",
@@ -513,7 +416,7 @@ describe("buildAgentSystemPrompt", () => {
params: {
workspaceDir: "/tmp/openclaw",
userTimezone: "America/Chicago",
userTime: "Monday, January 5th, 2026 - 3:26 PM",
userTime: "Monday, January 5th, 2026 3:26 PM",
userTimeFormat: "12" as const,
},
},
@@ -522,7 +425,7 @@ describe("buildAgentSystemPrompt", () => {
params: {
workspaceDir: "/tmp/openclaw",
userTimezone: "America/Chicago",
userTime: "Monday, January 5th, 2026 - 15:26",
userTime: "Monday, January 5th, 2026 15:26",
userTimeFormat: "24" as const,
},
},
@@ -555,19 +458,23 @@ describe("buildAgentSystemPrompt", () => {
// The system prompt intentionally does NOT include the current date/time.
// Only the timezone is included, to keep the prompt stable for caching.
// See: https://github.com/moltbot/moltbot/commit/66eec295b894bce8333886cfbca3b960c57c4946
// Agents should use session_status or message timestamps to determine the date/time.
// Related: https://github.com/moltbot/moltbot/issues/1897
// https://github.com/moltbot/moltbot/issues/3658
it("does NOT include a date or time in the system prompt (cache stability)", () => {
const prompt = buildAgentSystemPrompt({
workspaceDir: "/tmp/clawd",
userTimezone: "America/Chicago",
userTime: "Monday, January 5th, 2026 - 3:26 PM",
userTime: "Monday, January 5th, 2026 3:26 PM",
userTimeFormat: "12",
});
// The prompt should contain the timezone but NOT the formatted date/time string.
// This is intentional for prompt cache stability. If you want to add date/time
// awareness, do it through gateway-level timestamp injection into messages, not
// the system prompt.
// This is intentional for prompt cache stability — the date/time was removed in
// commit 66eec295b. If you're here because you want to add it back, please see
// https://github.com/moltbot/moltbot/issues/3658 for the preferred approach:
// gateway-level timestamp injection into messages, not the system prompt.
expect(prompt).toContain("Time zone: America/Chicago");
expect(prompt).not.toContain("Monday, January 5th, 2026");
expect(prompt).not.toContain("3:26 PM");
@@ -578,14 +485,14 @@ describe("buildAgentSystemPrompt", () => {
const prompt = buildAgentSystemPrompt({
workspaceDir: "/tmp/openclaw",
modelAliasLines: [
"- Opus: anthropic/claude-opus-4-6",
"- Sonnet: anthropic/claude-sonnet-4-6",
"- Opus: anthropic/claude-opus-4-5",
"- Sonnet: anthropic/claude-sonnet-4-5",
],
});
expect(prompt).toContain("## Model Aliases");
expect(prompt).toContain("Prefer aliases when specifying model overrides");
expect(prompt).toContain("- Opus: anthropic/claude-opus-4-6");
expect(prompt).toContain("- Opus: anthropic/claude-opus-4-5");
});
it("adds ClaudeBot self-update guidance when gateway tool is available", () => {
@@ -692,115 +599,35 @@ describe("buildAgentSystemPrompt", () => {
expect(prompt).not.toContain("# Project Context");
});
it("orders stable project context before the cache boundary and moves HEARTBEAT below it", () => {
const prompt = buildAgentSystemPrompt({
workspaceDir: "/tmp/openclaw",
contextFiles: [
{ path: "HEARTBEAT.md", content: "Check inbox." },
{ path: "MEMORY.md", content: "Long-term notes." },
{ path: "AGENTS.md", content: "Follow repo rules." },
{ path: "SOUL.md", content: "Warm but direct." },
{ path: "TOOLS.md", content: "Prefer rg." },
],
});
const agentsIndex = prompt.indexOf("## AGENTS.md");
const soulIndex = prompt.indexOf("## SOUL.md");
const toolsIndex = prompt.indexOf("## TOOLS.md");
const memoryIndex = prompt.indexOf("## MEMORY.md");
const boundaryIndex = prompt.indexOf(SYSTEM_PROMPT_CACHE_BOUNDARY);
const heartbeatHeadingIndex = prompt.indexOf("# Dynamic Project Context");
const heartbeatFileIndex = prompt.indexOf("## HEARTBEAT.md");
expect(agentsIndex).toBeGreaterThan(-1);
expect(soulIndex).toBeGreaterThan(agentsIndex);
expect(toolsIndex).toBeGreaterThan(soulIndex);
expect(memoryIndex).toBeGreaterThan(toolsIndex);
expect(boundaryIndex).toBeGreaterThan(memoryIndex);
expect(heartbeatHeadingIndex).toBeGreaterThan(boundaryIndex);
expect(heartbeatFileIndex).toBeGreaterThan(heartbeatHeadingIndex);
expect(prompt).toContain(
"The following frequently-changing project context files are kept below the cache boundary when possible:",
);
});
it("keeps heartbeat-only project context below the cache boundary", () => {
const prompt = buildAgentSystemPrompt({
workspaceDir: "/tmp/openclaw",
contextFiles: [{ path: "HEARTBEAT.md", content: "Check inbox." }],
});
const boundaryIndex = prompt.indexOf(SYSTEM_PROMPT_CACHE_BOUNDARY);
const projectContextIndex = prompt.indexOf("# Project Context");
const heartbeatFileIndex = prompt.indexOf("## HEARTBEAT.md");
expect(boundaryIndex).toBeGreaterThan(-1);
expect(projectContextIndex).toBeGreaterThan(boundaryIndex);
expect(heartbeatFileIndex).toBeGreaterThan(projectContextIndex);
expect(prompt).not.toContain("# Dynamic Project Context");
});
it("replaces provider-owned prompt sections without disturbing core ordering", () => {
const prompt = buildAgentSystemPrompt({
workspaceDir: "/tmp/openclaw",
promptContribution: {
sectionOverrides: {
interaction_style: "## Interaction Style\n\nCustom interaction guidance.",
execution_bias: "## Execution Bias\n\nCustom execution guidance.",
},
},
});
expect(prompt).toContain("## Interaction Style\n\nCustom interaction guidance.");
expect(prompt).toContain("## Execution Bias\n\nCustom execution guidance.");
expect(prompt).not.toContain("Bias toward action and momentum.");
});
it("places provider stable prefixes above the cache boundary", () => {
const prompt = buildAgentSystemPrompt({
workspaceDir: "/tmp/openclaw",
promptContribution: {
stablePrefix: "## Provider Stable Block\n\nStable provider guidance.",
},
});
const boundaryIndex = prompt.indexOf(SYSTEM_PROMPT_CACHE_BOUNDARY);
const stableIndex = prompt.indexOf("## Provider Stable Block");
const safetyIndex = prompt.indexOf("## Safety");
expect(stableIndex).toBeGreaterThan(-1);
expect(boundaryIndex).toBeGreaterThan(stableIndex);
expect(safetyIndex).toBeGreaterThan(stableIndex);
});
it("places provider dynamic suffixes below the cache boundary", () => {
const prompt = buildAgentSystemPrompt({
workspaceDir: "/tmp/openclaw",
promptContribution: {
dynamicSuffix: "## Provider Dynamic Block\n\nPer-turn provider guidance.",
},
});
const boundaryIndex = prompt.indexOf(SYSTEM_PROMPT_CACHE_BOUNDARY);
const dynamicIndex = prompt.indexOf("## Provider Dynamic Block");
const heartbeatIndex = prompt.indexOf("## Heartbeats");
expect(boundaryIndex).toBeGreaterThan(-1);
expect(dynamicIndex).toBeGreaterThan(boundaryIndex);
expect(heartbeatIndex).toBe(-1);
});
it("summarizes the message tool when available", () => {
const prompt = buildAgentSystemPrompt({
workspaceDir: "/tmp/openclaw",
toolNames: ["message"],
});
expect(prompt).toContain("message: Send messages and channel actions");
expect(prompt).toContain("### message tool");
expect(prompt).toContain("Use `message` for proactive sends + channel actions");
expect(prompt).toContain(`respond with ONLY: ${SILENT_REPLY_TOKEN}`);
});
it("reapplies provider prompt contributions", () => {
const prompt = buildAgentSystemPrompt({
workspaceDir: "/tmp/openclaw",
promptContribution: {
stablePrefix: "## Provider Stable\n\nStable guidance.",
dynamicSuffix: "## Provider Dynamic\n\nDynamic guidance.",
sectionOverrides: {
tool_call_style: "## Tool Call Style\nProvider-specific tool call guidance.",
},
},
});
expect(prompt).toContain("## Provider Stable\n\nStable guidance.");
expect(prompt).toContain("## Provider Dynamic\n\nDynamic guidance.");
expect(prompt).toContain("## Tool Call Style\nProvider-specific tool call guidance.");
expect(prompt).not.toContain("Default: do not narrate routine, low-risk tool calls");
});
it("includes inline button style guidance when runtime supports inline buttons", () => {
const prompt = buildAgentSystemPrompt({
workspaceDir: "/tmp/openclaw",
@@ -815,6 +642,19 @@ describe("buildAgentSystemPrompt", () => {
expect(prompt).toContain("`style` can be `primary`, `success`, or `danger`");
});
it("suppresses plain chat approval commands when inline approval UI is available", () => {
const prompt = buildAgentSystemPrompt({
workspaceDir: "/tmp/openclaw",
runtimeInfo: {
channel: "telegram",
capabilities: ["inlineButtons"],
},
});
expect(prompt).toContain("rely on native approval card/buttons when they appear");
expect(prompt).toContain("do not also send plain chat /approve instructions");
});
it("includes runtime provider capabilities when present", () => {
const prompt = buildAgentSystemPrompt({
workspaceDir: "/tmp/openclaw",
@@ -828,6 +668,20 @@ describe("buildAgentSystemPrompt", () => {
expect(prompt).toContain("capabilities=inlinebuttons");
});
it("canonicalizes runtime provider capabilities before rendering", () => {
const prompt = buildAgentSystemPrompt({
workspaceDir: "/tmp/openclaw",
runtimeInfo: {
channel: "telegram",
capabilities: [" InlineButtons ", "voice", "inlinebuttons", "Voice"],
},
});
expect(prompt).toContain("channel=telegram");
expect(prompt).toContain("capabilities=inlinebuttons,voice");
expect(prompt).not.toContain("capabilities= InlineButtons ,voice,inlinebuttons,Voice");
});
it("includes agent id in runtime when provided", () => {
const prompt = buildAgentSystemPrompt({
workspaceDir: "/tmp/openclaw",
@@ -865,7 +719,7 @@ describe("buildAgentSystemPrompt", () => {
arch: "arm64",
node: "v20",
model: "anthropic/claude",
defaultModel: "anthropic/claude-opus-4-6",
defaultModel: "anthropic/claude-opus-4-5",
},
"telegram",
["inlineButtons"],
@@ -878,56 +732,20 @@ describe("buildAgentSystemPrompt", () => {
expect(line).toContain("os=macOS (arm64)");
expect(line).toContain("node=v20");
expect(line).toContain("model=anthropic/claude");
expect(line).toContain("default_model=anthropic/claude-opus-4-6");
expect(line).toContain("default_model=anthropic/claude-opus-4-5");
expect(line).toContain("channel=telegram");
expect(line).toContain("capabilities=inlinebuttons");
expect(line).toContain("thinking=low");
});
it("normalizes runtime capability ordering and casing for cache stability", () => {
const line = buildRuntimeLine(
{
agentId: "work",
},
"telegram",
[" React ", "inlineButtons", "react"],
"low",
);
expect(line).toContain("capabilities=inlinebuttons,react");
});
it("keeps semantically equivalent structured prompt inputs byte-stable", () => {
const clean = buildAgentSystemPrompt({
it("renders extra system prompt exactly once", () => {
const prompt = buildAgentSystemPrompt({
workspaceDir: "/tmp/openclaw",
runtimeInfo: {
channel: "telegram",
capabilities: ["inlinebuttons", "react"],
},
skillsPrompt:
"<available_skills>\n <skill>\n <name>demo</name>\n </skill>\n</available_skills>",
heartbeatPrompt: "ping",
extraSystemPrompt: "Group chat context\nSecond line",
workspaceNotes: ["Reminder: keep commits scoped."],
modelAliasLines: ["- Sonnet: anthropic/claude-sonnet-4-5"],
});
const noisy = buildAgentSystemPrompt({
workspaceDir: "/tmp/openclaw",
runtimeInfo: {
channel: "telegram",
capabilities: [" react ", "inlineButtons", "react"],
},
skillsPrompt:
"<available_skills>\r\n <skill> \r\n <name>demo</name>\t\r\n </skill>\r\n</available_skills>\r\n",
heartbeatPrompt: " ping \r\n",
extraSystemPrompt: " Group chat context \r\nSecond line \t\r\n",
workspaceNotes: [" Reminder: keep commits scoped. \t\r\n"],
modelAliasLines: [" - Sonnet: anthropic/claude-sonnet-4-5 \t\r\n"],
extraSystemPrompt: "Custom runtime context",
});
expect(noisy).toBe(clean);
expect(noisy).not.toContain("\r");
expect(noisy).not.toMatch(/[ \t]+$/m);
expect(prompt.match(/Custom runtime context/g)).toHaveLength(1);
expect(prompt.match(/## Group Chat Context/g)).toHaveLength(1);
});
it("describes sandboxed runtime and elevated when allowed", () => {

View File

@@ -49,8 +49,6 @@ const CONTEXT_FILE_ORDER = new Map<string, number>([
const DYNAMIC_CONTEXT_FILE_BASENAMES = new Set(["heartbeat.md"]);
const DEFAULT_HEARTBEAT_PROMPT_CONTEXT_BLOCK =
"Default heartbeat prompt:\n`Read HEARTBEAT.md if it exists (workspace context). Follow it strictly. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK.`";
const STATIC_NON_NATIVE_APPROVAL_CHANNELS = new Set(["signal"]);
function normalizeContextFilePath(pathValue: string): string {
return pathValue.trim().replace(/\\/g, "/");
}
@@ -134,6 +132,22 @@ function buildHeartbeatSection(params: { isMinimal: boolean; heartbeatPrompt?: s
];
}
function buildExecApprovalPromptGuidance(params: {
runtimeChannel?: string;
inlineButtonsEnabled?: boolean;
}) {
const runtimeChannel = normalizeOptionalLowercaseString(params.runtimeChannel);
const usesNativeApprovalUi =
params.inlineButtonsEnabled ||
(runtimeChannel
? Boolean(resolveChannelApprovalCapability(getChannelPlugin(runtimeChannel))?.native)
: false);
if (usesNativeApprovalUi) {
return "When exec returns approval-pending on this channel, rely on native approval card/buttons when they appear and do not also send plain chat /approve instructions. Only include the concrete /approve command if the tool result says chat approvals are unavailable or only manual approval is possible.";
}
return "When exec returns approval-pending, include the concrete /approve command from tool output as plain chat text for the user, and do not ask for a different or rotated code.";
}
function buildSkillsSection(params: { skillsPrompt?: string; readToolName: string }) {
const trimmed = params.skillsPrompt?.trim();
if (!trimmed) {
@@ -205,22 +219,83 @@ function buildTimeSection(params: { userTimezone?: string }) {
return ["## Current Date & Time", `Time zone: ${params.userTimezone}`, ""];
}
function buildReplyTagsSection(isMinimal: boolean) {
function buildAssistantOutputDirectivesSection(isMinimal: boolean) {
if (isMinimal) {
return [];
}
return [
"## Reply Tags",
"To request a native reply/quote on supported surfaces, include one tag in your reply:",
"## Assistant Output Directives",
"Use these when you need delivery metadata in an assistant message:",
"- `MEDIA:<path-or-url>` on its own line requests attachment delivery. The web UI strips supported MEDIA lines and renders them inline; channels still decide actual delivery behavior.",
"- `[[audio_as_voice]]` marks attached audio as a voice-note style delivery hint. The web UI may show a voice-note badge when audio is present; channels still own delivery semantics.",
"- To request a native reply/quote on supported surfaces, include one reply tag in your reply:",
"- Reply tags must be the very first token in the message (no leading text/newlines): [[reply_to_current]] your reply.",
"- [[reply_to_current]] replies to the triggering message.",
"- Prefer [[reply_to_current]]. Use [[reply_to:<id>]] only when an id was explicitly provided (e.g. by the user or a tool).",
"Whitespace inside the tag is allowed (e.g. [[ reply_to_current ]] / [[ reply_to: 123 ]]).",
"Tags are removed before sending; support depends on the current channel config.",
"- Channel-specific interactive directives are separate and should not be mixed into this web render guidance.",
"Supported tags are stripped before user-visible rendering; support still depends on the current channel config.",
"",
];
}
function buildWebchatCanvasSection(params: {
isMinimal: boolean;
runtimeChannel?: string;
canvasRootDir?: string;
}) {
if (params.isMinimal || params.runtimeChannel !== "webchat") {
return [];
}
return [
"## Control UI Embed",
"Use `[embed ...]` only in Control UI/webchat sessions for inline rich rendering inside the assistant bubble.",
"- Do not use `[embed ...]` for non-web channels.",
"- `[embed ...]` is separate from `MEDIA:`. Use `MEDIA:` for attachments; use `[embed ...]` for web-only rich rendering.",
'- Use self-closing form for hosted embed documents: `[embed ref="cv_123" title="Status" height="320" /]`.',
'- You may also use an explicit hosted URL: `[embed url="/__openclaw__/canvas/documents/cv_123/index.html" title="Status" height="320" /]`.',
'- Never use local filesystem paths or `file://...` URLs in `[embed ...]`. Hosted embeds must point at `/__openclaw__/canvas/...` URLs or use `ref="..."`.',
params.canvasRootDir
? `- The active hosted embed root for this session is: \`${sanitizeForPromptLiteral(params.canvasRootDir)}\`. If you manually stage a hosted embed file, write it there, not in the workspace.`
: "- The active hosted embed root is profile-scoped, not workspace-scoped. If you manually stage a hosted embed file, write it under the active profile embed root, not in the workspace.",
"- Quote all attribute values. Prefer `ref` for hosted documents unless you already have the full `/__openclaw__/canvas/documents/<id>/index.html` URL.",
"",
];
}
function buildExecutionBiasSection(params: { isMinimal: boolean }) {
if (params.isMinimal) {
return [];
}
return [
"## Execution Bias",
"If the user asks you to do the work, start doing it in the same turn.",
"Use a real tool call or concrete action first when the task is actionable; do not stop at a plan or promise-to-act reply.",
"Commentary-only turns are incomplete when tools are available and the next action is clear.",
"If the work will take multiple steps or a while to finish, send one short progress update before or while acting.",
"",
];
}
function normalizeProviderPromptBlock(value?: string): string | undefined {
if (typeof value !== "string") {
return undefined;
}
const normalized = normalizeStructuredPromptSection(value);
return normalized || undefined;
}
function buildOverridablePromptSection(params: {
override?: string;
fallback: string[];
}): string[] {
const override = normalizeProviderPromptBlock(params.override);
if (override) {
return [override, ""];
}
return params.fallback;
}
function buildMessagingSection(params: {
isMinimal: boolean;
availableTools: Set<string>;
@@ -290,56 +365,6 @@ function buildDocsSection(params: { docsPath?: string; isMinimal: boolean; readT
];
}
function buildExecutionBiasSection(params: { isMinimal: boolean }) {
if (params.isMinimal) {
return [];
}
return [
"## Execution Bias",
"If the user asks you to do the work, start doing it in the same turn.",
"Use a real tool call or concrete action first when the task is actionable; do not stop at a plan or promise-to-act reply.",
"Commentary-only turns are incomplete when tools are available and the next action is clear.",
"If the work will take multiple steps or a while to finish, send one short progress update before or while acting.",
"",
];
}
function normalizeProviderPromptBlock(value?: string): string | undefined {
if (typeof value !== "string") {
return undefined;
}
const normalized = normalizeStructuredPromptSection(value);
return normalized || undefined;
}
function buildOverridablePromptSection(params: {
override?: string;
fallback: string[];
}): string[] {
const override = normalizeProviderPromptBlock(params.override);
if (override) {
return [override, ""];
}
return params.fallback;
}
function buildExecApprovalPromptGuidance(params: {
runtimeChannel?: string;
inlineButtonsEnabled?: boolean;
}) {
const runtimeChannel = normalizeOptionalLowercaseString(params.runtimeChannel);
const usesNativeApprovalUi =
runtimeChannel === "webchat" ||
params.inlineButtonsEnabled === true ||
(runtimeChannel && !STATIC_NON_NATIVE_APPROVAL_CHANNELS.has(runtimeChannel)
? Boolean(resolveChannelApprovalCapability(getChannelPlugin(runtimeChannel))?.native)
: false);
if (usesNativeApprovalUi) {
return "When exec returns approval-pending on this channel, rely on native approval card/buttons when they appear and do not also send plain chat /approve instructions. Only include the concrete /approve command if the tool result says chat approvals are unavailable or only manual approval is possible.";
}
return "When exec returns approval-pending, include the concrete /approve command from tool output as plain chat text for the user, and do not ask for a different or rotated code.";
}
function formatFullAccessBlockedReason(reason?: EmbeddedFullAccessBlockedReason): string {
if (reason === "host-policy") {
return "host policy";
@@ -352,7 +377,6 @@ function formatFullAccessBlockedReason(reason?: EmbeddedFullAccessBlockedReason)
}
return "runtime constraints";
}
export function buildAgentSystemPrompt(params: {
workspaceDir: string;
defaultThinkLevel?: ThinkLevel;
@@ -363,6 +387,7 @@ export function buildAgentSystemPrompt(params: {
ownerDisplaySecret?: string;
reasoningTagHint?: boolean;
toolNames?: string[];
toolSummaries?: Record<string, string>;
modelAliasLines?: string[];
userTimezone?: string;
userTime?: string;
@@ -383,13 +408,13 @@ export function buildAgentSystemPrompt(params: {
os?: string;
arch?: string;
node?: string;
provider?: string;
model?: string;
defaultModel?: string;
shell?: string;
channel?: string;
capabilities?: string[];
repoRoot?: string;
canvasRootDir?: string;
};
messageToolHints?: string[];
sandboxInfo?: EmbeddedSandboxInfo;
@@ -398,7 +423,6 @@ export function buildAgentSystemPrompt(params: {
level: "minimal" | "extensive";
channel: string;
};
/** Whether to include the active memory plugin prompt guidance in the base system prompt. Defaults to true. */
includeMemorySection?: boolean;
memoryCitationsMode?: MemoryCitationsMode;
promptContribution?: ProviderSystemPromptContribution;
@@ -406,12 +430,75 @@ export function buildAgentSystemPrompt(params: {
const acpEnabled = params.acpEnabled !== false;
const sandboxedRuntime = params.sandboxInfo?.enabled === true;
const acpSpawnRuntimeEnabled = acpEnabled && !sandboxedRuntime;
const coreToolSummaries: Record<string, string> = {
read: "Read file contents",
write: "Create or overwrite files",
edit: "Make precise edits to files",
apply_patch: "Apply multi-file patches",
grep: "Search file contents for patterns",
find: "Find files by glob pattern",
ls: "List directory contents",
exec: "Run shell commands (pty available for TTY-required CLIs)",
process: "Manage background exec sessions",
web_search: "Search the web (Brave API)",
web_fetch: "Fetch and extract readable content from a URL",
// Channel docking: add login tools here when a channel needs interactive linking.
browser: "Control web browser",
canvas: "Present/eval/snapshot the Canvas",
nodes: "List/describe/notify/camera/screen on paired nodes",
cron: "Manage cron jobs and wake events (use for reminders; when scheduling a reminder, write the systemEvent text as something that will read like a reminder when it fires, and mention that it is a reminder depending on the time gap between setting and firing; include recent context in reminder text if appropriate)",
message: "Send messages and channel actions",
gateway: "Restart, apply config, or run updates on the running OpenClaw process",
agents_list: acpSpawnRuntimeEnabled
? 'List OpenClaw agent ids allowed for sessions_spawn when runtime="subagent" (not ACP harness ids)'
: "List OpenClaw agent ids allowed for sessions_spawn",
sessions_list: "List other sessions (incl. sub-agents) with filters/last",
sessions_history: "Fetch history for another session/sub-agent",
sessions_send: "Send a message to another session/sub-agent",
sessions_spawn: acpSpawnRuntimeEnabled
? 'Spawn an isolated sub-agent or ACP coding session (runtime="acp" requires `agentId` unless `acp.defaultAgent` is configured; ACP harness ids follow acp.allowedAgents, not agents_list)'
: "Spawn an isolated sub-agent session",
subagents: "List, steer, or kill sub-agent runs for this requester session",
session_status:
"Show a /status-equivalent status card (usage + time + Reasoning/Verbose/Elevated); use for model-use questions (📊 session_status); optional per-session model override",
image: "Analyze an image with the configured image model",
image_generate: "Generate images with the configured image-generation model",
};
const toolOrder = [
"read",
"write",
"edit",
"apply_patch",
"grep",
"find",
"ls",
"exec",
"process",
"web_search",
"web_fetch",
"browser",
"canvas",
"nodes",
"cron",
"message",
"gateway",
"agents_list",
"sessions_list",
"sessions_history",
"sessions_send",
"subagents",
"session_status",
"image",
"image_generate",
];
const rawToolNames = (params.toolNames ?? []).map((tool) => tool.trim());
const canonicalToolNames = rawToolNames.filter(Boolean);
// Preserve caller casing while deduping tool names by lowercase.
const canonicalByNormalized = new Map<string, string>();
for (const name of canonicalToolNames) {
const normalized = normalizeLowercaseStringOrEmpty(name);
const normalized = name.toLowerCase();
if (!canonicalByNormalized.has(normalized)) {
canonicalByNormalized.set(normalized, name);
}
@@ -419,20 +506,38 @@ export function buildAgentSystemPrompt(params: {
const resolveToolName = (normalized: string) =>
canonicalByNormalized.get(normalized) ?? normalized;
const normalizedTools = canonicalToolNames.map((tool) => normalizeLowercaseStringOrEmpty(tool));
const normalizedTools = canonicalToolNames.map((tool) => tool.toLowerCase());
const availableTools = new Set(normalizedTools);
const hasSessionsSpawn = availableTools.has("sessions_spawn");
const hasUpdatePlanTool = availableTools.has("update_plan");
const acpHarnessSpawnAllowed = hasSessionsSpawn && acpSpawnRuntimeEnabled;
const externalToolSummaries = new Map<string, string>();
for (const [key, value] of Object.entries(params.toolSummaries ?? {})) {
const normalized = key.trim().toLowerCase();
if (!normalized || !value?.trim()) {
continue;
}
externalToolSummaries.set(normalized, value.trim());
}
const extraTools = Array.from(
new Set(normalizedTools.filter((tool) => !toolOrder.includes(tool))),
);
const enabledTools = toolOrder.filter((tool) => availableTools.has(tool));
const toolLines = enabledTools.map((tool) => {
const summary = coreToolSummaries[tool] ?? externalToolSummaries.get(tool);
const name = resolveToolName(tool);
return summary ? `- ${name}: ${summary}` : `- ${name}`;
});
for (const tool of extraTools.toSorted()) {
const summary = coreToolSummaries[tool] ?? externalToolSummaries.get(tool);
const name = resolveToolName(tool);
toolLines.push(summary ? `- ${name}: ${summary}` : `- ${name}`);
}
const hasGateway = availableTools.has("gateway");
const hasCronTool = availableTools.has("cron") || canonicalToolNames.length === 0;
const readToolName = resolveToolName("read");
const execToolName = resolveToolName("exec");
const processToolName = resolveToolName("process");
const extraSystemPrompt =
typeof params.extraSystemPrompt === "string"
? normalizeStructuredPromptSection(params.extraSystemPrompt)
: undefined;
const extraSystemPrompt = params.extraSystemPrompt?.trim();
const promptContribution = params.promptContribution;
const providerStablePrefix = normalizeProviderPromptBlock(promptContribution?.stablePrefix);
const providerDynamicSuffix = normalizeProviderPromptBlock(promptContribution?.dynamicSuffix);
@@ -464,14 +569,8 @@ export function buildAgentSystemPrompt(params: {
: undefined;
const reasoningLevel = params.reasoningLevel ?? "off";
const userTimezone = params.userTimezone?.trim();
const skillsPrompt =
typeof params.skillsPrompt === "string"
? normalizeStructuredPromptSection(params.skillsPrompt)
: undefined;
const heartbeatPrompt =
typeof params.heartbeatPrompt === "string"
? normalizeStructuredPromptSection(params.heartbeatPrompt)
: undefined;
const skillsPrompt = params.skillsPrompt?.trim();
const heartbeatPrompt = params.heartbeatPrompt?.trim();
const runtimeInfo = params.runtimeInfo;
const runtimeChannel = normalizeOptionalLowercaseString(runtimeInfo?.channel);
const runtimeCapabilities = runtimeInfo?.capabilities ?? [];
@@ -522,45 +621,41 @@ export function buildAgentSystemPrompt(params: {
isMinimal,
readToolName,
});
const workspaceNotes = (params.workspaceNotes ?? [])
.map((note) => normalizeStructuredPromptSection(note))
.filter(Boolean);
const modelAliasLines = (params.modelAliasLines ?? [])
.map((line) => normalizeStructuredPromptSection(line))
.filter(Boolean);
const workspaceNotes = (params.workspaceNotes ?? []).map((note) => note.trim()).filter(Boolean);
// For "none" mode, return just the basic identity line
if (promptMode === "none") {
return "You are a personal assistant operating inside OpenClaw.";
return "You are a personal assistant running inside OpenClaw.";
}
const lines = [
"You are a personal assistant operating inside OpenClaw.",
"You are a personal assistant running inside OpenClaw.",
"",
"## Tooling",
"Structured tool definitions are the source of truth for tool names, descriptions, and parameters.",
"Tool names are case-sensitive. Call tools exactly as listed in the structured tool definitions.",
"If a tool is present in the structured tool definitions, it is available unless a later tool call reports a policy/runtime restriction.",
"TOOLS.md does not control tool availability; it is user guidance for how to use external tools.",
...(hasCronTool
? [
`For follow-up at a future time (for example "check back in 10 minutes", reminders, run-later work, or recurring tasks), use cron instead of ${execToolName} sleep, yieldMs delays, or ${processToolName} polling.`,
`Use ${execToolName}/${processToolName} only for commands that start now and continue running in the background.`,
`For long-running work that starts now, start it once and rely on automatic completion wake when it is enabled and the command emits output or fails; otherwise use ${processToolName} to confirm completion, and use it for logs, status, input, or intervention.`,
"Do not emulate scheduling with sleep loops, timeout loops, or repeated polling.",
]
"Tool availability (filtered by policy):",
"Tool names are case-sensitive. Call tools exactly as listed.",
toolLines.length > 0
? toolLines.join("\n")
: [
`For long waits, avoid rapid poll loops: use ${execToolName} with enough yieldMs or ${processToolName}(action=poll, timeout=<ms>).`,
`For long-running work that starts now, start it once and rely on automatic completion wake when it is enabled and the command emits output or fails; otherwise use ${processToolName} to confirm completion, and use it for logs, status, input, or intervention.`,
]),
...(hasUpdatePlanTool
? [
"For non-trivial multi-step work, keep a short plan updated with `update_plan`.",
"Skip `update_plan` for simple tasks, obvious one-step fixes, or work you can finish in a few direct actions.",
"When you use `update_plan`, keep exactly one step `in_progress` until the work is done.",
"After calling `update_plan`, continue the work and do not repeat the full plan unless the user asks.",
]
: []),
"Pi lists the standard tools above. This runtime enables:",
"- grep: search file contents for patterns",
"- find: find files by glob pattern",
"- ls: list directory contents",
"- apply_patch: apply multi-file patches",
`- ${execToolName}: run shell commands (supports background via yieldMs/background)`,
`- ${processToolName}: manage background exec sessions`,
"- browser: control OpenClaw's dedicated browser",
"- canvas: present/eval/snapshot the Canvas",
"- nodes: list/describe/notify/camera/screen on paired nodes",
"- cron: manage cron jobs and wake events (use for reminders; when scheduling a reminder, write the systemEvent text as something that will read like a reminder when it fires, and mention that it is a reminder depending on the time gap between setting and firing; include recent context in reminder text if appropriate)",
"- sessions_list: list sessions",
"- sessions_history: fetch session history",
"- sessions_send: send to another session",
"- subagents: list/steer/kill sub-agent runs",
'- session_status: show usage/time/model state and answer "what model are we using?"',
].join("\n"),
"TOOLS.md does not control tool availability; it is user guidance for how to use external tools.",
`For long waits, avoid rapid poll loops: use ${execToolName} with enough yieldMs or ${processToolName}(action=poll, timeout=<ms>).`,
"If a task is more complex or takes longer, spawn a sub-agent. Completion is push-based: it will auto-announce when done.",
...(acpHarnessSpawnAllowed
? [
@@ -624,19 +719,23 @@ export function buildAgentSystemPrompt(params: {
"Get Updates (self-update) is ONLY allowed when the user explicitly asks for it.",
"Do not run config.apply or update.run unless the user explicitly requests an update or config change; if it's not explicit, ask first.",
"Use config.schema.lookup with a specific dot path to inspect only the relevant config subtree before making config changes or answering config-field questions; avoid guessing field names/types.",
"Actions: config.schema.lookup, config.get, config.apply (validate + write full config, then hot-reload or restart as needed), config.patch (partial update, merges with existing), update.run (update deps or git, then restart).",
"Actions: config.schema.lookup, config.get, config.apply (validate + write full config, then restart), config.patch (partial update, merges with existing), update.run (update deps or git, then restart).",
"After restart, OpenClaw pings the last active session automatically.",
].join("\n")
: "",
hasGateway && !isMinimal ? "" : "",
"",
// Skip model aliases for subagent/none modes
modelAliasLines.length > 0 && !isMinimal ? "## Model Aliases" : "",
modelAliasLines.length > 0 && !isMinimal
params.modelAliasLines && params.modelAliasLines.length > 0 && !isMinimal
? "## Model Aliases"
: "",
params.modelAliasLines && params.modelAliasLines.length > 0 && !isMinimal
? "Prefer aliases when specifying model overrides; full provider/model is also accepted."
: "",
modelAliasLines.length > 0 && !isMinimal ? modelAliasLines.join("\n") : "",
modelAliasLines.length > 0 && !isMinimal ? "" : "",
params.modelAliasLines && params.modelAliasLines.length > 0 && !isMinimal
? params.modelAliasLines.join("\n")
: "",
params.modelAliasLines && params.modelAliasLines.length > 0 && !isMinimal ? "" : "",
userTimezone
? "If you need the current date, time, or day of week, run session_status (📊 session_status)."
: "",
@@ -716,7 +815,12 @@ export function buildAgentSystemPrompt(params: {
"## Workspace Files (injected)",
"These user-editable files are loaded by OpenClaw and included below in Project Context.",
"",
...buildReplyTagsSection(isMinimal),
...buildAssistantOutputDirectivesSection(isMinimal),
...buildWebchatCanvasSection({
isMinimal,
runtimeChannel,
canvasRootDir: params.runtimeInfo?.canvasRootDir,
}),
...buildMessagingSection({
isMinimal,
availableTools,
@@ -774,12 +878,10 @@ export function buildAgentSystemPrompt(params: {
if (!isMinimal) {
lines.push(
"## Silent Replies",
`Use ${SILENT_REPLY_TOKEN} ONLY when no user-visible reply is required.`,
`When you have nothing to say, respond with ONLY: ${SILENT_REPLY_TOKEN}`,
"",
"⚠️ Rules:",
"- Valid cases: silent housekeeping, deliberate no-op ambient wakeups, or after a messaging tool already delivered the user-visible reply.",
"- Never use it to avoid doing requested work or to end an actionable turn early.",
"- It must be your ENTIRE message - nothing else",
"- It must be your ENTIRE message — nothing else",
`- Never append it to an actual response (never include "${SILENT_REPLY_TOKEN}" in real replies)`,
"- Never wrap it in markdown or code blocks",
"",

245
src/chat/canvas-render.ts Normal file
View File

@@ -0,0 +1,245 @@
import { parseFenceSpans } from "../markdown/fences.js";
export type CanvasSurface = "assistant_message";
export type CanvasPreview = {
kind: "canvas";
surface: CanvasSurface;
render: "url";
title?: string;
preferredHeight?: number;
url?: string;
viewId?: string;
className?: string;
style?: string;
};
function tryParseJsonRecord(value: string | undefined): Record<string, unknown> | undefined {
if (typeof value !== "string") {
return undefined;
}
try {
const parsed = JSON.parse(value);
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
? (parsed as Record<string, unknown>)
: undefined;
} catch {
return undefined;
}
}
function getRecordStringField(
record: Record<string, unknown> | undefined,
key: string,
): string | undefined {
const value = record?.[key];
return typeof value === "string" && value.trim() ? value : undefined;
}
function getRecordNumberField(
record: Record<string, unknown> | undefined,
key: string,
): number | undefined {
const value = record?.[key];
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
}
function getNestedRecord(
record: Record<string, unknown> | undefined,
key: string,
): Record<string, unknown> | undefined {
const value = record?.[key];
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: undefined;
}
function normalizeSurface(value: string | undefined): CanvasSurface | undefined {
return value === "assistant_message" ? value : undefined;
}
function normalizePreferredHeight(value: number | undefined): number | undefined {
return typeof value === "number" && Number.isFinite(value) && value >= 160
? Math.min(Math.trunc(value), 1200)
: undefined;
}
function coerceCanvasPreview(
record: Record<string, unknown> | undefined,
): CanvasPreview | undefined {
if (!record) {
return undefined;
}
const kind = getRecordStringField(record, "kind")?.trim().toLowerCase();
if (kind !== "canvas") {
return undefined;
}
const presentation = getNestedRecord(record, "presentation");
const view = getNestedRecord(record, "view");
const source = getNestedRecord(record, "source");
const requestedSurface =
getRecordStringField(presentation, "target") ?? getRecordStringField(record, "target");
const surface = requestedSurface ? normalizeSurface(requestedSurface) : "assistant_message";
if (!surface) {
return undefined;
}
const title = getRecordStringField(presentation, "title") ?? getRecordStringField(view, "title");
const preferredHeight = normalizePreferredHeight(
getRecordNumberField(presentation, "preferred_height") ??
getRecordNumberField(presentation, "preferredHeight") ??
getRecordNumberField(view, "preferred_height") ??
getRecordNumberField(view, "preferredHeight"),
);
const className =
getRecordStringField(presentation, "class_name") ??
getRecordStringField(presentation, "className");
const style = getRecordStringField(presentation, "style");
const viewUrl = getRecordStringField(view, "url") ?? getRecordStringField(view, "entryUrl");
const viewId = getRecordStringField(view, "id") ?? getRecordStringField(view, "docId");
if (viewUrl) {
return {
kind: "canvas",
surface,
render: "url",
url: viewUrl,
...(viewId ? { viewId } : {}),
...(title ? { title } : {}),
...(preferredHeight ? { preferredHeight } : {}),
...(className ? { className } : {}),
...(style ? { style } : {}),
};
}
const sourceType = getRecordStringField(source, "type")?.trim().toLowerCase();
if (sourceType === "url") {
const url = getRecordStringField(source, "url");
if (!url) {
return undefined;
}
return {
kind: "canvas",
surface,
render: "url",
url,
...(title ? { title } : {}),
...(preferredHeight ? { preferredHeight } : {}),
...(className ? { className } : {}),
...(style ? { style } : {}),
};
}
return undefined;
}
function parseCanvasAttributes(raw: string): Record<string, string> {
const attrs: Record<string, string> = {};
const re = /([A-Za-z_][A-Za-z0-9_-]*)\s*=\s*(?:"([^"]*)"|'([^']*)')/g;
let match: RegExpExecArray | null;
while ((match = re.exec(raw))) {
const key = match[1]?.trim().toLowerCase();
const value = (match[2] ?? match[3] ?? "").trim();
if (key && value) {
attrs[key] = value;
}
}
return attrs;
}
function defaultCanvasEntryUrl(ref: string): string {
const encoded = encodeURIComponent(ref.trim());
return `/__openclaw__/canvas/documents/${encoded}/index.html`;
}
function previewFromShortcode(attrs: Record<string, string>): CanvasPreview | undefined {
if (attrs.target && normalizeSurface(attrs.target) !== "assistant_message") {
return undefined;
}
const surface = "assistant_message";
const title = attrs.title?.trim() || undefined;
const preferredHeight =
attrs.height && Number.isFinite(Number(attrs.height))
? normalizePreferredHeight(Number(attrs.height))
: undefined;
const className = attrs.class?.trim() || attrs.class_name?.trim() || undefined;
const style = attrs.style?.trim() || undefined;
const ref = attrs.ref?.trim();
const url = attrs.url?.trim();
if (url || ref) {
return {
kind: "canvas",
surface,
render: "url",
url: url ?? defaultCanvasEntryUrl(ref),
...(ref ? { viewId: ref } : {}),
...(title ? { title } : {}),
...(preferredHeight ? { preferredHeight } : {}),
...(className ? { className } : {}),
...(style ? { style } : {}),
};
}
return undefined;
}
export function extractCanvasFromText(
outputText: string | undefined,
_toolName?: string,
): CanvasPreview | undefined {
const parsed = tryParseJsonRecord(outputText);
return coerceCanvasPreview(parsed);
}
export function extractCanvasShortcodes(text: string | undefined): {
text: string;
previews: CanvasPreview[];
} {
if (!text?.trim() || !text.toLowerCase().includes("[embed")) {
return { text: text ?? "", previews: [] };
}
const fenceSpans = parseFenceSpans(text);
const matches: Array<{
start: number;
end: number;
attrs: Record<string, string>;
body?: string;
}> = [];
const blockRe = /\[embed\s+([^\]]*?)\]([\s\S]*?)\[\/embed\]/gi;
const selfClosingRe = /\[embed\s+([^\]]*?)\/\]/gi;
for (const re of [blockRe, selfClosingRe]) {
let match: RegExpExecArray | null;
while ((match = re.exec(text))) {
const start = match.index ?? 0;
if (fenceSpans.some((span) => start >= span.start && start < span.end)) {
continue;
}
matches.push({
start,
end: start + match[0].length,
attrs: parseCanvasAttributes(match[1] ?? ""),
...(match[2] !== undefined ? { body: match[2] } : {}),
});
}
}
if (matches.length === 0) {
return { text, previews: [] };
}
matches.sort((a, b) => a.start - b.start);
const previews: CanvasPreview[] = [];
let cursor = 0;
let stripped = "";
for (const match of matches) {
if (match.start < cursor) {
continue;
}
stripped += text.slice(cursor, match.start);
const preview = previewFromShortcode(match.attrs);
if (!preview) {
stripped += text.slice(match.start, match.end);
} else {
previews.push(preview);
}
cursor = match.end;
}
stripped += text.slice(cursor);
return {
text: stripped.replace(/\n{3,}/g, "\n\n").trim(),
previews,
};
}

View File

@@ -1,9 +1,7 @@
import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js";
export type ToolContentBlock = Record<string, unknown>;
export function normalizeToolContentType(value: unknown): string {
return normalizeLowercaseStringOrEmpty(value);
return typeof value === "string" ? value.toLowerCase() : "";
}
export function isToolCallContentType(value: unknown): boolean {

View File

@@ -93,6 +93,58 @@ describe("ui.seamColor", () => {
});
});
describe("gateway.controlUi.embedSandbox", () => {
it("accepts strict, scripts, and trusted modes", () => {
for (const mode of ["strict", "scripts", "trusted"] as const) {
const result = OpenClawSchema.safeParse({
gateway: {
controlUi: {
embedSandbox: mode,
},
},
});
expect(result.success).toBe(true);
}
});
it("rejects unsupported values", () => {
const result = OpenClawSchema.safeParse({
gateway: {
controlUi: {
embedSandbox: "yolo",
},
},
});
expect(result.success).toBe(false);
});
});
describe("gateway.controlUi.allowExternalEmbedUrls", () => {
it("accepts boolean values", () => {
for (const value of [true, false]) {
const result = OpenClawSchema.safeParse({
gateway: {
controlUi: {
allowExternalEmbedUrls: value,
},
},
});
expect(result.success).toBe(true);
}
});
it("rejects non-boolean values", () => {
const result = OpenClawSchema.safeParse({
gateway: {
controlUi: {
allowExternalEmbedUrls: "yes",
},
},
});
expect(result.success).toBe(false);
});
});
describe("plugins.entries.*.hooks.allowPromptInjection", () => {
it("accepts boolean values", () => {
const result = OpenClawSchema.safeParse({

View File

@@ -20586,6 +20586,31 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = {
description:
"Optional filesystem root for Control UI assets (defaults to dist/control-ui).",
},
embedSandbox: {
anyOf: [
{
type: "string",
const: "strict",
},
{
type: "string",
const: "scripts",
},
{
type: "string",
const: "trusted",
},
],
title: "Control UI Embed Sandbox Mode",
description:
'Iframe sandbox policy for hosted Control UI embeds. "strict" disables scripts, "scripts" allows interactive embeds while keeping origin isolation (default), and "trusted" adds `allow-same-origin` for same-site documents that intentionally need stronger privileges.',
},
allowExternalEmbedUrls: {
type: "boolean",
title: "Allow External Control UI Embed URLs",
description:
"DANGEROUS toggle that allows hosted embeds to load absolute external http(s) URLs. Keep this off unless your Control UI intentionally embeds trusted third-party pages; hosted /__openclaw__/canvas and /__openclaw__/a2ui documents do not need it.",
},
allowedOrigins: {
type: "array",
items: {
@@ -24073,6 +24098,16 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = {
placeholder: "dist/control-ui",
tags: ["network"],
},
"gateway.controlUi.embedSandbox": {
label: "Control UI Embed Sandbox Mode",
help: 'Iframe sandbox policy for hosted Control UI embeds. "strict" disables scripts, "scripts" allows interactive embeds while keeping origin isolation (default), and "trusted" adds `allow-same-origin` for same-site documents that intentionally need stronger privileges.',
tags: ["security", "access", "advanced"],
},
"gateway.controlUi.allowExternalEmbedUrls": {
label: "Allow External Control UI Embed URLs",
help: "DANGEROUS toggle that allows hosted embeds to load absolute external http(s) URLs. Keep this off unless your Control UI intentionally embeds trusted third-party pages; hosted /__openclaw__/canvas and /__openclaw__/a2ui documents do not need it.",
tags: ["security", "access", "network", "advanced"],
},
"gateway.controlUi.allowedOrigins": {
label: "Control UI Allowed Origins",
help: 'Allowed browser origins for Control UI/WebChat websocket connections (full origins only, e.g. https://control.example.com). Required for non-loopback Control UI deployments unless dangerous Host-header fallback is explicitly enabled. Setting ["*"] means allow any browser origin and should be avoided outside tightly controlled local testing.',

View File

@@ -118,6 +118,7 @@ const TARGET_KEYS = [
"gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback",
"gateway.controlUi.allowInsecureAuth",
"gateway.controlUi.dangerouslyDisableDeviceAuth",
"gateway.controlUi.embedSandbox",
"cron",
"cron.enabled",
"cron.store",
@@ -306,6 +307,7 @@ const TARGET_KEYS = [
"discovery.wideArea.enabled",
"discovery.mdns",
"discovery.mdns.mode",
"gateway.controlUi.embedSandbox",
"canvasHost",
"canvasHost.enabled",
"canvasHost.root",

View File

@@ -380,6 +380,10 @@ export const FIELD_HELP: Record<string, string> = {
"Optional URL prefix where the Control UI is served (e.g. /openclaw).",
"gateway.controlUi.root":
"Optional filesystem root for Control UI assets (defaults to dist/control-ui).",
"gateway.controlUi.embedSandbox":
'Iframe sandbox policy for hosted Control UI embeds. "strict" disables scripts, "scripts" allows interactive embeds while keeping origin isolation (default), and "trusted" adds `allow-same-origin` for same-site documents that intentionally need stronger privileges.',
"gateway.controlUi.allowExternalEmbedUrls":
"DANGEROUS toggle that allows hosted embeds to load absolute external http(s) URLs. Keep this off unless your Control UI intentionally embeds trusted third-party pages; hosted /__openclaw__/canvas and /__openclaw__/a2ui documents do not need it.",
"gateway.controlUi.allowedOrigins":
'Allowed browser origins for Control UI/WebChat websocket connections (full origins only, e.g. https://control.example.com). Required for non-loopback Control UI deployments unless dangerous Host-header fallback is explicitly enabled. Setting ["*"] means allow any browser origin and should be avoided outside tightly controlled local testing.',
"gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback":

View File

@@ -265,6 +265,8 @@ export const FIELD_LABELS: Record<string, string> = {
"Web Fetch Allow RFC 2544 Benchmark Range",
"gateway.controlUi.basePath": "Control UI Base Path",
"gateway.controlUi.root": "Control UI Assets Root",
"gateway.controlUi.embedSandbox": "Control UI Embed Sandbox Mode",
"gateway.controlUi.allowExternalEmbedUrls": "Allow External Control UI Embed URLs",
"gateway.controlUi.allowedOrigins": "Control UI Allowed Origins",
"gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback":
"Dangerously Allow Host-Header Origin Fallback",

View File

@@ -43,6 +43,8 @@ const TAG_OVERRIDES: Record<string, ConfigTag[]> = {
"gateway.auth.token": ["security", "auth", "access", "network"],
"gateway.auth.password": ["security", "auth", "access", "network"],
"gateway.push.apns.relay.baseUrl": ["network", "advanced"],
"gateway.controlUi.embedSandbox": ["security", "access", "advanced"],
"gateway.controlUi.allowExternalEmbedUrls": ["security", "access", "network", "advanced"],
"gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback": [
"security",
"access",

View File

@@ -85,6 +85,18 @@ export type GatewayControlUiConfig = {
basePath?: string;
/** Optional filesystem root for Control UI assets (defaults to dist/control-ui). */
root?: string;
/**
* Embed sandbox mode for hosted Control UI previews.
* - strict: no script execution inside embeds
* - scripts: allow scripts while keeping embeds origin-isolated (default)
* - trusted: allow scripts and same-origin privileges
*/
embedSandbox?: "strict" | "scripts" | "trusted";
/**
* DANGEROUS: Allow hosted embeds to load absolute external http(s) URLs.
* Default off; prefer hosted /__openclaw__/canvas or /__openclaw__/a2ui content.
*/
allowExternalEmbedUrls?: boolean;
/** Allowed browser origins for Control UI/WebChat websocket connections. */
allowedOrigins?: string[];
/**

View File

@@ -680,6 +680,10 @@ export const OpenClawSchema = z
enabled: z.boolean().optional(),
basePath: z.string().optional(),
root: z.string().optional(),
embedSandbox: z
.union([z.literal("strict"), z.literal("scripts"), z.literal("trusted")])
.optional(),
allowExternalEmbedUrls: z.boolean().optional(),
allowedOrigins: z.array(z.string()).optional(),
dangerouslyAllowHostHeaderOriginFallback: z.boolean().optional(),
allowInsecureAuth: z.boolean().optional(),

View File

@@ -0,0 +1,242 @@
import { mkdtemp, mkdir, writeFile, readFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
buildCanvasDocumentEntryUrl,
createCanvasDocument,
resolveCanvasDocumentAssets,
resolveCanvasDocumentDir,
resolveCanvasHttpPathToLocalPath,
} from "./canvas-documents.js";
const tempDirs: string[] = [];
afterEach(async () => {
await Promise.all(
tempDirs.splice(0).map(async (dir) => {
await import("node:fs/promises").then((fs) => fs.rm(dir, { recursive: true, force: true }));
}),
);
});
describe("canvas documents", () => {
it("builds entry urls for materialized path documents under managed storage", async () => {
const stateDir = await mkdtemp(path.join(tmpdir(), "openclaw-canvas-documents-"));
tempDirs.push(stateDir);
const workspaceDir = await mkdtemp(path.join(tmpdir(), "openclaw-canvas-documents-workspace-"));
tempDirs.push(workspaceDir);
await mkdir(path.join(workspaceDir, "player"), { recursive: true });
await writeFile(path.join(workspaceDir, "player/index.html"), "<div>ok</div>", "utf8");
const document = await createCanvasDocument(
{
kind: "html_bundle",
entrypoint: {
type: "path",
value: "player/index.html",
},
},
{ stateDir, workspaceDir },
);
expect(document.entryUrl).toContain("/__openclaw__/canvas/documents/");
expect(document.localEntrypoint).toBe("index.html");
expect(resolveCanvasDocumentDir(document.id, { stateDir })).toContain(stateDir);
});
it("normalizes nested local entrypoint urls", () => {
const url = buildCanvasDocumentEntryUrl("cv_example", "collection.media/index.html");
expect(url).toBe("/__openclaw__/canvas/documents/cv_example/collection.media/index.html");
});
it("encodes special characters in hosted entrypoint path segments", () => {
const url = buildCanvasDocumentEntryUrl("cv_example", "bundle#1/entry%20point?.html");
expect(url).toBe(
"/__openclaw__/canvas/documents/cv_example/bundle%231/entry%2520point%3F.html",
);
});
it("materializes inline html bundles as index documents", async () => {
const stateDir = await mkdtemp(path.join(tmpdir(), "openclaw-canvas-documents-"));
tempDirs.push(stateDir);
const document = await createCanvasDocument(
{
kind: "html_bundle",
title: "Preview",
entrypoint: {
type: "html",
value:
"<!doctype html><html><head><style>.demo{color:red}</style></head><body><div class='demo'>Front</div></body></html>",
},
},
{ stateDir },
);
const indexHtml = await import("node:fs/promises").then((fs) =>
fs.readFile(
path.join(resolveCanvasDocumentDir(document.id, { stateDir }), "index.html"),
"utf8",
),
);
expect(indexHtml).toContain("<div class='demo'>Front</div>");
expect(indexHtml).toContain("<style>.demo{color:red}</style>");
expect(document.title).toBe("Preview");
expect(document.entryUrl).toBe(`/__openclaw__/canvas/documents/${document.id}/index.html`);
});
it("reuses a supplied stable document id by replacing the prior materialized view", async () => {
const stateDir = await mkdtemp(path.join(tmpdir(), "openclaw-canvas-documents-"));
tempDirs.push(stateDir);
const first = await createCanvasDocument(
{
id: "status-card",
kind: "html_bundle",
entrypoint: { type: "html", value: "<div>first</div>" },
},
{ stateDir },
);
const second = await createCanvasDocument(
{
id: "status-card",
kind: "html_bundle",
entrypoint: { type: "html", value: "<div>second</div>" },
},
{ stateDir },
);
expect(first.id).toBe("status-card");
expect(second.id).toBe("status-card");
const indexHtml = await import("node:fs/promises").then((fs) =>
fs.readFile(
path.join(resolveCanvasDocumentDir(second.id, { stateDir }), "index.html"),
"utf8",
),
);
expect(indexHtml).toContain("second");
expect(indexHtml).not.toContain("first");
});
it("exposes stable managed asset urls for copied canvas assets", async () => {
const stateDir = await mkdtemp(path.join(tmpdir(), "openclaw-canvas-documents-"));
tempDirs.push(stateDir);
const workspaceDir = await mkdtemp(path.join(tmpdir(), "openclaw-canvas-documents-workspace-"));
tempDirs.push(workspaceDir);
await mkdir(path.join(workspaceDir, "collection.media"), { recursive: true });
await writeFile(path.join(workspaceDir, "collection.media/audio.mp3"), "audio", "utf8");
const document = await createCanvasDocument(
{
kind: "html_bundle",
entrypoint: {
type: "html",
value:
'<audio controls><source src="collection.media/audio.mp3" type="audio/mpeg" /></audio>',
},
assets: [
{
logicalPath: "collection.media/audio.mp3",
sourcePath: "collection.media/audio.mp3",
contentType: "audio/mpeg",
},
],
},
{ stateDir, workspaceDir },
);
expect(resolveCanvasDocumentAssets(document, { stateDir })).toEqual([
{
logicalPath: "collection.media/audio.mp3",
contentType: "audio/mpeg",
localPath: path.join(
resolveCanvasDocumentDir(document.id, { stateDir }),
"collection.media/audio.mp3",
),
url: `/__openclaw__/canvas/documents/${document.id}/collection.media/audio.mp3`,
},
]);
expect(
resolveCanvasDocumentAssets(document, {
baseUrl: "http://127.0.0.1:19003",
stateDir,
}),
).toEqual([
{
logicalPath: "collection.media/audio.mp3",
contentType: "audio/mpeg",
localPath: path.join(
resolveCanvasDocumentDir(document.id, { stateDir }),
"collection.media/audio.mp3",
),
url: `http://127.0.0.1:19003/__openclaw__/canvas/documents/${document.id}/collection.media/audio.mp3`,
},
]);
});
it("wraps local pdf documents in an index viewer page", async () => {
const stateDir = await mkdtemp(path.join(tmpdir(), "openclaw-canvas-documents-"));
tempDirs.push(stateDir);
const workspaceDir = await mkdtemp(path.join(tmpdir(), "openclaw-canvas-documents-workspace-"));
tempDirs.push(workspaceDir);
await writeFile(path.join(workspaceDir, "demo.pdf"), "%PDF-1.4", "utf8");
const document = await createCanvasDocument(
{
kind: "document",
entrypoint: {
type: "path",
value: "demo.pdf",
},
},
{ stateDir, workspaceDir },
);
expect(document.entryUrl).toBe(`/__openclaw__/canvas/documents/${document.id}/index.html`);
const indexHtml = await readFile(
path.join(resolveCanvasDocumentDir(document.id, { stateDir }), "index.html"),
"utf8",
);
expect(indexHtml).toContain('type="application/pdf"');
expect(indexHtml).toContain('data="demo.pdf"');
});
it("wraps remote pdf urls in an index viewer page", async () => {
const stateDir = await mkdtemp(path.join(tmpdir(), "openclaw-canvas-documents-"));
tempDirs.push(stateDir);
const document = await createCanvasDocument(
{
kind: "document",
entrypoint: {
type: "url",
value: "https://example.com/demo.pdf",
},
},
{ stateDir },
);
expect(document.entryUrl).toBe(`/__openclaw__/canvas/documents/${document.id}/index.html`);
const indexHtml = await readFile(
path.join(resolveCanvasDocumentDir(document.id, { stateDir }), "index.html"),
"utf8",
);
expect(indexHtml).toContain('type="application/pdf"');
expect(indexHtml).toContain('data="https://example.com/demo.pdf"');
});
it("rejects traversal-style document ids in hosted canvas paths", async () => {
const stateDir = await mkdtemp(path.join(tmpdir(), "openclaw-canvas-documents-"));
tempDirs.push(stateDir);
expect(
resolveCanvasHttpPathToLocalPath(
"/__openclaw__/canvas/documents/../collection.media/index.html",
{ stateDir },
),
).toBeNull();
});
});

View File

@@ -0,0 +1,347 @@
import { randomUUID } from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import { CANVAS_HOST_PATH } from "../canvas-host/a2ui.js";
import { resolveStateDir } from "../config/paths.js";
import { resolveUserPath } from "../utils.js";
export type CanvasDocumentKind = "html_bundle" | "url_embed" | "document" | "image" | "video_asset";
export type CanvasDocumentAsset = {
logicalPath: string;
sourcePath: string;
contentType?: string;
};
export type CanvasDocumentEntrypoint =
| { type: "html"; value: string }
| { type: "path"; value: string }
| { type: "url"; value: string };
export type CanvasDocumentCreateInput = {
id?: string;
kind: CanvasDocumentKind;
title?: string;
preferredHeight?: number;
entrypoint?: CanvasDocumentEntrypoint;
assets?: CanvasDocumentAsset[];
surface?: "assistant_message" | "tool_card" | "sidebar";
};
export type CanvasDocumentManifest = {
id: string;
kind: CanvasDocumentKind;
title?: string;
preferredHeight?: number;
createdAt: string;
entryUrl: string;
localEntrypoint?: string;
externalUrl?: string;
surface?: "assistant_message" | "tool_card" | "sidebar";
assets: Array<{
logicalPath: string;
contentType?: string;
}>;
};
export type CanvasDocumentResolvedAsset = {
logicalPath: string;
contentType?: string;
url: string;
localPath: string;
};
const CANVAS_DOCUMENTS_DIR_NAME = "documents";
function isPdfPathLike(value: string): boolean {
return /\.pdf(?:[?#].*)?$/i.test(value.trim());
}
function buildPdfWrapper(url: string): string {
const escaped = escapeHtml(url);
return `<!doctype html><html><body style="margin:0;background:#e5e7eb;"><object data="${escaped}" type="application/pdf" style="width:100%;height:100vh;border:0;"><iframe src="${escaped}" style="width:100%;height:100vh;border:0;"></iframe><p style="padding:16px;font:14px system-ui,sans-serif;">Unable to render PDF preview. <a href="${escaped}" target="_blank" rel="noopener noreferrer">Open PDF</a>.</p></object></body></html>`;
}
function escapeHtml(value: string): string {
return value
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
function normalizeLogicalPath(value: string): string {
const normalized = value.replaceAll("\\", "/").replace(/^\/+/, "");
const parts = normalized.split("/").filter(Boolean);
if (parts.length === 0 || parts.some((part) => part === "." || part === "..")) {
throw new Error("canvas document logicalPath invalid");
}
return parts.join("/");
}
function canvasDocumentId(): string {
return `cv_${randomUUID().replaceAll("-", "")}`;
}
function normalizeCanvasDocumentId(value: string): string {
const normalized = value.trim();
if (
!normalized ||
normalized === "." ||
normalized === ".." ||
!/^[A-Za-z0-9._-]+$/.test(normalized)
) {
throw new Error("canvas document id invalid");
}
return normalized;
}
export function resolveCanvasRootDir(rootDir?: string, stateDir = resolveStateDir()): string {
const resolved = rootDir?.trim() ? resolveUserPath(rootDir) : path.join(stateDir, "canvas");
return path.resolve(resolved);
}
export function resolveCanvasDocumentsDir(rootDir?: string, stateDir = resolveStateDir()): string {
return path.join(resolveCanvasRootDir(rootDir, stateDir), CANVAS_DOCUMENTS_DIR_NAME);
}
export function resolveCanvasDocumentDir(
documentId: string,
options?: { rootDir?: string; stateDir?: string },
): string {
return path.join(resolveCanvasDocumentsDir(options?.rootDir, options?.stateDir), documentId);
}
export function buildCanvasDocumentEntryUrl(documentId: string, entrypoint: string): string {
const normalizedEntrypoint = normalizeLogicalPath(entrypoint);
const encodedEntrypoint = normalizedEntrypoint
.split("/")
.map((segment) => encodeURIComponent(segment))
.join("/");
return `${CANVAS_HOST_PATH}/${CANVAS_DOCUMENTS_DIR_NAME}/${encodeURIComponent(documentId)}/${encodedEntrypoint}`;
}
export function buildCanvasDocumentAssetUrl(documentId: string, logicalPath: string): string {
return buildCanvasDocumentEntryUrl(documentId, logicalPath);
}
export function resolveCanvasHttpPathToLocalPath(
requestPath: string,
options?: { rootDir?: string; stateDir?: string },
): string | null {
const trimmed = requestPath.trim();
const prefix = `${CANVAS_HOST_PATH}/${CANVAS_DOCUMENTS_DIR_NAME}/`;
if (!trimmed.startsWith(prefix)) {
return null;
}
const pathWithoutQuery = trimmed.replace(/[?#].*$/, "");
const relative = pathWithoutQuery.slice(prefix.length);
const segments = relative
.split("/")
.map((segment) => {
try {
return decodeURIComponent(segment);
} catch {
return segment;
}
})
.filter(Boolean);
if (segments.length < 2) {
return null;
}
const [rawDocumentId, ...entrySegments] = segments;
try {
const documentId = normalizeCanvasDocumentId(rawDocumentId);
const normalizedEntrypoint = normalizeLogicalPath(entrySegments.join("/"));
const documentsDir = path.resolve(
resolveCanvasDocumentsDir(options?.rootDir, options?.stateDir),
);
const candidatePath = path.resolve(
resolveCanvasDocumentDir(documentId, options),
normalizedEntrypoint,
);
if (
!(candidatePath === documentsDir || candidatePath.startsWith(`${documentsDir}${path.sep}`))
) {
return null;
}
return candidatePath;
} catch {
return null;
}
}
async function writeManifest(rootDir: string, manifest: CanvasDocumentManifest): Promise<void> {
await fs.writeFile(
path.join(rootDir, "manifest.json"),
`${JSON.stringify(manifest, null, 2)}\n`,
"utf8",
);
}
async function copyAssets(
rootDir: string,
assets: CanvasDocumentAsset[] | undefined,
workspaceDir: string,
): Promise<CanvasDocumentManifest["assets"]> {
const copied: CanvasDocumentManifest["assets"] = [];
for (const asset of assets ?? []) {
const logicalPath = normalizeLogicalPath(asset.logicalPath);
const sourcePath = asset.sourcePath.startsWith("~")
? resolveUserPath(asset.sourcePath)
: path.isAbsolute(asset.sourcePath)
? path.resolve(asset.sourcePath)
: path.resolve(workspaceDir, asset.sourcePath);
const destination = path.join(rootDir, logicalPath);
await fs.mkdir(path.dirname(destination), { recursive: true });
await fs.copyFile(sourcePath, destination);
copied.push({
logicalPath,
...(asset.contentType ? { contentType: asset.contentType } : {}),
});
}
return copied;
}
async function materializeEntrypoint(
rootDir: string,
input: CanvasDocumentCreateInput,
workspaceDir: string,
): Promise<Pick<CanvasDocumentManifest, "entryUrl" | "localEntrypoint" | "externalUrl">> {
const entrypoint = input.entrypoint;
if (!entrypoint) {
throw new Error("canvas document entrypoint required");
}
if (entrypoint.type === "html") {
const fileName = "index.html";
await fs.writeFile(path.join(rootDir, fileName), entrypoint.value, "utf8");
return {
localEntrypoint: fileName,
entryUrl: buildCanvasDocumentEntryUrl(path.basename(rootDir), fileName),
};
}
if (entrypoint.type === "url") {
if (input.kind === "document" && isPdfPathLike(entrypoint.value)) {
const fileName = "index.html";
await fs.writeFile(path.join(rootDir, fileName), buildPdfWrapper(entrypoint.value), "utf8");
return {
localEntrypoint: fileName,
externalUrl: entrypoint.value,
entryUrl: buildCanvasDocumentEntryUrl(path.basename(rootDir), fileName),
};
}
return {
externalUrl: entrypoint.value,
entryUrl: entrypoint.value,
};
}
const resolvedPath = entrypoint.value.startsWith("~")
? resolveUserPath(entrypoint.value)
: path.isAbsolute(entrypoint.value)
? path.resolve(entrypoint.value)
: path.resolve(workspaceDir, entrypoint.value);
if (input.kind === "image" || input.kind === "video_asset") {
const copiedName = path.basename(resolvedPath);
await fs.copyFile(resolvedPath, path.join(rootDir, copiedName));
const wrapper =
input.kind === "image"
? `<!doctype html><html><body style="margin:0;background:#0f172a;display:flex;align-items:center;justify-content:center;"><img src="${escapeHtml(copiedName)}" style="max-width:100%;max-height:100vh;object-fit:contain;" /></body></html>`
: `<!doctype html><html><body style="margin:0;background:#0f172a;"><video src="${escapeHtml(copiedName)}" controls autoplay style="width:100%;height:100vh;object-fit:contain;background:#000;"></video></body></html>`;
await fs.writeFile(path.join(rootDir, "index.html"), wrapper, "utf8");
return {
localEntrypoint: "index.html",
entryUrl: buildCanvasDocumentEntryUrl(path.basename(rootDir), "index.html"),
};
}
const fileName = path.basename(resolvedPath);
await fs.copyFile(resolvedPath, path.join(rootDir, fileName));
if (input.kind === "document" && isPdfPathLike(fileName)) {
await fs.writeFile(path.join(rootDir, "index.html"), buildPdfWrapper(fileName), "utf8");
return {
localEntrypoint: "index.html",
entryUrl: buildCanvasDocumentEntryUrl(path.basename(rootDir), "index.html"),
};
}
return {
localEntrypoint: fileName,
entryUrl: buildCanvasDocumentEntryUrl(path.basename(rootDir), fileName),
};
}
export async function createCanvasDocument(
input: CanvasDocumentCreateInput,
options?: { stateDir?: string; workspaceDir?: string; canvasRootDir?: string },
): Promise<CanvasDocumentManifest> {
const workspaceDir = options?.workspaceDir ?? process.cwd();
const id = input.id?.trim() ? normalizeCanvasDocumentId(input.id) : canvasDocumentId();
const rootDir = resolveCanvasDocumentDir(id, {
stateDir: options?.stateDir,
rootDir: options?.canvasRootDir,
});
await fs.rm(rootDir, { recursive: true, force: true }).catch(() => undefined);
await fs.mkdir(rootDir, { recursive: true });
const assets = await copyAssets(rootDir, input.assets, workspaceDir);
const entry = await materializeEntrypoint(rootDir, input, workspaceDir);
const manifest: CanvasDocumentManifest = {
id,
kind: input.kind,
...(input.title?.trim() ? { title: input.title.trim() } : {}),
...(typeof input.preferredHeight === "number"
? { preferredHeight: input.preferredHeight }
: {}),
...(input.surface ? { surface: input.surface } : {}),
createdAt: new Date().toISOString(),
entryUrl: entry.entryUrl,
...(entry.localEntrypoint ? { localEntrypoint: entry.localEntrypoint } : {}),
...(entry.externalUrl ? { externalUrl: entry.externalUrl } : {}),
assets,
};
await writeManifest(rootDir, manifest);
return manifest;
}
export async function loadCanvasDocumentManifest(
documentId: string,
options?: { stateDir?: string; canvasRootDir?: string },
): Promise<CanvasDocumentManifest | null> {
const id = normalizeCanvasDocumentId(documentId);
const manifestPath = path.join(
resolveCanvasDocumentDir(id, {
stateDir: options?.stateDir,
rootDir: options?.canvasRootDir,
}),
"manifest.json",
);
try {
const raw = await fs.readFile(manifestPath, "utf8");
const parsed = JSON.parse(raw);
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
? (parsed as CanvasDocumentManifest)
: null;
} catch {
return null;
}
}
export function resolveCanvasDocumentAssets(
manifest: CanvasDocumentManifest,
options?: { baseUrl?: string; stateDir?: string; canvasRootDir?: string },
): CanvasDocumentResolvedAsset[] {
const baseUrl = options?.baseUrl?.trim().replace(/\/+$/, "");
const documentDir = resolveCanvasDocumentDir(manifest.id, {
stateDir: options?.stateDir,
rootDir: options?.canvasRootDir,
});
return manifest.assets.map((asset) => ({
logicalPath: asset.logicalPath,
...(asset.contentType ? { contentType: asset.contentType } : {}),
localPath: path.join(documentDir, asset.logicalPath),
url: baseUrl
? `${baseUrl}${buildCanvasDocumentAssetUrl(manifest.id, asset.logicalPath)}`
: buildCanvasDocumentAssetUrl(manifest.id, asset.logicalPath),
}));
}

View File

@@ -1,7 +1,14 @@
export const CONTROL_UI_BOOTSTRAP_CONFIG_PATH = "/__openclaw/control-ui-config.json";
export type ControlUiEmbedSandboxMode = "strict" | "scripts" | "trusted";
export type ControlUiBootstrapConfig = {
basePath: string;
assistantName: string;
assistantAvatar: string;
assistantAgentId: string;
serverVersion?: string;
localMediaPreviewRoots?: string[];
embedSandbox?: ControlUiEmbedSandboxMode;
allowExternalEmbedUrls?: boolean;
};

View File

@@ -4,8 +4,14 @@ import type { IncomingMessage } from "node:http";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import type { ResolvedGatewayAuth } from "./auth.js";
import { CONTROL_UI_BOOTSTRAP_CONFIG_PATH } from "./control-ui-contract.js";
import { handleControlUiAvatarRequest, handleControlUiHttpRequest } from "./control-ui.js";
import {
handleControlUiAssistantMediaRequest,
handleControlUiAvatarRequest,
handleControlUiHttpRequest,
} from "./control-ui.js";
import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js";
import { makeMockHttpResponse } from "./test-http-response.js";
describe("handleControlUiHttpRequest", () => {
@@ -27,6 +33,8 @@ describe("handleControlUiHttpRequest", () => {
basePath: string;
assistantName: string;
assistantAvatar: string;
assistantAgentId: string;
localMediaPreviewRoots?: string[];
};
}
@@ -77,6 +85,33 @@ describe("handleControlUiHttpRequest", () => {
return { res, end, handled };
}
async function runAssistantMediaRequest(params: {
url: string;
method: "GET" | "HEAD";
basePath?: string;
auth?: ResolvedGatewayAuth;
headers?: IncomingMessage["headers"];
trustedProxies?: string[];
remoteAddress?: string;
}) {
const { res, end } = makeMockHttpResponse();
const handled = await handleControlUiAssistantMediaRequest(
{
url: params.url,
method: params.method,
headers: params.headers ?? {},
socket: { remoteAddress: params.remoteAddress ?? "127.0.0.1" },
} as IncomingMessage,
res,
{
...(params.basePath ? { basePath: params.basePath } : {}),
...(params.auth ? { auth: params.auth } : {}),
...(params.trustedProxies ? { trustedProxies: params.trustedProxies } : {}),
},
);
return { res, end, handled };
}
async function writeAssetFile(rootPath: string, filename: string, contents: string) {
const assetsDir = path.join(rootPath, "assets");
await fs.mkdir(assetsDir, { recursive: true });
@@ -92,6 +127,18 @@ describe("handleControlUiHttpRequest", () => {
return hardlinkPath;
}
async function withAllowedAssistantMediaRoot<T>(params: {
prefix: string;
fn: (tmpRoot: string) => Promise<T>;
}) {
const tmpRoot = await fs.mkdtemp(path.join(resolvePreferredOpenClawTmpDir(), params.prefix));
try {
return await params.fn(tmpRoot);
} finally {
await fs.rm(tmpRoot, { recursive: true, force: true });
}
}
async function withBasePathRootFixture<T>(params: {
siblingDir: string;
fn: (paths: { root: string; sibling: string }) => Promise<T>;
@@ -131,6 +178,122 @@ describe("handleControlUiHttpRequest", () => {
});
});
it("serves assistant local media through the control ui media route", async () => {
await withAllowedAssistantMediaRoot({
prefix: "ui-media-",
fn: async (tmpRoot) => {
const filePath = path.join(tmpRoot, "photo.png");
await fs.writeFile(filePath, Buffer.from("not-a-real-png"));
const { res, handled } = await runAssistantMediaRequest({
url: `/__openclaw__/assistant-media?source=${encodeURIComponent(filePath)}&token=test-token`,
method: "GET",
auth: { mode: "token", token: "test-token", allowTailscale: false },
});
expect(handled).toBe(true);
expect(res.statusCode).toBe(200);
},
});
});
it("rejects assistant local media outside allowed preview roots", async () => {
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-ui-media-blocked-"));
try {
const filePath = path.join(tmp, "photo.png");
await fs.writeFile(filePath, Buffer.from("not-a-real-png"));
const { res, handled, end } = await runAssistantMediaRequest({
url: `/__openclaw__/assistant-media?source=${encodeURIComponent(filePath)}&token=test-token`,
method: "GET",
auth: { mode: "token", token: "test-token", allowTailscale: false },
});
expectNotFoundResponse({ handled, res, end });
} finally {
await fs.rm(tmp, { recursive: true, force: true });
}
});
it("reports assistant local media availability metadata", async () => {
await withAllowedAssistantMediaRoot({
prefix: "ui-media-meta-",
fn: async (tmpRoot) => {
const filePath = path.join(tmpRoot, "photo.png");
await fs.writeFile(filePath, Buffer.from("not-a-real-png"));
const { res, handled, end } = await runAssistantMediaRequest({
url: `/__openclaw__/assistant-media?meta=1&source=${encodeURIComponent(filePath)}&token=test-token`,
method: "GET",
auth: { mode: "token", token: "test-token", allowTailscale: false },
});
expect(handled).toBe(true);
expect(res.statusCode).toBe(200);
expect(JSON.parse(String(end.mock.calls[0]?.[0] ?? ""))).toEqual({ available: true });
},
});
});
it("reports assistant local media availability failures with a reason", async () => {
const { res, handled, end } = await runAssistantMediaRequest({
url: `/__openclaw__/assistant-media?meta=1&source=${encodeURIComponent("/Users/test/Documents/private.pdf")}&token=test-token`,
method: "GET",
auth: { mode: "token", token: "test-token", allowTailscale: false },
});
expect(handled).toBe(true);
expect(res.statusCode).toBe(200);
expect(JSON.parse(String(end.mock.calls[0]?.[0] ?? ""))).toEqual({
available: false,
code: "outside-allowed-folders",
reason: "Outside allowed folders",
});
});
it("rejects assistant local media without a valid auth token when auth is enabled", async () => {
await withAllowedAssistantMediaRoot({
prefix: "ui-media-auth-",
fn: async (tmpRoot) => {
const filePath = path.join(tmpRoot, "photo.png");
await fs.writeFile(filePath, Buffer.from("not-a-real-png"));
const { res, handled, end } = await runAssistantMediaRequest({
url: `/__openclaw__/assistant-media?source=${encodeURIComponent(filePath)}`,
method: "GET",
auth: { mode: "token", token: "test-token", allowTailscale: false },
});
expect(handled).toBe(true);
expect(res.statusCode).toBe(401);
expect(String(end.mock.calls[0]?.[0] ?? "")).toContain("Unauthorized");
},
});
});
it("rejects trusted-proxy assistant media requests from disallowed browser origins", async () => {
await withAllowedAssistantMediaRoot({
prefix: "ui-media-proxy-",
fn: async (tmpRoot) => {
const filePath = path.join(tmpRoot, "photo.png");
await fs.writeFile(filePath, Buffer.from("not-a-real-png"));
const { res, handled, end } = await runAssistantMediaRequest({
url: `/__openclaw__/assistant-media?source=${encodeURIComponent(filePath)}`,
method: "GET",
auth: {
mode: "trusted-proxy",
allowTailscale: false,
trustedProxy: {
userHeader: "x-forwarded-user",
},
},
trustedProxies: ["10.0.0.1"],
remoteAddress: "10.0.0.1",
headers: {
host: "gateway.example.com",
origin: "https://evil.example",
"x-forwarded-user": "nick@example.com",
"x-forwarded-proto": "https",
},
});
expect(handled).toBe(true);
expect(res.statusCode).toBe(401);
expect(String(end.mock.calls[0]?.[0] ?? "")).toContain("Unauthorized");
},
});
});
it("includes CSP hash for inline scripts in index.html", async () => {
const scriptContent = "(function(){ var x = 1; })();";
const html = `<html><head><script>${scriptContent}</script></head><body></body></html>\n`;
@@ -195,8 +358,8 @@ describe("handleControlUiHttpRequest", () => {
expect(parsed.basePath).toBe("");
expect(parsed.assistantName).toBe("</script><script>alert(1)//");
expect(parsed.assistantAvatar).toBe("/avatar/main");
expect(parsed).not.toHaveProperty("assistantAgentId");
expect(parsed).not.toHaveProperty("serverVersion");
expect(parsed.assistantAgentId).toBe("main");
expect(Array.isArray(parsed.localMediaPreviewRoots)).toBe(true);
},
});
});
@@ -222,8 +385,8 @@ describe("handleControlUiHttpRequest", () => {
expect(parsed.basePath).toBe("/openclaw");
expect(parsed.assistantName).toBe("Ops");
expect(parsed.assistantAvatar).toBe("/openclaw/avatar/main");
expect(parsed).not.toHaveProperty("assistantAgentId");
expect(parsed).not.toHaveProperty("serverVersion");
expect(parsed.assistantAgentId).toBe("main");
expect(Array.isArray(parsed.localMediaPreviewRoots)).toBe(true);
},
});
});

View File

@@ -7,11 +7,19 @@ import {
isPackageProvenControlUiRootSync,
resolveControlUiRootSync,
} from "../infra/control-ui-assets.js";
import { openLocalFileSafely, SafeOpenError } from "../infra/fs-safe.js";
import { safeFileURLToPath } from "../infra/local-file-access.js";
import { isWithinDir } from "../infra/path-safety.js";
import { openVerifiedFileSync } from "../infra/safe-open-sync.js";
import { assertLocalMediaAllowed, getDefaultLocalRoots } from "../media/local-media-access.js";
import { getAgentScopedMediaLocalRoots } from "../media/local-roots.js";
import { detectMime } from "../media/mime.js";
import { AVATAR_MAX_BYTES } from "../shared/avatar-policy.js";
import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js";
import { resolveUserPath } from "../utils.js";
import { resolveRuntimeServiceVersion } from "../version.js";
import { DEFAULT_ASSISTANT_IDENTITY, resolveAssistantIdentity } from "./assistant-identity.js";
import type { AuthRateLimiter } from "./auth-rate-limit.js";
import { authorizeHttpGatewayConnect, type ResolvedGatewayAuth } from "./auth.js";
import {
CONTROL_UI_BOOTSTRAP_CONFIG_PATH,
type ControlUiBootstrapConfig,
@@ -29,8 +37,11 @@ import {
normalizeControlUiBasePath,
resolveAssistantAvatarUrl,
} from "./control-ui-shared.js";
import { sendGatewayAuthFailure } from "./http-common.js";
import { getBearerToken, resolveHttpBrowserOriginPolicy } from "./http-utils.js";
const ROOT_PREFIX = "/";
const CONTROL_UI_ASSISTANT_MEDIA_PREFIX = "/__openclaw__/assistant-media";
const CONTROL_UI_ASSETS_MISSING_MESSAGE =
"Control UI assets not found. Build them with `pnpm ui:build` (auto-installs UI deps), or run `pnpm ui:dev` during development.";
@@ -153,6 +164,218 @@ function isValidAgentId(agentId: string): boolean {
return /^[a-z0-9][a-z0-9_-]{0,63}$/i.test(agentId);
}
function normalizeAssistantMediaSource(source: string): string | null {
const trimmed = source.trim();
if (!trimmed) {
return null;
}
if (trimmed.startsWith("file://")) {
try {
return safeFileURLToPath(trimmed);
} catch {
return null;
}
}
if (trimmed.startsWith("~")) {
return resolveUserPath(trimmed);
}
return trimmed;
}
function resolveAssistantMediaRoutePath(basePath?: string): string {
const normalizedBasePath =
basePath && basePath !== "/" ? (basePath.endsWith("/") ? basePath.slice(0, -1) : basePath) : "";
return `${normalizedBasePath}${CONTROL_UI_ASSISTANT_MEDIA_PREFIX}`;
}
function resolveAssistantMediaAuthToken(req: IncomingMessage): string | undefined {
const bearer = getBearerToken(req);
if (bearer) {
return bearer;
}
const urlRaw = req.url;
if (!urlRaw) {
return undefined;
}
try {
const url = new URL(urlRaw, "http://localhost");
const token = url.searchParams.get("token")?.trim();
return token || undefined;
} catch {
return undefined;
}
}
type AssistantMediaAvailability =
| { available: true }
| { available: false; reason: string; code: string };
function classifyAssistantMediaError(err: unknown): AssistantMediaAvailability {
if (err instanceof SafeOpenError) {
switch (err.code) {
case "not-found":
return { available: false, code: "file-not-found", reason: "File not found" };
case "not-file":
return { available: false, code: "not-a-file", reason: "Not a file" };
case "invalid-path":
case "path-mismatch":
case "symlink":
return { available: false, code: "invalid-file", reason: "Invalid file" };
default:
return {
available: false,
code: "attachment-unavailable",
reason: "Attachment unavailable",
};
}
}
if (err instanceof Error && "code" in err) {
const errorCode = (err as { code?: unknown }).code;
switch (typeof errorCode === "string" ? errorCode : "") {
case "path-not-allowed":
return {
available: false,
code: "outside-allowed-folders",
reason: "Outside allowed folders",
};
case "invalid-file-url":
case "invalid-path":
case "unsafe-bypass":
case "network-path-not-allowed":
case "invalid-root":
return { available: false, code: "blocked-local-file", reason: "Blocked local file" };
case "not-found":
return { available: false, code: "file-not-found", reason: "File not found" };
case "not-file":
return { available: false, code: "not-a-file", reason: "Not a file" };
default:
break;
}
}
return { available: false, code: "attachment-unavailable", reason: "Attachment unavailable" };
}
async function resolveAssistantMediaAvailability(
source: string,
localRoots: readonly string[],
): Promise<AssistantMediaAvailability> {
try {
await assertLocalMediaAllowed(source, localRoots);
const opened = await openLocalFileSafely({ filePath: source });
await opened.handle.close();
return { available: true };
} catch (err) {
return classifyAssistantMediaError(err);
}
}
export async function handleControlUiAssistantMediaRequest(
req: IncomingMessage,
res: ServerResponse,
opts?: {
basePath?: string;
config?: OpenClawConfig;
agentId?: string;
auth?: ResolvedGatewayAuth;
trustedProxies?: string[];
allowRealIpFallback?: boolean;
rateLimiter?: AuthRateLimiter;
},
): Promise<boolean> {
const urlRaw = req.url;
if (!urlRaw || !isReadHttpMethod(req.method)) {
return false;
}
const url = new URL(urlRaw, "http://localhost");
if (url.pathname !== resolveAssistantMediaRoutePath(opts?.basePath)) {
return false;
}
applyControlUiSecurityHeaders(res);
if (opts?.auth) {
const token = resolveAssistantMediaAuthToken(req);
const authResult = await authorizeHttpGatewayConnect({
auth: opts.auth,
connectAuth: token ? { token, password: token } : null,
req,
browserOriginPolicy: resolveHttpBrowserOriginPolicy(req),
trustedProxies: opts.trustedProxies,
allowRealIpFallback: opts.allowRealIpFallback,
rateLimiter: opts.rateLimiter,
});
if (!authResult.ok) {
sendGatewayAuthFailure(res, authResult);
return true;
}
}
const source = normalizeAssistantMediaSource(url.searchParams.get("source") ?? "");
if (!source) {
respondControlUiNotFound(res);
return true;
}
const localRoots = opts?.config
? getAgentScopedMediaLocalRoots(opts.config, opts.agentId)
: getDefaultLocalRoots();
if (url.searchParams.get("meta") === "1") {
const availability = await resolveAssistantMediaAvailability(source, localRoots);
sendJson(res, 200, availability);
return true;
}
let opened: Awaited<ReturnType<typeof openLocalFileSafely>> | null = null;
let handleClosed = false;
const closeOpenedHandle = async () => {
if (!opened || handleClosed) {
return;
}
handleClosed = true;
await opened.handle.close().catch(() => {});
};
try {
await assertLocalMediaAllowed(source, localRoots);
opened = await openLocalFileSafely({ filePath: source });
const sniffLength = Math.min(opened.stat.size, 8192);
const sniffBuffer = sniffLength > 0 ? Buffer.allocUnsafe(sniffLength) : undefined;
const bytesRead =
sniffBuffer && sniffLength > 0
? (await opened.handle.read(sniffBuffer, 0, sniffLength, 0)).bytesRead
: 0;
const mime = await detectMime({
buffer: sniffBuffer?.subarray(0, bytesRead),
filePath: source,
});
if (mime) {
res.setHeader("Content-Type", mime);
} else {
res.setHeader("Content-Type", "application/octet-stream");
}
res.setHeader("Cache-Control", "no-cache");
res.setHeader("Content-Length", String(opened.stat.size));
const stream = opened.handle.createReadStream({ start: 0, autoClose: false });
const finishClose = () => {
void closeOpenedHandle();
};
stream.once("end", finishClose);
stream.once("close", finishClose);
stream.once("error", () => {
void closeOpenedHandle();
if (!res.headersSent) {
respondControlUiNotFound(res);
} else {
res.destroy();
}
});
res.once("close", finishClose);
stream.pipe(res);
return true;
} catch {
await closeOpenedHandle();
respondControlUiNotFound(res);
return true;
}
}
export function handleControlUiAvatarRequest(
req: IncomingMessage,
res: ServerResponse,
@@ -221,7 +444,7 @@ export function handleControlUiAvatarRequest(
}
function setStaticFileHeaders(res: ServerResponse, filePath: string) {
const ext = normalizeLowercaseStringOrEmpty(path.extname(filePath));
const ext = path.extname(filePath).toLowerCase();
res.setHeader("Content-Type", contentTypeForExt(ext));
// Static UI should never be cached aggressively while iterating; allow the
// browser to revalidate.
@@ -365,6 +588,16 @@ export function handleControlUiHttpRequest(
basePath,
assistantName: identity.name,
assistantAvatar: avatarValue ?? identity.avatar,
assistantAgentId: identity.agentId,
serverVersion: resolveRuntimeServiceVersion(process.env),
localMediaPreviewRoots: [...getAgentScopedMediaLocalRoots(config ?? {}, identity.agentId)],
embedSandbox:
config?.gateway?.controlUi?.embedSandbox === "trusted"
? "trusted"
: config?.gateway?.controlUi?.embedSandbox === "strict"
? "strict"
: "scripts",
allowExternalEmbedUrls: config?.gateway?.controlUi?.allowExternalEmbedUrls === true,
} satisfies ControlUiBootstrapConfig);
return true;
}
@@ -463,7 +696,7 @@ export function handleControlUiHttpRequest(
// against the same set of extensions that contentTypeForExt() recognises so
// that dotted SPA routes (e.g. /user/jane.doe, /v2.0) still get the
// client-side router fallback.
if (STATIC_ASSET_EXTENSIONS.has(normalizeLowercaseStringOrEmpty(path.extname(fileRel)))) {
if (STATIC_ASSET_EXTENSIONS.has(path.extname(fileRel).toLowerCase())) {
respondControlUiNotFound(res);
return true;
}

View File

@@ -8,6 +8,7 @@ import {
withGatewayServer,
} from "./server-http.test-harness.js";
import type { ReadinessChecker } from "./server/readiness.js";
import { withTempConfig } from "./test-temp-config.js";
describe("gateway OpenAI-compatible disabled HTTP routes", () => {
it("returns 404 when compat endpoints are disabled", async () => {
@@ -112,6 +113,57 @@ describe("gateway probe endpoints", () => {
});
});
it("hides readiness details when trusted-proxy auth violates browser origin policy", async () => {
const getReadiness: ReadinessChecker = () => ({
ready: false,
failing: ["discord", "telegram"],
uptimeMs: 8_000,
});
await withTempConfig({
prefix: "probe-remote-origin-rejected",
cfg: {
gateway: {
trustedProxies: ["10.0.0.1"],
controlUi: {
allowedOrigins: ["https://control.example"],
},
},
},
run: async () => {
await withGatewayServer({
prefix: "probe-remote-origin-rejected-server",
resolvedAuth: {
mode: "trusted-proxy",
allowTailscale: false,
trustedProxy: { userHeader: "x-forwarded-user" },
},
overrides: {
getReadiness,
},
run: async (server) => {
const req = createRequest({
path: "/ready",
remoteAddress: "10.0.0.1",
host: "gateway.test",
headers: {
origin: "https://evil.example",
forwarded: "for=203.0.113.10;proto=https;host=gateway.test",
"x-forwarded-user": "user@example.com",
"x-forwarded-proto": "https",
},
});
const { res, getBody } = createResponse();
await dispatchRequest(server, req, res);
expect(res.statusCode).toBe(503);
expect(JSON.parse(getBody())).toEqual({ ready: false });
},
});
},
});
});
it("returns typed internal error payload when readiness evaluation throws", async () => {
const getReadiness: ReadinessChecker = () => {
throw new Error("boom");

View File

@@ -19,7 +19,7 @@ describe("runGatewayHttpRequestStages", () => {
expect(await runGatewayHttpRequestStages(stages)).toBe(false);
});
it("skips a throwing stage and continues to subsequent stages", async () => {
it("skips a throwing stage marked continueOnError and continues to subsequent stages", async () => {
const stageC = vi.fn(() => true);
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
@@ -27,6 +27,7 @@ describe("runGatewayHttpRequestStages", () => {
{ name: "a", run: () => false },
{
name: "broken-facade",
continueOnError: true,
run: () => {
throw new Error("Cannot find module '@slack/bolt'");
},
@@ -46,13 +47,14 @@ describe("runGatewayHttpRequestStages", () => {
consoleSpy.mockRestore();
});
it("skips a rejecting async stage and continues", async () => {
it("skips a rejecting async stage marked continueOnError and continues", async () => {
const stageC = vi.fn(() => true);
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
const stages = [
{
name: "async-broken",
continueOnError: true,
run: async () => {
throw new Error("ERR_MODULE_NOT_FOUND");
},
@@ -72,9 +74,7 @@ describe("runGatewayHttpRequestStages", () => {
consoleSpy.mockRestore();
});
it("returns false when the only non-throwing stages do not handle", async () => {
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
it("rethrows when a stage throws without continueOnError", async () => {
const stages = [
{
name: "broken",
@@ -85,10 +85,6 @@ describe("runGatewayHttpRequestStages", () => {
{ name: "unmatched", run: () => false },
];
const result = await runGatewayHttpRequestStages(stages);
expect(result).toBe(false);
consoleSpy.mockRestore();
await expect(runGatewayHttpRequestStages(stages)).rejects.toThrow("load failed");
});
});

View File

@@ -17,7 +17,7 @@ import type { OpenClawConfig } from "../config/types.openclaw.js";
import type { createSubsystemLogger } from "../logging/subsystem.js";
import { resolveHookExternalContentSource as resolveHookExternalContentSourceFromSession } from "../security/external-content.js";
import { safeEqualSecret } from "../security/secret-equal.js";
import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js";
import { resolveAssistantIdentity } from "./assistant-identity.js";
import {
AUTH_RATE_LIMIT_SCOPE_HOOK_AUTH,
createAuthRateLimiter,
@@ -32,6 +32,7 @@ import {
} from "./auth.js";
import { normalizeCanvasScopedUrl } from "./canvas-capability.js";
import {
handleControlUiAssistantMediaRequest,
handleControlUiAvatarRequest,
handleControlUiHttpRequest,
type ControlUiRootState,
@@ -100,7 +101,8 @@ function resolveMappedHookExternalContentSource(params: {
payload: Record<string, unknown>;
sessionKey: string;
}) {
const payloadSource = normalizeLowercaseStringOrEmpty(params.payload.source);
const payloadSource =
typeof params.payload.source === "string" ? params.payload.source.trim().toLowerCase() : "";
if (params.subPath === "gmail" || payloadSource === "gmail") {
return "gmail" as const;
}
@@ -265,25 +267,12 @@ function writeUpgradeAuthFailure(
socket.write("HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n");
}
function writeUpgradeServiceUnavailable(
socket: { write: (chunk: string) => void },
responseBody: string,
) {
socket.write(
"HTTP/1.1 503 Service Unavailable\r\n" +
"Connection: close\r\n" +
"Content-Type: text/plain; charset=utf-8\r\n" +
`Content-Length: ${Buffer.byteLength(responseBody, "utf8")}\r\n` +
"\r\n" +
responseBody,
);
}
export type HooksRequestHandler = (req: IncomingMessage, res: ServerResponse) => Promise<boolean>;
type GatewayHttpRequestStage = {
name: string;
run: () => Promise<boolean> | boolean;
continueOnError?: boolean;
};
export async function runGatewayHttpRequestStages(
@@ -295,10 +284,12 @@ export async function runGatewayHttpRequestStages(
return true;
}
} catch (err) {
if (!stage.continueOnError) {
throw err;
}
// Log and skip the failing stage so subsequent stages (control-ui,
// gateway-probes, etc.) remain reachable. A common trigger is a
// plugin-owned route/runtime code can still fail to load when an
// optional dependency is missing. Keep later stages reachable.
// gateway-probes, etc.) remain reachable. A common trigger is a
// plugin-owned route/runtime code still failing to load an optional dependency.
console.error(`[gateway-http] stage "${stage.name}" threw — skipping:`, err);
}
}
@@ -362,6 +353,7 @@ function buildPluginRequestStages(params: {
},
{
name: "plugin-http",
continueOnError: true,
run: () => {
const pathContext =
params.pluginPathContext ?? resolvePluginRoutePathContext(params.requestPath);
@@ -798,7 +790,7 @@ export function createGatewayHttpServer(opts: {
});
// Don't interfere with WebSocket upgrades; ws handles the 'upgrade' event.
if (normalizeLowercaseStringOrEmpty(req.headers.upgrade) === "websocket") {
if ((req.headers.upgrade ?? "").toLowerCase() === "websocket") {
return;
}
@@ -958,6 +950,19 @@ export function createGatewayHttpServer(opts: {
);
if (controlUiEnabled) {
requestStages.push({
name: "control-ui-assistant-media",
run: () =>
handleControlUiAssistantMediaRequest(req, res, {
basePath: controlUiBasePath,
config: configSnapshot,
agentId: resolveAssistantIdentity({ cfg: configSnapshot }).agentId,
auth: resolvedAuth,
trustedProxies,
allowRealIpFallback,
rateLimiter,
}),
});
requestStages.push({
name: "control-ui-avatar",
run: () =>
@@ -973,6 +978,7 @@ export function createGatewayHttpServer(opts: {
handleControlUiHttpRequest(req, res, {
basePath: controlUiBasePath,
config: configSnapshot,
agentId: resolveAssistantIdentity({ cfg: configSnapshot }).agentId,
root: controlUiRoot,
}),
});
@@ -1067,15 +1073,29 @@ export function attachGatewayUpgradeHandler(opts: {
}
}
const preauthBudgetKey = resolveRequestClientIp(req, trustedProxies, allowRealIpFallback);
// Keep startup upgrades inside the pre-auth budget until WS handlers attach.
if (!preauthConnectionBudget.acquire(preauthBudgetKey)) {
writeUpgradeServiceUnavailable(socket, "Too many unauthenticated sockets");
if (wss.listenerCount("connection") === 0) {
const responseBody = "Gateway websocket handlers unavailable";
socket.write(
"HTTP/1.1 503 Service Unavailable\r\n" +
"Connection: close\r\n" +
"Content-Type: text/plain; charset=utf-8\r\n" +
`Content-Length: ${Buffer.byteLength(responseBody, "utf8")}\r\n` +
"\r\n" +
responseBody,
);
socket.destroy();
return;
}
if (wss.listenerCount("connection") === 0) {
preauthConnectionBudget.release(preauthBudgetKey);
writeUpgradeServiceUnavailable(socket, "Gateway websocket handlers unavailable");
if (!preauthConnectionBudget.acquire(preauthBudgetKey)) {
const responseBody = "Too many unauthenticated sockets";
socket.write(
"HTTP/1.1 503 Service Unavailable\r\n" +
"Connection: close\r\n" +
"Content-Type: text/plain; charset=utf-8\r\n" +
`Content-Length: ${Buffer.byteLength(responseBody, "utf8")}\r\n` +
"\r\n" +
responseBody,
);
socket.destroy();
return;
}

View File

@@ -9,15 +9,18 @@ import {
GATEWAY_CLIENT_MODES,
GATEWAY_CLIENT_NAMES,
} from "../protocol/client-info.js";
import type { ModelCatalogEntry } from "../../agents/model-catalog.types.js";
import { ErrorCodes } from "../protocol/index.js";
import { CHAT_SEND_SESSION_KEY_MAX_LENGTH } from "../protocol/schema/primitives.js";
import type { GatewayRequestContext } from "./types.js";
const mockState = vi.hoisted(() => ({
config: {} as Record<string, unknown>,
transcriptPath: "",
sessionId: "sess-1",
mainSessionKey: "main",
finalText: "[[reply_to_current]]",
finalPayload: null as { text?: string; mediaUrl?: string } | null,
dispatchedReplies: [] as Array<{
kind: "tool" | "block" | "final";
payload: { text?: string; mediaUrl?: string; mediaUrls?: string[] };
@@ -28,6 +31,8 @@ const mockState = vi.hoisted(() => ({
sessionEntry: {} as Record<string, unknown>,
lastDispatchCtx: undefined as MsgContext | undefined,
lastDispatchImages: undefined as Array<{ mimeType: string; data: string }> | undefined,
lastDispatchImageOrder: undefined as string[] | undefined,
modelCatalog: null as ModelCatalogEntry[] | null,
emittedTranscriptUpdates: [] as Array<{
sessionFile: string;
sessionKey?: string;
@@ -35,6 +40,7 @@ const mockState = vi.hoisted(() => ({
messageId?: string;
}>,
savedMediaResults: [] as Array<{ path: string; contentType?: string }>,
saveMediaError: null as Error | null,
savedMediaCalls: [] as Array<{ contentType?: string; subdir?: string; size: number }>,
saveMediaWait: null as Promise<void> | null,
activeSaveMediaCalls: 0,
@@ -60,7 +66,9 @@ vi.mock("../session-utils.js", async () => {
? { canonicalKey: mockState.sessionEntry.canonicalKey }
: {}),
cfg: {
...mockState.config,
session: {
...(mockState.config.session as Record<string, unknown> | undefined),
mainKey: mockState.mainSessionKey,
},
},
@@ -83,7 +91,7 @@ vi.mock("../../auto-reply/dispatch.js", () => ({
async (params: {
ctx: MsgContext;
dispatcher: {
sendFinalReply: (payload: { text: string }) => boolean;
sendFinalReply: (payload: { text?: string; mediaUrl?: string }) => boolean;
sendBlockReply: (payload: {
text?: string;
mediaUrl?: string;
@@ -100,10 +108,12 @@ vi.mock("../../auto-reply/dispatch.js", () => ({
replyOptions?: {
onAgentRunStart?: (runId: string) => void;
images?: Array<{ mimeType: string; data: string }>;
imageOrder?: string[];
};
}) => {
mockState.lastDispatchCtx = params.ctx;
mockState.lastDispatchImages = params.replyOptions?.images;
mockState.lastDispatchImageOrder = params.replyOptions?.imageOrder;
if (mockState.dispatchError) {
throw mockState.dispatchError;
}
@@ -125,7 +135,7 @@ vi.mock("../../auto-reply/dispatch.js", () => ({
});
}
} else {
params.dispatcher.sendFinalReply({ text: mockState.finalText });
params.dispatcher.sendFinalReply(mockState.finalPayload ?? { text: mockState.finalText });
}
params.dispatcher.markComplete();
await params.dispatcher.waitForIdle();
@@ -161,6 +171,10 @@ vi.mock("../../media/store.js", async () => {
if (mockState.saveMediaWait) {
await mockState.saveMediaWait;
}
if (mockState.saveMediaError) {
mockState.activeSaveMediaCalls -= 1;
throw mockState.saveMediaError;
}
mockState.savedMediaCalls.push({ contentType, subdir, size: buffer.byteLength });
const next = mockState.savedMediaResults.shift();
try {
@@ -267,6 +281,7 @@ function createChatContext(): Pick<
| "chatAbortControllers"
| "chatRunBuffers"
| "chatDeltaSentAt"
| "chatDeltaLastBroadcastLen"
| "chatAbortedRuns"
| "removeChatRun"
| "dedupe"
@@ -281,23 +296,25 @@ function createChatContext(): Pick<
chatAbortControllers: new Map(),
chatRunBuffers: new Map(),
chatDeltaSentAt: new Map(),
chatDeltaLastBroadcastLen: new Map(),
chatAbortedRuns: new Map(),
removeChatRun: vi.fn(),
dedupe: new Map(),
loadGatewayModelCatalog: async () => [
{
provider: "openai",
id: "gpt-5.4",
name: "GPT-5.4",
input: ["text", "image"],
},
{
provider: "anthropic",
id: "claude-opus-4-6",
name: "Claude Opus 4.6",
input: ["text", "image"],
},
],
loadGatewayModelCatalog: async () =>
mockState.modelCatalog ?? [
{
provider: "openai",
id: "gpt-5.4",
name: "GPT-5.4",
input: ["text", "image"],
},
{
provider: "anthropic",
id: "claude-opus-4-6",
name: "Claude Opus 4.6",
input: ["text", "image"],
},
],
registerToolEventRecipient: vi.fn(),
logGateway: {
warn: vi.fn(),
@@ -380,7 +397,9 @@ async function runNonStreamingChatSend(params: {
describe("chat directive tag stripping for non-streaming final payloads", () => {
afterEach(() => {
mockState.config = {};
mockState.finalText = "[[reply_to_current]]";
mockState.finalPayload = null;
mockState.dispatchedReplies = [];
mockState.dispatchError = null;
mockState.mainSessionKey = "main";
@@ -389,8 +408,11 @@ describe("chat directive tag stripping for non-streaming final payloads", () =>
mockState.sessionEntry = {};
mockState.lastDispatchCtx = undefined;
mockState.lastDispatchImages = undefined;
mockState.lastDispatchImageOrder = undefined;
mockState.modelCatalog = null;
mockState.emittedTranscriptUpdates = [];
mockState.savedMediaResults = [];
mockState.saveMediaError = null;
mockState.savedMediaCalls = [];
mockState.saveMediaWait = null;
mockState.activeSaveMediaCalls = 0;
@@ -1669,6 +1691,78 @@ describe("chat directive tag stripping for non-streaming final payloads", () =>
});
});
it("preserves offloaded attachment media paths in transcript order", async () => {
createTranscriptFixture("openclaw-chat-send-user-transcript-offloaded-");
mockState.finalText = "ok";
mockState.triggerAgentRunStart = true;
mockState.sessionEntry = {
modelProvider: "test-provider",
model: "vision-model",
};
mockState.modelCatalog = [
{
provider: "test-provider",
id: "vision-model",
name: "Vision model",
input: ["text", "image"],
},
];
mockState.savedMediaResults = [
{ path: "/tmp/offloaded-big.png", contentType: "image/png" },
{ path: "/tmp/chat-send-inline.png", contentType: "image/png" },
];
const respond = vi.fn();
const context = createChatContext();
const bigPng = Buffer.alloc(2_100_000);
bigPng.set([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a], 0);
await runNonStreamingChatSend({
context,
respond,
idempotencyKey: "idem-user-transcript-offloaded",
message: "edit both",
requestParams: {
attachments: [
{
mimeType: "image/png",
content:
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aYoYAAAAASUVORK5CYII=",
},
{
mimeType: "image/png",
content: bigPng.toString("base64"),
},
],
},
expectBroadcast: false,
waitForCompletion: false,
});
await waitForAssertion(() => {
const userUpdate = mockState.emittedTranscriptUpdates.find(
(update) =>
typeof update.message === "object" &&
update.message !== null &&
(update.message as { role?: unknown }).role === "user",
);
const message = userUpdate?.message as
| {
MediaPath?: string;
MediaPaths?: string[];
MediaType?: string;
MediaTypes?: string[];
}
| undefined;
expect(message?.MediaPath).toBe("/tmp/chat-send-inline.png");
expect(message?.MediaPaths).toEqual([
"/tmp/chat-send-inline.png",
"/tmp/offloaded-big.png",
]);
expect(message?.MediaType).toBe("image/png");
expect(message?.MediaTypes).toEqual(["image/png", "image/png"]);
});
});
it("skips transcript media notes for ACP bridge clients", async () => {
createTranscriptFixture("openclaw-chat-send-user-transcript-acp-images-");
mockState.finalText = "ok";
@@ -1765,6 +1859,224 @@ describe("chat directive tag stripping for non-streaming final payloads", () =>
});
});
it("preserves media-only final replies in the final broadcast message", async () => {
createTranscriptFixture("openclaw-chat-send-media-only-final-");
mockState.finalPayload = { mediaUrl: "https://example.com/final.png" };
const respond = vi.fn();
const context = createChatContext();
const payload = await runNonStreamingChatSend({
context,
respond,
idempotencyKey: "idem-media-only-final",
});
expect(extractFirstTextBlock(payload)).toBe("MEDIA:https://example.com/final.png");
});
it("strips NO_REPLY from transcript text when final replies only carry media", async () => {
createTranscriptFixture("openclaw-chat-send-media-only-silent-final-");
mockState.finalPayload = {
text: "NO_REPLY",
mediaUrl: "https://example.com/final.png",
};
const respond = vi.fn();
const context = createChatContext();
const payload = await runNonStreamingChatSend({
context,
respond,
idempotencyKey: "idem-media-only-silent-final",
});
expect(extractFirstTextBlock(payload)).toBe("MEDIA:https://example.com/final.png");
});
it("drops image attachments for text-only session models", async () => {
createTranscriptFixture("openclaw-chat-send-text-only-attachments-");
mockState.finalText = "ok";
mockState.sessionEntry = {
modelProvider: "test-provider",
model: "text-only",
};
mockState.modelCatalog = [
{
provider: "test-provider",
id: "text-only",
name: "Text only",
input: ["text"],
},
];
const respond = vi.fn();
const context = createChatContext();
await runNonStreamingChatSend({
context,
respond,
idempotencyKey: "idem-text-only-attachments",
message: "describe image",
requestParams: {
attachments: [
{
mimeType: "image/png",
content:
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/woAAn8B9FD5fHAAAAAASUVORK5CYII=",
},
],
},
expectBroadcast: false,
});
expect(mockState.lastDispatchImages).toBeUndefined();
expect(mockState.lastDispatchImageOrder).toBeUndefined();
});
it("resolves attachment image support from the session agent model", async () => {
createTranscriptFixture("openclaw-chat-send-agent-scoped-text-only-attachments-");
mockState.finalText = "ok";
mockState.config = {
agents: {
list: [
{
id: "vision",
default: true,
model: "test-provider/vision-model",
},
{
id: "writer",
model: "test-provider/text-only",
},
],
},
};
mockState.modelCatalog = [
{
provider: "test-provider",
id: "vision-model",
name: "Vision model",
input: ["text", "image"],
},
{
provider: "test-provider",
id: "text-only",
name: "Text only",
input: ["text"],
},
];
const respond = vi.fn();
const context = createChatContext();
await runNonStreamingChatSend({
context,
respond,
sessionKey: "agent:writer:main",
idempotencyKey: "idem-agent-scoped-text-only-attachments",
message: "describe image",
requestParams: {
attachments: [
{
mimeType: "image/png",
content:
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/woAAn8B9FD5fHAAAAAASUVORK5CYII=",
},
],
},
expectBroadcast: false,
});
expect(mockState.lastDispatchImages).toBeUndefined();
expect(mockState.lastDispatchImageOrder).toBeUndefined();
});
it("passes imageOrder for mixed inline and offloaded chat.send attachments", async () => {
createTranscriptFixture("openclaw-chat-send-image-order-");
mockState.finalText = "ok";
mockState.sessionEntry = {
modelProvider: "test-provider",
model: "vision-model",
};
mockState.modelCatalog = [
{
provider: "test-provider",
id: "vision-model",
name: "Vision model",
input: ["text", "image"],
},
];
mockState.savedMediaResults = [{ path: "/tmp/offloaded-big.png", contentType: "image/png" }];
const respond = vi.fn();
const context = createChatContext();
const bigPng = Buffer.alloc(2_100_000);
bigPng.set([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a], 0);
await runNonStreamingChatSend({
context,
respond,
idempotencyKey: "idem-image-order",
message: "describe both",
requestParams: {
attachments: [
{
mimeType: "image/png",
content:
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/woAAn8B9FD5fHAAAAAASUVORK5CYII=",
},
{
mimeType: "image/png",
content: bigPng.toString("base64"),
},
],
},
expectBroadcast: false,
});
expect(mockState.lastDispatchImages).toHaveLength(1);
expect(mockState.lastDispatchImageOrder).toEqual(["inline", "offloaded"]);
});
it("maps media offload failures to UNAVAILABLE in chat.send", async () => {
createTranscriptFixture("openclaw-chat-send-media-offload-error-");
mockState.sessionEntry = {
modelProvider: "test-provider",
model: "vision-model",
};
mockState.modelCatalog = [
{
provider: "test-provider",
id: "vision-model",
name: "Vision model",
input: ["text", "image"],
},
];
mockState.saveMediaError = new Error("disk full");
const respond = vi.fn();
const context = createChatContext();
const bigPng = Buffer.alloc(2_100_000);
bigPng.set([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a], 0);
await runNonStreamingChatSend({
context,
respond,
idempotencyKey: "idem-media-offload-error",
message: "describe image",
requestParams: {
attachments: [
{
mimeType: "image/png",
content: bigPng.toString("base64"),
},
],
},
waitFor: "none",
});
expect(respond).toHaveBeenCalledWith(
false,
undefined,
expect.objectContaining({ code: ErrorCodes.UNAVAILABLE }),
);
});
it("persists chat.send attachments one at a time", async () => {
createTranscriptFixture("openclaw-chat-send-image-serial-save-");
mockState.finalText = "ok";
@@ -1813,6 +2125,48 @@ describe("chat directive tag stripping for non-streaming final payloads", () =>
});
});
it("does not parse or offload attachments for stop commands", async () => {
createTranscriptFixture("openclaw-chat-send-stop-command-attachments-");
mockState.savedMediaResults = [
{ path: "/tmp/should-not-exist.png", contentType: "image/png" },
];
const respond = vi.fn();
const context = createChatContext();
context.chatAbortControllers.set("run-same-session", {
controller: new AbortController(),
sessionId: "sess-prev",
sessionKey: "main",
startedAtMs: Date.now(),
expiresAtMs: Date.now() + 10_000,
});
await runNonStreamingChatSend({
context,
respond,
idempotencyKey: "idem-stop-command-attachments",
message: "/stop",
requestParams: {
attachments: [
{
mimeType: "image/png",
content:
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/woAAn8B9FD5fHAAAAAASUVORK5CYII=",
},
],
},
expectBroadcast: false,
waitFor: "none",
});
expect(mockState.savedMediaCalls).toEqual([]);
expect(mockState.lastDispatchImages).toBeUndefined();
expect(respond).toHaveBeenCalledWith(true, {
ok: true,
aborted: true,
runIds: ["run-same-session"],
});
});
it("emits a user transcript update when chat.send completes without an agent run", async () => {
createTranscriptFixture("openclaw-chat-send-user-transcript-no-run-");
mockState.finalText = "ok";

View File

@@ -1,33 +1,28 @@
import fs from "node:fs";
import path from "node:path";
import { CURRENT_SESSION_VERSION, SessionManager } from "@mariozechner/pi-coding-agent";
import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload";
import { resolveSessionAgentId } from "../../agents/agent-scope.js";
import { resolveThinkingDefault } from "../../agents/model-selection.js";
import { rewriteTranscriptEntriesInSessionFile } from "../../agents/pi-embedded-runner/transcript-rewrite.js";
import { resolveAgentTimeoutMs } from "../../agents/timeout.js";
import {
extractAssistantText as extractAssistantHistoryText,
hasAssistantPhaseMetadata,
} from "../../agents/tools/chat-history-text.js";
import { dispatchInboundMessage } from "../../auto-reply/dispatch.js";
import { createReplyDispatcher } from "../../auto-reply/reply/reply-dispatcher.js";
import type { MsgContext } from "../../auto-reply/templating.js";
import type { ReplyPayload } from "../../auto-reply/types.js";
import { extractCanvasFromText } from "../../chat/canvas-render.js";
import { resolveSessionFilePath } from "../../config/sessions.js";
import { formatErrorMessage } from "../../infra/errors.js";
import { jsonUtf8Bytes } from "../../infra/json-utf8-bytes.js";
import { isAudioFileName } from "../../media/mime.js";
import type { PromptImageOrderEntry } from "../../media/prompt-image-order.js";
import { type SavedMedia, saveMediaBuffer } from "../../media/store.js";
import { createChannelReplyPipeline } from "../../plugin-sdk/channel-reply-pipeline.js";
import { resolveAssistantMessagePhase } from "../../shared/chat-message-content.js";
import { normalizeInputProvenance, type InputProvenance } from "../../sessions/input-provenance.js";
import { resolveSendPolicy } from "../../sessions/send-policy.js";
import { parseAgentSessionKey } from "../../sessions/session-key-utils.js";
import { emitSessionTranscriptUpdate } from "../../sessions/transcript-events.js";
import { resolveAssistantMessagePhase } from "../../shared/chat-message-content.js";
import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalString,
} from "../../shared/string-coerce.js";
import { isSuppressedControlReplyText } from "../control-reply-text.js";
import {
stripInlineDirectiveTagsForDisplay,
stripInlineDirectiveTagsFromMessageForDisplay,
@@ -47,13 +42,11 @@ import {
} from "../chat-abort.js";
import {
type ChatImageContent,
MediaOffloadError,
type OffloadedRef,
parseMessageWithAttachments,
} from "../chat-attachments.js";
import { stripEnvelopeFromMessage, stripEnvelopeFromMessages } from "../chat-sanitize.js";
import { augmentChatHistoryWithCliSessionImports } from "../cli-session-history.js";
import { isSuppressedControlReplyText } from "../control-reply-text.js";
import { ADMIN_SCOPE } from "../method-scopes.js";
import {
GATEWAY_CLIENT_CAPS,
@@ -90,6 +83,7 @@ import type {
GatewayRequestHandlerOptions,
GatewayRequestHandlers,
} from "./types.js";
import { MediaOffloadError } from "../chat-attachments.js";
type TranscriptAppendResult = {
ok: boolean;
@@ -156,6 +150,19 @@ const CHANNEL_AGNOSTIC_SESSION_SCOPES = new Set([
]);
const CHANNEL_SCOPED_SESSION_SHAPES = new Set(["direct", "dm", "group", "channel"]);
export function resolveEffectiveChatHistoryMaxChars(
cfg: { gateway?: { webchat?: { chatHistoryMaxChars?: number } } },
maxChars?: number,
): number {
if (typeof maxChars === "number") {
return maxChars;
}
if (typeof cfg.gateway?.webchat?.chatHistoryMaxChars === "number") {
return cfg.gateway.webchat.chatHistoryMaxChars;
}
return DEFAULT_CHAT_HISTORY_TEXT_MAX_CHARS;
}
type ChatSendDeliveryEntry = {
deliveryContext?: {
channel?: string;
@@ -199,6 +206,35 @@ type SideResultPayload = {
ts: number;
};
function buildTranscriptReplyText(payloads: ReplyPayload[]): string {
const chunks = payloads
.map((payload) => {
const parts = resolveSendableOutboundReplyParts(payload);
const lines: string[] = [];
if (typeof payload.replyToId === "string" && payload.replyToId.trim()) {
lines.push(`[[reply_to:${payload.replyToId.trim()}]]`);
} else if (payload.replyToCurrent) {
lines.push("[[reply_to_current]]");
}
const text = payload.text?.trim();
if (text && !isSuppressedControlReplyText(text)) {
lines.push(text);
}
for (const mediaUrl of parts.mediaUrls) {
const trimmed = mediaUrl.trim();
if (trimmed) {
lines.push(`MEDIA:${trimmed}`);
}
}
if (payload.audioAsVoice && parts.mediaUrls.some((mediaUrl) => isAudioFileName(mediaUrl))) {
lines.push("[[audio_as_voice]]");
}
return lines.join("\n").trim();
})
.filter(Boolean);
return chunks.join("\n\n").trim();
}
function resolveChatSendOriginatingRoute(params: {
client?: { mode?: string | null; id?: string | null } | null;
deliver?: boolean;
@@ -255,9 +291,9 @@ function resolveChatSendOriginatingRoute(params: {
.filter(Boolean);
const sessionScopeHead = sessionScopeParts[0];
const sessionChannelHint = normalizeMessageChannel(sessionScopeHead);
const normalizedSessionScopeHead = normalizeLowercaseStringOrEmpty(sessionScopeHead);
const normalizedSessionScopeHead = (sessionScopeHead ?? "").trim().toLowerCase();
const sessionPeerShapeCandidates = [sessionScopeParts[1], sessionScopeParts[2]]
.map((part) => normalizeLowercaseStringOrEmpty(part))
.map((part) => (part ?? "").trim().toLowerCase())
.filter(Boolean);
const isChannelAgnosticSessionScope = CHANNEL_AGNOSTIC_SESSION_SCOPES.has(
normalizedSessionScopeHead,
@@ -274,7 +310,7 @@ function resolveChatSendOriginatingRoute(params: {
const hasClientMetadata =
(typeof params.client?.mode === "string" && params.client.mode.trim().length > 0) ||
(typeof params.client?.id === "string" && params.client.id.trim().length > 0);
const configuredMainKey = normalizeLowercaseStringOrEmpty(params.mainKey ?? "main");
const configuredMainKey = (params.mainKey ?? "main").trim().toLowerCase();
const isConfiguredMainSessionScope =
normalizedSessionScopeHead.length > 0 && normalizedSessionScopeHead === configuredMainKey;
const canInheritConfiguredMainRoute =
@@ -368,17 +404,6 @@ function canInjectSystemProvenance(client: GatewayRequestHandlerOptions["client"
return scopes.includes(ADMIN_SCOPE);
}
/**
* Persist inline images and offloaded-ref media to the transcript media store.
*
* Inline images are re-saved from their base64 payload so that a stable
* filesystem path can be stored in the transcript. Offloaded refs are already
* on disk (saved by parseMessageWithAttachments); their SavedMedia metadata is
* synthesised directly from the OffloadedRef, avoiding a redundant write.
*
* Both sets are combined so that transcript media fields remain complete
* regardless of whether attachments were inlined or offloaded.
*/
async function persistChatSendImages(params: {
images: ChatImageContent[];
imageOrder: PromptImageOrderEntry[];
@@ -386,41 +411,56 @@ async function persistChatSendImages(params: {
client: GatewayRequestHandlerOptions["client"];
logGateway: GatewayRequestContext["logGateway"];
}): Promise<SavedMedia[]> {
if (isAcpBridgeClient(params.client)) {
if ((params.images.length === 0 && params.offloadedRefs.length === 0) || isAcpBridgeClient(params.client)) {
return [];
}
const saved: SavedMedia[] = [];
let inlineIndex = 0;
let offloadedIndex = 0;
for (const entry of params.imageOrder) {
if (entry === "offloaded") {
const ref = params.offloadedRefs[offloadedIndex++];
if (!ref) {
continue;
}
saved.push({
id: ref.id,
path: ref.path,
size: 0,
contentType: ref.mimeType,
});
continue;
}
const img = params.images[inlineIndex++];
if (!img) {
continue;
}
const inlineSaved: SavedMedia[] = [];
for (const img of params.images) {
try {
saved.push(await saveMediaBuffer(Buffer.from(img.data, "base64"), img.mimeType, "inbound"));
inlineSaved.push(await saveMediaBuffer(Buffer.from(img.data, "base64"), img.mimeType, "inbound"));
} catch (err) {
params.logGateway.warn(
`chat.send: failed to persist inbound image (${img.mimeType}): ${formatForLog(err)}`,
);
}
}
const offloadedSaved = params.offloadedRefs.map((ref) => ({
id: ref.id,
path: ref.path,
size: 0,
contentType: ref.mimeType,
}));
if (params.imageOrder.length === 0) {
return [...inlineSaved, ...offloadedSaved];
}
const saved: SavedMedia[] = [];
let inlineIndex = 0;
let offloadedIndex = 0;
for (const entry of params.imageOrder) {
if (entry === "inline") {
const inline = inlineSaved[inlineIndex++];
if (inline) {
saved.push(inline);
}
continue;
}
const offloaded = offloadedSaved[offloadedIndex++];
if (offloaded) {
saved.push(offloaded);
}
}
for (; inlineIndex < inlineSaved.length; inlineIndex++) {
const inline = inlineSaved[inlineIndex];
if (inline) {
saved.push(inline);
}
}
for (; offloadedIndex < offloadedSaved.length; offloadedIndex++) {
const offloaded = offloadedSaved[offloadedIndex];
if (offloaded) {
saved.push(offloaded);
}
}
return saved;
}
@@ -520,7 +560,7 @@ async function rewriteChatSendUserTurnMediaPaths(params: {
function truncateChatHistoryText(
text: string,
maxChars: number,
maxChars: number = DEFAULT_CHAT_HISTORY_TEXT_MAX_CHARS,
): { text: string; truncated: boolean } {
if (text.length <= maxChars) {
return { text, truncated: false };
@@ -531,30 +571,94 @@ function truncateChatHistoryText(
};
}
function isToolHistoryBlockType(type: unknown): boolean {
if (typeof type !== "string") {
return false;
}
const normalized = type.trim().toLowerCase();
return (
normalized === "toolcall" ||
normalized === "tool_call" ||
normalized === "tooluse" ||
normalized === "tool_use" ||
normalized === "toolresult" ||
normalized === "tool_result"
);
}
function extractChatHistoryBlockText(message: unknown): string | undefined {
if (!message || typeof message !== "object") {
return undefined;
}
const entry = message as Record<string, unknown>;
if (typeof entry.content === "string") {
return entry.content;
}
if (typeof entry.text === "string") {
return entry.text;
}
if (!Array.isArray(entry.content)) {
return undefined;
}
const textParts = entry.content
.map((block) => {
if (!block || typeof block !== "object") {
return undefined;
}
const typed = block as { text?: unknown; type?: unknown };
return typeof typed.text === "string" ? typed.text : undefined;
})
.filter((value): value is string => typeof value === "string");
return textParts.length > 0 ? textParts.join("\n") : undefined;
}
function sanitizeChatHistoryContentBlock(
block: unknown,
maxChars: number,
opts?: { preserveExactToolPayload?: boolean; maxChars?: number },
): { block: unknown; changed: boolean } {
if (!block || typeof block !== "object") {
return { block, changed: false };
}
const entry = { ...(block as Record<string, unknown>) };
let changed = false;
const preserveExactToolPayload =
opts?.preserveExactToolPayload === true || isToolHistoryBlockType(entry.type);
const maxChars = opts?.maxChars ?? DEFAULT_CHAT_HISTORY_TEXT_MAX_CHARS;
if (typeof entry.text === "string") {
const stripped = stripInlineDirectiveTagsForDisplay(entry.text);
const res = truncateChatHistoryText(stripped.text, maxChars);
entry.text = res.text;
changed ||= stripped.changed || res.truncated;
if (preserveExactToolPayload) {
entry.text = stripped.text;
changed ||= stripped.changed;
} else {
const res = truncateChatHistoryText(stripped.text, maxChars);
entry.text = res.text;
changed ||= stripped.changed || res.truncated;
}
}
if (typeof entry.content === "string") {
const stripped = stripInlineDirectiveTagsForDisplay(entry.content);
if (preserveExactToolPayload) {
entry.content = stripped.text;
changed ||= stripped.changed;
} else {
const res = truncateChatHistoryText(stripped.text, maxChars);
entry.content = res.text;
changed ||= stripped.changed || res.truncated;
}
}
if (typeof entry.partialJson === "string") {
const res = truncateChatHistoryText(entry.partialJson, maxChars);
entry.partialJson = res.text;
changed ||= res.truncated;
if (!preserveExactToolPayload) {
const res = truncateChatHistoryText(entry.partialJson, maxChars);
entry.partialJson = res.text;
changed ||= res.truncated;
}
}
if (typeof entry.arguments === "string") {
const res = truncateChatHistoryText(entry.arguments, maxChars);
entry.arguments = res.text;
changed ||= res.truncated;
if (!preserveExactToolPayload) {
const res = truncateChatHistoryText(entry.arguments, maxChars);
entry.arguments = res.text;
changed ||= res.truncated;
}
}
if (typeof entry.thinking === "string") {
const res = truncateChatHistoryText(entry.thinking, maxChars);
@@ -640,13 +744,23 @@ function sanitizeCost(raw: unknown): { total?: number } | undefined {
function sanitizeChatHistoryMessage(
message: unknown,
maxChars: number,
maxChars: number = DEFAULT_CHAT_HISTORY_TEXT_MAX_CHARS,
): { message: unknown; changed: boolean } {
if (!message || typeof message !== "object") {
return { message, changed: false };
}
const entry = { ...(message as Record<string, unknown>) };
let changed = false;
const role = typeof entry.role === "string" ? entry.role.toLowerCase() : "";
const preserveExactToolPayload =
role === "toolresult" ||
role === "tool_result" ||
role === "tool" ||
role === "function" ||
typeof entry.toolName === "string" ||
typeof entry.tool_name === "string" ||
typeof entry.toolCallId === "string" ||
typeof entry.tool_call_id === "string";
if ("details" in entry) {
delete entry.details;
@@ -688,35 +802,34 @@ function sanitizeChatHistoryMessage(
if (typeof entry.content === "string") {
const stripped = stripInlineDirectiveTagsForDisplay(entry.content);
const res = truncateChatHistoryText(stripped.text, maxChars);
entry.content = res.text;
changed ||= stripped.changed || res.truncated;
} else if (Array.isArray(entry.content)) {
const updated = entry.content.map((block) => sanitizeChatHistoryContentBlock(block, maxChars));
const sanitizedBlocks = updated.map((item) => item.block);
const hasPhaseMetadata = hasAssistantPhaseMetadata(entry);
if (hasPhaseMetadata) {
const stripped = stripInlineDirectiveTagsForDisplay(extractAssistantHistoryText(entry) ?? "");
if (preserveExactToolPayload) {
entry.content = stripped.text;
changed ||= stripped.changed;
} else {
const res = truncateChatHistoryText(stripped.text, maxChars);
const nonTextBlocks = sanitizedBlocks.filter(
(block) =>
!block || typeof block !== "object" || (block as { type?: unknown }).type !== "text",
);
entry.content = res.text
? [{ type: "text", text: res.text }, ...nonTextBlocks]
: nonTextBlocks;
changed = true;
} else if (updated.some((item) => item.changed)) {
entry.content = sanitizedBlocks;
entry.content = res.text;
changed ||= stripped.changed || res.truncated;
}
} else if (Array.isArray(entry.content)) {
const updated = entry.content.map((block) =>
sanitizeChatHistoryContentBlock(block, { preserveExactToolPayload, maxChars }),
);
if (updated.some((item) => item.changed)) {
entry.content = updated.map((item) => item.block);
changed = true;
}
}
if (typeof entry.text === "string") {
const stripped = stripInlineDirectiveTagsForDisplay(entry.text);
const res = truncateChatHistoryText(stripped.text, maxChars);
entry.text = res.text;
changed ||= stripped.changed || res.truncated;
if (preserveExactToolPayload) {
entry.text = stripped.text;
changed ||= stripped.changed;
} else {
const res = truncateChatHistoryText(stripped.text, maxChars);
entry.text = res.text;
changed ||= stripped.changed || res.truncated;
}
}
return { message: changed ? entry : message, changed };
@@ -729,7 +842,35 @@ function sanitizeChatHistoryMessage(
* dropping messages that carry real text alongside a stale `content: "NO_REPLY"`.
*/
function extractAssistantTextForSilentCheck(message: unknown): string | undefined {
return extractAssistantHistoryText(message);
if (!message || typeof message !== "object") {
return undefined;
}
const entry = message as Record<string, unknown>;
if (entry.role !== "assistant") {
return undefined;
}
if (typeof entry.text === "string") {
return entry.text;
}
if (typeof entry.content === "string") {
return entry.content;
}
if (!Array.isArray(entry.content) || entry.content.length === 0) {
return undefined;
}
const texts: string[] = [];
for (const block of entry.content) {
if (!block || typeof block !== "object") {
return undefined;
}
const typed = block as { type?: unknown; text?: unknown };
if (typed.type !== "text" || typeof typed.text !== "string") {
return undefined;
}
texts.push(typed.text);
}
return texts.length > 0 ? texts.join("\n") : undefined;
}
function hasAssistantNonTextContent(message: unknown): boolean {
@@ -749,7 +890,8 @@ function shouldDropAssistantHistoryMessage(message: unknown): boolean {
if (!message || typeof message !== "object") {
return false;
}
if ((message as { role?: unknown }).role !== "assistant") {
const entry = message as { role?: unknown };
if (entry.role !== "assistant") {
return false;
}
if (resolveAssistantMessagePhase(message) === "commentary") {
@@ -762,24 +904,22 @@ function shouldDropAssistantHistoryMessage(message: unknown): boolean {
return !hasAssistantNonTextContent(message);
}
export function sanitizeChatHistoryMessages(messages: unknown[], maxChars: number): unknown[] {
export function sanitizeChatHistoryMessages(
messages: unknown[],
maxChars: number = DEFAULT_CHAT_HISTORY_TEXT_MAX_CHARS,
): unknown[] {
if (messages.length === 0) {
return messages;
}
let changed = false;
const next: unknown[] = [];
for (const message of messages) {
// Drop raw control-token replies before any maxChars truncation can make
// an exact token look like partial user-visible text.
if (shouldDropAssistantHistoryMessage(message)) {
changed = true;
continue;
}
const res = sanitizeChatHistoryMessage(message, maxChars);
changed ||= res.changed;
// Drop assistant commentary-only entries and exact control replies, but
// keep mixed assistant entries that still carry non-text content. Run this
// again after sanitizing so display-only cleanup can still suppress stale tokens.
if (shouldDropAssistantHistoryMessage(res.message)) {
changed = true;
continue;
@@ -789,6 +929,152 @@ export function sanitizeChatHistoryMessages(messages: unknown[], maxChars: numbe
return changed ? next : messages;
}
function appendCanvasBlockToAssistantHistoryMessage(params: {
message: unknown;
preview: ReturnType<typeof extractCanvasFromText>;
rawText: string | null;
}): unknown {
const preview = params.preview;
if (!preview || !params.message || typeof params.message !== "object") {
return params.message;
}
const entry = params.message as Record<string, unknown>;
const baseContent = Array.isArray(entry.content)
? [...entry.content]
: typeof entry.content === "string"
? [{ type: "text", text: entry.content }]
: typeof entry.text === "string"
? [{ type: "text", text: entry.text }]
: [];
const alreadyPresent = baseContent.some((block) => {
if (!block || typeof block !== "object") {
return false;
}
const typed = block as { type?: unknown; preview?: unknown };
return (
typed.type === "canvas" &&
typed.preview &&
typeof typed.preview === "object" &&
(((typed.preview as { viewId?: unknown }).viewId &&
(typed.preview as { viewId?: unknown }).viewId === preview.viewId) ||
((typed.preview as { url?: unknown }).url &&
(typed.preview as { url?: unknown }).url === preview.url))
);
});
if (!alreadyPresent) {
baseContent.push({
type: "canvas",
preview,
rawText: params.rawText,
});
}
return {
...entry,
content: baseContent,
};
}
function messageContainsToolHistoryContent(message: unknown): boolean {
if (!message || typeof message !== "object") {
return false;
}
const entry = message as Record<string, unknown>;
if (
typeof entry.toolCallId === "string" ||
typeof entry.tool_call_id === "string" ||
typeof entry.toolName === "string" ||
typeof entry.tool_name === "string"
) {
return true;
}
if (!Array.isArray(entry.content)) {
return false;
}
return entry.content.some((block) => {
if (!block || typeof block !== "object") {
return false;
}
return isToolHistoryBlockType((block as { type?: unknown }).type);
});
}
export function augmentChatHistoryWithCanvasBlocks(messages: unknown[]): unknown[] {
if (messages.length === 0) {
return messages;
}
const next = [...messages];
let changed = false;
let lastAssistantIndex = -1;
let lastRenderableAssistantIndex = -1;
const pending: Array<{
preview: NonNullable<ReturnType<typeof extractCanvasFromText>>;
rawText: string | null;
}> = [];
for (let index = 0; index < next.length; index++) {
const message = next[index];
if (!message || typeof message !== "object") {
continue;
}
const entry = message as Record<string, unknown>;
const role = typeof entry.role === "string" ? entry.role.toLowerCase() : "";
if (role === "assistant") {
lastAssistantIndex = index;
if (!messageContainsToolHistoryContent(entry)) {
lastRenderableAssistantIndex = index;
if (pending.length > 0) {
let target = next[index];
for (const item of pending) {
target = appendCanvasBlockToAssistantHistoryMessage({
message: target,
preview: item.preview,
rawText: item.rawText,
});
}
next[index] = target;
pending.length = 0;
changed = true;
}
}
continue;
}
if (!messageContainsToolHistoryContent(entry)) {
continue;
}
const toolName =
typeof entry.toolName === "string"
? entry.toolName
: typeof entry.tool_name === "string"
? entry.tool_name
: undefined;
const text = extractChatHistoryBlockText(entry);
const preview = extractCanvasFromText(text, toolName);
if (!preview) {
continue;
}
pending.push({
preview,
rawText: text ?? null,
});
}
if (pending.length > 0) {
const targetIndex =
lastRenderableAssistantIndex >= 0 ? lastRenderableAssistantIndex : lastAssistantIndex;
if (targetIndex >= 0) {
let target = next[targetIndex];
for (const item of pending) {
target = appendCanvasBlockToAssistantHistoryMessage({
message: target,
preview: item.preview,
rawText: item.rawText,
});
}
next[targetIndex] = target;
changed = true;
}
}
return changed ? next : messages;
}
function buildOversizedHistoryPlaceholder(message?: unknown): Record<string, unknown> {
const role =
message &&
@@ -895,7 +1181,7 @@ function ensureTranscriptFile(params: { transcriptPath: string; sessionId: strin
});
return { ok: true };
} catch (err) {
return { ok: false, error: formatErrorMessage(err) };
return { ok: false, error: err instanceof Error ? err.message : String(err) };
}
}
@@ -919,9 +1205,8 @@ function transcriptHasIdempotencyKey(transcriptPath: string, idempotencyKey: str
function appendAssistantTranscriptMessage(params: {
message: string;
/** Rich Pi message blocks (text, embedded audio, etc.). Overrides plain `message` when set. */
content?: Array<Record<string, unknown>>;
label?: string;
content?: Array<Record<string, unknown>>;
sessionId: string;
storePath: string | undefined;
sessionFile?: string;
@@ -1042,13 +1327,18 @@ function createChatAbortOps(context: GatewayRequestContext): ChatAbortOps {
};
}
function normalizeOptionalText(value?: string | null): string | undefined {
const trimmed = value?.trim();
return trimmed || undefined;
}
function normalizeExplicitChatSendOrigin(
params: ChatSendExplicitOrigin,
): { ok: true; value?: ChatSendExplicitOrigin } | { ok: false; error: string } {
const originatingChannel = normalizeOptionalString(params.originatingChannel);
const originatingTo = normalizeOptionalString(params.originatingTo);
const accountId = normalizeOptionalString(params.accountId);
const messageThreadId = normalizeOptionalString(params.messageThreadId);
const originatingChannel = normalizeOptionalText(params.originatingChannel);
const originatingTo = normalizeOptionalText(params.originatingTo);
const accountId = normalizeOptionalText(params.accountId);
const messageThreadId = normalizeOptionalText(params.messageThreadId);
const hasAnyExplicitOriginField = Boolean(
originatingChannel || originatingTo || accountId || messageThreadId,
);
@@ -1084,8 +1374,8 @@ function resolveChatAbortRequester(
): ChatAbortRequester {
const scopes = Array.isArray(client?.connect?.scopes) ? client.connect.scopes : [];
return {
connId: normalizeOptionalString(client?.connId),
deviceId: normalizeOptionalString(client?.connect?.device?.id),
connId: normalizeOptionalText(client?.connId),
deviceId: normalizeOptionalText(client?.connect?.device?.id),
isAdmin: scopes.includes(ADMIN_SCOPE),
};
}
@@ -1097,8 +1387,8 @@ function canRequesterAbortChatRun(
if (requester.isAdmin) {
return true;
}
const ownerDeviceId = normalizeOptionalString(entry.ownerDeviceId);
const ownerConnId = normalizeOptionalString(entry.ownerConnId);
const ownerDeviceId = normalizeOptionalText(entry.ownerDeviceId);
const ownerConnId = normalizeOptionalText(entry.ownerConnId);
if (!ownerDeviceId && !ownerConnId) {
return true;
}
@@ -1269,25 +1559,14 @@ export const chatHandlers: GatewayRequestHandlers = {
);
return;
}
const {
sessionKey,
limit,
maxChars: rpcMaxChars,
} = params as {
const { sessionKey, limit, maxChars } = params as {
sessionKey: string;
limit?: number;
maxChars?: number;
};
const { cfg, storePath, entry } = loadSessionEntry(sessionKey);
const configMaxChars = cfg.gateway?.webchat?.chatHistoryMaxChars;
const effectiveMaxChars =
typeof rpcMaxChars === "number"
? rpcMaxChars
: typeof configMaxChars === "number"
? configMaxChars
: DEFAULT_CHAT_HISTORY_TEXT_MAX_CHARS;
const sessionAgentId = resolveSessionAgentId({ sessionKey, config: cfg });
const sessionId = entry?.sessionId;
const sessionAgentId = resolveSessionAgentId({ sessionKey, config: cfg });
const resolvedSessionModel = resolveSessionModelRef(cfg, entry, sessionAgentId);
const localMessages =
sessionId && storePath ? readSessionMessages(sessionId, storePath, entry?.sessionFile) : [];
@@ -1300,9 +1579,12 @@ export const chatHandlers: GatewayRequestHandlers = {
const defaultLimit = 200;
const requested = typeof limit === "number" ? limit : defaultLimit;
const max = Math.min(hardMax, requested);
const effectiveMaxChars = resolveEffectiveChatHistoryMaxChars(cfg, maxChars);
const sliced = rawMessages.length > max ? rawMessages.slice(-max) : rawMessages;
const sanitized = stripEnvelopeFromMessages(sliced);
const normalized = sanitizeChatHistoryMessages(sanitized, effectiveMaxChars);
const normalized = augmentChatHistoryWithCanvasBlocks(
sanitizeChatHistoryMessages(sanitized, effectiveMaxChars),
);
const maxHistoryBytes = getMaxChatHistoryMessagesBytes();
const perMessageHardCap = Math.min(CHAT_HISTORY_MAX_SINGLE_MESSAGE_BYTES, maxHistoryBytes);
const replaced = replaceOversizedChatHistoryMessages({
@@ -1320,16 +1602,11 @@ export const chatHandlers: GatewayRequestHandlers = {
}
let thinkingLevel = entry?.thinkingLevel;
if (!thinkingLevel) {
const sessionAgentId = resolveSessionAgentId({
sessionKey,
config: cfg,
});
const resolvedModel = resolveSessionModelRef(cfg, entry, sessionAgentId);
const catalog = await context.loadGatewayModelCatalog();
thinkingLevel = resolveThinkingDefault({
cfg,
provider: resolvedModel.provider,
model: resolvedModel.model,
provider: resolvedSessionModel.provider,
model: resolvedSessionModel.model,
catalog,
});
}
@@ -1510,18 +1787,16 @@ export const chatHandlers: GatewayRequestHandlers = {
);
return;
}
// Load session entry before attachment parsing so we can gate media-URI
// marker injection on the model's image capability. This prevents opaque
// media:// markers from leaking into prompts for text-only model runs.
const rawSessionKey = p.sessionKey;
const { cfg, entry, canonicalKey: sessionKey } = loadSessionEntry(rawSessionKey);
const agentId = resolveSessionAgentId({
sessionKey,
config: cfg,
});
let parsedMessage = inboundMessage;
let parsedImages: ChatImageContent[] = [];
let parsedImageOrder: PromptImageOrderEntry[] = [];
let parsedOffloadedRefs: OffloadedRef[] = [];
let imageOrder: PromptImageOrderEntry[] = [];
let offloadedRefs: OffloadedRef[] = [];
const timeoutMs = resolveAgentTimeoutMs({
cfg,
overrideMs: p.timeoutMs,
@@ -1578,16 +1853,13 @@ export const chatHandlers: GatewayRequestHandlers = {
});
return;
}
if (normalizedAttachments.length > 0) {
const sessionAgentId = resolveSessionAgentId({ sessionKey, config: cfg });
const modelRef = resolveSessionModelRef(cfg, entry, sessionAgentId);
const modelRef = resolveSessionModelRef(cfg, entry, agentId);
const supportsImages = await resolveGatewayModelSupportsImages({
loadGatewayModelCatalog: context.loadGatewayModelCatalog,
provider: modelRef.provider,
model: modelRef.model,
});
try {
const parsed = await parseMessageWithAttachments(inboundMessage, normalizedAttachments, {
maxBytes: 5_000_000,
@@ -1596,19 +1868,14 @@ export const chatHandlers: GatewayRequestHandlers = {
});
parsedMessage = parsed.message;
parsedImages = parsed.images;
parsedImageOrder = parsed.imageOrder;
parsedOffloadedRefs = parsed.offloadedRefs;
imageOrder = parsed.imageOrder;
offloadedRefs = parsed.offloadedRefs;
} catch (err) {
// MediaOffloadError indicates a server-side storage fault (ENOSPC, EPERM,
// etc.). All other errors are client-side input validation failures.
// Map them to different HTTP status codes so callers can retry server
// faults without treating them as bad requests.
const isServerFault = err instanceof MediaOffloadError;
respond(
false,
undefined,
errorShape(
isServerFault ? ErrorCodes.UNAVAILABLE : ErrorCodes.INVALID_REQUEST,
err instanceof MediaOffloadError ? ErrorCodes.UNAVAILABLE : ErrorCodes.INVALID_REQUEST,
String(err),
),
);
@@ -1624,23 +1891,18 @@ export const chatHandlers: GatewayRequestHandlers = {
sessionKey: rawSessionKey,
startedAtMs: now,
expiresAtMs: resolveChatRunExpiresAtMs({ now, timeoutMs }),
ownerConnId: normalizeOptionalString(client?.connId),
ownerDeviceId: normalizeOptionalString(client?.connect?.device?.id),
ownerConnId: normalizeOptionalText(client?.connId),
ownerDeviceId: normalizeOptionalText(client?.connect?.device?.id),
});
const ackPayload = {
runId: clientRunId,
status: "started" as const,
};
respond(true, ackPayload, undefined, { runId: clientRunId });
// Persist both inline images and already-offloaded refs to the media
// store so that transcript media fields remain complete for all attachment
// sizes. Offloaded refs are already on disk; persistChatSendImages converts
// their metadata without re-writing the files.
const persistedImagesPromise = persistChatSendImages({
images: parsedImages,
imageOrder: parsedImageOrder,
offloadedRefs: parsedOffloadedRefs,
imageOrder,
offloadedRefs,
client,
logGateway: context.logGateway,
});
@@ -1671,7 +1933,7 @@ export const chatHandlers: GatewayRequestHandlers = {
});
// Inject timestamp so agents know the current date/time.
// Only BodyForAgent gets the timestamp — Body stays raw for UI display.
// See: https://github.com/openclaw/openclaw/issues/3658
// See: https://github.com/moltbot/moltbot/issues/3658
const stampedMessage = injectTimestamp(messageForAgent, timestampOptsFromConfig(cfg));
const ctx: MsgContext = {
@@ -1698,10 +1960,6 @@ export const chatHandlers: GatewayRequestHandlers = {
GatewayClientScopes: client?.connect?.scopes ?? [],
};
const agentId = resolveSessionAgentId({
sessionKey,
config: cfg,
});
const { onModelSelected, ...replyPipeline } = createChannelReplyPipeline({
cfg,
agentId,
@@ -1843,7 +2101,7 @@ export const chatHandlers: GatewayRequestHandlers = {
runId: clientRunId,
abortSignal: abortController.signal,
images: parsedImages.length > 0 ? parsedImages : undefined,
imageOrder: parsedImageOrder.length > 0 ? parsedImageOrder : undefined,
imageOrder: imageOrder.length > 0 ? imageOrder : undefined,
onAgentRunStart: (runId) => {
agentRunStarted = true;
void emitUserTranscriptUpdate();
@@ -1898,33 +2156,18 @@ export const chatHandlers: GatewayRequestHandlers = {
sessionKey,
});
} else {
const finalPayloads = deliveredReplies
const combinedReply = buildTranscriptReplyText(
deliveredReplies
.filter((entry) => entry.kind === "final")
.map((entry) => entry.payload);
const combinedReply = finalPayloads
.map((part) => part.text?.trim() ?? "")
.filter(Boolean)
.join("\n\n")
.trim();
const audioBlocks = buildWebchatAudioContentBlocksFromReplyPayloads(finalPayloads);
const assistantContent: Array<Record<string, unknown>> = [];
if (combinedReply) {
assistantContent.push({ type: "text", text: combinedReply });
} else if (audioBlocks.length > 0) {
assistantContent.push({ type: "text", text: "Audio reply" });
}
assistantContent.push(...audioBlocks);
.map((entry) => entry.payload)
);
let message: Record<string, unknown> | undefined;
if (assistantContent.length > 0) {
if (combinedReply) {
const { storePath: latestStorePath, entry: latestEntry } =
loadSessionEntry(sessionKey);
const sessionId = latestEntry?.sessionId ?? entry?.sessionId ?? clientRunId;
const transcriptFallbackText =
combinedReply || (audioBlocks.length > 0 ? "Audio reply" : "");
const appended = appendAssistantTranscriptMessage({
message: transcriptFallbackText,
content: assistantContent,
message: combinedReply,
sessionId,
storePath: latestStorePath,
sessionFile: latestEntry?.sessionFile,
@@ -1940,7 +2183,7 @@ export const chatHandlers: GatewayRequestHandlers = {
const now = Date.now();
message = {
role: "assistant",
content: assistantContent,
content: [{ type: "text", text: combinedReply }],
timestamp: now,
// Keep this compatible with Pi stopReason enums even though this message isn't
// persisted to the transcript due to the append failure.

View File

@@ -16,7 +16,13 @@ import { validateExecApprovalRequestParams } from "../protocol/index.js";
import { waitForAgentJob } from "./agent-job.js";
import { injectTimestamp, timestampOptsFromConfig } from "./agent-timestamp.js";
import { normalizeRpcAttachmentsToChatAttachments } from "./attachment-normalize.js";
import { sanitizeChatSendMessageInput } from "./chat.js";
import {
DEFAULT_CHAT_HISTORY_TEXT_MAX_CHARS,
augmentChatHistoryWithCanvasBlocks,
resolveEffectiveChatHistoryMaxChars,
sanitizeChatHistoryMessages,
sanitizeChatSendMessageInput,
} from "./chat.js";
import { createExecApprovalHandlers } from "./exec-approval.js";
import { logsHandlers } from "./logs.js";
@@ -111,6 +117,39 @@ describe("waitForAgentJob", () => {
});
});
describe("augmentChatHistoryWithCanvasBlocks", () => {
it("ignores user messages that merely contain canvas-shaped text", () => {
const previewJson = JSON.stringify({
kind: "canvas",
view: {
backend: "canvas",
id: "cv_user_text",
url: "/__openclaw__/canvas/documents/cv_user_text/index.html",
title: "User pasted preview",
preferred_height: 240,
},
presentation: {
target: "assistant_message",
},
});
const messages = [
{
role: "user",
content: previewJson,
timestamp: 1,
},
{
role: "assistant",
content: "Plain assistant reply",
timestamp: 2,
},
];
expect(augmentChatHistoryWithCanvasBlocks(messages)).toEqual(messages);
});
});
describe("injectTimestamp", () => {
beforeEach(() => {
vi.useFakeTimers();
@@ -223,6 +262,73 @@ describe("injectTimestamp", () => {
});
});
describe("sanitizeChatHistoryMessages", () => {
it("drops commentary-only assistant entries when phase exists only in textSignature", () => {
const result = sanitizeChatHistoryMessages([
{
role: "user",
content: [{ type: "text", text: "hello" }],
timestamp: 1,
},
{
role: "assistant",
content: [
{
type: "text",
text: "thinking like caveman",
textSignature: JSON.stringify({ v: 1, id: "msg_commentary", phase: "commentary" }),
},
],
timestamp: 2,
},
{
role: "assistant",
content: [{ type: "text", text: "real reply" }],
timestamp: 3,
},
]);
expect(result).toEqual([
{
role: "user",
content: [{ type: "text", text: "hello" }],
timestamp: 1,
},
{
role: "assistant",
content: [{ type: "text", text: "real reply" }],
timestamp: 3,
},
]);
});
});
describe("resolveEffectiveChatHistoryMaxChars", () => {
it("uses gateway.webchat.chatHistoryMaxChars when RPC maxChars is absent", () => {
expect(
resolveEffectiveChatHistoryMaxChars(
{ gateway: { webchat: { chatHistoryMaxChars: 123 } } },
undefined,
),
).toBe(123);
});
it("prefers RPC maxChars over config", () => {
expect(
resolveEffectiveChatHistoryMaxChars(
{ gateway: { webchat: { chatHistoryMaxChars: 123 } } },
45,
),
).toBe(45);
});
it("falls back to the default hardcoded limit", () => {
expect(resolveEffectiveChatHistoryMaxChars({}, undefined)).toBe(
DEFAULT_CHAT_HISTORY_TEXT_MAX_CHARS,
);
});
});
describe("timestampOptsFromConfig", () => {
it.each([
{

View File

@@ -118,7 +118,7 @@ function makeWsClient(params: {
connId: string;
clientIp: string;
role: "node" | "operator";
mode: "node" | "backend";
mode: "node" | "backend" | "webchat";
canvasCapability?: string;
canvasCapabilityExpiresAtMs?: number;
}): GatewayWsClient {
@@ -246,7 +246,7 @@ describe("gateway canvas host auth", () => {
handleHttpRequest: allowCanvasHostHttp,
run: async ({ listener, clients }) => {
const host = "127.0.0.1";
const operatorOnlyCapability = "operator-only";
const webchatCapability = "webchat-cap";
const expiredNodeCapability = "expired-node";
const activeNodeCapability = "active-node";
const activeCanvasPath = scopedCanvasPath(activeNodeCapability, `${CANVAS_HOST_PATH}/`);
@@ -264,19 +264,19 @@ describe("gateway canvas host auth", () => {
clients.add(
makeWsClient({
connId: "c-operator",
connId: "c-webchat",
clientIp: "192.168.1.10",
role: "operator",
mode: "backend",
canvasCapability: operatorOnlyCapability,
mode: "webchat",
canvasCapability: webchatCapability,
canvasCapabilityExpiresAtMs: Date.now() + 60_000,
}),
);
const operatorCapabilityBlocked = await fetchCanvas(
`http://${host}:${listener.port}${scopedCanvasPath(operatorOnlyCapability, `${CANVAS_HOST_PATH}/`)}`,
const webchatCapabilityAllowed = await fetchCanvas(
`http://${host}:${listener.port}${scopedCanvasPath(webchatCapability, `${CANVAS_HOST_PATH}/`)}`,
);
expect(operatorCapabilityBlocked.status).toBe(401);
expect(webchatCapabilityAllowed.status).toBe(200);
clients.add(
makeWsClient({

View File

@@ -450,6 +450,32 @@ describe("gateway server hooks", () => {
});
});
test("rejects mapped hook session rebinding into a disallowed target-agent prefix", async () => {
testState.hooksConfig = {
enabled: true,
token: HOOK_TOKEN,
allowRequestSessionKey: true,
allowedSessionKeyPrefixes: ["hook:", "agent:main:"],
mappings: [
{
match: { path: "mapped-rebind-denied" },
action: "agent",
agentId: "hooks",
messageTemplate: "Mapped: {{payload.subject}}",
sessionKey: "agent:main:slack:channel:c123",
},
],
};
setMainAndHooksAgents();
await withGatewayServer(async ({ port }) => {
const denied = await postHook(port, "/hooks/mapped-rebind-denied", { subject: "hello" });
expect(denied.status).toBe(400);
const body = (await denied.json()) as { error?: string };
expect(body.error).toContain("sessionKey must start with one of");
expect(cronIsolatedRun).not.toHaveBeenCalled();
});
});
test("dedupes repeated /hooks/agent deliveries by idempotency key", async () => {
testState.hooksConfig = { enabled: true, token: HOOK_TOKEN };
await withGatewayServer(async ({ port }) => {

View File

@@ -9,7 +9,6 @@ import {
} from "../auth.js";
import { CANVAS_CAPABILITY_TTL_MS } from "../canvas-capability.js";
import { getBearerToken, resolveHttpBrowserOriginPolicy } from "../http-utils.js";
import { GATEWAY_CLIENT_MODES, normalizeGatewayClientMode } from "../protocol/client-info.js";
import type { GatewayWsClient } from "./ws-types.js";
export function isCanvasPath(pathname: string): boolean {
@@ -22,22 +21,12 @@ export function isCanvasPath(pathname: string): boolean {
);
}
function isNodeWsClient(client: GatewayWsClient): boolean {
if (client.connect.role === "node") {
return true;
}
return normalizeGatewayClientMode(client.connect.client.mode) === GATEWAY_CLIENT_MODES.NODE;
}
function hasAuthorizedNodeWsClientForCanvasCapability(
function hasAuthorizedWsClientForCanvasCapability(
clients: Set<GatewayWsClient>,
capability: string,
): boolean {
const nowMs = Date.now();
for (const client of clients) {
if (!isNodeWsClient(client)) {
continue;
}
if (!client.canvasCapability || !client.canvasCapabilityExpiresAtMs) {
continue;
}
@@ -95,7 +84,7 @@ export async function authorizeCanvasRequest(params: {
lastAuthFailure = authResult;
}
if (canvasCapability && hasAuthorizedNodeWsClientForCanvasCapability(clients, canvasCapability)) {
if (canvasCapability && hasAuthorizedWsClientForCanvasCapability(clients, canvasCapability)) {
return { ok: true };
}
return lastAuthFailure ?? { ok: false, reason: "unauthorized" };

View File

@@ -0,0 +1,103 @@
import { EventEmitter } from "node:events";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { WebSocketServer } from "ws";
import type { ResolvedGatewayAuth } from "../auth.js";
const { attachGatewayWsMessageHandlerMock } = vi.hoisted(() => ({
attachGatewayWsMessageHandlerMock: vi.fn(),
}));
vi.mock("./ws-connection/message-handler.js", () => ({
attachGatewayWsMessageHandler: attachGatewayWsMessageHandlerMock,
}));
import { attachGatewayWsConnectionHandler } from "./ws-connection.js";
import { resolveSharedGatewaySessionGeneration } from "./ws-shared-generation.js";
function createLogger() {
return {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
};
}
function createResolvedAuth(token: string): ResolvedGatewayAuth {
return {
mode: "token",
allowTailscale: false,
token,
};
}
describe("attachGatewayWsConnectionHandler", () => {
beforeEach(() => {
attachGatewayWsMessageHandlerMock.mockReset();
});
it("threads current auth getters into the handshake handler instead of a stale snapshot", () => {
const listeners = new Map<string, (...args: unknown[]) => void>();
const wss = {
on: vi.fn((event: string, handler: (...args: unknown[]) => void) => {
listeners.set(event, handler);
}),
} as unknown as WebSocketServer;
const socket = Object.assign(new EventEmitter(), {
_socket: {
remoteAddress: "127.0.0.1",
remotePort: 1234,
localAddress: "127.0.0.1",
localPort: 5678,
},
send: vi.fn(),
close: vi.fn(),
});
const upgradeReq = {
headers: { host: "127.0.0.1:19001" },
socket: { localAddress: "127.0.0.1" },
};
const initialAuth = createResolvedAuth("token-before");
let currentAuth = initialAuth;
attachGatewayWsConnectionHandler({
wss,
clients: new Set(),
preauthConnectionBudget: { release: vi.fn() } as never,
port: 19001,
canvasHostEnabled: false,
resolvedAuth: initialAuth,
getResolvedAuth: () => currentAuth,
gatewayMethods: [],
events: [],
logGateway: createLogger() as never,
logHealth: createLogger() as never,
logWsControl: createLogger() as never,
extraHandlers: {},
broadcast: vi.fn(),
buildRequestContext: () =>
({
unsubscribeAllSessionEvents: vi.fn(),
nodeRegistry: { unregister: vi.fn() },
nodeUnsubscribeAll: vi.fn(),
}) as never,
});
const onConnection = listeners.get("connection");
expect(onConnection).toBeTypeOf("function");
onConnection?.(socket, upgradeReq);
expect(attachGatewayWsMessageHandlerMock).toHaveBeenCalledTimes(1);
const passed = attachGatewayWsMessageHandlerMock.mock.calls[0]?.[0] as {
getResolvedAuth: () => ResolvedGatewayAuth;
getRequiredSharedGatewaySessionGeneration?: () => string | undefined;
};
currentAuth = createResolvedAuth("token-after");
expect(passed.getResolvedAuth()).toMatchObject({ token: "token-after" });
expect(passed.getRequiredSharedGatewaySessionGeneration?.()).toBe(
resolveSharedGatewaySessionGeneration(currentAuth),
);
});
});

View File

@@ -2,6 +2,7 @@ import type { IncomingMessage } from "node:http";
import os from "node:os";
import type { WebSocket } from "ws";
import { loadConfig } from "../../../config/config.js";
import type { ResolvedGatewayAuth } from "../../auth.js";
import {
getBoundDeviceBootstrapProfile,
getDeviceBootstrapTokenProfile,
@@ -40,7 +41,6 @@ import {
type DeviceBootstrapProfile,
} from "../../../shared/device-bootstrap-profile.js";
import { roleScopesAllow } from "../../../shared/operator-scope-compat.js";
import { normalizeOptionalString } from "../../../shared/string-coerce.js";
import {
isBrowserOperatorUiClient,
isGatewayCliClient,
@@ -49,7 +49,7 @@ import {
} from "../../../utils/message-channel.js";
import { resolveRuntimeServiceVersion } from "../../../version.js";
import type { AuthRateLimiter } from "../../auth-rate-limit.js";
import type { GatewayAuthResult, ResolvedGatewayAuth } from "../../auth.js";
import type { GatewayAuthResult } from "../../auth.js";
import { isLocalDirectRequest } from "../../auth.js";
import {
buildCanvasScopedHostUrl,
@@ -173,7 +173,7 @@ export function attachGatewayWsMessageHandler(params: {
canvasHostUrl?: string;
connectNonce: string;
getResolvedAuth: () => ResolvedGatewayAuth;
getRequiredSharedGatewaySessionGeneration: () => string | undefined;
getRequiredSharedGatewaySessionGeneration?: () => string | undefined;
/** Optional rate limiter for auth brute-force protection. */
rateLimiter?: AuthRateLimiter;
/** Browser-origin fallback limiter (loopback is never exempt). */
@@ -267,7 +267,6 @@ export function attachGatewayWsMessageHandler(params: {
const hasUntrustedProxyHeaders = hasProxyHeaders && !remoteIsTrustedProxy;
const hostIsLocalish = isLocalishHost(requestHost);
const isLocalClient = isLocalDirectRequest(upgradeReq, trustedProxies, allowRealIpFallback);
const reportedClientIp =
isLocalClient || hasUntrustedProxyHeaders
? undefined
@@ -392,6 +391,7 @@ export function attachGatewayWsMessageHandler(params: {
const frame = parsed;
const connectParams = frame.params as ConnectParams;
const resolvedAuth = getResolvedAuth();
const clientLabel = connectParams.client.displayName ?? connectParams.client.id;
const clientMeta = {
client: connectParams.client.id,
@@ -458,7 +458,6 @@ export function attachGatewayWsMessageHandler(params: {
const isControlUi = isOperatorUiClient(connectParams.client);
const isBrowserOperatorUi = isBrowserOperatorUiClient(connectParams.client);
const isWebchat = isWebchatConnect(connectParams);
const resolvedAuth = getResolvedAuth();
if (enforceOriginCheckForAnyClient || isBrowserOperatorUi || isWebchat) {
const hostHeaderOriginFallbackEnabled =
configSnapshot.gateway?.controlUi?.dangerouslyAllowHostHeaderOriginFallback === true;
@@ -681,7 +680,7 @@ export function attachGatewayWsMessageHandler(params: {
rejectDeviceAuthInvalid("device-signature-stale", "device signature expired");
return;
}
const providedNonce = normalizeOptionalString(device.nonce) ?? "";
const providedNonce = typeof device.nonce === "string" ? device.nonce.trim() : "";
if (!providedNonce) {
rejectDeviceAuthInvalid("device-nonce-missing", "device nonce required");
return;
@@ -744,14 +743,15 @@ export function attachGatewayWsMessageHandler(params: {
rejectUnauthorized(authResult);
return;
}
const sharedGatewaySessionGeneration =
authMethod === "token" || authMethod === "password"
? resolveSharedGatewaySessionGeneration(resolvedAuth)
: undefined;
if (authMethod === "token" || authMethod === "password") {
const sharedGatewaySessionGeneration =
resolveSharedGatewaySessionGeneration(resolvedAuth);
const requiredSharedGatewaySessionGeneration =
getRequiredSharedGatewaySessionGeneration();
if (sharedGatewaySessionGeneration !== requiredSharedGatewaySessionGeneration) {
getRequiredSharedGatewaySessionGeneration?.();
if (
requiredSharedGatewaySessionGeneration !== undefined &&
sharedGatewaySessionGeneration !== requiredSharedGatewaySessionGeneration
) {
setCloseCause("gateway-auth-rotated", {
authGenerationStale: true,
});
@@ -891,9 +891,6 @@ export function attachGatewayWsMessageHandler(params: {
isWebchat,
reason,
});
// QR bootstrap onboarding stays single-use, but the first node bootstrap handshake
// should seed bounded device tokens and only consume the bootstrap token once the
// hello-ok path succeeds so reconnects can recover from pre-hello failures.
const allowSilentBootstrapPairing =
authMethod === "bootstrap-token" &&
reason === "not-paired" &&
@@ -1013,10 +1010,6 @@ export function attachGatewayWsMessageHandler(params: {
const isPaired = paired?.publicKey === devicePublicKey;
if (!isPaired) {
if (!(skipLocalBackendSelfPairing || skipControlUiPairingForDevice)) {
// Initial local backend/control-ui self-pairing can bypass the
// pairing prompt, but only while the device is still unpaired.
// Once a device is paired, reconnects must stay inside the
// approved role/scope baseline below.
const ok = await requirePairing("not-paired", paired);
if (!ok) {
return;
@@ -1212,11 +1205,14 @@ export function attachGatewayWsMessageHandler(params: {
snapshot.health = cachedHealth;
snapshot.stateVersion.health = getHealthVersion();
}
const canvasCapability =
role === "node" && canvasHostUrl ? mintCanvasCapabilityToken() : undefined;
const canvasCapability = canvasHostUrl ? mintCanvasCapabilityToken() : undefined;
const canvasCapabilityExpiresAtMs = canvasCapability
? Date.now() + CANVAS_CAPABILITY_TTL_MS
: undefined;
const usesSharedGatewayAuth = authMethod === "token" || authMethod === "password";
const sharedGatewaySessionGeneration = usesSharedGatewayAuth
? resolveSharedGatewaySessionGeneration(resolvedAuth)
: undefined;
const scopedCanvasHostUrl =
canvasHostUrl && canvasCapability
? (buildCanvasScopedHostUrl(canvasHostUrl, canvasCapability) ?? canvasHostUrl)
@@ -1254,7 +1250,7 @@ export function attachGatewayWsMessageHandler(params: {
socket,
connect: connectParams,
connId,
usesSharedGatewayAuth: authMethod === "token" || authMethod === "password",
usesSharedGatewayAuth,
sharedGatewaySessionGeneration,
presenceKey,
clientIp: reportedClientIp,
@@ -1271,7 +1267,7 @@ export function attachGatewayWsMessageHandler(params: {
remoteIp: reportedClientIp,
});
const instanceIdRaw = connectParams.client.instanceId;
const instanceId = normalizeOptionalString(instanceIdRaw) ?? "";
const instanceId = typeof instanceIdRaw === "string" ? instanceIdRaw.trim() : "";
const nodeIdsForPairing = new Set<string>([nodeSession.nodeId]);
if (instanceId) {
nodeIdsForPairing.add(instanceId);
@@ -1369,17 +1365,6 @@ export function attachGatewayWsMessageHandler(params: {
return;
}
if (client.usesSharedGatewayAuth) {
const requiredSharedGatewaySessionGeneration = getRequiredSharedGatewaySessionGeneration();
if (client.sharedGatewaySessionGeneration !== requiredSharedGatewaySessionGeneration) {
setCloseCause("gateway-auth-rotated", {
authGenerationStale: true,
});
close(4001, "gateway auth changed");
return;
}
}
// After handshake, accept only req frames
if (!validateRequestFrame(parsed)) {
send({
@@ -1395,6 +1380,21 @@ export function attachGatewayWsMessageHandler(params: {
}
const req = parsed;
logWs("in", "req", { connId, id: req.id, method: req.method });
if (client.usesSharedGatewayAuth) {
const requiredSharedGatewaySessionGeneration =
getRequiredSharedGatewaySessionGeneration?.();
if (
requiredSharedGatewaySessionGeneration !== undefined &&
client.sharedGatewaySessionGeneration !== requiredSharedGatewaySessionGeneration
) {
setCloseCause("gateway-auth-rotated", {
authGenerationStale: true,
method: req.method,
});
close(4001, "gateway auth changed");
return;
}
}
const respond = (
ok: boolean,
payload?: unknown,

View File

@@ -1,4 +1,5 @@
import type { ServerResponse } from "node:http";
import { PassThrough } from "node:stream";
import { vi } from "vitest";
export function makeMockHttpResponse(): {
@@ -6,13 +7,20 @@ export function makeMockHttpResponse(): {
setHeader: ReturnType<typeof vi.fn>;
end: ReturnType<typeof vi.fn>;
} {
const stream = new PassThrough();
const streamEnd = stream.end.bind(stream);
const setHeader = vi.fn();
const end = vi.fn();
const res = {
const end = vi.fn((chunk?: unknown) => {
if (chunk !== undefined) {
stream.write(chunk as string | Uint8Array);
}
streamEnd();
});
const res = Object.assign(stream, {
headersSent: false,
statusCode: 200,
setHeader,
end,
} as unknown as ServerResponse;
}) as unknown as ServerResponse;
return { res, setHeader, end };
}

View File

@@ -307,7 +307,7 @@ export async function readLocalFileSafely(params: {
filePath: string;
maxBytes?: number;
}): Promise<SafeLocalReadResult> {
const opened = await openVerifiedLocalFile(params.filePath);
const opened = await openLocalFileSafely({ filePath: params.filePath });
try {
return await readOpenedFileSafely({ opened, maxBytes: params.maxBytes });
} finally {
@@ -315,6 +315,10 @@ export async function readLocalFileSafely(params: {
}
}
export async function openLocalFileSafely(params: { filePath: string }): Promise<SafeOpenResult> {
return await openVerifiedLocalFile(params.filePath);
}
async function readOpenedFileSafely(params: {
opened: SafeOpenResult;
maxBytes?: number;

View File

@@ -1,8 +1,8 @@
import os from "node:os";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
appendLocalMediaParentRoots,
buildMediaLocalRoots,
getAgentScopedMediaLocalRoots,
getAgentScopedMediaLocalRootsForSources,
@@ -53,14 +53,6 @@ describe("local media roots", () => {
expect(normalizedRoots).not.toContain(picturesRoot);
}
function expectPicturesRootAbsent(roots: readonly string[], picturesRoot?: string) {
expectPicturesRootPresence({
roots,
shouldContainPictures: false,
picturesRoot,
});
}
function expectAgentMediaRootsCase(params: {
stateDir: string;
getRoots: () => readonly string[];
@@ -86,12 +78,12 @@ describe("local media roots", () => {
it.each([
{
name: "keeps temp, media cache, and workspace roots by default",
name: "keeps temp, media cache, canvas, and workspace roots by default",
stateDir: path.join("/tmp", "openclaw-media-roots-state"),
getRoots: () => getDefaultMediaLocalRoots(),
expectedContained: ["media", "workspace", "sandboxes"],
expectedContained: ["media", "canvas", "workspace", "sandboxes"],
expectedExcluded: ["agents"],
minLength: 3,
minLength: 4,
},
{
name: "adds the active agent workspace without re-opening broad agent state roots",
@@ -110,12 +102,38 @@ describe("local media roots", () => {
});
});
it("adds concrete parent roots for local media sources without widening to filesystem root", () => {
const picturesDir =
process.platform === "win32" ? "C:\\Users\\peter\\Pictures" : "/Users/peter/Pictures";
const moviesDir =
process.platform === "win32" ? "C:\\Users\\peter\\Movies" : "/Users/peter/Movies";
const roots = appendLocalMediaParentRoots(
["/tmp/base"],
[
path.join(picturesDir, "photo.png"),
pathToFileURL(path.join(moviesDir, "clip.mp4")).href,
"https://example.com/remote.png",
"/top-level-file.png",
],
);
expect(roots.map(normalizeHostPath)).toEqual(
expect.arrayContaining([
normalizeHostPath("/tmp/base"),
normalizeHostPath(picturesDir),
normalizeHostPath(moviesDir),
]),
);
expect(roots.map(normalizeHostPath)).not.toContain(normalizeHostPath("/"));
});
it.each([
{
name: "does not widen agent media roots for concrete local sources when workspaceOnly is disabled",
name: "widens agent media roots for concrete local sources when workspaceOnly is disabled",
stateDir: path.join("/tmp", "openclaw-flexible-media-roots-state"),
cfg: {},
shouldContainPictures: false,
shouldContainPictures: true,
},
{
name: "does not widen agent media roots when workspaceOnly is enabled",
@@ -130,7 +148,7 @@ describe("local media roots", () => {
shouldContainPictures: false,
},
{
name: "does not widen media roots even when messaging-profile agents explicitly enable filesystem tools",
name: "widens media roots again when messaging-profile agents explicitly enable filesystem tools",
stateDir: path.join("/tmp", "openclaw-messaging-fs-media-roots-state"),
cfg: {
tools: {
@@ -138,7 +156,7 @@ describe("local media roots", () => {
fs: { workspaceOnly: false },
},
},
shouldContainPictures: false,
shouldContainPictures: true,
},
] as const)("$name", ({ stateDir, cfg, shouldContainPictures }) => {
const roots = withStateDir(stateDir, () =>
@@ -151,47 +169,14 @@ describe("local media roots", () => {
expectPicturesRootPresence({ roots, shouldContainPictures });
});
it("keeps agent-scoped defaults even when mediaSources include file URLs and top-level paths", () => {
const stateDir = path.join("/tmp", "openclaw-file-url-media-roots-state");
const picturesDir =
process.platform === "win32" ? "C:\\Users\\peter\\Pictures" : "/Users/peter/Pictures";
const moviesDir =
process.platform === "win32" ? "C:\\Users\\peter\\Movies" : "/Users/peter/Movies";
const roots = withStateDir(stateDir, () =>
getAgentScopedMediaLocalRootsForSources({
cfg: {},
agentId: "ops",
mediaSources: [
path.join(picturesDir, "photo.png"),
pathToFileURL(path.join(moviesDir, "clip.mp4")).href,
"/top-level-file.png",
],
}),
);
it("keeps the config-dir media cache root when state and config paths differ", () => {
const stateDir = path.join("/tmp", "openclaw-legacy-state");
const configDir = path.join("/tmp", "openclaw-current-config");
const roots = buildMediaLocalRoots(stateDir, configDir);
expectNormalizedRootsContain(roots, [
path.join(stateDir, "media"),
path.join(stateDir, "workspace"),
path.join(stateDir, "workspace-ops"),
]);
expectPicturesRootAbsent(roots, picturesDir);
expectPicturesRootAbsent(roots, moviesDir);
expect(roots.map(normalizeHostPath)).not.toContain(normalizeHostPath("/"));
});
it("includes the config media root when legacy state and config dirs diverge", () => {
const homeRoot = path.join(os.tmpdir(), "openclaw-legacy-home-test");
const roots = buildMediaLocalRoots(
path.join(homeRoot, ".clawdbot"),
path.join(homeRoot, ".openclaw"),
);
expectNormalizedRootsContain(roots, [
path.join(homeRoot, ".clawdbot", "media"),
path.join(homeRoot, ".clawdbot", "workspace"),
path.join(homeRoot, ".clawdbot", "sandboxes"),
path.join(homeRoot, ".openclaw", "media"),
path.join(configDir, "media"),
]);
});
});

View File

@@ -1,16 +1,24 @@
import path from "node:path";
import { resolveAgentWorkspaceDir } from "../agents/agent-scope.js";
import {
resolveEffectiveToolFsRootExpansionAllowed,
resolveEffectiveToolFsWorkspaceOnly,
} from "../agents/tool-fs-policy.js";
import { resolveStateDir } from "../config/paths.js";
import type { OpenClawConfig } from "../config/types.js";
import { safeFileURLToPath } from "../infra/local-file-access.js";
import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js";
import { normalizeOptionalString } from "../shared/string-coerce.js";
import { resolveConfigDir } from "../utils.js";
import { resolveConfigDir, resolveUserPath } from "../utils.js";
type BuildMediaLocalRootsOptions = {
preferredTmpDir?: string;
};
let cachedPreferredTmpDir: string | undefined;
const HTTP_URL_RE = /^https?:\/\//i;
const DATA_URL_RE = /^data:/i;
const WINDOWS_DRIVE_RE = /^[A-Za-z]:[\\/]/;
function resolveCachedPreferredTmpDir(): string {
if (!cachedPreferredTmpDir) {
@@ -30,14 +38,11 @@ export function buildMediaLocalRoots(
return Array.from(
new Set([
preferredTmpDir,
path.join(resolvedConfigDir, "media"),
path.join(resolvedStateDir, "media"),
path.join(resolvedStateDir, "canvas"),
path.join(resolvedStateDir, "workspace"),
path.join(resolvedStateDir, "sandboxes"),
// Upgraded installs can still resolve the active state dir to the legacy
// ~/.clawdbot tree while new media writes already go under ~/.openclaw/media.
// Keep inbound media readable across that split without widening roots beyond
// the managed media cache.
path.join(resolvedConfigDir, "media"),
]),
);
}
@@ -66,24 +71,60 @@ export function getAgentScopedMediaLocalRoots(
return roots;
}
/**
* @deprecated Kept for plugin-sdk compatibility. Media sources no longer widen allowed roots.
*/
export function appendLocalMediaParentRoots(
roots: readonly string[],
_mediaSources?: readonly string[],
): string[] {
return Array.from(new Set(roots.map((root) => path.resolve(root))));
function resolveLocalMediaPath(source: string): string | undefined {
const trimmed = source.trim();
if (!trimmed || HTTP_URL_RE.test(trimmed) || DATA_URL_RE.test(trimmed)) {
return undefined;
}
if (trimmed.startsWith("file://")) {
try {
return safeFileURLToPath(trimmed);
} catch {
return undefined;
}
}
if (trimmed.startsWith("~")) {
return resolveUserPath(trimmed);
}
if (path.isAbsolute(trimmed) || WINDOWS_DRIVE_RE.test(trimmed)) {
return path.resolve(trimmed);
}
return undefined;
}
export function getAgentScopedMediaLocalRootsForSources({
cfg,
agentId,
mediaSources: _mediaSources,
}: {
export function appendLocalMediaParentRoots(
roots: readonly string[],
mediaSources?: readonly string[],
): string[] {
const appended = Array.from(new Set(roots.map((root) => path.resolve(root))));
for (const source of mediaSources ?? []) {
const localPath = resolveLocalMediaPath(source);
if (!localPath) {
continue;
}
const parentDir = path.dirname(localPath);
if (parentDir === path.parse(parentDir).root) {
continue;
}
const normalizedParent = path.resolve(parentDir);
if (!appended.includes(normalizedParent)) {
appended.push(normalizedParent);
}
}
return appended;
}
export function getAgentScopedMediaLocalRootsForSources(params: {
cfg: OpenClawConfig;
agentId?: string;
mediaSources?: readonly string[];
}): readonly string[] {
return getAgentScopedMediaLocalRoots(cfg, agentId);
const roots = getAgentScopedMediaLocalRoots(params.cfg, params.agentId);
if (resolveEffectiveToolFsWorkspaceOnly({ cfg: params.cfg, agentId: params.agentId })) {
return roots;
}
if (!resolveEffectiveToolFsRootExpansionAllowed({ cfg: params.cfg, agentId: params.agentId })) {
return roots;
}
return appendLocalMediaParentRoots(roots, params.mediaSources);
}

View File

@@ -1,17 +1,7 @@
import path from "node:path";
import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalLowercaseString,
} from "../shared/string-coerce.js";
import { fileTypeFromBuffer } from "file-type";
import { type MediaKind, mediaKindFromMime } from "./constants.js";
let fileTypeModulePromise: Promise<typeof import("file-type")> | undefined;
function loadFileTypeModule(): Promise<typeof import("file-type")> {
fileTypeModulePromise ??= import("file-type");
return fileTypeModulePromise;
}
// Map common mimes to preferred file extensions.
const EXT_BY_MIME: Record<string, string> = {
"image/heic": ".heic",
@@ -58,7 +48,7 @@ const MIME_BY_EXT: Record<string, string> = {
".jpeg": "image/jpeg",
".js": "text/javascript",
".htm": "text/html",
".xml": "text/xml", // pin text/xml as canonical (application/xml also maps to .xml in EXT_BY_MIME)
".xml": "text/xml",
};
const AUDIO_FILE_EXTENSIONS = new Set([
@@ -74,7 +64,11 @@ const AUDIO_FILE_EXTENSIONS = new Set([
]);
export function normalizeMimeType(mime?: string | null): string | undefined {
return normalizeOptionalLowercaseString(mime?.split(";")[0]);
if (!mime) {
return undefined;
}
const cleaned = mime.split(";")[0]?.trim().toLowerCase();
return cleaned || undefined;
}
async function sniffMime(buffer?: Buffer): Promise<string | undefined> {
@@ -82,7 +76,6 @@ async function sniffMime(buffer?: Buffer): Promise<string | undefined> {
return undefined;
}
try {
const { fileTypeFromBuffer } = await loadFileTypeModule();
const type = await fileTypeFromBuffer(buffer);
return type?.mime ?? undefined;
} catch {
@@ -97,15 +90,23 @@ export function getFileExtension(filePath?: string | null): string | undefined {
try {
if (/^https?:\/\//i.test(filePath)) {
const url = new URL(filePath);
return normalizeLowercaseStringOrEmpty(path.extname(url.pathname)) || undefined;
return path.extname(url.pathname).toLowerCase() || undefined;
}
} catch {
// fall back to plain path parsing
}
const ext = normalizeLowercaseStringOrEmpty(path.extname(filePath));
const ext = path.extname(filePath).toLowerCase();
return ext || undefined;
}
export function mimeTypeFromFilePath(filePath?: string | null): string | undefined {
const ext = getFileExtension(filePath);
if (!ext) {
return undefined;
}
return MIME_BY_EXT[ext];
}
export function isAudioFileName(fileName?: string | null): boolean {
const ext = getFileExtension(fileName);
if (!ext) {
@@ -126,7 +127,7 @@ function isGenericMime(mime?: string): boolean {
if (!mime) {
return true;
}
const m = normalizeLowercaseStringOrEmpty(mime);
const m = mime.toLowerCase();
return m === "application/octet-stream" || m === "application/zip";
}
@@ -174,7 +175,7 @@ export function isGifMedia(opts: {
contentType?: string | null;
fileName?: string | null;
}): boolean {
if (normalizeOptionalLowercaseString(opts.contentType) === "image/gif") {
if (opts.contentType?.toLowerCase() === "image/gif") {
return true;
}
const ext = getFileExtension(opts.fileName);
@@ -185,7 +186,7 @@ export function imageMimeFromFormat(format?: string | null): string | undefined
if (!format) {
return undefined;
}
switch (normalizeLowercaseStringOrEmpty(format)) {
switch (format.toLowerCase()) {
case "jpg":
case "jpeg":
return "image/jpeg";

View File

@@ -91,4 +91,16 @@ describe("splitMediaFromOutput", () => {
expectStableAudioAsVoiceDetectionCase(input);
}
});
it("returns ordered text and media segments while ignoring fenced MEDIA lines", () => {
const result = splitMediaFromOutput(
"Before\nMEDIA:https://example.com/a.png\n```text\nMEDIA:https://example.com/ignored.png\n```\nAfter",
);
expect(result.segments).toEqual([
{ type: "text", text: "Before" },
{ type: "media", url: "https://example.com/a.png" },
{ type: "text", text: "```text\nMEDIA:https://example.com/ignored.png\n```\nAfter" },
]);
});
});

View File

@@ -6,6 +6,16 @@ import { parseAudioTag } from "./audio-tags.js";
// Allow optional wrapping backticks and punctuation after the token; capture the core token.
export const MEDIA_TOKEN_RE = /\bMEDIA:\s*`?([^\n]+)`?/gi;
export type ParsedMediaOutputSegment =
| {
type: "text";
text: string;
}
| {
type: "media";
url: string;
};
export function normalizeMediaSource(src: string) {
return src.startsWith("file://") ? src.replace("file://", "") : src;
}
@@ -125,6 +135,7 @@ export function splitMediaFromOutput(raw: string): {
mediaUrls?: string[];
mediaUrl?: string; // legacy first item for backward compatibility
audioAsVoice?: boolean; // true if [[audio_as_voice]] tag was found
segments?: ParsedMediaOutputSegment[];
} {
// KNOWN: Leading whitespace is semantically meaningful in Markdown (lists, indented fences).
// We only trim the end; token cleanup below handles removing `MEDIA:` lines.
@@ -140,6 +151,19 @@ export function splitMediaFromOutput(raw: string): {
const media: string[] = [];
let foundMediaToken = false;
const segments: ParsedMediaOutputSegment[] = [];
const pushTextSegment = (text: string) => {
if (!text) {
return;
}
const last = segments[segments.length - 1];
if (last?.type === "text") {
last.text = `${last.text}\n${text}`;
return;
}
segments.push({ type: "text", text });
};
// Parse fenced code blocks to avoid extracting MEDIA tokens from inside them
const hasFenceMarkers = mayContainFenceMarkers(trimmedRaw);
@@ -154,6 +178,7 @@ export function splitMediaFromOutput(raw: string): {
// Skip MEDIA extraction if this line is inside a fenced code block
if (hasFenceMarkers && isInsideFence(fenceSpans, lineOffset)) {
keptLines.push(line);
pushTextSegment(line);
lineOffset += line.length + 1; // +1 for newline
continue;
}
@@ -161,6 +186,7 @@ export function splitMediaFromOutput(raw: string): {
const trimmedStart = line.trimStart();
if (!trimmedStart.startsWith("MEDIA:")) {
keptLines.push(line);
pushTextSegment(line);
lineOffset += line.length + 1; // +1 for newline
continue;
}
@@ -168,11 +194,13 @@ export function splitMediaFromOutput(raw: string): {
const matches = Array.from(line.matchAll(MEDIA_TOKEN_RE));
if (matches.length === 0) {
keptLines.push(line);
pushTextSegment(line);
lineOffset += line.length + 1; // +1 for newline
continue;
}
const pieces: string[] = [];
const lineSegments: ParsedMediaOutputSegment[] = [];
let cursor = 0;
for (const match of matches) {
@@ -219,6 +247,17 @@ export function splitMediaFromOutput(raw: string): {
}
}
if (!hasValidMedia && !unwrapped && /\s/.test(payloadValue)) {
const spacedFallback = normalizeMediaSource(cleanCandidate(payloadValue));
if (isValidMedia(spacedFallback, { allowSpaces: true, allowBareFilename: true })) {
media.splice(mediaStartIndex, media.length - mediaStartIndex, spacedFallback);
hasValidMedia = true;
foundMediaToken = true;
validCount = 1;
invalidParts.length = 0;
}
}
if (!hasValidMedia) {
const fallback = normalizeMediaSource(cleanCandidate(payloadValue));
if (isValidMedia(fallback, { allowSpaces: true, allowBareFilename: true })) {
@@ -230,6 +269,17 @@ export function splitMediaFromOutput(raw: string): {
}
if (hasValidMedia) {
const beforeText = pieces
.join("")
.replace(/[ \t]{2,}/g, " ")
.trim();
if (beforeText) {
lineSegments.push({ type: "text", text: beforeText });
}
pieces.length = 0;
for (const url of media.slice(mediaStartIndex, mediaStartIndex + validCount)) {
lineSegments.push({ type: "media", url });
}
if (invalidParts.length > 0) {
pieces.push(invalidParts.join(" "));
}
@@ -255,6 +305,14 @@ export function splitMediaFromOutput(raw: string): {
// If the line becomes empty, drop it.
if (cleanedLine) {
keptLines.push(cleanedLine);
lineSegments.push({ type: "text", text: cleanedLine });
}
for (const segment of lineSegments) {
if (segment.type === "text") {
pushTextSegment(segment.text);
continue;
}
segments.push(segment);
}
lineOffset += line.length + 1; // +1 for newline
}
@@ -274,9 +332,10 @@ export function splitMediaFromOutput(raw: string): {
}
if (media.length === 0) {
const parsedText = foundMediaToken || hasAudioAsVoice ? cleanedText : trimmedRaw;
const result: ReturnType<typeof splitMediaFromOutput> = {
// Return cleaned text if we found a media token OR audio tag, otherwise original
text: foundMediaToken || hasAudioAsVoice ? cleanedText : trimmedRaw,
text: parsedText,
segments: parsedText ? [{ type: "text", text: parsedText }] : [],
};
if (hasAudioAsVoice) {
result.audioAsVoice = true;
@@ -288,6 +347,7 @@ export function splitMediaFromOutput(raw: string): {
text: cleanedText,
mediaUrls: media,
mediaUrl: media[0],
segments: segments.length > 0 ? segments : [{ type: "text", text: cleanedText }],
...(hasAudioAsVoice ? { audioAsVoice: true } : {}),
};
}

View File

@@ -2,47 +2,54 @@ import fs from "node:fs/promises";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { CANVAS_HOST_PATH } from "../canvas-host/a2ui.js";
import { resolveStateDir } from "../config/paths.js";
import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js";
import { createSuiteTempRootTracker } from "../test-helpers/temp-dir.js";
import { createJpegBufferWithDimensions, createPngBufferWithDimensions } from "./test-helpers.js";
let loadWebMedia: typeof import("./web-media.js").loadWebMedia;
const mediaRootTracker = createSuiteTempRootTracker({
prefix: "web-media-core-",
parentDir: resolvePreferredOpenClawTmpDir(),
});
const TINY_PNG_BASE64 =
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/woAAn8B9FD5fHAAAAAASUVORK5CYII=";
let fixtureRoot = "";
let fakePdfFile = "";
let oversizedJpegFile = "";
let realPdfFile = "";
let tinyPngFile = "";
let stateDir = "";
let canvasPngFile = "";
let workspaceDir = "";
let workspacePngFile = "";
beforeAll(async () => {
({ loadWebMedia } = await import("./web-media.js"));
await mediaRootTracker.setup();
fixtureRoot = await mediaRootTracker.make("case");
fakePdfFile = path.join(fixtureRoot, "fake.pdf");
realPdfFile = path.join(fixtureRoot, "real.pdf");
fixtureRoot = await fs.mkdtemp(path.join(resolvePreferredOpenClawTmpDir(), "web-media-core-"));
tinyPngFile = path.join(fixtureRoot, "tiny.png");
oversizedJpegFile = path.join(fixtureRoot, "oversized.jpg");
await fs.writeFile(fakePdfFile, "TOP_SECRET_TEXT", "utf8");
await fs.writeFile(
realPdfFile,
Buffer.from("%PDF-1.4\n1 0 obj\n<<>>\nendobj\ntrailer\n<<>>\n%%EOF"),
);
await fs.writeFile(tinyPngFile, Buffer.from(TINY_PNG_BASE64, "base64"));
await fs.writeFile(
oversizedJpegFile,
createJpegBufferWithDimensions({ width: 6_000, height: 5_000 }),
workspaceDir = path.join(fixtureRoot, "workspace");
workspacePngFile = path.join(workspaceDir, "chart.png");
await fs.mkdir(workspaceDir, { recursive: true });
await fs.writeFile(workspacePngFile, Buffer.from(TINY_PNG_BASE64, "base64"));
stateDir = resolveStateDir();
canvasPngFile = path.join(
stateDir,
"canvas",
"documents",
"cv_test",
"collection.media",
"tiny.png",
);
await fs.mkdir(path.dirname(canvasPngFile), { recursive: true });
await fs.writeFile(canvasPngFile, Buffer.from(TINY_PNG_BASE64, "base64"));
});
afterAll(async () => {
await mediaRootTracker.cleanup();
if (fixtureRoot) {
await fs.rm(fixtureRoot, { recursive: true, force: true });
}
if (stateDir) {
await fs.rm(path.join(stateDir, "canvas", "documents", "cv_test"), {
recursive: true,
force: true,
});
}
});
describe("loadWebMedia", () => {
@@ -108,24 +115,6 @@ describe("loadWebMedia", () => {
await expectLoadedWebMediaCase(createUrl());
});
it("rejects oversized pixel-count images before decode/resize backends run", async () => {
const oversizedPngFile = path.join(fixtureRoot, "oversized.png");
await fs.writeFile(
oversizedPngFile,
createPngBufferWithDimensions({ width: 8_000, height: 4_000 }),
);
await expect(loadWebMedia(oversizedPngFile, createLocalWebMediaOptions())).rejects.toThrow(
/pixel input limit/i,
);
});
it("preserves pixel-limit errors for oversized JPEG optimization", async () => {
await expect(loadWebMedia(oversizedJpegFile, createLocalWebMediaOptions())).rejects.toThrow(
/pixel input limit/i,
);
});
it.each([
{
name: "rejects remote-host file URLs before filesystem checks",
@@ -147,67 +136,60 @@ describe("loadWebMedia", () => {
await expectRejectedWebMediaWithoutFilesystemAccess(testCase);
});
describe("workspaceDir relative path resolution", () => {
it("resolves a bare filename against workspaceDir", async () => {
const result = await loadWebMedia("tiny.png", {
...createLocalWebMediaOptions(),
workspaceDir: fixtureRoot,
});
expect(result.kind).toBe("image");
expect(result.buffer.length).toBeGreaterThan(0);
});
it("loads browser-style canvas media paths as managed local files", async () => {
const result = await loadWebMedia(
`${CANVAS_HOST_PATH}/documents/cv_test/collection.media/tiny.png`,
{ maxBytes: 1024 * 1024 },
);
expect(result.kind).toBe("image");
expect(result.buffer.length).toBeGreaterThan(0);
});
it("resolves a dot-relative path against workspaceDir", async () => {
const result = await loadWebMedia("./tiny.png", {
...createLocalWebMediaOptions(),
workspaceDir: fixtureRoot,
});
expect(result.kind).toBe("image");
expect(result.buffer.length).toBeGreaterThan(0);
it("resolves relative local media paths against the provided workspace directory", async () => {
const result = await loadWebMedia("chart.png", {
maxBytes: 1024 * 1024,
localRoots: [workspaceDir],
workspaceDir,
});
expect(result.kind).toBe("image");
expect(result.buffer.length).toBeGreaterThan(0);
});
it("resolves a MEDIA:-prefixed relative path against workspaceDir", async () => {
const result = await loadWebMedia("MEDIA:tiny.png", {
...createLocalWebMediaOptions(),
workspaceDir: fixtureRoot,
});
expect(result.kind).toBe("image");
expect(result.buffer.length).toBeGreaterThan(0);
});
it("leaves absolute paths unchanged when workspaceDir is set", async () => {
const result = await loadWebMedia(tinyPngFile, {
...createLocalWebMediaOptions(),
workspaceDir: "/some/other/dir",
});
expect(result.kind).toBe("image");
expect(result.buffer.length).toBeGreaterThan(0);
it("rejects host-read text files outside local roots", async () => {
const secretFile = path.join(fixtureRoot, "secret.txt");
await fs.writeFile(secretFile, "secret", "utf8");
await expect(
loadWebMedia(secretFile, {
maxBytes: 1024 * 1024,
localRoots: "any",
readFile: async (filePath) => await fs.readFile(filePath),
hostReadCapability: true,
}),
).rejects.toMatchObject({
code: "path-not-allowed",
});
});
describe("host read capability", () => {
it("rejects document uploads that only match by file extension", async () => {
await expect(
loadWebMedia(fakePdfFile, {
maxBytes: 1024 * 1024,
localRoots: [fixtureRoot],
hostReadCapability: true,
}),
).rejects.toMatchObject({
code: "path-not-allowed",
});
});
it("still allows real PDF uploads detected from file content", async () => {
const result = await loadWebMedia(realPdfFile, {
it("rejects renamed host-read text files even when the extension looks allowed", async () => {
const disguisedPdf = path.join(fixtureRoot, "secret.pdf");
await fs.writeFile(disguisedPdf, "secret", "utf8");
await expect(
loadWebMedia(disguisedPdf, {
maxBytes: 1024 * 1024,
localRoots: [fixtureRoot],
localRoots: "any",
readFile: async (filePath) => await fs.readFile(filePath),
hostReadCapability: true,
});
}),
).rejects.toMatchObject({
code: "path-not-allowed",
});
});
expect(result.kind).toBe("document");
expect(result.contentType).toBe("application/pdf");
expect(result.fileName).toBe("real.pdf");
it("rejects traversal-style canvas media paths before filesystem access", async () => {
await expect(
loadWebMedia(`${CANVAS_HOST_PATH}/documents/../collection.media/tiny.png`),
).rejects.toMatchObject({
code: "path-not-allowed",
});
});
});

View File

@@ -1,19 +1,15 @@
import path from "node:path";
import { resolveCanvasHttpPathToLocalPath } from "../gateway/canvas-documents.js";
import { logVerbose, shouldLogVerbose } from "../globals.js";
import { SafeOpenError, readLocalFileSafely } from "../infra/fs-safe.js";
import { assertNoWindowsNetworkPath, safeFileURLToPath } from "../infra/local-file-access.js";
import type { SsrFPolicy } from "../infra/net/ssrf.js";
import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalString,
} from "../shared/string-coerce.js";
import { resolveUserPath } from "../utils.js";
import { maxBytesForKind, type MediaKind } from "./constants.js";
import { fetchRemoteMedia } from "./fetch.js";
import {
convertHeicToJpeg,
hasAlphaChannel,
MAX_IMAGE_INPUT_PIXELS,
optimizeImageToPng,
resizeToJpeg,
} from "./image-ops.js";
@@ -23,7 +19,13 @@ import {
LocalMediaAccessError,
type LocalMediaAccessErrorCode,
} from "./local-media-access.js";
import { detectMime, extensionForMime, kindFromMime, normalizeMimeType } from "./mime.js";
import {
detectMime,
extensionForMime,
getFileExtension,
kindFromMime,
normalizeMimeType,
} from "./mime.js";
export { getDefaultLocalRoots, LocalMediaAccessError };
export type { LocalMediaAccessErrorCode };
@@ -39,6 +41,7 @@ type WebMediaOptions = {
maxBytes?: number;
optimizeImages?: boolean;
ssrfPolicy?: SsrFPolicy;
workspaceDir?: string;
/** Allowed root directories for local path reads. "any" is deprecated; prefer sandboxValidated + readFile. */
localRoots?: readonly string[] | "any";
/** Caller already validated the local path (sandbox/other guards); requires readFile override. */
@@ -46,8 +49,6 @@ type WebMediaOptions = {
readFile?: (filePath: string) => Promise<Buffer>;
/** Host-local fs-policy read piggyback; rejects plaintext-like document sends. */
hostReadCapability?: boolean;
/** Agent workspace directory for resolving relative MEDIA: paths. */
workspaceDir?: string;
};
function resolveWebMediaOptions(params: {
@@ -73,6 +74,7 @@ function resolveWebMediaOptions(params: {
const HEIC_MIME_RE = /^image\/hei[cf]$/i;
const HEIC_EXT_RE = /\.(heic|heif)$/i;
const WINDOWS_DRIVE_RE = /^[A-Za-z]:[\\/]/;
const HOST_READ_ALLOWED_DOCUMENT_MIMES = new Set([
"application/msword",
"application/pdf",
@@ -96,44 +98,54 @@ function formatCapReduce(label: string, cap: number, size: number): string {
return `${label} could not be reduced below ${formatMb(cap, 0)}MB (got ${formatMb(size)}MB)`;
}
function isPixelLimitError(error: unknown): boolean {
return (
error instanceof Error &&
error.message.includes(`${MAX_IMAGE_INPUT_PIXELS.toLocaleString("en-US")} pixel input limit`)
);
}
function isHeicSource(opts: { contentType?: string; fileName?: string }): boolean {
if (HEIC_MIME_RE.test(normalizeOptionalString(opts.contentType) ?? "")) {
if (opts.contentType && HEIC_MIME_RE.test(opts.contentType.trim())) {
return true;
}
if (HEIC_EXT_RE.test(normalizeOptionalString(opts.fileName) ?? "")) {
if (opts.fileName && HEIC_EXT_RE.test(opts.fileName.trim())) {
return true;
}
return false;
}
function assertHostReadMediaAllowed(params: {
sniffedContentType?: string;
contentType?: string;
filePath?: string;
kind: MediaKind | undefined;
}): void {
if (params.kind === "image" || params.kind === "audio" || params.kind === "video") {
const sniffedKind = kindFromMime(params.sniffedContentType);
if (sniffedKind === "image" || sniffedKind === "audio" || sniffedKind === "video") {
return;
}
if (params.kind !== "document") {
const contentType = normalizeMimeType(params.contentType);
throw new LocalMediaAccessError(
"path-not-allowed",
`Host-local media sends only allow images, audio, video, PDF, and Office documents (got ${contentType ?? "unknown"}).`,
);
const sniffedMime = normalizeMimeType(params.sniffedContentType);
if (
sniffedKind === "document" &&
sniffedMime &&
HOST_READ_ALLOWED_DOCUMENT_MIMES.has(sniffedMime)
) {
return;
}
if (
sniffedMime === "application/x-cfb" &&
[".doc", ".ppt", ".xls"].includes(getFileExtension(params.filePath) ?? "")
) {
return;
}
const normalizedMime = normalizeMimeType(params.contentType);
if (normalizedMime && HOST_READ_ALLOWED_DOCUMENT_MIMES.has(normalizedMime)) {
return;
if (
params.kind === "document" &&
normalizedMime &&
HOST_READ_ALLOWED_DOCUMENT_MIMES.has(normalizedMime)
) {
throw new LocalMediaAccessError(
"path-not-allowed",
`Host-local media sends require buffer-verified media/document types (got fallback ${normalizedMime}).`,
);
}
throw new LocalMediaAccessError(
"path-not-allowed",
`Host-local media sends only allow images, audio, video, PDF, and Office documents (got ${normalizedMime ?? "unknown"}).`,
`Host-local media sends only allow buffer-verified images, audio, video, PDF, and Office documents (got ${sniffedMime ?? normalizedMime ?? "unknown"}).`,
);
}
@@ -185,9 +197,7 @@ async function optimizeImageWithFallback(params: {
meta?: { contentType?: string; fileName?: string };
}): Promise<OptimizedImage> {
const { buffer, cap, meta } = params;
const isPng =
meta?.contentType === "image/png" ||
normalizeLowercaseStringOrEmpty(meta?.fileName).endsWith(".png");
const isPng = meta?.contentType === "image/png" || meta?.fileName?.toLowerCase().endsWith(".png");
const hasAlpha = isPng && (await hasAlphaChannel(buffer));
if (hasAlpha) {
@@ -214,11 +224,11 @@ async function loadWebMediaInternal(
maxBytes,
optimizeImages = true,
ssrfPolicy,
workspaceDir,
localRoots,
sandboxValidated = false,
readFile: readFileOverride,
hostReadCapability = false,
workspaceDir,
} = options;
// Strip MEDIA: prefix used by agent tools (e.g. TTS) to tag media paths.
// Be lenient: LLM output may add extra whitespace (e.g. " MEDIA : /tmp/x.png").
@@ -231,6 +241,7 @@ async function loadWebMediaInternal(
throw new LocalMediaAccessError("invalid-file-url", (err as Error).message, { cause: err });
}
}
mediaUrl = resolveCanvasHttpPathToLocalPath(mediaUrl) ?? mediaUrl;
const optimizeAndClampImage = async (
buffer: Buffer,
@@ -319,11 +330,7 @@ async function loadWebMediaInternal(
if (mediaUrl.startsWith("~")) {
mediaUrl = resolveUserPath(mediaUrl);
}
// Resolve relative MEDIA: paths (e.g. "poker_profit.png", "./subdir/file.png")
// against the agent workspace directory so bare filenames written by agents
// are found on disk and pass the local-roots allowlist check.
if (workspaceDir && !path.isAbsolute(mediaUrl)) {
if (workspaceDir && !path.isAbsolute(mediaUrl) && !WINDOWS_DRIVE_RE.test(mediaUrl)) {
mediaUrl = path.resolve(workspaceDir, mediaUrl);
}
try {
@@ -376,10 +383,17 @@ async function loadWebMediaInternal(
throw err;
}
}
const detectedMime = await detectMime({ buffer: data, filePath: mediaUrl });
const verifiedMime = hostReadCapability ? await detectMime({ buffer: data }) : detectedMime;
const mime = verifiedMime ?? detectedMime;
const sniffedMime = await detectMime({ buffer: data });
const mime = await detectMime({ buffer: data, filePath: mediaUrl });
const kind = kindFromMime(mime);
if (hostReadCapability) {
assertHostReadMediaAllowed({
sniffedContentType: sniffedMime,
contentType: mime,
filePath: mediaUrl,
kind,
});
}
let fileName = path.basename(mediaUrl) || undefined;
if (fileName && !path.extname(fileName) && mime) {
const ext = extensionForMime(mime);
@@ -387,12 +401,6 @@ async function loadWebMediaInternal(
fileName = `${fileName}${ext}`;
}
}
if (hostReadCapability) {
assertHostReadMediaAllowed({
contentType: verifiedMime,
kind: kindFromMime(detectedMime ?? verifiedMime),
});
}
return await clampAndFinalize({
buffer: data,
contentType: mime,
@@ -472,10 +480,7 @@ export async function optimizeImageToJpeg(
quality,
};
}
} catch (error) {
if (isPixelLimitError(error)) {
throw error;
}
} catch {
// Continue trying other size/quality combinations
}
}

View File

@@ -51,21 +51,16 @@
/* Chat thread - scrollable middle section, transparent */
.chat-thread {
flex: 1;
flex: 1 1 0;
/* Grow, shrink, and use 0 base for proper scrolling */
overflow-y: auto;
overflow-x: hidden;
padding: 0 12px 16px;
padding: 0 6px 6px;
margin: 0 0 0 0;
min-height: 0;
/* Allow shrinking for flex scroll behavior */
border-radius: 0;
border: none;
border-radius: var(--radius-md);
background: transparent;
display: flex;
flex-direction: column;
gap: 12px;
min-width: 0;
}
.chat-thread-inner > :first-child {
@@ -305,18 +300,114 @@
justify-content: flex-end;
}
/* Embedded audio (e.g. gateway-injected TTS from slash commands) */
.chat-message-audio {
.chat-assistant-attachments {
display: flex;
flex-direction: column;
gap: 8px;
gap: 10px;
margin-bottom: 8px;
max-width: min(420px, 100%);
}
.chat-message-audio-el {
width: 100%;
min-height: 36px;
.chat-assistant-attachments img.chat-message-image {
display: block;
}
.chat-assistant-attachment-card {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: color-mix(in srgb, var(--card) 82%, var(--bg));
}
.chat-assistant-attachment-card--audio,
.chat-assistant-attachment-card--video {
display: flex;
flex-direction: column;
align-items: stretch;
}
.chat-assistant-attachment-card--audio audio,
.chat-assistant-attachment-card--video video {
width: min(100%, 360px);
max-width: 100%;
}
.chat-assistant-attachment-card--video video {
border-radius: var(--radius-sm);
background: #000;
}
.chat-assistant-attachment-card__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 8px;
}
.chat-assistant-attachment-card__title,
.chat-assistant-attachment-card__link {
color: var(--text);
font-size: 13px;
text-decoration: none;
word-break: break-word;
}
.chat-assistant-attachment-card__reason {
color: var(--muted);
font-size: 12px;
line-height: 1.4;
}
.chat-assistant-attachment-card__icon {
display: inline-flex;
width: 16px;
height: 16px;
color: var(--muted);
}
.chat-assistant-attachment-card__icon svg {
width: 16px;
height: 16px;
}
.chat-assistant-attachment-badge,
.chat-reply-pill {
display: inline-flex;
align-items: center;
gap: 6px;
width: fit-content;
max-width: 100%;
border-radius: 999px;
font-size: 12px;
}
.chat-assistant-attachment-badge {
padding: 3px 8px;
background: color-mix(in srgb, var(--accent) 14%, transparent);
color: var(--accent);
border: 1px solid color-mix(in srgb, var(--accent) 24%, transparent);
}
.chat-reply-pill {
margin-bottom: 8px;
padding: 5px 10px;
color: var(--muted);
border: 1px solid var(--border);
background: color-mix(in srgb, var(--bg) 70%, transparent);
}
.chat-reply-pill__icon {
display: inline-flex;
width: 14px;
height: 14px;
}
.chat-reply-pill__icon svg {
width: 14px;
height: 14px;
}
/* Compose input row - horizontal layout */
@@ -762,8 +853,8 @@
}
.chat-controls__session {
min-width: 98px;
max-width: 190px;
min-width: 140px;
max-width: 300px;
}
.chat-controls__session-row {
@@ -774,13 +865,8 @@
}
.chat-controls__model {
min-width: 124px;
max-width: 206px;
}
.chat-controls__thinking-select {
min-width: 88px;
max-width: 118px;
min-width: 170px;
max-width: 320px;
}
.chat-controls__thinking {
@@ -805,17 +891,13 @@
.chat-controls__session select {
padding: 6px 10px;
font-size: 13px;
max-width: 190px;
max-width: 300px;
overflow: hidden;
text-overflow: ellipsis;
}
.chat-controls__model select {
max-width: 206px;
}
.chat-controls__thinking-select select {
max-width: 118px;
max-width: 320px;
}
.chat-controls__thinking {
@@ -829,6 +911,10 @@
border: 1px solid var(--border);
}
.chat-controls__auto-expand {
padding: 8px;
}
/* Light theme thinking indicator override */
:root[data-theme-mode="light"] .chat-controls__thinking {
background: rgba(255, 255, 255, 0.9);
@@ -846,11 +932,6 @@
max-width: none;
}
.chat-controls__thinking-select {
min-width: 130px;
max-width: none;
}
.chat-controls {
gap: 8px;
}
@@ -858,14 +939,6 @@
.chat-compose__field textarea {
min-height: 64px;
}
.card.chat {
padding: 0;
}
.chat-thread {
padding: 0 0 16px;
}
}
@media (max-width: 640px) {
@@ -904,10 +977,6 @@
.chat-controls__model {
min-width: 150px;
}
.chat-controls__thinking-select {
min-width: 140px;
}
}
/* Chat loading skeleton */

View File

@@ -1,20 +1,28 @@
/* Tool Card Styles */
.chat-tool-card {
border: 1px solid var(--border);
border: 1px solid color-mix(in srgb, var(--border) 85%, transparent);
border-radius: var(--radius-md);
padding: 10px 12px;
padding: 12px 14px;
margin-top: 6px;
background: var(--card);
background: color-mix(in srgb, var(--card) 88%, var(--secondary) 12%);
box-shadow: inset 0 1px 0 color-mix(in srgb, var(--bg) 75%, transparent);
transition:
border-color var(--duration-fast) ease-out,
background var(--duration-fast) ease-out;
background var(--duration-fast) ease-out,
box-shadow var(--duration-fast) ease-out;
max-height: 120px;
overflow: hidden;
}
.chat-tool-card--expanded {
max-height: none;
overflow: visible;
}
.chat-tool-card:hover {
border-color: var(--border-strong);
background: var(--bg-hover);
border-color: color-mix(in srgb, var(--border-strong) 80%, transparent);
background: color-mix(in srgb, var(--card) 70%, var(--bg-hover) 30%);
box-shadow: inset 0 1px 0 color-mix(in srgb, var(--bg) 85%, transparent);
}
/* First tool card in a group - no top margin */
@@ -35,8 +43,16 @@
.chat-tool-card__header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 10px;
}
.chat-tool-card__actions {
display: inline-flex;
align-items: center;
gap: 8px;
gap: 6px;
flex-wrap: wrap;
justify-content: flex-end;
}
.chat-tool-card__title {
@@ -44,7 +60,7 @@
align-items: center;
gap: 6px;
font-weight: 600;
font-size: 13px;
font-size: 14px;
line-height: 1.2;
}
@@ -68,19 +84,41 @@
}
/* "View >" action link */
.chat-tool-card__action {
.chat-tool-card__action,
.chat-tool-card__action-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 4px;
font-size: 12px;
color: var(--accent);
opacity: 0.8;
transition: opacity 150ms ease-out;
font-size: 11px;
color: var(--muted);
opacity: 1;
transition:
color 150ms ease-out,
background 150ms ease-out,
border-color 150ms ease-out;
}
.chat-tool-card__action svg {
width: 12px;
height: 12px;
.chat-tool-card__action-btn {
width: 28px;
height: 28px;
padding: 0;
border: 1px solid color-mix(in srgb, var(--border) 80%, transparent);
border-radius: 999px;
background: color-mix(in srgb, var(--secondary) 55%, transparent);
cursor: pointer;
font: inherit;
}
.chat-tool-card__action-icon {
display: inline-flex;
align-items: center;
justify-content: center;
}
.chat-tool-card__action-icon svg {
width: 11px;
height: 11px;
stroke: currentColor;
fill: none;
stroke-width: 1.5px;
@@ -88,8 +126,12 @@
stroke-linejoin: round;
}
.chat-tool-card--clickable:hover .chat-tool-card__action {
opacity: 1;
.chat-tool-card--clickable:hover .chat-tool-card__action,
.chat-tool-card__action-btn:hover,
.chat-tool-card__action-btn:focus-visible {
color: var(--text);
background: color-mix(in srgb, var(--bg-hover) 70%, transparent);
border-color: color-mix(in srgb, var(--border-strong) 70%, transparent);
}
/* Status indicator for completed/empty results */
@@ -111,47 +153,212 @@
.chat-tool-card__status-text {
font-size: 11px;
margin-top: 4px;
margin-top: 10px;
}
.chat-tool-card__detail {
font-size: 12px;
color: var(--muted);
margin-top: 4px;
margin-top: 6px;
}
/* Collapsed preview - fixed height with truncation */
.chat-tools-inline {
display: grid;
gap: 6px;
}
.chat-tool-card__block {
margin-top: 12px;
}
.chat-tool-card__preview,
.chat-tool-card__raw {
margin-top: 12px;
}
.chat-tool-card__preview {
font-size: 11px;
color: var(--muted);
margin-top: 8px;
padding: 8px 10px;
background: var(--secondary);
border: 1px solid color-mix(in srgb, var(--border) 78%, transparent);
border-radius: var(--radius-md);
white-space: pre-wrap;
background: color-mix(in srgb, var(--secondary) 78%, transparent);
overflow: hidden;
max-height: 44px;
line-height: 1.4;
border: 1px solid var(--border);
}
.chat-tool-card--clickable:hover .chat-tool-card__preview {
background: var(--bg-hover);
border-color: var(--border-strong);
.chat-tool-card__preview-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 10px 12px;
border-bottom: 1px solid color-mix(in srgb, var(--border) 72%, transparent);
background: color-mix(in srgb, var(--card) 82%, transparent);
}
/* Short inline output */
.chat-tool-card__inline {
.chat-tool-card__preview-label {
font-size: 11px;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--muted);
}
.chat-tool-card__preview-tabs {
display: inline-flex;
align-items: center;
gap: 4px;
}
.chat-tool-card__preview-tab {
border: 1px solid color-mix(in srgb, var(--border) 76%, transparent);
background: color-mix(in srgb, var(--bg) 72%, transparent);
color: var(--muted);
border-radius: 999px;
padding: 4px 10px;
font: inherit;
font-size: 12px;
cursor: pointer;
transition:
color 150ms ease-out,
border-color 150ms ease-out,
background 150ms ease-out;
}
.chat-tool-card__preview-tab.is-active,
.chat-tool-card__preview-tab:hover,
.chat-tool-card__preview-tab:focus-visible {
color: var(--text);
margin-top: 6px;
padding: 6px 8px;
background: var(--secondary);
border-radius: var(--radius-sm);
border-color: color-mix(in srgb, var(--border-strong) 80%, transparent);
background: color-mix(in srgb, var(--card) 92%, transparent);
}
.chat-tool-card__preview-panel {
padding: 12px;
}
.chat-tool-card__preview-frame {
display: block;
width: 100%;
min-height: 420px;
border: 1px solid color-mix(in srgb, var(--border) 68%, transparent);
border-radius: var(--radius-md);
background: #fff;
}
.chat-tool-card__raw-toggle {
width: 100%;
display: inline-flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 10px 12px;
border: 1px solid color-mix(in srgb, var(--border) 75%, transparent);
border-radius: var(--radius-md);
background: color-mix(in srgb, var(--secondary) 68%, transparent);
color: var(--muted);
font: inherit;
font-size: 12px;
cursor: pointer;
transition:
color 150ms ease-out,
border-color 150ms ease-out,
background 150ms ease-out;
}
.chat-tool-card__raw-toggle:hover,
.chat-tool-card__raw-toggle:focus-visible {
color: var(--text);
border-color: color-mix(in srgb, var(--border-strong) 80%, transparent);
background: color-mix(in srgb, var(--card) 92%, transparent);
}
.chat-tool-card__raw-toggle[aria-expanded="true"] .chat-tool-card__raw-toggle-icon {
transform: rotate(180deg);
}
.chat-tool-card__raw-toggle-icon {
display: inline-flex;
align-items: center;
justify-content: center;
transition: transform 150ms ease-out;
}
.chat-tool-card__raw-toggle-icon svg {
width: 14px;
height: 14px;
stroke: currentColor;
fill: none;
stroke-width: 1.6px;
stroke-linecap: round;
stroke-linejoin: round;
}
.chat-tool-card__raw-body {
margin-top: 8px;
}
.chat-tool-card__block-header {
display: inline-flex;
align-items: center;
gap: 6px;
margin-bottom: 6px;
color: var(--muted);
}
.chat-tool-card__block-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 14px;
height: 14px;
}
.chat-tool-card__block-icon svg {
width: 14px;
height: 14px;
stroke: currentColor;
fill: none;
stroke-width: 1.5px;
stroke-linecap: round;
stroke-linejoin: round;
}
.chat-tool-card__block-label {
font-size: 11px;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: inherit;
}
.chat-tool-card__block-preview,
.chat-tool-card__block-content,
.chat-tool-card__block-empty {
margin: 0;
padding: 11px 12px;
border-radius: var(--radius-md);
border: 1px solid color-mix(in srgb, var(--border) 75%, transparent);
background: color-mix(in srgb, var(--secondary) 82%, transparent);
font-size: 11px;
line-height: 1.45;
white-space: pre-wrap;
word-break: break-word;
}
.chat-tool-card__block-preview,
.chat-tool-card__block-empty {
color: var(--muted);
}
.chat-tool-card__block-content {
color: var(--text);
overflow-x: auto;
}
.chat-tool-card__block-preview {
overflow: hidden;
max-height: 52px;
}
.chat-tools-summary {
display: flex;
align-items: center;
@@ -331,12 +538,21 @@
border: 1px solid color-mix(in srgb, var(--border) 75%, transparent);
border-radius: var(--radius-md);
background: color-mix(in srgb, var(--bg-hover) 35%, transparent);
width: 100%;
text-align: left;
appearance: none;
-webkit-appearance: none;
font: inherit;
transition:
color 150ms ease,
background 150ms ease,
border-color 150ms ease;
}
.chat-tool-msg-summary[type="button"] {
background: color-mix(in srgb, var(--bg-hover) 35%, transparent);
}
.chat-tool-msg-summary::-webkit-details-marker {
display: none;
}
@@ -348,6 +564,15 @@
transition: transform 150ms ease;
}
.chat-tool-msg-collapse--static > .chat-tool-msg-summary::before {
display: none;
}
.chat-tool-msg-collapse--manual.is-open > .chat-tool-msg-summary::before,
.chat-tool-msg-summary[aria-expanded="true"]::before {
transform: rotate(90deg);
}
.chat-tool-msg-collapse[open] > .chat-tool-msg-summary::before {
transform: rotate(90deg);
}
@@ -407,6 +632,42 @@
min-width: 0;
}
.chat-tool-msg-summary__spacer {
flex: 1 1 auto;
}
.chat-tool-msg-summary__btn {
width: 24px;
height: 24px;
padding: 0;
border: 1px solid color-mix(in srgb, var(--border) 70%, transparent);
border-radius: 999px;
background: color-mix(in srgb, var(--secondary) 55%, transparent);
color: var(--muted);
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
flex-shrink: 0;
}
.chat-tool-msg-summary__btn:hover,
.chat-tool-msg-summary__btn:focus-visible {
color: var(--text);
background: color-mix(in srgb, var(--bg-hover) 70%, transparent);
border-color: color-mix(in srgb, var(--border-strong) 70%, transparent);
}
.chat-tool-msg-summary__btn svg {
width: 11px;
height: 11px;
stroke: currentColor;
fill: none;
stroke-width: 1.5px;
stroke-linecap: round;
stroke-linejoin: round;
}
.chat-tool-msg-body {
padding-top: 8px;
}

View File

@@ -9,7 +9,6 @@ const loadChatHistoryMock = vi.hoisted(() => vi.fn(async () => undefined));
type GatewayClientMock = {
start: ReturnType<typeof vi.fn>;
stop: ReturnType<typeof vi.fn>;
request: ReturnType<typeof vi.fn>;
options: { clientVersion?: string };
emitHello: (hello?: GatewayHelloOk) => void;
emitClose: (info: {
@@ -23,8 +22,8 @@ type GatewayClientMock = {
const gatewayClientInstances: GatewayClientMock[] = [];
vi.mock("./gateway.ts", async () => {
const actual = await vi.importActual<typeof import("./gateway.ts")>("./gateway.ts");
vi.mock("./gateway.ts", async (importOriginal) => {
const actual = await importOriginal<typeof import("./gateway.ts")>();
function resolveGatewayErrorDetailCode(
error: { details?: unknown } | null | undefined,
@@ -40,7 +39,6 @@ vi.mock("./gateway.ts", async () => {
class GatewayBrowserClient {
readonly start = vi.fn();
readonly stop = vi.fn();
readonly request = vi.fn(async () => ({}));
constructor(
private opts: {
@@ -58,7 +56,6 @@ vi.mock("./gateway.ts", async () => {
gatewayClientInstances.push({
start: this.start,
stop: this.stop,
request: this.request,
options: { clientVersion: this.opts.clientVersion },
emitHello: (hello) => {
this.opts.onHello?.(
@@ -89,9 +86,8 @@ vi.mock("./gateway.ts", async () => {
return { ...actual, GatewayBrowserClient, resolveGatewayErrorDetailCode };
});
vi.mock("./controllers/chat.ts", async () => {
const actual =
await vi.importActual<typeof import("./controllers/chat.ts")>("./controllers/chat.ts");
vi.mock("./controllers/chat.ts", async (importOriginal) => {
const actual = await importOriginal<typeof import("./controllers/chat.ts")>();
return {
...actual,
loadChatHistory: loadChatHistoryMock,
@@ -102,6 +98,8 @@ type TestGatewayHost = Parameters<typeof connectGateway>[0] & {
chatSideResult: unknown;
chatSideResultTerminalRuns: Set<string>;
chatStream: string | null;
chatToolMessages: Record<string, unknown>[];
toolStreamById: Map<string, unknown>;
toolStreamOrder: string[];
};
@@ -140,12 +138,10 @@ function createHost(): TestGatewayHost {
assistantName: "OpenClaw",
assistantAvatar: null,
assistantAgentId: null,
localMediaPreviewRoots: [],
serverVersion: null,
sessionKey: "main",
basePath: "",
chatMessage: "",
chatMessages: [],
chatAttachments: [],
chatQueue: [],
chatToolMessages: [],
chatStreamSegments: [],
@@ -196,7 +192,6 @@ describe("connectGateway", () => {
beforeEach(() => {
gatewayClientInstances.length = 0;
loadChatHistoryMock.mockClear();
vi.restoreAllMocks();
});
it("ignores stale client onGap callbacks after reconnect", () => {
@@ -219,69 +214,6 @@ describe("connectGateway", () => {
expect(host.lastError).toBeNull();
});
it("preserves live approval prompts, clears stale run indicators, and resumes queued work after seq-gap reconnect", () => {
const now = 1_700_000_000_000;
vi.spyOn(Date, "now").mockReturnValue(now);
const host = createHost();
connectGateway(host);
const client = gatewayClientInstances[0];
expect(client).toBeDefined();
const chatHost = host as typeof host & {
chatRunId: string | null;
chatQueue: Array<{
id: string;
text: string;
createdAt: number;
pendingRunId?: string;
}>;
};
chatHost.chatRunId = "run-1";
chatHost.chatQueue = [
{
id: "pending",
text: "/steer tighten the plan",
createdAt: 1,
pendingRunId: "run-1",
},
{
id: "queued",
text: "follow up",
createdAt: 2,
},
];
host.execApprovalQueue = [
{
id: "approval-1",
kind: "exec",
request: { command: "rm -rf /tmp/demo" },
createdAtMs: now,
expiresAtMs: now + 60_000,
},
];
client.emitGap(20, 24);
expect(gatewayClientInstances).toHaveLength(2);
expect(host.execApprovalQueue).toHaveLength(1);
expect(host.execApprovalQueue[0]?.id).toBe("approval-1");
expect(chatHost.chatQueue).toHaveLength(1);
expect(chatHost.chatQueue[0]?.text).toBe("follow up");
const reconnectClient = gatewayClientInstances[1];
expect(reconnectClient).toBeDefined();
reconnectClient.emitHello();
expect(reconnectClient.request).toHaveBeenCalledWith("chat.send", {
sessionKey: "main",
message: "follow up",
deliver: false,
idempotencyKey: expect.any(String),
attachments: undefined,
});
expect(chatHost.chatQueue).toHaveLength(0);
});
it("ignores stale client onEvent callbacks after reconnect", () => {
const host = createHost();
@@ -353,6 +285,27 @@ describe("connectGateway", () => {
expect(host.lastErrorCode).toBeNull();
});
it("preserves pending approval requests across reconnect", () => {
const host = createHost();
host.execApprovalQueue = [
{
id: "approval-1",
kind: "exec",
title: "Approve command",
summary: "rm -rf /tmp/nope",
createdAtMs: Date.now(),
expiresAtMs: Date.now() + 60_000,
} as never,
];
connectGateway(host);
expect(host.execApprovalQueue).toHaveLength(1);
connectGateway(host);
expect(host.execApprovalQueue).toHaveLength(1);
expect(host.execApprovalQueue[0]?.id).toBe("approval-1");
});
it("maps generic fetch-failed auth errors to actionable token mismatch message", () => {
const host = createHost();

View File

@@ -50,7 +50,6 @@ import {
import { GatewayBrowserClient } from "./gateway.ts";
import type { Tab } from "./navigation.ts";
import type { UiSettings } from "./storage.ts";
import { normalizeOptionalString } from "./string-coerce.ts";
import type {
AgentsListResult,
PresenceEntry,
@@ -130,7 +129,7 @@ export function resolveControlUiClientVersion(params: {
serverVersion: string | null;
pageUrl?: string;
}): string | undefined {
const serverVersion = normalizeOptionalString(params.serverVersion);
const serverVersion = params.serverVersion?.trim();
if (!serverVersion) {
return undefined;
}
@@ -156,16 +155,16 @@ function normalizeSessionKeyForDefaults(
value: string | undefined,
defaults: SessionDefaultsSnapshot,
): string {
const raw = normalizeOptionalString(value) ?? "";
const mainSessionKey = normalizeOptionalString(defaults.mainSessionKey);
const raw = (value ?? "").trim();
const mainSessionKey = defaults.mainSessionKey?.trim();
if (!mainSessionKey) {
return raw;
}
if (!raw) {
return mainSessionKey;
}
const mainKey = normalizeOptionalString(defaults.mainKey) ?? "main";
const defaultAgentId = normalizeOptionalString(defaults.defaultAgentId);
const mainKey = defaults.mainKey?.trim() || "main";
const defaultAgentId = defaults.defaultAgentId?.trim();
const isAlias =
raw === "main" ||
raw === mainKey ||
@@ -214,8 +213,6 @@ export function connectGateway(host: GatewayHost, options?: ConnectGatewayOption
host.hello = null;
host.connected = false;
if (reconnectReason === "seq-gap") {
// A seq gap means the socket stayed on the same gateway; preserve prompts
// that only arrived as ephemeral events and clear stale run-scoped indicators.
host.execApprovalQueue = pruneExecApprovalQueue(host.execApprovalQueue);
clearPendingQueueItemsForRun(
host as unknown as Parameters<typeof clearPendingQueueItemsForRun>[0],
@@ -223,8 +220,6 @@ export function connectGateway(host: GatewayHost, options?: ConnectGatewayOption
);
shutdownHost.resumeChatQueueAfterReconnect = true;
} else {
// Preserve any still-live approvals that were already staged in UI state.
// Initial connect can happen after a soft reload while an approval is pending.
host.execApprovalQueue = pruneExecApprovalQueue(host.execApprovalQueue);
}
host.execApprovalError = null;
@@ -236,8 +231,8 @@ export function connectGateway(host: GatewayHost, options?: ConnectGatewayOption
});
const client = new GatewayBrowserClient({
url: host.settings.gatewayUrl,
token: normalizeOptionalString(host.settings.token) ? host.settings.token : undefined,
password: normalizeOptionalString(host.password) ? host.password : undefined,
token: host.settings.token.trim() ? host.settings.token : undefined,
password: host.password.trim() ? host.password : undefined,
clientName: "openclaw-control-ui",
clientVersion,
mode: "webchat",
@@ -342,12 +337,12 @@ function handleTerminalChatEvent(
// Check if tool events were seen before resetting (resetToolStream clears toolStreamOrder).
const toolHost = host as unknown as Parameters<typeof resetToolStream>[0];
const hadToolEvents = toolHost.toolStreamOrder.length > 0;
resetToolStream(toolHost);
const flushQueue = () =>
void flushChatQueueForEvent(host as unknown as Parameters<typeof flushChatQueueForEvent>[0]);
clearPendingQueueItemsForRun(
host as unknown as Parameters<typeof clearPendingQueueItemsForRun>[0],
payload?.runId,
);
void flushChatQueueForEvent(host as unknown as Parameters<typeof flushChatQueueForEvent>[0]);
const runId = payload?.runId;
if (runId && host.refreshSessionsAfterChat.has(runId)) {
host.refreshSessionsAfterChat.delete(runId);
@@ -360,9 +355,18 @@ function handleTerminalChatEvent(
// Reload history when tools were used so the persisted tool results
// replace the now-cleared streaming state.
if (hadToolEvents && state === "final") {
void loadChatHistory(host as unknown as ChatState);
const completedRunId = runId ?? null;
void loadChatHistory(host as unknown as ChatState).finally(() => {
if (completedRunId && host.chatRunId && host.chatRunId !== completedRunId) {
return;
}
resetToolStream(toolHost);
flushQueue();
});
return true;
}
resetToolStream(toolHost);
flushQueue();
return false;
}
@@ -437,7 +441,10 @@ function handleGatewayEventUnsafe(host: GatewayHost, evt: GatewayEventFrame) {
if (evt.event === "shutdown") {
const payload = evt.payload as { reason?: unknown; restartExpectedMs?: unknown } | undefined;
const reason = normalizeOptionalString(payload?.reason) ?? "gateway stopping";
const reason =
payload && typeof payload.reason === "string" && payload.reason.trim()
? payload.reason.trim()
: "gateway stopping";
const shutdownMessage =
typeof payload?.restartExpectedMs === "number"
? `Restarting: ${reason}`

View File

@@ -11,6 +11,7 @@ function createHost() {
assistantName: "OpenClaw",
assistantAvatar: null,
assistantAgentId: null,
localMediaPreviewRoots: [],
chatHasAutoScrolled: false,
chatManualRefreshInFlight: false,
chatLoading: false,

View File

@@ -28,6 +28,9 @@ type LifecycleHost = {
assistantAvatar: string | null;
assistantAgentId: string | null;
serverVersion: string | null;
localMediaPreviewRoots: string[];
embedSandboxMode: "strict" | "scripts" | "trusted";
allowExternalEmbedUrls: boolean;
chatHasAutoScrolled: boolean;
chatManualRefreshInFlight: boolean;
chatLoading: boolean;

View File

@@ -23,6 +23,7 @@ vi.mock("./controllers/sessions.ts", () => ({
import {
isCronSessionKey,
parseSessionKey,
resolveAssistantAttachmentAuthToken,
resolveSessionDisplayName,
switchChatSession,
} from "./app-render.helpers.ts";
@@ -123,6 +124,35 @@ describe("parseSessionKey", () => {
});
});
describe("resolveAssistantAttachmentAuthToken", () => {
it("prefers the explicit gateway token when present", () => {
expect(
resolveAssistantAttachmentAuthToken({
settings: { token: "session-token" } as AppViewState["settings"],
password: "shared-password",
}),
).toBe("session-token");
});
it("falls back to the shared password when token is blank", () => {
expect(
resolveAssistantAttachmentAuthToken({
settings: { token: " " } as AppViewState["settings"],
password: "shared-password",
}),
).toBe("shared-password");
});
it("returns null when neither auth secret is available", () => {
expect(
resolveAssistantAttachmentAuthToken({
settings: { token: "" } as AppViewState["settings"],
password: " ",
}),
).toBeNull();
});
});
/* ================================================================
* resolveSessionDisplayName full resolution with row data
* ================================================================ */

View File

@@ -44,6 +44,14 @@ type ChatRefreshHost = AppViewState & {
updateComplete?: Promise<unknown>;
};
export function resolveAssistantAttachmentAuthToken(state: Pick<AppViewState, "settings" | "password">) {
return (
normalizeOptionalString(state.settings.token) ??
normalizeOptionalString(state.password) ??
null
);
}
function resolveSidebarChatSessionKey(state: AppViewState): string {
const snapshot = state.hello?.snapshot as
| { sessionDefaults?: SessionDefaultsSnapshot }

View File

@@ -1,4 +1,9 @@
import { html, nothing } from "lit";
import {
buildAgentMainSessionKey,
parseAgentSessionKey,
resolveAgentIdFromSessionKey,
} from "../../../src/routing/session-key.js";
import { t } from "../i18n/index.ts";
import { getSafeLocalStorage } from "../local-storage.ts";
import { refreshChatAvatar } from "./app-chat.ts";
@@ -8,6 +13,7 @@ import {
renderChatMobileToggle,
renderChatSessionSelect,
renderTab,
resolveAssistantAttachmentAuthToken,
renderSidebarConnectionStatus,
renderTopbarThemeModeToggle,
switchChatSession,
@@ -105,15 +111,10 @@ import {
updateSkillEdit,
updateSkillEnabled,
} from "./controllers/skills.ts";
import { buildExternalLinkRel, EXTERNAL_LINK_TARGET } from "./external-link.ts";
import "./components/dashboard-header.ts";
import { buildExternalLinkRel, EXTERNAL_LINK_TARGET } from "./external-link.ts";
import { icons } from "./icons.ts";
import { normalizeBasePath, TAB_GROUPS, subtitleForTab, titleForTab } from "./navigation.ts";
import {
buildAgentMainSessionKey,
parseAgentSessionKey,
resolveAgentIdFromSessionKey,
} from "./session-key.ts";
import { agentLogoUrl } from "./views/agents-utils.ts";
import {
resolveAgentConfig,
@@ -188,7 +189,6 @@ function resolveDreamingNextCycle(
}
let clawhubSearchTimer: ReturnType<typeof setTimeout> | null = null;
function lazyRender<M>(getter: () => M | null, render: (mod: M) => unknown) {
const mod = getter();
return mod ? render(mod) : nothing;
@@ -1847,6 +1847,7 @@ export function renderApp(state: AppViewState) {
error: state.lastError,
sessions: state.sessionsResult,
focusMode: chatFocus,
autoExpandToolCalls: false,
onRefresh: () => {
state.chatSideResult = null;
state.resetToolStream();
@@ -1909,11 +1910,16 @@ export function renderApp(state: AppViewState) {
sidebarContent: state.sidebarContent,
sidebarError: state.sidebarError,
splitRatio: state.splitRatio,
onOpenSidebar: (content: string) => state.handleOpenSidebar(content),
canvasHostUrl: state.hello?.canvasHostUrl ?? null,
onOpenSidebar: (content) => state.handleOpenSidebar(content),
onCloseSidebar: () => state.handleCloseSidebar(),
onSplitRatioChange: (ratio: number) => state.handleSplitRatioChange(ratio),
assistantName: state.assistantName,
assistantAvatar: state.assistantAvatar,
localMediaPreviewRoots: state.localMediaPreviewRoots,
embedSandboxMode: state.embedSandboxMode,
allowExternalEmbedUrls: state.allowExternalEmbedUrls,
assistantAttachmentAuthToken: resolveAssistantAttachmentAuthToken(state),
basePath: state.basePath ?? "",
})
: nothing}

View File

@@ -10,8 +10,10 @@ import type {
ClawHubSkillDetail,
SkillMessage,
} from "./controllers/skills.ts";
import type { EmbedSandboxMode } from "./embed-sandbox.ts";
import type { GatewayBrowserClient, GatewayHelloOk } from "./gateway.ts";
import type { Tab } from "./navigation.ts";
import type { SidebarContent } from "./sidebar-content.ts";
import type { UiSettings } from "./storage.ts";
import type { ThemeTransitionContext } from "./theme-transition.ts";
import type { ResolvedTheme, ThemeMode, ThemeName } from "./theme.ts";
@@ -34,6 +36,7 @@ import type {
CostUsageSummary,
SessionUsageTimeSeries,
SessionsListResult,
SessionCompactionCheckpoint,
SkillStatusReport,
StatusSummary,
ToolsCatalogResult,
@@ -62,6 +65,9 @@ export type AppViewState = {
assistantName: string;
assistantAvatar: string | null;
assistantAgentId: string | null;
localMediaPreviewRoots: string[];
embedSandboxMode: EmbedSandboxMode;
allowExternalEmbedUrls: boolean;
sessionKey: string;
chatLoading: boolean;
chatSending: boolean;
@@ -89,7 +95,7 @@ export type AppViewState = {
chatNewMessagesBelow: boolean;
navDrawerOpen: boolean;
sidebarOpen: boolean;
sidebarContent: string | null;
sidebarContent: SidebarContent | null;
sidebarError: string | null;
splitRatio: number;
scrollToBottom: (opts?: { smooth?: boolean }) => void;
@@ -208,6 +214,9 @@ export type AppViewState = {
sessionsLoading: boolean;
sessionsResult: SessionsListResult | null;
sessionsError: string | null;
threadsLoading: boolean;
threadsResult: SessionsListResult | null;
threadsError: string | null;
sessionsFilterActive: string;
sessionsFilterLimit: string;
sessionsIncludeGlobal: boolean;
@@ -220,7 +229,7 @@ export type AppViewState = {
sessionsPageSize: number;
sessionsSelectedKeys: Set<string>;
sessionsExpandedCheckpointKey: string | null;
sessionsCheckpointItemsByKey: Record<string, import("./types.ts").SessionCompactionCheckpoint[]>;
sessionsCheckpointItemsByKey: Record<string, SessionCompactionCheckpoint[]>;
sessionsCheckpointLoadingKey: string | null;
sessionsCheckpointBusyKey: string | null;
sessionsCheckpointErrorByKey: Record<string, string>;
@@ -410,7 +419,7 @@ export type AppViewState = {
resetChatScroll: () => void;
exportLogs: (lines: string[], label: string) => void;
handleLogsScroll: (event: Event) => void;
handleOpenSidebar: (content: string) => void;
handleOpenSidebar: (content: SidebarContent) => void;
handleCloseSidebar: () => void;
handleSplitRatioChange: (ratio: number) => void;
};

View File

@@ -1,5 +1,6 @@
import { LitElement } from "lit";
import { state } from "lit/decorators.js";
import { customElement, state } from "lit/decorators.js";
import { resolveAgentIdFromSessionKey } from "../../../src/routing/session-key.js";
import { i18n, I18nController, isSupportedLocale } from "../i18n/index.ts";
import {
handleChannelConfigReload as handleChannelConfigReloadInternal,
@@ -75,7 +76,7 @@ import type {
} from "./controllers/skills.ts";
import type { GatewayBrowserClient, GatewayHelloOk } from "./gateway.ts";
import type { Tab } from "./navigation.ts";
import { resolveAgentIdFromSessionKey } from "./session-key.ts";
import type { SidebarContent } from "./sidebar-content.ts";
import { loadSettings, type UiSettings } from "./storage.ts";
import { VALID_THEME_NAMES, type ResolvedTheme, type ThemeMode, type ThemeName } from "./theme.ts";
import type {
@@ -127,6 +128,7 @@ function resolveOnboardingMode(): boolean {
return normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on";
}
@customElement("openclaw-app")
export class OpenClawApp extends LitElement {
private i18nController = new I18nController(this);
clientInstanceId = generateUUID();
@@ -159,6 +161,9 @@ export class OpenClawApp extends LitElement {
@state() assistantName = bootAssistantIdentity.name;
@state() assistantAvatar = bootAssistantIdentity.avatar;
@state() assistantAgentId = bootAssistantIdentity.agentId ?? null;
@state() localMediaPreviewRoots: string[] = [];
@state() embedSandboxMode: "strict" | "scripts" | "trusted" = "scripts";
@state() allowExternalEmbedUrls = false;
@state() serverVersion: string | null = null;
@state() sessionKey = this.settings.sessionKey;
@@ -188,7 +193,7 @@ export class OpenClawApp extends LitElement {
// Sidebar state for tool output viewing
@state() sidebarOpen = false;
@state() sidebarContent: string | null = null;
@state() sidebarContent: SidebarContent | null = null;
@state() sidebarError: string | null = null;
@state() splitRatio = this.settings.splitRatio;
@@ -767,7 +772,7 @@ export class OpenClawApp extends LitElement {
}
// Sidebar handlers for tool output viewing
handleOpenSidebar(content: string) {
handleOpenSidebar(content: SidebarContent) {
if (this.sidebarCloseTimer != null) {
window.clearTimeout(this.sidebarCloseTimer);
this.sidebarCloseTimer = null;
@@ -803,7 +808,3 @@ export class OpenClawApp extends LitElement {
return renderApp(this as unknown as AppViewState);
}
}
if (!customElements.get("openclaw-app")) {
customElements.define("openclaw-app", OpenClawApp);
}

View File

@@ -0,0 +1,39 @@
import { describe, expect, it } from "vitest";
import { resolveCanvasIframeUrl } from "./canvas-url.ts";
describe("resolveCanvasIframeUrl", () => {
it("allows same-origin hosted canvas document paths", () => {
expect(resolveCanvasIframeUrl("/__openclaw__/canvas/documents/cv_demo/index.html")).toBe(
"/__openclaw__/canvas/documents/cv_demo/index.html",
);
});
it("rewrites safe canvas paths through the scoped canvas host", () => {
expect(
resolveCanvasIframeUrl(
"/__openclaw__/canvas/documents/cv_demo/index.html",
"http://127.0.0.1:19003/__openclaw__/cap/cap_123",
),
).toBe(
"http://127.0.0.1:19003/__openclaw__/cap/cap_123/__openclaw__/canvas/documents/cv_demo/index.html",
);
});
it("rejects non-canvas same-origin paths", () => {
expect(resolveCanvasIframeUrl("/not-canvas/snake.html")).toBeUndefined();
});
it("rejects absolute external URLs", () => {
expect(resolveCanvasIframeUrl("https://example.com/evil.html")).toBeUndefined();
});
it("allows absolute external URLs only when explicitly enabled", () => {
expect(resolveCanvasIframeUrl("https://example.com/embed.html?x=1#y", undefined, true)).toBe(
"https://example.com/embed.html?x=1#y",
);
});
it("rejects file URLs", () => {
expect(resolveCanvasIframeUrl("file:///tmp/snake.html")).toBeUndefined();
});
});

74
ui/src/ui/canvas-url.ts Normal file
View File

@@ -0,0 +1,74 @@
const A2UI_PATH = "/__openclaw__/a2ui";
const CANVAS_HOST_PATH = "/__openclaw__/canvas";
const CANVAS_CAPABILITY_PATH_PREFIX = "/__openclaw__/cap";
function isCanvasHttpPath(pathname: string): boolean {
return (
pathname === CANVAS_HOST_PATH ||
pathname.startsWith(`${CANVAS_HOST_PATH}/`) ||
pathname === A2UI_PATH ||
pathname.startsWith(`${A2UI_PATH}/`)
);
}
function isExternalHttpUrl(entry: URL): boolean {
return entry.protocol === "http:" || entry.protocol === "https:";
}
function sanitizeCanvasEntryUrl(
rawEntryUrl: string,
allowExternalEmbedUrls = false,
): string | undefined {
try {
const entry = new URL(rawEntryUrl, "http://localhost");
if (entry.origin !== "http://localhost") {
if (!allowExternalEmbedUrls || !isExternalHttpUrl(entry)) {
return undefined;
}
return entry.toString();
}
if (!isCanvasHttpPath(entry.pathname)) {
return undefined;
}
return `${entry.pathname}${entry.search}${entry.hash}`;
} catch {
return undefined;
}
}
export function resolveCanvasIframeUrl(
entryUrl: string | undefined,
canvasHostUrl?: string | null,
allowExternalEmbedUrls = false,
): string | undefined {
const rawEntryUrl = entryUrl?.trim();
if (!rawEntryUrl) {
return undefined;
}
const safeEntryUrl = sanitizeCanvasEntryUrl(rawEntryUrl, allowExternalEmbedUrls);
if (!safeEntryUrl) {
return undefined;
}
if (!canvasHostUrl?.trim()) {
return safeEntryUrl;
}
try {
const scopedHostUrl = new URL(canvasHostUrl);
const scopedPrefix = scopedHostUrl.pathname.replace(/\/+$/, "");
if (!scopedPrefix.startsWith(CANVAS_CAPABILITY_PATH_PREFIX)) {
return safeEntryUrl;
}
const entry = new URL(safeEntryUrl, scopedHostUrl.origin);
if (!isCanvasHttpPath(entry.pathname)) {
return safeEntryUrl;
}
entry.protocol = scopedHostUrl.protocol;
entry.username = scopedHostUrl.username;
entry.password = scopedHostUrl.password;
entry.host = scopedHostUrl.host;
entry.pathname = `${scopedPrefix}${entry.pathname}`;
return entry.toString();
} catch {
return safeEntryUrl;
}
}

View File

@@ -23,7 +23,7 @@ describe("shouldReloadHistoryForFinalEvent", () => {
).toBe(true);
});
it("returns false when final event includes assistant payload", () => {
it("returns true when final event includes assistant payload", () => {
expect(
shouldReloadHistoryForFinalEvent({
runId: "run-1",
@@ -31,7 +31,7 @@ describe("shouldReloadHistoryForFinalEvent", () => {
state: "final",
message: { role: "assistant", content: [{ type: "text", text: "done" }] },
}),
).toBe(false);
).toBe(true);
});
it("returns true when final event message role is non-assistant", () => {

View File

@@ -1,17 +1,5 @@
import type { ChatEventPayload } from "./controllers/chat.ts";
import { normalizeLowercaseStringOrEmpty } from "./string-coerce.ts";
export function shouldReloadHistoryForFinalEvent(payload?: ChatEventPayload): boolean {
if (!payload || payload.state !== "final") {
return false;
}
if (!payload.message || typeof payload.message !== "object") {
return true;
}
const message = payload.message as Record<string, unknown>;
const role = normalizeLowercaseStringOrEmpty(message.role);
if (role && role !== "assistant") {
return true;
}
return false;
return Boolean(payload && payload.state === "final");
}

View File

@@ -22,16 +22,19 @@ describe("chat markdown rendering", () => {
await app.updateComplete;
const toolCards = Array.from(app.querySelectorAll<HTMLElement>(".chat-tool-card"));
const toolCard = toolCards.find((card) =>
card.querySelector(".chat-tool-card__preview, .chat-tool-card__inline"),
);
expect(toolCard).not.toBeUndefined();
toolCard?.click();
const toolSummary = app.querySelector<HTMLElement>(".chat-tool-msg-summary");
expect(toolSummary).not.toBeNull();
toolSummary?.click();
await app.updateComplete;
const strong = app.querySelector(".sidebar-markdown strong");
expect(strong?.textContent).toBe("world");
const openSidebarButton = app.querySelector<HTMLElement>(".chat-tool-card__action-btn");
expect(openSidebarButton).not.toBeNull();
openSidebarButton?.click();
await app.updateComplete;
const strongNodes = Array.from(app.querySelectorAll(".sidebar-markdown strong"));
expect(strongNodes.some((node) => node.textContent === "world")).toBe(true);
});
});

View File

@@ -2,12 +2,18 @@ import { html, nothing } from "lit";
import { unsafeHTML } from "lit/directives/unsafe-html.js";
import { getSafeLocalStorage } from "../../local-storage.ts";
import type { AssistantIdentity } from "../assistant-identity.ts";
import type { EmbedSandboxMode } from "../embed-sandbox.ts";
import { icons } from "../icons.ts";
import { toSanitizedMarkdownHtml } from "../markdown.ts";
import { openExternalUrlSafe } from "../open-external-url.ts";
import { normalizeLowercaseStringOrEmpty } from "../string-coerce.ts";
import type { SidebarContent } from "../sidebar-content.ts";
import { detectTextDirection } from "../text-direction.ts";
import type { MessageGroup, ToolCard } from "../types/chat-types.ts";
import type {
MessageContentItem,
MessageGroup,
NormalizedMessage,
ToolCard,
} from "../types/chat-types.ts";
import { agentLogoUrl } from "../views/agents-utils.ts";
import { renderCopyAsMarkdownButton } from "./copy-as-markdown.ts";
import {
@@ -15,19 +21,37 @@ import {
extractThinkingCached,
formatReasoningMarkdown,
} from "./message-extract.ts";
import { isToolResultMessage, normalizeRoleForGrouping } from "./message-normalizer.ts";
import {
isToolResultMessage,
normalizeMessage,
normalizeRoleForGrouping,
} from "./message-normalizer.ts";
import { isTtsSupported, speakText, stopTts, isTtsSpeaking } from "./speech.ts";
import { extractToolCards, renderToolCardSidebar } from "./tool-cards.ts";
import {
extractToolCards,
renderExpandedToolCardContent,
renderRawOutputToggle,
renderToolCard,
renderToolPreview,
} from "./tool-cards.ts";
type AssistantAttachmentAvailability =
| { status: "checking" }
| { status: "available" }
| { status: "unavailable"; reason: string; checkedAt: number };
const assistantAttachmentAvailabilityCache = new Map<string, AssistantAttachmentAvailability>();
const ASSISTANT_ATTACHMENT_UNAVAILABLE_RETRY_MS = 5_000;
export function resetAssistantAttachmentAvailabilityCacheForTest() {
assistantAttachmentAvailabilityCache.clear();
}
type ImageBlock = {
url: string;
alt?: string;
};
type AudioClip = {
url: string;
};
function extractImages(message: unknown): ImageBlock[] {
const m = message as Record<string, unknown>;
const content = m.content;
@@ -65,32 +89,6 @@ function extractImages(message: unknown): ImageBlock[] {
return images;
}
function extractAudioClips(message: unknown): AudioClip[] {
const m = message as Record<string, unknown>;
const content = m.content;
const clips: AudioClip[] = [];
if (!Array.isArray(content)) {
return clips;
}
for (const block of content) {
if (typeof block !== "object" || block === null) {
continue;
}
const b = block as Record<string, unknown>;
if (b.type !== "audio") {
continue;
}
const source = b.source as Record<string, unknown> | undefined;
if (source?.type === "base64" && typeof source.data === "string") {
const data = source.data;
const mediaType = (source.media_type as string) || "audio/mpeg";
const url = data.startsWith("data:") ? data : `data:${mediaType};base64,${data}`;
clips.push({ url });
}
}
return clips;
}
export function renderReadingIndicatorGroup(assistant?: AssistantIdentity, basePath?: string) {
return html`
<div class="chat-group assistant">
@@ -109,7 +107,7 @@ export function renderReadingIndicatorGroup(assistant?: AssistantIdentity, baseP
export function renderStreamingGroup(
text: string,
startedAt: number,
onOpenSidebar?: (content: string) => void,
onOpenSidebar?: (content: SidebarContent) => void,
assistant?: AssistantIdentity,
basePath?: string,
) {
@@ -129,6 +127,7 @@ export function renderStreamingGroup(
content: [{ type: "text", text }],
timestamp: startedAt,
},
`stream:${startedAt}`,
{ isStreaming: true, showReasoning: false },
onOpenSidebar,
)}
@@ -144,12 +143,23 @@ export function renderStreamingGroup(
export function renderMessageGroup(
group: MessageGroup,
opts: {
onOpenSidebar?: (content: string) => void;
onOpenSidebar?: (content: SidebarContent) => void;
showReasoning: boolean;
showToolCalls?: boolean;
autoExpandToolCalls?: boolean;
isToolMessageExpanded?: (messageId: string) => boolean;
onToggleToolMessageExpanded?: (messageId: string) => void;
isToolExpanded?: (toolCardId: string) => boolean;
onToggleToolExpanded?: (toolCardId: string) => void;
onRequestUpdate?: () => void;
assistantName?: string;
assistantAvatar?: string | null;
basePath?: string;
localMediaPreviewRoots?: readonly string[];
assistantAttachmentAuthToken?: string | null;
canvasHostUrl?: string | null;
embedSandboxMode?: EmbedSandboxMode;
allowExternalEmbedUrls?: boolean;
contextWindow?: number | null;
onDelete?: () => void;
},
@@ -195,10 +205,22 @@ export function renderMessageGroup(
${group.messages.map((item, index) =>
renderGroupedMessage(
item.message,
item.key,
{
isStreaming: group.isStreaming && index === group.messages.length - 1,
showReasoning: opts.showReasoning,
showToolCalls: opts.showToolCalls ?? true,
autoExpandToolCalls: opts.autoExpandToolCalls ?? false,
isToolMessageExpanded: opts.isToolMessageExpanded,
onToggleToolMessageExpanded: opts.onToggleToolMessageExpanded,
isToolExpanded: opts.isToolExpanded,
onToggleToolExpanded: opts.onToggleToolExpanded,
onRequestUpdate: opts.onRequestUpdate,
canvasHostUrl: opts.canvasHostUrl,
basePath: opts.basePath,
localMediaPreviewRoots: opts.localMediaPreviewRoots,
assistantAttachmentAuthToken: opts.assistantAttachmentAuthToken,
embedSandboxMode: opts.embedSandboxMode,
},
opts.onOpenSidebar,
),
@@ -346,15 +368,9 @@ function extractGroupText(group: MessageGroup): string {
return parts.join("\n\n");
}
export const SKIP_DELETE_CONFIRM_KEY = "openclaw:skipDeleteConfirm";
const SKIP_DELETE_CONFIRM_KEY = "openclaw:skipDeleteConfirm";
type DeleteConfirmSide = "left" | "right";
type DeleteConfirmPopover = {
popover: HTMLDivElement;
cancel: HTMLButtonElement;
yes: HTMLButtonElement;
check: HTMLInputElement;
};
function shouldSkipDeleteConfirm(): boolean {
try {
@@ -364,45 +380,6 @@ function shouldSkipDeleteConfirm(): boolean {
}
}
function createDeleteConfirmPopover(side: DeleteConfirmSide): DeleteConfirmPopover {
const popover = document.createElement("div");
popover.className = `chat-delete-confirm chat-delete-confirm--${side}`;
const text = document.createElement("p");
text.className = "chat-delete-confirm__text";
text.textContent = "Delete this message?";
const remember = document.createElement("label");
remember.className = "chat-delete-confirm__remember";
const check = document.createElement("input");
check.className = "chat-delete-confirm__check";
check.type = "checkbox";
const rememberText = document.createElement("span");
rememberText.textContent = "Don't ask again";
remember.append(check, rememberText);
const actions = document.createElement("div");
actions.className = "chat-delete-confirm__actions";
const cancel = document.createElement("button");
cancel.className = "chat-delete-confirm__cancel";
cancel.type = "button";
cancel.textContent = "Cancel";
const yes = document.createElement("button");
yes.className = "chat-delete-confirm__yes";
yes.type = "button";
yes.textContent = "Delete";
actions.append(cancel, yes);
popover.append(text, remember, actions);
return { popover, cancel, yes, check };
}
function renderDeleteButton(onDelete: () => void, side: DeleteConfirmSide) {
return html`
<span class="chat-delete-wrap">
@@ -422,31 +399,43 @@ function renderDeleteButton(onDelete: () => void, side: DeleteConfirmSide) {
existing.remove();
return;
}
const { popover, cancel, yes, check } = createDeleteConfirmPopover(side);
const popover = document.createElement("div");
popover.className = `chat-delete-confirm chat-delete-confirm--${side}`;
popover.innerHTML = `
<p class="chat-delete-confirm__text">Delete this message?</p>
<label class="chat-delete-confirm__remember">
<input type="checkbox" class="chat-delete-confirm__check" />
<span>Don't ask again</span>
</label>
<div class="chat-delete-confirm__actions">
<button class="chat-delete-confirm__cancel" type="button">Cancel</button>
<button class="chat-delete-confirm__yes" type="button">Delete</button>
</div>
`;
wrap.appendChild(popover);
const removePopover = () => {
popover.remove();
document.removeEventListener("click", closeOnOutside, true);
};
const cancel = popover.querySelector(".chat-delete-confirm__cancel")!;
const yes = popover.querySelector(".chat-delete-confirm__yes")!;
const check = popover.querySelector(".chat-delete-confirm__check") as HTMLInputElement;
// Close on click outside.
const closeOnOutside = (evt: MouseEvent) => {
if (!popover.contains(evt.target as Node) && evt.target !== btn) {
removePopover();
}
};
cancel.addEventListener("click", removePopover);
cancel.addEventListener("click", () => popover.remove());
yes.addEventListener("click", () => {
if (check.checked) {
try {
getSafeLocalStorage()?.setItem(SKIP_DELETE_CONFIRM_KEY, "1");
} catch {}
}
removePopover();
popover.remove();
onDelete();
});
// Close on click outside
const closeOnOutside = (evt: MouseEvent) => {
if (!popover.contains(evt.target as Node) && evt.target !== btn) {
popover.remove();
document.removeEventListener("click", closeOnOutside, true);
}
};
requestAnimationFrame(() => document.addEventListener("click", closeOnOutside, true));
}}
>
@@ -611,52 +600,372 @@ function renderMessageImages(images: ImageBlock[]) {
`;
}
function renderMessageAudio(clips: AudioClip[]) {
if (clips.length === 0) {
function renderReplyPill(replyTarget: NormalizedMessage["replyTarget"]) {
if (!replyTarget) {
return nothing;
}
return html`
<div class="chat-message-audio">
${clips.map(
(clip) =>
html`<audio
class="chat-message-audio-el"
controls
preload="metadata"
src=${clip.url}
></audio>`,
)}
<div class="chat-reply-pill">
<span class="chat-reply-pill__icon">${icons.messageSquare}</span>
<span class="chat-reply-pill__label">
${replyTarget.kind === "current"
? "Replying to current message"
: `Replying to ${replyTarget.id}`}
</span>
</div>
`;
}
/** Render tool cards inside a collapsed `<details>` element. */
function renderCollapsedToolCards(
toolCards: ToolCard[],
onOpenSidebar?: (content: string) => void,
) {
const calls = toolCards.filter((c) => c.kind === "call");
const results = toolCards.filter((c) => c.kind === "result");
const totalTools = Math.max(calls.length, results.length) || toolCards.length;
const toolNames = [...new Set(toolCards.map((c) => c.name))];
const summaryLabel =
toolNames.length <= 3
? toolNames.join(", ")
: `${toolNames.slice(0, 2).join(", ")} +${toolNames.length - 2} more`;
function isLocalAssistantAttachmentSource(source: string): boolean {
const trimmed = source.trim();
if (/^\/(?:__openclaw__|media)\//.test(trimmed)) {
return false;
}
return (
trimmed.startsWith("file://") ||
trimmed.startsWith("~") ||
trimmed.startsWith("/") ||
/^[a-zA-Z]:[\\/]/.test(trimmed)
);
}
function normalizeLocalAttachmentPath(source: string): string | null {
const trimmed = source.trim();
if (!isLocalAssistantAttachmentSource(trimmed)) {
return null;
}
if (trimmed.startsWith("file://")) {
try {
const url = new URL(trimmed);
const pathname = decodeURIComponent(url.pathname);
if (/^\/[a-zA-Z]:\//.test(pathname)) {
return pathname.slice(1);
}
return pathname;
} catch {
return null;
}
}
if (trimmed.startsWith("~")) {
return null;
}
return trimmed;
}
function resolveHomeCandidatesFromRoots(localMediaPreviewRoots: readonly string[]): string[] {
const candidates = new Set<string>();
for (const root of localMediaPreviewRoots) {
const normalized = canonicalizeLocalPathForComparison(root.trim());
const unixHome = normalized.match(/^(\/Users\/[^/]+|\/home\/[^/]+)(?:\/|$)/);
if (unixHome?.[1]) {
candidates.add(unixHome[1]);
continue;
}
const windowsHome = normalized.match(/^([a-z]:\/Users\/[^/]+)(?:\/|$)/i);
if (windowsHome?.[1]) {
candidates.add(windowsHome[1]);
}
}
return [...candidates];
}
function canonicalizeLocalPathForComparison(value: string): string {
let slashNormalized = value.replace(/\\/g, "/").replace(/\/+$/, "");
if (/^\/[a-zA-Z]:\//.test(slashNormalized)) {
slashNormalized = slashNormalized.slice(1);
}
if (/^[a-zA-Z]:\//.test(slashNormalized)) {
return slashNormalized.toLowerCase();
}
return slashNormalized;
}
function isLocalAttachmentPreviewAllowed(
source: string,
localMediaPreviewRoots: readonly string[],
): boolean {
const normalizedSource = normalizeLocalAttachmentPath(source);
const comparableSources = normalizedSource
? [canonicalizeLocalPathForComparison(normalizedSource)]
: source.trim().startsWith("~")
? resolveHomeCandidatesFromRoots(localMediaPreviewRoots).map((home) =>
canonicalizeLocalPathForComparison(source.trim().replace(/^~(?=$|[\\/])/, home)),
)
: [];
if (comparableSources.length === 0) {
return false;
}
return localMediaPreviewRoots.some((root) => {
const normalizedRoot = canonicalizeLocalPathForComparison(root.trim());
return (
normalizedRoot.length > 0 &&
comparableSources.some(
(comparableSource) =>
comparableSource === normalizedRoot || comparableSource.startsWith(`${normalizedRoot}/`),
)
);
});
}
function buildAssistantAttachmentUrl(
source: string,
basePath?: string,
authToken?: string | null,
): string {
if (!isLocalAssistantAttachmentSource(source)) {
return source;
}
const normalizedBasePath =
basePath && basePath !== "/" ? (basePath.endsWith("/") ? basePath.slice(0, -1) : basePath) : "";
const params = new URLSearchParams({ source });
const normalizedToken = authToken?.trim();
if (normalizedToken) {
params.set("token", normalizedToken);
}
return `${normalizedBasePath}/__openclaw__/assistant-media?${params.toString()}`;
}
function buildAssistantAttachmentMetaUrl(
source: string,
basePath?: string,
authToken?: string | null,
): string {
const attachmentUrl = buildAssistantAttachmentUrl(source, basePath, authToken);
return `${attachmentUrl}${attachmentUrl.includes("?") ? "&" : "?"}meta=1`;
}
function resolveAssistantAttachmentAvailability(
source: string,
localMediaPreviewRoots: readonly string[],
basePath: string | undefined,
authToken: string | null | undefined,
onRequestUpdate: (() => void) | undefined,
): AssistantAttachmentAvailability {
if (!isLocalAssistantAttachmentSource(source)) {
return { status: "available" };
}
if (!isLocalAttachmentPreviewAllowed(source, localMediaPreviewRoots)) {
return { status: "unavailable", reason: "Outside allowed folders", checkedAt: Date.now() };
}
const normalizedAuthToken = authToken?.trim() ?? "";
const cacheKey = `${basePath ?? ""}::${normalizedAuthToken}::${source}`;
const cached = assistantAttachmentAvailabilityCache.get(cacheKey);
if (cached) {
if (
cached.status === "unavailable" &&
Date.now() - cached.checkedAt >= ASSISTANT_ATTACHMENT_UNAVAILABLE_RETRY_MS
) {
assistantAttachmentAvailabilityCache.delete(cacheKey);
} else {
return cached;
}
}
assistantAttachmentAvailabilityCache.set(cacheKey, { status: "checking" });
if (typeof fetch === "function") {
void fetch(buildAssistantAttachmentMetaUrl(source, basePath, authToken), {
method: "GET",
headers: { Accept: "application/json" },
credentials: "same-origin",
})
.then(async (res) => {
const payload = (await res.json().catch(() => null)) as {
available?: boolean;
reason?: string;
} | null;
if (payload?.available === true) {
assistantAttachmentAvailabilityCache.set(cacheKey, { status: "available" });
} else {
assistantAttachmentAvailabilityCache.set(cacheKey, {
status: "unavailable",
reason: payload?.reason?.trim() || "Attachment unavailable",
checkedAt: Date.now(),
});
}
})
.catch(() => {
assistantAttachmentAvailabilityCache.set(cacheKey, {
status: "unavailable",
reason: "Attachment unavailable",
checkedAt: Date.now(),
});
})
.finally(() => {
onRequestUpdate?.();
});
}
return { status: "checking" };
}
function renderAssistantAttachmentStatusCard(params: {
kind: "image" | "audio" | "video" | "document";
label: string;
badge: string;
reason?: string;
}) {
const icon =
params.kind === "image"
? icons.image
: params.kind === "audio"
? icons.mic
: params.kind === "video"
? icons.monitor
: icons.paperclip;
return html`
<details class="chat-tools-collapse">
<summary class="chat-tools-summary">
<span class="chat-tools-summary__icon">${icons.zap}</span>
<span class="chat-tools-summary__count"
>${totalTools} tool${totalTools === 1 ? "" : "s"}</span
<div class="chat-assistant-attachment-card chat-assistant-attachment-card--blocked">
<div class="chat-assistant-attachment-card__header">
<span class="chat-assistant-attachment-card__icon">${icon}</span>
<span class="chat-assistant-attachment-card__title">${params.label}</span>
<span class="chat-assistant-attachment-badge chat-assistant-attachment-badge--muted"
>${params.badge}</span
>
<span class="chat-tools-summary__names">${summaryLabel}</span>
</summary>
<div class="chat-tools-collapse__body">
${toolCards.map((card) => renderToolCardSidebar(card, onOpenSidebar))}
</div>
</details>
${params.reason
? html`<div class="chat-assistant-attachment-card__reason">${params.reason}</div>`
: nothing}
</div>
`;
}
function renderAssistantAttachments(
attachments: Array<Extract<MessageContentItem, { type: "attachment" }>>,
localMediaPreviewRoots: readonly string[],
basePath?: string,
authToken?: string | null,
onRequestUpdate?: () => void,
) {
if (attachments.length === 0) {
return nothing;
}
return html`
<div class="chat-assistant-attachments">
${attachments.map(({ attachment }) => {
const availability = resolveAssistantAttachmentAvailability(
attachment.url,
localMediaPreviewRoots,
basePath,
authToken,
onRequestUpdate,
);
const attachmentUrl =
availability.status === "available"
? buildAssistantAttachmentUrl(attachment.url, basePath, authToken)
: null;
if (attachment.kind === "image") {
if (!attachmentUrl) {
return renderAssistantAttachmentStatusCard({
kind: "image",
label: attachment.label,
badge: availability.status === "checking" ? "Checking..." : "Unavailable",
reason: availability.status === "unavailable" ? availability.reason : undefined,
});
}
return html`
<img
src=${attachmentUrl}
alt=${attachment.label}
class="chat-message-image"
@click=${() => openExternalUrlSafe(attachmentUrl, { allowDataImage: true })}
/>
`;
}
if (attachment.kind === "audio") {
return html`
<div class="chat-assistant-attachment-card chat-assistant-attachment-card--audio">
<div class="chat-assistant-attachment-card__header">
<span class="chat-assistant-attachment-card__title">${attachment.label}</span>
${!attachmentUrl
? html`<span
class="chat-assistant-attachment-badge chat-assistant-attachment-badge--muted"
>${availability.status === "checking" ? "Checking..." : "Unavailable"}</span
>`
: attachment.isVoiceNote
? html`<span class="chat-assistant-attachment-badge">Voice note</span>`
: nothing}
</div>
${attachmentUrl
? html`<audio controls preload="metadata" src=${attachmentUrl}></audio>`
: availability.status === "unavailable"
? html`<div class="chat-assistant-attachment-card__reason">
${availability.reason}
</div>`
: nothing}
</div>
`;
}
if (attachment.kind === "video") {
if (!attachmentUrl) {
return renderAssistantAttachmentStatusCard({
kind: "video",
label: attachment.label,
badge: availability.status === "checking" ? "Checking..." : "Unavailable",
reason: availability.status === "unavailable" ? availability.reason : undefined,
});
}
return html`
<div class="chat-assistant-attachment-card chat-assistant-attachment-card--video">
<video controls preload="metadata" src=${attachmentUrl}></video>
<a
class="chat-assistant-attachment-card__link"
href=${attachmentUrl}
target="_blank"
rel="noreferrer"
>${attachment.label}</a
>
</div>
`;
}
if (!attachmentUrl) {
return renderAssistantAttachmentStatusCard({
kind: "document",
label: attachment.label,
badge: availability.status === "checking" ? "Checking..." : "Unavailable",
reason: availability.status === "unavailable" ? availability.reason : undefined,
});
}
return html`
<div class="chat-assistant-attachment-card">
<span class="chat-assistant-attachment-card__icon">${icons.paperclip}</span>
<a
class="chat-assistant-attachment-card__link"
href=${attachmentUrl}
target="_blank"
rel="noreferrer"
>${attachment.label}</a
>
</div>
`;
})}
</div>
`;
}
function renderInlineToolCards(
toolCards: ToolCard[],
opts: {
messageKey: string;
onOpenSidebar?: (content: SidebarContent) => void;
isToolExpanded?: (toolCardId: string) => boolean;
onToggleToolExpanded?: (toolCardId: string) => void;
canvasHostUrl?: string | null;
embedSandboxMode?: EmbedSandboxMode;
allowExternalEmbedUrls?: boolean;
},
) {
return html`
<div class="chat-tools-inline">
${toolCards.map((card, index) =>
renderToolCard(card, {
expanded: opts.isToolExpanded?.(`${opts.messageKey}:toolcard:${index}`) ?? false,
onToggleExpanded: opts.onToggleToolExpanded
? () => opts.onToggleToolExpanded?.(`${opts.messageKey}:toolcard:${index}`)
: () => undefined,
onOpenSidebar: opts.onOpenSidebar,
canvasHostUrl: opts.canvasHostUrl,
embedSandboxMode: opts.embedSandboxMode ?? "scripts",
allowExternalEmbedUrls: opts.allowExternalEmbedUrls ?? false,
}),
)}
</div>
`;
}
@@ -705,14 +1014,14 @@ function jsonSummaryLabel(parsed: unknown): string {
return "JSON";
}
function renderExpandButton(markdown: string, onOpenSidebar: (content: string) => void) {
function renderExpandButton(markdown: string, onOpenSidebar: (content: SidebarContent) => void) {
return html`
<button
class="btn btn--xs chat-expand-btn"
type="button"
title="Open in canvas"
aria-label="Open in canvas"
@click=${() => onOpenSidebar(markdown)}
@click=${() => onOpenSidebar({ kind: "markdown", content: markdown })}
>
<span class="chat-expand-btn__icon" aria-hidden="true">${icons.panelRightOpen}</span>
</button>
@@ -721,28 +1030,58 @@ function renderExpandButton(markdown: string, onOpenSidebar: (content: string) =
function renderGroupedMessage(
message: unknown,
opts: { isStreaming: boolean; showReasoning: boolean; showToolCalls?: boolean },
onOpenSidebar?: (content: string) => void,
messageKey: string,
opts: {
isStreaming: boolean;
showReasoning: boolean;
showToolCalls?: boolean;
autoExpandToolCalls?: boolean;
isToolMessageExpanded?: (messageId: string) => boolean;
onToggleToolMessageExpanded?: (messageId: string) => void;
isToolExpanded?: (toolCardId: string) => boolean;
onToggleToolExpanded?: (toolCardId: string) => void;
onRequestUpdate?: () => void;
canvasHostUrl?: string | null;
basePath?: string;
localMediaPreviewRoots?: readonly string[];
assistantAttachmentAuthToken?: string | null;
embedSandboxMode?: EmbedSandboxMode;
allowExternalEmbedUrls?: boolean;
},
onOpenSidebar?: (content: SidebarContent) => void,
) {
const m = message as Record<string, unknown>;
const role = typeof m.role === "string" ? m.role : "unknown";
const normalizedRole = normalizeRoleForGrouping(role);
const normalizedRawRole = normalizeLowercaseStringOrEmpty(role);
const isToolResult =
isToolResultMessage(message) ||
normalizedRawRole === "toolresult" ||
normalizedRawRole === "tool_result" ||
role.toLowerCase() === "toolresult" ||
role.toLowerCase() === "tool_result" ||
typeof m.toolCallId === "string" ||
typeof m.tool_call_id === "string";
const toolCards = (opts.showToolCalls ?? true) ? extractToolCards(message) : [];
const toolCards = (opts.showToolCalls ?? true) ? extractToolCards(message, messageKey) : [];
const hasToolCards = toolCards.length > 0;
const images = extractImages(message);
const hasImages = images.length > 0;
const audioClips = extractAudioClips(message);
const hasAudio = audioClips.length > 0;
const extractedText = extractTextCached(message);
const normalizedMessage = normalizeMessage(message);
const extractedText = normalizedMessage.content
.reduce<string[]>((lines, item) => {
if (item.type === "text" && typeof item.text === "string") {
lines.push(item.text);
}
return lines;
}, [])
.join("\n")
.trim();
const assistantAttachments = normalizedMessage.content.filter(
(item): item is Extract<MessageContentItem, { type: "attachment" }> =>
item.type === "attachment",
);
const assistantViewBlocks = normalizedMessage.content.filter(
(item): item is Extract<MessageContentItem, { type: "canvas" }> => item.type === "canvas",
);
const extractedThinking =
opts.showReasoning && role === "assistant" ? extractThinkingCached(message) : null;
const markdownBase = extractedText?.trim() ? extractedText : null;
@@ -754,26 +1093,26 @@ function renderGroupedMessage(
// Detect pure-JSON messages and render as collapsible block
const jsonResult = markdown && !opts.isStreaming ? detectJson(markdown) : null;
const bubbleClasses = [
"chat-bubble",
opts.isStreaming ? "streaming" : "",
"fade-in",
canCopyMarkdown ? "has-copy" : "",
]
const bubbleClasses = ["chat-bubble", opts.isStreaming ? "streaming" : "", "fade-in"]
.filter(Boolean)
.join(" ");
if (!markdown && hasToolCards && isToolResult) {
return renderCollapsedToolCards(toolCards, onOpenSidebar);
}
// Suppress empty bubbles when tool cards are the only content and toggle is off
const visibleToolCards = hasToolCards && (opts.showToolCalls ?? true);
if (!markdown && !visibleToolCards && !hasImages && !hasAudio) {
if (
!markdown &&
!visibleToolCards &&
!hasImages &&
assistantAttachments.length === 0 &&
assistantViewBlocks.length === 0 &&
!normalizedMessage.replyTarget
) {
return nothing;
}
const isToolMessage = normalizedRole === "tool" || isToolResult;
const toolMessageDisclosureId = `toolmsg:${messageKey}`;
const toolMessageExpanded = opts.isToolMessageExpanded?.(toolMessageDisclosureId) ?? false;
const toolNames = [...new Set(toolCards.map((c) => c.name))];
const toolSummaryLabel =
toolNames.length <= 3
@@ -781,11 +1120,19 @@ function renderGroupedMessage(
: `${toolNames.slice(0, 2).join(", ")} +${toolNames.length - 2} more`;
const toolPreview =
markdown && !toolSummaryLabel ? markdown.trim().replace(/\s+/g, " ").slice(0, 120) : "";
const singleToolCard = toolCards.length === 1 ? toolCards[0] : null;
const toolMessageLabel =
singleToolCard && !markdown && !hasImages
? singleToolCard.outputText?.trim()
? "Tool output"
: "Tool call"
: "Tool output";
const hasActions = canCopyMarkdown || canExpand;
return html`
<div class="${bubbleClasses}">
${renderReplyPill(normalizedMessage.replyTarget)}
${hasActions
? html`<div class="chat-bubble-actions">
${canExpand ? renderExpandButton(markdown!, onOpenSidebar!) : nothing}
@@ -794,47 +1141,108 @@ function renderGroupedMessage(
: nothing}
${isToolMessage
? html`
<details class="chat-tool-msg-collapse">
<summary class="chat-tool-msg-summary">
<div
class="chat-tool-msg-collapse chat-tool-msg-collapse--manual ${toolMessageExpanded
? "is-open"
: ""}"
>
<button
class="chat-tool-msg-summary"
type="button"
aria-expanded=${String(toolMessageExpanded)}
@click=${() => opts.onToggleToolMessageExpanded?.(toolMessageDisclosureId)}
>
<span class="chat-tool-msg-summary__icon">${icons.zap}</span>
<span class="chat-tool-msg-summary__label">Tool output</span>
<span class="chat-tool-msg-summary__label">${toolMessageLabel}</span>
${toolSummaryLabel
? html`<span class="chat-tool-msg-summary__names">${toolSummaryLabel}</span>`
: toolPreview
? html`<span class="chat-tool-msg-summary__preview">${toolPreview}</span>`
: nothing}
</summary>
<div class="chat-tool-msg-body">
${renderMessageImages(images)} ${renderMessageAudio(audioClips)}
${reasoningMarkdown
? html`<div class="chat-thinking">
${unsafeHTML(toSanitizedMarkdownHtml(reasoningMarkdown))}
</div>`
: nothing}
${jsonResult
? html`<details class="chat-json-collapse">
<summary class="chat-json-summary">
<span class="chat-json-badge">JSON</span>
<span class="chat-json-label">${jsonSummaryLabel(jsonResult.parsed)}</span>
</summary>
<pre class="chat-json-content"><code>${jsonResult.pretty}</code></pre>
</details>`
: markdown
? html`<div class="chat-text" dir="${detectTextDirection(markdown)}">
${unsafeHTML(toSanitizedMarkdownHtml(markdown))}
</div>`
: nothing}
${hasToolCards ? renderCollapsedToolCards(toolCards, onOpenSidebar) : nothing}
</div>
</details>
</button>
${toolMessageExpanded
? html`
<div class="chat-tool-msg-body">
${renderMessageImages(images)}
${renderAssistantAttachments(
assistantAttachments,
opts.localMediaPreviewRoots ?? [],
opts.basePath,
opts.assistantAttachmentAuthToken,
opts.onRequestUpdate,
)}
${reasoningMarkdown
? html`<div class="chat-thinking">
${unsafeHTML(toSanitizedMarkdownHtml(reasoningMarkdown))}
</div>`
: nothing}
${jsonResult
? html`<details
class="chat-json-collapse"
?open=${Boolean(opts.autoExpandToolCalls)}
>
<summary class="chat-json-summary">
<span class="chat-json-badge">JSON</span>
<span class="chat-json-label"
>${jsonSummaryLabel(jsonResult.parsed)}</span
>
</summary>
<pre class="chat-json-content"><code>${jsonResult.pretty}</code></pre>
</details>`
: markdown
? html`<div class="chat-text" dir="${detectTextDirection(markdown)}">
${unsafeHTML(toSanitizedMarkdownHtml(markdown))}
</div>`
: nothing}
${hasToolCards
? singleToolCard && !markdown && !hasImages
? renderExpandedToolCardContent(
singleToolCard,
onOpenSidebar,
opts.canvasHostUrl,
opts.embedSandboxMode ?? "scripts",
opts.allowExternalEmbedUrls ?? false,
)
: renderInlineToolCards(toolCards, {
messageKey,
onOpenSidebar,
isToolExpanded: opts.isToolExpanded,
onToggleToolExpanded: opts.onToggleToolExpanded,
canvasHostUrl: opts.canvasHostUrl,
embedSandboxMode: opts.embedSandboxMode ?? "scripts",
allowExternalEmbedUrls: opts.allowExternalEmbedUrls ?? false,
})
: nothing}
</div>
`
: nothing}
</div>
`
: html`
${renderMessageImages(images)} ${renderMessageAudio(audioClips)}
${renderMessageImages(images)}
${renderAssistantAttachments(
assistantAttachments,
opts.localMediaPreviewRoots ?? [],
opts.basePath,
opts.assistantAttachmentAuthToken,
opts.onRequestUpdate,
)}
${reasoningMarkdown
? html`<div class="chat-thinking">
${unsafeHTML(toSanitizedMarkdownHtml(reasoningMarkdown))}
</div>`
: nothing}
${normalizedRole === "assistant" && assistantViewBlocks.length > 0
? html`${assistantViewBlocks.map(
(block) => html`${renderToolPreview(block.preview, "chat_message", {
onOpenSidebar,
rawText: block.rawText ?? null,
canvasHostUrl: opts.canvasHostUrl,
embedSandboxMode: opts.embedSandboxMode ?? "scripts",
})}
${block.rawText ? renderRawOutputToggle(block.rawText) : nothing}`,
)}`
: nothing}
${jsonResult
? html`<details class="chat-json-collapse">
<summary class="chat-json-summary">
@@ -848,7 +1256,17 @@ function renderGroupedMessage(
${unsafeHTML(toSanitizedMarkdownHtml(markdown))}
</div>`
: nothing}
${hasToolCards ? renderCollapsedToolCards(toolCards, onOpenSidebar) : nothing}
${hasToolCards
? renderInlineToolCards(toolCards, {
messageKey,
onOpenSidebar,
isToolExpanded: opts.isToolExpanded,
onToggleToolExpanded: opts.onToggleToolExpanded,
canvasHostUrl: opts.canvasHostUrl,
embedSandboxMode: opts.embedSandboxMode ?? "scripts",
allowExternalEmbedUrls: opts.allowExternalEmbedUrls ?? false,
})
: nothing}
`}
</div>
`;

View File

@@ -33,6 +33,19 @@ describe("message-normalizer", () => {
});
});
it("does not reinterpret directive-like user string content", () => {
const result = normalizeMessage({
role: "user",
content: "MEDIA:/tmp/example.png\n[[reply_to_current]]",
});
expect(result.content).toEqual([
{ type: "text", text: "MEDIA:/tmp/example.png\n[[reply_to_current]]" },
]);
expect(result.replyTarget).toBeUndefined();
expect(result.audioAsVoice).toBeUndefined();
});
it("normalizes message with array content", () => {
const result = normalizeMessage({
role: "assistant",
@@ -59,6 +72,23 @@ describe("message-normalizer", () => {
});
});
it("does not reinterpret directive-like user text blocks inside array content", () => {
const result = normalizeMessage({
role: "user",
content: [{ type: "text", text: "MEDIA:/tmp/example.png\n[[audio_as_voice]]" }],
});
expect(result.content).toEqual([
{
type: "text",
text: "MEDIA:/tmp/example.png\n[[audio_as_voice]]",
name: undefined,
args: undefined,
},
]);
expect(result.audioAsVoice).toBeUndefined();
});
it("normalizes message with text field (alternative format)", () => {
const result = normalizeMessage({
role: "user",
@@ -68,6 +98,220 @@ describe("message-normalizer", () => {
expect(result.content).toEqual([{ type: "text", text: "Alternative format" }]);
});
it("expands [embed] shortcodes into canvas blocks", () => {
const result = normalizeMessage({
role: "assistant",
content: 'Here.\n[embed ref="cv_status" title="Status" height="320" /]',
});
expect(result.content).toEqual([
{ type: "text", text: "Here." },
{
type: "canvas",
preview: {
kind: "canvas",
surface: "assistant_message",
render: "url",
viewId: "cv_status",
url: "/__openclaw__/canvas/documents/cv_status/index.html",
title: "Status",
preferredHeight: 320,
},
rawText: null,
},
]);
});
it("ignores [embed] shortcodes inside fenced code blocks", () => {
const result = normalizeMessage({
role: "assistant",
content: '```text\n[embed ref="cv_status" /]\n```',
});
expect(result.content).toEqual([
{
type: "text",
text: '```text\n[embed ref="cv_status" /]\n```',
},
]);
});
it("leaves block-form inline html embed shortcodes as plain text", () => {
const result = normalizeMessage({
role: "assistant",
content: '[embed content_type="html" title="Status"]\n<div>Ready</div>\n[/embed]',
});
expect(result.content).toEqual([
{
type: "text",
text: '[embed content_type="html" title="Status"]\n<div>Ready</div>\n[/embed]',
},
]);
});
it("extracts MEDIA attachments and reply metadata from assistant text", () => {
const result = normalizeMessage({
role: "assistant",
content:
"[[reply_to:thread-123]]Intro\nMEDIA:https://example.com/image.png\nOutro\nMEDIA:https://example.com/voice.ogg\n[[audio_as_voice]]",
});
expect(result.replyTarget).toEqual({ kind: "id", id: "thread-123" });
expect(result.audioAsVoice).toBe(true);
expect(result.content).toEqual([
{ type: "text", text: "Intro" },
{
type: "attachment",
attachment: {
url: "https://example.com/image.png",
kind: "image",
label: "image.png",
mimeType: "image/png",
},
},
{ type: "text", text: "Outro" },
{
type: "attachment",
attachment: {
url: "https://example.com/voice.ogg",
kind: "audio",
label: "voice.ogg",
mimeType: "audio/ogg",
isVoiceNote: true,
},
},
]);
});
it("marks media-only audio attachments as voice notes when audio_as_voice is present", () => {
const result = normalizeMessage({
role: "assistant",
content: "MEDIA:https://example.com/voice.ogg\n[[audio_as_voice]]",
});
expect(result.audioAsVoice).toBe(true);
expect(result.content).toEqual([
{
type: "attachment",
attachment: {
url: "https://example.com/voice.ogg",
kind: "audio",
label: "voice.ogg",
mimeType: "audio/ogg",
isVoiceNote: true,
},
},
]);
});
it("keeps valid local MEDIA paths as assistant attachments", () => {
const result = normalizeMessage({
role: "assistant",
content: "Hello\nMEDIA:/tmp/openclaw/test-image.png\nWorld",
});
expect(result.content).toEqual([
{ type: "text", text: "Hello" },
{
type: "attachment",
attachment: {
url: "/tmp/openclaw/test-image.png",
kind: "image",
label: "test-image.png",
mimeType: "image/png",
},
},
{ type: "text", text: "World" },
]);
});
it("keeps spaced local filenames together instead of leaking suffix text", () => {
const result = normalizeMessage({
role: "assistant",
content: "MEDIA:/tmp/openclaw/shinkansen kato - Google Shopping.pdf",
});
expect(result.content).toEqual([
{
type: "attachment",
attachment: {
url: "/tmp/openclaw/shinkansen kato - Google Shopping.pdf",
kind: "document",
label: "shinkansen kato - Google Shopping.pdf",
mimeType: "application/pdf",
},
},
]);
});
it("does not fall back to raw text when an invalid MEDIA line is stripped", () => {
const result = normalizeMessage({
role: "assistant",
content: "MEDIA:~/Pictures/My File.png",
});
expect(result.content).toEqual([]);
});
it("preserves relative MEDIA references as visible text instead of dropping the assistant turn", () => {
const result = normalizeMessage({
role: "assistant",
content: "MEDIA:chart.png",
});
expect(result.content).toEqual([{ type: "text", text: "MEDIA:chart.png" }]);
});
it("strips reply_to_current without rendering a quoted preview", () => {
const result = normalizeMessage({
role: "assistant",
content: "[[reply_to_current]]\nReply body",
});
expect(result.replyTarget).toEqual({ kind: "current" });
expect(result.content).toEqual([{ type: "text", text: "Reply body" }]);
});
it("does not restore stripped reply tags when no visible text remains", () => {
const result = normalizeMessage({
role: "assistant",
content: "[[reply_to_current]]",
});
expect(result.replyTarget).toEqual({ kind: "current" });
expect(result.content).toEqual([]);
});
it("preserves structured attachment content items", () => {
const result = normalizeMessage({
role: "assistant",
content: [
{
type: "attachment",
attachment: {
url: "~/Pictures/test image.png",
kind: "image",
label: "test image.png",
mimeType: "image/png",
},
},
],
});
expect(result.content).toEqual([
{
type: "attachment",
attachment: {
url: "~/Pictures/test image.png",
kind: "image",
label: "test image.png",
mimeType: "image/png",
},
},
]);
});
it("detects tool result by toolCallId", () => {
const result = normalizeMessage({
role: "assistant",
@@ -124,7 +368,7 @@ describe("message-normalizer", () => {
content: [{ type: "tool_use", name: "test", arguments: { foo: "bar" } }],
});
expect(result.content[0].args).toEqual({ foo: "bar" });
expect((result.content[0] as { args?: unknown }).args).toEqual({ foo: "bar" });
});
it("handles input field for anthropic tool_use blocks", () => {
@@ -133,7 +377,7 @@ describe("message-normalizer", () => {
content: [{ type: "tool_use", name: "Bash", input: { command: "pwd" } }],
});
expect(result.content[0].args).toEqual({ command: "pwd" });
expect((result.content[0] as { args?: unknown }).args).toEqual({ command: "pwd" });
});
it("preserves top-level sender labels", () => {

View File

@@ -3,14 +3,242 @@
*/
import { stripInboundMetadata } from "../../../../src/auto-reply/reply/strip-inbound-meta.js";
import { extractCanvasShortcodes } from "../../../../src/chat/canvas-render.js";
import {
isToolCallContentType,
isToolResultContentType,
resolveToolBlockArgs,
} from "../../../../src/chat/tool-content.js";
import { normalizeLowercaseStringOrEmpty } from "../string-coerce.ts";
import { mediaKindFromMime } from "../../../../src/media/constants.js";
import { splitMediaFromOutput } from "../../../../src/media/parse.js";
import { parseInlineDirectives } from "../../../../src/utils/directive-tags.js";
import type { NormalizedMessage, MessageContentItem } from "../types/chat-types.ts";
function coerceCanvasPreview(
value: unknown,
):
| Extract<NonNullable<NormalizedMessage["content"][number]>, { type: "canvas" }>["preview"]
| null {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return null;
}
const preview = value as Record<string, unknown>;
if (preview.kind !== "canvas" || preview.surface === "tool_card") {
return null;
}
const render = preview.render === "url" ? "url" : null;
if (!render) {
return null;
}
return {
kind: "canvas",
surface: "assistant_message",
render,
...(typeof preview.title === "string" ? { title: preview.title } : {}),
...(typeof preview.preferredHeight === "number"
? { preferredHeight: preview.preferredHeight }
: {}),
...(typeof preview.url === "string" ? { url: preview.url } : {}),
...(typeof preview.viewId === "string" ? { viewId: preview.viewId } : {}),
...(typeof preview.className === "string" ? { className: preview.className } : {}),
...(typeof preview.style === "string" ? { style: preview.style } : {}),
};
}
function isRenderableAssistantAttachment(url: string): boolean {
const trimmed = url.trim();
return (
/^https?:\/\//i.test(trimmed) ||
/^data:(?:image|audio|video)\//i.test(trimmed) ||
/^\/(?:__openclaw__|media)\//.test(trimmed) ||
trimmed.startsWith("file://") ||
trimmed.startsWith("~") ||
trimmed.startsWith("/") ||
/^[a-zA-Z]:[\\/]/.test(trimmed)
);
}
function shouldPreserveRelativeAssistantAttachment(url: string): boolean {
const trimmed = url.trim();
if (!trimmed) {
return false;
}
return (
!/^https?:\/\//i.test(trimmed) &&
!/^data:(?:image|audio|video)\//i.test(trimmed) &&
!/^\/(?:__openclaw__|media)\//.test(trimmed) &&
!trimmed.startsWith("file://") &&
!trimmed.startsWith("~") &&
!trimmed.startsWith("/") &&
!/^[a-zA-Z]:[\\/]/.test(trimmed)
);
}
const MIME_BY_EXT: Record<string, string> = {
png: "image/png",
jpg: "image/jpeg",
jpeg: "image/jpeg",
webp: "image/webp",
gif: "image/gif",
heic: "image/heic",
heif: "image/heif",
ogg: "audio/ogg",
oga: "audio/ogg",
mp3: "audio/mpeg",
wav: "audio/wav",
flac: "audio/flac",
aac: "audio/aac",
opus: "audio/opus",
m4a: "audio/mp4",
mp4: "video/mp4",
mov: "video/quicktime",
pdf: "application/pdf",
txt: "text/plain",
md: "text/markdown",
csv: "text/csv",
json: "application/json",
zip: "application/zip",
};
function getFileExtension(url: string): string | undefined {
const trimmed = url.trim();
if (!trimmed) {
return undefined;
}
const source = (() => {
try {
if (/^https?:\/\//i.test(trimmed)) {
return new URL(trimmed).pathname;
}
} catch {}
return trimmed;
})();
const fileName = source.split(/[\\/]/).pop() ?? source;
const match = /\.([a-zA-Z0-9]+)$/.exec(fileName);
return match?.[1]?.toLowerCase();
}
function mimeTypeFromUrl(url: string): string | undefined {
const ext = getFileExtension(url);
return ext ? MIME_BY_EXT[ext] : undefined;
}
function inferAttachmentKind(url: string): {
kind: "image" | "audio" | "video" | "document";
mimeType?: string;
label: string;
} {
const mimeType = mimeTypeFromUrl(url);
const kind = mediaKindFromMime(mimeType) ?? "document";
const label = (() => {
try {
if (/^https?:\/\//i.test(url)) {
const parsed = new URL(url);
const name = parsed.pathname.split("/").pop()?.trim();
return name || parsed.hostname || url;
}
} catch {}
const name = url.split(/[\\/]/).pop()?.trim();
return name || url;
})();
return { kind, mimeType, label };
}
function mergeAdjacentTextItems(items: MessageContentItem[]): MessageContentItem[] {
const merged: MessageContentItem[] = [];
for (const item of items) {
const previous = merged[merged.length - 1];
if (item.type === "text" && previous?.type === "text") {
previous.text = [previous.text, item.text].filter((value) => value !== undefined).join("\n");
continue;
}
merged.push(item);
}
return merged.filter((item) => item.type !== "text" || Boolean(item.text?.trim()));
}
function expandTextContent(text: string): {
content: MessageContentItem[];
audioAsVoice: boolean;
replyTarget: NormalizedMessage["replyTarget"];
} {
const extracted = extractCanvasShortcodes(text);
const parsed = splitMediaFromOutput(extracted.text);
const parts: MessageContentItem[] = [];
let audioAsVoice = parsed.audioAsVoice === true;
let replyTarget: NormalizedMessage["replyTarget"] = null;
const segments = parsed.segments ?? [{ type: "text" as const, text: parsed.text }];
for (const segment of segments) {
if (segment.type === "media") {
if (!isRenderableAssistantAttachment(segment.url)) {
if (shouldPreserveRelativeAssistantAttachment(segment.url)) {
parts.push({ type: "text", text: `MEDIA:${segment.url}` });
}
continue;
}
const inferred = inferAttachmentKind(segment.url);
parts.push({
type: "attachment",
attachment: {
url: segment.url,
kind: inferred.kind,
label: inferred.label,
mimeType: inferred.mimeType,
},
});
continue;
}
const directives = parseInlineDirectives(segment.text, {
stripAudioTag: true,
stripReplyTags: true,
});
audioAsVoice = audioAsVoice || directives.audioAsVoice;
if (directives.replyToExplicitId) {
replyTarget = { kind: "id", id: directives.replyToExplicitId };
} else if (directives.replyToCurrent && replyTarget === null) {
replyTarget = { kind: "current" };
}
if (directives.text) {
parts.push({ type: "text", text: directives.text });
}
}
for (const preview of extracted.previews) {
parts.push({ type: "canvas", preview, rawText: null });
}
const content = mergeAdjacentTextItems(
parts.map((item) => {
if (item.type === "attachment" && item.attachment.kind === "audio" && audioAsVoice) {
return {
...item,
attachment: {
...item.attachment,
isVoiceNote: true,
},
};
}
return item;
}),
);
return {
content:
content.length > 0
? content
: (parsed.mediaUrls ?? []).some((url) => shouldPreserveRelativeAssistantAttachment(url))
? (parsed.mediaUrls ?? [])
.filter((url) => shouldPreserveRelativeAssistantAttachment(url))
.map((url) => ({ type: "text" as const, text: `MEDIA:${url}` }))
: replyTarget === null && !audioAsVoice && parsed.text.trim().length > 0
? [{ type: "text", text: parsed.text }]
: [],
audioAsVoice,
replyTarget,
};
}
/**
* Normalize a raw message object into a consistent structure.
*/
@@ -36,21 +264,112 @@ export function normalizeMessage(message: unknown): NormalizedMessage {
if (hasToolId || hasToolContent || hasToolName) {
role = "toolResult";
}
const isAssistantMessage = role === "assistant";
// Extract content
let content: MessageContentItem[] = [];
let audioAsVoice = false;
let replyTarget: NormalizedMessage["replyTarget"] = null;
if (typeof m.content === "string") {
content = [{ type: "text", text: m.content }];
if (isAssistantMessage) {
const expanded = expandTextContent(m.content);
content = expanded.content;
audioAsVoice = expanded.audioAsVoice;
replyTarget = expanded.replyTarget;
} else {
content = [{ type: "text", text: m.content }];
}
} else if (Array.isArray(m.content)) {
content = m.content.map((item: Record<string, unknown>) => ({
type: (item.type as MessageContentItem["type"]) || "text",
text: item.text as string | undefined,
name: item.name as string | undefined,
args: resolveToolBlockArgs(item),
}));
content = m.content.flatMap((item: Record<string, unknown>) => {
if (
item.type === "attachment" &&
item.attachment &&
typeof item.attachment === "object" &&
!Array.isArray(item.attachment)
) {
const attachment = item.attachment as {
url?: unknown;
kind?: unknown;
label?: unknown;
mimeType?: unknown;
isVoiceNote?: unknown;
};
if (
typeof attachment.url !== "string" ||
(attachment.kind !== "image" &&
attachment.kind !== "audio" &&
attachment.kind !== "video" &&
attachment.kind !== "document") ||
typeof attachment.label !== "string"
) {
return [];
}
return [
{
type: "attachment" as const,
attachment: {
url: attachment.url,
kind: attachment.kind,
label: attachment.label,
...(typeof attachment.mimeType === "string"
? { mimeType: attachment.mimeType }
: {}),
...(attachment.isVoiceNote === true ? { isVoiceNote: true } : {}),
},
},
];
}
if (
item.type === "canvas" &&
item.preview &&
typeof item.preview === "object" &&
!Array.isArray(item.preview)
) {
const preview = coerceCanvasPreview(item.preview);
if (!preview) {
return [];
}
return [
{
type: "canvas" as const,
preview,
rawText: typeof item.rawText === "string" ? item.rawText : null,
},
];
}
if (item.type === "text" && typeof item.text === "string" && isAssistantMessage) {
const expanded = expandTextContent(item.text);
audioAsVoice = audioAsVoice || expanded.audioAsVoice;
if (expanded.replyTarget?.kind === "id") {
replyTarget = expanded.replyTarget;
} else if (expanded.replyTarget?.kind === "current" && replyTarget === null) {
replyTarget = expanded.replyTarget;
}
return expanded.content;
}
return [
{
type:
(item.type as Extract<
MessageContentItem,
{ type: "text" | "tool_call" | "tool_result" }
>["type"]) || "text",
text: item.text as string | undefined,
name: item.name as string | undefined,
args: resolveToolBlockArgs(item),
},
];
});
} else if (typeof m.text === "string") {
content = [{ type: "text", text: m.text }];
if (isAssistantMessage) {
const expanded = expandTextContent(m.text);
content = expanded.content;
audioAsVoice = expanded.audioAsVoice;
replyTarget = expanded.replyTarget;
} else {
content = [{ type: "text", text: m.text }];
}
}
const timestamp = typeof m.timestamp === "number" ? m.timestamp : Date.now();
@@ -68,14 +387,22 @@ export function normalizeMessage(message: unknown): NormalizedMessage {
});
}
return { role, content, timestamp, id, senderLabel };
return {
role,
content,
timestamp,
id,
senderLabel,
...(audioAsVoice ? { audioAsVoice: true } : {}),
...(replyTarget ? { replyTarget } : {}),
};
}
/**
* Normalize role for grouping purposes.
*/
export function normalizeRoleForGrouping(role: string): string {
const lower = normalizeLowercaseStringOrEmpty(role);
const lower = role.toLowerCase();
// Preserve original casing when it's already a core role.
if (role === "user" || role === "User") {
return role;
@@ -103,6 +430,6 @@ export function normalizeRoleForGrouping(role: string): string {
*/
export function isToolResultMessage(message: unknown): boolean {
const m = message as Record<string, unknown>;
const role = normalizeLowercaseStringOrEmpty(m.role);
const role = typeof m.role === "string" ? m.role.toLowerCase() : "";
return role === "toolresult" || role === "tool_result";
}

View File

@@ -1,34 +1,450 @@
/* @vitest-environment jsdom */
import { render } from "lit";
import { describe, expect, it } from "vitest";
import { extractToolCards, renderToolCardSidebar } from "./tool-cards.ts";
import { describe, expect, it, vi } from "vitest";
import {
buildToolCardSidebarContent,
extractToolCards,
renderToolCard,
renderToolPreview,
} from "./tool-cards.ts";
describe("tool cards", () => {
it("renders anthropic tool_use input details in tool cards", () => {
const cards = extractToolCards({
role: "assistant",
content: [
{
type: "tool_use",
id: "toolu_123",
name: "Bash",
input: { command: 'time claude -p "say ok"' },
},
],
});
describe("tool-cards", () => {
it("pretty-prints structured args and pairs tool output onto the same card", () => {
const cards = extractToolCards(
{
role: "assistant",
toolCallId: "call-1",
content: [
{
type: "toolcall",
id: "call-1",
name: "browser.open",
arguments: { url: "https://example.com", retry: 0 },
},
{
type: "toolresult",
id: "call-1",
name: "browser.open",
text: "Opened page",
},
],
},
"msg:1",
);
expect(cards).toHaveLength(1);
expect(cards[0]).toMatchObject({
kind: "call",
name: "Bash",
args: { command: 'time claude -p "say ok"' },
id: "msg:1:call-1",
name: "browser.open",
outputText: "Opened page",
});
expect(cards[0]?.inputText).toContain('"url": "https://example.com"');
expect(cards[0]?.inputText).toContain('"retry": 0');
});
it("preserves string args verbatim and keeps empty-output cards", () => {
const cards = extractToolCards(
{
role: "assistant",
toolCallId: "call-2",
content: [
{
type: "toolcall",
name: "deck_manage",
arguments: "with Example Deck",
},
],
},
"msg:2",
);
expect(cards).toHaveLength(1);
expect(cards[0]?.inputText).toBe("with Example Deck");
expect(cards[0]?.outputText).toBeUndefined();
});
it("preserves tool-call input payloads from tool_use blocks", () => {
const cards = extractToolCards(
{
role: "assistant",
content: [
{
type: "tool_use",
id: "call-2b",
name: "deck_manage",
input: { deck: "Example Deck", mode: "preview" },
},
],
},
"msg:2b",
);
expect(cards).toHaveLength(1);
expect(cards[0]?.inputText).toContain('"deck": "Example Deck"');
expect(cards[0]?.inputText).toContain('"mode": "preview"');
});
it("builds sidebar content with input and empty output status", () => {
const [card] = extractToolCards(
{
role: "assistant",
toolCallId: "call-3",
content: [
{
type: "toolcall",
name: "deck_manage",
arguments: "with Example Deck",
},
],
},
"msg:3",
);
const sidebar = buildToolCardSidebarContent(card);
expect(sidebar).toContain("## Deck Manage");
expect(sidebar).toContain("### Tool input");
expect(sidebar).toContain("with Example Deck");
expect(sidebar).toContain("### Tool output");
expect(sidebar).toContain("No output");
});
it("extracts canvas handle payloads into canvas previews", () => {
const [card] = extractToolCards(
{
role: "tool",
toolName: "canvas_render",
content: JSON.stringify({
kind: "canvas",
view: {
backend: "canvas",
id: "cv_inline",
url: "/__openclaw__/canvas/documents/cv_inline/index.html",
},
presentation: {
target: "assistant_message",
title: "Inline demo",
preferred_height: 420,
},
}),
},
"msg:view:1",
);
expect(card?.preview).toMatchObject({
kind: "canvas",
surface: "assistant_message",
render: "url",
viewId: "cv_inline",
url: "/__openclaw__/canvas/documents/cv_inline/index.html",
title: "Inline demo",
preferredHeight: 420,
});
});
it("drops tool_card-targeted canvas payloads", () => {
const [card] = extractToolCards(
{
role: "tool",
toolName: "canvas_render",
content: JSON.stringify({
kind: "canvas",
view: {
backend: "canvas",
id: "cv_tool_card",
url: "/__openclaw__/canvas/documents/cv_tool_card/index.html",
},
presentation: {
target: "tool_card",
title: "Tool card demo",
},
}),
},
"msg:view:2",
);
expect(card?.preview).toBeUndefined();
});
it("renders trusted canvas previews with same-origin only when explicitly requested", () => {
const container = document.createElement("div");
render(renderToolCardSidebar(cards[0]), container);
render(
renderToolPreview(
{
kind: "canvas",
surface: "assistant_message",
render: "url",
viewId: "cv_inline",
url: "/__openclaw__/canvas/documents/cv_inline/index.html",
title: "Inline demo",
preferredHeight: 420,
},
"chat_message",
{ embedSandboxMode: "trusted" },
),
container,
);
expect(container.textContent).toContain('time claude -p "say ok"');
expect(container.textContent).toContain("Bash");
const iframe = container.querySelector<HTMLIFrameElement>(".chat-tool-card__preview-frame");
expect(iframe?.getAttribute("sandbox")).toBe("allow-scripts allow-same-origin");
});
it("does not extract inline-html canvas payloads into canvas previews", () => {
const [card] = extractToolCards(
{
role: "tool",
toolName: "canvas_render",
content: JSON.stringify({
kind: "canvas",
source: {
type: "html",
content: "<div>hello</div>",
},
presentation: {
target: "assistant_message",
title: "Status",
preferred_height: 300,
},
}),
},
"msg:view:3",
);
expect(card?.preview).toBeUndefined();
});
it("does not create a view preview for malformed json output", () => {
const [card] = extractToolCards(
{
role: "tool",
toolName: "canvas_render",
content: '{"kind":"present_view","view":{"id":"broken"}',
},
"msg:view:4",
);
expect(card?.preview).toBeUndefined();
});
it("does not create a view preview for generic tool text output", () => {
const [card] = extractToolCards(
{
role: "tool",
toolName: "browser.open",
content: "present_view: cv_widget",
},
"msg:view:5",
);
expect(card?.preview).toBeUndefined();
});
it("renders expanded cards with inline input and output sections", () => {
const container = document.createElement("div");
const toggle = vi.fn();
render(
renderToolCard(
{
id: "msg:4:call-4",
name: "browser.open",
args: { url: "https://example.com" },
inputText: '{\n "url": "https://example.com"\n}',
outputText: "Opened page",
},
{ expanded: true, onToggleExpanded: toggle },
),
container,
);
expect(container.textContent).toContain("Tool input");
expect(container.textContent).toContain("Tool output");
expect(container.textContent).toContain("https://example.com");
expect(container.textContent).toContain("Opened page");
});
it("renders expanded tool calls without an inline output block when no output is present", () => {
const container = document.createElement("div");
render(
renderToolCard(
{
id: "msg:4b:call-4b",
name: "sessions_spawn",
args: { mode: "session", thread: true },
inputText: '{\n "mode": "session",\n "thread": true\n}',
},
{ expanded: true, onToggleExpanded: vi.fn() },
),
container,
);
expect(container.textContent).toContain("Tool input");
expect(container.textContent).toContain('"thread": true');
expect(container.textContent).not.toContain("Tool output");
expect(container.textContent).not.toContain("No output");
});
it("labels collapsed tool calls as tool call", () => {
const container = document.createElement("div");
render(
renderToolCard(
{
id: "msg:5:call-5",
name: "sessions_spawn",
args: { mode: "run" },
inputText: '{\n "mode": "run"\n}',
},
{ expanded: false, onToggleExpanded: vi.fn() },
),
container,
);
expect(container.textContent).toContain("Tool call");
expect(container.textContent).not.toContain("Tool input");
const summaryButton = container.querySelector("button.chat-tool-msg-summary");
expect(summaryButton).not.toBeNull();
expect(summaryButton?.getAttribute("aria-expanded")).toBe("false");
});
it("does not render inline preview frames inside tool rows anymore", () => {
const container = document.createElement("div");
render(
renderToolCard(
{
id: "msg:view:6",
name: "canvas_render",
outputText: JSON.stringify({
kind: "canvas",
source: {
type: "html",
content: '<div onclick="alert(1)">front<script>window.bad = true;</script></div>',
},
presentation: {
target: "tool_card",
title: "Status view",
},
}),
preview: {
kind: "canvas",
surface: "assistant_message",
render: "url",
url: "/__openclaw__/canvas/documents/cv_status/index.html",
title: "Status view",
},
},
{ expanded: true, onToggleExpanded: vi.fn() },
),
container,
);
const rawToggle = container.querySelector<HTMLButtonElement>(".chat-tool-card__raw-toggle");
const rawBody = container.querySelector<HTMLElement>(".chat-tool-card__raw-body");
expect(container.querySelector(".chat-tool-card__preview-frame")).toBeNull();
expect(rawToggle?.getAttribute("aria-expanded")).toBe("false");
expect(rawBody?.hidden).toBe(true);
rawToggle?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(rawToggle?.getAttribute("aria-expanded")).toBe("true");
expect(rawBody?.hidden).toBe(false);
});
it("keeps raw details for legacy canvas tool output without rendering tool-row previews", () => {
const container = document.createElement("div");
render(
renderToolCard(
{
id: "msg:view:7",
name: "canvas_render",
outputText: JSON.stringify({
kind: "canvas",
view: {
backend: "canvas",
id: "cv_counter",
url: "/__openclaw__/canvas/documents/cv_counter/index.html",
title: "Counter demo",
preferred_height: 480,
},
presentation: {
target: "tool_card",
},
}),
preview: {
kind: "canvas",
surface: "assistant_message",
render: "url",
viewId: "cv_counter",
title: "Counter demo",
url: "/__openclaw__/canvas/documents/cv_counter/index.html",
preferredHeight: 480,
},
},
{ expanded: true, onToggleExpanded: vi.fn() },
),
container,
);
const rawToggle = container.querySelector<HTMLButtonElement>(".chat-tool-card__raw-toggle");
const rawBody = container.querySelector<HTMLElement>(".chat-tool-card__raw-body");
expect(container.textContent).toContain("Counter demo");
expect(container.querySelector(".chat-tool-card__preview-frame")).toBeNull();
expect(rawToggle?.getAttribute("aria-expanded")).toBe("false");
expect(rawBody?.hidden).toBe(true);
rawToggle?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(rawToggle?.getAttribute("aria-expanded")).toBe("true");
expect(rawBody?.hidden).toBe(false);
expect(rawBody?.textContent).toContain('"kind":"canvas"');
});
it("opens assistant-surface canvas payloads in the sidebar when explicitly requested", () => {
const container = document.createElement("div");
const onOpenSidebar = vi.fn();
render(
renderToolCard(
{
id: "msg:view:8",
name: "canvas_render",
outputText: JSON.stringify({
kind: "canvas",
view: {
backend: "canvas",
id: "cv_sidebar",
url: "/__openclaw__/canvas/documents/cv_sidebar/index.html",
title: "Player",
preferred_height: 360,
},
presentation: {
target: "assistant_message",
},
}),
preview: {
kind: "canvas",
surface: "assistant_message",
render: "url",
viewId: "cv_sidebar",
url: "/__openclaw__/canvas/documents/cv_sidebar/index.html",
title: "Player",
preferredHeight: 360,
},
},
{ expanded: true, onToggleExpanded: vi.fn(), onOpenSidebar },
),
container,
);
const sidebarButton = container.querySelector<HTMLButtonElement>(".chat-tool-card__action-btn");
sidebarButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(sidebarButton).not.toBeNull();
expect(onOpenSidebar).toHaveBeenCalledWith(
expect.objectContaining({
kind: "canvas",
docId: "cv_sidebar",
entryUrl: "/__openclaw__/canvas/documents/cv_sidebar/index.html",
}),
);
});
});

View File

@@ -1,118 +1,19 @@
import { html, nothing } from "lit";
import {
isToolCallContentType,
isToolResultContentType,
resolveToolBlockArgs,
} from "../../../../src/chat/tool-content.js";
import { extractCanvasFromText } from "../../../../src/chat/canvas-render.js";
import { resolveCanvasIframeUrl } from "../canvas-url.ts";
import { resolveEmbedSandbox, type EmbedSandboxMode } from "../embed-sandbox.ts";
import { icons } from "../icons.ts";
import type { SidebarContent } from "../sidebar-content.ts";
import { formatToolDetail, resolveToolDisplay } from "../tool-display.ts";
import type { ToolCard } from "../types/chat-types.ts";
import { TOOL_INLINE_THRESHOLD } from "./constants.ts";
import { extractTextCached } from "./message-extract.ts";
import { isToolResultMessage } from "./message-normalizer.ts";
import { formatToolOutputForSidebar, getTruncatedPreview } from "./tool-helpers.ts";
export function extractToolCards(message: unknown): ToolCard[] {
const m = message as Record<string, unknown>;
const content = normalizeContent(m.content);
const cards: ToolCard[] = [];
export type ToolPreview = NonNullable<ToolCard["preview"]>;
for (const item of content) {
const isToolCall =
isToolCallContentType(item.type) ||
(typeof item.name === "string" && resolveToolBlockArgs(item) != null);
if (isToolCall) {
cards.push({
kind: "call",
name: (item.name as string) ?? "tool",
args: coerceArgs(resolveToolBlockArgs(item)),
});
}
}
for (const item of content) {
if (!isToolResultContentType(item.type)) {
continue;
}
const text = extractToolText(item);
const name = typeof item.name === "string" ? item.name : "tool";
cards.push({ kind: "result", name, text });
}
if (isToolResultMessage(message) && !cards.some((card) => card.kind === "result")) {
const name =
(typeof m.toolName === "string" && m.toolName) ||
(typeof m.tool_name === "string" && m.tool_name) ||
"tool";
const text = extractTextCached(message) ?? undefined;
cards.push({ kind: "result", name, text });
}
return cards;
}
export function renderToolCardSidebar(card: ToolCard, onOpenSidebar?: (content: string) => void) {
const display = resolveToolDisplay({ name: card.name, args: card.args });
const detail = formatToolDetail(display);
const hasText = Boolean(card.text?.trim());
const canClick = Boolean(onOpenSidebar);
const handleClick = canClick
? () => {
if (hasText) {
onOpenSidebar!(formatToolOutputForSidebar(card.text!));
return;
}
const info = `## ${display.label}\n\n${
detail ? `**Command:** \`${detail}\`\n\n` : ""
}*No output — tool completed successfully.*`;
onOpenSidebar!(info);
}
: undefined;
const isShort = hasText && (card.text?.length ?? 0) <= TOOL_INLINE_THRESHOLD;
const showCollapsed = hasText && !isShort;
const showInline = hasText && isShort;
const isEmpty = !hasText;
return html`
<div
class="chat-tool-card ${canClick ? "chat-tool-card--clickable" : ""}"
@click=${handleClick}
role=${canClick ? "button" : nothing}
tabindex=${canClick ? "0" : nothing}
@keydown=${canClick
? (e: KeyboardEvent) => {
if (e.key !== "Enter" && e.key !== " ") {
return;
}
e.preventDefault();
handleClick?.();
}
: nothing}
>
<div class="chat-tool-card__header">
<div class="chat-tool-card__title">
<span class="chat-tool-card__icon">${icons[display.icon]}</span>
<span>${display.label}</span>
</div>
${canClick
? html`<span class="chat-tool-card__action"
>${hasText ? "View" : ""} ${icons.check}</span
>`
: nothing}
${isEmpty && !canClick
? html`<span class="chat-tool-card__status">${icons.check}</span>`
: nothing}
</div>
${detail ? html`<div class="chat-tool-card__detail">${detail}</div>` : nothing}
${isEmpty ? html` <div class="chat-tool-card__status-text muted">Completed</div> ` : nothing}
${showCollapsed
? html`<div class="chat-tool-card__preview mono">${getTruncatedPreview(card.text!)}</div>`
: nothing}
${showInline ? html`<div class="chat-tool-card__inline mono">${card.text}</div>` : nothing}
</div>
`;
function resolveCanvasPreviewSandbox(preview: ToolPreview): string {
return resolveEmbedSandbox(preview.kind === "canvas" ? "scripts" : "scripts");
}
function normalizeContent(content: unknown): Array<Record<string, unknown>> {
@@ -149,3 +50,539 @@ function extractToolText(item: Record<string, unknown>): string | undefined {
}
return undefined;
}
export function extractToolPreview(
outputText: string | undefined,
toolName: string | undefined,
): ToolCard["preview"] | undefined {
return extractCanvasFromText(outputText, toolName);
}
function resolveToolCardId(
item: Record<string, unknown>,
message: Record<string, unknown>,
index: number,
prefix = "tool",
): string {
const explicitId =
(typeof item.id === "string" && item.id.trim()) ||
(typeof item.toolCallId === "string" && item.toolCallId.trim()) ||
(typeof item.tool_call_id === "string" && item.tool_call_id.trim()) ||
(typeof item.callId === "string" && item.callId.trim()) ||
(typeof message.toolCallId === "string" && message.toolCallId.trim()) ||
(typeof message.tool_call_id === "string" && message.tool_call_id.trim()) ||
"";
if (explicitId) {
return `${prefix}:${explicitId}`;
}
const name =
(typeof item.name === "string" && item.name.trim()) ||
(typeof message.toolName === "string" && message.toolName.trim()) ||
(typeof message.tool_name === "string" && message.tool_name.trim()) ||
"tool";
return `${prefix}:${name}:${index}`;
}
function serializeToolInput(args: unknown): string | undefined {
if (args === undefined || args === null) {
return undefined;
}
if (typeof args === "string") {
return args;
}
try {
return JSON.stringify(args, null, 2);
} catch {
if (typeof args === "number" || typeof args === "boolean" || typeof args === "bigint") {
return String(args);
}
if (typeof args === "symbol") {
return args.description ? `Symbol(${args.description})` : "Symbol()";
}
return Object.prototype.toString.call(args);
}
}
function formatPayloadForSidebar(
text: string | undefined,
language: "json" | "text" = "text",
): string {
if (!text?.trim()) {
return "";
}
if (language === "json") {
return `\`\`\`json
${text}
\`\`\``;
}
const formatted = formatToolOutputForSidebar(text);
if (formatted.includes("```")) {
return formatted;
}
return `\`\`\`text
${text}
\`\`\``;
}
function findLatestCard(cards: ToolCard[], id: string, name: string): ToolCard | undefined {
for (let i = cards.length - 1; i >= 0; i--) {
const card = cards[i];
if (!card) {
continue;
}
if (card.id === id || (card.name === name && !card.outputText)) {
return card;
}
}
return undefined;
}
export function extractToolCards(message: unknown, prefix = "tool"): ToolCard[] {
const m = message as Record<string, unknown>;
const content = normalizeContent(m.content);
const cards: ToolCard[] = [];
for (let index = 0; index < content.length; index++) {
const item = content[index] ?? {};
const kind = (typeof item.type === "string" ? item.type : "").toLowerCase();
const isToolCall =
["toolcall", "tool_call", "tooluse", "tool_use"].includes(kind) ||
(typeof item.name === "string" &&
(item.arguments != null || item.args != null || item.input != null));
if (!isToolCall) {
continue;
}
const args = coerceArgs(item.arguments ?? item.args ?? item.input);
cards.push({
id: resolveToolCardId(item, m, index, prefix),
name: (item.name as string) ?? "tool",
args,
inputText: serializeToolInput(args),
});
}
for (let index = 0; index < content.length; index++) {
const item = content[index] ?? {};
const kind = (typeof item.type === "string" ? item.type : "").toLowerCase();
if (kind !== "toolresult" && kind !== "tool_result") {
continue;
}
const name = typeof item.name === "string" ? item.name : "tool";
const cardId = resolveToolCardId(item, m, index, prefix);
const existing = findLatestCard(cards, cardId, name);
const text = extractToolText(item);
const preview = extractToolPreview(text, name);
if (existing) {
existing.outputText = text;
existing.preview = preview;
continue;
}
cards.push({
id: cardId,
name,
outputText: text,
preview,
});
}
const role = typeof m.role === "string" ? m.role.toLowerCase() : "";
const isStandaloneToolMessage =
isToolResultMessage(message) ||
role === "tool" ||
role === "function" ||
typeof m.toolName === "string" ||
typeof m.tool_name === "string";
if (isStandaloneToolMessage && cards.length === 0) {
const name =
(typeof m.toolName === "string" && m.toolName) ||
(typeof m.tool_name === "string" && m.tool_name) ||
"tool";
const text = extractTextCached(message) ?? undefined;
cards.push({
id: resolveToolCardId({}, m, 0, prefix),
name,
outputText: text,
preview: extractToolPreview(text, name),
});
}
return cards;
}
export function buildToolCardSidebarContent(card: ToolCard): string {
const display = resolveToolDisplay({ name: card.name, args: card.args });
const detail = formatToolDetail(display);
const sections = [`## ${display.label}`, `**Tool:** \`${display.name}\``];
if (detail) {
sections.push(`**Summary:** ${detail}`);
}
if (card.inputText?.trim()) {
const inputIsJson = typeof card.args === "object" && card.args !== null;
sections.push(
`### Tool input\n${formatPayloadForSidebar(card.inputText, inputIsJson ? "json" : "text")}`,
);
}
if (card.outputText?.trim()) {
sections.push(`### Tool output\n${formatToolOutputForSidebar(card.outputText)}`);
} else {
sections.push(`### Tool output\n*No output — tool completed successfully.*`);
}
return sections.join("\n\n");
}
function handleRawDetailsToggle(event: Event) {
const button = event.currentTarget as HTMLButtonElement | null;
const root = button?.closest(".chat-tool-card__raw");
const body = root?.querySelector<HTMLElement>(".chat-tool-card__raw-body");
if (!button || !body) {
return;
}
const expanded = button.getAttribute("aria-expanded") === "true";
button.setAttribute("aria-expanded", String(!expanded));
body.hidden = expanded;
}
function renderPreviewFrame(params: {
title: string;
src?: string;
height?: number;
sandbox?: string;
}) {
return html`
<iframe
class="chat-tool-card__preview-frame"
title=${params.title}
sandbox=${params.sandbox ?? ""}
src=${params.src ?? nothing}
style=${params.height ? `height:${params.height}px` : ""}
></iframe>
`;
}
export function renderToolPreview(
preview: ToolPreview | undefined,
surface: "chat_tool" | "chat_message" | "sidebar",
options?: {
onOpenSidebar?: (content: SidebarContent) => void;
rawText?: string | null;
canvasHostUrl?: string | null;
embedSandboxMode?: EmbedSandboxMode;
allowExternalEmbedUrls?: boolean;
},
) {
if (!preview) {
return nothing;
}
if (preview.kind !== "canvas" || surface === "chat_tool") {
return nothing;
}
if (preview.surface !== "assistant_message") {
return nothing;
}
return html`
<div class="chat-tool-card__preview" data-kind="canvas" data-surface=${surface}>
<div class="chat-tool-card__preview-header">
<span class="chat-tool-card__preview-label">${preview.title?.trim() || "Canvas"}</span>
</div>
<div class="chat-tool-card__preview-panel" data-side="canvas">
${renderPreviewFrame({
title: preview.title?.trim() || "Canvas",
src: resolveCanvasIframeUrl(
preview.url,
options?.canvasHostUrl,
options?.allowExternalEmbedUrls ?? false,
),
height: preview.preferredHeight,
sandbox:
preview.kind === "canvas"
? resolveEmbedSandbox(options?.embedSandboxMode ?? "scripts")
: resolveCanvasPreviewSandbox(preview),
})}
</div>
</div>
`;
}
export function buildSidebarContent(value: string): SidebarContent {
return {
kind: "markdown",
content: value,
};
}
export function buildPreviewSidebarContent(
preview: ToolPreview,
rawText?: string | null,
): SidebarContent | null {
if (preview.kind !== "canvas" || preview.render !== "url" || !preview.viewId || !preview.url) {
return null;
}
return {
kind: "canvas",
docId: preview.viewId,
entryUrl: preview.url,
...(preview.title ? { title: preview.title } : {}),
...(preview.preferredHeight ? { preferredHeight: preview.preferredHeight } : {}),
...(rawText ? { rawText } : {}),
};
}
export function renderRawOutputToggle(text: string) {
return html`
<div class="chat-tool-card__raw">
<button
class="chat-tool-card__raw-toggle"
type="button"
aria-expanded="false"
@click=${handleRawDetailsToggle}
>
<span>Raw details</span>
<span class="chat-tool-card__raw-toggle-icon">${icons.chevronDown}</span>
</button>
<div class="chat-tool-card__raw-body" hidden>
${renderToolDataBlock({
label: "Tool output",
text,
expanded: true,
})}
</div>
</div>
`;
}
function renderToolDataBlock(params: {
label: string;
text: string;
expanded: boolean;
empty?: boolean;
}) {
const { label, text, expanded, empty } = params;
return html`
<div class="chat-tool-card__block ${expanded ? "chat-tool-card__block--expanded" : ""}">
<div class="chat-tool-card__block-header">
<span class="chat-tool-card__block-icon">${icons.zap}</span>
<span class="chat-tool-card__block-label">${label}</span>
</div>
${empty
? html`<div class="chat-tool-card__block-empty muted">${text}</div>`
: expanded
? html`<pre class="chat-tool-card__block-content"><code>${text}</code></pre>`
: html`<div class="chat-tool-card__block-preview mono">
${getTruncatedPreview(text)}
</div>`}
</div>
`;
}
function renderCollapsedToolSummary(params: {
label: string;
name: string;
expanded: boolean;
onToggleExpanded: () => void;
}) {
const { label, name, expanded, onToggleExpanded } = params;
return html`
<button
class="chat-tool-msg-summary"
type="button"
aria-expanded=${String(expanded)}
@click=${() => onToggleExpanded()}
>
<span class="chat-tool-msg-summary__icon">${icons.zap}</span>
<span class="chat-tool-msg-summary__label">${label}</span>
<span class="chat-tool-msg-summary__names">${name}</span>
</button>
`;
}
export function renderToolCard(
card: ToolCard,
opts: {
expanded: boolean;
onToggleExpanded: (id: string) => void;
onOpenSidebar?: (content: SidebarContent) => void;
canvasHostUrl?: string | null;
embedSandboxMode?: EmbedSandboxMode;
allowExternalEmbedUrls?: boolean;
},
) {
const hasOutput = Boolean(card.outputText?.trim());
const previewLabel = hasOutput ? "Tool output" : "Tool call";
return html`
<div
class="chat-tool-msg-collapse chat-tool-msg-collapse--manual ${opts.expanded
? "is-open"
: ""}"
>
${renderCollapsedToolSummary({
label: previewLabel,
name: card.name,
expanded: opts.expanded,
onToggleExpanded: () => opts.onToggleExpanded(card.id),
})}
${opts.expanded
? html`
<div class="chat-tool-msg-body">
${renderExpandedToolCardContent(
card,
opts.onOpenSidebar,
opts.canvasHostUrl,
opts.embedSandboxMode ?? "scripts",
opts.allowExternalEmbedUrls ?? false,
)}
</div>
`
: nothing}
</div>
`;
}
export function renderExpandedToolCardContent(
card: ToolCard,
onOpenSidebar?: (content: SidebarContent) => void,
canvasHostUrl?: string | null,
embedSandboxMode: EmbedSandboxMode = "scripts",
allowExternalEmbedUrls = false,
) {
const display = resolveToolDisplay({ name: card.name, args: card.args });
const detail = formatToolDetail(display);
const hasOutput = Boolean(card.outputText?.trim());
const hasInput = Boolean(card.inputText?.trim());
const canOpenSidebar = Boolean(onOpenSidebar);
const previewSidebarContent =
card.preview?.kind === "canvas"
? buildPreviewSidebarContent(card.preview, card.outputText)
: null;
const sidebarActionContent =
previewSidebarContent ?? buildSidebarContent(buildToolCardSidebarContent(card));
const visiblePreview = card.preview
? renderToolPreview(card.preview, "chat_tool", {
onOpenSidebar,
rawText: card.outputText,
canvasHostUrl,
embedSandboxMode,
allowExternalEmbedUrls,
})
: nothing;
return html`
<div class="chat-tool-card chat-tool-card--expanded">
<div class="chat-tool-card__header">
<div class="chat-tool-card__title">
<span class="chat-tool-card__icon">${icons[display.icon]}</span>
<span>${display.label}</span>
</div>
${canOpenSidebar
? html`
<div class="chat-tool-card__actions">
<button
class="chat-tool-card__action-btn"
type="button"
@click=${() => onOpenSidebar?.(sidebarActionContent)}
title="Open in the side panel"
aria-label="Open tool details in side panel"
>
<span class="chat-tool-card__action-icon">${icons.panelRightOpen}</span>
</button>
</div>
`
: nothing}
</div>
${detail ? html`<div class="chat-tool-card__detail">${detail}</div>` : nothing}
${hasInput
? renderToolDataBlock({
label: "Tool input",
text: card.inputText!,
expanded: true,
})
: nothing}
${hasOutput
? card.preview
? html`${visiblePreview} ${renderRawOutputToggle(card.outputText!)}`
: renderToolDataBlock({
label: "Tool output",
text: card.outputText!,
expanded: true,
})
: nothing}
</div>
`;
}
export function renderToolCardSidebar(
card: ToolCard,
onOpenSidebar?: (content: SidebarContent) => void,
canvasHostUrl?: string | null,
embedSandboxMode: EmbedSandboxMode = "scripts",
) {
const display = resolveToolDisplay({ name: card.name, args: card.args });
const detail = formatToolDetail(display);
const hasText = Boolean(card.outputText?.trim());
const hasPreview = Boolean(card.preview);
const sidebarContent =
card.preview?.kind === "canvas"
? buildPreviewSidebarContent(card.preview, card.outputText)
: buildSidebarContent(buildToolCardSidebarContent(card));
const actionContent = sidebarContent ?? buildSidebarContent(buildToolCardSidebarContent(card));
const canClick = Boolean(onOpenSidebar);
const handleClick = canClick ? () => onOpenSidebar?.(actionContent) : undefined;
const isShort = hasText && !hasPreview && (card.outputText?.length ?? 0) <= 240;
const showCollapsed = hasText && !hasPreview && !isShort;
const showInline = hasText && !hasPreview && isShort;
const isEmpty = !hasText && !hasPreview;
return html`
<div
class="chat-tool-card ${canClick ? "chat-tool-card--clickable" : ""}"
@click=${handleClick}
role=${canClick ? "button" : nothing}
tabindex=${canClick ? "0" : nothing}
@keydown=${canClick
? (e: KeyboardEvent) => {
if (e.key !== "Enter" && e.key !== " ") {
return;
}
e.preventDefault();
handleClick?.();
}
: nothing}
>
<div class="chat-tool-card__header">
<div class="chat-tool-card__title">
<span class="chat-tool-card__icon">${icons[display.icon]}</span>
<span>${display.label}</span>
</div>
${canClick
? html`<span class="chat-tool-card__action"
>${hasText || hasPreview ? "View" : ""} ${icons.check}</span
>`
: nothing}
${isEmpty && !canClick
? html`<span class="chat-tool-card__status">${icons.check}</span>`
: nothing}
</div>
${detail ? html`<div class="chat-tool-card__detail">${detail}</div>` : nothing}
${isEmpty ? html`<div class="chat-tool-card__status-text muted">Completed</div>` : nothing}
${hasPreview
? html`${renderToolPreview(card.preview!, "chat_tool", {
onOpenSidebar,
rawText: card.outputText,
canvasHostUrl,
embedSandboxMode,
})}`
: nothing}
${showCollapsed
? html`<div class="chat-tool-card__preview mono">
${getTruncatedPreview(card.outputText!)}
</div>`
: nothing}
${showInline ? html`<div class="chat-tool-card__inline mono">${card.outputText}</div>` : nothing}
</div>
`;
}

View File

@@ -12,6 +12,11 @@ describe("loadControlUiBootstrapConfig", () => {
basePath: "/openclaw",
assistantName: "Ops",
assistantAvatar: "O",
assistantAgentId: "main",
serverVersion: "2026.3.7",
localMediaPreviewRoots: ["/tmp/openclaw"],
embedSandbox: "scripts",
allowExternalEmbedUrls: true,
}),
});
vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch);
@@ -21,6 +26,9 @@ describe("loadControlUiBootstrapConfig", () => {
assistantName: "Assistant",
assistantAvatar: null,
assistantAgentId: null,
localMediaPreviewRoots: [],
embedSandboxMode: "scripts" as const,
allowExternalEmbedUrls: false,
serverVersion: null,
};
@@ -32,8 +40,11 @@ describe("loadControlUiBootstrapConfig", () => {
);
expect(state.assistantName).toBe("Ops");
expect(state.assistantAvatar).toBe("O");
expect(state.assistantAgentId).toBeNull();
expect(state.serverVersion).toBeNull();
expect(state.assistantAgentId).toBe("main");
expect(state.serverVersion).toBe("2026.3.7");
expect(state.localMediaPreviewRoots).toEqual(["/tmp/openclaw"]);
expect(state.embedSandboxMode).toBe("scripts");
expect(state.allowExternalEmbedUrls).toBe(true);
vi.unstubAllGlobals();
});
@@ -47,6 +58,9 @@ describe("loadControlUiBootstrapConfig", () => {
assistantName: "Assistant",
assistantAvatar: null,
assistantAgentId: null,
localMediaPreviewRoots: [],
embedSandboxMode: "scripts" as const,
allowExternalEmbedUrls: false,
serverVersion: null,
};
@@ -57,8 +71,8 @@ describe("loadControlUiBootstrapConfig", () => {
expect.objectContaining({ method: "GET" }),
);
expect(state.assistantName).toBe("Assistant");
expect(state.assistantAgentId).toBeNull();
expect(state.serverVersion).toBeNull();
expect(state.embedSandboxMode).toBe("scripts");
expect(state.allowExternalEmbedUrls).toBe(false);
vi.unstubAllGlobals();
});
@@ -72,6 +86,9 @@ describe("loadControlUiBootstrapConfig", () => {
assistantName: "Assistant",
assistantAvatar: null,
assistantAgentId: null,
localMediaPreviewRoots: [],
embedSandboxMode: "scripts" as const,
allowExternalEmbedUrls: false,
serverVersion: null,
};
@@ -81,8 +98,6 @@ describe("loadControlUiBootstrapConfig", () => {
`/openclaw${CONTROL_UI_BOOTSTRAP_CONFIG_PATH}`,
expect.objectContaining({ method: "GET" }),
);
expect(state.assistantAgentId).toBeNull();
expect(state.serverVersion).toBeNull();
vi.unstubAllGlobals();
});

View File

@@ -1,6 +1,7 @@
import {
CONTROL_UI_BOOTSTRAP_CONFIG_PATH,
type ControlUiBootstrapConfig,
type ControlUiEmbedSandboxMode,
} from "../../../../src/gateway/control-ui-contract.js";
import { normalizeAssistantIdentity } from "../assistant-identity.ts";
import { normalizeBasePath } from "../navigation.ts";
@@ -11,6 +12,9 @@ export type ControlUiBootstrapState = {
assistantAvatar: string | null;
assistantAgentId: string | null;
serverVersion: string | null;
localMediaPreviewRoots: string[];
embedSandboxMode: ControlUiEmbedSandboxMode;
allowExternalEmbedUrls: boolean;
};
export async function loadControlUiBootstrapConfig(state: ControlUiBootstrapState) {
@@ -37,11 +41,24 @@ export async function loadControlUiBootstrapConfig(state: ControlUiBootstrapStat
}
const parsed = (await res.json()) as ControlUiBootstrapConfig;
const normalized = normalizeAssistantIdentity({
agentId: parsed.assistantAgentId ?? null,
name: parsed.assistantName,
avatar: parsed.assistantAvatar ?? null,
});
state.assistantName = normalized.name;
state.assistantAvatar = normalized.avatar;
state.assistantAgentId = normalized.agentId ?? null;
state.serverVersion = parsed.serverVersion ?? null;
state.localMediaPreviewRoots = Array.isArray(parsed.localMediaPreviewRoots)
? parsed.localMediaPreviewRoots.filter((value): value is string => typeof value === "string")
: [];
state.embedSandboxMode =
parsed.embedSandbox === "trusted"
? "trusted"
: parsed.embedSandbox === "strict"
? "strict"
: "scripts";
state.allowExternalEmbedUrls = parsed.allowExternalEmbedUrls === true;
} catch {
// Ignore bootstrap failures; UI will update identity after connecting.
}

View File

@@ -0,0 +1,15 @@
import type { ControlUiEmbedSandboxMode } from "../../../src/gateway/control-ui-contract.js";
export type EmbedSandboxMode = ControlUiEmbedSandboxMode;
export function resolveEmbedSandbox(mode: EmbedSandboxMode | null | undefined): string {
switch (mode) {
case "strict":
return "";
case "trusted":
return "allow-scripts allow-same-origin";
case "scripts":
default:
return "allow-scripts";
}
}

View File

@@ -404,6 +404,26 @@ describe("GatewayBrowserClient", () => {
vi.useRealTimers();
});
it("cancels a scheduled reconnect when stopped before the retry fires", async () => {
vi.useFakeTimers();
const client = new GatewayBrowserClient({
url: "ws://127.0.0.1:18789",
token: "shared-auth-token",
});
client.start();
const ws = getLatestWebSocket();
ws.emitClose(1006, "socket lost");
client.stop();
await vi.advanceTimersByTimeAsync(30_000);
expect(wsInstances).toHaveLength(1);
vi.useRealTimers();
});
it("does not auto-reconnect on AUTH_TOKEN_MISSING", async () => {
vi.useFakeTimers();
localStorage.clear();

View File

@@ -12,7 +12,6 @@ import {
} from "../../../src/gateway/protocol/connect-error-details.js";
import { clearDeviceAuthToken, loadDeviceAuthToken, storeDeviceAuthToken } from "./device-auth.ts";
import { loadOrCreateDeviceIdentity, signDevicePayload } from "./device-identity.ts";
import { normalizeLowercaseStringOrEmpty, normalizeOptionalString } from "./string-coerce.ts";
import { generateUUID } from "./uuid.ts";
export type GatewayEventFrame = {
@@ -83,7 +82,7 @@ export function isNonRecoverableAuthError(error: GatewayErrorInfo | undefined):
function isTrustedRetryEndpoint(url: string): boolean {
try {
const gatewayUrl = new URL(url, window.location.href);
const host = normalizeLowercaseStringOrEmpty(gatewayUrl.hostname);
const host = gatewayUrl.hostname.trim().toLowerCase();
const isLoopbackHost =
host === "localhost" || host === "::1" || host === "[::1]" || host === "127.0.0.1";
const isLoopbackIPv4 = host.startsWith("127.");
@@ -112,6 +111,7 @@ export type GatewayHelloOk = {
scopes?: string[];
issuedAtMs?: number;
};
canvasHostUrl?: string;
policy?: { tickIntervalMs?: number };
};
@@ -295,10 +295,7 @@ export class GatewayBrowserClient {
stop() {
this.closed = true;
if (this.connectTimer !== null) {
window.clearTimeout(this.connectTimer);
this.connectTimer = null;
}
this.clearConnectTimer();
this.ws?.close();
this.ws = null;
this.pendingConnectError = undefined;
@@ -348,7 +345,11 @@ export class GatewayBrowserClient {
}
const delay = this.backoffMs;
this.backoffMs = Math.min(this.backoffMs * 1.7, 15_000);
window.setTimeout(() => this.connect(), delay);
this.clearConnectTimer();
this.connectTimer = window.setTimeout(() => {
this.connectTimer = null;
this.connect();
}, delay);
}
private flushPending(err: Error) {
@@ -387,8 +388,8 @@ export class GatewayBrowserClient {
const role = CONTROL_UI_OPERATOR_ROLE;
const scopes = [...CONTROL_UI_OPERATOR_SCOPES];
const client = this.buildConnectClient();
const explicitGatewayToken = normalizeOptionalString(this.opts.token);
const explicitPassword = normalizeOptionalString(this.opts.password);
const explicitGatewayToken = this.opts.token?.trim() || undefined;
const explicitPassword = this.opts.password?.trim() || undefined;
// crypto.subtle is only available in secure contexts (HTTPS, localhost).
// Over plain HTTP, we skip device identity and fall back to token-only auth.
@@ -496,10 +497,7 @@ export class GatewayBrowserClient {
return;
}
this.connectSent = true;
if (this.connectTimer !== null) {
window.clearTimeout(this.connectTimer);
this.connectTimer = null;
}
this.clearConnectTimer();
const plan = await this.buildConnectPlan();
void this.request<GatewayHelloOk>("connect", this.buildConnectParams(plan))
@@ -565,8 +563,8 @@ export class GatewayBrowserClient {
}
private selectConnectAuth(params: { role: string; deviceId: string }): SelectedConnectAuth {
const explicitGatewayToken = normalizeOptionalString(this.opts.token);
const authPassword = normalizeOptionalString(this.opts.password);
const explicitGatewayToken = this.opts.token?.trim() || undefined;
const authPassword = this.opts.password?.trim() || undefined;
const storedEntry = loadDeviceAuthToken({
deviceId: params.deviceId,
role: params.role,
@@ -613,11 +611,17 @@ export class GatewayBrowserClient {
private queueConnect() {
this.connectNonce = null;
this.connectSent = false;
if (this.connectTimer !== null) {
window.clearTimeout(this.connectTimer);
}
this.clearConnectTimer();
this.connectTimer = window.setTimeout(() => {
this.connectTimer = null;
void this.sendConnect();
}, 750);
}
private clearConnectTimer() {
if (this.connectTimer !== null) {
window.clearTimeout(this.connectTimer);
this.connectTimer = null;
}
}
}

View File

@@ -0,0 +1,15 @@
export type MarkdownSidebarContent = {
kind: "markdown";
content: string;
};
export type CanvasSidebarContent = {
kind: "canvas";
docId: string;
title?: string;
entryUrl: string;
preferredHeight?: number;
rawText?: string | null;
};
export type SidebarContent = MarkdownSidebarContent | CanvasSidebarContent;

View File

@@ -21,12 +21,28 @@ export type MessageGroup = {
};
/** Content item types in a normalized message */
export type MessageContentItem = {
type: "text" | "tool_call" | "tool_result";
text?: string;
name?: string;
args?: unknown;
};
export type MessageContentItem =
| {
type: "text" | "tool_call" | "tool_result";
text?: string;
name?: string;
args?: unknown;
}
| {
type: "attachment";
attachment: {
url: string;
kind: "image" | "audio" | "video" | "document";
label: string;
mimeType?: string;
isVoiceNote?: boolean;
};
}
| {
type: "canvas";
preview: Extract<NonNullable<ToolCard["preview"]>, { kind: "canvas" }>;
rawText?: string | null;
};
/** Normalized message structure for rendering */
export type NormalizedMessage = {
@@ -35,12 +51,34 @@ export type NormalizedMessage = {
timestamp: number;
id?: string;
senderLabel?: string | null;
audioAsVoice?: boolean;
replyTarget?:
| {
kind: "current";
}
| {
kind: "id";
id: string;
}
| null;
};
/** Tool card representation for tool calls and results */
/** Tool card representation for inline tool call/result rendering */
export type ToolCard = {
kind: "call" | "result";
id: string;
name: string;
args?: unknown;
text?: string;
inputText?: string;
outputText?: string;
preview?: {
kind: "canvas";
surface: "assistant_message";
render: "url";
title?: string;
preferredHeight?: number;
url?: string;
viewId?: string;
className?: string;
style?: string;
};
};

File diff suppressed because it is too large Load Diff

View File

@@ -2,10 +2,7 @@ import { html, nothing, type TemplateResult } from "lit";
import { ref } from "lit/directives/ref.js";
import { repeat } from "lit/directives/repeat.js";
import { unsafeHTML } from "lit/directives/unsafe-html.js";
import type {
CompactionStatus as CompactionIndicatorStatus,
FallbackStatus as FallbackIndicatorStatus,
} from "../app-tool-stream.ts";
import type { CompactionStatus, FallbackStatus } from "../app-tool-stream.ts";
import {
CHAT_ATTACHMENT_ACCEPT,
isSupportedChatAttachmentMimeType,
@@ -18,7 +15,12 @@ import {
renderStreamingGroup,
} from "../chat/grouped-render.ts";
import { InputHistory } from "../chat/input-history.ts";
import { normalizeMessage, normalizeRoleForGrouping } from "../chat/message-normalizer.ts";
import { extractTextCached } from "../chat/message-extract.ts";
import {
isToolResultMessage,
normalizeMessage,
normalizeRoleForGrouping,
} from "../chat/message-normalizer.ts";
import { PinnedMessages } from "../chat/pinned-messages.ts";
import { getPinnedMessageSummary } from "../chat/pinned-summary.ts";
import { messageMatchesSearchQuery } from "../chat/search-match.ts";
@@ -32,12 +34,14 @@ import {
type SlashCommandDef,
} from "../chat/slash-commands.ts";
import { isSttSupported, startStt, stopStt } from "../chat/speech.ts";
import { buildSidebarContent, extractToolCards, extractToolPreview } from "../chat/tool-cards.ts";
import type { EmbedSandboxMode } from "../embed-sandbox.ts";
import { icons } from "../icons.ts";
import { toSanitizedMarkdownHtml } from "../markdown.ts";
import { normalizeLowercaseStringOrEmpty } from "../string-coerce.ts";
import type { SidebarContent } from "../sidebar-content.ts";
import { detectTextDirection } from "../text-direction.ts";
import type { GatewaySessionRow, SessionsListResult } from "../types.ts";
import type { ChatItem, MessageGroup } from "../types/chat-types.ts";
import type { ChatItem, MessageGroup, ToolCard } from "../types/chat-types.ts";
import type { ChatAttachment, ChatQueueItem } from "../ui-types.ts";
import { agentLogoUrl, resolveAgentAvatarUrl } from "./agents-utils.ts";
import { renderMarkdownSidebar } from "./markdown-sidebar.ts";
@@ -52,8 +56,8 @@ export type ChatProps = {
loading: boolean;
sending: boolean;
canAbort?: boolean;
compactionStatus?: CompactionIndicatorStatus | null;
fallbackStatus?: FallbackIndicatorStatus | null;
compactionStatus?: CompactionStatus | null;
fallbackStatus?: FallbackStatus | null;
messages: unknown[];
sideResult?: ChatSideResult | null;
toolMessages: unknown[];
@@ -70,11 +74,17 @@ export type ChatProps = {
sessions: SessionsListResult | null;
focusMode: boolean;
sidebarOpen?: boolean;
sidebarContent?: string | null;
sidebarContent?: SidebarContent | null;
sidebarError?: string | null;
splitRatio?: number;
canvasHostUrl?: string | null;
embedSandboxMode?: EmbedSandboxMode;
allowExternalEmbedUrls?: boolean;
assistantName: string;
assistantAvatar: string | null;
localMediaPreviewRoots?: string[];
assistantAttachmentAuthToken?: string | null;
autoExpandToolCalls?: boolean;
attachments?: ChatAttachment[];
onAttachmentsChange?: (attachments: ChatAttachment[]) => void;
showNewMessages?: boolean;
@@ -98,7 +108,7 @@ export type ChatProps = {
onAgentChange: (agentId: string) => void;
onNavigateToAgent?: () => void;
onSessionSelect?: (sessionKey: string) => void;
onOpenSidebar?: (content: string) => void;
onOpenSidebar?: (content: SidebarContent) => void;
onCloseSidebar?: () => void;
onSplitRatioChange?: (ratio: number) => void;
onChatScroll?: (event: Event) => void;
@@ -112,6 +122,9 @@ const FALLBACK_TOAST_DURATION_MS = 8000;
const inputHistories = new Map<string, InputHistory>();
const pinnedMessagesMap = new Map<string, PinnedMessages>();
const deletedMessagesMap = new Map<string, DeletedMessages>();
const expandedToolCardsBySession = new Map<string, Map<string, boolean>>();
const initializedToolCardsBySession = new Map<string, Set<string>>();
const lastAutoExpandPrefBySession = new Map<string, boolean>();
function getInputHistory(sessionKey: string): InputHistory {
return getOrCreateSessionCacheValue(inputHistories, sessionKey, () => new InputHistory());
@@ -133,6 +146,143 @@ function getDeletedMessages(sessionKey: string): DeletedMessages {
);
}
function getExpandedToolCards(sessionKey: string): Map<string, boolean> {
return getOrCreateSessionCacheValue(expandedToolCardsBySession, sessionKey, () => new Map());
}
function getInitializedToolCards(sessionKey: string): Set<string> {
return getOrCreateSessionCacheValue(initializedToolCardsBySession, sessionKey, () => new Set());
}
function appendCanvasBlockToAssistantMessage(
message: unknown,
preview: Extract<NonNullable<ToolCard["preview"]>, { kind: "canvas" }>,
rawText: string | null,
) {
const raw = message as Record<string, unknown>;
const existingContent = Array.isArray(raw.content)
? [...raw.content]
: typeof raw.content === "string"
? [{ type: "text", text: raw.content }]
: typeof raw.text === "string"
? [{ type: "text", text: raw.text }]
: [];
const alreadyHasArtifact = existingContent.some((block) => {
if (!block || typeof block !== "object") {
return false;
}
const typed = block as {
type?: unknown;
preview?: { kind?: unknown; viewId?: unknown; url?: unknown };
};
return (
typed.type === "canvas" &&
typed.preview?.kind === "canvas" &&
((preview.viewId && typed.preview.viewId === preview.viewId) ||
(preview.url && typed.preview.url === preview.url))
);
});
if (alreadyHasArtifact) {
return message;
}
return {
...raw,
content: [
...existingContent,
{
type: "canvas",
preview,
...(rawText ? { rawText } : {}),
},
],
};
}
function extractChatMessagePreview(toolMessage: unknown): {
preview: Extract<NonNullable<ToolCard["preview"]>, { kind: "canvas" }>;
text: string | null;
timestamp: number | null;
} | null {
const normalized = normalizeMessage(toolMessage);
const cards = extractToolCards(toolMessage, "preview");
for (let index = cards.length - 1; index >= 0; index--) {
const card = cards[index];
if (card?.preview?.kind === "canvas") {
return {
preview: card.preview,
text: card.outputText ?? null,
timestamp: normalized.timestamp ?? null,
};
}
}
const text = extractTextCached(toolMessage) ?? undefined;
const toolRecord = toolMessage as Record<string, unknown>;
const toolName =
typeof toolRecord.toolName === "string"
? toolRecord.toolName
: typeof toolRecord.tool_name === "string"
? toolRecord.tool_name
: undefined;
const preview = extractToolPreview(text, toolName);
if (preview?.kind !== "canvas") {
return null;
}
return { preview, text: text ?? null, timestamp: normalized.timestamp ?? null };
}
function findNearestAssistantMessageIndex(
items: ChatItem[],
toolTimestamp: number | null,
): number | null {
const assistantEntries = items
.map((item, index) => {
if (item.kind !== "message") {
return null;
}
const message = item.message as Record<string, unknown>;
const role = typeof message.role === "string" ? message.role.toLowerCase() : "";
if (role !== "assistant") {
return null;
}
return {
index,
timestamp: normalizeMessage(item.message).timestamp ?? null,
};
})
.filter(Boolean) as Array<{ index: number; timestamp: number | null }>;
if (assistantEntries.length === 0) {
return null;
}
if (toolTimestamp == null) {
return assistantEntries[assistantEntries.length - 1]?.index ?? null;
}
let previous: { index: number; timestamp: number } | null = null;
let next: { index: number; timestamp: number } | null = null;
for (const entry of assistantEntries) {
if (entry.timestamp == null) {
continue;
}
if (entry.timestamp <= toolTimestamp) {
previous = { index: entry.index, timestamp: entry.timestamp };
continue;
}
next = { index: entry.index, timestamp: entry.timestamp };
break;
}
if (previous && next) {
const previousDelta = toolTimestamp - previous.timestamp;
const nextDelta = next.timestamp - toolTimestamp;
return nextDelta < previousDelta ? next.index : previous.index;
}
if (previous) {
return previous.index;
}
if (next) {
return next.index;
}
return assistantEntries[assistantEntries.length - 1]?.index ?? null;
}
interface ChatEphemeralState {
sttRecording: boolean;
sttInterimText: string;
@@ -183,11 +333,65 @@ function adjustTextareaHeight(el: HTMLTextAreaElement) {
el.style.height = `${Math.min(el.scrollHeight, 150)}px`;
}
function renderCompactionIndicator(status: CompactionIndicatorStatus | null | undefined) {
function syncToolCardExpansionState(
sessionKey: string,
items: Array<ChatItem | MessageGroup>,
autoExpandToolCalls: boolean,
) {
const expanded = getExpandedToolCards(sessionKey);
const initialized = getInitializedToolCards(sessionKey);
const previousAutoExpand = lastAutoExpandPrefBySession.get(sessionKey) ?? false;
const currentToolCardIds = new Set<string>();
for (const item of items) {
if (item.kind !== "group") {
continue;
}
for (const entry of item.messages) {
const cards = extractToolCards(entry.message, entry.key);
for (let cardIndex = 0; cardIndex < cards.length; cardIndex++) {
const disclosureId = `${entry.key}:toolcard:${cardIndex}`;
currentToolCardIds.add(disclosureId);
if (initialized.has(disclosureId)) {
continue;
}
expanded.set(disclosureId, autoExpandToolCalls);
initialized.add(disclosureId);
}
const messageRecord = entry.message as Record<string, unknown>;
const role = typeof messageRecord.role === "string" ? messageRecord.role : "unknown";
const normalizedRole = normalizeRoleForGrouping(role);
const isToolMessage =
isToolResultMessage(entry.message) ||
normalizedRole === "tool" ||
role.toLowerCase() === "toolresult" ||
role.toLowerCase() === "tool_result" ||
typeof messageRecord.toolCallId === "string" ||
typeof messageRecord.tool_call_id === "string";
if (!isToolMessage) {
continue;
}
const disclosureId = `toolmsg:${entry.key}`;
currentToolCardIds.add(disclosureId);
if (initialized.has(disclosureId)) {
continue;
}
expanded.set(disclosureId, autoExpandToolCalls);
initialized.add(disclosureId);
}
}
if (autoExpandToolCalls && !previousAutoExpand) {
for (const toolCardId of currentToolCardIds) {
expanded.set(toolCardId, true);
}
}
lastAutoExpandPrefBySession.set(sessionKey, autoExpandToolCalls);
}
function renderCompactionIndicator(status: CompactionStatus | null | undefined) {
if (!status) {
return nothing;
}
if (status.phase === "active") {
if (status.phase === "active" || status.phase === "retrying") {
return html`
<div
class="compaction-indicator compaction-indicator--active"
@@ -198,18 +402,7 @@ function renderCompactionIndicator(status: CompactionIndicatorStatus | null | un
</div>
`;
}
if (status.phase === "retrying") {
return html`
<div
class="compaction-indicator compaction-indicator--active"
role="status"
aria-live="polite"
>
${icons.loader} Retrying after compaction...
</div>
`;
}
if (status.phase === "complete" && status.completedAt) {
if (status.completedAt) {
const elapsed = Date.now() - status.completedAt;
if (elapsed < COMPACTION_TOAST_DURATION_MS) {
return html`
@@ -226,7 +419,7 @@ function renderCompactionIndicator(status: CompactionIndicatorStatus | null | un
return nothing;
}
function renderFallbackIndicator(status: FallbackIndicatorStatus | null | undefined) {
function renderFallbackIndicator(status: FallbackStatus | null | undefined) {
if (!status) {
return nothing;
}
@@ -538,12 +731,12 @@ function updateSlashMenu(value: string, requestUpdate: () => void): void {
// Arg mode: /command <partial-arg>
const argMatch = value.match(/^\/(\S+)\s(.*)$/);
if (argMatch) {
const cmdName = normalizeLowercaseStringOrEmpty(argMatch[1]);
const argFilter = normalizeLowercaseStringOrEmpty(argMatch[2]);
const cmdName = argMatch[1].toLowerCase();
const argFilter = argMatch[2].toLowerCase();
const cmd = SLASH_COMMANDS.find((c) => c.name === cmdName);
if (cmd?.argOptions?.length) {
const filtered = argFilter
? cmd.argOptions.filter((opt) => normalizeLowercaseStringOrEmpty(opt).startsWith(argFilter))
? cmd.argOptions.filter((opt) => opt.toLowerCase().startsWith(argFilter))
: cmd.argOptions;
if (filtered.length > 0) {
vs.slashMenuMode = "args";
@@ -926,7 +1119,7 @@ function renderSlashMenu(
export function renderChat(props: ChatProps) {
const canCompose = props.connected;
const isBusy = props.sending || props.stream !== null || props.canAbort;
const isBusy = props.sending || props.stream !== null;
const canAbort = Boolean(props.canAbort && props.onAbort);
const activeSession = props.sessions?.sessions?.find((row) => row.key === props.sessionKey);
const reasoningLevel = activeSession?.reasoningLevel ?? "off";
@@ -975,6 +1168,12 @@ export function renderChat(props: ChatProps) {
};
const chatItems = buildChatItems(props);
syncToolCardExpansionState(props.sessionKey, chatItems, Boolean(props.autoExpandToolCalls));
const expandedToolCards = getExpandedToolCards(props.sessionKey);
const toggleToolCardExpanded = (toolCardId: string) => {
expandedToolCards.set(toolCardId, !expandedToolCards.get(toolCardId));
requestUpdate();
};
const isEmpty = chatItems.length === 0 && !props.loading;
const thread = html`
@@ -1062,9 +1261,24 @@ export function renderChat(props: ChatProps) {
onOpenSidebar: props.onOpenSidebar,
showReasoning,
showToolCalls: props.showToolCalls,
autoExpandToolCalls: Boolean(props.autoExpandToolCalls),
isToolMessageExpanded: (messageId: string) =>
expandedToolCards.get(messageId) ?? false,
onToggleToolMessageExpanded: (messageId: string) => {
expandedToolCards.set(messageId, !expandedToolCards.get(messageId));
requestUpdate();
},
isToolExpanded: (toolCardId: string) => expandedToolCards.get(toolCardId) ?? false,
onToggleToolExpanded: toggleToolCardExpanded,
onRequestUpdate: requestUpdate,
assistantName: props.assistantName,
assistantAvatar: assistantIdentity.avatar,
basePath: props.basePath,
localMediaPreviewRoots: props.localMediaPreviewRoots ?? [],
assistantAttachmentAuthToken: props.assistantAttachmentAuthToken ?? null,
canvasHostUrl: props.canvasHostUrl,
embedSandboxMode: props.embedSandboxMode ?? "scripts",
allowExternalEmbedUrls: props.allowExternalEmbedUrls ?? false,
contextWindow:
activeSession?.contextTokens ?? props.sessions?.defaults?.contextTokens ?? null,
onDelete: () => {
@@ -1245,12 +1459,25 @@ export function renderChat(props: ChatProps) {
${renderMarkdownSidebar({
content: props.sidebarContent ?? null,
error: props.sidebarError ?? null,
canvasHostUrl: props.canvasHostUrl,
embedSandboxMode: props.embedSandboxMode ?? "scripts",
allowExternalEmbedUrls: props.allowExternalEmbedUrls ?? false,
onClose: props.onCloseSidebar!,
onViewRawText: () => {
if (!props.sidebarContent || !props.onOpenSidebar) {
return;
}
props.onOpenSidebar(`\`\`\`\n${props.sidebarContent}\n\`\`\``);
if (props.sidebarContent.kind === "markdown") {
props.onOpenSidebar(
buildSidebarContent(`\`\`\`\n${props.sidebarContent.content}\n\`\`\``),
);
return;
}
if (props.sidebarContent.rawText?.trim()) {
props.onOpenSidebar(
buildSidebarContent(`\`\`\`json\n${props.sidebarContent.rawText}\n\`\`\``),
);
}
},
})}
</div>
@@ -1419,7 +1646,7 @@ export function renderChat(props: ChatProps) {
${icons.download}
</button>
${canAbort && (isBusy || props.sending)
${canAbort
? html`
<button
class="chat-send-btn chat-send-btn--stop"
@@ -1471,14 +1698,13 @@ function groupMessages(items: ChatItem[]): Array<ChatItem | MessageGroup> {
const normalized = normalizeMessage(item.message);
const role = normalizeRoleForGrouping(normalized.role);
const senderLabel =
normalizeLowercaseStringOrEmpty(role) === "user" ? (normalized.senderLabel ?? null) : null;
const senderLabel = role.toLowerCase() === "user" ? (normalized.senderLabel ?? null) : null;
const timestamp = normalized.timestamp || Date.now();
if (
!currentGroup ||
currentGroup.role !== role ||
(normalizeLowercaseStringOrEmpty(role) === "user" && currentGroup.senderLabel !== senderLabel)
(role.toLowerCase() === "user" && currentGroup.senderLabel !== senderLabel)
) {
if (currentGroup) {
result.push(currentGroup);
@@ -1537,7 +1763,7 @@ function buildChatItems(props: ChatProps): Array<ChatItem | MessageGroup> {
continue;
}
if (!props.showToolCalls && normalizeLowercaseStringOrEmpty(normalized.role) === "toolresult") {
if (!props.showToolCalls && normalized.role.toLowerCase() === "toolresult") {
continue;
}
@@ -1552,6 +1778,31 @@ function buildChatItems(props: ChatProps): Array<ChatItem | MessageGroup> {
message: msg,
});
}
const liftedCanvasSources = tools
.map((tool) => extractChatMessagePreview(tool))
.filter((entry) => Boolean(entry)) as Array<{
preview: Extract<NonNullable<ToolCard["preview"]>, { kind: "canvas" }>;
text: string | null;
timestamp: number | null;
}>;
for (const liftedCanvasSource of liftedCanvasSources) {
const assistantIndex = findNearestAssistantMessageIndex(items, liftedCanvasSource.timestamp);
if (assistantIndex == null) {
continue;
}
const item = items[assistantIndex];
if (!item || item.kind !== "message") {
continue;
}
items[assistantIndex] = {
...item,
message: appendCanvasBlockToAssistantMessage(
item.message as Record<string, unknown>,
liftedCanvasSource.preview,
liftedCanvasSource.text,
),
};
}
// Interleave stream segments and tool cards in order. Each segment
// contains text that was streaming before the corresponding tool started.
// This ensures correct visual ordering: text → tool → text → tool → ...
@@ -1596,7 +1847,20 @@ function messageKey(message: unknown, index: number): string {
const m = message as Record<string, unknown>;
const toolCallId = typeof m.toolCallId === "string" ? m.toolCallId : "";
if (toolCallId) {
return `tool:${toolCallId}`;
const role = typeof m.role === "string" ? m.role : "unknown";
const id = typeof m.id === "string" ? m.id : "";
if (id) {
return `tool:${role}:${toolCallId}:${id}`;
}
const messageId = typeof m.messageId === "string" ? m.messageId : "";
if (messageId) {
return `tool:${role}:${toolCallId}:${messageId}`;
}
const timestamp = typeof m.timestamp === "number" ? m.timestamp : null;
if (timestamp != null) {
return `tool:${role}:${toolCallId}:${timestamp}:${index}`;
}
return `tool:${role}:${toolCallId}:${index}`;
}
const id = typeof m.id === "string" ? m.id : "";
if (id) {

View File

@@ -1,20 +1,36 @@
import { html } from "lit";
import { html, nothing } from "lit";
import { unsafeHTML } from "lit/directives/unsafe-html.js";
import { resolveCanvasIframeUrl } from "../canvas-url.ts";
import { resolveEmbedSandbox, type EmbedSandboxMode } from "../embed-sandbox.ts";
import { icons } from "../icons.ts";
import { toSanitizedMarkdownHtml } from "../markdown.ts";
import type { SidebarContent } from "../sidebar-content.ts";
function resolveSidebarCanvasSandbox(
content: SidebarContent,
embedSandboxMode: EmbedSandboxMode,
): string {
return content.kind === "canvas" ? resolveEmbedSandbox(embedSandboxMode) : "allow-scripts";
}
export type MarkdownSidebarProps = {
content: string | null;
content: SidebarContent | null;
error: string | null;
onClose: () => void;
onViewRawText: () => void;
canvasHostUrl?: string | null;
embedSandboxMode?: EmbedSandboxMode;
allowExternalEmbedUrls?: boolean;
};
export function renderMarkdownSidebar(props: MarkdownSidebarProps) {
const content = props.content;
return html`
<div class="sidebar-panel">
<div class="sidebar-header">
<div class="sidebar-title">Tool Output</div>
<div class="sidebar-title">
${content?.kind === "canvas" ? content.title?.trim() || "Render Preview" : "Tool Details"}
</div>
<button @click=${props.onClose} class="btn" title="Close sidebar">${icons.x}</button>
</div>
<div class="sidebar-content">
@@ -25,10 +41,40 @@ export function renderMarkdownSidebar(props: MarkdownSidebarProps) {
View Raw Text
</button>
`
: props.content
? html`<div class="sidebar-markdown">
${unsafeHTML(toSanitizedMarkdownHtml(props.content))}
</div>`
: content
? content.kind === "canvas"
? html`
<div class="chat-tool-card__preview" data-kind="canvas">
<div class="chat-tool-card__preview-panel" data-side="front">
<iframe
class="chat-tool-card__preview-frame"
title=${content.title?.trim() || "Render preview"}
sandbox=${resolveSidebarCanvasSandbox(
content,
props.embedSandboxMode ?? "scripts",
)}
src=${resolveCanvasIframeUrl(
content.entryUrl,
props.canvasHostUrl,
props.allowExternalEmbedUrls ?? false,
) ?? nothing}
style=${content.preferredHeight
? `height:${content.preferredHeight}px`
: ""}
></iframe>
</div>
${content.rawText?.trim()
? html`
<div style="margin-top: 12px;">
<button @click=${props.onViewRawText} class="btn">View Raw Text</button>
</div>
`
: nothing}
</div>
`
: html`<div class="sidebar-markdown">
${unsafeHTML(toSanitizedMarkdownHtml(content.content))}
</div>`
: html` <div class="muted">No content available</div> `}
</div>
</div>