* feat(gateway): add sessions.files.set workspace file write with hash CAS (#104698)
* feat(ui): CodeMirror file editor panel with CAS save + clickable tool-card paths (#104698)
* fix(gateway): issue the file CAS hash only for strict-UTF-8 text (#104698)
* docs(web): document the editable file detail panel (#104698)
* fix(gateway,ui): round-trip BOM and CRLF bytes through the file editor (#104698)
* fix(ui): keep mixed-line-ending files read-only in the file editor (#104698)
* fix(ui): re-gate editability when a conflict reload returns non-editable content (#104698)
* fix(gateway): reject NUL bytes in sessions.files.set replacement content (#104698)
* fix(gateway,ui): regenerate protocol bindings, fix prod types and lint for file editor (#104698)
* feat(dashboard): modular dashboard — workspace store, Workspaces tab, sandboxed custom widgets
Squashes openclaw/openclaw#101094 + #101097 + #101098 onto current main and
applies the maintainer review fixes to the backend control plane.
Co-authored-by: 100yenadmin <239388517+100yenadmin@users.noreply.github.com>
* fix(dashboard): UI review fixes — grid, error boundary, embed sandbox, locale
* fix(dashboard): make the CLI and agent broadcasts actually reachable
Three defects only a live run surfaces, all invisible to the unit suites:
- The plugin claimed the CLI command name `dashboard`, which core already owns
(it opens the Control UI). A plugin CLI group that overlaps a core command is
dropped at registration behind a `logger.debug`, so the entire CLI face was
unreachable while `cli.test.ts` kept passing against its own Commander
program. Renamed to `openclaw workspaces`, matching the tab it drives.
- The manifest never declared `activation.onCommands`, so the CLI root resolved
to no owning plugin even once the name was free.
- `dashboard.widget.approve` needs `operator.approvals`; the CLI asked for
`operator.write` on every call. It now requests the approvals scope only for
the approve call, matching `operator-approvals-client.ts`.
Also: agent tools resolved their broadcast from the plugin runtime's
gateway-request scope, an AsyncLocalStorage set only around gateway RPCs and
plugin HTTP routes. An agent turn started from a channel, cron, or heartbeat
therefore wrote the document without emitting `plugin.dashboard.changed`, so an
open Control UI never saw the edit — the feature's headline promise. The gateway
broadcast is server-lifetime, so the plugin now remembers it in a single slot and
agent tools fall back to it.
* docs(web): document dashboard workspaces, provenance, and the custom-widget sandbox
* fix(dashboard): agent-tool ergonomics + close two approval-boundary gaps
From a source-blind agent driving the dashboard_* tools with nothing but their
schemas, and from a Codex review of the hardening delta.
- dashboard_widget_update could never succeed. It passed its whole parameter
record to the patch reader, whose allowlist rejects the very `tab`/`id` keys
the tool's own schema marks required, so every call died on
"unexpected param: tab". Its test only ran Value.Check against the schema and
never executed the tool.
- dashboard_data_read surfaced an `rpc` binding as a thrown error, though its
description promised `binding_client_resolved`. It now returns that as a
result the model can act on.
- Valid widget kinds and the rpc allowlist were undiscoverable: a model saw only
"builtin:<name> or custom:<name>" and "Allowlisted gateway read method", then
brute-forced ~40 calls against errors that named no alternatives. Both schemas
and both validator errors now enumerate them, and the kind description says
what each builtin renders and which binding id it reads. widget_move documents
that grid and toTab are exclusive; widget_scaffold says an operator must
approve, because no agent tool can.
- workspace.replace could mint a pending registry entry for a name that was
never scaffolded. An operator could then approve a widget whose code did not
exist yet, and the agent could write it afterwards. Registry entries now come
from dashboard_widget_scaffold and nowhere else, and approve refuses a name
with no manifest on disk.
- dashboard.widget.approve answered with the whole workspace document, so a
connection holding only operator.approvals could read it through the approvals
door. It now returns the registry entry it changed.
* fix(dashboard): approval pins the code it approves
Codex review found the scaffold-before-approval gate still nameable rather than
binding: approve only proved that widget.json parsed, and the Control UI loaded a
hardcoded index.html rather than the manifest's entrypoint. An agent could
scaffold a widget, win approval on an innocuous or absent entrypoint, then write
the real payload afterwards — code appearing after the human said yes.
Approval now hashes every servable file in the widget directory and stores the
digests on the registry entry, refusing a manifest whose declared entrypoint is
missing. The asset route re-hashes each file it reads and 404s anything that does
not match, so a file edited or added after approval never reaches a browser. The
Control UI loads the manifest's entrypoint, which is the file that was hashed.
The content-type allowlist moves to manifest.ts so the set of files approval
hashes and the set the route can serve cannot drift apart.
Proof, against a running gateway: scaffold -> 404, approve -> 200, rewrite
index.html -> 404, add late.js -> 404.
* fix(dashboard): parse the approved manifest from the bytes that were hashed
Codex found a TOCTOU in the approval path: it loaded and validated widget.json,
then walked the directory again to compute the digests. An agent could swap
widget.json between the two reads, so the operator validated one entrypoint while
the digest froze — and the Control UI later mounted — a different one.
snapshotApprovedWidget now reads the widget directory once: it hashes every
servable file, parses the manifest out of the same widget.json bytes it hashed,
and requires the declared entrypoint to be among them.
Proof, against a running gateway: approve -> index.html 200; rewrite widget.json
to point at evil.html and drop evil.html in -> both 404.
* fix(dashboard): cap approval asset reads; bound the grid fallback search
Two findings from the fourth Codex pass.
Approval hashes agent-authored files that are untrusted until it runs, and read
each one into memory with no size check — dropping one huge .png into a scaffold
directory would stall or OOM the gateway during approve. Sizes are now checked
before the read, with a 2 MB per-file and 8 MB total cap.
nearestFreeSlot searched one band below the lowest occupied row, so a crowded
layout near the bottom could return y=500 as the closest free slot: a placement
the store rejects, which the UI applies optimistically and then snaps back. The
search now stops at the last row a widget of that height can legally occupy.
* fix(dashboard): refuse oversized widget assets before reading them
Approved widget files stay writable and the asset route is unauthenticated, so
swapping an approved small file for a very large one made every GET buffer the
whole file before the digest check rejected it. The route now refuses anything
past the same per-file cap approval enforces, on the stat it already performs.
* fix(dashboard): enforce widget approval boundaries
* docs(changelog): note modular dashboard workspaces
* fix(dashboard): enforce static custom-widget data boundary
* fix(dashboard): satisfy UI lint
* test(dashboard): avoid legacy proto access
* feat(dashboard): make plugin opt-in
* docs(dashboard): refresh workspaces map
* refactor(workspaces): standardize plugin naming
* fix(workspaces): make widget prompt sends idempotent
* docs(workspaces): fix internal path references
* test(workspaces): make prompt assertion lint-safe
* test(workspaces): type prompt request mock
* fix(workspaces): harden approval and binding boundaries
* test(workspaces): complete stale binding client mock
* fix(workspaces): harden widget file boundaries
* fix(workspaces): scope custom widget capabilities
* fix(workspaces): align approval provenance
* fix(workspaces): close branch contract gaps
* test(workspaces): complete builtin context fixtures
* fix(workspaces): aggregate overview usage
* chore(workspaces): defer release note
* chore(workspaces): refresh i18n metadata
---------
Co-authored-by: 100yenadmin <239388517+100yenadmin@users.noreply.github.com>
* fix(memory-wiki): strip fenced code and inline code before wikilink extraction
The wikilink extractor in extractWikiLinks previously scanned
the full markdown content including fenced code blocks and
inline code spans. Literal [[...]] syntax inside code regions
(e.g. bash test syntax, Scala generics like
Future[Option[User]]) produced false-positive broken-wikilink
lint warnings.
Add FENCED_CODE_BLOCK_PATTERN and INLINE_CODE_PATTERN and
strip those regions from the searchable text before running
the Obsidian and Markdown link regexes. Five regression tests
cover backtick-fenced, tilde-fenced, 6-backtick-fenced, and
inline code scenarios plus a full vault lint round-trip.
Fixes#97945
* fix(memory-wiki): accept longer closing fence in code block stripping
The FENCED_CODE_BLOCK_PATTERN used a regex backreference (\1)
that required the closing fence to be exactly identical to the
opening fence. CommonMark allows the closing fence to be the
same character type and at least as long — e.g. ``` opening
with ```` closing is valid. Switch to a function-based
replacement that compares fence character type and length.
Add regression test for the longer-closing-fence case per
ClawSweeper review feedback (#98095).
* style(memory-wiki): add braces to if-body in fence replacement callback
Fixes ESLint curly rule violation at line 403.
* fix(memory-wiki): replace fence regex with line scanner for wikilink extraction
Replace FENCED_CODE_BLOCK_RE regex/callback with a line-by-line scanner
that keeps searching past invalid fence-looking lines (e.g. a shorter
``` line inside a longer `````` block) until a valid closing
fence of the same character type and at least as long is found.
Also handles tilde fences and longer closing fences per CommonMark spec.
🦞 diamond lobster: L2 evidence (5 real function-call scenarios, all passing)
Ref. https://github.com/openclaw/openclaw/pull/98095
* fix(memory-wiki): use CommonMark code masking
---------
Co-authored-by: Vincent Koc <25068+vincentkoc@users.noreply.github.com>
* feat(logbook): automatic work journal plugin with a plugin-contributed Control UI tab
Squash of PR #99930 work for rebase onto the Control UI route refactor:
- extensions/logbook: Dayflow-style capture -> observations -> timeline cards
pipeline with SQLite store, node capture commands, standup/ask, retention
- plugin SDK/gateway seam: surface "tab" Control UI descriptors projected
into hello-ok controlUiTabs (scope-filtered, deterministic order)
- Control UI: dynamic plugin tabs with bundled Logbook view
- docs, tests, labeler wiring
* feat(ui): port plugin tabs and Logbook to the route-owned Control UI architecture
- shared /plugin route carries the tab id in the query (?id=<tab>), matching
the router's exact-path contract
- openclaw-plugin-page renders bundled views (Logbook), sandboxed plugin
frames (descriptor path), or the unavailable card
- sidebar renders hello controlUiTabs after each group's static routes
- Logbook view/controller live under ui/src/pages/plugin/
* fix(ui): namespace plugin tabs by pluginId to prevent cross-plugin tab id collisions
* fix(logbook): prefer app capture nodes and rotate off failing nodes
* fix(plugins): reject protocol-relative Control UI tab paths
* fix(logbook): harden automatic journal
* docs(changelog): remove maintainer self-credit
* chore(ui): refresh locale metadata after rebase
* fix(logbook): preserve analysis window boundaries
* fix(logbook): align status privacy and timezone
* fix(ui): stop hidden plugin tab polling
* refactor: extract reusable AI runtime package
* refactor: complete AI provider relocation
* refactor: keep llm core internal
* refactor(ai): make @openclaw/ai self-contained with host policy ports
Move pure transport helpers (tool projections, strict-schema normalization,
prompt-cache boundary, stream guards, anthropic/openai compat, request
activity) from src into packages/ai; move utf16-slice into
normalization-core. Inject host policy (guarded fetch, redaction,
strict-tool defaults, diagnostics logging) through AiTransportHost with
inert library defaults installed by src/llm/stream.ts. Narrow the public
barrel to instance-scoped createApiRegistry/createLlmRuntime; the
process-default runtime moves behind internal/ and
registerBuiltInApiProviders takes an explicit registry. Delete the
src/llm/api-registry re-export facade.
* fix(ai): teach node, jiti, and vite resolvers the @openclaw/ai and utf16-slice subpaths
The workspace alias tables in root-alias.cjs, plugin-sdk-native-resolver,
sdk-alias, the shared vitest config, and the Control UI vite config only
knew @openclaw/llm-core; Node-side plugin loading resolved @openclaw/ai
through the pnpm symlink to the unbuilt dist (checks-node-compact CI
failures), and the Control UI build broke on the new
normalization-core/utf16-slice subpath.
* chore(ui): drop leftover service-worker debug logging
* build(release): ship @openclaw/ai with its own shrinkwrap and honest dependency set
packages/ai declares only its six real runtime deps (kysely, chalk, json5,
tslog, zod, fs-safe, and proxyline were never imported); orphaned root deps
removed. generate-npm-shrinkwrap now treats publishable packages/* like
publishable plugins so the AI tarball pins its transitive tree even though
workspace deps are omitted from the root shrinkwrap. knip learns the
package entry points; the tsdown dts neverBundle option moves to its
documented deps.dts home; the README documents the no-semver internal/*
contract and host ports.
* docs(ai): add minimal external-consumer example app
examples/ai-chat consumes only the public @openclaw/ai surface (built dist
via the workspace link): isolated runtime, built-in provider registration,
one streamed completion. Supports Anthropic/OpenAI via env keys and a
keyless local Ollama target; live-verified against Ollama.
* docs(ai): document the @openclaw/ai package and workspace shrinkwrap boundary
* chore(check): include examples/ in duplicate-scan targets
* fix: emit normalization package subpaths
* fix: complete AI package boundary artifacts
* fix: align AI package boundary contracts
* fix(ci): stabilize package release contracts
* test: align documentation contract checks
* test: keep cron docs guard aligned
* test: align restored docs contract guards
* test: follow upstream docs contracts
* docs: drop superseded talk wording
Refactor the Control UI around route-owned page lifecycle and state while preserving existing behavior and design.
Prepared head SHA: bd51b6fa76
Co-authored-by: Shakker <165377636+shakkernerd@users.noreply.github.com>
Reviewed-by: @shakkernerd
* feat(imessage): add native poll action
Wire the imsg CLI 'poll send' bridge command into the iMessage channel
message-tool action surface, mirroring the existing Discord poll action.
Adds the 'poll' action (gate: polls), a sendPoll runtime, selector-gated
capability advertisement (pollPayloadMessage), config type + zod schema,
regenerated channel metadata, docs, and tests.
* feat(imessage): read inbound polls, vote, and suppress vote echo
Builds on the native poll send action:
- Inbound polls now render to the agent as a readable line (question +
numbered options + tallies) instead of the raw 0xFFFD balloon placeholder,
so a received poll no longer reads as an empty message.
- New `poll-vote` action casts a vote via `imsg poll vote`, resolving a
1-based option index / text / UUID to the poll's option identifier.
- message_tool_only echo guard: the model tends to narrate its choice in a
text reply right after voting ("Blue."), which is redundant since the vote
shows on the poll. A new `poll_vote_echo` suppression reason (alongside
inbound_metadata_echo / internal_runtime_context_echo) drops a send/reply
that exactly restates the just-cast vote, using the option label imsg
returns. Extra content passes through untouched.
* fix(imessage): gate poll-vote on imsg poll.vote rpc capability
Released imsg carries the pollPayloadMessage selector (poll create) but
predates the poll.vote CLI/RPC. Gating both poll and poll-vote on that
selector alone would advertise a vote action the released CLI rejects.
Gate poll-vote additionally on the advertised poll.vote rpc method so this
plugin can ship ahead of the imsg release.
* fix(imessage): enforce poll.vote capability at execution, not just discovery
Codex review flagged the discovery gate as bypassable: a caller that already
knows action=poll-vote skips describeMessageTool and reaches handleAction
directly. Add the same imessageRpcSupportsMethod(status, 'poll.vote') check in
the poll-vote execution path (after assertPrivateApiEnabled), so a direct
dispatch on released imsg fails closed with a clear message instead of an
opaque CLI rejection. Adds a negative handleAction test.
* fix(imessage): harden native poll support
* fix(message): validate targets before channel discovery
* fix(message): validate targets before channel discovery
---------
Co-authored-by: Omar Shahine <lobster@users.noreply.github.com>
Co-authored-by: Omar Shahine <10343873+omarshahine@users.noreply.github.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
* feat(openai): add GPT-5.6 series support
* docs: refresh map for GPT-5.6
* fix(openai): preserve GPT-5.6 thinking metadata
* fix(codex): sync managed app server version
* fix(codex): sync managed app server version
* fix(openai): account for GPT-5.6 cache writes
---------
Co-authored-by: Peter Steinberger <steipete@golden-gate.local>
* fix(acpx): consume acpx 0.11.1 model capability errors
* fix(acpx): refresh npm shrinkwrap for 0.11.1
* test: include workflow checks in tooling plan