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:
committed by
gsxdsm
parent
de98e31582
commit
3defcbe849
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -120,7 +120,6 @@ export {
|
||||
WorktrunkBinaryUnavailableError,
|
||||
WorktrunkInstallDeniedError,
|
||||
WorktrunkInstallFailedError,
|
||||
WORKTRUNK_PINNED_RELEASE,
|
||||
WORKTRUNK_INSTALL_DIR,
|
||||
WORKTRUNK_INSTALL_PATH,
|
||||
WORKTRUNK_PROBE_TIMEOUT_MS,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user