The permission diagnosis added in #127703 compared Node's reported errno
path against the configured directory as raw strings. Node reports the
canonical path, so a config directory reached through a symlink never
matched and the operator fell back to the raw EACCES the change existed to
replace. macOS /var -> /private/var makes this ordinary, not exotic.
Resolve the directory only when the raw comparison fails, so successful
config writes gain no syscall. The narrow path check stays: an unrelated
permission error from the caller's own mutation must keep propagating.
The Mantis lane and the Telegram user driver reached for /proc to enforce
media containment and to prove a pid was still alive. Off Linux those paths
do not exist, so the containment check compared a literal "/proc/self/fd/N"
string against the output root and refused every file, while both liveness
probes silently reported "gone": the lane stole a held harness lock and the
driver deleted the observer pid file without ever signalling the process.
Containment now uses each runtime's strongest portable construct. Python
descends the media path one component at a time from an open directory
descriptor (O_DIRECTORY|O_NOFOLLOW, dir_fd), which is race-free and also
covers the intermediate directories the old leaf-only O_NOFOLLOW did not.
Node has no openat(2), so publicRelativePath re-walks the resolved
components after the descriptor is open and refuses any that became a
symlink. Liveness moves to process.kill(pid, 0) and to ps state/args, which
keeps the pid-reuse and zombie handling the /proc reads provided.
The compound-command approval binding test asserted an operand count that
only held where the host interpreters live in a root-owned prefix; a
Homebrew python3 is writable, so it correctly binds as a third executable
operand. Assert the script operands by path instead.
* fix(daemon): skip gateway port-release assertion when stopping a node-host LaunchAgent
The generic LaunchAgent stop path (assertGatewayPortReleasedAfterStop)
resolves a gateway-relative port from the stopped service's own
program arguments/env and then asserts that port is no longer bound
after stop. `resolveNodeService()` reuses this exact same stop path
for the node-host service, but a node-host's "--port" argument is the
Gateway port it connects to *outward as a client* -- it never binds
that port itself.
On a host where the node-host and Gateway are co-located (a common,
documented topology), this made `openclaw node stop`/`restart` fail
with a false-positive "gateway port NNNN is still busy after
LaunchAgent stop" error, because the assertion was inadvertently
checking whether the co-located Gateway's own (legitimately still
open) port had been released -- something the node-host stop has no
business asserting.
Skip the port-release assertion entirely when OPENCLAW_SERVICE_KIND
indicates the node-host service, using the existing service-kind
env marker already used elsewhere in the daemon lifecycle code for
this exact gateway-vs-node distinction.
Fixes openclaw/openclaw#124296 (secondary bug: node stop/restart
false-positive port-collision guard when co-located with Gateway).
* fix(daemon): apply node-vs-gateway port guard exemption to LaunchAgent restart
The stop path (assertGatewayPortReleasedAfterStop in launchd-stop.ts) was
previously patched to skip the gateway-port-released check when
OPENCLAW_SERVICE_KIND is "node", but restartLaunchAgent in
launchd-lifecycle.ts had its own independent busy-port ownership guard
that was not touched, so `openclaw node restart` still failed for
co-located node-host + Gateway topologies.
Extract the OPENCLAW_SERVICE_KIND-based decision into a single shared
helper, shouldSkipGatewayPortOwnershipCheck (new module
launchd-node-gateway-guard.ts), and use it from both the stop and
restart guards so the two lifecycle paths can no longer diverge.
Add a regression test for co-located restart succeeding, mirroring the
existing stop regression test.
Refs openclaw/openclaw#124296
* test(daemon): add real-launchctl proof for co-located node/gateway port guard (#124296)
Adds an e2e integration test that drives real launchctl (no mocked
launchctl or port-inspection calls): a real "gateway" LaunchAgent that
genuinely binds a scratch TCP port, and a real "node-host" LaunchAgent
(OPENCLAW_SERVICE_KIND=node) targeting that same port. Verifies both
stopLaunchAgent and restartLaunchAgent succeed against the real busy
co-located port, and that the co-located Gateway LaunchAgent stays
running throughout.
Run against the pre-fix commit (2a7d735), this reproduces the exact
reported false-positive:
Error: gateway port <port> is busy but is not verifiably owned by
LaunchAgent ai.openclaw.launchd-int-node-<id>
Run against this branch, all 8 launchd integration tests pass.
* test(daemon): probe real port busy-ness before exercising node lifecycle
Addresses ClawSweeper P2 ask: the real-launchctl e2e proof only relied on
the LaunchAgent runtime status reporting 'running' for the gateway, which
doesn't guarantee the listener has actually bound the scratch TCP port yet
(status vs. socket bind can race). Add explicit probePortUsage() calls
(the same real, unmocked TCP probe used in production code) immediately
before both the stop and restart assertions, so the test proves the port
is genuinely busy at the moment the guard is exercised, not just inferred
from launchd state.
* refactor(daemon): resolve gateway ownership at launchd context boundary
Co-authored-by: Anis Khan <2815766+aniskhan001@users.noreply.github.com>
---------
Co-authored-by: Anis Khan <2815766+aniskhan001@users.noreply.github.com>
Every config write failed with a bare Node errno naming openclaw.json.lock
when the config directory was not writable. The lock file is an internal
artifact; the directory permissions are the actual problem, so operators
were sent to investigate the wrong thing.
withConfigMutationLock is the choke point for all config writes, so the
diagnosis lands there once and every writer benefits. Only EACCES, EPERM,
and EROFS whose reported path sits directly in the config directory are
relabelled; the caller's mutation runs inside the same scope, so an
unrelated permission error from its own work must keep propagating.
The Model Setup page could sit at "Checking this Gateway for available AI
access…" forever on fresh clients: the route loader ran before the Gateway
websocket connected and returned a loading state with a null-client
connection, the lazy page module finished importing after hello, and the
page mounted with routeData undefined under an already-connected Gateway.
When the stale loader result settled a microtask later, willUpdate
correctly discarded it, but synchronizeGateway hit the identity-equality
early return and nothing ever armed detectTask again - a silent dead-end
(fresh headless clients hit it ~100%; warm tabs recovered only because
their cached module mounted before hello).
Replace the mount-branch routeData-connection comparison with a single
self-healing invariant, ensureRouteSettledDetection(), also called from
the identity-equality early return: once route data has settled, a page
still holding phase "loading" with an idle detect task and a connected,
capable Gateway starts detection itself. Guards keep the normal path
duplicate-free: undefined routeData means the loader's own detect is
still in flight, and hasUpdated defers to willUpdate's seeding since
subscriptions fire before the first render.
Regression test mounts the page without routeData under a connected
Gateway, then delivers the stale pre-connect loader result and asserts
exactly one openclaw.setup.detect and a rendered result; it fails on the
pre-fix code with zero detect calls. Live-verified with a headless
Playwright client against a dev gateway: pre-fix stalls past 45s, fixed
build reaches ready in ~5s with a single detect request.
Non-interactive onboarding's gateway-health failure emitted the generic
classification hint and the caller's phase-specific hint together, so the
first and most prominent Fix told operators to restart a managed gateway
while the next line said no daemon had been installed.
logNonInteractiveOnboardingFailure now suppresses the classification hint
when the caller already supplied one; the caller knows whether
--install-daemon was requested and the classifier does not. Callers that
pass no hints are unchanged.
* fix(tui): own local shell process scopes
Keep local shell root results independent from authoritative process-scope
extinction, and make TUI shutdown cancel and join its ProcessSupervisor scope.
On Windows, create commands atomically inside a retained non-breakaway Job
Object and prove extinction from exact Job accounting.
Fixes#127488
* fix(process): preserve optional extinction waits
The provider picker enumerates every plugin's providerAuthChoices (63
provider ids), but the bundled icon set only covered CodexBar's 52 marks,
so Arcee, Baseten, BytePlus, Cerebras, Cohere, DeepInfra, Featherless,
Fireworks, Hugging Face, Meta, NVIDIA, Together, and many more rendered
as lettered fallback badges.
Adds 24 monochrome marks from @lobehub/icons-static-svg v1.94.0 (MIT,
metadata-cleaned; mapping documented in ATTRIBUTION.md) and aliases
variant provider ids (kilocode, kimi-coding, xiaomi, ollama-cloud,
tencent-tokenhub, vercel-ai-gateway, ...) onto existing marks. GMI Cloud,
SGLang, and Vydra keep the lettered fallback: no vector mark exists
upstream.