* fix(compaction): scope the transcript byte fuse to the latest compaction window
readVisibleTranscriptStats only closed its window at a reset boundary, so once a session compacted, maxActiveTranscriptBytes measured the whole active path and every following turn re-compacted. The preflight fuse now measures the window the model actually sees (since the latest compaction or reset, plus its kept tail); history readers keep their reset-only scope.
* test(compaction): cover retained transcript accounting
## Worked on by
- @VACInc
Co-authored-by: VACInc <3279061+VACInc@users.noreply.github.com>
---------
Co-authored-by: VACInc <3279061+VACInc@users.noreply.github.com>
Co-authored-by: roboclaw-bot <309084314+roboclaw-bot@users.noreply.github.com>
* fix(workers): re-arm admission-deadline launches, terminalize dead-worker turns, defer debris cleanup
Three lifecycle repairs from the 50-worker campaign (#129979): the gateway
launch adapter re-arms a launch up to five times (exponential backoff with
jitter, caller-deadline capped, deterministic derived launch/turn ids so
adapter replays find the same journal rows) when the node journal proves the
child died on the admission deadline — credential/build rejections and
post-start failures stay terminal; stuck-turn recovery records a terminal
failure once a journal-terminal worker has no claim past a 30s cleanup grace
instead of skipping keep_lane forever; and startup recovery defers orphan
git cleanup for terminal placements into the first tracked background sweep
(50-placement fixture: 50 startup git scans to zero, ~556ms to ~5ms) while
pending-result recovery and ownership fences stay ahead of readiness.
* fix(workers): use the diagnosed admission-deadline message after #130435 rebase
WorkerConnection now formats the redacted last-failure diagnosis into the
deadline error itself, so the runtime's re-format (and its
lastConnectionFailure tracking) is redundant; errorText uses error.message.
* fix(workers): cap admission re-arms within the minted credential lifetime
Re-arms reused the credential minted before the first admission attempt,
so with five 120s windows plus backoff the final advertised retry could
start after the credential expiry and fail closed as credential-expired.
The launcher now threads credential.expiresAtMs through the tunnel launch
request, and the adapter re-arms only while the retried child still gets a
full admission window (WORKER_ADMISSION_DEADLINE_MS, now shared from the
connection contract) on the original credential; otherwise it returns the
terminal admission result. Expiry-boundary tests walk both sides.
At high completion concurrency, 12/50 turns failed with 'Cloud worker
finished, but its workspace result could not be reconciled' (#129979):
between a worker's manifest upload and its verification, a sibling
completion could trigger retention with a snapshot holding only the old
base manifest — the terminal worker no longer protected the workspace, so
cleanup deleted the just-uploaded manifest and verification died on ENOENT.
The node workspace runtime now pins the latest transferred manifest per
generation and includes it in every cleanup's reachable set (cleared with
its generation), and gateway retention snapshots treat unsettled results —
an active claim or a pending-result fence — as protection, which also
preserves it across restarts when node-local pins are gone. Capture errors
retain bounded redacted stderr so this failure class can never be
faceless again. No deadlines, protocol, or completion-semantics changes;
deterministic 1-turn and 50-turn reproductions fail pre-fix.
Lease complete mirror file operations through the workspace owner instead of locking only remote transport. Preserve successful writes across exec publication and share serialization across backend handles.
Proven with a real Docker red/green reproduction, 128-workflow mirror/remote stress matrix, exact inventories, failure recovery, 170 focused tests, and updated operator docs. Related: #127441; follow-up to #130031.
* feat: make model persistence scope explicit
* fix: keep unauthorized model directives as plain text
An unauthorized sender's inline directives are cleared to plain text before
they are acted on, but the persistent write target was derived from the
directives as they arrived. An unauthorized `/model <provider/model> -a` or
`-g` therefore reached the owner-authority error return instead of the
plain-text path every other directive takes.
Derive the target, the authority flag, and the persist flag after the
clearing. Authorized senders skip the clearing, so their behavior is
unchanged; the only closure that captures the target is invoked well after
the new declaration site.
Covers both persistent flags with a regression test that fails on the prior
head with "Agent and global model defaults require owner authority or
operator.admin scope." and passes here.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat(models): make model selection scope configurable
Preserve current model-selection defaults when the optional scope preference is unset. Add explicit session, agent, and global overrides without broadening config-write authority.
Co-authored-by: Marvinthebored <peter@lindsey.jp>
* test(models): simplify scope expectation fixtures
---------
Co-authored-by: Marvinthebored <peter@lindsey.jp>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
* feat(portals): expose portals to sessions on node-backed cloud workers
Worker-turn sessions placed on cloud workers (bundled Crabbox) can now open
portals. The portal tool joins the closed worker session-tool catalog and
executes on the Gateway, which mints a single-use ticket per proxied
connection; the enrolled node redeems it over a TLS-pinned WebSocket via the
new private worker.portal.stream.v1 command and splices to the worker's
loopback port. The desktop stream broker is generalized with discriminated
desktop/portal tickets, and shared node stream transport is extracted from
the desktop stream command. Portal availability is negotiated via a new
gateway hello capability plus the node's reconnect-scoped
workerHost.portalStream declaration, so old node bundles simply do not
receive the tool. Environment teardown and owner-epoch replacement close a
worker's portals; node disconnects destroy live streams while the listener
keeps serving the waiting page.
Live-proven on a Crabbox AWS worker: agent opened a portal over the worker
protocol, python3 -m http.server on the box served through the Gateway
portal URL with token auth intact.
* fix(portals): break portal proxy/service type cycle; regenerate Swift protocol models
* fix(portals): drop dead PortalTarget re-export; fix test import
* fix(portals): only tear down turn-created portals on post-open authority loss
ClawSweeper finding: a revoked turn's duplicate open could close the live
portal a still-authorized predecessor established, because service.open
returns the existing same-environment portal and the executor's error path
closed whatever id it received. portal.open now reports created-vs-reused;
the executor tears down only portals it created and closes its redundant
carrier handle whenever an existing portal is reused.
* feat(portals): fence portal reuse behind the caller's live-authority assert
Rank-up move from review: portal.open revalidates the caller inside the
serialized operation before a reuse can mutate a live portal's metadata,
mirroring close(assertCurrent). Regression test proves a lapsed caller
cannot rewrite a reused portal's title.
* test(portals): expect assertCurrent in worker portal open call
* refactor(gateway): extract worker request dispatch into its own module
worker-connection.ts crossed the 700-line ratchet after rebasing onto
current main; dispatchWorkerRequest and its rejection helper move to
worker-connection-dispatch.ts unchanged (434 + 329 lines).
Crabbox already ships a Daytona backend; add the profile example and
provider-specific notes (no crabbox login step, default snapshot ships
Node, default machine class) confirmed by a live lease-and-exec proof.
* fix(sandbox): isolate role-required sandboxes per guest, not per agent
Role-forced sandboxing (#129926) contained a guest against the Gateway host
but not against other guests. Sandbox identity stayed agent-scoped, so every
session on a shared agent converged on one scope key, container and
workspace; configured workspaceAccess "rw" additionally pinned the workspace
to the shared agent directory regardless of scope, and configured scope
"shared" used a literal shared container name.
Measured before the fix, two guests on one shared agent:
scope=agent(default) rw -> same scope key, same workspace
scope=agent(default) ro -> same scope key, same workspace
scope=session rw -> distinct keys but SAME workspace
scope=session ro -> isolated
So only one of four combinations isolated guests, and it was not the default.
The authorization layer promised "edit only your own sessions" while the
container layer handed every guest the same writable workspace.
Role-required sessions now derive their sandbox identity from the session
creator: the scope key is principal-qualified, so different guests never
share a container or workspace while one guest's own sessions still do (per
guest, not per session). Configured "rw" resolves to "ro" with an
operator-visible warning, configured "shared" resolves to an effective agent
scope, and a required session with no creator provenance fails closed before
provisioning. Sessions without the requirement keep today's behavior.
* chore: drop accidentally committed sandbox scope repro scratch file
* feat(secrets): agent-requested credentials the model never sees
The new main-session secrets tool lets the agent request a credential by
name: the human enters the value in a masked question card (Control UI,
/ask/<id> deep link, iOS/macOS/Android), and the gateway diverts the
answer straight into the shared secret store at question.resolve. The
record, broadcast, waitAnswer, tool result, transcript, and model context
only ever carry a synthetic stored marker.
- protocol: additive secretStore binding, secretStoreExisting replacement
metadata, and resolve-time secretStoreAllowedHosts (since 2026.8)
- gateway: store-bound question validation, admin-gated minting (blocks
questions-scope self-answer escalation past secrets.store.set), shared
redaction-first store write service reused by secrets.store.set
- tool: secrets request/list/delete; write-only by design, delete carries
verified agent runtime identity; channel delivery is link-only so chat
text is never captured as a secret
- Control UI: masked composer card with requester identity, store banner,
editable allowed hosts, replacement warning, retry-on-validation-error,
a standalone /ask/<id> page, and a named startup-JS baseline bump
- mobile: SecureField / password transformation for isSecret questions,
no answer echo in terminal summaries; new native string registered in
the locale-refresh inventory (generated artifacts stay workflow-owned)
- regression: claimed harness secret input stays out of session transcripts
Live-proven on an isolated dev gateway: real model turn, masked entry via
Playwright, value present only in secret_store_entries, absent from every
transcript, log, and the DOM.
* chore(protocol): regenerate protocol models and tool display
* fix(cli): read image string options through a typed helper
PR #129463 added four commander option narrowings in image.ts without
SAFETY coverage, leaving the assertion-safety ratchet red (21 > 17) for
every branch on current main. Replace the casts with a typeof-checked
read so the assertions are removed rather than annotated; each value is
still validated by its normalizer. SAFETY comments cannot work in this
file: the ratchet's raw scanner never rescans template tokens, so
comments after the first substitution template are unreadable to it.
* chore(protocol): refresh Swift models against current main
* chore(i18n): re-baseline the native inventory on current main
* docs(secrets): state the default-on tool policy and how to disable it
* fix(secrets): tell the model what the store actually does
The shipped tool description named the three actions and nothing else,
and no parameter carried a description. The model could not tell that
request blocks a human, that reason is shown to that human, what secret
and env select, or - the silent-failure case - that a secret stored with
no allowedHosts can never be substituted, so a successful request could
produce a permanently unusable credential. Move the description to the
presets module beside ask_user and document every parameter.
* refactor(agents): share one blocking-question lifecycle between tools
ask_user and secrets each carried their own registration, wait, and
cancel logic, and they had diverged: ask_user recovers an answer that
lands between its wait timeout and the cancel, while secrets discarded
it and reported no_answer even though the Gateway had already stored the
credential. One shared canceller and answer reader fixes that race for
both, folds the two divergent gateway-call types into one, and drops two
type assertions in favour of the canonical record guard (ask_user's
assertion baseline shrinks 11 -> 8).
Net +49 production lines: the shared module costs more than the
duplication it removes, and buys the correctness fix plus a single owner
for question lifecycle.
* fix(ui): keep the allowed-hosts field readable as an input
Main's composer restructure moved the free-text input styling into the
option-row context, so the store-request hosts field - which sits outside
a row - lost its border and read as static text. It is the one field the
operator is meant to review and edit before releasing a credential, so
give it its own border and focus ring.
* fix(secrets): close two credential-boundary holes in agent requests
Requests are now protected-secret only. list renders env values, so an
agent could request kind=env, watch a human type it into a masked box
under a no-visibility promise, then read it straight back; the tool text
even claimed values are never returned. Environment values stay operator
-set in Settings or the CLI, where they are agent-readable by design.
Store-bound questions are also bound to the run that requested them. The
resolve path authorized only the answering client, so a terminated or
replaced agent run could still have a credential written on its behalf -
the recorded runId was provenance, not closure-bound authority. Minting
now requires a runId and resolution revalidates that exact live run
immediately before the store write, with no await in between, failing
closed as QUESTION_REQUESTER_INACTIVE.
Both reported by ClawSweeper as P1 credential-boundary findings.
The schema already rejects a hand-declared openclaw or clawd profile
that sets neither. Stop telling operators those fields auto-assign.
Co-authored-by: Cursor <cursoragent@cursor.com>
* docs(plan): add worker hibernation design note
Evaluates dormant leases, checkpoint-based warm start, and serverless
backends for a worker-environment hibernation tier; proposes a plugin-only
phase 1 (Crabbox checkpoint fork warm images) with SDK/store phases gated
on maintainer acceptance.
* feat(crabbox): add opt-in profile warm images
* fix(crabbox): preserve warm image machine class
* refactor(crabbox): tighten warm-image seams after review
* fix(crabbox): budget warm-image capture for real snapshot round trips
The 60s command bound starved the scrub and checkpoint-create phases under
coordinator latency, silently degrading every teardown to cold-only.
Live-measured on AWS.
* docs(plan): record worker-hibernation acceptance and live proof
Phase 1 live-proven: local-container full loop (cold 101s vs warm fork 40s,
scrub boundary verified) and AWS cold provisioning with graceful degrade at
the coordinator admin gate; named upstream follow-ups.
* fix(crabbox): make warm-image lifecycle recovery-safe
Delete least-recently-used provider snapshots before image index admission.
Recover stale empty capture reservations without breaking single-flight.
Persist effective machine classes across restarts and revert heartbeat coupling.
Align the hibernation plan with the exact-class warm-image key policy.
* fix(gateway): fan out batched gmail hook pushes and bound gmail hook bodies
The built-in gmail hook preset rendered only messages[0] of a Pub/Sub push
that batches up to 100 messages, silently dropping the rest (#120277), and
the shared 256KiB hook body cap 413-wedged gog's history-rewind retry loop
on large batches (#120278).
Hook mappings gain an explicit forEach fan-out contract: one action per
payload array element, per-item replay identity derived from dispatch scope
so producer redeliveries converge instead of duplicating, and batch
responses bounded under gog's 10s hook client timeout. gmail-path mappings
inherit a body bound derived from the provisioned producer contract
(hooks.gmail.maxBytes x gog's 100-message batch).
* fix(gateway): guard null transform action before fan-out merge
* fix(gateway): admit fan-out hook items in the background
Live proof against the real gog producer exposed a livelock: eight
concurrent cold isolated-run workspace preps take ~15s, exceeding the 15s
admission deadline, so every fan-out item was canceled, nothing reached the
replay cache, and each Pub/Sub redelivery repeated the same cold burst
forever. Fan-out items now admit without the start deadline; the producer's
redelivery plus per-item replay identity own retry semantics, and direct
/hooks/agent dispatches keep the bounded 15s admission contract.
* test(gateway): adapt reload-handler hook assertions to actions[] shape
* test(gateway): split hook fan-out mapping tests into their own file
hooks-mapping.test.ts crossed the max-lines lint cap after the fan-out
coverage landed; the fan-out describe moves to hooks-mapping.fanout.test.ts.
* fix(gateway): harden fan-out identity, mixed action kinds, and body ceiling
Addresses the ClawSweeper P1 findings on #130002: within-batch identical
rendered items now get occurrence-numbered replay identities (both run,
redeliveries replay both); per-item transforms emitting mixed wake/agent
kinds dispatch every action through one unified loop instead of silently
dropping the non-first kind; and the gmail-derived body allowance is
capped at 32 MiB so an operator-set hooks.gmail.maxBytes cannot amplify
into an unbounded in-memory request buffer.
* feat(cron): link chat notifications to Control UI run inspection
When gateway.publicOrigin is configured and the Control UI is enabled,
channel-delivered automation and task notifications append an
'Inspect: <url>' line so operators can click straight to the run record:
- cron failure alerts and command/script completion announcements link to
/automations?job=<id>&run=cron:<id>:<startedAt> (appended after summary
redaction so the redactor cannot strip the link)
- isolated agent run completions link to the run's exact session route
(appended only after silent/NO_REPLY suppression, immutably, so a
suppressed run cannot become a visible announcement)
- direct-channel task terminal notifications link to the task's child
session; session-queued events stay link-free
- the automations page adopts ?job=&run= deep links (loader search pass-
through, one-shot adoption after jobs load, History tab + run highlight)
No new config surface: the URL derives from the existing
gateway.publicOrigin + gateway.controlUi owner in control-ui-link-base.
* fix(cron): match linked runs by execution id and dedupe delivery runtime type
Live verification caught that ledger run ids are public run ids (receipt
UUIDs, manual:<...>) while notifications embed the execution id
(cron:<jobId>:<startedAtMs>), so the linked-run highlight never matched:
the automations page now resolves the execution id against the entry's
recorded runAtMs, with regression tests for both id spaces.
Also: move the isolated-delivery inspection-link append into the payload
normalization owner (keeps delivery-dispatch under the max-lines limit),
and replace the duplicate TaskRegistryDeliveryRuntime declaration in
task-registry.test-support with a type-only import of the canonical type
(the duplicate hid the new optional resolver from the tsgo test shard).
* fix(gateway): permission modes work on rootless sessions via workspace default root
Session permission modes hard-required a recorded sessionRoot, which only the
sessions.create RPC ever records. Channel sessions, pre-#124909 rows, and
exec-node sessions are rootless, so runs failed closed with 'session permission
mode requires a recorded session root' (rows poisoned before the #128149 guards
were permanently bricked) and operators could not set a mode on channel
sessions at all.
The mode semantics are root-free; every containment consumer already falls
back to the workspace, and the remote worker launcher already derives its
containment root from placement.remoteWorkspaceDir. Make that the canonical
rule: a recorded sessionRoot pins worktree/explicit-cwd boundaries, and a
rootless session's boundary is the agent's canonical workspace resolved where
the run is prepared.
- embedded runner + compaction: default policy root to realpath(workspace)
instead of throwing/dropping the mode; bricked rows self-heal
- gateway: delete the patch/create/reset rejection guards and
session-permission-policy.ts entirely
- codex plugin: helpers take a required defaultRoot (agent workspace fact,
never a requested thread cwd); delete the four root throws; sandbox context
for conversation bindings now derives from the agent workspace
- ui: drop the rootless-rejection surface from the permission picker
- docs: permission-modes boundary contract updated
* fix(agents): split workspace path identity out of the kysely state store
Heals main: #116650 made memory-host-sdk/dreaming.ts import
resolveWorkspaceStateIdentity from workspace-state-store, statically pulling
kysely into the memory-core doctor-contract closure and failing the
doctor-contract-closure-guard lane on origin/main. The identity helpers are
pure path/crypto code; move them to workspace-state-identity.ts and repoint
all importers (dreaming, event-store, onboarding recommendations, workspace
migrations, legacy state).
* fix(ui): drop the unused permission picker sessionRoot prop and dead i18n keys
check-prod-types caught the New Session dialog still passing the removed
sessionRoot picker prop; the prop and the permissionControls.rootLabel /
sessionRoot strings have had no consumers since #124301.
* fix(codex): resolve rootless permission boundaries from agent config, not bind cwd
ClawSweeper P1: /codex bind --cwd stores the requested path as the binding
workspaceDir, which the previous commit then trusted as defaultRoot — letting
a bound thread's requested cwd become (and widen) the rootless permission
boundary. resolveConversationAppServerRuntime now owns the fact: it resolves
the selected agent's canonical workspace from config (falling back to the
plugin's configured default workspace), and the caller-supplied
agentWorkspaceDir parameter chain is deleted so no call site can pass a
tainted value. Recovery of recorded roots is unchanged (pinned roots stay
pinned); the moved-history test now proves the bind-cwd clamp lands on the
agent workspace.
* feat(secrets): add opt-in traffic allowlist to the secret egress proxy
Add secrets.egressProxy.allowedHosts: when present, the egress proxy
refuses non-sentinel requests and CONNECT tunnels to hosts outside the
effective allowed set (configured list, hosts bound to the run's
registered secrets, and bypassHosts) with a typed host-not-allowed
refusal naming the remediation. Empty array is lockdown; omitting the
key keeps the previous unrestricted behavior. Sentinel substitution,
per-secret destination binding, proxy auth, and bypass tunnels are
unchanged; the allowlist is defense in depth for cooperating traffic
since bypass-surviving sentinels remain the primary defense.
* fix(secrets): validate egress proxy allowlist hostnames at the config boundary
Extract the exact-host contract from the secret store into a canonical
normalizeExactAllowedHost helper and validate secrets.egressProxy
allowedHosts and bypassHosts entries through it. Schemes, ports,
wildcards, and malformed hostnames are now rejected when config is
accepted instead of throwing during egress-proxy startup, which the
runtime normalizer did for both keys before this change.
* fix(doctor): repair unusable secret egress proxy host entries
Tightening the egress-proxy host schema can invalidate an existing
config: a disabled proxy with a malformed bypassHosts entry loaded fine
before and now fails validation, which exits the Gateway with code 78.
Add the matching doctor --fix migration. Invalid entries never
functioned (the proxy's own hostname normalizer threw on them at
startup), so they are dropped rather than canonicalized; enabled and
valid entries are preserved untouched.
* fix(sandbox): keep environment values out of process arguments
Route Docker and Podman environment values through private env files, and stage SSH and OpenShell environments separately from command argv. Container env-file values are now explicitly single-line.
* fix(sandbox): always clean staged SSH environment
Remove staged SSH and OpenShell exec scripts during finalization even when the local SSH process exits normally with a non-shell error code.
* test(openshell): align remote seed and staged exec fixtures
* fix(sandbox): validate container environment transport early
Reject Docker and Podman environment entries that secure env files cannot represent during config validation, with manual remediation guidance while preserving SSH and OpenShell multiline values.
* fix(config): keep sandbox env validation cold
Share the dependency-light scope and environment merge contract between runtime resolution and Docker/Podman config validation so isolated config and Doctor flows do not load runtime state.
One shared agent could not give different people different execution
isolation: sandbox selection saw only the agent's mode and the session key,
never who created the session. Teams that share an agent had to choose
between sandboxing everyone or no one.
Add a closed, optional role axis sandbox?: "inherit" | "required"
(default "inherit", so existing deployments are unchanged). The Gateway
resolves it from the authenticated session creator and stamps it write-once
onto genuinely new session entries; the agents layer reads that immutable
fact and forces a sandbox even when the agent config says mode: "off".
Anchoring on the creator keeps a session's isolation stable for its lifetime:
a maintainer replying inside a guest session cannot un-sandbox it, and a
caller whose role requires a sandbox is refused inside an unstamped session
rather than silently inheriting host execution. Reset, rewind, rollover and
fast-path all carry the requirement forward so a session cannot be reset out
of its sandbox.
Because exec injects the agent's managed GitHub identity only for gateway-host
execution and blanks GH_TOKEN/GITHUB_TOKEN otherwise, a forced-sandbox session
also loses access to the shared agent's credentials — the property that makes
one shared agent safe for untrusted guests.
Session entries are JSON; no SQLite schema, schema-version, or protocol change.
Docs also correct the sandbox backend list, which omitted the first-class
podman backend.
The remote-mode seed obligation lived only in process memory
(remoteSeedPending), so a gateway restart between sandbox create and the
first exec adopted the sandbox via 'sandbox get' and silently skipped the
one-time seed, leaving an empty remote-canonical workspace with no visible
failure.
Adopted remote sandboxes now probe the managed roots once per process at
the ensure boundary and arm the seed only when every root is missing or
empty, so recovery can never wipe operator content in an already-seeded
workspace (the seed guard shipped in v2026.7.1, so legacy seeded sandboxes
without any marker exist). The probe also heals a create that timed out
client-side after the sandbox actually came up.
Regression test simulates create -> restart -> first exec and fails
pre-fix; a sibling test locks the never-reseed-non-empty-roots invariant.
* fix(gateway): stop fabricating scope-upgrade requests on trusted-proxy reconnects
Same-key trusted-proxy reconnects whose auto-approvable scopes are already
covered by the paired grant now narrow the session in place instead of
running the pairing request/approve machinery: no pending-store writes and
no false 'device access upgrade requested' / 'auto-approved' security-audit
warns on every Control UI page load. The upgrade audit warn moved into
requirePairing so it fires only when a pairing request is materialized.
Durable widening (broadened deviceAutoApprove.scopes) and key-mismatch
repairs keep the existing lanes.
* fix(gateway): revalidate the live paired grant in the reconnect fast path
Address ClawSweeper P1: the narrowing decision now rereads the
authoritative paired row as the last await before returning, so a
concurrent revoke, key replacement, or grant reduction fails closed into
the pairing lane instead of riding the pre-plan snapshot. Extract the
pairing approval-plan resolver into connect-pairing-approval-plan.ts to
keep the authorization owner under the 700-line lint boundary.
* fix(agents): enforce session permissions for shared terminal input
* chore: keep terminal release notes in pull request
* fix(agents): fence full terminal input to active run authority
* fix(gateway): refresh provider usage asynchronously
* fix(ui): report a stalled provider-usage refresh on Model Providers
The page observed the incomplete-usage marker but discarded the exhausted
outcome, so once the retry budget was spent it rendered ordinary provider
cards with no usage and no explanation — indistinguishable from providers
that report no usage at all. Keep the outcome and render the warning the
Usage page already owns, reusing usage.providerUsage.stalled rather than
minting a Model Providers key so no locale baseline churns.
A user-initiated refresh now restarts the retry budget. The notice tells the
operator to refresh, so the button has to hand back attempts to spend; only
the forced path resets it, or the budget could never exhaust.
Also fixes tsgo:core:test on the current head: createStore's inferred literal
had no usageStats, so the run-bookkeeping case could not stamp it, and
view.test.ts needed the new prop.
Closes the ClawSweeper P2 at model-providers-page.ts:169-175.
* fix(ui): keep the stalled usage notice when usage.status starts rejecting
loadModelProvidersData turned a rejected usage.status into providerUsage:
null, which the page read as a completed load. observe(false) then reset the
retry budget and cleared the stalled callout, so a permanently broken usage
endpoint rendered as ordinary cards with no usage and no explanation — the
same silent failure the callout was added to prevent. The reset also fired
mid-cycle: one incomplete response followed by one rejection restarted the
budget, so the notice could be deferred indefinitely.
Record the failure at its producer instead of inferring it downstream. A null
providerUsage also means "not loaded yet", and no caller can tell the two
apart, so load.ts now reports providerUsageFailed explicitly and the page
treats a failed read as unresolved rather than resolved-empty.
Found by a Codex review of 417d43b65d.
* revert(gateway): drop the opportunistic model-catalog fast path
It broke two chat.history tests on main — both assert the cold catalog loader
runs exactly once, and reading the prepared snapshot first means it never does.
checks-node-compact-small-10 was red for that reason.
The change was a separate-surface latency fix that this PR picked up in passing,
and the body already offered to split it. Dropping it is the honest resolution:
rewriting main's assertions to accommodate a drive-by optimization would trade
one concern's proof for another's convenience. optional-model-catalog.ts,
server-model-catalog-auth.ts and their test return to the merge-base.
This PR is now only the usage.status non-blocking contract and its clients.
* fix(usage): preserve incomplete retry state
* perf(ui): keep usage capability startup-neutral
* fix(ui): restore provider usage retry convergence
* fix(usage): restore retry and cache invariants
* fix(usage): stabilize provider convergence
* test(ui): exercise provider recovery path
* test(ui): remove stale usage route fixture field
* fix(macos): show provider usage errors
* fix(macos): bound usage retries per menu open
* fix(macos): end usage retries on menu close
---------
Co-authored-by: Josh Lehman <550978+jalehman@users.noreply.github.com>
Stop OpenClaw from copying or refreshing Claude CLI OAuth tokens.
Claude CLI now owns native login and refresh state; Doctor removes retired copies while preserving CLI routing.
Co-authored-by: Ayaan Zaidi <hi@obviy.us>
* fix(systemd): honor effective drop-in configuration
Read one coherent manager-loaded service snapshot while retaining the managed base definition for repairs. Track drop-in ownership, surface pending daemon reloads, and keep update and doctor rewrites from adopting operator-owned values.
Closes#128929
* fix(systemd): expose reload status in service snapshot
* test(systemd): cover Linux doctor ownership
CI jobs are not installs; unchecked they outnumber operators by orders of
magnitude and make version and platform counts meaningless. A configured
telemetry endpoint still reports, so update-path E2E lanes keep working.
* fix(gateway): bound live health aggregation
Keep verbose health within a Gateway-owned deadline, preserve healthy sibling results when channel probes stall, and document that the CLI timeout controls the Gateway connection.
Fixes#128890
* fix(gateway): retain health probe permits
Keep process-wide health capacity occupied until timed-out plugin work actually settles, and prove repeated refreshes cannot replace five stalled probes.
* fix(macos): honor user accent precedence in config snapshot and live-update chat window
The Control UI user accent (ui.prefs.accent) landed in #128432/#128577 with
precedence user accent -> operator ui.seamColor -> theme default, and the
gateway's talk.config payload already applies it. The macOS app had two gaps:
- ChannelsStore.applyUIConfig read raw ui.seamColor from the config.get
snapshot and clobbered the user accent set from talk.config depending on
arrival order. It now resolves ui.prefs.accent ?? ui.seamColor via a
testable helper mirroring the gateway precedence.
- The native chat window read AppStateStore.seamColorHex once at window
construction, so accent changes never live-updated. MacChatSurface now
reads the @Observable store in body, deleting the one-shot userAccent
plumbing.
Docs: configuration-reference.md documents the precedence for native-app
chrome. Regression test fails pre-fix (snapshot returned the operator seam
color instead of the user accent).
* fix(macos): refresh config from gateway config.changed events
Addresses the review finding that no macOS consumer turned the gateway's
hash-only config.changed broadcast into refreshed shared state, so a
Control UI accent change never reached an open native chat window while
the app ran. ChannelsStore now subscribes to gateway pushes and re-fetches
config.get on config.changed, reconnect snapshots, and sequence gaps.
The refresh applies non-force so an in-progress local settings draft wins
(the gateway rejects stale-hash writes anyway). The in-flight reload queue
gains a closed pending level (none/refresh/force) so a refresh arriving
during a load is coalesced instead of dropped, and a requeued refresh
cannot clobber a dirty draft the way the old boolean force-pending did.
* refactor(anthropic): explore official Claude Agent SDK runtime
* refactor(anthropic): replace handwritten Claude sessions with SDK
* refactor(anthropic): collapse SDK live-session ownership
* refactor(anthropic): simplify SDK ownership and preserve live skills
* fix(anthropic): fence cancelled SDK runs before process startup
* fix(anthropic): harden SDK approvals, lifecycle, and packaging
* refactor(anthropic): own SDK process trees and streamline runtime
* fix(anthropic): repair rebased packaging and legacy test fixtures
* feat(gateway): generic operator roles for non-maintainer access
Adds gateway.roles: named role bundles over a closed capability set —
sessions.others (none/view/suggest/write), an agents allowlist, and an
operator-scope ceiling. Roles are person-level (additive user_profiles.role
column, SQLite stays at v9); users.setRole (admin-only) assigns them. With
no gateway.roles config, behavior is unchanged for solo deployments.
Enforcement is deny-by-default from a host-minted actor identity
(system vs operator+profileId on server-only client.internal, never
accepted from the wire) and covers every entry point: WS RPCs, OpenAI-compat
and Responses HTTP, tools invocation, cron, questions, usage, task
suggestions, session catalog/sharing/reads. The agents allowlist gates both
session creation and run-start on existing sessions. Subagent completion
announce and descendant wake mint explicit system authority so role
boundaries never silently drop parent notifications.
The enforcement surface is expressed through a narrow policy vocabulary
(operatorSessionCap, hasOperatorBoundary, authorizeSessionSharing) rather
than per-handler policy internals.
* fix(gateway): heal PR CI after rebase onto main
- Break import cycles: extract GatewayOperatorRoleActor leaf contract; merge
session-group-mutation-targets into session-sharing-target-input.
- Split sessions-suggestions.test.ts (max-lines) into a visibility suite.
- Add users.setRole to the 2026.8 train registry test and regenerate the
Kotlin protocol client.
- Startup UNAVAILABLE gating now precedes session authorization: session
stores are not loaded during startup, so authorization reads would deny
with a misleading non-retryable error.
- sessions.assignOwner keeps its documented visibility-authorized contract
when no operator role caps the caller; view/suggest-capped roles still
cannot reassign foreign session ownership.
- Test stubs updated for main's socket readyState guard (#128144) and the
system-authority arg on channel-native resets.
* test(gateway): chat.send pending-profile dispatch carries its required session target
chat.send requires a non-empty sessionKey at the protocol level; the mutation
pipeline now rejects targetless frames before profile-dependent dispatch, so
the pending-profile test must send a realistic frame.