* fix(auth): create a fresh install with canonical shared-auth ownership
A brand-new install was born in the retired shape. `parseSharedAuthStoreOwnership(undefined)` returns
`legacy-main`, which is the correct compat answer for an existing install whose profiles really do
live in the main agent database -- but a new install has no ownership row and no legacy data either,
so onboarding wrote its first credential into `agents/main/agent/openclaw-agent.sqlite` and the
operator's very first `openclaw doctor` told them to run a migration for state OpenClaw had created
seconds earlier. The main agent also stayed undeletable until they did.
Record `auth.sharedStore = {"location":"state-db"}` when the shared store is first written and the
legacy source provably holds nothing: no `auth_profile_store` row, no `auth_profile_state` row, and
no unfinished cleanup ledger entry. Any legacy row, or any inspection error, leaves ownership alone
so doctor keeps owning the relocation. The check is memoized per ownership generation with a WeakSet
keyed on the process-stable ownership object, so a legacy root is inspected once per process and
doctor's committed flip naturally invalidates it.
The legacy row inspection moves out of `state-migrations.shared-auth-store.ts` into the auth-profiles
owner so doctor and runtime share one contract instead of runtime importing migration code. Explicit
main-agent credential writes now follow the shared target, which is a no-op on legacy roots where
both routes already resolve to the same file.
No SQLite schema change; the ownership row is data. Existing installs take exactly the path they take
today.
* fix(auth): preserve JSON-era shared credentials
* docs(auth): explain why doctor names the main agent dir during a shared JSON import
* test(auth): assert shared-owner runtime reads
* test(doctor): read migrated catalog credentials through the shared owner
A fresh root records state-db shared ownership, so the model-catalog credential migration persists into the shared store rather than the agent file. The assertion read the agent file directly and saw an empty store while all three credentials were present and correct in state/openclaw.sqlite. Read through the owner for that state root instead of pinning storage layout; the credential contents are still asserted exactly.
* fix(cli): message send cannot address channels from npm-installed plugins
Target resolution, channel enumeration, and target-prefix inference only consulted the process-root channel registry, so message CLI actions running against a scoped registry handle could not see installed channel plugins even though selection and send execution could. Carry the selection-resolved plugin into target resolution, fall back to the registry handle in scope for resolver-owned lookups, and list runtime-visible channel plugins for channel selection and prefix inference.
* fix(cli): keep runtime-visible channel reads import-light
Importing channel-resolution from the target-prefix leaf pulled the plugin bootstrap/loader graph into every consumer and reordered module loading under distant vi.mock factories (subagent-registry.steer-restart failed in CI with a hoisting TDZ). Move the scoped-registry reads into a dedicated import-light module, share its registry matcher with channel-resolution, and drop the mock workarounds the heavier graph had required.
* chore(ui): re-baseline startup JS for the outbound scoped-registry reads
CI measured 348285 B gzip on the merge ref (baseline 347023 + 1056 tolerance). The first CI round measured 347784 B, so most of the growth is main-side drift since the 2026-08-19 baseline; the outbound changes account for roughly 60 B in a local A/B. Updated with the documented --update-baseline --startup-js-bytes flow using the CI value.
* Revert "chore(ui): re-baseline startup JS for the outbound scoped-registry reads"
This reverts commit f60bd4c45f.
* fix(cli): plan broadcast accounts from runtime-visible channel plugins
The unscoped message broadcast --account planner still enumerated only process-root plugins, so a registry-scoped installed channel could not join broadcast candidate planning. Use the runtime-visible read and cover the scoped and no-scope paths.
* fix(cli): honor scoped channel plugin precedence
* fix(outbound): preserve loaded plugin fallback order
---------
Co-authored-by: Patrick Erichsen <patrick.a.erichsen@gmail.com>
A reachable gateway whose health check failed either escaped as a raw thrown
error or hit healthCommand's CLI-style runtime.exit(1), killing non-interactive
onboarding before logNonInteractiveOnboardingJson could emit the --json summary
and polluting JSON stdout with human diagnostic text. Route health failures
through logNonInteractiveOnboardingFailure (structured ok:false payload in
--json mode, framed text otherwise) via healthCommandNonExiting, and capture
healthCommand's human output off stdout in --json runs so the diagnostic lands
in the payload's detail field instead.
The first `openclaw doctor` on a freshly onboarded install reported "Persisted
plugin registry is missing or stale. Repair with `openclaw doctor --fix`".
Nothing was wrong: the `installed_plugin_index` row had never existed, `plugins
list` reported 148 plugins without it, no retired `plugins.installs` records
were present, and starting the gateway once builds the row by itself -- measured
going 0 -> 1 across a single `gateway run`.
`preflightPluginRegistryInstallMigration` returned a single `action: "migrate"`
whether the index was absent or unreadable, and the health issue name
`registry-missing-or-stale` shows the conflation: missing and stale are
different states and only one is a problem.
Split that into `initialize | migrate`. A root with no persisted index, no
install records, and no retired config records is initialization the gateway
owns, so doctor stays quiet. Install records without a readable index stays a
migration, as does a config still carrying retired `plugins.installs` records,
or a caller that supplied no config to prove otherwise. `doctor --fix` still
builds the index in every case -- this changes the warning, not the repair.
Production +5 LOC.
Host zlib versions compress identical startup assets into different bytes, causing false Linux budget failures. Emit canonical shipped pako gzip sidecars, restore the 512 B ratchet tolerance, and lower the startup baseline from 348351 B to 344531 B.