diff --git a/apps/cli/src/service/templates.test.ts b/apps/cli/src/service/templates.test.ts
index c036241ea6..735b04c80f 100644
--- a/apps/cli/src/service/templates.test.ts
+++ b/apps/cli/src/service/templates.test.ts
@@ -17,6 +17,14 @@ const BREW_LAUNCHER: LauncherInfo = {
version: "0.49.0",
};
+// Versioned bin dir typical of per-user node managers (fnm/nvm/asdf/volta/mise).
+const FNM_LAUNCHER: LauncherInfo = {
+ nodePath: "/home/alice/.local/share/fnm/node-versions/v24.14.0/installation/bin/node",
+ cliEntry:
+ "/home/alice/.local/share/fnm/node-versions/v24.14.0/installation/lib/node_modules/kandev/bin/cli.js",
+ kind: "npm",
+};
+
describe("renderSystemdUnit", () => {
it("renders a user unit with absolute paths and --headless", () => {
const unit = renderSystemdUnit({
@@ -69,6 +77,65 @@ describe("renderSystemdUnit", () => {
expect(unit).toContain("Environment=KANDEV_SERVER_PORT=9000");
});
+ it("prepends launcher node bin dir to PATH so npx-based agents resolve under fnm/nvm/asdf/volta/mise", () => {
+ const unit = renderSystemdUnit({
+ launcher: FNM_LAUNCHER,
+ homeDir: "/home/alice/.kandev",
+ logDir: "/home/alice/.kandev/logs",
+ mode: "user",
+ });
+ expect(unit).toContain(
+ "Environment=PATH=/home/alice/.local/share/fnm/node-versions/v24.14.0/installation/bin:/usr/local/bin:/usr/bin:/bin:/opt/homebrew/bin:/home/linuxbrew/.linuxbrew/bin",
+ );
+ });
+
+ it("does not duplicate node bin dir when it is already on the system PATH", () => {
+ const unit = renderSystemdUnit({
+ launcher: NPM_LAUNCHER,
+ homeDir: "/home/alice/.kandev",
+ logDir: "/home/alice/.kandev/logs",
+ mode: "user",
+ });
+ // /usr/local/bin already in SYSTEMD_PATH — dirname(nodePath) must not double it.
+ expect(unit).toContain(
+ "Environment=PATH=/usr/local/bin:/usr/bin:/bin:/opt/homebrew/bin:/home/linuxbrew/.linuxbrew/bin",
+ );
+ expect(unit).not.toContain("/usr/local/bin:/usr/local/bin");
+ });
+
+ it("prepends node bin dir for system-mode units too", () => {
+ const unit = renderSystemdUnit({
+ launcher: FNM_LAUNCHER,
+ homeDir: "/var/lib/kandev",
+ logDir: "/var/lib/kandev/logs",
+ mode: "system",
+ systemUser: "alice",
+ });
+ expect(unit).toContain(
+ "Environment=PATH=/home/alice/.local/share/fnm/node-versions/v24.14.0/installation/bin:/usr/local/bin:/usr/bin:/bin:/opt/homebrew/bin:/home/linuxbrew/.linuxbrew/bin",
+ );
+ });
+
+ it("does not prepend '.' when nodePath has no POSIX separator (Windows-style or bare filename)", () => {
+ // path.dirname('C:\\...\\node.exe') === '.' on POSIX. Without the
+ // isAbsolute guard, this would put CWD first in the daemon's PATH —
+ // a privilege-escalation footgun the systemd unit must not introduce.
+ const unit = renderSystemdUnit({
+ launcher: {
+ nodePath: 'C:\\Program Files\\node "Node"\\node.exe',
+ cliEntry: "/home/alice/cli.js",
+ kind: "unknown",
+ },
+ homeDir: "/home/alice/.kandev",
+ logDir: "/home/alice/.kandev/logs",
+ mode: "user",
+ });
+ expect(unit).not.toMatch(/^Environment=PATH=\.:/m);
+ expect(unit).toContain(
+ "Environment=PATH=/usr/local/bin:/usr/bin:/bin:/opt/homebrew/bin:/home/linuxbrew/.linuxbrew/bin",
+ );
+ });
+
it("quotes ExecStart paths that contain spaces", () => {
const unit = renderSystemdUnit({
launcher: {
@@ -104,6 +171,69 @@ describe("renderLaunchdPlist", () => {
expect(plist).not.toContain("KANDEV_BUNDLE_DIR");
});
+ it("prepends launcher node bin dir to PATH for plists too (fnm/nvm/asdf/volta/mise)", () => {
+ const plist = renderLaunchdPlist({
+ launcher: {
+ nodePath: "/Users/alice/.volta/tools/image/node/24.14.0/bin/node",
+ cliEntry: "/Users/alice/.volta/tools/image/packages/kandev/bin/cli.js",
+ kind: "npm",
+ },
+ homeDir: "/Users/alice/.kandev",
+ logDir: "/Users/alice/.kandev/logs",
+ mode: "user",
+ });
+ expect(plist).toContain(
+ "PATH\n /Users/alice/.volta/tools/image/node/24.14.0/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin",
+ );
+ });
+
+ it("does not duplicate node bin dir in plist PATH when already present", () => {
+ const plist = renderLaunchdPlist({
+ launcher: BREW_LAUNCHER,
+ homeDir: "/Users/alice/.kandev",
+ logDir: "/Users/alice/.kandev/logs",
+ mode: "user",
+ });
+ // /opt/homebrew/bin already first in LAUNCHD_PATH — must not be doubled.
+ expect(plist).toContain(
+ "PATH\n /opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin",
+ );
+ });
+
+ it("prepends node bin dir for system-mode LaunchDaemons too", () => {
+ const plist = renderLaunchdPlist({
+ launcher: {
+ nodePath: "/Users/alice/.volta/tools/image/node/24.14.0/bin/node",
+ cliEntry: "/Users/alice/.volta/tools/image/packages/kandev/bin/cli.js",
+ kind: "npm",
+ },
+ homeDir: "/Library/Application Support/kandev",
+ logDir: "/Library/Logs/kandev",
+ mode: "system",
+ systemUser: "_kandev",
+ });
+ expect(plist).toContain(
+ "PATH\n /Users/alice/.volta/tools/image/node/24.14.0/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin",
+ );
+ });
+
+ it("does not prepend '.' to plist PATH when nodePath has no POSIX separator", () => {
+ const plist = renderLaunchdPlist({
+ launcher: {
+ nodePath: "C:\\Program Files\\node\\node.exe",
+ cliEntry: "/home/alice/cli.js",
+ kind: "unknown",
+ },
+ homeDir: "/Users/alice/.kandev",
+ logDir: "/Users/alice/.kandev/logs",
+ mode: "user",
+ });
+ expect(plist).not.toMatch(/PATH<\/key>\s*\.:/);
+ expect(plist).toContain(
+ "PATH\n /opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin",
+ );
+ });
+
it("escapes XML special characters in paths", () => {
const plist = renderLaunchdPlist({
launcher: {
diff --git a/apps/cli/src/service/templates.ts b/apps/cli/src/service/templates.ts
index a6076630fd..e00425945b 100644
--- a/apps/cli/src/service/templates.ts
+++ b/apps/cli/src/service/templates.ts
@@ -1,3 +1,5 @@
+import path from "node:path";
+
import type { LauncherInfo } from "./paths";
export type UnitInputs = {
@@ -20,6 +22,26 @@ const SYSTEMD_PATH =
"/usr/local/bin:/usr/bin:/bin:/opt/homebrew/bin:/home/linuxbrew/.linuxbrew/bin";
const LAUNCHD_PATH = "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin";
+// Prepend the launcher node's bin dir so `npm`/`npx` resolve under per-user
+// node managers (fnm, nvm, asdf, volta, mise), where node lives in a versioned
+// subdirectory not covered by the system PATH. ExecStart already points at this
+// node, so its parent dir is exactly where the matching npm/npx live — without
+// this, npx-based ACP agents (claude, codex, opencode) fail to spawn.
+//
+// The isAbsolute guard exists because `path.dirname` on POSIX returns `'.'`
+// for a path with no `/` separator (e.g. a Windows-style `C:\…\node.exe`
+// passed through). Prepending `.` would put CWD ahead of system dirs in the
+// daemon's PATH — a privilege-escalation footgun. Production callers always
+// pass `process.execPath` (absolute on Linux/macOS), so the guard only kicks
+// in for non-POSIX or relative inputs from future callers / tests.
+function pathWithNodeBinDir(basePath: string, nodePath: string): string {
+ const nodeBinDir = path.dirname(nodePath);
+ if (!path.isAbsolute(nodeBinDir)) return basePath;
+ const parts = basePath.split(":");
+ if (parts.includes(nodeBinDir)) return basePath;
+ return `${nodeBinDir}:${basePath}`;
+}
+
/**
* Marker substring baked into every unit/plist kandev writes. Used to safely
* detect "this file is ours, overwriting is fine" vs "user has put something
@@ -45,7 +67,7 @@ export function renderSystemdUnit(input: UnitInputs): string {
const env: string[] = [
envLine("KANDEV_HOME_DIR", input.homeDir),
envLine("KANDEV_LOG_LEVEL", "info"),
- envLine("PATH", SYSTEMD_PATH),
+ envLine("PATH", pathWithNodeBinDir(SYSTEMD_PATH, input.launcher.nodePath)),
];
if (input.port !== undefined) {
env.push(envLine("KANDEV_SERVER_PORT", String(input.port)));
@@ -94,7 +116,7 @@ export function renderLaunchdPlist(input: UnitInputs): string {
const envEntries: Array<[string, string]> = [
["KANDEV_HOME_DIR", input.homeDir],
["KANDEV_LOG_LEVEL", "info"],
- ["PATH", LAUNCHD_PATH],
+ ["PATH", pathWithNodeBinDir(LAUNCHD_PATH, input.launcher.nodePath)],
];
if (input.port !== undefined) {
envEntries.push(["KANDEV_SERVER_PORT", String(input.port)]);