fix(canvas): restore shipped policy/host-disable contracts; harden workspaces doctor cleanup (#110927)

* fix(dashboard): harden canvas and workspace compatibility

* docs(web): add dashboard architecture

* chore: remove release-owned changelog entry

* docs: update dashboard architecture map
This commit is contained in:
Peter Steinberger
2026-07-18 23:00:00 +01:00
committed by GitHub
parent 2f045a73f4
commit 460e3e669c
17 changed files with 903 additions and 29 deletions
+1
View File
@@ -1752,6 +1752,7 @@
"web/index",
"web/control-ui",
"web/dashboard",
"web/dashboard-architecture",
"web/webchat",
"web/tui",
"web/lobster"
+20
View File
@@ -10551,6 +10551,26 @@ Do not edit it by hand; run `pnpm docs:map:gen`.
- H2: Debugging/testing: dev server + remote Gateway
- H2: Related
## web/dashboard-architecture.md
- Route: /web/dashboard-architecture
- Headings:
- H2: Vision
- H2: Concepts
- H2: UX flows
- H2: Interaction tiers
- H2: Widget model and hosting
- H3: Widgets host content; MCP apps are one content kind
- H3: Transcript display: one widget card
- H3: Server-sourced widgets (pinned MCP apps)
- H2: Layout: fluid grid
- H2: Data model (per-agent DB)
- H2: Protocol surface
- H2: Agent tools
- H2: What this replaces
- H2: Non-goals (this program)
- H2: Implementation plan
## web/dashboard.md
- Route: /web/dashboard
+333
View File
@@ -0,0 +1,333 @@
---
summary: "Session dashboards: architecture and implementation plan (technical design, pre-GA)"
read_when:
- Implementing or reviewing the session dashboard (boards) feature
- Changing widget hosting, the widget bridge, or board storage
title: "Dashboard Architecture"
---
<Note>
Technical design document for the session dashboard feature, written before and
during implementation. It is the source of truth for the build-out. When the
feature ships, `/web/dashboard` becomes the user-facing page and this page stays
as the architecture reference.
</Note>
## Vision
Working with an agent today is a text stream. The dashboard makes it a
workbench: the agent renders live, interactive widgets; the user pins them onto
a persistent surface; chat docks to the side (or hides) and the main content is
the board. You go from "talking to the agent" to "operating a control panel the
agent built for you" without ever leaving the session.
Principles:
- **A board is a face of a session, not a new object.** Every session (thread)
has two faces: the transcript and the board. A session with no pinned widgets
is plain chat. Pin one widget and the board exists. Boards inherit the
session's identity, agent ownership, naming, pinning, and lifecycle. There is
no `dashboard_create`, no board registry, no separate ACL model.
- **Agent parity.** Everything the user can do on a board, the agent can do
with tools: add/update/remove widgets, arrange them, manage tabs, switch the
visible tab, dock or hide the chat.
- **Native, not embedded.** The board is Lit components in the Control UI shell
(the same design system as the rest of the app). Only widget _content_ is
sandboxed in iframes. No URL bar, no browser chrome.
- **Small agent surface.** Widgets are addressed by stable name and updated in
place. Layout is a fluid auto-compacting grid; the agent speaks sizes and
anchors, never pixels or coordinates.
- **Capabilities over trust.** Widget code is arbitrary agent-authored HTML/JS
in a hard sandbox. Reach (gateway data, actions, network) exists only through
a declared, operator-granted capability manifest.
## Concepts
| Concept | Definition |
| ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Session (thread) | Existing gateway session, keyed by stable `sessionKey`. Owned by an agent. |
| Board | The widget face of one session. Exists iff the session has widgets/tabs. Survives `/new`/`/reset` (attached to `sessionKey`, not the transcript). |
| Tab | A presentation page of a board: which widgets, their arrangement, and the chat dock state (`left`/`right`/`bottom`/`hidden`). Boards start with one implicit tab. |
| Widget | Named, sandboxed HTML/JS program owned by the session. Addressed as `sessionKey` + `name`. Updated in place by name. |
| Capability manifest | Per-widget declaration of reach: `data` (read bindings), `actions` (allowlisted verbs), `prompt` (send to session), `net` (allowed origins). |
| Pin (widget) | Moving a transcript widget onto the session's board (user affordance or agent tool arg). Unpin removes it from the board. |
| Pin (session) | Existing sidebar pinning of sessions. A pinned session with a board opens on its board face. |
## UX flows
- **Graduation:** agent calls `show_widget` in any chat → widget renders inline
in the transcript exactly as today → hover shows **Pin to dashboard** → widget
appears on the session's board. The agent can pass `pin: true` to do the same.
- **Board view:** a session with a board gets a face toggle (Chat / Dashboard).
Board view = tab strip (only when >1 tab) + fluid grid + docked chat pane.
Chat dock is resizable, movable (left/right/bottom), and collapsible exactly
like the sidebar. Per-tab dock state is remembered.
- **Drag:** user drags widgets; grid auto-compacts (widgets float up, neighbors
reflow). Resize by handle snaps to size steps. No pixel placement — for
anyone.
- **Reset warning:** `/new` / `/reset` on a board-bearing session asks for
confirmation in the web UI ("context resets, the dashboard stays") and keeps
the board.
- **Sidebar:** pinned sessions render their board face when they have one.
The Home session's board is the default "agent dashboard".
- **Interactions** (three tiers, see below): silent state events, visible
prompt sends, and automation triggers.
## Interaction tiers
1. **State events (default).** Widget UI interactions the model should know
about but not respond to. `bridge.emitState({...})` appends a structured
session notice (same mechanism as group-activity notices). No agent turn is
started; the model sees accumulated notices on its next run.
2. **Prompts (explicit talk).** `bridge.sendPrompt(text)` — requires user
activation; sends a visible user message into the session (the docked chat
shows it). Rate-limited; each send is user-confirmed unless the widget holds
the `prompt` capability grant.
3. **Automation.** `bridge.runAction(name, args)` — fires a manifest-declared
action. Initial verb set: `cron.trigger` (run an existing cron job now) and
`binding.refresh`. Cron jobs already run in visible, isolated run-sessions
and can use a cheaper model: that is the "small model powers the widget"
path. No hidden sessions anywhere.
## Widget model and hosting
Widget HTML/JS is authored by the agent (typically via `show_widget`), wrapped
in the standard document shell (CSP meta, size reporter, bridge bootstrap) and
rendered in `<iframe sandbox="allow-scripts">` (never `allow-same-origin`).
- **Inline (transcript) widgets** keep the current canvas-document pipeline:
written under the state dir, served by the gateway, pruned per scope, no
approval (they are capless by construction — prompt sends are user-confirmed).
- **Board widgets** are session state: bytes live in the owning agent's SQLite
DB (`board_widgets`), served by a core gateway route
(`/__openclaw__/board/<agentId>/<sessionKey>/<name>/`) that reads the DB.
Pinning a transcript widget copies the bytes. Caps: 256 KB per widget,
48 widgets per board.
- **Update in place:** re-emitting a widget with the same `name` replaces the
bytes, bumps `revision`, broadcasts `board.changed`, and live views reload
that iframe only.
- **Byte freezing:** granted capabilities bind to the sha256 of the widget
bytes. Changing bytes keeps `data`/`net`/`actions` grants only if the new
revision declares a subset of the granted manifest; a widened manifest
re-prompts the operator.
### Widgets host content; MCP apps are one content kind
The **widget is the OpenClaw primitive**: the named, pinned, sized,
session-owned board cell with a grant record. What renders inside it is a
content kind:
- `html` — agent-authored via `show_widget`, bytes in board storage.
- `mcp-app` — a third-party MCP app view (`ui://` resource from a configured
server) hosted inside the widget cell.
MCP apps do not define the widget model; widgets gained the ability to host
them. Identity, placement, pinning, grants, and the author-facing API stay
OpenClaw's — so `show_widget` code stays as short as it is today and never
needs to know the MCP Apps spec exists.
Shared infrastructure underneath (this is where the simplification lands):
- **One sandbox host.** `html` widgets render through the same hardened
pipeline MCP apps shipped with (double-iframe on the dedicated sandbox
origin, per-widget CSP declared and fail-closed decoded) instead of a second
bespoke iframe host. The proxy receives HTML by value, so local content is
the natural case.
- **One authorization model.** A widget's reach is a granted allowlist,
whatever its kind: for `html` widgets, host tools; for `mcp-app` widgets,
the server's app-visible tools (via the existing `allowedAppToolNames`
mechanism, made durable per widget instead of per-minting-run).
- **Host tools for `html` widgets** (exposed over the widget bridge, checked
against the grant):
- `openclaw.prompt.send` — tier 2; routed through the visible composer,
user-confirmed unless granted
- `openclaw.state.emit` — tier 1 session notices (coalesced, size-capped)
- `openclaw.data.read` — parameterized read-only bindings (existing
allowlisted read RPC set), resolved gateway-side
- `openclaw.cron.trigger` — tier 3 automation
- **`net` = CSP.** Network reach uses the already-shipped per-widget CSP
declaration (`connect-src` origins) — the self-updating weather widget
fetches its API directly from the sandbox, no gateway involvement.
- **Grants.** A widget declaring nothing renders immediately (sandboxed,
`default-src 'none'`, prompt sends individually confirmed) — same trust as
today's inline chat widgets. Declared tools/origins put the widget in
`pending` on the board: a placeholder card lists them human-readably with
one-tap **Allow**/**Reject**. Grants are per widget name; for `html` widgets
they are byte-frozen (sha256), and changed bytes keep the grant only if the
declaration shrank.
- **Authoring shim.** The document wrapper injects
`window.openclaw.sendPrompt/emitState/read/call` as the stable author API;
whether the transport underneath is our channel or the AppBridge is an
internal detail the widget author never sees. Size reporting and theme
tokens ride the same bridge.
### Transcript display: one widget card
Inline display unifies on the widget primitive. When a tool result carries UI —
`show_widget` output or an MCP tool result with an app resource — the system
materializes an **ephemeral, auto-named widget** (session-scoped, pruned) and
the transcript renders a single widget card that dispatches on content kind.
MCP app auto-display stays exactly as the spec expects (zero extra model work);
it just _is_ a widget underneath. This deletes the parallel `mcpApp`
special-cases in chat rendering (surface gating, separate dedup), gives every
inline UI the same pin affordance, and makes the widget registry the primary
re-open path (transcript-scan reconstruction stays as fallback for never-pinned
history). The read-only ticketed standalone host overlaps with boards as a
persistent re-open surface — consolidation candidate to evaluate in T6, not
assumed.
Composition: v1 is grid adjacency (agent chrome widget next to an app widget on
one tab). v2 adds **host-managed app slots** — agent widget HTML declares a
slot region and the host composites the real app view as a sibling sandbox.
The app never renders inside the agent's iframe: nesting would break bridge
identity and enable overlay/clickjack of granted app UI, so the slot is a
layout contract, not an embed.
### Server-sourced widgets (pinned MCP apps)
With the unified host, pinning a third-party MCP app is just a widget whose
content is fetched from the server instead of stored: `board_widgets` keeps the
descriptor (`serverName`, `toolName`, `uiResourceUri`, originating
`toolCallId` + `sessionKey`) instead of HTML bytes, and the board re-mints the
view lease past the chat-turn 10-minute TTL (re-fetching the `ui://` resource
on staleness). Chat inline MCP app views get the same **Pin to dashboard**
affordance as agent widgets. Re-opened views are read-only today by design;
pinned apps that should stay interactive get a durable grant over the server's
app-visible tools (explicit allowlist shown to the operator on pin), decoupled
from the minting run. Ungranted pins stay read-only — still useful for display
dashboards. v1 pins to the originating session's board; cross-session pinning
needs a lease broker and waits. Coordinate with open PR #109807 (`ui/message`
composer routing, theme/size propagation).
## Layout: fluid grid
12 columns, fixed row height, **auto-compacting** (gravity-up, push-aside on
drag — gridstack semantics, implemented natively; grid math stays pure and
DOM-free). Widget layout state per tab: `{ name, w (1-12), h (rows) }` plus
order. Agent vocabulary:
- `size`: `sm` (3×3) · `md` (6×4) · `lg` (8×6) · `xl` (12×8) · `full`
(single-widget tab)
- `after: <widgetName>` optional ordering anchor; omitted = append
- User drags/resizes freely; the same order+size model round-trips.
## Data model (per-agent DB)
New tables in `agents/<agentId>/agent/openclaw-agent.sqlite`
(**requires an agent-DB schema-version bump — operator sign-off required
before this lands**):
```sql
CREATE TABLE board_tabs (
session_key TEXT NOT NULL,
tab_id TEXT NOT NULL, -- slug
title TEXT NOT NULL,
position INTEGER NOT NULL,
chat_dock TEXT NOT NULL DEFAULT 'right', -- left|right|bottom|hidden
created_by TEXT NOT NULL, -- 'user' | 'agent'
PRIMARY KEY (session_key, tab_id)
) STRICT;
CREATE TABLE board_widgets (
session_key TEXT NOT NULL,
name TEXT NOT NULL, -- stable widget name
tab_id TEXT NOT NULL,
title TEXT,
html BLOB NOT NULL, -- wrapped document source
sha256 TEXT NOT NULL,
revision INTEGER NOT NULL,
size_w INTEGER NOT NULL,
size_h INTEGER NOT NULL,
position INTEGER NOT NULL, -- order within tab (auto-compact input)
manifest TEXT NOT NULL DEFAULT '{}', -- capability manifest JSON
grant_state TEXT NOT NULL DEFAULT 'none', -- none|pending|granted|rejected
granted_sha TEXT, -- byte-frozen grant
created_by TEXT NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
PRIMARY KEY (session_key, name)
) STRICT;
```
Board existence = any rows for the `sessionKey`. Deleting a session deletes its
board rows. `/new`/`/reset` does not touch them.
## Protocol surface
RPCs (core method table, typebox schemas in `gateway-protocol`):
- `board.get { sessionKey }` → tabs + widget metadata (no bytes) — `operator.read`
- `board.update { sessionKey, ops[] }` — tab CRUD/reorder, widget move/resize/
remove/unpin, dock state, focus-tab — `operator.write`
- `board.widget.put { sessionKey, name, html, manifest, placement }`
`operator.write` (agent tool path and pin path)
- `board.widget.grant { sessionKey, name, decision }``operator.approvals`
- `board.event { sessionKey, widget, payload }` — tier-1 state event ingest —
`operator.write`
Events (in `EVENT_SCOPE_GUARDS`, read scope):
- `board.changed { sessionKey, revision, widget? }` — persisted state changed;
UI refetches (and reloads one iframe when `widget` is present).
- `board.command { sessionKey, command }` — transient UI drive (agent switches
the visible tab, toggles chat dock) — the `ui.command` pattern.
Widget bytes are served over the authenticated HTTP surface, not the socket.
## Agent tools
Three tools total (core, always registered; rendering gated on the
`inline-widgets` client cap as today):
- `show_widget { title, widget_code, name?, pin?, size?, tab?, after?,
capabilities? }` — create/update by name; `pin` places it on the board.
Without `name`/`pin` it behaves exactly like today (inline, ephemeral).
- `dashboard { action, ... }` — board management verbs: `read`, `tab_create`,
`tab_update`, `tab_delete`, `tabs_reorder`, `widget_move`, `widget_remove`,
`unpin`, `focus_tab`, `set_chat_dock`.
- Existing `cron` tools cover the automation tier; no new tool needed.
Tool descriptions teach the size/anchor vocabulary and the tier model. The
agent is told about user tier-1 events via session notices, e.g.
`[dashboard] user clicked "Refresh" on widget weather (tab main)`.
## What this replaces
- **`extensions/workspaces` is deleted.** Experimental, `enabledByDefault:
false`, never in a stable release (first appeared in 2026.7.2 betas). No
migration; a doctor rule removes stale `<stateDir>/workspaces/` if present.
Harvested ideas: pure grid math, bridge security model (port bootstrap,
binding gating, rate limits), byte-frozen approval.
- **Widget hosting moves from `extensions/canvas` to core.** The canvas doc
store, document wrapper, HTTP serving, and the `show_widget` tool become core
(`src/canvas/`); the plugin keeps the node-canvas control tool (`canvas`) and
A2UI. The `pluginSurfaceUrls["canvas"]` advertisement and
`/__openclaw__/canvas` paths are shipped native-client contracts and stay
stable. Discord sessions keep the Discord-owned `show_widget` variant.
- **WorkBoard is untouched** (integration is a follow-up program).
## Non-goals (this program)
- Multi-user board sharing/ACLs (future; will arrive via session sharing).
- Native macOS/iOS board rendering (they get it wherever they embed the
Control UI; the inline-widget path is unchanged).
- Builtin data widgets (sessions/usage/cron cards) — the capability bridge plus
agent-authored widgets cover v1; a builtin kind registry can come later.
- WorkBoard-on-dashboard.
## Implementation plan
Independent worktrees, Codex-built, review+land sequentially. Land-then-fix.
| # | Branch | Scope | Depends on |
| --- | ------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------- |
| T1 | `claude/dashboard-remove-workspaces` | Delete workspaces plugin + UI + docs + i18n keys; doctor cleanup rule | — |
| T2 | `claude/dashboard-canvas-core` | Promote widget hosting + `show_widget` to core; canvas plugin keeps node tool; zero behavior change | — |
| T3 | `claude/dashboard-domain` | Agent-DB tables (schema bump), `board.*` RPCs + events, `dashboard` tool, `show_widget` pin/name/manifest args, tier-1 notices, reset-keeps-board | T2 |
| T4 | `claude/dashboard-ui` | Board face + tab strip + fluid auto-compact grid + chat dock (left/right/bottom/hidden) + transcript pin affordance + sidebar board face + reset confirm | T3 (mock-first via dev fixtures) |
| T5 | `claude/dashboard-capabilities` | Grant store/UI + byte freezing; move `html` widgets onto the shared sandbox host; host tools (`openclaw.prompt.send/state.emit/data.read/cron.trigger`); `net` CSP; authoring shim | T3, T4 |
| T7 | `claude/dashboard-mcp-apps` | `mcp-app` content kind: pin affordance on inline app views, descriptor storage, lease re-mint/refresh, durable server-tool grants (reuses shipped MCP Apps host) | T3, T4 |
| T6 | polish | Live E2E on a scratch gateway (real keys), screenshots, fixes, user-focused `/web/dashboard` rewrite, enable-by-default review | all |
Validation per repo rules: focused vitest locally, full gates on
Crabbox/Testbox, `$autoreview` before every land, live proof for T6.
@@ -1,5 +1,6 @@
// Verifies gateway client capabilities are hard availability requirements for tools.
import { describe, expect, it, vi } from "vitest";
import { withEnv } from "../test-utils/env.js";
vi.mock("./openclaw-plugin-tools.js", () => ({
resolveOpenClawPluginToolsForOptions: () => [],
@@ -41,6 +42,25 @@ describe("gateway client capability tool filtering", () => {
).toBe(false);
});
it("keeps the core widget tool out when Canvas host config disables it", () => {
expect(
hasWidget(
createOpenClawTools({
clientCaps: ["inline-widgets"],
config: {
plugins: { entries: { canvas: { config: { host: { enabled: false } } } } },
},
}),
),
).toBe(false);
});
it("keeps the core widget tool out when OPENCLAW_SKIP_CANVAS_HOST is set", () => {
withEnv({ OPENCLAW_SKIP_CANVAS_HOST: "1" }, () => {
expect(hasWidget(createOpenClawTools({ clientCaps: ["inline-widgets"] }))).toBe(false);
});
});
it("only exposes screen to UI-command clients", () => {
expect(hasScreen(createOpenClawTools())).toBe(false);
expect(hasScreen(createOpenClawTools({ clientCaps: ["ui-commands"] }))).toBe(true);
+2 -1
View File
@@ -4,6 +4,7 @@ import type {
SourceReplyDeliveryMode,
TaskSuggestionDeliveryMode,
} from "../auto-reply/get-reply-options.types.js";
import { isCoreCanvasHostEnabled } from "../canvas/config.js";
import { createShowWidgetTool } from "../canvas/widget-tool.js";
import type { ChatType } from "../channels/chat-type.js";
import type { InboundEventKind } from "../channels/inbound-event/kind.js";
@@ -527,7 +528,7 @@ export function createOpenClawTools(
...(messageTool && includeMessageTool ? [messageTool] : []),
// Discord sessions get the Discord plugin's own show_widget (Activities
// delivery); registering the core tool there would collide on the name.
...(options?.agentChannel === "discord"
...(options?.agentChannel === "discord" || !isCoreCanvasHostEnabled(resolvedConfig)
? []
: [
createShowWidgetTool({
+38
View File
@@ -81,6 +81,44 @@ describe("tool-policy-pipeline", () => {
expect(names).toEqual(["plugin_tool"]);
});
test.each([
{ expected: ["exec"], policy: { deny: ["canvas"] } },
{ expected: ["canvas", "show_widget"], policy: { allow: ["canvas"] } },
])("keeps promoted show_widget in the Canvas policy family ($policy)", ({ expected, policy }) => {
const tools = [{ name: "exec" }, { name: "show_widget" }, { name: "canvas" }];
const filtered = applyToolPolicyPipeline({
tools: asPolicyTools(tools),
toolMeta: (tool) => (tool.name === "canvas" ? { pluginId: "canvas" } : undefined),
warn: () => {},
steps: [{ policy, label: "tools", stripPluginOnlyAllowlist: true }],
});
expect(filtered.map((tool) => tool.name).toSorted()).toEqual(expected);
});
test.each([
{ expected: ["exec", "show_widget"], policy: { deny: ["canvas"] } },
{ expected: ["canvas"], policy: { allow: ["canvas"] } },
])(
"does not apply the Canvas core alias to Discord-owned show_widget ($policy)",
({ expected, policy }) => {
const tools = [{ name: "exec" }, { name: "show_widget" }, { name: "canvas" }];
const filtered = applyToolPolicyPipeline({
tools: asPolicyTools(tools),
toolMeta: (tool) => {
if (tool.name === "show_widget") {
return { pluginId: "discord" };
}
return tool.name === "canvas" ? { pluginId: "canvas" } : undefined;
},
warn: () => {},
steps: [{ policy, label: "tools", stripPluginOnlyAllowlist: true }],
});
expect(filtered.map((tool) => tool.name).toSorted()).toEqual(expected);
},
);
test("warns about unknown allowlist entries", () => {
const warnings: string[] = [];
const tools = [{ name: "exec" }] as unknown as DummyTool[];
+13 -3
View File
@@ -47,6 +47,12 @@ export type DeclaredToolAllowlistContext = {
/** Synthetic allowlist entry that means "use default plugin tools". */
export const DEFAULT_PLUGIN_TOOLS_ALLOWLIST_ENTRY = "__openclaw_default_plugin_tools__";
const SHIPPED_PLUGIN_POLICY_FAMILY_CORE_TOOLS = new Map<string, readonly string[]>([
// `canvas` is a shipped operator policy family. Keep promoted `show_widget`
// in that family so existing allow/deny configs retain their old surface.
["canvas", ["show_widget"]],
]);
/** Returns true when an allow policy is narrower than all/default plugin tools. */
export function hasRestrictiveAllowPolicy(policy?: { allow?: string[] }): boolean {
return (
@@ -171,9 +177,13 @@ function expandPluginGroups(
}
continue;
}
const tools = groups.byPlugin.get(normalized);
if (tools && tools.length > 0) {
expanded.push(...tools);
const tools = groups.byPlugin.get(normalized) ?? [];
// Discord owns its own show_widget; only alias names absent from plugin ownership metadata.
const promotedCoreTools = (
SHIPPED_PLUGIN_POLICY_FAMILY_CORE_TOOLS.get(normalized) ?? []
).filter((toolName) => !groups.all.includes(toolName));
if (tools.length > 0 || promotedCoreTools.length > 0) {
expanded.push(...tools, ...promotedCoreTools);
continue;
}
expanded.push(normalized);
+18
View File
@@ -0,0 +1,18 @@
/** Core Canvas host enablement from the shipped Canvas plugin configuration surface. */
import { isRecord } from "@openclaw/normalization-core/record-coerce";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { isTruthyEnvValue } from "../infra/env.js";
/** Returns whether core-owned widget hosting and tools should be active. */
export function isCoreCanvasHostEnabled(
config?: OpenClawConfig,
env: NodeJS.ProcessEnv = process.env,
): boolean {
// Canvas owned these shipped operator switches before hosting moved into core.
// Core keeps reading them so existing disablement still covers the whole Canvas family.
if (isTruthyEnvValue(env.OPENCLAW_SKIP_CANVAS_HOST)) {
return false;
}
const host = config?.plugins?.entries?.canvas?.config?.host;
return !isRecord(host) || host.enabled !== false;
}
+5 -4
View File
@@ -25,9 +25,10 @@ export function resolveCanvasNodeCapability(
/** Adds the core Canvas surface while removing stale plugin-owned duplicates. */
export function withCoreCanvasNodeCapability(
surfaces: readonly PluginNodeCapabilitySurface[],
enabled = true,
): PluginNodeCapabilitySurface[] {
return [
CANVAS_NODE_CAPABILITY,
...surfaces.filter((entry) => entry.surface.trim() !== CANVAS_NODE_CAPABILITY.surface),
];
const withoutStaleCanvas = surfaces.filter(
(entry) => entry.surface.trim() !== CANVAS_NODE_CAPABILITY.surface,
);
return enabled ? [CANVAS_NODE_CAPABILITY, ...withoutStaleCanvas] : withoutStaleCanvas;
}
@@ -1481,6 +1481,84 @@ describe("doctor health contributions", () => {
});
});
it("reports removed Workspaces state during non-fix doctor runs", async () => {
const contribution = requireDoctorContribution("doctor:legacy-state");
mocks.runDoctorHealthRepairs.mockImplementation(async (input: { cfg?: unknown }) => ({
config: input.cfg ?? {},
findings: [
{
checkId: "core/doctor/removed-workspaces-state",
severity: "warning",
message: "Retired Workspaces plugin state remains at /tmp/workspaces.",
path: "/tmp/workspaces",
fixHint: "Run openclaw doctor --fix.",
},
],
remainingFindings: [],
changes: [],
warnings: [],
diffs: [],
effects: [],
checksRun: 1,
checksRepaired: 0,
checksValidated: 1,
}));
const ctx = {
cfg: {},
sourceConfigValid: true,
prompter: buildDoctorPrompter(false),
runtime: { log: vi.fn(), error: vi.fn(), exit: vi.fn() },
options: { nonInteractive: true },
configPath: "/tmp/fake-openclaw.json",
} as unknown as Parameters<(typeof contribution)["run"]>[0];
await contribution.run(ctx);
expect(mocks.runDoctorHealthRepairs).toHaveBeenCalledWith(
expect.objectContaining({ mode: "fix", dryRun: true }),
expect.objectContaining({ dryRun: true }),
);
expect(ctx.runtime.log).toHaveBeenCalledWith(
expect.stringContaining("core/doctor/removed-workspaces-state"),
);
});
it("does not report a removed Workspaces finding after a successful fix", async () => {
const contribution = requireDoctorContribution("doctor:legacy-state");
mocks.runDoctorHealthRepairs.mockImplementation(async (input: { cfg?: unknown }) => ({
config: input.cfg ?? {},
findings: [
{
checkId: "core/doctor/removed-workspaces-state",
severity: "warning",
message: "Retired Workspaces plugin state remains at /tmp/workspaces.",
},
],
remainingFindings: [],
changes: ["Removed retired Workspaces plugin state at /tmp/workspaces."],
warnings: [],
diffs: [],
effects: [],
checksRun: 1,
checksRepaired: 1,
checksValidated: 1,
}));
const ctx = {
cfg: {},
sourceConfigValid: true,
prompter: buildDoctorPrompter(true),
runtime: { log: vi.fn(), error: vi.fn(), exit: vi.fn() },
options: { nonInteractive: true },
configPath: "/tmp/fake-openclaw.json",
} as unknown as Parameters<(typeof contribution)["run"]>[0];
await contribution.run(ctx);
expect(ctx.runtime.log).not.toHaveBeenCalledWith(
expect.stringContaining("core/doctor/removed-workspaces-state"),
);
});
it("grants Doctor-only state migration authority only in repair mode", async () => {
const contribution = requireDoctorContribution("doctor:legacy-state");
const cfg = { session: { store: "/tmp/shared-sessions.json" } };
+8 -5
View File
@@ -476,11 +476,11 @@ async function runClaudeCliHealth(ctx: DoctorHealthFlowContext): Promise<void> {
noteClaudeCliHealth(ctx.cfg);
}
async function runCoreContributionHealthRepair(
async function runCoreContributionHealth(
ctx: DoctorHealthFlowContext,
checkIds: readonly string[],
): Promise<void> {
if (!ctx.prompter.shouldRepair || checkIds.length === 0) {
if (checkIds.length === 0) {
return;
}
const { CORE_HEALTH_CHECKS } = await import("./doctor-core-checks.js");
@@ -495,6 +495,7 @@ async function runCoreContributionHealthRepair(
return;
}
const workspaceDir = resolveAgentWorkspaceDir(ctx.cfg, resolveDefaultAgentId(ctx.cfg));
const dryRun = !ctx.prompter.shouldRepair;
const result = await runDoctorHealthRepairs(
{
mode: "fix",
@@ -502,10 +503,12 @@ async function runCoreContributionHealthRepair(
cfg: ctx.cfg,
cwd: workspaceDir,
configPath: ctx.configPath,
dryRun,
},
{ checks },
{ checks, dryRun },
);
ctx.cfg = result.config;
renderStructuredHealthFindings(ctx, dryRun ? result.findings : result.remainingFindings);
if (result.changes.length > 0) {
note(result.changes.join("\n"), "Doctor changes");
}
@@ -520,7 +523,7 @@ async function runLegacyStateHealth(ctx: DoctorHealthFlowContext): Promise<void>
const { note } = await loadNoteModule();
// Settle retired-plugin state cleanup (may replace ctx.cfg) before the
// legacy-state detect/migrate pair reads the config.
await runCoreContributionHealthRepair(ctx, ["core/doctor/removed-workspaces-state"]);
await runCoreContributionHealth(ctx, ["core/doctor/removed-workspaces-state"]);
const doctorOnlyStateMigrations = ctx.options.repair === true || ctx.options.yes === true;
const legacyState = await detectLegacyStateMigrations({
cfg: ctx.cfg,
@@ -769,7 +772,7 @@ async function runWebFetchProxyHealth(ctx: DoctorHealthFlowContext): Promise<voi
async function runBrowserHealth(ctx: DoctorHealthFlowContext): Promise<void> {
const { noteChromeMcpBrowserReadiness } = await import("../commands/doctor-browser.js");
await runCoreContributionHealthRepair(ctx, ["core/doctor/browser-clawd-profile-residue"]);
await runCoreContributionHealth(ctx, ["core/doctor/browser-clawd-profile-residue"]);
await noteChromeMcpBrowserReadiness(ctx.cfg);
}
@@ -11,6 +11,23 @@ const runtime = { log() {}, error() {}, exit() {} };
describe("removed Workspaces state doctor check", () => {
let root: string | undefined;
async function createStateDir(
fingerprint: "sqlite" | "widgets-data" = "sqlite",
): Promise<string> {
root = await fs.mkdtemp(join(tmpdir(), "openclaw-workspaces-state-"));
const staleDir = join(root, "workspaces");
await fs.mkdir(staleDir, { recursive: true });
if (fingerprint === "sqlite") {
await fs.writeFile(join(staleDir, "workspaces.sqlite"), "stale", "utf8");
} else {
await Promise.all([
fs.mkdir(join(staleDir, "widgets"), { recursive: true }),
fs.mkdir(join(staleDir, "data"), { recursive: true }),
]);
}
return staleDir;
}
afterEach(async () => {
if (root !== undefined) {
await fs.rm(root, { force: true, recursive: true });
@@ -19,10 +36,7 @@ describe("removed Workspaces state doctor check", () => {
});
it("previews and removes the stale plugin state directory", async () => {
root = await fs.mkdtemp(join(tmpdir(), "openclaw-workspaces-state-"));
const staleDir = join(root, "workspaces");
await fs.mkdir(join(staleDir, "assets"), { recursive: true });
await fs.writeFile(join(staleDir, "workspaces.sqlite"), "stale", "utf8");
const staleDir = await createStateDir();
await withEnvAsync({ OPENCLAW_STATE_DIR: root }, async () => {
const findings = await removedWorkspacesStateCheck.detect({
@@ -82,4 +96,128 @@ describe("removed Workspaces state doctor check", () => {
).resolves.toEqual([]);
});
});
it("detects the widgets/data plugin layout fingerprint", async () => {
const staleDir = await createStateDir("widgets-data");
await withEnvAsync({ OPENCLAW_STATE_DIR: root }, async () => {
await expect(
removedWorkspacesStateCheck.detect({ mode: "lint", runtime, cfg: {} }),
).resolves.toEqual([
expect.objectContaining({
checkId: "core/doctor/removed-workspaces-state",
path: staleDir,
}),
]);
});
});
it("ignores a plain user directory without plugin fingerprints", async () => {
root = await fs.mkdtemp(join(tmpdir(), "openclaw-workspaces-state-"));
const userDir = join(root, "workspaces");
await fs.mkdir(userDir, { recursive: true });
await fs.writeFile(join(userDir, "notes.md"), "personal", "utf8");
await withEnvAsync({ OPENCLAW_STATE_DIR: root }, async () => {
await expect(
removedWorkspacesStateCheck.detect({ mode: "lint", runtime, cfg: {} }),
).resolves.toEqual([]);
await expect(
removedWorkspacesStateCheck.repair?.({ mode: "fix", runtime, cfg: {} }, []),
).resolves.toMatchObject({ status: "skipped", changes: [] });
await expect(fs.readFile(join(userDir, "notes.md"), "utf8")).resolves.toBe("personal");
});
});
it.each(["defaults", "agent"] as const)(
"skips cleanup when the %s workspace resolves to the retired state directory",
async (kind) => {
const staleDir = await createStateDir();
const resolvedAlias = join(staleDir, "..", "workspaces");
const cfg =
kind === "defaults"
? { agents: { defaults: { workspace: resolvedAlias } } }
: { agents: { list: [{ id: "ops", workspace: resolvedAlias }] } };
await withEnvAsync({ OPENCLAW_STATE_DIR: root }, async () => {
const findings = await removedWorkspacesStateCheck.detect({
mode: "lint",
runtime,
cfg,
});
expect(findings).toEqual([
expect.objectContaining({
message: expect.stringContaining("Automatic removal is disabled"),
path: staleDir,
severity: "warning",
}),
]);
const repaired = await removedWorkspacesStateCheck.repair?.(
{ mode: "fix", runtime, cfg },
findings,
);
expect(repaired).toMatchObject({
status: "skipped",
changes: [],
warnings: [expect.stringContaining("Automatic removal is disabled")],
});
await expect(fs.stat(staleDir)).resolves.toBeDefined();
});
},
);
it("skips cleanup when an agent workspace is nested under the retired state directory", async () => {
const staleDir = await createStateDir();
const nestedWorkspace = join(staleDir, "active-agent");
await fs.mkdir(nestedWorkspace);
const cfg = { agents: { list: [{ id: "active", workspace: nestedWorkspace }] } };
await withEnvAsync({ OPENCLAW_STATE_DIR: root }, async () => {
const findings = await removedWorkspacesStateCheck.detect({ mode: "lint", runtime, cfg });
const repaired = await removedWorkspacesStateCheck.repair?.(
{ mode: "fix", runtime, cfg },
findings,
);
expect(repaired).toMatchObject({ status: "skipped", changes: [] });
await expect(fs.stat(nestedWorkspace)).resolves.toBeDefined();
});
});
it("skips cleanup when an agent workspace symlink resolves beneath the retired state directory", async () => {
const staleDir = await createStateDir();
const nestedWorkspace = join(staleDir, "active-agent");
const workspaceAlias = join(root!, "active-agent-link");
await fs.mkdir(nestedWorkspace);
await fs.symlink(nestedWorkspace, workspaceAlias, "dir");
const cfg = { agents: { defaults: { workspace: workspaceAlias } } };
await withEnvAsync({ OPENCLAW_STATE_DIR: root }, async () => {
const findings = await removedWorkspacesStateCheck.detect({ mode: "lint", runtime, cfg });
const repaired = await removedWorkspacesStateCheck.repair?.(
{ mode: "fix", runtime, cfg },
findings,
);
expect(repaired).toMatchObject({ status: "skipped", changes: [] });
await expect(fs.stat(nestedWorkspace)).resolves.toBeDefined();
});
});
it("skips cleanup when the retired state directory is nested under an agent workspace", async () => {
const staleDir = await createStateDir();
const cfg = { agents: { defaults: { workspace: root } } };
await withEnvAsync({ OPENCLAW_STATE_DIR: root }, async () => {
const findings = await removedWorkspacesStateCheck.detect({ mode: "lint", runtime, cfg });
const repaired = await removedWorkspacesStateCheck.repair?.(
{ mode: "fix", runtime, cfg },
findings,
);
expect(repaired).toMatchObject({ status: "skipped", changes: [] });
await expect(fs.stat(staleDir)).resolves.toBeDefined();
});
});
});
@@ -1,7 +1,9 @@
// Doctor cleanup for state left by the retired experimental Workspaces plugin.
import { lstat, rm } from "node:fs/promises";
import { lstat, realpath, rm } from "node:fs/promises";
import path from "node:path";
import { resolveStateDir } from "../config/paths.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { resolveUserPath } from "../utils.js";
import type { HealthCheck, HealthRepairEffect } from "./health-checks.js";
const CHECK_ID = "core/doctor/removed-workspaces-state";
@@ -10,18 +12,94 @@ function resolveRemovedWorkspacesStateDir(): string {
return path.join(resolveStateDir(process.env), "workspaces");
}
async function pathExists(target: string): Promise<boolean> {
async function pathKind(target: string): Promise<"directory" | "file" | null> {
try {
await lstat(target);
return true;
const stats = await lstat(target);
if (stats.isDirectory()) {
return "directory";
}
return stats.isFile() ? "file" : null;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
return false;
return null;
}
throw error;
}
}
async function hasRemovedWorkspacesFingerprint(target: string): Promise<boolean> {
if ((await pathKind(target)) !== "directory") {
return false;
}
if ((await pathKind(path.join(target, "workspaces.sqlite"))) === "file") {
return true;
}
const [widgetsKind, dataKind] = await Promise.all([
pathKind(path.join(target, "widgets")),
pathKind(path.join(target, "data")),
]);
return widgetsKind === "directory" && dataKind === "directory";
}
async function canonicalPath(target: string): Promise<string> {
const resolved = path.resolve(target);
try {
return await realpath(resolved);
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code === "ENOENT" || code === "ENOTDIR") {
return resolved;
}
throw error;
}
}
function isSameOrDescendant(parent: string, candidate: string): boolean {
const relative = path.relative(parent, candidate);
return (
relative === "" ||
(!path.isAbsolute(relative) && !relative.startsWith(`..${path.sep}`) && relative !== "..")
);
}
async function configuredAgentWorkspaceCollisions(
cfg: OpenClawConfig,
target: string,
): Promise<string[]> {
const configured: Array<{ label: string; workspace: string | undefined }> = [
{ label: "agents.defaults.workspace", workspace: cfg.agents?.defaults?.workspace },
...(cfg.agents?.list ?? []).map((agent) => ({
label: `agents.list.${agent.id}.workspace`,
workspace: agent.workspace,
})),
];
const resolvedTarget = await canonicalPath(target);
const resolvedEntries = await Promise.all(
configured
.filter(
(entry): entry is { label: string; workspace: string } =>
typeof entry.workspace === "string" && entry.workspace.trim().length > 0,
)
.map(async (entry) => ({
label: entry.label,
resolvedWorkspace: await canonicalPath(resolveUserPath(entry.workspace, process.env)),
})),
);
return resolvedEntries
.filter(
(entry) =>
isSameOrDescendant(resolvedTarget, entry.resolvedWorkspace) ||
isSameOrDescendant(entry.resolvedWorkspace, resolvedTarget),
)
.map((entry) => entry.label);
}
function collisionWarning(target: string, collisions: readonly string[]): string {
return `Retired Workspaces plugin fingerprints remain at ${target}, but ${collisions.join(
", ",
)} resolves to that directory or an overlapping path. Automatic removal is disabled.`;
}
function repairEffect(target: string, dryRun: boolean): HealthRepairEffect {
return {
kind: "state",
@@ -36,12 +114,26 @@ export const removedWorkspacesStateCheck: HealthCheck = {
kind: "core",
description: "State from the retired experimental Workspaces plugin has been removed.",
source: "doctor",
async detect(_ctx, scope) {
async detect(ctx, scope) {
const target = resolveRemovedWorkspacesStateDir();
const scopedPaths = new Set(scope?.paths ?? []);
if ((scopedPaths.size > 0 && !scopedPaths.has(target)) || !(await pathExists(target))) {
if (
(scopedPaths.size > 0 && !scopedPaths.has(target)) ||
!(await hasRemovedWorkspacesFingerprint(target))
) {
return [];
}
const collisions = await configuredAgentWorkspaceCollisions(ctx.cfg, target);
if (collisions.length > 0) {
return [
{
checkId: CHECK_ID,
severity: "warning",
message: collisionWarning(target, collisions),
path: target,
},
];
}
return [
{
checkId: CHECK_ID,
@@ -54,13 +146,23 @@ export const removedWorkspacesStateCheck: HealthCheck = {
},
async repair(ctx) {
const target = resolveRemovedWorkspacesStateDir();
if (!(await pathExists(target))) {
if (!(await hasRemovedWorkspacesFingerprint(target))) {
return {
status: "skipped",
reason: "retired Workspaces plugin state no longer exists",
reason: "retired Workspaces plugin fingerprints are absent",
changes: [],
};
}
const collisions = await configuredAgentWorkspaceCollisions(ctx.cfg, target);
if (collisions.length > 0) {
const warning = collisionWarning(target, collisions);
return {
status: "skipped",
reason: warning,
changes: [],
warnings: [warning],
};
}
const dryRun = ctx.dryRun === true;
const effects = [repairEffect(target, dryRun)];
if (dryRun) {
+95
View File
@@ -0,0 +1,95 @@
// Core Canvas Gateway route tests cover shipped host-disable switches.
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { resolveCanvasNodeCapability } from "../canvas/constants.js";
import { createCanvasDocument } from "../canvas/documents.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { withEnvAsync } from "../test-utils/env.js";
import type { ResolvedGatewayAuth } from "./auth.js";
import { createGatewayHttpServer } from "./server-http.js";
const resolvedAuth: ResolvedGatewayAuth = {
mode: "token",
token: "test-token",
allowTailscale: false,
};
const tempDirs: string[] = [];
afterEach(async () => {
await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { force: true, recursive: true })));
});
async function requestHostedDocument(params: {
config: OpenClawConfig;
skipHost?: string;
}): Promise<Response> {
const stateDir = await mkdtemp(path.join(tmpdir(), "openclaw-canvas-gateway-"));
tempDirs.push(stateDir);
const document = await createCanvasDocument(
{
id: "host-switch-test",
kind: "html_bundle",
entrypoint: { type: "html", value: "<html><body>hosted</body></html>" },
},
{ stateDir },
);
return await withEnvAsync(
{
OPENCLAW_SKIP_CANVAS_HOST: params.skipHost,
OPENCLAW_STATE_DIR: stateDir,
},
async () => {
const server = createGatewayHttpServer({
clients: new Set(),
controlUiEnabled: false,
controlUiBasePath: "/__control__",
openAiChatCompletionsEnabled: false,
openResponsesEnabled: false,
handleHooksRequest: async () => false,
handlePluginRequest: async () => false,
resolvePluginNodeCapabilityRoute: (pathContext) =>
resolveCanvasNodeCapability(pathContext.candidates),
resolvedAuth,
getRuntimeConfig: () => params.config,
});
await new Promise<void>((resolve) => {
server.listen(0, "127.0.0.1", () => resolve());
});
const address = server.address();
const port = typeof address === "object" && address ? address.port : 0;
try {
return await fetch(`http://127.0.0.1:${port}${document.entryUrl}`, {
headers: { authorization: "Bearer test-token", connection: "close" },
});
} finally {
await new Promise<void>((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
});
}
},
);
}
describe("core Canvas Gateway host switches", () => {
it("serves core widget documents by default", async () => {
const response = await requestHostedDocument({ config: {} });
expect(response.status).toBe(200);
expect(await response.text()).toContain("hosted");
});
it.each([
{
label: "plugins.entries.canvas.config.host.enabled=false",
config: {
plugins: { entries: { canvas: { config: { host: { enabled: false } } } } },
},
},
{ label: "OPENCLAW_SKIP_CANVAS_HOST", config: {}, skipHost: "1" },
])("does not register the core widget route for $label", async ({ config, skipHost }) => {
const response = await requestHostedDocument({ config, skipHost });
expect(response.status).toBe(404);
});
});
+6 -1
View File
@@ -9,6 +9,7 @@ import {
import { createServer as createHttpsServer } from "node:https";
import type { TlsOptions } from "node:tls";
import type { WebSocketServer } from "ws";
import { isCoreCanvasHostEnabled } from "../canvas/config.js";
import { isCanvasDocumentHttpPath } from "../canvas/constants.js";
import { resolveBundledChannelGatewayAuthBypassPaths } from "../channels/plugins/gateway-auth-bypass.js";
import { getRuntimeConfig } from "../config/io.js";
@@ -765,7 +766,11 @@ export function createGatewayHttpServer(opts: {
},
});
}
if (nodeCapability && isCanvasDocumentHttpPath(scopedRequestPath)) {
if (
nodeCapability &&
isCoreCanvasHostEnabled(configSnapshot) &&
isCanvasDocumentHttpPath(scopedRequestPath)
) {
requestStages.push({
name: "canvas-documents",
run: async () =>
+7 -1
View File
@@ -10,6 +10,7 @@ import type { AddressInfo } from "node:net";
import type { Duplex } from "node:stream";
import { WebSocketServer } from "ws";
import { resolveMcpAppSandboxPort } from "../agents/mcp-app-sandbox.js";
import { isCoreCanvasHostEnabled } from "../canvas/config.js";
import { resolveCanvasNodeCapability } from "../canvas/constants.js";
import type { CliDeps } from "../cli/deps.types.js";
import type { createSubsystemLogger } from "../logging/subsystem.js";
@@ -95,6 +96,7 @@ const loadGatewayPluginsHttpModule = async () => await import("./server/plugins-
/** Creates the HTTP/WebSocket runtime state and pinned plugin registries for one gateway start. */
export async function createGatewayRuntimeState(params: {
cfg: import("../config/config.js").OpenClawConfig;
getRuntimeConfig?: () => import("../config/config.js").OpenClawConfig;
bindHost: string;
port: number;
controlUiEnabled: boolean;
@@ -165,6 +167,7 @@ export async function createGatewayRuntimeState(params: {
releasePinnedPluginChannelRegistry();
}
try {
const loadRuntimeConfig = params.getRuntimeConfig ?? (() => params.cfg);
const resolvePluginRouteRegistry = () =>
params.getPluginRouteRegistry?.() ?? params.pluginRegistry;
const clients = new Set<GatewayWsClient>();
@@ -254,7 +257,9 @@ export async function createGatewayRuntimeState(params: {
return shouldEnforceGatewayAuthForPluginPath(resolvePluginRouteRegistry(), pathContext);
};
const resolvePluginNodeCapabilityRoute = (pathContext: PluginRoutePathContext) => {
const coreCanvasCapability = resolveCanvasNodeCapability(pathContext.candidates);
const coreCanvasCapability = isCoreCanvasHostEnabled(loadRuntimeConfig())
? resolveCanvasNodeCapability(pathContext.candidates)
: undefined;
if (coreCanvasCapability) {
return coreCanvasCapability;
}
@@ -309,6 +314,7 @@ export async function createGatewayRuntimeState(params: {
getResolvedAuth: params.getResolvedAuth,
rateLimiter: params.rateLimiter,
getReadiness: params.getReadiness,
getRuntimeConfig: loadRuntimeConfig,
isTerminalEnabled: params.isTerminalEnabled,
tlsOptions: params.gatewayTls?.enabled ? params.gatewayTls.tlsOptions : undefined,
});
+6 -1
View File
@@ -8,6 +8,7 @@ import {
} from "../agents/embedded-agent-runner/run-state.js";
import { clearSessionSuspensionTimers } from "../agents/session-suspension.js";
import { getTotalPendingReplies } from "../auto-reply/reply/dispatcher-registry.js";
import { isCoreCanvasHostEnabled } from "../canvas/config.js";
import { withCoreCanvasNodeCapability } from "../canvas/constants.js";
import {
getLoadedChannelPluginEntryById,
@@ -1200,6 +1201,7 @@ export async function startGatewayServer(
} = await startupTrace.measure("runtime.state", () =>
createGatewayRuntimeState({
cfg: cfgAtStart,
getRuntimeConfig,
bindHost,
port,
controlUiEnabled,
@@ -2039,7 +2041,10 @@ export async function startGatewayServer(
gatewayHost: bindHost ?? undefined,
pluginSurfaceScheme,
getPluginNodeCapabilities: () =>
withCoreCanvasNodeCapability(listPluginNodeCapabilities(pluginRegistry)),
withCoreCanvasNodeCapability(
listPluginNodeCapabilities(pluginRegistry),
isCoreCanvasHostEnabled(getRuntimeConfig()),
),
resolvedAuth,
getResolvedAuth,
getRequiredSharedGatewaySessionGeneration: () =>