fix: bootstrap linuxbrew for skills

This commit is contained in:
Peter Steinberger
2026-01-06 23:27:38 +01:00
parent 585a455690
commit 18c43fe462
6 changed files with 203 additions and 9 deletions

View File

@@ -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).

View File

@@ -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<string | undefined> {
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 };
}

59
src/infra/brew.test.ts Normal file
View File

@@ -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");
});
});

71
src/infra/brew.ts Normal file
View File

@@ -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;
}

View File

@@ -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 });
}
});
});

View File

@@ -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"));