From 18c43fe46229fad41c97cf1364bff8fa741bb299 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 6 Jan 2026 23:27:38 +0100 Subject: [PATCH] fix: bootstrap linuxbrew for skills --- CHANGELOG.md | 1 + src/agents/skills-install.ts | 29 ++++++++++----- src/infra/brew.test.ts | 59 ++++++++++++++++++++++++++++++ src/infra/brew.ts | 71 ++++++++++++++++++++++++++++++++++++ src/infra/path-env.test.ts | 48 ++++++++++++++++++++++++ src/infra/path-env.ts | 4 ++ 6 files changed, 203 insertions(+), 9 deletions(-) create mode 100644 src/infra/brew.test.ts create mode 100644 src/infra/brew.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f13fcc5d..c53d0563b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -56,6 +56,7 @@ - Docs: add group chat participation guidance to the AGENTS template. - Gmail: stop restart loop when `gog gmail watch serve` fails to bind (address already in use). - Linux: auto-attempt lingering during onboarding (try without sudo, fallback to sudo) and prompt on install/restart to keep the gateway alive after logout/idle. Thanks @tobiasbischoff for PR #237. +- Skills: add Linuxbrew paths to gateway PATH bootstrap so the Skills UI can run brew installers under systemd/minimal environments. - TUI: migrate key handling to the updated pi-tui Key matcher API. - TUI: add `/elev` alias for `/elevated`. - Logging: redact sensitive tokens in verbose tool summaries by default (configurable patterns). diff --git a/src/agents/skills-install.ts b/src/agents/skills-install.ts index 0def4ce96..5d3c41608 100644 --- a/src/agents/skills-install.ts +++ b/src/agents/skills-install.ts @@ -2,6 +2,7 @@ import fs from "node:fs"; import path from "node:path"; import type { ClawdbotConfig } from "../config/config.js"; +import { resolveBrewExecutable } from "../infra/brew.js"; import { runCommandWithTimeout } from "../process/exec.js"; import { resolveUserPath } from "../utils.js"; import { @@ -129,9 +130,13 @@ function buildInstallCommand( async function resolveBrewBinDir( timeoutMs: number, + brewExe?: string, ): Promise { - if (!hasBinary("brew")) return undefined; - const prefixResult = await runCommandWithTimeout(["brew", "--prefix"], { + const exe = + brewExe ?? (hasBinary("brew") ? "brew" : resolveBrewExecutable()); + if (!exe) return undefined; + + const prefixResult = await runCommandWithTimeout([exe, "--prefix"], { timeoutMs: Math.min(timeoutMs, 30_000), }); if (prefixResult.code === 0) { @@ -194,7 +199,9 @@ export async function installSkill( code: null, }; } - if (spec.kind === "brew" && !hasBinary("brew")) { + + const brewExe = hasBinary("brew") ? "brew" : resolveBrewExecutable(); + if (spec.kind === "brew" && !brewExe) { return { ok: false, message: "brew not installed", @@ -204,9 +211,9 @@ export async function installSkill( }; } if (spec.kind === "uv" && !hasBinary("uv")) { - if (hasBinary("brew")) { + if (brewExe) { const brewResult = await runCommandWithTimeout( - ["brew", "install", "uv"], + [brewExe, "install", "uv"], { timeoutMs, }, @@ -240,10 +247,14 @@ export async function installSkill( }; } + if (spec.kind === "brew" && brewExe && command.argv[0] === "brew") { + command.argv[0] = brewExe; + } + if (spec.kind === "go" && !hasBinary("go")) { - if (hasBinary("brew")) { + if (brewExe) { const brewResult = await runCommandWithTimeout( - ["brew", "install", "go"], + [brewExe, "install", "go"], { timeoutMs, }, @@ -269,8 +280,8 @@ export async function installSkill( } let env: NodeJS.ProcessEnv | undefined; - if (spec.kind === "go" && hasBinary("brew")) { - const brewBin = await resolveBrewBinDir(timeoutMs); + if (spec.kind === "go" && brewExe) { + const brewBin = await resolveBrewBinDir(timeoutMs, brewExe); if (brewBin) env = { GOBIN: brewBin }; } diff --git a/src/infra/brew.test.ts b/src/infra/brew.test.ts new file mode 100644 index 000000000..ddbec8b7a --- /dev/null +++ b/src/infra/brew.test.ts @@ -0,0 +1,59 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { resolveBrewExecutable, resolveBrewPathDirs } from "./brew.js"; + +describe("brew helpers", () => { + it("resolves brew from ~/.linuxbrew/bin when executable exists", async () => { + const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-brew-")); + try { + const homebrewBin = path.join(tmp, ".linuxbrew", "bin"); + await fs.mkdir(homebrewBin, { recursive: true }); + const brewPath = path.join(homebrewBin, "brew"); + await fs.writeFile(brewPath, "#!/bin/sh\necho ok\n", "utf-8"); + await fs.chmod(brewPath, 0o755); + + const env: NodeJS.ProcessEnv = {}; + expect(resolveBrewExecutable({ homeDir: tmp, env })).toBe(brewPath); + } finally { + await fs.rm(tmp, { recursive: true, force: true }); + } + }); + + it("prefers HOMEBREW_PREFIX/bin/brew when present", async () => { + const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-brew-")); + try { + const prefix = path.join(tmp, "prefix"); + const prefixBin = path.join(prefix, "bin"); + await fs.mkdir(prefixBin, { recursive: true }); + const prefixBrew = path.join(prefixBin, "brew"); + await fs.writeFile(prefixBrew, "#!/bin/sh\necho ok\n", "utf-8"); + await fs.chmod(prefixBrew, 0o755); + + const homebrewBin = path.join(tmp, ".linuxbrew", "bin"); + await fs.mkdir(homebrewBin, { recursive: true }); + const homebrewBrew = path.join(homebrewBin, "brew"); + await fs.writeFile(homebrewBrew, "#!/bin/sh\necho ok\n", "utf-8"); + await fs.chmod(homebrewBrew, 0o755); + + const env: NodeJS.ProcessEnv = { HOMEBREW_PREFIX: prefix }; + expect(resolveBrewExecutable({ homeDir: tmp, env })).toBe(prefixBrew); + } finally { + await fs.rm(tmp, { recursive: true, force: true }); + } + }); + + it("includes Linuxbrew bin/sbin in path candidates", () => { + const env: NodeJS.ProcessEnv = { HOMEBREW_PREFIX: "/custom/prefix" }; + const dirs = resolveBrewPathDirs({ homeDir: "/home/test", env }); + expect(dirs).toContain("/custom/prefix/bin"); + expect(dirs).toContain("/custom/prefix/sbin"); + expect(dirs).toContain("/home/linuxbrew/.linuxbrew/bin"); + expect(dirs).toContain("/home/linuxbrew/.linuxbrew/sbin"); + expect(dirs).toContain("/home/test/.linuxbrew/bin"); + expect(dirs).toContain("/home/test/.linuxbrew/sbin"); + }); +}); diff --git a/src/infra/brew.ts b/src/infra/brew.ts new file mode 100644 index 000000000..53d29e5c4 --- /dev/null +++ b/src/infra/brew.ts @@ -0,0 +1,71 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +function isExecutable(filePath: string): boolean { + try { + fs.accessSync(filePath, fs.constants.X_OK); + return true; + } catch { + return false; + } +} + +function normalizePathValue(value: unknown): string | undefined { + if (typeof value !== "string") return undefined; + const trimmed = value.trim(); + return trimmed ? trimmed : undefined; +} + +export function resolveBrewPathDirs(opts?: { + homeDir?: string; + env?: NodeJS.ProcessEnv; +}): string[] { + const homeDir = opts?.homeDir ?? os.homedir(); + const env = opts?.env ?? process.env; + + const dirs: string[] = []; + const prefix = normalizePathValue(env.HOMEBREW_PREFIX); + if (prefix) { + dirs.push(path.join(prefix, "bin"), path.join(prefix, "sbin")); + } + + // Linuxbrew defaults. + dirs.push("/home/linuxbrew/.linuxbrew/bin", "/home/linuxbrew/.linuxbrew/sbin"); + dirs.push(path.join(homeDir, ".linuxbrew", "bin")); + dirs.push(path.join(homeDir, ".linuxbrew", "sbin")); + + // macOS defaults (also used by some Linux setups). + dirs.push("/opt/homebrew/bin", "/usr/local/bin"); + + return dirs; +} + +export function resolveBrewExecutable(opts?: { + homeDir?: string; + env?: NodeJS.ProcessEnv; +}): string | undefined { + const homeDir = opts?.homeDir ?? os.homedir(); + const env = opts?.env ?? process.env; + + const candidates: string[] = []; + + const brewFile = normalizePathValue(env.HOMEBREW_BREW_FILE); + if (brewFile) candidates.push(brewFile); + + const prefix = normalizePathValue(env.HOMEBREW_PREFIX); + if (prefix) candidates.push(path.join(prefix, "bin", "brew")); + + // Linuxbrew defaults. + candidates.push("/home/linuxbrew/.linuxbrew/bin/brew"); + candidates.push(path.join(homeDir, ".linuxbrew", "bin", "brew")); + + // macOS defaults. + candidates.push("/opt/homebrew/bin/brew", "/usr/local/bin/brew"); + + for (const candidate of candidates) { + if (isExecutable(candidate)) return candidate; + } + + return undefined; +} diff --git a/src/infra/path-env.test.ts b/src/infra/path-env.test.ts index a4d485d52..11f337ab6 100644 --- a/src/infra/path-env.test.ts +++ b/src/infra/path-env.test.ts @@ -111,4 +111,52 @@ describe("ensureClawdbotCliOnPath", () => { await fs.rm(tmp, { recursive: true, force: true }); } }); + + it("prepends Linuxbrew dirs when present", async () => { + const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-path-")); + const originalPath = process.env.PATH; + const originalFlag = process.env.CLAWDBOT_PATH_BOOTSTRAPPED; + const originalHomebrewPrefix = process.env.HOMEBREW_PREFIX; + const originalHomebrewBrewFile = process.env.HOMEBREW_BREW_FILE; + const originalXdgBinHome = process.env.XDG_BIN_HOME; + try { + const execDir = path.join(tmp, "exec"); + await fs.mkdir(execDir, { recursive: true }); + + const linuxbrewBin = path.join(tmp, ".linuxbrew", "bin"); + const linuxbrewSbin = path.join(tmp, ".linuxbrew", "sbin"); + await fs.mkdir(linuxbrewBin, { recursive: true }); + await fs.mkdir(linuxbrewSbin, { recursive: true }); + + process.env.PATH = "/usr/bin"; + delete process.env.CLAWDBOT_PATH_BOOTSTRAPPED; + delete process.env.HOMEBREW_PREFIX; + delete process.env.HOMEBREW_BREW_FILE; + delete process.env.XDG_BIN_HOME; + + ensureClawdbotCliOnPath({ + execPath: path.join(execDir, "node"), + cwd: tmp, + homeDir: tmp, + platform: "linux", + }); + + const updated = process.env.PATH ?? ""; + const parts = updated.split(path.delimiter); + expect(parts[0]).toBe(linuxbrewBin); + expect(parts[1]).toBe(linuxbrewSbin); + } finally { + process.env.PATH = originalPath; + if (originalFlag === undefined) + delete process.env.CLAWDBOT_PATH_BOOTSTRAPPED; + else process.env.CLAWDBOT_PATH_BOOTSTRAPPED = originalFlag; + if (originalHomebrewPrefix === undefined) delete process.env.HOMEBREW_PREFIX; + else process.env.HOMEBREW_PREFIX = originalHomebrewPrefix; + if (originalHomebrewBrewFile === undefined) delete process.env.HOMEBREW_BREW_FILE; + else process.env.HOMEBREW_BREW_FILE = originalHomebrewBrewFile; + if (originalXdgBinHome === undefined) delete process.env.XDG_BIN_HOME; + else process.env.XDG_BIN_HOME = originalXdgBinHome; + await fs.rm(tmp, { recursive: true, force: true }); + } + }); }); diff --git a/src/infra/path-env.ts b/src/infra/path-env.ts index bba3d3e82..a6cf36ed0 100644 --- a/src/infra/path-env.ts +++ b/src/infra/path-env.ts @@ -2,6 +2,8 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import { resolveBrewPathDirs } from "./brew.js"; + type EnsureClawdbotPathOpts = { execPath?: string; cwd?: string; @@ -75,6 +77,8 @@ function candidateBinDirs(opts: EnsureClawdbotPathOpts): string[] { const miseShims = path.join(miseDataDir, "shims"); if (isDirectory(miseShims)) candidates.push(miseShims); + candidates.push(...resolveBrewPathDirs({ homeDir })); + // Common global install locations (macOS first). if (platform === "darwin") { candidates.push(path.join(homeDir, "Library", "pnpm"));