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
5
.changeset/fn-4706-worktrunk-removal.md
Normal file
5
.changeset/fn-4706-worktrunk-removal.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": minor
|
||||||
|
---
|
||||||
|
|
||||||
|
Disable the worktrunk auto-install path. The pinned cognitive-engineering-lab/worktrunk v0.4.2 release is no longer reachable (see FN-4704/FN-4705), so `installWorktrunk()` now throws `WorktrunkInstallFailedError` immediately. Users who opt into `worktrunk.enabled` must set `worktrunk.binaryPath` or place `worktrunk` on `$PATH`. Auto-install will return once an authoritative upstream release source is re-established. `WORKTRUNK_PINNED_RELEASE` and the release-download helpers are removed from `@fusion/engine` exports.
|
||||||
@@ -1,34 +1,11 @@
|
|||||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
import { EventEmitter } from "node:events";
|
import type { WorktrunkSettings } from "@fusion/core";
|
||||||
import type { AgentPermissionPolicy, WorktrunkSettings } from "@fusion/core";
|
|
||||||
import type { AgentActionGateContext } from "../agent-action-gate.js";
|
|
||||||
import type { RunAuditor } from "../run-audit.js";
|
import type { RunAuditor } from "../run-audit.js";
|
||||||
|
|
||||||
// ── Mocks ──────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
vi.mock("node:child_process", () => ({
|
vi.mock("node:child_process", () => ({
|
||||||
exec: vi.fn(),
|
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", () => ({
|
vi.mock("../logger.js", () => ({
|
||||||
createLogger: () => ({
|
createLogger: () => ({
|
||||||
log: vi.fn(),
|
log: vi.fn(),
|
||||||
@@ -37,111 +14,42 @@ vi.mock("../logger.js", () => ({
|
|||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// ── Imports (after mocks) ──────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const { exec: execImport } = await import("node:child_process");
|
const { exec: execImport } = await import("node:child_process");
|
||||||
const execMock = vi.mocked(execImport);
|
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 {
|
const {
|
||||||
resolveWorktrunkBinary,
|
resolveWorktrunkBinary,
|
||||||
installWorktrunk,
|
installWorktrunk,
|
||||||
probeWorktrunk,
|
probeWorktrunk,
|
||||||
clearWorktrunkResolveCache,
|
clearWorktrunkResolveCache,
|
||||||
WorktrunkInstallDeniedError,
|
|
||||||
WorktrunkInstallFailedError,
|
WorktrunkInstallFailedError,
|
||||||
WORKTRUNK_PINNED_RELEASE,
|
|
||||||
WORKTRUNK_INSTALL_PATH,
|
|
||||||
} = await import("../worktrunk-installer.js");
|
} = 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 {
|
function mockExecSequence(responses: Array<{ stdout?: string; error?: Error }>): void {
|
||||||
let i = 0;
|
let i = 0;
|
||||||
execMock.mockImplementation(((_cmd: string, _opts: unknown, cb: unknown) => {
|
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)];
|
const resp = responses[Math.min(i++, responses.length - 1)];
|
||||||
if (resp.error) {
|
if (resp.error) {
|
||||||
callback(resp.error);
|
callback(resp.error);
|
||||||
} else {
|
return;
|
||||||
callback(null, { stdout: resp.stdout ?? "", stderr: "" });
|
|
||||||
}
|
}
|
||||||
|
callback(null, { stdout: resp.stdout ?? "", stderr: "" });
|
||||||
}) as unknown as typeof execImport);
|
}) 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 {
|
function makeSettings(overrides?: Partial<WorktrunkSettings>): WorktrunkSettings {
|
||||||
return { enabled: true, onFailure: "fail", ...overrides };
|
return { enabled: true, onFailure: "fail", ...overrides };
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeAuditor(): { auditor: RunAuditor; 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; target: string; metadata: Record<string, unknown> }> = [];
|
const events: Array<{ type: string; metadata: Record<string, unknown> }> = [];
|
||||||
return {
|
return {
|
||||||
auditor: {
|
auditor: {
|
||||||
git: vi.fn().mockResolvedValue(undefined),
|
git: vi.fn().mockResolvedValue(undefined),
|
||||||
database: vi.fn().mockResolvedValue(undefined),
|
database: vi.fn().mockResolvedValue(undefined),
|
||||||
filesystem: vi.fn().mockImplementation(async (input: { type: string; target: string; metadata?: Record<string, unknown> }) => {
|
filesystem: vi.fn().mockImplementation(async (input: { type: string; metadata?: Record<string, unknown> }) => {
|
||||||
events.push({ type: input.type, target: input.target, metadata: input.metadata ?? {} });
|
events.push({ type: input.type, metadata: input.metadata ?? {} });
|
||||||
}),
|
}),
|
||||||
sandbox: vi.fn().mockResolvedValue(undefined),
|
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", () => {
|
describe("worktrunk-installer", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
clearWorktrunkResolveCache();
|
clearWorktrunkResolveCache();
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
it("probeWorktrunk returns ok=true with parsed version", async () => {
|
||||||
vi.restoreAllMocks();
|
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("resolveWorktrunkBinary resolves explicit binaryPath when probe succeeds", async () => {
|
||||||
it("returns ok=true with parsed version", async () => {
|
mockExecSequence([{ stdout: "worktrunk 0.4.2\n" }]);
|
||||||
mockExecOk("worktrunk 0.4.2\n");
|
await expect(resolveWorktrunkBinary({ settings: makeSettings({ binaryPath: "/opt/worktrunk" }) })).resolves.toEqual({
|
||||||
const result = await probeWorktrunk("/usr/local/bin/worktrunk");
|
binaryPath: "/opt/worktrunk",
|
||||||
expect(result).toEqual({ ok: true, version: "0.4.2" });
|
source: "override",
|
||||||
});
|
|
||||||
|
|
||||||
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");
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── resolveWorktrunkBinary ──────────────────────────────────────────────
|
it("resolveWorktrunkBinary resolves PATH hit when override is absent", async () => {
|
||||||
|
mockExecSequence([
|
||||||
describe("resolveWorktrunkBinary", () => {
|
{ stdout: "/usr/bin/worktrunk\n" },
|
||||||
it("resolves from settings.binaryPath when probe succeeds", async () => {
|
{ stdout: "worktrunk 0.4.2\n" },
|
||||||
mockExecOk("worktrunk 0.4.2\n");
|
]);
|
||||||
const result = await resolveWorktrunkBinary({
|
await expect(resolveWorktrunkBinary({ settings: makeSettings() })).resolves.toEqual({
|
||||||
settings: makeSettings({ binaryPath: "/opt/worktrunk" }),
|
binaryPath: "/usr/bin/worktrunk",
|
||||||
});
|
source: "path",
|
||||||
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();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── installWorktrunk ────────────────────────────────────────────────────
|
it("resolveWorktrunkBinary fails with disabled install error when override/PATH/cached probes fail", async () => {
|
||||||
|
mockExecSequence([
|
||||||
describe("installWorktrunk", () => {
|
{ error: new Error("not found") },
|
||||||
it("throws WorktrunkInstallDeniedError when gate blocks", async () => {
|
{ error: new Error("not found") },
|
||||||
const { auditor, events } = makeAuditor();
|
]);
|
||||||
const gateContext = makeGateContext("block");
|
await expect(resolveWorktrunkBinary({ settings: makeSettings() })).rejects.toThrow(WorktrunkInstallFailedError);
|
||||||
|
await expect(resolveWorktrunkBinary({ settings: makeSettings() })).rejects.toThrow(
|
||||||
await expect(
|
"worktrunk auto-install path disabled; set worktrunk.binaryPath or install worktrunk on PATH",
|
||||||
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"));
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -120,7 +120,6 @@ export {
|
|||||||
WorktrunkBinaryUnavailableError,
|
WorktrunkBinaryUnavailableError,
|
||||||
WorktrunkInstallDeniedError,
|
WorktrunkInstallDeniedError,
|
||||||
WorktrunkInstallFailedError,
|
WorktrunkInstallFailedError,
|
||||||
WORKTRUNK_PINNED_RELEASE,
|
|
||||||
WORKTRUNK_INSTALL_DIR,
|
WORKTRUNK_INSTALL_DIR,
|
||||||
WORKTRUNK_INSTALL_PATH,
|
WORKTRUNK_INSTALL_PATH,
|
||||||
WORKTRUNK_PROBE_TIMEOUT_MS,
|
WORKTRUNK_PROBE_TIMEOUT_MS,
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ const _worktrunkBinaryCache = new Map<string, { binaryPath: string; resolvedAt:
|
|||||||
|
|
||||||
export async function getWorktrunkBinary(
|
export async function getWorktrunkBinary(
|
||||||
settings: WorktrunkSettings,
|
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 cacheKey = `${process.env.HOME ?? ""}::${settings.binaryPath ?? ""}`;
|
||||||
const cached = _worktrunkBinaryCache.get(cacheKey);
|
const cached = _worktrunkBinaryCache.get(cacheKey);
|
||||||
if (cached) {
|
if (cached) {
|
||||||
|
|||||||
@@ -1,53 +1,14 @@
|
|||||||
import { exec } from "node:child_process";
|
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 os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { PassThrough } from "node:stream";
|
|
||||||
import { promisify } from "node:util";
|
import { promisify } from "node:util";
|
||||||
import type { AgentPermissionPolicy, WorktrunkSettings } from "@fusion/core";
|
import type { WorktrunkSettings } from "@fusion/core";
|
||||||
import { evaluateAgentActionGate, type AgentActionGateContext } from "./agent-action-gate.js";
|
|
||||||
import { createLogger } from "./logger.js";
|
import { createLogger } from "./logger.js";
|
||||||
import type { EngineRunContext, RunAuditor } from "./run-audit.js";
|
import type { EngineRunContext, RunAuditor } from "./run-audit.js";
|
||||||
import { assertSafeUrl } from "./web-fetch.js";
|
|
||||||
|
|
||||||
const execAsync = promisify(exec);
|
const execAsync = promisify(exec);
|
||||||
const logger = createLogger("worktrunk-installer");
|
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_PROBE_TIMEOUT_MS = 10_000;
|
||||||
export const WORKTRUNK_DOWNLOAD_TIMEOUT_MS = 60_000;
|
export const WORKTRUNK_DOWNLOAD_TIMEOUT_MS = 60_000;
|
||||||
export const WORKTRUNK_DOWNLOAD_MAX_BYTES = 50 * 1024 * 1024;
|
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_DIR = path.join(os.homedir(), ".fusion", "bin");
|
||||||
export const WORKTRUNK_INSTALL_PATH = path.join(WORKTRUNK_INSTALL_DIR, "worktrunk");
|
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 {
|
export class WorktrunkBinaryUnavailableError extends Error {
|
||||||
constructor(message: string, details?: Record<string, unknown>) {
|
constructor(message: string, details?: Record<string, unknown>) {
|
||||||
super(message);
|
super(message);
|
||||||
@@ -85,14 +49,6 @@ function homeKey(settings: WorktrunkSettings): string {
|
|||||||
return `${os.homedir()}::${settings.binaryPath ?? ""}`;
|
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(
|
async function emitBinaryAudit(
|
||||||
auditor: RunAuditor | undefined,
|
auditor: RunAuditor | undefined,
|
||||||
type: "binary:install-requested" | "binary:install-success" | "binary:install-failed" | "binary:install-denied",
|
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: {
|
export async function resolveWorktrunkBinary(opts: {
|
||||||
settings: WorktrunkSettings;
|
settings: WorktrunkSettings;
|
||||||
actionGateContext?: AgentActionGateContext;
|
|
||||||
auditor?: RunAuditor;
|
auditor?: RunAuditor;
|
||||||
runContext?: EngineRunContext;
|
runContext?: EngineRunContext;
|
||||||
}): Promise<{ binaryPath: string; source: "override" | "path" | "cached" | "installed-release" | "installed-cargo" }> {
|
}): Promise<{ binaryPath: string; source: "override" | "path" | "cached" }> {
|
||||||
const { settings } = opts;
|
const { settings } = opts;
|
||||||
const key = homeKey(settings);
|
const key = homeKey(settings);
|
||||||
const cached = resolveCache.get(key);
|
const cached = resolveCache.get(key);
|
||||||
@@ -325,109 +115,21 @@ export async function resolveWorktrunkBinary(opts: {
|
|||||||
const installProbe = await probeWorktrunk(cachedInstallPath);
|
const installProbe = await probeWorktrunk(cachedInstallPath);
|
||||||
if (installProbe.ok) return { binaryPath: cachedInstallPath, source: "cached" };
|
if (installProbe.ok) return { binaryPath: cachedInstallPath, source: "cached" };
|
||||||
|
|
||||||
logger.log("resolve: installing worktrunk");
|
logger.log("resolve: install path disabled; failing");
|
||||||
const installed = await installWorktrunk(opts);
|
await installWorktrunk(opts);
|
||||||
resolveCache.set(key, {
|
|
||||||
inputBinaryPath: settings.binaryPath ?? null,
|
|
||||||
path: installed.binaryPath,
|
|
||||||
resolvedAt: Date.now(),
|
|
||||||
});
|
|
||||||
return installed;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function installWorktrunk(opts: {
|
export async function installWorktrunk(opts: {
|
||||||
settings: WorktrunkSettings;
|
settings: WorktrunkSettings;
|
||||||
actionGateContext?: AgentActionGateContext;
|
|
||||||
auditor?: RunAuditor;
|
auditor?: RunAuditor;
|
||||||
runContext?: EngineRunContext;
|
runContext?: EngineRunContext;
|
||||||
}): Promise<{ binaryPath: string; source: "installed-release" | "installed-cargo" }> {
|
}): Promise<never> {
|
||||||
const causes: Array<{ stage: string; error: string }> = [];
|
await emitBinaryAudit(opts.auditor, "binary:install-denied", {
|
||||||
|
reason: "auto-install-disabled",
|
||||||
await emitBinaryAudit(opts.auditor, "binary:install-requested", {
|
|
||||||
version: WORKTRUNK_PINNED_RELEASE.version,
|
|
||||||
taskId: opts.runContext?.taskId,
|
taskId: opts.runContext?.taskId,
|
||||||
runId: opts.runContext?.runId,
|
runId: opts.runContext?.runId,
|
||||||
});
|
});
|
||||||
|
throw new WorktrunkInstallFailedError(AUTO_INSTALL_DISABLED_MESSAGE, { stage: "auto-install-disabled" });
|
||||||
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,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function clearWorktrunkResolveCache(): void {
|
export function clearWorktrunkResolveCache(): void {
|
||||||
|
|||||||
Reference in New Issue
Block a user