Skip to content

fix(ops): generated systemd/launchd units bake a PATH so bridges find user tools - #593

Merged
schubydoo merged 4 commits into
mainfrom
install-service-generated-unit-sets-no-path-so-s
Jun 25, 2026
Merged

fix(ops): generated systemd/launchd units bake a PATH so bridges find user tools#593
schubydoo merged 4 commits into
mainfrom
install-service-generated-unit-sets-no-path-so-s

Conversation

@schubydoo

Copy link
Copy Markdown
Owner

Summary

clauster install-service <kind> generated units that set only CLAUSTER_CONFIG, never a PATH. 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 via procutil.child_env(), so every claude bridge inherited that minimal PATH and couldn't resolve user-local tools (uv in ~/.local/bin, nvm node/npx, cargo, Go tools). The bridge still ran, but an agent inside it couldn't invoke uv/ruff/pytest/node — a confusing, hard-to-diagnose gap.

Change

  • render_service_unit() now bakes a PATH into the systemd unit (Environment=PATH=…) and the launchd plist (a PATH key in EnvironmentVariables): the run-as user's ~/.local/bin first, then the standard system bin dirs.
  • New _home_for_user() / _service_path() helpers. systemd's %h specifier resolves to /root for a system unit regardless of User=, 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 /Users on macOS) when the user isn't present on the rendering host yet.
  • Each renderer carries a comment pointing operators at 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.
  • windows/nssm left as-is — the minimal-PATH problem is POSIX-specific; acceptance covers systemd + launchd.

Tests

  • Assert the PATH provision + the operator-knob comment in the rendered systemd and launchd units.
  • Cover both _home_for_user branches: 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 (the user value already lands raw on the User= directive; launchd is XML-escaped), launchd plist stays valid XML, PATH is purely additive.

Closes #590

… 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

codecov Bot commented Jun 25, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@greptile-apps

greptile-apps Bot commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes a real operational gap: clauster install-service generated units with no PATH, so every bridge the service spawned inherited systemd's minimal default and couldn't find user-local tools (uv, ruff, pytest, etc.) even though they work fine in an interactive shell. The fix bakes a proper PATH into both the systemd Environment= directive (double-quoted to handle spaces in home dirs) and the launchd EnvironmentVariables dict, resolved from the run-as user's passwd-db entry at render time.

  • Two new helpers — _home_for_user() and _service_path() — do the home resolution with a clean platform-aware fallback for users not yet present on the rendering host.
  • Tests cover: passwd-db hit, lookup-miss fallback (Linux/macOS), space-in-home quoting, and presence of the operator comment pointing at claude.path_append/claude.env.
  • Docs updated in both configuration.md and installation.md to explain the static-vs-shell-managed toolchain boundary.

Confidence Score: 5/5

Safe to merge — the change is purely additive, writing an extra ENV directive into generated unit files, with no effect on already-deployed units.

The home-dir resolution has a well-tested fallback path, the systemd value is correctly double-quoted, the launchd value goes through XML escaping, and new tests cover all four branches. No existing behaviour is removed or altered.

No files require special attention.

Important Files Changed

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
Loading
%%{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
Loading

Reviews (4): Last reviewed commit: "docs: document the baked unit PATH and r..." | Re-trigger Greptile

Comment thread tests/test_ops.py Outdated
Comment thread src/clauster/ops.py Outdated
schubydoo and others added 3 commits June 25, 2026 09:13
_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>
@schubydoo
schubydoo merged commit 55fdb2a into main Jun 25, 2026
26 checks passed
@schubydoo
schubydoo deleted the install-service-generated-unit-sets-no-path-so-s branch June 25, 2026 17:03
schubydoo pushed a commit that referenced this pull request Jun 25, 2026
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

install-service: generated unit sets no PATH, so systemd bridges inherit a minimal PATH and can't find user tools (uv/node/cargo/go)

1 participant