* fix(gateway): preserve model auth unavailability reasons
Record missing credentials, unusable auth, and cooldown separately from unknown availability. Carry native runtime auth facts and retry times through the additive models.list projection without changing protocol versions.
* fix(ui): gate composers only on actionable auth failures
Read producer availability reasons in chat and new-session gates. Preserve Model Setup for missing credentials, keep accurate rejection copy, and allow sending during cooldown or unknown availability. Keep unavailable picker options disabled.
* fix(gateway): distinguish permanent auth rejection from cooldown
Keep permanently rejected profiles and inline credentials actionable without a retry timestamp. Use only viable transient profiles for the earliest recovery time, preserving explicit profile retry behavior.
* test: isolate submit gates and native runtime availability fixtures
Extract the existing submit-gate suite and shared draft fixture to stay within the file-size limit. Supply registered CLI metadata through the existing test boundary so availability checks do not cold-load executable plugin setup code.
* chore(protocol): regenerate Swift gateway models for availability reasons
* test(gateway): expect availability reasons in models.list projections
* feat(approvals): standing-grant ledger, revocation, and configurable terms
Completes the operator half of cron standing grants (#129526):
- Grants are listable and revocable. New gateway methods
exec.approval.grants.list/revoke (operator.approvals scope), CLI
'openclaw approvals grants list|revoke', and a Standing grants section
on the Control UI approvals page with per-row revoke. Revocation is
idempotent, records the acting client, and takes effect at the next
occurrence's spawn boundary.
- Grant terms are frozen at mint and configurable. Default stays
until-revoked (matching what shipped Always allow has always meant);
tools.exec.grantExpiryDays sets a day-bounded default for future
grants (enterprise fleet policy), and resolve surfaces may override
per grant via grantExpiresInDays / --expires-in-days. Existing grants
keep their minted terms; config changes are never retroactive.
- expires_at_ms goes nullable (NULL = until revoked). The mandatory-
expiry shape never reached a release tag, so the lazy ensure rebuilds
it in place; grants are re-derivable authority, so the fail-closed
cost is one re-prompt.
- Approval cards say what Always allow does. Automation approvals carry
a standing-grant ApprovalScope (new union member) built server-side
from the cron run registry (now carrying the job display name); the
Control UI inbox card and modal render it via i18n, e.g. 'Always
allow runs this exact command for "grant-demo" without asking, for
10 days (revocable)'.
- Aux method registries stay in lockstep: new parity test fails when a
GATEWAY_AUX_METHODS entry lacks a lazy handler (this gap produced
live unknown-method errors during bring-up).
Live-proven on a hermetic gateway: until-revoked grant ran 62 promptless
occurrences; UI and CLI revocation each returned the prompt on the next
occurrence; config default stamped exactly 10.0 days via a UI resolve;
--expires-in-days 99 override stamped 99.0 days; ledger and card
screenshots captured from the running Control UI.
* chore(ui): annotate the scope narrowing assertion for the safety ratchet
* test(gateway): move aux-method parity check to its own suite
* fix(ci): boundary-parse UI approval scope; register grants schemas for native codegen
- The Swift/Kotlin generators discover named schemas through the
protocol schema fragments; the standing-grant scope member and the
grants list/revoke shapes were missing there, which made the Swift
generator silently drop the ApprovalScope union and broke ios-build.
Registered, regenerated, union and new struct emitted again.
- The chained-assertion lane rejects 'as unknown as' in the UI scope
parser; replaced with a real per-kind boundary parser, which is what
the rule asks for.
- The sqlite-allow-raw marker must share the line of a mid-statement
prepare() call for the Kysely guardrail; split the pragma read so the
SAFETY comment and the allow marker each sit on their own guarded
line.
* chore(ui): record startup-budget growth for the approvals scope line
The standing-grant scope renderer and grants-ledger strings ride the
boot path (~700 B gzip). Recorded via the check's --update-baseline flow
with the reason inline; well under the committed 350 KiB hard cap.
* fix(gateway): restore the channel-restart helper export dropped on main
#118157 made startGatewayChannelFromActiveRegistry module-local while
its colocated test still imports it, breaking check-test-types and the
compact shard on every merge ref. Smallest correct main heal per the
broken-CI doctrine; the test passes again.
* fix(gateway): drive channel-restart reload coverage through its exported owner
Replaces the prior export restore: #118157 made the restart helper
module-local (the dead-export scan is right that tests are not
production consumers), but left its colocated test importing it, so
main failed check-test-types either way. The test now exercises the
same preserveManualStop semantics through rollbackStoppedGatewayChannels,
the exported reload owner, and the helper stays private.
* test(ui): answer the grants ledger request in approvals-page history tests
The approvals page now loads the standing-grant ledger next to history;
a method-aware stub answers it out of band so the ordered history mocks
keep their call counts. Adds coverage for the ledger render and the
revoke round-trip.
* chore(ui): record CI-observed startup-JS baseline for the grants surface
The e2e-real-gateway lane builds the same tree ~600 B larger than checks-ui
and local builds, past the 64 B variance allowance; record the CI-observed
value so the enforcement limit stops straddling cross-lane build variance.
* fix(cli): escape standing-grant ledger fields visibly; register grants methods in inventory tests
The grant ledger's revokedBy (client display name), automation label, and
agent-authored command now go through escapeApprovalTextForTerminal like the
pending-approvals table, so hostile control characters render as visible
\u{...} escapes instead of being silently stripped by the table renderer.
Regression test fails pre-fix. Also lists exec.approval.grants.list/revoke
in the 2026.8 release-train roster, classifies the approvals grants command
group for JSON output, and fixes the stubGrants mock typing.
* fix(ui): open worktree sessions before setup finishes
Admit the first turn before naming, checkout, and repository setup; show
startup progress and keep failures retryable in the same session. Reuse the
accepted-run workspace owner, fence cancellation and setup authority, and
block sibling work until the workspace is ready.
Fixes#124966 (immediate worktree sessions and setup progress).
* test(ui): retain project picker capability in startup fixture
* fix(worktrees): fence checkout retries after cancellation
* test: align native phases and title fallback with admitted startup
* feat(ui): show activity cards for online people
Record live person timing independently of heartbeat freshness and expose keyboard- and touch-accessible cards with visible session links. Preserve continuous online intervals across overlapping tabs without adding persistent activity history.
Closes#130649
* build(protocol): refresh generated presence timing fields
* fix(gateway): scope presence snapshots to read-access operators
Apply one recipient projection to hello, system-presence, and events. Keep person timing and time-zone details behind read access, and filter watched references with the canonical session-list policy without leaking hidden counts. Preserve idle-person metadata for readers and prevent non-reader activity-driven frames.
* fix(plugins): preserve Gateway capability load context
Carry the owning registry's metadata generation, workspace, install records, and built-artifact preference through capability discovery. Preserve standalone source loading and existing speech eligibility while avoiding synchronous source transforms on cold Talk catalog requests.
Fixes#130777
* test: align integrated presence and capability coverage
Keep the recipient matrix in its timing-aware owner, close the suite-owned Gateway for shutdown proof, and spy on the canonical install-record reader. Remove the retired private carrier's assertion allowance.
* fix(gateway): broadcast presence when clients connect
Publish completed connection rows through the canonical scoped broadcaster so established readers see first connections and reconnects without waiting for profile edits or activity. Preserve shared online intervals and reset them only after the final socket closes.
* fix(ci): align presence fixtures and sidebar type ownership
Reuse the canonical sidebar host type to remove the type-only lazy-runtime cycle. Assert omitted scopes through hello auth and denied presence reads, and supply presence version callbacks in the shared WebSocket context fixture. Retain startup admission and drain assertions without changing deadlines.
* fix(ui): retain latest scroll commands until their destination
Do not treat the virtualizer's idle debounce or a decreasing offset as reader takeover. Settle at the actual DOM end within one pixel, retaining the separate eight-pixel UI-follow policy and explicit input cancellation. Cover retargeted idle delivery and near-end precision with the real dependency, synchronize pointer baseline capture, and type the existing composer snapshot as its textarea contract.
Fixes#130892
* feat(plugins): surface plugin capability consent in Control UI and CLI
Adds plugins.inspect (declared manifest surface, operator grants, install
provenance/integrity, ClawHub trust), a Control UI consent dialog on install
and external-plugin enable, a server-side acceptance gate persisted on the
install record, artifact-anchored widen diffing, and --accept-capabilities
for non-interactive CLI use.
NOT READY TO LAND: autoreview found critical gaps (see PR notes) — the
declared surface omits 20 of 21 contract families, native plugins always
report zero hooks, several install/enable paths bypass the gate, and the
acknowledgment is not bound to the reviewed surface.
* refactor(plugins): bind capability consent to the reviewed surface
Collapses the consent error payload to the fields the client cannot fetch
(reviewToken, widened, acceptedAt) and pulls identity/declared/grants/source/
trust from plugins.inspect, shrinking the registry-free protocol reader from
395 to 91 lines and removing its divergence from the closed schema.
Acknowledgment now carries the SHA-256 reviewToken of the surface the operator
saw; the server recomputes the final staged artifact's surface and rejects any
mismatch before persisting acceptance. That closes review-then-swap, laundering
of forged acceptance through an unchanged update, and cross-artifact replay.
All 22 manifest contract families are now declared, hashed and diffed, so a
privileged family such as gatewayMethodDispatch can no longer be added without
re-consent. Consent reads the manifest runtime discovery will execute, ambiguous
install ownership fails closed, integrity resolution has one owner and no longer
labels npm SHA-1 shasums as SHA-256, and code plugins disclose that hooks
register at runtime instead of rendering an empty "no hooks" row.
* fix(gateway): register plugins.inspect in method inventories and regenerate protocol
Adds plugins.inspect to the advertised-method inventories (widening the
fixed-size slice windows so older indices stay stable), regenerates the Kotlin
protocol bindings, drops an unused exported type, and replaces two nested
conditional spreads with a plain conditional.
* refactor(plugins): split oversized consent modules and clear lint findings
Extracts the MCP controller out of the plugins page, unchanged-install
reconciliation out of update-installed, and the install lifecycle suite out of
the management-service tests, bringing all three back under the max-lines limit
without suppressions. Also renames a shadowed binding, drops an unnecessary
generic, removes a spread-to-modify in a map, and types catch callbacks as
unknown.
* chore(protocol): regenerate Kotlin bindings after rebase
* feat(plugins): let chat /plugins install review and accept capabilities
The consent gate applies to chat installs too, but the command had no way to
give consent, so external installs dead-ended on a CLI-only flag. Chat now
replies with the plugin's declared capability surface and the exact command to
rerun, and accepts a trailing --accept-capabilities mirroring the existing
--force acknowledgement. ClawHub trust acknowledgement stays CLI-only.
Staged-artifact verification is unchanged: the reviewToken is still checked
against the final artifact before acceptance is recorded.
* refactor(plugins): single-source the declared-surface groups and manifest precedence
The ordered capability group list was defined independently in the consent
engine, the protocol error reader, the CLI formatter and the Control UI, so a
new contract family had to be added in four places with nothing enforcing it.
All four now derive from one canonical list in the protocol schema with a
compile-time exhaustiveness guard.
Native-versus-bundle manifest precedence is centralized in one helper that both
discovery and staged consent call, so the two cannot drift again — that
divergence was a real bug where consent read one manifest and the runtime
executed another.
Also documents that carrying acceptance forward requires pinned artifact
integrity, so integrity-less sources such as local paths ask on every install.
* fix(plugins): enforce reviewed consent across activation flows
Route setup, repair, linked installs, updates, and chat activation through artifact-bound capability consent. Reuse canonical package discovery and recheck staged activation before config publication. Invalidate stale Control UI review requests on reconnect.
Verified focused owner and sibling tests, runtime rebuild, and real isolated CLI/Gateway install, inspect, enable, widening, and stale-token rejection flows.
* test(plugins): cover beta installs through capability consent
* test(plugins): align consent fixtures with staged artifacts
* fix(ui): review staged plugin capabilities once
* test(ui): inline the remaining plugin consent confirmation
* test(plugins): verify consent with deferred install transactions
* refactor(setup): share inference execution plan construction
* test(ui): settle applied config before deferring refresh
* fix(plugins): protect consent provenance and reuse acceptance
* feat(ui): save appearance preferences per user profile
When a Control UI connection is bound to an authenticated user profile
(trusted-proxy, Tailscale, GitHub identity), theme, theme mode, and accent
become per-profile: stored in the existing user_preferences KV store via
users.prefs.set, overriding gateway-wide ui.prefs, with live cross-device
updates over a new additive users.prefs.changed event scoped to the same
merged profile. Restore default deletes only the profile key and falls back
to the gateway-wide value. talk.config projects the caller's profile accent
for native clients. Profile-less connections (token/password/none auth)
keep the existing gateway-config behavior byte-identically.
Release-note context: on multi-person gateways appearance choices are now
personal and follow you across devices; an admin restyling their own UI no
longer reskins the whole team.
* chore(protocol): allowlist users.prefs.changed for mobile clients
iOS and Android resolve the profile accent through talk.config on connect
and config refresh; live per-profile appearance push on natives is a named
follow-up.
* chore(protocol): regenerate Swift/Kotlin models for users.prefs.changed
* fix(ui): honor explicit defaults and identity switches for profile appearance
Addresses the three ClawSweeper review findings:
- resetValue for profile-bound appearance keys is the deletion fallback
(gateway value), so an explicit product-default selection persists as a
profile write instead of being misclassified as a reset.
- An identity switch between two known scopes forces a full appearance
reconcile and clears appearance keys the returning identity never set,
so a shared browser cannot keep the previous profile's look (boot keeps
the last-seen shortcut).
- talk.config joins the profile-dependent dispatch gate so a pending
GitHub identity sync completes before the profile accent is projected;
token clients pass through untouched.
* chore(ui): satisfy assertion-safety ratchet and docs formatting
* refactor(ui): extract server-prefs storage primitives
server-prefs.ts crossed the 700-line cap after the review fixes; the
stateless localStorage persistence primitives move to
server-prefs-storage.ts (no behavior change). Shrink-prunes the
assertion-safety baseline for the moved casts.
* fix(ui): keep imported custom themes out of profile storage
Custom palettes are browser-local by contract, so a profile must never
carry theme=custom to a browser that cannot render it. The wire contract
drops custom from the storable theme values (readers self-heal any stored
value), a profile-bound custom selection stays retained browser-local
(including the offline-queued path), and the exhaustive theme test now
encodes the exception.
* fix(ui): distinguish inherited model defaults from session pins
The Gateway session projection collapsed inherited agent defaults and
persisted conversation pins into the same effective model fields, so the
Control UI picker marked a row Default while its footer simultaneously
called it a session override and offered a meaningless reset.
Project the authoritative persisted provenance instead: the session owner
resolves `modelOverrideSource` (normalizing entries written before source
tracking), `buildGatewaySessionRow` and the session change event carry the
additive `"user" | "auto" | null` protocol field, and the Control UI treats
only `"user"` as a conversation pin.
Closes#120752
* test(ui): scope model provenance to active pane
* fix(ui): derive the model picker's inherited sentinel from recorded provenance
Deriving "inherits the agent default" from the effective model matching the
default meant a session pinned to model X stopped reading as pinned once the
agent default moved to X. The picker then rendered the default row as already
selected, so clicking it matched its own commit value and was swallowed, leaving
the stored pin unreachable from the UI.
Project the session store's existing modelOverrideSource onto the gateway row and
let the UI resolve a closed selection source (inherited/pinned/fallback) from it,
falling back to the old equality guess only for gateways too old to send the
marker. Preserve the null tombstone in reconcile so a cleared pin stays
distinguishable from that silence.
* test(ui): pin the model picker's digit-key ownership
The numbered keycaps and the digit handler are paired by search focus, but the
guard that keeps digits in the search input had no coverage outside the browser
e2e lane. Add a unit regression for both halves and record the contract at the
numbering site, where reviewers keep reading the two gates as unrelated.
* fix(ui): keep session model selection provenance authoritative
Project saved model provenance through Gateway rows and events, retire temporary UI claims at their owner, and use canonical refresh after slash and status model changes. Preserve model intent through rollback and deletion without retaining a public cache setter.
Co-authored-by: Vyctor H. Brzezowski <krzyszchweski@gmail.com>
* refactor(ui): keep recovery with scoped session operations
Preserve recovery notifications, visible errors, and connection checks while keeping the optimistic mutation owner within its module limit.
Co-authored-by: Vyctor H. Brzezowski <krzyszchweski@gmail.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).
* feat(approvals): typed approval scope summaries on channel cards
Approval owners can attach a closed ApprovalScope union (message-send,
payment, external-post) describing an action's blast radius. The gateway
sanitizes it once at the producer boundary, the core view model renders a
Scope metadata row so Slack/Discord/Google Chat cards show it unchanged,
shared text builders cover Telegram/WhatsApp/Signal/iMessage/Matrix, and
the durable presentation carries it additively for operator surfaces.
Scope is display-only, never authorization; missing scope keeps today's
cards.
* fix(approvals): emit native ApprovalScope union and clamp recipient previews
Name the three scope variants as registered protocol schemas so the Swift
generator emits the ApprovalScope discriminated union the presentation
structs reference, and commit the regenerated GatewayModels.swift. Clamp
recipient previews to the declared recipientCount at the sanitize boundary
so a count of 1 with 2 previews can no longer render inconsistently.
Addresses both ClawSweeper findings on #130116.
* refactor(approvals): extract text sanitizer to break the exec-approvals import cycle
check:architecture flagged approval-scope joining the exec-approvals SCC
through exec-approval-command-display. Move the self-contained display
sanitizer into a leaf module (exec-approval-text-sanitize) with no
exec-approvals imports and migrate all sanitize importers; command-display
keeps only the payload-typed command/preview resolver.
* chore(plugin-sdk): ratchet public surface budgets down after sanitizer extraction
The approval display sanitizers left the publicly reachable SDK graph when
they moved to the exec-approval-text-sanitize leaf: exports 4343 -> 4338,
callable exports 2582 -> 2578. Shrink-only budget pin.
* 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.
* fix(scripts): see SAFETY comments after template substitutions
The assertion ratchet scanned each file with a raw ts.createScanner, which
reads the `}` closing a template-literal substitution as a block close. The
scanner desynced there, so every `// SAFETY:` comment after a file's first
`${...}` was invisible and its annotated assertions were counted as bare.
Track substitution brace depth and rescan the closing brace the way the parser
does. The fix uncovers already-annotated assertions in eight files, so the
baseline shrinks accordingly.
* fix(skills): record skill usage again and retire dead curator tables
Skill lifecycle curation shipped in 2026.7.1 with two producers: a trusted
`skill.used` consumer writing `skill_usage`, and a daily sweep aging skills
into `skill_lifecycle`. The weekly collection review (#121653) replaced that
policy and deleted both producers, but left every reader in place. Since then
`skill_usage` has had no writer, so curator status reported `lastUsedAtMs:
null` and `useCount: 0` forever, and `skill_lifecycle` had no writer either, so
pin/unpin/restore either threw "not found" on fresh installs or, worse,
"succeeded" on upgraded ones while gating nothing at all.
`skill_workshop_proposal_origin_runs` was never read in any revision; proposal
provenance is authoritative in `record_json`.
Restore the usage producer at its owner and retire what has no owner:
- `skill.used` events populate `skill_usage` again, registered with the
collection-review maintenance it belongs beside. Curator status derives
curated skills from applied Workshop create proposals and reports real usage.
- Weekly review receives bounded `useCount` / `lastUsedDaysAgo` evidence, with
prompt text stating usage supports keeping a procedure and never alone
justifies a drop.
- State schema 10 drops `skill_lifecycle` and
`skill_workshop_proposal_origin_runs`. Previously archived skills return to
the active collection, where review judges them by content; the migration
logs how many. Reconcile now clears usage only for skills it actually drops.
- `skills.curator.pin`/`unpin`/`restore` stay registered for existing clients
but fail with an explicit retirement message instead of silently doing
nothing.
Retirement code moves to openclaw-state-db-table-retirements.ts to keep the
schema-repair module under max-lines; that split is a pure relocation.
Production delta is +23 raw: about -46 for the change itself, +44 for the file
split, +25 for the ratchet fix in the previous commit.
* feat(approvals): mint scoped standing grants for cron allow-always
When an operator resolves allow-always for an approval raised by a cron
job's isolated run, the Gateway now mints a scoped standing grant in the
same SQLite transaction that resolves the approval, instead of writing an
unbounded command digest into the JSON allowlist. Subsequent occurrences
of that job execute the exact approved operation (command text, cwd, env
hash) without prompting while the grant revalidates against authoritative
rows: 30-day expiry, revocation, the cron job still existing with the same
config revision, and the minting approval row still holding allow-always
all fail closed back to the normal prompt. Non-cron allow-always behavior
is unchanged.
- New first-use lazy STRICT table operator_approval_standing_grants in the
shared state DB (declared canonically, no schema-version bump; older and
downgraded readers stay valid without it).
- The cron run owner records run -> {agent, job, config revision} in a
process-local registry at run start; exec.approval.request stamps the
cron source and exact operation binding onto the approval at creation,
so nothing is ever inferred from session keys or run ids.
- The gateway exec host consults grants only when policy would prompt;
ask=always, security=deny, mutable file operands, heredoc, strict
inline-eval, and audit-suppression approvals keep prompting. Grant use
updates last_used_at_ms/use_count and emits the exec approval security
event with the grant and minting approval as lineage.
- Abort-wins guard: a run with an abort tombstone never mints.
* feat(approvals): deliver cron exec approvals to approval clients and wait inline
The standing-grant mint path was unreachable end-to-end: #128031 made
cron approval requests register with delivery fully suppressed, so the
shared owner expired them as no-approval-route within milliseconds, and
even a delivered card would have died seconds later when the isolated
run finalized on the approval-pending handoff and authority-close
cancelled the parked approval.
Cron approval requests now carry deliverToApprovalClientsOnly: the
shared delivery owner broadcasts them to connected websocket approval
clients (Control UI, TUI) but skips internal chat approval runtimes,
forwarder/iOS delivery, and turn-source routes, so the per-occurrence
chat spam #128031 removed stays removed. With no approval client
connected, the request still expires no-route into the existing
headless denial. The gateway exec host additionally waits inline for
cron-triggered approvals (the same treatment native chat channels got
in #93918), keeping the isolated run and its delegated authority alive
for the full approval window; cron jobs are single-flight, so at most
one card per job is pending at a time and allow-always ends the
recurrence by minting the standing grant.
Live-proven on a hermetic gateway: card delivered with ~30-minute
window, run waited 72s for the operator click, allow-always executed
the occurrence and minted the grant with no JSON allowlist digest,
the next occurrence ran promptless (use_count 1, no new approval row),
and editing the job failed closed back to a fresh prompt.
* chore(protocol): regenerate Swift models for deliverToApprovalClientsOnly
* fix(approvals): consume standing grants at the spawn boundary; keep node cron headless
Review findings from ClawSweeper on #129526:
- Grant authority is now recorded only at the final effect. The consult
path validates without recording a use and returns a
revalidateBeforeExecution closure (the mutable-file-binding seam) that
consumes the grant immediately before runExecProcess; any invalidation
during awaited pre-spawn work (job edit/delete, revocation, parent
approval reversal) denies with next-step text instead of executing on
stale authority. Regression proves consult leaves use_count at 0 and a
reversed minting approval denies at the boundary.
- Cron approval-client delivery is scoped to host=gateway. Node-host cron
cannot mint or consume grants yet, so it keeps the fully suppressed
headless policy from #128031 instead of raising cards whose allow-always
could not stick; node-host grant support stays a named follow-up.
* test(agents): complete plugin-metadata-snapshot mock factories
Five explicit vi.mock factories for current-plugin-metadata-snapshot.js
exported only getCurrentPluginMetadataSnapshot. Under isolate:false shard
composition the incomplete mock can bleed into siblings that import the
real module — model-resolution-consistency.test.ts failed on CI with
'No withPluginMetadataSnapshotScope export is defined on the mock'.
Spread importOriginal so every binding prod touches stays exported, per
the repo mock-factory rule; only the snapshot getter stays overridden.
* chore: drop accidentally committed pinned swiftlint binary; ignore .build/
scripts/install-swift-tools.sh installs pinned Swift tools into
.build/swift-tools per the lint-swift.sh remediation hint; the 36MB
binary must never ride a commit. Remove it and ignore the directory.
* test(agents): shield model-resolution-consistency from leaked snapshot mocks
The agents-embedded shard still failed after completing five factories:
~20 more test files across the repo mock current-plugin-metadata-snapshot
with incomplete explicit factories, and under isolate:false composition
any of them can strip withPluginMetadataSnapshotScope from this file's
imports. Give the victim a file-local identity mock (importOriginal
spread) that always wins, and revert the static-catalog factory edit that
tipped that grandfathered file over the max-lines cap — the repo-wide
factory completion belongs to a dedicated sweep.
* feat(ui): configure capabilities before session start
Reuse the active-chat Plus menu on new sessions, move Draft into it, and persist admin-scoped tool overrides before the initial turn. Closes#128079.
* test(ui): follow new-session Draft menu
* refactor(ui): reconcile new-session capability ownership
* fix(ui): gate terminal launch on capability overrides
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
---------
Co-authored-by: RoboClaw <309084314+roboclaw-bot@users.noreply.github.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
* feat(nodes): automatic device placement for sessions.dispatch
sessions.dispatch gains autoDevice: true — the gateway selects the eligible
session-host node with the most available worker slots (deterministic
tie-break), retries up to three candidates when a node churns at the
pre-provisioning eligibility fence, and reports the chosen device in the
placement runner projection. Control UI offers Any available node with
actionable disabled reasons. No-eligible-host failures state why.
* fix(ui): break draft-place-state/draft-session-placement import cycle
resolveDraftSessionPlacement only needs four scalar fields; a structural
param type replaces the Pick<DraftPlaceState,...> import that created the
madge cycle.
* fix(ui): keep the devices section hidden when no devices are paired
The Any available node row lives inside the Your devices section; rendering
it with zero paired devices resurrected the section on gateway-only setups.
Gate it on device presence — Connect a machine remains the discoverability
path — and cover both the empty and non-hostable cases.
* fix(gateway): project dispatch runner state through the canonical reader
The dispatch reply no longer synthesizes an available device runner; it uses
the fenced workerPlacementRunnerAvailabilityReader (and disk-space reader)
exactly like session reads, so a node lost after durable provisioning
projects offline consistently. Documents placement.runner.deviceId in the
protocol reference.
* feat(control-ui): show client IP and time zone on the activity identity card
The Activity identity card showed only host and platform, so an operator
looking at a teammate could not tell where that person was connecting from.
Presence already carried a best-effort `ip`, and it was simply not rendered.
Add the client's self-reported IANA time zone to the connect handshake and
presence entry, and render both `ip` and `timeZone` on the device row. The
time zone matters because the connecting address is frequently unusable for
location: connect handling omits `ip` for loopback clients, and tunneled or
Tailscale clients land in private/CGNAT ranges. A browser knows its own zone
regardless of how it reached the gateway.
Both protocol additions are optional fields, so no version bump is needed.
* build(protocol): regenerate Swift models for presence timeZone
When the agent runtime talks to a remote Gateway (gateway.mode "remote" or a
gatewayUrl/gatewayToken override), the message tool withholds the runtime
identity, so a proven-not-sent outbound failure kept its durable retry row
and also surfaced as an error the model would answer by resending — a
duplicate once the queue replayed the row.
- deliver-queue-execute: mark the thrown error recoveryOwnedRetry when the
proven-not-sent row stays replay-eligible (one retry owner per row)
- message.action: additive UNAVAILABLE detail code OUTBOUND_DELIVERY_QUEUED
- message tool: project that error into a non-throwing delivery_queued
result ("queued, will retry automatically, do not resend") and keep the
autogenerated idempotency-key mapping so an identical resend collapses
Validated with unit tests and a real-Telegram remote-gateway E2E
(affected on main: duplicate; fixed: single send, delivery_queued).
Closes#124279
Co-authored-by: Ayaan Zaidi <hi@obviy.us>
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).
* 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).
The Gateway owns start-or-steer at admission (6515f6a255) and no
client produces expectedRunId anymore (d84a910fc8). The field shipped
only in v2026.8.1-beta.2 - never a stable tag - so it is removed rather
than deprecated. Steer sends resolve the selected session's current
operation; the exact-match branch, the operation|run target identity
discriminator, run_mismatch rejection, and the suggestion producers'
active-run-id selection (with its ambiguity failure) are deleted.
Provider-native turn fencing (Codex expectedTurnId) is unchanged:
the backend-captured runId on the injection target remains.
* feat(gateway): proxy channel conversation avatars
* feat(discord): capture conversation avatars
* feat(slack): capture DM sender avatars
* test(discord): bind guild avatar mock
* feat(ui): render channel conversation avatars
* fix(ui): align sidebar owner fixtures
* fix(gateway): version channel-avatar routes by media revision
A stable per-session URL let AuthenticatedAvatarRouteLoader's blob and
sticky-404 caches pin a mounted row to a stale or blank avatar after the
backing media changed. Append an opaque digest of the media reference so
replacement and 404-recovery change the route identity.
* test(ui): align sidebar owner facet
* fix(ui): keep owner chip until channel avatar loads
A session with a channelAvatarUrl suppressed its owner chip even while the
blob was loading, auth was not ready, or the route 404ed, leaving an empty
lead slot. The chip now rides as fallback content inside the avatar element
and yields only to a usable image. Covers 404 and auth-pending states;
avatar rows keep renderedOwnerId unset so an owner-viewer stays visible in
the facepile.
* perf(ui): keep channel avatar fallback within budget
* perf(ui): lazy-load the channel avatar element
The avatar element and its authenticated blob loader rode the startup
bundle through session-leading-indicator, pushing startup JS 51 B over the
CI gzip budget. Channel avatars are not startup-critical: register the
element on the first avatar row; the owner-chip fallback covers the
one-time upgrade window. Startup JS returns ~1 KiB under the ceiling.
* build(ui): raise startup baseline for channel avatars
CI-measured startup JS is 344379 B against a 343289 B baseline (+1090 B).
The avatar element and blob loader are code-split out of startup (previous
commit); the residual is the sidebar lead-slot render branch and row
plumbing, which cannot be deferred. Baseline updated via
check-control-ui-performance --update-baseline with CI bytes per the
script's contract; well inside the 4096 B ratchet step and 358400 B
ceiling.