fix(ops): generated systemd/launchd units bake a PATH so bridges find user tools - #593
Conversation
… find user tools `install-service` emitted units that set only CLAUSTER_CONFIG, so the service inherited systemd's minimal default PATH. clauster propagates its environment to every spawned bridge (procutil.child_env), so bridge agents couldn't resolve user-local tools (uv/ruff/pytest, nvm node/npx, cargo, go) that work fine in an interactive shell — a hard-to-diagnose gap. Bake an Environment=PATH (systemd) / EnvironmentVariables PATH key (launchd) of the run-as user's ~/.local/bin plus the standard system dirs, and add a unit comment pointing operators at claude.path_append / claude.env for shell-managed toolchains a static dir can't cover. windows/nssm is left as-is — the minimal PATH problem is POSIX-specific. systemd's %h specifier resolves to /root for a system unit regardless of User=, so the home is resolved at render time via the passwd db, falling back to a platform-aware /home (or /Users on macOS) when the user isn't on the host yet. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
|
| Filename | Overview |
|---|---|
| src/clauster/ops.py | Adds _home_for_user, _service_path, and wires baked PATH into both systemd and launchd renderers; systemd value is double-quoted, launchd value is XML-escaped — both are correct. |
| tests/test_ops.py | Four new test cases cover the passwd hit, platform-aware fallback (pinned via monkeypatch), and space-in-home quoting; all assertions are substring-based so they survive varying CI host home directories. |
| docs/installation.md | Adds the baked PATH line to the example systemd unit and an explanatory callout note directing operators to claude.path_append for shell-managed toolchains. |
| docs/configuration.md | Refines the path_append documentation to reflect that ~/.local/bin is now baked in by the generated unit, so path_append is for shell-managed runtimes only. |
| .changeset/590-install-service-unit-path.md | Patch-level changeset entry correctly describing the fix. |
Flowchart
%%{init: {'theme': 'neutral'}}%%
flowchart TD
A["clauster install-service"] --> B["render_service_unit(kind, user)"]
B --> C["_service_path(user)"]
C --> D["_home_for_user(user)"]
D --> E{user provided?}
E -- yes --> F["pwd.getpwnam(user).pw_dir"]
F -- success --> G["real home dir"]
F -- "KeyError / OSError" --> H{sys.platform?}
H -- darwin --> I["/Users/user"]
H -- other --> J["/home/user"]
E -- no --> K["os.path.expanduser ~"]
G & I & J & K --> L["home/.local/bin + system dirs"]
L --> M{kind?}
M -- systemd --> N["Environment=PATH=... double-quoted"]
M -- launchd --> O["xml-escaped PATH in EnvironmentVariables"]
N --> P["Bridges inherit PATH via child_env"]
O --> P
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
flowchart TD
A["clauster install-service"] --> B["render_service_unit(kind, user)"]
B --> C["_service_path(user)"]
C --> D["_home_for_user(user)"]
D --> E{user provided?}
E -- yes --> F["pwd.getpwnam(user).pw_dir"]
F -- success --> G["real home dir"]
F -- "KeyError / OSError" --> H{sys.platform?}
H -- darwin --> I["/Users/user"]
H -- other --> J["/home/user"]
E -- no --> K["os.path.expanduser ~"]
G & I & J & K --> L["home/.local/bin + system dirs"]
L --> M{kind?}
M -- systemd --> N["Environment=PATH=... double-quoted"]
M -- launchd --> O["xml-escaped PATH in EnvironmentVariables"]
N --> P["Bridges inherit PATH via child_env"]
O --> P
Reviews (4): Last reviewed commit: "docs: document the baked unit PATH and r..." | Re-trigger Greptile
_home_for_user returned a pathlib.Path, whose str form is platform-dependent —
on the Windows CI runner Path("/home/x") stringified to "\home\x", so the baked
PATH (and its test) broke. These are POSIX-only systemd/launchd units, so return
a plain POSIX string instead.
Also harden the new tests for the macOS/Windows runners: pin sys.platform where
the home-root branch is under test, drop the test-level `import pwd` in favor of
a natural passwd miss, and skip the passwd-db test on Windows (no pwd module).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ir is safe Greptile P2: systemd splits an unquoted Environment= value on whitespace, so a home directory containing a space (/home/john doe/.local/bin) would truncate the baked PATH. Wrap the assignment in double quotes — systemd strips them and keeps the span whole — and add a test rendering a space-bearing home. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The install-service systemd/launchd units now bake a PATH, so update the docs to match the change: - installation.md: the embedded sample unit shows the quoted Environment=PATH line, with a new note explaining bridges inherit it and pointing at claude.path_append / claude.env for shell-managed toolchains. - configuration.md: the path_append prose no longer frames it as the fix for a "minimal service PATH" (the generated unit isn't minimal anymore) — it's now for what a static dir can't cover (nvm/pyenv/cargo/go) and non-unit deploys. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Merging this PR tags and publishes **v0.12.4**.
Generated by knope from the `.changeset/` fragments — don't edit it by
hand; add or amend a changeset on the source PR instead.
---
## Fixes
- Fix the live bridge-log tail going dead after a service restart +
bridge reattach (the upgrade path). A reattached bridge was rebuilt
without its `bridge_debug_log_path`, so `/ws/bridge-log/{instance_id}`
closed every connection immediately — the tail flickered through a few
reconnect attempts and then gave up with "Live tail disconnected",
leaving the operator blind to a bridge that was actually alive. Both
reattach paths now re-bind the tail to the log the bridge is still
writing: a pty survivor derives it from its keeper sidecar's shared
spawn-set stem, and a standard survivor recovers the newest debug log it
wrote. The Reconnect button also resets the consecutive-failure counter
on a manual retry so it can no longer be a no-op once auto-reconnect has
capped out. ([#595](#595))
- Fix `clauster install-service` so a frozen/standalone binary (or
`clauster` console-script) install no longer emits a service unit with
an invalid `clauster -m clauster run` command — the unit now invokes the
clauster entry point directly across systemd, launchd, and Windows/nssm,
and only a bare `python -m clauster` interpreter keeps the module
prefix. ([#588](#588))
- Fix `clauster install-service` so the generated systemd and launchd
units bake a `PATH` (the run-as user's `~/.local/bin` plus the standard
system dirs) instead of leaving the service with a minimal default.
Clauster propagates its environment to every spawned bridge, so
previously a bridge agent couldn't resolve user-local tools
(`uv`/`ruff`/`pytest`, etc.) that work fine in an interactive shell. A
unit comment now points operators at `claude.path_append` / `claude.env`
for shell-managed toolchains (nvm/pyenv/cargo/go).
([#593](#593))
- Fix the hosted live-view rendering each assistant reply twice. One
assistant turn emits both the streamed `assistant` frame and a trailing
`result` frame whose `result` field repeats the same text, and the
live-view rendered both — the message as white paragraphs and the result
echo as a green run-on block. The `result` frame now collapses a
successful turn to a "turn complete" marker and surfaces text only on
the error path (where `result` carries content no assistant frame emits,
e.g. "Not logged in · Please run /login").
([#596](#596))
- Fix clauster-spawned hosted (claustrum) sessions being misclassified
as `EXTERNAL`/"unmanaged" by the `claude agents --json` cross-check,
which also left a stale Active card alongside the Stopped one after Stop
— the poll loop now recognizes the hosted registry (by claustrum agent
pid, with a workspace-cwd fallback for pre-CT-1 daemons, plus CL-8
orphan survivors) and attributes those sessions to Clauster instead of
surfacing them as external.
([#594](#594))
Co-authored-by: clauster-ci[bot] <289303168+clauster-ci[bot]@users.noreply.github.com>
Summary
clauster install-service <kind>generated units that set onlyCLAUSTER_CONFIG, never aPATH. Under systemd a service inherits a minimal default PATH (/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin) — it does not source the operator's login shell. Clauster propagates its own environment to every spawned bridge viaprocutil.child_env(), so everyclaudebridge inherited that minimal PATH and couldn't resolve user-local tools (uvin~/.local/bin, nvmnode/npx,cargo, Go tools). The bridge still ran, but an agent inside it couldn't invokeuv/ruff/pytest/node— a confusing, hard-to-diagnose gap.Change
render_service_unit()now bakes aPATHinto the systemd unit (Environment=PATH=…) and the launchd plist (aPATHkey inEnvironmentVariables): the run-as user's~/.local/binfirst, then the standard system bin dirs._home_for_user()/_service_path()helpers. systemd's%hspecifier resolves to/rootfor a system unit regardless ofUser=, so it can't reach the operator's home — the home is resolved at render time via the passwd db, falling back to a platform-aware/home(or/Userson macOS) when the user isn't present on the rendering host yet.claude.path_append/claude.env(from feat(config): allow extending the bridge subprocess PATH/env via clauster.yml #504) for shell-managed toolchains (nvm/pyenv/cargo/go) that a static dir can't cover.Tests
_home_for_userbranches: passwd-db hit (resolves the real home) and the lookup-miss fallback (Linux/home, macOS/Users).Reviewed against the project's safety invariants with
clauster-reviewer: no injection regression (theuservalue already lands raw on theUser=directive; launchd is XML-escaped), launchd plist stays valid XML, PATH is purely additive.Closes #590