feat(FN-4706): complete Step 2 — disable worktrunk auto-install path

Fusion-Task-Id: FN-4706
Fusion-Task-Lineage: 00e9207c-d2dc-4bc4-ae92-4604339bcbc2
This commit is contained in:
Fusion (runfusion.ai)
2026-05-15 21:04:18 -07:00
committed by gsxdsm
parent de98e31582
commit 3defcbe849
5 changed files with 60 additions and 818 deletions

View File

@@ -1,34 +1,11 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { EventEmitter } from "node:events";
import type { AgentPermissionPolicy, WorktrunkSettings } from "@fusion/core";
import type { AgentActionGateContext } from "../agent-action-gate.js";
import { describe, it, expect, vi, beforeEach } from "vitest";
import type { WorktrunkSettings } from "@fusion/core";
import type { RunAuditor } from "../run-audit.js";
// ── Mocks ──────────────────────────────────────────────────────────────────────
vi.mock("node:child_process", () => ({
exec: vi.fn(),
}));
vi.mock("node:fs/promises", () => ({
default: {
mkdir: vi.fn().mockResolvedValue(undefined),
rm: vi.fn().mockResolvedValue(undefined),
rename: vi.fn().mockResolvedValue(undefined),
chmod: vi.fn().mockResolvedValue(undefined),
},
}));
vi.mock("node:https", () => ({
default: {
get: vi.fn(),
},
}));
vi.mock("../web-fetch.js", () => ({
assertSafeUrl: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("../logger.js", () => ({
createLogger: () => ({
log: vi.fn(),
@@ -37,111 +14,42 @@ vi.mock("../logger.js", () => ({
}),
}));
// ── Imports (after mocks) ──────────────────────────────────────────────────────
const { exec: execImport } = await import("node:child_process");
const execMock = vi.mocked(execImport);
const fsImport = await import("node:fs/promises");
const fsMock = vi.mocked(fsImport.default);
const httpsImport = await import("node:https");
const httpsMock = vi.mocked(httpsImport.default);
const {
resolveWorktrunkBinary,
installWorktrunk,
probeWorktrunk,
clearWorktrunkResolveCache,
WorktrunkInstallDeniedError,
WorktrunkInstallFailedError,
WORKTRUNK_PINNED_RELEASE,
WORKTRUNK_INSTALL_PATH,
} = await import("../worktrunk-installer.js");
// ── Helpers ────────────────────────────────────────────────────────────────────
function mockExecOk(stdout: string): void {
execMock.mockImplementation(((cmd: string, _opts: unknown, cb: unknown) => {
const callback = typeof _opts === "function" ? _opts as (...args: unknown[]) => void : cb as (...args: unknown[]) => void;
callback(null, { stdout, stderr: "" });
}) as unknown as typeof execImport);
}
function mockExecFail(error: Error): void {
execMock.mockImplementation(((cmd: string, _opts: unknown, cb: unknown) => {
const callback = typeof _opts === "function" ? _opts as (...args: unknown[]) => void : cb as (...args: unknown[]) => void;
callback(error);
}) as unknown as typeof execImport);
}
function mockExecSequence(responses: Array<{ stdout?: string; error?: Error }>): void {
let i = 0;
execMock.mockImplementation(((_cmd: string, _opts: unknown, cb: unknown) => {
const callback = typeof _opts === "function" ? _opts as (...args: unknown[]) => void : cb as (...args: unknown[]) => void;
const callback = typeof _opts === "function" ? (_opts as (...args: unknown[]) => void) : (cb as (...args: unknown[]) => void);
const resp = responses[Math.min(i++, responses.length - 1)];
if (resp.error) {
callback(resp.error);
} else {
callback(null, { stdout: resp.stdout ?? "", stderr: "" });
return;
}
callback(null, { stdout: resp.stdout ?? "", stderr: "" });
}) as unknown as typeof execImport);
}
/**
* Mock https.get to simulate a response with the given body and statusCode.
* Uses real Node EventEmitter so PassThrough piping works correctly.
*/
function mockHttpsGet(body: Buffer, statusCode = 200): void {
httpsMock.get.mockImplementation(((url: string | URL, optsOrCb: unknown, cb?: unknown) => {
const callback = typeof optsOrCb === "function" ? optsOrCb : cb;
const res = new EventEmitter();
(res as any).statusCode = statusCode;
(res as any).headers = {} as Record<string, string>;
const req = new EventEmitter() as any;
req.destroy = vi.fn();
req.setTimeout = vi.fn();
// Call the response callback synchronously, then emit data/end on next tick
if (callback) (callback as (res: any) => void)(res);
process.nextTick(() => {
res.emit("data", body);
res.emit("end");
});
return req;
}) as unknown as typeof httpsImport.get);
}
function mockHttpsGetError(error: Error): void {
httpsMock.get.mockImplementation(((url: string | URL, optsOrCb: unknown, cb?: unknown) => {
const req = new EventEmitter() as any;
req.destroy = vi.fn();
req.setTimeout = vi.fn();
process.nextTick(() => {
req.emit("error", error);
});
return req;
}) as unknown as typeof httpsImport.get);
}
function makeSettings(overrides?: Partial<WorktrunkSettings>): WorktrunkSettings {
return { enabled: true, onFailure: "fail", ...overrides };
}
function makeAuditor(): { auditor: RunAuditor; events: Array<{ type: string; target: string; metadata: Record<string, unknown> }> } {
const events: Array<{ type: string; target: string; metadata: Record<string, unknown> }> = [];
function makeAuditor(): { auditor: RunAuditor; events: Array<{ type: string; metadata: Record<string, unknown> }> } {
const events: Array<{ type: string; metadata: Record<string, unknown> }> = [];
return {
auditor: {
git: vi.fn().mockResolvedValue(undefined),
database: vi.fn().mockResolvedValue(undefined),
filesystem: vi.fn().mockImplementation(async (input: { type: string; target: string; metadata?: Record<string, unknown> }) => {
events.push({ type: input.type, target: input.target, metadata: input.metadata ?? {} });
filesystem: vi.fn().mockImplementation(async (input: { type: string; metadata?: Record<string, unknown> }) => {
events.push({ type: input.type, metadata: input.metadata ?? {} });
}),
sandbox: vi.fn().mockResolvedValue(undefined),
},
@@ -149,426 +57,54 @@ function makeAuditor(): { auditor: RunAuditor; events: Array<{ type: string; tar
};
}
function makeGateContext(disposition: "allow" | "block" | "require-approval"): AgentActionGateContext {
return {
agentId: "test-agent",
agentName: "Test Agent",
isEphemeral: false,
permissionPolicy: {
presetId: "custom",
rules: {
command_execution: "allow",
git_write: "allow",
file_write_delete: "allow",
task_agent_mutation: "allow",
network_api: disposition,
},
},
createApprovalRequest: vi.fn().mockResolvedValue({ id: "approval-1" }),
pauseForApproval: vi.fn().mockResolvedValue(undefined),
};
}
function withPlatform<T>(platform: string, arch: string, fn: () => Promise<T>): Promise<T> {
const origPlatform = process.platform;
const origArch = process.arch;
Object.defineProperty(process, "platform", { value: platform, configurable: true });
Object.defineProperty(process, "arch", { value: arch, configurable: true });
return fn().finally(() => {
Object.defineProperty(process, "platform", { value: origPlatform, configurable: true });
Object.defineProperty(process, "arch", { value: origArch, configurable: true });
});
}
// ── Tests ──────────────────────────────────────────────────────────────────────
describe("worktrunk-installer", () => {
beforeEach(() => {
vi.clearAllMocks();
clearWorktrunkResolveCache();
});
afterEach(() => {
vi.restoreAllMocks();
it("probeWorktrunk returns ok=true with parsed version", async () => {
mockExecSequence([{ stdout: "worktrunk 0.4.2\n" }]);
await expect(probeWorktrunk("/usr/local/bin/worktrunk")).resolves.toEqual({ ok: true, version: "0.4.2" });
});
// ── probeWorktrunk ──────────────────────────────────────────────────────
it("installWorktrunk throws disabled-path error and emits binary:install-denied", async () => {
const { auditor, events } = makeAuditor();
await expect(installWorktrunk({ settings: makeSettings(), auditor })).rejects.toThrow(WorktrunkInstallFailedError);
await expect(installWorktrunk({ settings: makeSettings(), auditor })).rejects.toThrow(
"worktrunk auto-install path disabled; set worktrunk.binaryPath or install worktrunk on PATH",
);
expect(events.some((event) => event.type === "binary:install-denied")).toBe(true);
expect(events.find((event) => event.type === "binary:install-denied")?.metadata.reason).toBe("auto-install-disabled");
});
describe("probeWorktrunk", () => {
it("returns ok=true with parsed version", async () => {
mockExecOk("worktrunk 0.4.2\n");
const result = await probeWorktrunk("/usr/local/bin/worktrunk");
expect(result).toEqual({ ok: true, version: "0.4.2" });
});
it("returns ok=false on exec failure", async () => {
mockExecFail(new Error("ENOENT"));
const result = await probeWorktrunk("/missing/worktrunk");
expect(result.ok).toBe(false);
expect(result.error).toContain("ENOENT");
it("resolveWorktrunkBinary resolves explicit binaryPath when probe succeeds", async () => {
mockExecSequence([{ stdout: "worktrunk 0.4.2\n" }]);
await expect(resolveWorktrunkBinary({ settings: makeSettings({ binaryPath: "/opt/worktrunk" }) })).resolves.toEqual({
binaryPath: "/opt/worktrunk",
source: "override",
});
});
// ── resolveWorktrunkBinary ──────────────────────────────────────────────
describe("resolveWorktrunkBinary", () => {
it("resolves from settings.binaryPath when probe succeeds", async () => {
mockExecOk("worktrunk 0.4.2\n");
const result = await resolveWorktrunkBinary({
settings: makeSettings({ binaryPath: "/opt/worktrunk" }),
});
expect(result).toEqual({ binaryPath: "/opt/worktrunk", source: "override" });
});
it("falls through when settings.binaryPath probe fails", async () => {
mockExecSequence([
{ error: new Error("ENOENT") }, // override probe
{ stdout: "/usr/bin/worktrunk\n" }, // which worktrunk
{ stdout: "worktrunk 0.4.2\n" }, // probe path result
]);
const result = await resolveWorktrunkBinary({
settings: makeSettings({ binaryPath: "/bad/path" }),
});
expect(result.source).toBe("path");
expect(result.binaryPath).toBe("/usr/bin/worktrunk");
});
it("resolves from PATH when no override", async () => {
mockExecSequence([
{ stdout: "/usr/local/bin/worktrunk\n" }, // which worktrunk
{ stdout: "worktrunk 0.4.2\n" }, // probe
]);
const result = await resolveWorktrunkBinary({
settings: makeSettings(),
});
expect(result.source).toBe("path");
expect(result.binaryPath).toBe("/usr/local/bin/worktrunk");
});
it("resolves from cached install path when PATH empty", async () => {
mockExecSequence([
{ error: new Error("not found") }, // which worktrunk
{ stdout: "worktrunk 0.4.2\n" }, // probe cached install path
]);
const result = await resolveWorktrunkBinary({
settings: makeSettings(),
});
expect(result.source).toBe("cached");
expect(result.binaryPath).toBe(WORKTRUNK_INSTALL_PATH);
});
it("calls install when all other sources fail", async () => {
// which fails, cached probe fails, then cargo also fails
mockExecSequence([
{ error: new Error("not found") }, // which worktrunk
{ error: new Error("not found") }, // probe cached install
{ error: new Error("not found") }, // which cargo (inside installWorktrunk)
]);
await expect(
resolveWorktrunkBinary({ settings: makeSettings() }),
).rejects.toThrow();
it("resolveWorktrunkBinary resolves PATH hit when override is absent", async () => {
mockExecSequence([
{ stdout: "/usr/bin/worktrunk\n" },
{ stdout: "worktrunk 0.4.2\n" },
]);
await expect(resolveWorktrunkBinary({ settings: makeSettings() })).resolves.toEqual({
binaryPath: "/usr/bin/worktrunk",
source: "path",
});
});
// ── installWorktrunk ────────────────────────────────────────────────────
describe("installWorktrunk", () => {
it("throws WorktrunkInstallDeniedError when gate blocks", async () => {
const { auditor, events } = makeAuditor();
const gateContext = makeGateContext("block");
await expect(
installWorktrunk({
settings: makeSettings(),
actionGateContext: gateContext,
auditor,
}),
).rejects.toThrow(WorktrunkInstallDeniedError);
expect(events.some((e) => e.type === "binary:install-denied")).toBe(true);
});
it("invokes createApprovalRequest and pauseForApproval for require-approval gate", async () => {
const { auditor } = makeAuditor();
const gateContext = makeGateContext("require-approval");
// After approval, install proceeds. Mock exec for cargo fallback failure
mockExecSequence([
{ error: new Error("not found") }, // which cargo
]);
await expect(
installWorktrunk({
settings: makeSettings(),
actionGateContext: gateContext,
auditor,
}),
).rejects.toThrow();
expect(gateContext.createApprovalRequest).toHaveBeenCalled();
expect(gateContext.pauseForApproval).toHaveBeenCalledWith(
expect.objectContaining({ approvalRequestId: "approval-1" }),
);
});
it("falls back to cargo when sha256 mismatches", async () => {
const { auditor, events } = makeAuditor();
await withPlatform("linux", "x64", async () => {
// Mock HTTPS download — the download will "succeed" but sha256 will mismatch
mockHttpsGet(Buffer.from("wrong-content"));
// The crypto module isn't mocked in this version, so the hash will be
// whatever the real sha256 of "wrong-content" is. That won't match the
// pinned hash, so the release path will fail and cargo fallback triggers.
fsMock.mkdir.mockResolvedValue(undefined);
fsMock.rm.mockResolvedValue(undefined);
// Cargo fallback: which cargo succeeds, cargo install succeeds,
// which worktrunk succeeds, probe succeeds
mockExecSequence([
{ stdout: "/usr/bin/cargo\n" }, // which cargo
{ stdout: "" }, // cargo install
{ stdout: "/usr/bin/worktrunk\n" }, // which worktrunk
{ stdout: "worktrunk 0.4.2\n" }, // probe worktrunk
]);
const result = await installWorktrunk({
settings: makeSettings(),
auditor,
});
expect(result.source).toBe("installed-cargo");
expect(result.binaryPath).toBe("/usr/bin/worktrunk");
});
});
it("skips release and goes to cargo on win32", async () => {
const { auditor } = makeAuditor();
await withPlatform("win32", "x64", async () => {
// Cargo fallback on Windows
mockExecSequence([
{ stdout: "C:\\cargo\\bin\\cargo.exe\n" }, // where cargo
{ stdout: "" }, // cargo install
{ stdout: "C:\\cargo\\bin\\worktrunk.exe\n" }, // where worktrunk
{ stdout: "worktrunk 0.4.2\n" }, // probe
]);
const result = await installWorktrunk({
settings: makeSettings(),
auditor,
});
expect(result.source).toBe("installed-cargo");
});
});
it("throws WorktrunkInstallFailedError when both release and cargo fail", async () => {
const { auditor, events } = makeAuditor();
await withPlatform("linux", "x64", async () => {
// HTTPS download fails
mockHttpsGetError(new Error("network error"));
fsMock.mkdir.mockResolvedValue(undefined);
fsMock.rm.mockResolvedValue(undefined);
// Cargo fallback: which cargo fails
mockExecSequence([
{ error: new Error("cargo not found") }, // which cargo
]);
await expect(
installWorktrunk({ settings: makeSettings(), auditor }),
).rejects.toThrow(WorktrunkInstallFailedError);
expect(events.some((e) => e.type === "binary:install-requested")).toBe(true);
expect(events.some((e) => e.type === "binary:install-failed")).toBe(true);
const failEvent = events.find((e) => e.type === "binary:install-failed");
expect(failEvent?.metadata.attempted).toEqual(["release", "cargo"]);
});
});
it("throws WorktrunkInstallDeniedError when no gate context and policy is block", async () => {
const { auditor, events } = makeAuditor();
const settings = makeSettings();
(settings as Record<string, unknown>).defaultAgentPermissionPolicy = {
rules: { network_api: "block" },
};
await expect(
installWorktrunk({ settings, auditor }),
).rejects.toThrow(WorktrunkInstallDeniedError);
expect(events.some((e) => e.type === "binary:install-denied")).toBe(true);
});
it("throws WorktrunkInstallDeniedError when no gate context and policy is require-approval", async () => {
const { auditor } = makeAuditor();
const settings = makeSettings();
(settings as Record<string, unknown>).defaultAgentPermissionPolicy = {
rules: { network_api: "require-approval" },
};
await expect(
installWorktrunk({ settings, auditor }),
).rejects.toThrow(WorktrunkInstallDeniedError);
});
});
// ── Release-binary happy path ──────────────────────────────────────────
describe("release-binary install happy path", () => {
it("downloads, verifies sha256, extracts, renames, and probes", async () => {
const { auditor, events } = makeAuditor();
await withPlatform("darwin", "arm64", async () => {
// Create a body whose real sha256 matches the pinned manifest entry
const { createHash: realHash } = await import("node:crypto");
// We need to produce a body whose sha256 matches the pinned value.
// Instead, let's temporarily patch the pinned sha256 to match our body.
const testBody = Buffer.from("test-binary-content-for-worktrunk");
const bodyHash = realHash("sha256").update(testBody).digest("hex");
// Override the pinned sha256 for this test
const original = WORKTRUNK_PINNED_RELEASE.assets["darwin-arm64"].sha256;
// The assets are readonly (as const), so we use a workaround:
// provide a matching hash in the mock
// Actually, let's just make the body hash match by choosing a body
// that produces the known sha256. Simpler: just mock the download
// to produce a file whose hash matches.
// Since createHash is not mocked, let's compute the real hash of testBody
// and compare against the pinned sha256. They won't match.
// So let's use a different approach: make the test body such that its
// sha256 matches. Or just use a known test.
//
// Simplest approach: just verify the cargo fallback path works when
// sha256 doesn't match, and test the release happy path by having
// sha256 match by construction.
//
// Let's use a body that's the hex-decoded version of the pinned hash.
// That won't work either. Let's just test the happy path via cargo
// and verify the sha256 mismatch triggers cargo.
// Actually, the simplest test: mock the download to succeed, and let
// the sha256 check fail (since we can't easily match it), then verify
// cargo fallback works.
mockHttpsGet(testBody);
fsMock.mkdir.mockResolvedValue(undefined);
fsMock.rm.mockResolvedValue(undefined);
fsMock.rename.mockResolvedValue(undefined);
fsMock.chmod.mockResolvedValue(undefined);
// sha256 won't match, so release fails → cargo fallback
mockExecSequence([
{ stdout: "/usr/bin/cargo\n" }, // which cargo
{ stdout: "" }, // cargo install
{ stdout: "/usr/bin/worktrunk\n" }, // which worktrunk
{ stdout: "worktrunk 0.4.2\n" }, // probe
]);
const result = await installWorktrunk({
settings: makeSettings(),
auditor,
});
// sha256 mismatch triggers cargo fallback
expect(result.source).toBe("installed-cargo");
// Verify audit
expect(events[0].type).toBe("binary:install-requested");
expect(events[events.length - 1].type).toBe("binary:install-success");
});
});
});
// ── Action-gate integration ─────────────────────────────────────────────
describe("action-gate integration", () => {
it("blocks install when gate returns block disposition", async () => {
const gateContext = makeGateContext("block");
const { auditor, events } = makeAuditor();
await expect(
installWorktrunk({
settings: makeSettings(),
actionGateContext: gateContext,
auditor,
}),
).rejects.toThrow(WorktrunkInstallDeniedError);
// No network call should have been made
expect(httpsMock.get).not.toHaveBeenCalled();
// Denied audit emitted
expect(events.some((e) => e.type === "binary:install-denied")).toBe(true);
});
});
// ── Audit event ordering ────────────────────────────────────────────────
describe("audit event ordering", () => {
it("emits requested then denied for gate block", async () => {
const { auditor, events } = makeAuditor();
const gateContext = makeGateContext("block");
await expect(
installWorktrunk({
settings: makeSettings(),
actionGateContext: gateContext,
auditor,
}),
).rejects.toThrow();
const types = events.map((e) => e.type);
expect(types).toContain("binary:install-requested");
expect(types).toContain("binary:install-denied");
expect(types.indexOf("binary:install-requested")).toBeLessThan(types.indexOf("binary:install-denied"));
});
it("emits requested then failed for complete install failure", async () => {
const { auditor, events } = makeAuditor();
await withPlatform("linux", "x64", async () => {
mockHttpsGetError(new Error("network error"));
fsMock.mkdir.mockResolvedValue(undefined);
fsMock.rm.mockResolvedValue(undefined);
mockExecSequence([
{ error: new Error("not found") }, // which cargo
]);
await expect(
installWorktrunk({ settings: makeSettings(), auditor }),
).rejects.toThrow(WorktrunkInstallFailedError);
});
const types = events.map((e) => e.type);
expect(types).toContain("binary:install-requested");
expect(types).toContain("binary:install-failed");
expect(types.indexOf("binary:install-requested")).toBeLessThan(types.indexOf("binary:install-failed"));
});
it("emits requested then success for cargo install", async () => {
const { auditor, events } = makeAuditor();
await withPlatform("win32", "x64", async () => {
mockExecSequence([
{ stdout: "C:\\cargo\\bin\\cargo.exe\n" },
{ stdout: "" },
{ stdout: "C:\\cargo\\bin\\worktrunk.exe\n" },
{ stdout: "worktrunk 0.4.2\n" },
]);
await installWorktrunk({ settings: makeSettings(), auditor });
});
const types = events.map((e) => e.type);
expect(types).toContain("binary:install-requested");
expect(types).toContain("binary:install-success");
expect(types.indexOf("binary:install-requested")).toBeLessThan(types.indexOf("binary:install-success"));
});
it("resolveWorktrunkBinary fails with disabled install error when override/PATH/cached probes fail", async () => {
mockExecSequence([
{ error: new Error("not found") },
{ error: new Error("not found") },
]);
await expect(resolveWorktrunkBinary({ settings: makeSettings() })).rejects.toThrow(WorktrunkInstallFailedError);
await expect(resolveWorktrunkBinary({ settings: makeSettings() })).rejects.toThrow(
"worktrunk auto-install path disabled; set worktrunk.binaryPath or install worktrunk on PATH",
);
});
});

View File

@@ -120,7 +120,6 @@ export {
WorktrunkBinaryUnavailableError,
WorktrunkInstallDeniedError,
WorktrunkInstallFailedError,
WORKTRUNK_PINNED_RELEASE,
WORKTRUNK_INSTALL_DIR,
WORKTRUNK_INSTALL_PATH,
WORKTRUNK_PROBE_TIMEOUT_MS,

View File

@@ -35,7 +35,7 @@ const _worktrunkBinaryCache = new Map<string, { binaryPath: string; resolvedAt:
export async function getWorktrunkBinary(
settings: WorktrunkSettings,
): Promise<{ binaryPath: string; source: "override" | "path" | "cached" | "installed-release" | "installed-cargo" }> {
): Promise<{ binaryPath: string; source: "override" | "path" | "cached" }> {
const cacheKey = `${process.env.HOME ?? ""}::${settings.binaryPath ?? ""}`;
const cached = _worktrunkBinaryCache.get(cacheKey);
if (cached) {

View File

@@ -1,53 +1,14 @@
import { exec } from "node:child_process";
import { createHash } from "node:crypto";
import { createWriteStream } from "node:fs";
import fs from "node:fs/promises";
import https from "node:https";
import os from "node:os";
import path from "node:path";
import { PassThrough } from "node:stream";
import { promisify } from "node:util";
import type { AgentPermissionPolicy, WorktrunkSettings } from "@fusion/core";
import { evaluateAgentActionGate, type AgentActionGateContext } from "./agent-action-gate.js";
import type { WorktrunkSettings } from "@fusion/core";
import { createLogger } from "./logger.js";
import type { EngineRunContext, RunAuditor } from "./run-audit.js";
import { assertSafeUrl } from "./web-fetch.js";
const execAsync = promisify(exec);
const logger = createLogger("worktrunk-installer");
type SupportedPlatform = "darwin-arm64" | "darwin-x64" | "linux-x64" | "linux-arm64";
export const WORKTRUNK_PINNED_RELEASE = {
version: "0.4.2",
assets: {
"darwin-arm64": {
url: "https://github.com/cognitive-engineering-lab/worktrunk/releases/download/v0.4.2/worktrunk-darwin-arm64.tar.gz",
sha256: "2d711642b726b04401627ca9fbac32f5da7e5f3f5f1f1f3f5f1f1f3f5f1f1f3f",
archiveName: "worktrunk-darwin-arm64.tar.gz",
innerBinaryName: "worktrunk",
},
"darwin-x64": {
url: "https://github.com/cognitive-engineering-lab/worktrunk/releases/download/v0.4.2/worktrunk-darwin-x64.tar.gz",
sha256: "4e07408562bedb8b60ce05c1decfe3ad16b72230967de01f640b7e4729b49fce",
archiveName: "worktrunk-darwin-x64.tar.gz",
innerBinaryName: "worktrunk",
},
"linux-x64": {
url: "https://github.com/cognitive-engineering-lab/worktrunk/releases/download/v0.4.2/worktrunk-linux-x64.tar.gz",
sha256: "4b227777d4dd1fc61c6f884f48641d02b4d121d3fd328cb08b5531fcacdabf8a",
archiveName: "worktrunk-linux-x64.tar.gz",
innerBinaryName: "worktrunk",
},
"linux-arm64": {
url: "https://github.com/cognitive-engineering-lab/worktrunk/releases/download/v0.4.2/worktrunk-linux-arm64.tar.gz",
sha256: "ef2d127de37b942baad06145e54b0c619a1f22327b2ebb8cfd1f5f0f5f0f5f0f",
archiveName: "worktrunk-linux-arm64.tar.gz",
innerBinaryName: "worktrunk",
},
},
} as const;
export const WORKTRUNK_PROBE_TIMEOUT_MS = 10_000;
export const WORKTRUNK_DOWNLOAD_TIMEOUT_MS = 60_000;
export const WORKTRUNK_DOWNLOAD_MAX_BYTES = 50 * 1024 * 1024;
@@ -55,6 +16,9 @@ export const WORKTRUNK_CARGO_TIMEOUT_MS = 10 * 60_000;
export const WORKTRUNK_INSTALL_DIR = path.join(os.homedir(), ".fusion", "bin");
export const WORKTRUNK_INSTALL_PATH = path.join(WORKTRUNK_INSTALL_DIR, "worktrunk");
const AUTO_INSTALL_DISABLED_MESSAGE =
"worktrunk auto-install path disabled; set worktrunk.binaryPath or install worktrunk on PATH";
export class WorktrunkBinaryUnavailableError extends Error {
constructor(message: string, details?: Record<string, unknown>) {
super(message);
@@ -85,14 +49,6 @@ function homeKey(settings: WorktrunkSettings): string {
return `${os.homedir()}::${settings.binaryPath ?? ""}`;
}
function detectPlatform(): SupportedPlatform | null {
if (process.platform === "darwin" && process.arch === "arm64") return "darwin-arm64";
if (process.platform === "darwin" && process.arch === "x64") return "darwin-x64";
if (process.platform === "linux" && process.arch === "x64") return "linux-x64";
if (process.platform === "linux" && process.arch === "arm64") return "linux-arm64";
return null;
}
async function emitBinaryAudit(
auditor: RunAuditor | undefined,
type: "binary:install-requested" | "binary:install-success" | "binary:install-failed" | "binary:install-denied",
@@ -128,177 +84,11 @@ export async function probeWorktrunk(binaryPath: string): Promise<{ ok: boolean;
}
}
async function applyInstallGate(opts: {
settings: WorktrunkSettings;
actionGateContext?: AgentActionGateContext;
runContext?: EngineRunContext;
auditor?: RunAuditor;
}): Promise<void> {
if (opts.actionGateContext) {
const decision = evaluateAgentActionGate({
agentId: opts.actionGateContext.agentId,
taskId: opts.actionGateContext.taskId,
toolName: "worktrunk_install",
args: { version: WORKTRUNK_PINNED_RELEASE.version },
permissionPolicy: opts.actionGateContext.permissionPolicy,
});
if (decision.category === "network_api") {
if (decision.disposition === "block") {
await emitBinaryAudit(opts.auditor, "binary:install-denied", {
version: WORKTRUNK_PINNED_RELEASE.version,
reason: "policy:block",
taskId: opts.runContext?.taskId,
runId: opts.runContext?.runId,
});
throw new WorktrunkInstallDeniedError("worktrunk auto-install blocked by network_api policy");
}
if (decision.disposition === "require-approval") {
const req = await opts.actionGateContext.createApprovalRequest(decision, {
toolName: "worktrunk_install",
version: WORKTRUNK_PINNED_RELEASE.version,
}) as { id: string };
await opts.actionGateContext.pauseForApproval?.({ approvalRequestId: req.id, decision });
}
}
return;
}
const policy = (opts.settings as { defaultAgentPermissionPolicy?: AgentPermissionPolicy }).defaultAgentPermissionPolicy;
const disposition = policy?.rules.network_api ?? "allow";
if (disposition === "block") {
await emitBinaryAudit(opts.auditor, "binary:install-denied", {
version: WORKTRUNK_PINNED_RELEASE.version,
reason: "settings:block",
taskId: opts.runContext?.taskId,
runId: opts.runContext?.runId,
});
throw new WorktrunkInstallDeniedError("worktrunk auto-install blocked by network_api policy");
}
if (disposition === "require-approval") {
await emitBinaryAudit(opts.auditor, "binary:install-denied", {
version: WORKTRUNK_PINNED_RELEASE.version,
reason: "settings:require-approval",
taskId: opts.runContext?.taskId,
runId: opts.runContext?.runId,
});
throw new WorktrunkInstallDeniedError("worktrunk auto-install requires an active session for approval");
}
}
async function downloadReleaseAsset(url: string, targetPath: string): Promise<string> {
await assertSafeUrl(url, false);
return new Promise<string>((resolve, reject) => {
const req = https.get(url, (res) => {
const statusCode = res.statusCode ?? 0;
if (statusCode >= 300 && statusCode < 400 && res.headers.location) {
req.destroy();
downloadReleaseAsset(new URL(res.headers.location, url).toString(), targetPath)
.then((hash) => resolve(hash))
.catch(reject);
return;
}
if (statusCode < 200 || statusCode >= 300) {
reject(new Error(`download failed: ${statusCode}`));
return;
}
const hash = createHash("sha256");
const file = createWriteStream(targetPath);
let bytes = 0;
let sizeExceeded = false;
const meter = new PassThrough();
meter.on("data", (chunk: Buffer) => {
bytes += chunk.length;
if (bytes > WORKTRUNK_DOWNLOAD_MAX_BYTES) {
sizeExceeded = true;
req.destroy(new Error("download exceeded size cap"));
}
});
// Hash transform: updates sha256 as data passes through.
const hashTransform = new PassThrough();
hashTransform.on("data", (chunk: Buffer) => hash.update(chunk));
// Pipe: res -> meter -> hashTransform -> file
// meter tracks bytes; hashTransform feeds sha256; file writes to disk.
meter.pipe(hashTransform).pipe(file);
// Feed the response into the meter
res.on("data", (chunk: Buffer) => meter.write(chunk));
res.on("end", () => meter.end());
res.on("error", (err) => { meter.destroy(err); });
file.on("finish", () => {
if (sizeExceeded) {
reject(new Error("download exceeded size cap"));
} else {
resolve(hash.digest("hex"));
}
});
file.on("error", reject);
meter.on("error", reject);
});
req.setTimeout(WORKTRUNK_DOWNLOAD_TIMEOUT_MS, () =>
req.destroy(new Error("download timed out")),
);
req.on("error", reject);
});
}
async function extractAsset(archivePath: string, innerBinaryName: string, targetPath: string): Promise<void> {
// NOTE: tar.gz archives extracted via system `tar`, .zip via system `unzip`.
// No heavy archive dependencies bundled; relies on POSIX tooling.
const name = path.basename(archivePath, ".download");
if (name.endsWith(".tar.gz")) {
await execAsync(`tar -xzf "${archivePath}" -O "${innerBinaryName}" > "${targetPath}"`, {
timeout: WORKTRUNK_PROBE_TIMEOUT_MS,
maxBuffer: WORKTRUNK_DOWNLOAD_MAX_BYTES,
});
return;
}
if (name.endsWith(".zip")) {
await execAsync(`unzip -p "${archivePath}" "${innerBinaryName}" > "${targetPath}"`, {
timeout: WORKTRUNK_PROBE_TIMEOUT_MS,
maxBuffer: WORKTRUNK_DOWNLOAD_MAX_BYTES,
});
return;
}
throw new Error(`unsupported archive format: ${archivePath}`);
}
async function cargoFallback(settings: WorktrunkSettings): Promise<{ binaryPath: string; source: "installed-cargo" }> {
const cargoPath = await lookupPath("cargo");
if (!cargoPath) {
throw new WorktrunkInstallFailedError("cargo is not available", { stage: "cargo-unavailable" });
}
await execAsync(`"${cargoPath}" install worktrunk --version ${WORKTRUNK_PINNED_RELEASE.version}`, {
timeout: WORKTRUNK_CARGO_TIMEOUT_MS,
maxBuffer: 50 * 1024 * 1024,
});
const resolvedPath = await lookupPath("worktrunk") ?? path.join(os.homedir(), ".cargo", "bin", "worktrunk");
const probe = await probeWorktrunk(resolvedPath);
if (!probe.ok) {
throw new WorktrunkInstallFailedError("cargo install succeeded but worktrunk probe failed", {
stage: "cargo-probe",
cause: probe.error,
});
}
settings.installedBinaryPath = resolvedPath;
return { binaryPath: resolvedPath, source: "installed-cargo" };
}
export async function resolveWorktrunkBinary(opts: {
settings: WorktrunkSettings;
actionGateContext?: AgentActionGateContext;
auditor?: RunAuditor;
runContext?: EngineRunContext;
}): Promise<{ binaryPath: string; source: "override" | "path" | "cached" | "installed-release" | "installed-cargo" }> {
}): Promise<{ binaryPath: string; source: "override" | "path" | "cached" }> {
const { settings } = opts;
const key = homeKey(settings);
const cached = resolveCache.get(key);
@@ -325,109 +115,21 @@ export async function resolveWorktrunkBinary(opts: {
const installProbe = await probeWorktrunk(cachedInstallPath);
if (installProbe.ok) return { binaryPath: cachedInstallPath, source: "cached" };
logger.log("resolve: installing worktrunk");
const installed = await installWorktrunk(opts);
resolveCache.set(key, {
inputBinaryPath: settings.binaryPath ?? null,
path: installed.binaryPath,
resolvedAt: Date.now(),
});
return installed;
logger.log("resolve: install path disabled; failing");
await installWorktrunk(opts);
}
export async function installWorktrunk(opts: {
settings: WorktrunkSettings;
actionGateContext?: AgentActionGateContext;
auditor?: RunAuditor;
runContext?: EngineRunContext;
}): Promise<{ binaryPath: string; source: "installed-release" | "installed-cargo" }> {
const causes: Array<{ stage: string; error: string }> = [];
await emitBinaryAudit(opts.auditor, "binary:install-requested", {
version: WORKTRUNK_PINNED_RELEASE.version,
}): Promise<never> {
await emitBinaryAudit(opts.auditor, "binary:install-denied", {
reason: "auto-install-disabled",
taskId: opts.runContext?.taskId,
runId: opts.runContext?.runId,
});
await applyInstallGate(opts);
const platform = detectPlatform();
if (platform) {
const asset = WORKTRUNK_PINNED_RELEASE.assets[platform];
const downloadPath = path.join(WORKTRUNK_INSTALL_DIR, `${asset.archiveName}.download`);
const extractedPath = path.join(WORKTRUNK_INSTALL_DIR, "worktrunk.tmp");
try {
logger.log(`download: ${asset.url}`);
await fs.mkdir(WORKTRUNK_INSTALL_DIR, { recursive: true });
const checksum = await downloadReleaseAsset(asset.url, downloadPath);
logger.log("verify: sha256");
if (checksum.toLowerCase() !== asset.sha256.toLowerCase()) {
await fs.rm(downloadPath, { force: true });
throw new WorktrunkInstallFailedError("sha256 mismatch", {
stage: "sha256",
expected: asset.sha256,
actual: checksum,
});
}
logger.log("extract: archive");
await extractAsset(downloadPath, asset.innerBinaryName, extractedPath);
await fs.rm(downloadPath, { force: true });
await fs.rename(extractedPath, WORKTRUNK_INSTALL_PATH);
await fs.chmod(WORKTRUNK_INSTALL_PATH, 0o755);
const probe = await probeWorktrunk(WORKTRUNK_INSTALL_PATH);
if (!probe.ok) {
throw new WorktrunkInstallFailedError("release install probe failed", { stage: "probe", cause: probe.error });
}
opts.settings.installedBinaryPath = WORKTRUNK_INSTALL_PATH;
logger.log("success: installed release asset");
await emitBinaryAudit(opts.auditor, "binary:install-success", {
version: WORKTRUNK_PINNED_RELEASE.version,
source: "release",
sha256: asset.sha256,
taskId: opts.runContext?.taskId,
runId: opts.runContext?.runId,
});
return { binaryPath: WORKTRUNK_INSTALL_PATH, source: "installed-release" };
} catch (error) {
causes.push({ stage: "release", error: error instanceof Error ? error.message : String(error) });
logger.warn(`failure: release install failed; falling back to cargo (${causes.at(-1)?.error})`);
await fs.rm(downloadPath, { force: true }).catch(() => undefined);
await fs.rm(extractedPath, { force: true }).catch(() => undefined);
}
} else {
logger.log("cargo-fallback: no release asset for platform");
causes.push({ stage: "release", error: "unsupported platform for pinned asset" });
}
try {
const result = await cargoFallback(opts.settings);
await emitBinaryAudit(opts.auditor, "binary:install-success", {
version: WORKTRUNK_PINNED_RELEASE.version,
source: "cargo",
taskId: opts.runContext?.taskId,
runId: opts.runContext?.runId,
});
return result;
} catch (error) {
causes.push({ stage: "cargo", error: error instanceof Error ? error.message : String(error) });
}
await emitBinaryAudit(opts.auditor, "binary:install-failed", {
version: WORKTRUNK_PINNED_RELEASE.version,
attempted: ["release", "cargo"],
causes,
taskId: opts.runContext?.taskId,
runId: opts.runContext?.runId,
});
throw new WorktrunkInstallFailedError("failed to auto-install worktrunk", {
stage: causes.at(-1)?.stage ?? "unknown",
attempted: ["release", "cargo"],
causes,
});
throw new WorktrunkInstallFailedError(AUTO_INSTALL_DISABLED_MESSAGE, { stage: "auto-install-disabled" });
}
export function clearWorktrunkResolveCache(): void {