mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
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:
committed by
GitHub
parent
2f045a73f4
commit
460e3e669c
@@ -1752,6 +1752,7 @@
|
||||
"web/index",
|
||||
"web/control-ui",
|
||||
"web/dashboard",
|
||||
"web/dashboard-architecture",
|
||||
"web/webchat",
|
||||
"web/tui",
|
||||
"web/lobster"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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" } };
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 () =>
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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: () =>
|
||||
|
||||
Reference in New Issue
Block a user