Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 11 additions & 4 deletions docs/prd/PRD-004-cli-onboarding-and-config.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,8 +120,11 @@ Command ownership stays explicit:
- `netclaw daemon status` — check if daemon is running, show PID and uptime
- `netclaw daemon install` — register as a systemd user service
(`~/.config/systemd/user/netclaw.service`, no sudo). Supports
`loginctl enable-linger` for surviving logout.
- `netclaw daemon uninstall` — remove systemd user service registration
`loginctl enable-linger` for surviving logout. Captures the operator's real
shell `PATH` into `~/.netclaw/config/daemon.env` (loaded via `EnvironmentFile=`)
so the daemon's shell tool resolves the same binaries the operator can.
- `netclaw daemon uninstall` — remove systemd user service registration and the
captured `daemon.env`

### TUI-Interactive Commands (Termina, daemon required)

Expand Down Expand Up @@ -317,8 +320,12 @@ The CLI SHALL provide commands to manage the daemon lifecycle:
- `netclaw daemon status` SHALL report daemon state (running/stopped, PID, uptime)
- `netclaw daemon install` SHALL register as a systemd user service (Linux) or
LaunchAgent (macOS). No sudo required — uses `systemctl --user` and
`loginctl enable-linger` on Linux.
- `netclaw daemon uninstall` SHALL remove the service registration
`loginctl enable-linger` on Linux. On Linux it SHALL capture the operator's
real `PATH` (from the CLI process, without spawning a shell) into a
netclaw-owned `EnvironmentFile` so the daemon's shell tool resolves
operator-installed binaries; `netclaw doctor --fix` SHALL rehydrate it.
- `netclaw daemon uninstall` SHALL remove the service registration and the
captured environment file

### CLI-013 Daemon Process

Expand Down
25 changes: 23 additions & 2 deletions docs/spec/SPEC-011-daemon-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -224,15 +224,17 @@ capture crash signals even when the process is unstable.

