* docs: fix five broken cross-page anchor links
Five internal doc links pointed at anchors that do not exist on the target
page, so readers following them landed at the top of the page instead of the
referenced section.
- /gateway/cloud-workers#desktop -> #desktop-interactive
(heading is "## Desktop (interactive)"), from experimental-features and
the gateway protocol reference.
- /web/urls#special-documents-and-startup-modes ->
#other-special-documents-and-startup-modes
(heading is "## Other special documents and startup modes"), twice from
the Control UI page.
- /plugins/sdk-runtime#api-runtime-agent -> #runtime-namespaces
"api.runtime.agent" is an <Accordion> title, not a heading, so it emits no
anchor; the enclosing "## Runtime namespaces" heading is the real target.
Sibling links on all three target pages already use heading-derived anchors
(#coordinator-backed-crabbox, #route-table, #reusable-runtime-utilities),
and this was the only link in docs/** aiming at an Accordion title out of
1342 such titles.
Docs-only change; no runtime or config surface is touched.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* docs: restore the sdk-entrypoints deep link
Running the real Mintlify anchor audit (pnpm docs:check-links:anchors) showed
that /plugins/sdk-runtime#api-runtime-agent already resolves: Mintlify does
generate anchors from <Accordion title="..."> elements, so the original link
was correct.
The audit flags fragments it cannot resolve -- it reported both
cloud-workers#desktop and urls#special-documents-and-startup-modes on main --
and it never reported this one. Retargeting it to #runtime-namespaces would
have replaced a precise deep link with a coarser section link, so this restores
the original.
The remaining four link instances in this PR are confirmed broken by the same
audit: main reports 313 broken links across 45 files, this branch reports 309
across 42, with no newly broken links.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Node session hosting was capped at a hardcoded 2 concurrent worker sessions.
The default is now one worker slot per available CPU core (clamped to 1..1024),
and nodeHost.workerRuns.capacity overrides it explicitly. The capacity option
was already plumbed through the supervisor; this wires it from config and
deletes the hardcoded constant. Docs and config baselines regenerated.
* fix(dashboard): open widget links through the host instead of sandbox popups
Widget frames granted allow-popups/allow-popups-to-escape-sandbox to every
rendered widget, including grantState "none" widgets that never reach an
operator approval card. CSP has no navigate-to, so a popup bypassed the
connect-src 'none' containment those widgets otherwise run under. Scripted
window.open was blocked only by the document guard's open lock, which is
emitted solely when blockDescendantFrames is set, so two unrelated knobs had
to agree for the boundary to hold.
Restore both sandbox strings and route user-clicked links through the existing
ticket-bound bridge: a trusted click on a ticketed board widget's http(s)
target="_blank" anchor forwards to the Control UI, which opens the tab with
noopener,noreferrer. Links keep working for every rendered widget, the host
owns the scheme filter instead of trusting widget-authored rel attributes, and
scripted popups are impossible by construction rather than by coincidence.
Also collapses the unreachable controlUiBaseUrl branch and corrects both docs
pages, including the claim that links opened for every rendered widget, which
was never true for MCP-App board widgets.
Follow-up to #127315; refs #127314.
* fix(dashboard): open widget links through the Control UI external-url owner
The widget bridge opened links with a local window.open helper, which the
lint:ui:no-raw-window-open boundary guard rejects. Delegate to
openExternalUrlSafe(), the Control UI owner for external navigation: it
applies noopener/noreferrer, detaches the opener handle, and keeps one
canonical open path. The bridge keeps a narrower absolute-http(s) gate,
since widget-supplied links must not reach blob: URLs that the shared
external-link policy permits.
* fix(dashboard): route middle-button widget links and honor canceled clicks
Address ClawSweeper review on #128125.
The wrapper only handled primary click, so with popup permission removed from
both sandbox layers a middle-button activation silently did nothing. It also
listened on capture, so a widget handler's preventDefault could not cancel the
link the wrapper then opened itself.
Match the Control UI activation contract in ui/src/app/native-link-routing.ts:
listen for click and auxclick, gate on !defaultPrevented && !shiftKey &&
!altKey && (click+button 0 || auxclick+button 1), and listen on bubble so
widget cancellation stays effective. A middle click now routes for any widget
link, not just target=_blank, since middle-click always means new tab and
leaving plain links unrouted would keep the same silent-no-op class.
Move the wrapped-document test into src/canvas/wrap.test.ts: it exercises
buildWidgetDocument, so it belongs with its owner, and the added assertions
would otherwise push widget-tool.test.ts past the max-lines cap that AGENTS.md
forbids suppressing.
* fix(scripts): bound tsgo runs with the managed-command watchdog
run-tsgo bypassed the repo's managed-command seam and called spawnSync
directly, so a wedged tsgo blocked its caller indefinitely: no timeout, no
process-group cleanup, and no SIGKILL escalation.
Observed in the wild as a tsgo holding 2.85 GB for 90+ minutes on 41s of
total CPU with RSS frozen to the byte, ignoring SIGTERM, with its wrapper
reparented to init. Because shouldReclaimLock() treats a live PID as a valid
lock owner, that orphan also held the heavy-check lock until every other
invocation hit the 10-minute lock timeout and threw.
Route the run through runManagedCommand, which already owns process-group
termination and SIGKILL escalation on timeout, and bound it with
OPENCLAW_TSGO_TIMEOUT_MS (default 45m) through the shared readPositiveEnvInt
helper, mirroring OPENCLAW_CLI_STARTUP_BUILD_TIMEOUT_MS in
ensure-cli-startup-build.mts.
* fix(scripts): saturate the tsgo watchdog at Node's timer ceiling
An OPENCLAW_TSGO_TIMEOUT_MS above 2147483647 reached setTimeout unchanged,
where Node collapses it to a 1ms delay, so raising the override killed
healthy typechecks immediately instead of loosening the bound.
* fix(scripts): make the tsgo watchdog opt-in and stop the harness leaking
ClawSweeper review on 6ba02c9d0a raised two findings.
[P1] The 45-minute default applied an unproven deadline to every tsgo
invocation. No supported duration contract covers every host and project, and
CI already bounds its own tsgo jobs at 15-20 minutes, so the default could only
ever fire outside CI where it was least validated. Drop it: an unset
OPENCLAW_TSGO_TIMEOUT_MS keeps the pre-existing unbounded wait, so no existing
run changes behavior, and operators opt in per host. Documented in
docs/help/testing.md beside the sibling Vitest watchdog.
[P2] The regression harness could leak its wedged child. The fake compiler
ignores SIGTERM by design, so a pre-fix or otherwise failing run left the tree
running after spawnSync gave up. Bound the fixture's loop as a backstop.
* fix(scripts): set the tsgo watchdog default from measured lane duration
ClawSweeper on c7a699ee82 reversed its earlier guidance: the opt-in default
adopted last iteration "deliberately preserves the indefinite tsgo hang that
this PR is meant to fix". Its objection was never that a default existed, only
that 45 minutes was unmeasured.
Measured instead of guessed: hosted tsgo lanes (check-test-types, and its core
stripes) complete in 1-2 minutes across recent successful main runs, against CI
job caps of 15-20 minutes. 30 minutes is 15-30x the observed duration, leaves
room for a far slower local host, and still bounds the 90-minute and multi-hour
wedges that motivated this PR. OPENCLAW_TSGO_TIMEOUT_MS remains the documented
override for hosts that need longer.
* test(scripts): reap the wedged fake tsgo tree on the harness outer timeout
ClawSweeper on 0d9f3604e8 flagged that the harness can still leave a detached
pre-fix process tree alive after its outer timeout. The bounded fixture loop
added earlier only capped the leak; it did not terminate the tree.
spawnSync's killSignal reaches the direct child only. runManagedCommand spawns
the compiler detached into its own process group, so the fake tsgo is a
grandchild that never receives that signal. The fixture now records its pid and
the harness reaps that group in a finally, with the bounded loop kept as a
last-resort backstop.
Verified: pid file written with the live pid, and killing that group terminates
the tree; focused suite 16/16 with no surviving fake-tsgo processes.
* fix(scripts): harden the tsgo watchdog after two-phase code review
Review fixes on top of the watchdog change, from one native pass and six cold
passes:
- A rejected OPENCLAW_TSGO_TIMEOUT_MS escaped main() as a raw module rejection.
It now reports one actionable line and exits 1. Strict validation was kept
rather than switching to coercion, so a typo cannot silently fall back to the
30-minute default.
- The rejection message named a numeric range while the parser enforces plain
decimal digits, so 1e5 and 007 were refused by a message saying they
qualified. It now names the real format and states that the watchdog cannot
be disabled.
- The timer ceiling is declared locally rather than imported from packages/.
A static import there resolves before the sparse-checkout guard runs, which
turned a clean sparse skip into ERR_MODULE_NOT_FOUND and flipped
check-changed's typecheck lane from exit 0 to exit 1.
- The wedge test asserted the kill message but not the outcome; it now captures
the wedged pid and asserts the process group is gone.
- Three near-duplicate "not killed" cases are table-driven.
- Doc bullet corrected: values ABOVE the ceiling saturate at it, and the
rejected-value list now includes non-decimal input.
Deferred follow-up, not fixed here: scripts/lib/tsx-cli-shim.mjs shares a
5000ms force-kill delay with managed-child-process, so Ctrl-C can still orphan
a wedged compiler about one run in three. Measured base 4/4 orphaned versus
4/10 here, so this change improves it; the fix is out of diff and shared with
four other wrappers.
* fix(scripts): close tsgo signal cleanup race
* fix(scripts): make tsgo watchdog opt-in
---------
Co-authored-by: ClawSweeper <steipete+clawsweeper@gmail.com>
`agents.defaults.modelPolicy.allow` seeded from a legacy model map kept
`claude-cli/*` refs that doctor never rewrote, while the model map migrated
to canonical `anthropic/*` keys — leaving no selectable Claude model after
upgrade. `openclaw doctor --fix` now canonicalizes explicit allowlists in
the same pass: legacy refs migrate to provider refs with their claude-cli
runtime policy preserved, and superseded legacy model-map keys are dropped.
Closes#124952
Co-authored-by: Jason O'Neal <jason.allen.oneal@gmail.com>
Co-authored-by: Ayaan Zaidi <hi@obviy.us>
With agents.defaults.heartbeat.every set to "0m", a background exec completion queued its system event and requested a targeted exec-event wake, but the shared unscheduled-wake policy only admitted immediate wakes, so the follow-up agent turn silently never ran. Admit the exact exec-event/event producer shape (session or agent target required) in one per-source predicate, dispatch wake timers outside the requesting attempt's transcript-writer context, and document that "0m" disables recurring cadence only.
Closes#62505
Co-authored-by: Jason O'Neal <jason.allen.oneal@gmail.com>
Co-authored-by: Ayaan Zaidi <hi@obviy.us>
* fix(heartbeat): keep background runs off the main lane
* docs(queue): describe heartbeat background lane
* fix(agents): forward heartbeat lane to embedded fallback
* test(agents): prove heartbeat and visible lane overlap
* test(agents): expose heartbeat lane in embedded harnesses
* refactor(agents): keep heartbeat lane choice at fallback boundary
Signed-off-by: sallyom <somalley@redhat.com>
---------
Signed-off-by: sallyom <somalley@redhat.com>
Co-authored-by: sallyom <somalley@redhat.com>
Adds a generic plugin-declared selectable-context-window surface mirroring thinkingLevels: ModelCatalogEntry.contextWindows + contextWindowDefault through catalog normalization and the gateway protocol, session validation on sessions.create/patch, and a 200K/1M switch inside the Control UI model picker for Claude CLI 5-series models. The Anthropic plugin owns the option mapping: explicit 1m → `[1m]` argv suffix, 200k → bare id + CLAUDE_CODE_DISABLE_1M_CONTEXT=1, omitted → bare id (shipped default argv). Run budgets follow the selection on both CLI and native paths, so a 200K session gets a matching auto-compact window instead of a silent 1M budget.
Review fixes landed in this PR: run-owner prepared-fact plumbing so ordinary replies honor the selection; atomic catalog overlay merge and normalization for the options/default tuple; one-owner tuple reads in the picker; sessions.create key-presence patch semantics; native-run budget capping.
Feature direction and in-picker switch shape by @obviyus (maintainer review).
The Telegram Desktop proof previously built the candidate from the raw PR
head, so a head behind main failed for reasons main already fixed
(observed on #127770: 59 commits behind, hitting the Unknown-model defect
fixed by #127952). GitHub's cached PR test merge cannot be the candidate
either: it was observed 50 commits / 12 hours stale and never refreshed.
The workflow now resolves the live refs/heads/main tip and merge base at
dispatch, requires a main-targeting PR, and builds a deterministic local
merge (merge-tree --merge-base + commit-tree with pinned identity/date) as
the candidate; conflicts fail with a direct rebase message and there is no
fallback to the raw head. Lane labels and docs now say the candidate is
the PR merged onto main.
* refactor(mantis): reuse authorized desktop captures
* fix(mantis): budget desktop authorization failures
* chore(mantis): bound desktop proof retries
* fix(e2e): drop unused recorder failure fact type export
* fix(ci): route Mantis desktop teardown through the recorder wrapper
Cleanup invoked the internal recorder executable as mantis-sut, which is
deliberately kept out of the docker group and cannot read the
recorder-owned session file; teardown therefore failed and blocked
safe_to_release. The cleanup step already runs as the recorder user, so
call the public wrapper whose exec shim cds into the session root.
* fix(e2e): make recorder failure fact lane-readable; document v2 lifecycle
The Mantis workflow runs the recorder as the desktop user while the lane
reads the authorization-failure fact as mantis-sut; 0600 made that read
fail EACCES and silently disabled the two-attempt retry budget. Write the
fact 0644 — the 0770 attempt directory bounds visibility.
Update the mantis doc's recorder section for the v2 session lifecycle:
required --session handle with healthy-session reuse, capture-only stop,
and teardown owning authorization termination and lease release.
Preserve opaque source strings across tool-result replay while retaining masking for direct .env reads, registered or recognizable secrets, structured sensitive fields, and payment data.
Prevent ClawHub skill updates from silently replacing locally modified files. Verify recorded install digests at the rollback boundary, restore on mismatch, and require explicit --force for unverifiable legacy installs.
Co-authored-by: yetval <yetvald@gmail.com>
Co-authored-by: Ayaan Zaidi <hi@obviy.us>
* fix(release): preserve validation plan across reruns
* test(release): align rerun plan assertions
* refactor(release): use canonical plan cache action
* style(test): format release plan cache assertion
* feat(ui): add cpu/memory graphs to system busyness overlay
* refactor(ui): redesign busyness overlay vitals as sparkline stat tiles
Merge the duplicated sparkline row and numeric tile grid into three vital
tiles (CPU, memory, event-loop delay) with gradient area charts, a live
pulse dot, pointer scrubbing with value+age readout, and a danger tint
driven by the gateway's own event-loop degradation reasons. Memory
auto-ranges its baseline so RSS trends stay visible.
When two config writes raced, the loser printed a bare "config changed
since last load" and exited 1 — no statement that its write was abandoned
and no hint that re-running works. The gateway sibling already says
"re-run config.get and retry".
Format ConfigMutationConflictError at handleConfigMutationError, the single
CLI boundary, so the low-level throw sites keep serving gateway and
programmatic callers unchanged. Dry-run JSON now reports kind "conflict"
instead of mislabelling a concurrency conflict as a schema error.
Concurrency control itself is unchanged: baseHash pinning and the retry
loop stay exactly as they were.
The workflow ran a Codex agent over the full test suite and pushed
`test: optimize slow tests` straight to `main` under `contents: write`, with no
pull request and no human review. Its gates were a path allowlist, a
no-add/delete/rename rule, a non-decreasing total test count, and
`pnpm check:changed` -- which covers changed lanes, not the full suite. Test
optimization is exactly the class of change where a plausible edit can weaken
coverage without moving the test count, so unattended landing is the wrong
trade. Autonomous commits to `main` are not something this repo wants.
It had also been inert since well before this. The daily-cadence gate excluded
prior runs with `select(.status != "cancelled")`, but a finished cancelled run
reports `status: "completed"` with `conclusion: "cancelled"` -- verified against
run 32506655531, which that filter counts as a prior run. Its `concurrency`
block sets `cancel-in-progress: false`, so main's push rate produced dozens of
cancelled runs per hour and every trigger skipped, reporting green after ~2
minutes of doing nothing. No `test: optimize slow tests` commit has ever landed
on `main`.
`pnpm test:perf:groups` and the rest of the performance tooling it drove stay;
they are useful by hand and documented in docs/reference/test.md.
Repository secret OPENCLAW_TEST_PERF_AGENT_OPENAI_API_KEY now has no consumer
and can be deleted.
Keep plugin-originated text and media bound to the active host-selected route and revoke authority at turn closure.
Fence Gateway-owned channels until a server-verifiable authority and media-policy contract exists.
Co-authored-by: Ayaan Zaidi <hi@obviy.us>
* fix(agents): remove a deleted agent's cron jobs on the offline delete path
Follow-up to #127037, which fixed the exec-approvals half of the same gap and
named this one explicitly.
`agents delete` tries the Gateway first and falls back to a local path. The
Gateway handler nests two transactional cleanups around the roster commit --
cron wrapping approvals wrapping the config write. After #127037 the offline
path did the inner one; it still skipped cron. So deleting an agent without a
Gateway left its scheduled jobs enabled:
$ openclaw agents delete cronprobe --force
Deleted agent: cronprobe <- no mention of cron
$ sqlite3 <state>/state/openclaw.sqlite "select job_id, name, agent_id, enabled from cron_jobs"
975cb750-... | cronprobe-job | cronprobe | 1
$ openclaw cron list
cronprobe-job every 1h Next: in 59m idle
To be accurate about severity: this is not silent. Each firing records
`error: "cron job agent is unavailable: cronprobe"` and `cron list` flips to
`error`. The defect is that the job keeps its schedule forever, and that
recreating an agent with the same id points it at the new agent.
The fallback had collapsed two different reasons into one `null` return, which
is what made the fix look unsafe at first: credential failures happen *before*
transport, so a live scheduler may still own the cron store, while an
unreachable Gateway means nothing else is holding it. `maybeDeleteAgentThroughGateway`
now returns a discriminated union, and only the unreachable branch mutates the
store directly. The credentials branch commits the roster, warns, and sets
`cronCleanupSkipped: true` in JSON.
The local `CronService` construction already existed inside
`local-request-context.ts`; it moves to `src/cron/local-service.ts` and both
callers share it rather than growing a second cron mutation path. That
extraction also switches the default-owner resolver from
`tryResolveLegacyCompatibilityAgentId` to `tryResolveAmbientOwnerAgentId`, which
is a superset -- it honors an explicitly configured
`agents.defaults.systemAgent.agentId` and otherwise falls back to exactly the
previous function. Live testing showed agentless memory-dreaming jobs need it to
load under explicit agent ownership.
Production +89/-62.
* test(agents): split the delete suite so the new cron coverage stays under the cap
The 40-line cron regression test added in the previous commit pushed
`src/commands/agents.delete.test.ts` to 1018 code lines, over the 1000 cap, and
`check-lint-core-3` went red. Repo policy forbids a `max-lines` suppression.
Unlike the earlier `cron/view.test.ts` split there was no describe-level seam --
23 flat tests in a single describe -- so the split follows subject instead. The
seven workspace-lifecycle tests (trashing, sharing, overlap, symlink reachability,
workspace-state cleanup) move to `agents.delete.workspace.test.ts`.
`vi.mock` and `vi.hoisted` are per-file and cannot be imported, so the mock
preamble and the shared `beforeEach` are declared in both files; the helper block
above them is unchanged in each. Each file then imports only what it uses, which
is why the import lists differ.
Trimming to a hair under the cap by moving only the new test was possible and
rejected: it would have left the file at ~978 code lines, back at the cap within
a couple of changes. This leaves 749 and 603 physical lines.
No test content changed: 27 passed before, 27 after.
* feat(control-ui): stream live draft previews in the typing indicator
Multi-identity sessions now show what a teammate is typing, not just that
they are typing: the composer's per-keystroke session.typing sends carry a
bounded tail of the draft (optional preview field, 400 code points max),
the gateway throttle re-emits on changed payloads at 250ms (boolean-only
stays at 1s, trailing edge keeps the latest draft), and the transcript
renders a per-actor bubble with the live text plus a blinking caret.
Actors without preview data keep the three-dot bubble.
Previews are ephemeral presence: never persisted, never part of the
session transcript or model context, excluded from aria-live regions, and
gated by the existing >=2-live-viewers, sharing-role, and incognito
checks. No new config surface.
* chore(protocol): regenerate Swift gateway models for typing preview
* fix(gateway): aggregate typing previews across same-actor connections
A boolean-only session.typing update from a second connection of the same
actor (another tab or device) erased their live draft preview, because
typing liveness aggregated per actor while the broadcast preview came only
from the latest request. Preview aggregation now lives with the connection
aggregation owner: updateTypingConnections tracks per-connection previews
and returns the newest non-empty preview among live connections, so the
broadcast keeps the active draft until its connection stops or expires.
Regression fails pre-fix (event lost its preview field).