* fix(daemon): preserve unknown service load state
* test(daemon): update service load-state fixtures
* fix(onboard): preserve loaded service diagnostic
* fix(daemon): preserve unknown lifecycle state
* fix(status): preserve loaded JSON fields
* fix(daemon): fail restart on unknown service state
* fix(daemon): fence unknown service state
* fix(cli): avoid false gateway restart failures
Allow restart to proceed when the loaded-state probe is unavailable but an installed service definition identifies the native manager. Native restart errors and Gateway health checks remain authoritative.
* fix(daemon): detect system-scope restart definitions
Move installed-definition detection into the platform service adapter so gateway restart can recover from an unavailable loaded-state probe for both user- and system-scope systemd units.
Remove program-argument runtime rediscovery so managed Gateway and node-host services consume only the canonical validated Node path, while executable wrappers remain supported.\n\nRefs #122061.
* perf(plugins): declare doctor contract surfaces
* perf(doctor): slim migration import closures
* perf(plugins): narrow doctor declaration record surface and wire owner-test lane
Registry records carry only the doctorContract declaration instead of the whole
parsed manifest, and check:changed now selects the src/plugins-owned declaration
honesty and closure-guard tests for extension module/manifest changes so
cross-lane drift cannot pass PR classification.
* fix(doctor): keep control-plane dist imports require-safe
Keep doctor and channel control-plane chunks off exec-class dependencies, and enforce native require(esm) loading during postbuild.
* chore(plugin-sdk): regenerate API baseline
* chore(plugin-sdk): sync export ordering
* fix(plugins): satisfy doctor contract CI boundaries
* perf(doctor): make qqbot doctor closure dependency-light
qqbot was the last plugin above 5s in doctor state-migration enumeration
(~8s under tsx/jiti). The cost was not the state-key builder (already a
leaf): its doctor closure value-imported the runtime-doctor SDK barrel,
whose plugin-state-store/state-db re-exports pull kysely (~330 modules),
plus security-runtime for one fileExists (~200 modules), all resolved
per-module by jiti during enumeration.
Split the migration-define helpers and light re-exports into a new
private-local plugin-sdk/runtime-doctor-migrations subpath; runtime-doctor
re-exports it so its public surface is byte-identical (API baseline hash
unchanged). qqbot's doctor-contract and state-migrations now import only
the light subpath, swapping fileExists for the equivalent async
legacyStateFileExists already in the closure.
qqbot enumeration: ~8.0s/531 modules -> ~0.25s/18 modules.
* chore(plugin-sdk): drop private-local subpath from API baseline
runtime-doctor-migrations is private-local-only; the baseline tracks public
modules, and the earlier line was generated before the classification.
* fix(plugins): register runtime-doctor-migrations boundary paths
The private-local subpath list feeds the extension package boundary map;
the shared paths config and xai's derived overrides must carry the same
entry or the boundary contract test fails.
* fix(daemon): skip unreadable foreign LaunchDaemon plists in ownership scan
inspectSystemLaunchDaemonOwnership raised SystemLaunchDaemonOwnershipError when any /Library/LaunchDaemons plist was unreadable, such as third-party VPN helper plists with mode 600. This broke gateway install/repair and launchd.integration.e2e on affected Macs. Unreadable plists are foreign by contract, while loaded same-label daemons remain caught by the bracketing launchctl probes.
* fix(ci): regenerate plugin-sdk api baseline after 267268f646
* test(doctor): scope gateway-bind persistence test to its writer seam
Culprit: 267268f646.
* docs(daemon): document unreadable plist tradeoff
* fix(daemon): reload systemd user manager after removing legacy units
uninstallLegacySystemdUnits removed unit files without a daemon-reload, so the
user manager kept the deleted unit definitions loaded and startable until an
unrelated reload. Legacy-unit removal now reuses the shared
disable-or-tolerate-missing helper, surfaces non-ENOENT unlink failures instead
of swallowing them, and reloads the user manager once after any unit file was
removed (matching uninstallUserSystemdGatewayUnit).
* chore: re-fire CI
* chore: re-fire CI against fixed main baseline
* fix(daemon): retry launchd bootstrap while a booted-out job tears down
launchctl bootout returns once launchd accepts the request, not once the
job has exited, so the reload path could bootstrap into a label the
outgoing gateway still held and fail with EIO. bootout had already
deregistered the job, leaving KeepAlive with nothing to respawn and the
gateway silently down until a manual bootstrap.
Retry bootstrap while the teardown is still pending, bounded by the
plist ExitTimeOut, and restore the job if bootstrap still fails.
* fix(daemon): report an unrestored LaunchAgent and scope the bootstrap retry
The reload path swallowed a failed restore, so a bootstrap that failed after
bootout surfaced only the original error while the LaunchAgent stayed booted
out. Combine the restore failure into the thrown error and say plainly that
the job is gone and KeepAlive has nothing left to respawn.
The teardown retry also applied to every bootstrapLaunchAgentOrThrow caller.
launchd answers the same EIO for a label that is merely still registered, so
start, install, and recovery paths burned the full teardown deadline on an
already-loaded service before failing anyway. Gate the retry on callers that
just issued bootout, and defer to the existing isLaunchctlAlreadyLoaded model
instead of treating that response as a teardown to wait out.
* [AI] fix(node-cli): warn when systemd user lingering is disabled after install
openclaw node install now detects when systemd user lingering is off and
warns the operator (text + JSON) to run 'sudo loginctl enable-linger <user>'.
Without lingering, the user-level node service is torn down when the last SSH
session ends, so the node silently goes offline after logout.
The check is read-only and never auto-enables lingering, matching the
operator-consent policy used elsewhere. It runs only on the verified-success
path: an optional onVerified hook is added to installDaemonServiceAndEmit
that fires after service.isLoaded() confirms the service is loaded and before
the success payload is emitted. The linger diagnostic runs there, so a failed
install or verification failure never carries a linger warning (avoids
misdirecting the operator to fix lingering for a service that was not
successfully installed). The already-installed short-circuit warns separately.
Skipped on non-Linux and when systemd user service is unavailable.
Adds unit tests for both paths, the linger=yes no-op, the install-failure
isolation, the verification-failure no-warn regression, and the
systemd-unavailable skip, plus response.test.ts cases covering onVerified
running on success and failing safely when it throws. The
readSystemdUserLingerStatus mock is typed with the full linger union to
satisfy tsgo. Documents the linger step in docs/cli/node.md and
docs/nodes/troubleshooting.md.
Real-behavior evidence captured on a Linux host by toggling
loginctl disable-linger/enable-linger and running the real install flow:
linger=no emits the warning on successful install (text + JSON) and on the
already-installed path; linger=yes emits nothing; a failed install or
verification failure emits no warning.
Fixes#107033
Co-Authored-By: deepseek-v4-flash <noreply@anthropic.com>
* fix(node-cli): align linger user with service owner
* docs(node): narrow crash-loop claim to gateway units
The duplicate-scope guard that raises on two managers running the same unit
name is enforced for gateway units (two supervisors on the same port SIGTERM
each other in a restart loop); assertNoSystemGatewayOwnership returns early
for node services, so claiming node services crash-loop misattributes gateway
behavior. Qualify the troubleshooting note accordingly.
Addresses ClawSweeper P3 finding on PR #118430.
* fix(systemd): align linger checks with service owner
* test(doctor): align linger status mock contract
* style(doctor): format linger mock
* test(wizard): mock systemd service account
---------
Co-authored-by: deepseek-v4-flash <noreply@anthropic.com>
Co-authored-by: Patrick Erichsen <patrick.a.erichsen@gmail.com>
* fix(daemon): keep backslashes and quotes intact in generated systemd units
systemdEscapeArg escaped only pairs of backslashes and rendered a quote as
two backslashes plus a quote, so any value holding a lone backslash or a
quote did not survive the readers in this module -- or systemd itself. The
installed Linux service received a different value than the operator
configured, with nothing reporting the difference.
parseSystemdEnvAssignment also carried a private copy of the shared
unquoting loop whose escape branch compared one character against a
two-character literal, so it never unescaped anything. Service inspection
reads through that path, so status and doctor reported the escaped form.
Align the writer with serializeSystemdEnvironmentFileValue and route the
reader through the shared splitter its three siblings already use. Adds
round-trip tables mirroring the Windows cmd sibling.
* fix(daemon): align systemd round-trip parser with current main
---------
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
* fix: gateway service commands refuse a named profile or relocated OPENCLAW_HOME
- Resolve the default install identity against the canonical state directory
for the active OpenClaw home and profile instead of the unprofiled OS
account default.
- `--profile <name>` / `--dev` project `.openclaw-<profile>` state and config
paths, so every named profile was classified as isolated state and refused
`install`, `start`, `stop`, `restart`, `uninstall`, Doctor service repair,
and self-update service handling.
- `OPENCLAW_HOME` relocates all OpenClaw path defaults and is documented for
running as a dedicated service user; a relocated home is now an install
identity. `HOME` alone still is not.
- An `OPENCLAW_STATE_DIR` or `OPENCLAW_CONFIG_PATH` pointing outside those
canonical paths is still treated as isolated state.
- Recovery guidance in the refusal message now names the paths that must match.
Verified: focused vitest shards for the changed suites plus the daemon, CLI,
and doctor suites that consume the identity check; tsgo core and core-test
lanes; oxlint; docs format, MDX, link, and map checks.
* fix(gateway): keep relocated homes isolated
* fix(config): validate service profile identity
* fix(daemon): enforce named-profile service ownership
* fix(update): reject drifted service selectors before probes
* test(windows): prove scheduled task lifecycle
* test(windows): harden scheduled task proof cleanup
* test(windows): bind lifecycle proof to checkout
* test(windows): normalize cleanup exit status
* test(windows): verify effective task privilege
* test(windows): protect scheduled task proof roots
* test(windows): prove listener-owned task lifecycle
* test(windows): fix scheduled task proof contracts
* test(windows): remove redundant mock coercions
* test(windows): measure fallback before task probes
* test(windows): prove scheduled task process origin
* fix(gateway): preserve unmanaged restart fallback
* test(gateway): cover denied restart ownership
* test(gateway): keep restart helper types private
* test(gateway): classify lifecycle helpers as test code
---------
Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
* fix(systemd): clear start-limit latch before gateway start
systemd latches a unit into failed/start-limit-hit after a crash loop,
then refuses systemctl start until reset-failed clears the latch. The
reset-failed step only ran for the restart action, so the natural
recovery command `openclaw gateway start` failed on a crash-looped
gateway while `openclaw gateway restart` recovered it.
Run reset-failed before both start and restart (stop is unchanged),
mirroring launchd's startLaunchAgent which calls launchctl enable before
kickstart. reset-failed is idempotent and a no-op on a healthy unit.
* test(systemd): cover start-limit latch clearing on system-scope start
Mirrors the existing system-scope restart test: when startSystemdService
targets a system unit as root, it must emit reset-failed before start so
a crash-looped gateway recovers via `openclaw gateway start` too.
* test(systemd): assert healthy start tolerates reset-failed no-op
reset-failed is idempotent on a non-failed unit (systemd returns success
without changing unit state), so prepending it to start cannot regress a
healthy gateway. Covers the merge-risk concern that every start now
depends on an extra reset-failed call.
* test(systemd): cover reset failure and stop controls
---------
Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
Doctor's dueling-scope repair removed the user-scope gateway unit on file
presence alone. A system unit file that is merely staged, disabled, failed, or
uncheckable would then cost the operator their only working gateway, and the
default confirm value is true so non-interactive repair amplifies it.
Gate the removal on isSystemUnitActiveOrEnabled: the system unit must be the
live or boot-configured supervisor. Otherwise doctor explains the ambiguity and
prints both fix directions instead of deleting anything. An erroring probe
fails closed for the same reason.
Applies the ClawSweeper rank-up move on #91221.
Address review: a marker-owned system unit with a *different* name (e.g. an
intentional rescue-bot gateway on the same host) must not be classified as a
duplicate of the canonical user unit, or doctor could remove a legitimate user
gateway. Dueling now requires user.unitName === system.unitName; the direct
canonical system path still matches the real #79375 case. Adds a regression
test for "user canonical + custom marker-owned system" -> kind:"user".
After upgrading on Linux, a user-scope unit
(~/.config/systemd/user/openclaw-gateway.service) and a system-scope unit
(/etc/systemd/system/openclaw-gateway.service) can both exist and both try to
manage the gateway. They bind the same port and each instance's stale-process
detection SIGTERMs the other, producing an endless restart cascade (#79375).
The detector `findInstalledSystemdGatewayScope` checked the user path first and
returned early, so it could never surface that both scopes coexist.
This adds, in three layers:
1. Detection: `findSystemdGatewayInstallation` — a discriminated union
(none/user/system/dueling) that reports every installed scope without
early-returning. `findInstalledSystemdGatewayScope` is refactored to delegate
to it while preserving the exact user-first preference its four lifecycle
callers rely on (no behavior change for stop/restart/is-enabled/runtime).
Adds `uninstallUserSystemdGatewayUnit` (removes only the $HOME user unit, no
root needed) and the pure `formatDuelingScopesWarning` helper.
2. Doctor: `maybeResolveDuelingSystemdGatewayScopes` detects the dueling state
and, after the existing confirm/policy gate, removes the redundant user-scope
unit while keeping the root-installed system unit authoritative. Declining or
an externally-managed policy falls back to the existing cleanup hints.
3. Startup guard: in service mode, when a stale-kill actually happened, log a
targeted remediation pointing at `openclaw doctor --fix` instead of letting
the loop look like routine stale cleanup. Diagnostic only — the kill decision
is unchanged.