```ini
[Unit]
Description=Netclaw Agent Daemon
Description=Netclaw Daemon
After=network.target

[Service]
Type=simple
ExecStart=/path/to/netclawd
ExecStop=/path/to/netclaw daemon stop
Restart=always
RestartSec=5
Environment=DOTNET_ENVIRONMENT=Production
EnvironmentFile=-/home/you/.netclaw/config/daemon.env

[Install]
WantedBy=default.target
Expand All @@ -241,7 +243,26 @@ WantedBy=default.target
No sudo required. Uses `systemctl --user enable netclaw` and
`loginctl enable-linger $USER` to survive user logout.

`netclaw daemon uninstall` stops the service and removes the unit file.
**Shell-tool PATH.** A systemd `--user` service starts with a sanitized,
non-interactive environment that does not inherit the operator's login-shell
`PATH`, so the agent's shell tool cannot resolve `netclaw`, `dotnet`, or
`~/.local/bin` binaries. Rather than bake a guessed directory list into the unit
(which can never anticipate every environment — see issue #1544), `install`
**captures the operator's real `PATH` from its own process** (the CLI is a child
of the operator's shell, so no shell is spawned and no dotfiles are sourced) and
writes `PATH=<installDir>:<captured>:<system floor>` (de-duplicated, empty elements dropped; the
floor `/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin` keeps the shell functional even if the
captured PATH was empty or partial) to `~/.netclaw/config/daemon.env`. The unit
loads it via `EnvironmentFile=-…` (the `-` makes a missing file degrade tool
resolution rather than block startup). `netclaw doctor --fix` rehydrates the file
from the operator's current `PATH`; `SystemdUnitPathDoctorCheck` validates the
wiring and contents. Producer, rehydrator, and validator share
`DaemonPathEnvironmentFile`. The value is a snapshot as of the last
`install`/`doctor --fix`, so after installing new tools re-run either and then
`systemctl --user restart netclaw`.

`netclaw daemon uninstall` stops the service and removes the unit file and the
`daemon.env` file.

### Service Registration (macOS)

Expand Down
2 changes: 1 addition & 1 deletion feeds/skills/.system/files/netclaw-operations/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ name: netclaw-operations
description: "REQUIRED when the user asks about scheduling, reminders, cron jobs, timers, background jobs, diagnostics, troubleshooting, MCP tools, daemon health, identity updates, or Netclaw capabilities and self-maintenance."
metadata:
author: netclaw
version: "2.23.1"
version: "2.24.0"
---

# Netclaw Operations
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ debugging a daemon-wide problem → read `daemon.log`.
| Daemon won't start | crash logs at `~/.netclaw/logs/crash-*.log` |
| Docker daemon cannot create `/home/netclaw/.netclaw/*` | Official image entrypoint repairs writable bind mounts to UID/GID `1654:1654`; if bypassed or read-only, run `sudo chown -R 1654:1654 <host-data-dir>` or use a Docker named volume |
| Discord/Slack channel offline | `netclaw status` shows the channel `disconnected` with a reason. Discord may also report `degraded` when Discord.Net says the socket is connected but the gateway is not ready, such as after a resumed session that Netclaw is replacing with a clean reconnect. A misconfigured channel (bad token, missing Discord Message Content intent) degrades only that channel — the daemon keeps running and other channels are unaffected. A transient network failure retries automatically; a config/permission failure stays offline until the operator fixes the config and restarts the daemon. |
| `command not found` for `netclaw` from shell tool when daemon runs as systemd service | `netclaw doctor` (the **Systemd Unit PATH** check warns when the unit was installed before PATH was baked in) |
| `command not found` for `netclaw`/`dotnet`/a user tool from the shell tool when the daemon runs as a systemd service | The systemd `--user` service does not inherit your login-shell `PATH`; `netclaw daemon install` captures it into `~/.netclaw/config/daemon.env`. Run `netclaw doctor` (the **Systemd Unit PATH** check flags a missing/stale/legacy env file), then `netclaw doctor --fix` to rehydrate `PATH` from your current shell (or re-run `netclaw daemon install`), and finally `systemctl --user restart netclaw`. Installed a new tool after install? Its dir won't be seen until you re-run one of those and restart. Per-directory managers (`mise`/`asdf`/`direnv`) are not captured. |

If webhook notifications are configured, daemon crash paths emit
`daemon.crashing` operational alerts with context (PID, reason, and latest known
Expand Down
2 changes: 2 additions & 0 deletions openspec/changes/systemd-daemon-path-capture/.openspec.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-07-03
168 changes: 168 additions & 0 deletions openspec/changes/systemd-daemon-path-capture/design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
## Context

A systemd `--user` service starts with a sanitized, non-interactive environment. It does **not**
read `~/.bashrc`/`~/.profile` and does **not** inherit the operator's login-shell `PATH` — by
design, so services are reproducible. Netclaw's agent shell tool (and
`BackgroundJobExecutionActor`) spawn `bash -c` from the daemon, so under the installed service they
see only whatever `PATH` the unit provides.

Today `netclaw daemon install` compensates by baking a **hardcoded** `Environment=PATH=` list into
the unit (`DaemonManager.ComposeSystemdUnitPath`): install dir, `~/.local/bin`, and the standard
system dirs. GitHub #1544 is the failure mode: `~/.dotnet` is not in that list, so the daemon's
shell tool cannot find `dotnet`. Any hardcoded list is a guess that will be wrong for some
operator.

The operator's shell already knows the correct `PATH`. Crucially, so does any `netclaw` CLI process
the operator launches from that shell — it inherits `PATH` as a normal environment variable. This
design captures that inherited value instead of guessing, and never runs operator shell code from
the daemon.

Confirmed premise (from code review of `Netclaw.Security`): the shell command policy is
`PATH`-independent. `ShellCommandPolicy` is a deny-list matching the literal typed verb token
(tokenized, punctuation-trimmed); `ApprovalPatternMatching` keys on verb + directory scoping. No
enforcement path resolves a command against `$PATH`, and there is no resolved-path allow-list.
Therefore widening the daemon's `PATH` changes only bare-name *resolution* (ergonomics), never the
security decision — so capturing the operator's `PATH` is safe.

## Goals / Non-Goals

**Goals:**

- The installed daemon's shell tool resolves the same tools the operator can resolve, without
hand-maintaining a directory list.
- Never execute operator shell/dotfiles from the daemon (no boot hang, no surprise side effects).
- Keep the value refreshable through commands the operator already runs (`daemon install`,
`doctor --fix`).
- Keep the producer (`DaemonManager`) and consumer (`SystemdUnitPathDoctorCheck`) in exact
agreement on the file, wiring, and contents.

**Non-Goals:**

- Live/continuous `PATH` sync. The value is a snapshot as of the last install / `doctor --fix`.
- Capturing dynamic per-directory `PATH` managers (`mise`, `asdf`, `direnv`).
- Changing manual `netclaw daemon start` (non-systemd), which already inherits the operator `PATH`.
- macOS/Windows service install (still unsupported).

## Decisions

### D1: Capture the CLI's inherited PATH — don't guess, don't source a shell

Read `Environment.GetEnvironmentVariable("PATH")` in the CLI process at install / `doctor --fix`
time. The CLI is a child of the operator's interactive shell, so this is the operator's real `PATH`
with **zero** shell execution.

- **vs. hardcoded list (status quo):** guaranteed to miss someone's tools (the #1544 bug). Rejected.
- **vs. daemon spawns `bash -lc` at startup:** would be fresher, but runs operator dotfiles in an
unsupervised background service — can hang boot, has unpredictable side effects, and to "fail
loud" on probe failure we'd need a fallback anyway. Rejected; violates the repo's
no-silent-fallback posture.
- **vs. global `~/.config/environment.d/`:** the systemd-blessed mechanism, but it (a) changes
`PATH` for **every** user service, not just netclaw, and (b) its `${PATH}` resolves to the
manager's sanitized default — it would **not** capture `~/.dotnet` without the operator hand-
listing dirs, so it does not actually solve #1544. Rejected as the primary mechanism.

### D2: Deliver via `EnvironmentFile=` (unit-scoped), not inline `Environment=` or environment.d

The unit references a netclaw-owned file: `EnvironmentFile=<path>` with a single `PATH=...` line.

- Unit-scoped → zero blast radius on other user services (unlike environment.d).
- Separately rewritable → `doctor --fix` can rehydrate `PATH` without rewriting the unit.
- `daemon-reload` + service restart deterministically re-reads it (vs. environment.d's fuzzy
re-read semantics).
- Clean removal on uninstall.

### D3: Env-file location reuses `NetclawPaths`

`DaemonManager` already receives a `NetclawPaths`. Add a `DaemonEnvironmentFilePath` property under
the existing `ConfigDirectory` (e.g. `<BasePath>/config/daemon.env`) rather than computing a new
ad-hoc path in `DaemonManager`. This follows the repo's "reuse before you add" rule and keeps the
path a single source of truth shared by the producer, the doctor check, and uninstall. The unit
references it by resolved absolute path.

### D4: Install-dir is prepended to the captured PATH

Provisioned value = `installDir : <captured operator PATH> : <system floor>`, de-duplicated with
empty elements dropped. installDir leads (bundled CLI wins), the operator's real dirs follow (the
point of #1544), and a guaranteed floor (`/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin`) is always
appended.

**The floor is not a guess at the operator's tools — it is a functional baseline** (a POSIX shell,
coreutils, admin `sbin` tools) that the old unit-baked PATH provided unconditionally. Code review
caught that dropping it regressed two cases the old code could never hit: an empty/unset capture
would yield `PATH=installDir` alone (silently breaking *every* shell command, and the doctor check
would still pass it), and a normal desktop login PATH omits `/usr/sbin`,`/sbin` (so `ip`/`iptables`
would stop resolving). Appending the floor restores the old guarantee while keeping the captured
operator dirs. **Empty `PATH` elements are dropped** because POSIX resolves an empty element to the
current directory — with the daemon running `bash -c` in an agent-controlled workspace, a captured
`::` (common from `PATH="$PATH:"` in a dotfile) would let a planted binary shadow a system command.

### D5: `doctor --fix` rehydration lives outside the config-file gate

`DoctorFixService.BuildPlanAsync` currently early-returns when `netclaw.json` is absent and only
emits `DoctorFileFix`es against the config JSON. The daemon env file is independent of app config,
so its rehydration fix is evaluated **before/around** that early-return. It reuses the existing
`DoctorFileFix` (path + original + updated text) + `ApplyAsync` file-write model. The fix is emitted
only when the file is missing, unwired, or missing the install dir — avoiding needless churn. The
fix description/plan surfaces the required `systemctl --user restart netclaw`; `ApplyAsync` writes
files only and never restarts the daemon.

### D6: Doctor check validates wiring + file, not an inline PATH line

`SystemdUnitPathDoctorCheck` moves from parsing the unit's `Environment=PATH=` to: (1) unit has
`EnvironmentFile=` → env file; (2) env file exists; (3) env file `PATH` includes the install dir
(install dir still derived from the unit's `ExecStart`). This keeps the check as the enforcement of
the producer/consumer contract, now against the new shape.

### D8: `EnvironmentFile=-` is tolerant of a missing file (surfaced during implementation)

The unit uses the `-` prefix (`EnvironmentFile=-<path>`), so a deleted/missing env file
degrades the daemon's shell-tool PATH to the sanitized systemd default rather than preventing
the entire daemon (Slack, connectors, everything) from starting. The strict alternative
(`EnvironmentFile=<path>`, service fails to start on a missing file) is "louder", but taking the
whole daemon down over a missing PATH helper file is disproportionate — and PATH is *not* a
security boundary here (see the confirmed premise), so a degraded PATH is a functionality gap, not
a privilege issue. The `SystemdUnitPathDoctorCheck` warning + `doctor --fix` make the degraded
state discoverable and repairable, which is the right altitude for a non-security degradation.

### D7: Freshness & restart are explicit, not implicit

`PATH` is current as of the last `install` / `doctor --fix`. Installing a new tool afterward
requires re-running either, then restarting the service. Both commands print the restart
instruction. The doctor **check** nudges when the file is missing/stale, closing the loop.

## Risks / Trade-offs

- **[Snapshot goes stale after installing new tools]** → `doctor --fix` rehydrates in one command;
the doctor check warns when the install dir is absent from `PATH`. Documented in the
`netclaw-operations` skill.
- **[`EnvironmentFile=` only re-read on unit (re)start]** → install and `doctor --fix` both surface
the explicit `systemctl --user restart netclaw` step; no silent restart.
- **[Captured PATH is only as good as the shell that ran the command]** → if the operator installs
from a minimal shell missing a tool dir, the daemon inherits that gap. Re-run from a normal shell
or after fixing the shell; `doctor --fix` re-captures. Documented.
- **[Dynamic per-directory PATH managers not captured]** → out of scope; documented limitation.
- **[Backward compat: existing installs still carry inline `Environment=PATH=`]** → they keep
working; the rewritten doctor check flags them (no `EnvironmentFile=`), and the next
`daemon install` (or `doctor --fix` + restart) migrates them to the env-file shape and drops the
inline directive.

## Migration Plan

1. Ship the code. No config-schema change — the env file is not part of `netclaw.json`, so no
`netclaw-config.v1.schema.json` update and no `SchemaFixResolver` interaction.
2. Existing installed services are unaffected until acted on. On upgrade, the doctor check surfaces
a warning for the old inline-PATH shape.
3. Operator remediation: `netclaw daemon install` (re-run, idempotent — rewrites unit + writes env
file) **or** `netclaw doctor --fix`, then `systemctl --user restart netclaw`.
4. Rollback: revert the code. Old and new unit shapes both keep the service runnable; a stray
`config/daemon.env` left by a newer build is inert to older builds (they ignore it).

## Open Questions

- Env-file basename under `ConfigDirectory`: `daemon.env` proposed — confirm no collision with
existing config assets in that directory.
- Should the doctor check treat "install dir present but a *previously captured* dir now missing"
as stale? MVP: only warns when the install dir is absent or the file is missing/unwired, to avoid
false positives from legitimately changed PATHs.
- Should `daemon install` offer to restart the service for the operator? MVP: instruct only, to
keep install non-disruptive and consistent with `doctor --fix`.
Loading
Loading