From 055d839fc308031f88f1b7eddfc5dcbe6439ef98 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 20 Dec 2025 13:31:19 +0000 Subject: [PATCH] feat(runtime): bootstrap PATH for clawdis --- .../Clawdis/GatewayLaunchAgentManager.swift | 10 +- src/gateway/server.ts | 3 + src/index.ts | 2 + src/infra/path-env.test.ts | 66 +++++++++++++ src/infra/path-env.ts | 99 +++++++++++++++++++ 5 files changed, 179 insertions(+), 1 deletion(-) create mode 100644 src/infra/path-env.test.ts create mode 100644 src/infra/path-env.ts diff --git a/apps/macos/Sources/Clawdis/GatewayLaunchAgentManager.swift b/apps/macos/Sources/Clawdis/GatewayLaunchAgentManager.swift index 585b88a0c..e0a302088 100644 --- a/apps/macos/Sources/Clawdis/GatewayLaunchAgentManager.swift +++ b/apps/macos/Sources/Clawdis/GatewayLaunchAgentManager.swift @@ -10,6 +10,10 @@ enum GatewayLaunchAgentManager { "\(bundlePath)/Contents/Resources/Relay/clawdis-gateway" } + private static func relayDir(bundlePath: String) -> String { + "\(bundlePath)/Contents/Resources/Relay" + } + static func status() async -> Bool { guard FileManager.default.fileExists(atPath: self.plistURL.path) else { return false } let result = await self.runLaunchctl(["print", "gui/\(getuid())/\(gatewayLaunchdLabel)"]) @@ -45,6 +49,10 @@ enum GatewayLaunchAgentManager { private static func writePlist(bundlePath: String, port: Int) { let gatewayBin = self.gatewayExecutablePath(bundlePath: bundlePath) + let relayDir = self.relayDir(bundlePath: bundlePath) + let preferredPath = + ([relayDir] + CommandResolver.preferredPaths()) + .joined(separator: ":") let plist = """ @@ -69,7 +77,7 @@ enum GatewayLaunchAgentManager { EnvironmentVariables PATH - \(CommandResolver.preferredPaths().joined(separator: ":")) + \(preferredPath) CLAWDIS_SKIP_BROWSER_CONTROL_SERVER 1 CLAWDIS_IMAGE_BACKEND diff --git a/src/gateway/server.ts b/src/gateway/server.ts index 31dec3b75..3a21dfbf2 100644 --- a/src/gateway/server.ts +++ b/src/gateway/server.ts @@ -67,6 +67,7 @@ import { requestNodePairing, verifyNodeToken, } from "../infra/node-pairing.js"; +import { ensureClawdisCliOnPath } from "../infra/path-env.js"; import { enqueueSystemEvent } from "../infra/system-events.js"; import { listSystemPresence, @@ -102,6 +103,8 @@ import { requestReplyHeartbeatNow } from "../web/reply-heartbeat-wake.js"; import { buildMessageWithAttachments } from "./chat-attachments.js"; import { handleControlUiHttpRequest } from "./control-ui.js"; +ensureClawdisCliOnPath(); + let stopBrowserControlServerIfStarted: (() => Promise) | null = null; async function startBrowserControlServerIfEnabled(): Promise { diff --git a/src/index.ts b/src/index.ts index 3b7ad6362..24df05eac 100644 --- a/src/index.ts +++ b/src/index.ts @@ -17,6 +17,7 @@ import { saveSessionStore, } from "./config/sessions.js"; import { ensureBinary } from "./infra/binaries.js"; +import { ensureClawdisCliOnPath } from "./infra/path-env.js"; import { describePortOwner, ensurePortAvailable, @@ -30,6 +31,7 @@ import { monitorWebProvider } from "./provider-web.js"; import { assertProvider, normalizeE164, toWhatsappJid } from "./utils.js"; dotenv.config({ quiet: true }); +ensureClawdisCliOnPath(); // Capture all console output into structured logs while keeping stdout/stderr behavior. enableConsoleCapture(); diff --git a/src/infra/path-env.test.ts b/src/infra/path-env.test.ts new file mode 100644 index 000000000..b3dbf969f --- /dev/null +++ b/src/infra/path-env.test.ts @@ -0,0 +1,66 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { ensureClawdisCliOnPath } from "./path-env.js"; + +describe("ensureClawdisCliOnPath", () => { + it("prepends the bundled Relay dir when a sibling clawdis exists", async () => { + const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-path-")); + try { + const relayDir = path.join(tmp, "Relay"); + await fs.mkdir(relayDir, { recursive: true }); + const gatewayPath = path.join(relayDir, "clawdis-gateway"); + const cliPath = path.join(relayDir, "clawdis"); + await fs.writeFile(gatewayPath, "#!/bin/sh\nexit 0\n", "utf-8"); + await fs.writeFile(cliPath, "#!/bin/sh\necho ok\n", "utf-8"); + await fs.chmod(gatewayPath, 0o755); + await fs.chmod(cliPath, 0o755); + + const originalPath = process.env.PATH; + const originalFlag = process.env.CLAWDIS_PATH_BOOTSTRAPPED; + process.env.PATH = "/usr/bin"; + delete process.env.CLAWDIS_PATH_BOOTSTRAPPED; + try { + ensureClawdisCliOnPath({ + execPath: gatewayPath, + cwd: tmp, + homeDir: tmp, + platform: "darwin", + }); + const updated = process.env.PATH ?? ""; + expect(updated.split(path.delimiter)[0]).toBe(relayDir); + } finally { + process.env.PATH = originalPath; + if (originalFlag === undefined) + delete process.env.CLAWDIS_PATH_BOOTSTRAPPED; + else process.env.CLAWDIS_PATH_BOOTSTRAPPED = originalFlag; + } + } finally { + await fs.rm(tmp, { recursive: true, force: true }); + } + }); + + it("is idempotent", () => { + const originalPath = process.env.PATH; + const originalFlag = process.env.CLAWDIS_PATH_BOOTSTRAPPED; + process.env.PATH = "/bin"; + process.env.CLAWDIS_PATH_BOOTSTRAPPED = "1"; + try { + ensureClawdisCliOnPath({ + execPath: "/tmp/does-not-matter", + cwd: "/tmp", + homeDir: "/tmp", + platform: "darwin", + }); + expect(process.env.PATH).toBe("/bin"); + } finally { + process.env.PATH = originalPath; + if (originalFlag === undefined) + delete process.env.CLAWDIS_PATH_BOOTSTRAPPED; + else process.env.CLAWDIS_PATH_BOOTSTRAPPED = originalFlag; + } + }); +}); diff --git a/src/infra/path-env.ts b/src/infra/path-env.ts new file mode 100644 index 000000000..f8703eba0 --- /dev/null +++ b/src/infra/path-env.ts @@ -0,0 +1,99 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +type EnsureClawdisPathOpts = { + execPath?: string; + cwd?: string; + homeDir?: string; + platform?: NodeJS.Platform; + pathEnv?: string; +}; + +function isExecutable(filePath: string): boolean { + try { + fs.accessSync(filePath, fs.constants.X_OK); + return true; + } catch { + return false; + } +} + +function isDirectory(dirPath: string): boolean { + try { + return fs.statSync(dirPath).isDirectory(); + } catch { + return false; + } +} + +function mergePath(params: { existing: string; prepend: string[] }): string { + const partsExisting = params.existing + .split(path.delimiter) + .map((part) => part.trim()) + .filter(Boolean); + const partsPrepend = params.prepend + .map((part) => part.trim()) + .filter(Boolean); + + const seen = new Set(); + const merged: string[] = []; + for (const part of [...partsPrepend, ...partsExisting]) { + if (!seen.has(part)) { + seen.add(part); + merged.push(part); + } + } + return merged.join(path.delimiter); +} + +function candidateBinDirs(opts: EnsureClawdisPathOpts): string[] { + const execPath = opts.execPath ?? process.execPath; + const cwd = opts.cwd ?? process.cwd(); + const homeDir = opts.homeDir ?? os.homedir(); + const platform = opts.platform ?? process.platform; + + const candidates: string[] = []; + + // Bun bundled (macOS app): `clawdis` is shipped next to `clawdis-gateway`. + try { + const execDir = path.dirname(execPath); + const siblingClawdis = path.join(execDir, "clawdis"); + if (isExecutable(siblingClawdis)) candidates.push(execDir); + } catch { + // ignore + } + + // Project-local installs (best effort): if a `node_modules/.bin/clawdis` exists near cwd, + // include it. This helps when running under launchd or other minimal PATH environments. + const localBinDir = path.join(cwd, "node_modules", ".bin"); + if (isExecutable(path.join(localBinDir, "clawdis"))) + candidates.push(localBinDir); + + // Common global install locations (macOS first). + if (platform === "darwin") { + candidates.push(path.join(homeDir, "Library", "pnpm")); + } + candidates.push(path.join(homeDir, ".local", "share", "pnpm")); + candidates.push(path.join(homeDir, ".bun", "bin")); + candidates.push(path.join(homeDir, ".yarn", "bin")); + candidates.push("/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin"); + + return candidates.filter(isDirectory); +} + +/** + * Best-effort PATH bootstrap so skills that require the `clawdis` CLI can run + * under launchd/minimal environments (and inside the macOS bun bundle). + */ +export function ensureClawdisCliOnPath(opts: EnsureClawdisPathOpts = {}) { + if (process.env.CLAWDIS_PATH_BOOTSTRAPPED === "1") return; + process.env.CLAWDIS_PATH_BOOTSTRAPPED = "1"; + + const existing = opts.pathEnv ?? process.env.PATH ?? ""; + const prepend = candidateBinDirs(opts); + if (prepend.length === 0) return; + + const merged = mergePath({ existing, prepend }); + if (merged) process.env.PATH = merged; +}