* feat(channels): introduce bots when they join group rooms
* feat(channels): add Discord and Telegram join introductions
* fix(channels): isolate untrusted evidence and select allowed join targets
* refactor(channels): scope joinIntro to implementing channels
* fix(channels): keep a delivered join introduction settled when its durable commit fails
* feat(channels): read more room history and document join introductions in detail
* chore(config): regenerate bundled channel metadata after rebase
* fix(memory): isolate sqlite-vec KNN in subprocess
* fix(memory): satisfy KNN subprocess CI guards
* test(release): include memory KNN child artifact
* test(memory): update KNN subprocess fixtures
* fix(memory): bound KNN child row payloads
* fix(memory): preserve published readers during cancellable KNN
Keep shadow reindex database, vector readiness, FTS and metadata state in
an owned async context. Public operations retain the published connection,
and escaped shadow continuations cannot write to it after rebuild closes.
Terminate the one-shot query child through its owned handle and retain
admission until close. Remove process-tree/PID probing and test-only hooks.
Cover publication overlap, callback reads, cancellation and recovery.
Co-authored-by: OpenClaw Assistant <assistant@openclaw.local>
* refactor(memory): separate database context ownership
Keep the published reader and shadow writer context in its own owner module. Repair plugin test imports and typed lint findings without changing the verified KNN behavior.
---------
Co-authored-by: OpenClaw Assistant <assistant@openclaw.local>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
Make ask_user use native Telegram controls when one single-select question can be represented directly, while keeping multi-select and batch questions on the text fallback. Add native Other-to-ForceReply input, preserve Gateway-owned option ordering across Telegram, Slack, and Discord, and prevent later progress from overwriting the delivered question.
Co-authored-by: Ayaan Zaidi <hi@obviy.us>
* perf(control-ui): load built-in theme palettes on demand
Every built-in theme's tokens shipped in the startup stylesheet, so the default
path downloaded six palettes it never painted and each new theme taxed everyone.
That is what pushed the startup CSS ceiling from 45 to 47 KiB when Tide, Beacon,
and Phosphor landed.
Moves the twelve non-default palettes into public/themes/<family>.css, one file
per family covering both modes. Claw stays inline because its tokens are the
:root defaults, so the default path loses nothing and gains the bytes back.
The first-paint story this needed: index.html's boot script now links the active
family's palette during head parsing, which makes it render-blocking exactly
like the app stylesheet, so a persisted theme paints its own colours on the
first frame instead of flashing the default. The href is built from the mount
prefix the gateway already stamps on <html>, so it follows a configured Control
UI base path without the script having to know one. theme.ts keeps the link
correct when the theme changes at runtime, reusing the helper the webfont
stylesheets already use.
The nested resolve-theme ternary became a family table in the same script, since
it now picks an asset as well as a data-theme value.
startup CSS 45.8 -> 42.2 KiB gzip, below the 44.3 KiB it measured before the
three themes landed; ceiling restored 47 -> 45 KiB
base.css 64.0 -> 35.8 KiB raw
Adds a regression test that blocks every bundle script and asserts the palette
still applies, so moving this back into the app bundle fails instead of silently
reintroducing the flash. Verified it catches that: with the boot-script link
removed the assertion reports `expected null to be '/themes/tide.css'`.
* fix(control-ui): publish themes after their palettes load
* fix(control-ui): clean up palette completion listeners
* refactor(control-ui): consolidate theme name resolution
* fix(tui): keep pending follow-up actions hidden after navigation
Separate pending task operations from manually hidden prompts and fence completion after controller disposal. Cover navigation and shutdown with controller and real-terminal regressions. Isolate SMS activation credentials in hermetic test environments exposed by the broad TUI QA run.
* test(tui): isolate pending task terminal regressions
* test(tui): include task suggestions in PTY routing assertion
Dashboard board mutations now fail closed when their admitted Gateway request or plugin generation retires, preventing stale persistence, grants, notices, and broadcasts.
Refs #129183.
The raw skipTrivia=false scanner in collectSafetyCommentLines never
re-scanned the `}` ending a template substitution, so it lexed a
phantom NoSubstitutionTemplateLiteral that swallowed every later
comment in the file. #129769 already fixed this with per-line text
matching, but shipped without a regression test, leaving the fix
unpinned against reintroduction.
* fix(agents): route CLI bootstrap truncation notice into the system prompt
CLI-backed runs appended the detailed bootstrap truncation warning to
every turn's wire prompt, which surfaced the warning block inside user
chat bubbles on transcript-rendering surfaces. The embedded runner
already routes a compact notice through the shared system prompt
builder's bootstrapTruncationNotice slot.
Route the CLI runner through the same slot and delete the per-turn
prompt append plus its context plumbing. Detailed per-file stats still
land in the system prompt report for diagnostics.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(agents): drift first-only CLI sessions when truncation state changes
Fold bootstrap truncation-notice presence into the CLI session reuse
fingerprint, mirroring the existing bootstrapMode hashing. Without this,
a systemPromptWhen:"first" backend resuming an existing session never
receives the updated system prompt when an AGENTS.md edit crosses the
truncation threshold mid-session — a path the removed per-turn body
append used to cover. Two-turn regression proves the drift resend.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* refactor(state): fold singleton tables into config_machine_state at schema v11
Eight singleton tables (skill_curator_state, update_check_state,
clawhub_promotions_feed_state, model_catalog_remote, voicewake_triggers,
voicewake_routing_config, voicewake_routing_routes,
onboarding_recommendations) were each one logical JSON value behind a
fixed key; their bespoke schemas, lazy ensures, and per-table accessors
collapse onto the shared config_machine_state KV under namespaced keys.
cron_store_epochs retires outright: it was born write-only in #114388
and no reader ever existed in any language. Durable values (update
check state, voicewake triggers and routing, per-workspace onboarding
answers) migrate insert-if-absent during the v10->v11 migration; cache
class contents rebuild on next use. Deferred with named reasons:
exec_approvals_config (macOS direct-SQL contract), installed_plugin_index
(same-tx lease fence), node_host_config and web_push_vapid_keys
(secret-table git-backup redaction).
# Conflicts:
# src/skills/workshop/collection-review-state.ts
# src/skills/workshop/collection-review.gateway-admission.test.ts
* test: register v11 guard carve-outs and suppression pin
The v11 migration module joins the raw-SQLite allowlist (migrations are
the named guardrail exception), the lint-suppression allowlist records
the second type-parameter suppression in config-machine-state, and the
identity module keeps only externally consumed exports.
* test: surface CLI stderr when migration-diagnostic assertion fails
* test: expect migration diagnostics on stderr for models plain commands
The #129037 pending-migration cases asserted that aliases/fallbacks
lists never open the state database, but config-health observation
(observeConfigSnapshot -> readConfigHealthStateFromStore) full-opens it
on any config read whose file exists — reproduced identically on clean
main with a main-built dist. The protected contract is exact stdout;
the diagnostic legitimately lands on stderr for every case.
* test: drop unused defaults import from CLI stdout e2e
* test: split session path derivation out of oversized session-files suite
#130016 pushed session-files.test.ts to 1008 lines, over the 1000-line
lint cap and red for every PR's check-lint. The sessionPathForFile
describe moves to a self-contained sibling following the existing
session-files.*.test.ts split pattern; no assertions change.
* refactor(state): fold four more singleton tables into schema v12
tui_last_sessions (cache-class, regenerates on next session switch),
sidebar_sections (persistent section order, migrated as one JSON array),
node_host_config, and web_push_vapid_keys join the v12 fold-in, taking
the retirement to thirteen tables at the same version. The two secret
singletons were blocked on table-granular git-backup redaction; backups
now exclude config_machine_state rows by secret key prefix (nodeHost.*,
webPush.vapidKeys) with a fail-closed row filter and regression proof,
so STATE_SECRET_TABLE_NAMES sheds both tables. The sidebar fold also
retires its lazy-ensure WeakSet and inline DDL; sidebar edits stay
inside the existing session-group write transaction via direct Kysely.
* fix(node-host): omit absent Cloudflare Access config like the column reader
The KV rewrite returned gateway.cloudflareAccess as an own undefined
property where the retired column reader omitted the key; toStrictEqual
consumers (state-migrations doctor-repair test) caught the shape drift.
Mirror the column reader's conditional spread at both construction
sites.
* fix(backup): disclose redacted machine-state prefixes after restore
The prefix-granular secret redaction recorded omitted key prefixes in
the backup manifest but the restore result exposed only excludedTables,
so a redacted restore looked complete while nodeHost.* and
webPush.vapidKeys configuration were intentionally absent. The restore
result and CLI output now disclose the omitted prefixes (JSON mode
carries them via the result shape), with restore-side regression
coverage.
* fix(tui): compare-and-delete retired session pointers
Doctor cleanup read matching pointer keys then deleted them
unconditionally, so a replacement pointer written between the scan and
the delete was erased. The delete now re-checks the stored value inside
the write transaction and only removes pointers that still name a
retired session; a live replacement survives (regression covered).
Also corrects the stale schema-version line in database-first.md.
npm/pnpm pack copy on-disk file modes into the tarball, and node-tar's
portable mode-fix only strips group/other write bits — it never adds
read bits. A restrictive-umask build host therefore ships owner-only
(0600/0700) tarball entries, which breaks the CLI for non-root users
after `sudo npm install -g` under mode-preserving consumers such as
system tar.
- Normalize every packed entry to 0644/0755 (a+rX, exec bits kept) as
the last step of packOpenClawPackageForDocker.
- Add a tar -tvf mode gate to check-openclaw-package-tarball that
rejects any non-world-readable entry.
- Run the docker-package-install npm lane as root and execute the
installed CLI as a non-root user to prove the fix live.
- Fix the docker-package-install bun proof, broken on main since
#129552 wired the bun smoke into the shared openclaw-e2e-instance
library: replace the drift-prone per-file harness copy list with
directory copies, and add a closure-walking guard test that fails
on missing harness dependencies.
* fix(outbound): terminalize definitive channel rejections
* refactor(outbound): rethrow unclassified Slack rejections by identity
The Slack send boundary replaced every non-Error rejection with a synthetic
Error before classifying. That changed the propagated value at all four send
call sites, contradicting the stated contract that unlisted rejections keep
their previous path, and forced a compensating one-level `cause` walk in
`isSlackInvalidBlocksError` so the downstream `invalid_blocks` fallback could
still match. The guard's second operand was also dead: `isRecord` accepts Error
instances, so `!(err instanceof Error) || !isRecord(err)` never reached its
right side for a plain object.
Classify off the raw value and rethrow unclassified rejections by identity;
the `cause`-walking compensator and its test go away with it. Distill the
Telegram migration classifier's three-state result object into a
message-or-nothing, and document the description-first and 52-bit id contracts
inline.
Production surface for the PR drops from +71/-11 to +62/-8.
* test(agents): remove clock-tick race from workspace bootstrap ctime coverage
The in-place-edit case added in #127769 assumes restoring mtime leaves ctime as
the only changed stat field, and that it therefore differs. Linux and macOS
stamp ctime from a coarse per-tick clock, so an edit landing in the same tick as
the cached stat leaves ctimeMs equal: the ctime-only scenario never occurs, the
cache correctly serves its entry, and the assertion fails. Measured 199/200
identical ctimes in a tight loop; the test failed 1 in 8 local runs and broke
checks-node-compact-large-14 on CI.
Re-touch until the kernel advances ctimeMs, then assert mtimeMs and size are
unchanged so the scenario is provably ctime-only. Stripping ctimeMs from the
cache identity still fails the test.
* fix(slack): keep post-dispatch upload completion rejections ambiguous
PlatformMessageNotDispatchedError is a provider assertion that no
recipient-visible send began, and its contract says never use it after an
ambiguous send. files.completeUploadExternal runs after onPlatformSendDispatch
and is the one-time share operation, so a rejection there cannot prove the file
was never shared however definitive its code reads.
Drop the permanent-rejection classifier from that call and keep it on the
pre-dispatch calls only (chat.postMessage, files.getUploadURLExternal,
resolveChannelId). The upload test that pinned the old behavior asserted
onPlatformSendDispatch had already fired, which is exactly the condition that
forbids the claim; it now pins ambiguity instead.
Also widens the workspace bootstrap ctime wait to a 1s deadline and reshapes it
as a while loop, so a coarser filesystem tick cannot exhaust the bound.
* test(slack): prove permanent rejection recovery
* fix(test): stabilize Slack channel action routing
* fix(test): retain channel parity for precise targets
* refactor(outbound): drop unrelated test-routing changes
* fix(telegram): require Bot API error code for migration rejection
* test: repair Telegram tuples and preserve Slack test routing
---------
Co-authored-by: Ayaan Zaidi <hi@obviy.us>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
* fix(infra): prevent dollar-pattern injection in home dir tilde expansion
String.replace interprets dollar-amp/dollar-1/dollar-backtick in the
replacement string. When the home directory contains these sequences
(e.g. a username with a dollar sign), tilde expansion via
.replace(/^~/, fallbackHome) corrupts the path silently.
Use a function replacement so the home path is treated literally,
matching the pattern already fixed in terminal-core/display-string (#111398).
Two call sites: resolveRawHomeDir and expandHomePrefix.
* fix(daemon): prevent dollar-pattern injection in state dir tilde expansion
Address review rank-up: the daemon state-path expansion deliberately does
not use the core helper and still passed home as a string replacement.
Apply the callback form here too and add a literal-dollar regression to
the existing service-env suite (fails on the string form, passes with
the callback).
* fix(launcher): keep literal $ patterns when expanding tilde OPENCLAW_HOME
* fix(ui): keep literal $ patterns in local media tilde expansion
* test(ui): prove literal-$ tilde local media preview through Control UI e2e
* test(ui): align literal-dollar media proof with compact attachment contract
Preserve the current authenticated metadata and ticket-scoped download behavior while exercising the real Chromium Control UI under a literal-dollar home.
Co-authored-by: liyuanbin <li.yuanbin1@xydigit.com>
---------
Co-authored-by: Peter Steinberger <steipete@gmail.com>
* 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.
Update gateway path and process sessionId schema descriptions so models know
which actions require them at runtime. Includes regenerated Codex prompt
snapshots (base telegram catalog + md token counts) matching the descriptions.
Fixes #