feat(FN-5320): canonicalize worktrunk binary name and manifest
Implements canonical worktrunk binary naming and manifest handling (FN-5320), adding a worktrunk installer that canonicalizes the executable name and manifest data, with documentation updates for architecture and settings, plus test alignments across routes, audit, and worktree acquisition fixtures. Fusion-Task-Id: FN-5320
This commit is contained in:
committed by
gsxdsm
parent
232a9fea86
commit
9011c2107e
@@ -16,14 +16,14 @@ describe("reliability interactions: worktrunk audit correlation", () => {
|
||||
|
||||
await auditor.git({
|
||||
type: "worktree:worktrunk-install",
|
||||
target: "/usr/local/bin/worktrunk",
|
||||
metadata: { op: "install", binaryPath: "/usr/local/bin/worktrunk", installSource: "release-binary", durationMs: 90 },
|
||||
target: "/usr/local/bin/wt",
|
||||
metadata: { op: "install", binaryPath: "/usr/local/bin/wt", installSource: "release-binary", durationMs: 90 },
|
||||
});
|
||||
|
||||
await auditor.git({
|
||||
type: "worktree:worktrunk-create",
|
||||
target: "/repo/.worktrees/fn-4626",
|
||||
metadata: { op: "create", binaryPath: "/usr/local/bin/worktrunk", worktreePath: "/repo/.worktrees/fn-4626", durationMs: 35 },
|
||||
metadata: { op: "create", binaryPath: "/usr/local/bin/wt", worktreePath: "/repo/.worktrees/fn-4626", durationMs: 35 },
|
||||
});
|
||||
|
||||
expect(events.map((event) => event.mutationType)).toEqual([
|
||||
@@ -51,7 +51,7 @@ describe("reliability interactions: worktrunk audit correlation", () => {
|
||||
cause: new Error("create failed"),
|
||||
stderr: "x".repeat(5000),
|
||||
exitCode: 9,
|
||||
binaryPath: "/usr/local/bin/worktrunk",
|
||||
binaryPath: "/usr/local/bin/wt",
|
||||
worktreePath: "/repo/.worktrees/fn-4626",
|
||||
},
|
||||
task: { id: "FN-4626", worktrunkFallbackAlertedAt: null } as any,
|
||||
|
||||
@@ -17,28 +17,28 @@ describe("run-audit worktrunk lifecycle events", () => {
|
||||
it.each<WorktrunkLifecycleCase>([
|
||||
{
|
||||
type: "worktree:worktrunk-install",
|
||||
target: "/usr/local/bin/worktrunk",
|
||||
metadata: { op: "install", binaryPath: "/usr/local/bin/worktrunk", durationMs: 12, installSource: "cargo" },
|
||||
target: "/usr/local/bin/wt",
|
||||
metadata: { op: "install", binaryPath: "/usr/local/bin/wt", durationMs: 12, installSource: "cargo" },
|
||||
},
|
||||
{
|
||||
type: "worktree:worktrunk-create",
|
||||
target: "/repo/.worktrees/fn-1",
|
||||
metadata: { op: "create", binaryPath: "/usr/local/bin/worktrunk", worktreePath: "/repo/.worktrees/fn-1", durationMs: 31 },
|
||||
metadata: { op: "create", binaryPath: "/usr/local/bin/wt", worktreePath: "/repo/.worktrees/fn-1", durationMs: 31 },
|
||||
},
|
||||
{
|
||||
type: "worktree:worktrunk-sync",
|
||||
target: "/repo/.worktrees/fn-1",
|
||||
metadata: { op: "sync", binaryPath: "/usr/local/bin/worktrunk", worktreePath: "/repo/.worktrees/fn-1", durationMs: 44 },
|
||||
metadata: { op: "sync", binaryPath: "/usr/local/bin/wt", worktreePath: "/repo/.worktrees/fn-1", durationMs: 44 },
|
||||
},
|
||||
{
|
||||
type: "worktree:worktrunk-prune",
|
||||
target: "worktrunk-prune",
|
||||
metadata: { op: "prune", binaryPath: "/usr/local/bin/worktrunk", durationMs: 20, prunedCount: 3 },
|
||||
metadata: { op: "prune", binaryPath: "/usr/local/bin/wt", durationMs: 20, prunedCount: 3 },
|
||||
},
|
||||
{
|
||||
type: "worktree:worktrunk-remove",
|
||||
target: "/repo/.worktrees/fn-1",
|
||||
metadata: { op: "remove", binaryPath: "/usr/local/bin/worktrunk", worktreePath: "/repo/.worktrees/fn-1", durationMs: 13 },
|
||||
metadata: { op: "remove", binaryPath: "/usr/local/bin/wt", worktreePath: "/repo/.worktrees/fn-1", durationMs: 13 },
|
||||
},
|
||||
])("persists $type with metadata", async ({ type, target, metadata }) => {
|
||||
const recordRunAuditEvent = vi.fn(async (_event: RunAuditEventInput) => undefined);
|
||||
|
||||
@@ -15,14 +15,15 @@ vi.mock("../worktree-db-hydrate.js", () => ({
|
||||
hydrateWorktreeDb: vi.fn().mockResolvedValue({ degraded: false, tasksCopied: 1, documentsCopied: 1 }),
|
||||
}));
|
||||
|
||||
const { execMock, existsSyncMock } = vi.hoisted(() => {
|
||||
const { execMock, existsSyncMock, accessMock } = vi.hoisted(() => {
|
||||
const mock = vi.fn();
|
||||
(mock as any)[Symbol.for("nodejs.util.promisify.custom")] = mock;
|
||||
return { execMock: mock, existsSyncMock: vi.fn() };
|
||||
return { execMock: mock, existsSyncMock: vi.fn(), accessMock: vi.fn().mockResolvedValue(undefined) };
|
||||
});
|
||||
|
||||
vi.mock("node:child_process", () => ({ exec: execMock }));
|
||||
vi.mock("node:fs", () => ({ existsSync: existsSyncMock }));
|
||||
vi.mock("node:fs/promises", () => ({ access: accessMock }));
|
||||
|
||||
describe("acquireTaskWorktree backend wiring", () => {
|
||||
const task = { id: "FN-1", title: "Task", description: "Desc", branch: null, worktree: null } as any;
|
||||
@@ -36,6 +37,8 @@ describe("acquireTaskWorktree backend wiring", () => {
|
||||
execMock.mockReset();
|
||||
existsSyncMock.mockReset();
|
||||
existsSyncMock.mockReturnValue(true);
|
||||
accessMock.mockReset();
|
||||
accessMock.mockResolvedValue(undefined);
|
||||
store.updateTask.mockClear();
|
||||
store.logEntry.mockClear();
|
||||
store.pauseTask.mockClear();
|
||||
@@ -92,11 +95,11 @@ describe("acquireTaskWorktree backend wiring", () => {
|
||||
task,
|
||||
rootDir: "/repo",
|
||||
store,
|
||||
settings: { worktreeNaming: "task-id", worktrunk: { enabled: true, binaryPath: "worktrunk" } } as any,
|
||||
settings: { worktreeNaming: "task-id", worktrunk: { enabled: true, binaryPath: "wt" } } as any,
|
||||
audit,
|
||||
});
|
||||
|
||||
expect(execMock.mock.calls.some((call) => String(call[0]).includes('"worktrunk" "switch" "--create" "fusion/fn-1"'))).toBe(true);
|
||||
expect(execMock.mock.calls.some((call) => String(call[0]).includes('"wt" "switch" "--create" "fusion/fn-1"'))).toBe(true);
|
||||
expect(audit.git).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: "worktree:worktrunk-create",
|
||||
@@ -126,13 +129,14 @@ describe("acquireTaskWorktree backend wiring", () => {
|
||||
|
||||
it("throws worktrunk_operation_failed and preserves stderr", async () => {
|
||||
execMock.mockRejectedValue({ stderr: "worktrunk exploded", status: 17 });
|
||||
const explicitBinaryPath = "/opt/wt";
|
||||
|
||||
await expect(
|
||||
acquireTaskWorktree({
|
||||
task,
|
||||
rootDir: "/repo",
|
||||
store,
|
||||
settings: { worktreeNaming: "task-id", worktrunk: { enabled: true, binaryPath: "worktrunk" } } as any,
|
||||
settings: { worktreeNaming: "task-id", worktrunk: { enabled: true, binaryPath: explicitBinaryPath } } as any,
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
name: "WorktrunkOperationError",
|
||||
@@ -140,6 +144,7 @@ describe("acquireTaskWorktree backend wiring", () => {
|
||||
stderr: "worktrunk exploded",
|
||||
exitCode: 17,
|
||||
});
|
||||
expect(execMock.mock.calls.some((call) => String(call[0]).includes(`"${explicitBinaryPath}" "switch" "--create"`))).toBe(true);
|
||||
});
|
||||
|
||||
it("uses explicit backend override", async () => {
|
||||
|
||||
@@ -76,12 +76,12 @@ describe("acquireTaskWorktree worktrunk wiring", () => {
|
||||
task,
|
||||
rootDir: "/repo",
|
||||
store: makeStore() as any,
|
||||
settings: { worktrunk: { enabled: true, binaryPath: "worktrunk" } } as any,
|
||||
settings: { worktrunk: { enabled: true, binaryPath: "wt" } } as any,
|
||||
createWorktree,
|
||||
});
|
||||
|
||||
expect(createWorktree).toHaveBeenCalledTimes(1);
|
||||
expect(execMock.mock.calls.some((call) => String(call[0]).includes('"worktrunk" "switch"'))).toBe(false);
|
||||
expect(execMock.mock.calls.some((call) => String(call[0]).includes('"wt" "switch"'))).toBe(false);
|
||||
});
|
||||
|
||||
it("emits worktrunk + native create audits when worktrunk succeeds", async () => {
|
||||
@@ -103,11 +103,11 @@ describe("acquireTaskWorktree worktrunk wiring", () => {
|
||||
task,
|
||||
rootDir: "/repo",
|
||||
store: makeStore() as any,
|
||||
settings: { worktrunk: { enabled: true, binaryPath: "worktrunk", onFailure: "fail" } } as any,
|
||||
settings: { worktrunk: { enabled: true, binaryPath: "wt", onFailure: "fail" } } as any,
|
||||
audit: audit as any,
|
||||
});
|
||||
|
||||
expect(execMock.mock.calls.some((call) => String(call[0]).includes('"worktrunk" "switch" "--create" "fusion/fn-1" "--no-hooks" "--no-cd"'))).toBe(true);
|
||||
expect(execMock.mock.calls.some((call) => String(call[0]).includes('"wt" "switch" "--create" "fusion/fn-1" "--no-hooks" "--no-cd"'))).toBe(true);
|
||||
expect(events.filter((event) => event.type === "worktree:worktrunk-create")).toHaveLength(1);
|
||||
expect(events.filter((event) => event.type === "worktree:create")).toHaveLength(1);
|
||||
});
|
||||
@@ -132,7 +132,7 @@ describe("acquireTaskWorktree worktrunk wiring", () => {
|
||||
task,
|
||||
rootDir: "/repo",
|
||||
store: store as any,
|
||||
settings: { worktrunk: { enabled: true, binaryPath: "worktrunk", onFailure: "fail" } } as any,
|
||||
settings: { worktrunk: { enabled: true, binaryPath: "wt", onFailure: "fail" } } as any,
|
||||
});
|
||||
|
||||
expect(result.worktreePath).toBe("/repo/.worktrees/custom/fusion-fn-1");
|
||||
@@ -152,12 +152,12 @@ describe("acquireTaskWorktree worktrunk wiring", () => {
|
||||
task,
|
||||
rootDir: "/repo",
|
||||
store: makeStore() as any,
|
||||
settings: { worktrunk: { enabled: true, binaryPath: "worktrunk", onFailure: "fail" } } as any,
|
||||
settings: { worktrunk: { enabled: true, binaryPath: "wt", onFailure: "fail" } } as any,
|
||||
audit: audit as any,
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "worktrunk_operation_failed", operation: "create" });
|
||||
|
||||
expect(execMock.mock.calls.some((call) => String(call[0]).includes('"worktrunk" "switch" "--create" "fusion/fn-1"'))).toBe(true);
|
||||
expect(execMock.mock.calls.some((call) => String(call[0]).includes('"wt" "switch" "--create" "fusion/fn-1"'))).toBe(true);
|
||||
expect(events.some((event) => event.type === "worktree:worktrunk-fallback-native")).toBe(false);
|
||||
});
|
||||
|
||||
@@ -178,12 +178,12 @@ describe("acquireTaskWorktree worktrunk wiring", () => {
|
||||
task,
|
||||
rootDir: "/repo",
|
||||
store: makeStore() as any,
|
||||
settings: { worktrunk: { enabled: true, binaryPath: "worktrunk", onFailure: "fallback-native" } } as any,
|
||||
settings: { worktrunk: { enabled: true, binaryPath: "wt", onFailure: "fallback-native" } } as any,
|
||||
audit: audit as any,
|
||||
logger: { log: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
||||
});
|
||||
|
||||
expect(execMock.mock.calls.some((call) => String(call[0]).includes('"worktrunk" "switch" "--create" "fusion/fn-1"'))).toBe(true);
|
||||
expect(execMock.mock.calls.some((call) => String(call[0]).includes('"wt" "switch" "--create" "fusion/fn-1"'))).toBe(true);
|
||||
expect(execMock.mock.calls.some((call) => String(call[0]).includes("git worktree add -b"))).toBe(true);
|
||||
expect(events.filter((event) => event.type === "worktree:worktrunk-fallback-native")).toHaveLength(1);
|
||||
});
|
||||
@@ -209,7 +209,7 @@ describe("acquireTaskWorktree worktrunk wiring", () => {
|
||||
task,
|
||||
rootDir: "/repo",
|
||||
store: makeStore() as any,
|
||||
settings: { worktrunk: { enabled: true, binaryPath: "worktrunk" } } as any,
|
||||
settings: { worktrunk: { enabled: true, binaryPath: "wt" } } as any,
|
||||
backend: {
|
||||
kind: "native",
|
||||
create,
|
||||
|
||||
@@ -27,6 +27,8 @@ const {
|
||||
clearWorktrunkResolveCache,
|
||||
requestWorktrunkInstallApproval,
|
||||
executeApprovedWorktrunkInstall,
|
||||
validateWorktrunkManifest,
|
||||
WORKTRUNK_INSTALL_PATH,
|
||||
WORKTRUNK_PINNED_RELEASE,
|
||||
WorktrunkInstallDeniedError,
|
||||
WorktrunkInstallFailedError,
|
||||
@@ -72,6 +74,25 @@ function makeAuditor(): {
|
||||
};
|
||||
}
|
||||
|
||||
function resetPinnedRelease(): void {
|
||||
WORKTRUNK_PINNED_RELEASE.source = "upstream-pending-verification";
|
||||
WORKTRUNK_PINNED_RELEASE.version = null;
|
||||
WORKTRUNK_PINNED_RELEASE.verifiedAt = null;
|
||||
WORKTRUNK_PINNED_RELEASE.assets = {};
|
||||
}
|
||||
|
||||
function setVerifiedPinnedRelease(): void {
|
||||
WORKTRUNK_PINNED_RELEASE.source = "upstream-verified";
|
||||
WORKTRUNK_PINNED_RELEASE.version = "0.4.2";
|
||||
WORKTRUNK_PINNED_RELEASE.verifiedAt = "2026-05-20T00:00:00.000Z";
|
||||
WORKTRUNK_PINNED_RELEASE.assets = {
|
||||
linux: {
|
||||
url: "https://github.com/max-sixty/worktrunk/releases/download/v0.4.2/wt-linux-x64.tar.gz",
|
||||
sha256: "a".repeat(64),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("worktrunk-installer", () => {
|
||||
const actor = { actorId: "dashboard-user", actorType: "user" as const, actorName: "Dashboard User" };
|
||||
|
||||
@@ -79,11 +100,12 @@ describe("worktrunk-installer", () => {
|
||||
vi.clearAllMocks();
|
||||
loggerMock.warn.mockReset();
|
||||
clearWorktrunkResolveCache();
|
||||
resetPinnedRelease();
|
||||
});
|
||||
|
||||
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" });
|
||||
mockExecSequence([{ stdout: "wt 0.4.2\n" }]);
|
||||
await expect(probeWorktrunk("/usr/local/bin/wt")).resolves.toEqual({ ok: true, version: "0.4.2" });
|
||||
});
|
||||
|
||||
it("requestWorktrunkInstallApproval creates pending request and dedupes", async () => {
|
||||
@@ -102,6 +124,7 @@ describe("worktrunk-installer", () => {
|
||||
});
|
||||
expect(approvalStore.create).toHaveBeenCalledTimes(1);
|
||||
expect(approvalStore.create.mock.calls[0][0].targetAction.action).toBe("worktrunk_install");
|
||||
expect(approvalStore.create.mock.calls[0][0].targetAction.context.approvalDedupeKey).toBe("worktrunk_install:pending");
|
||||
|
||||
approvalStore.findLatestByDedupeKey.mockReturnValue({ id: "apr-1", status: "pending" });
|
||||
await expect(requestWorktrunkInstallApproval({ approvalStore, actor })).resolves.toEqual({
|
||||
@@ -131,9 +154,10 @@ describe("worktrunk-installer", () => {
|
||||
});
|
||||
|
||||
it("installWorktrunk with pre-approved override emits requested/success and returns path", async () => {
|
||||
setVerifiedPinnedRelease();
|
||||
const { auditor, filesystemEvents } = makeAuditor();
|
||||
await expect(installWorktrunk({ settings: makeSettings(), auditor, gateOverride: "pre-approved" })).resolves.toEqual({
|
||||
binaryPath: expect.stringContaining("worktrunk"),
|
||||
binaryPath: WORKTRUNK_INSTALL_PATH,
|
||||
source: "installed-release",
|
||||
});
|
||||
expect(filesystemEvents.some((event) => event.type === "binary:install-requested" && event.metadata.reason === "pre-approved")).toBe(true);
|
||||
@@ -141,6 +165,7 @@ describe("worktrunk-installer", () => {
|
||||
});
|
||||
|
||||
it("executeApprovedWorktrunkInstall marks request completed", async () => {
|
||||
setVerifiedPinnedRelease();
|
||||
const approvalStore = {
|
||||
markCompleted: vi.fn(),
|
||||
} as any;
|
||||
@@ -151,9 +176,9 @@ describe("worktrunk-installer", () => {
|
||||
targetAction: {
|
||||
category: "network_api",
|
||||
action: "worktrunk_install",
|
||||
summary: `Install worktrunk v${WORKTRUNK_PINNED_RELEASE.version}`,
|
||||
summary: "Install worktrunk (pending verification)",
|
||||
resourceType: "binary",
|
||||
resourceId: "/tmp/worktrunk",
|
||||
resourceId: "/tmp/wt",
|
||||
},
|
||||
requestedAt: new Date().toISOString(),
|
||||
createdAt: new Date().toISOString(),
|
||||
@@ -161,7 +186,7 @@ describe("worktrunk-installer", () => {
|
||||
} as any;
|
||||
|
||||
await expect(executeApprovedWorktrunkInstall({ approvalStore, settings: makeSettings(), request })).resolves.toEqual({
|
||||
binaryPath: expect.stringContaining("worktrunk"),
|
||||
binaryPath: WORKTRUNK_INSTALL_PATH,
|
||||
source: "installed-release",
|
||||
});
|
||||
expect(approvalStore.markCompleted).toHaveBeenCalledTimes(1);
|
||||
@@ -178,17 +203,17 @@ describe("worktrunk-installer", () => {
|
||||
});
|
||||
|
||||
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",
|
||||
mockExecSequence([{ stdout: "wt 0.4.2\n" }]);
|
||||
await expect(resolveWorktrunkBinary({ settings: makeSettings({ binaryPath: "/opt/wt" }) })).resolves.toEqual({
|
||||
binaryPath: "/opt/wt",
|
||||
source: "override",
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts actionGateContext and preserves current disabled-install behavior", async () => {
|
||||
mockExecSequence([{ stdout: "worktrunk 0.4.2\n" }]);
|
||||
mockExecSequence([{ stdout: "wt 0.4.2\n" }]);
|
||||
const result = await resolveWorktrunkBinary({
|
||||
settings: makeSettings({ binaryPath: "/opt/worktrunk" }),
|
||||
settings: makeSettings({ binaryPath: "/opt/wt" }),
|
||||
actionGateContext: {} as AgentActionGateContext,
|
||||
});
|
||||
|
||||
@@ -200,11 +225,11 @@ describe("worktrunk-installer", () => {
|
||||
|
||||
it("resolveWorktrunkBinary resolves PATH hit when override is absent", async () => {
|
||||
mockExecSequence([
|
||||
{ stdout: "/usr/bin/worktrunk\n" },
|
||||
{ stdout: "worktrunk 0.4.2\n" },
|
||||
{ stdout: "/usr/bin/wt\n" },
|
||||
{ stdout: "wt 0.4.2\n" },
|
||||
]);
|
||||
await expect(resolveWorktrunkBinary({ settings: makeSettings() })).resolves.toEqual({
|
||||
binaryPath: "/usr/bin/worktrunk",
|
||||
binaryPath: "/usr/bin/wt",
|
||||
source: "path",
|
||||
});
|
||||
});
|
||||
@@ -220,8 +245,58 @@ describe("worktrunk-installer", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("PATH probe looks for wt", async () => {
|
||||
mockExecSequence([
|
||||
{ error: new Error("not found") },
|
||||
{ error: new Error("not found") },
|
||||
]);
|
||||
|
||||
await expect(resolveWorktrunkBinary({ settings: makeSettings() })).rejects.toThrow(WorktrunkInstallFailedError);
|
||||
|
||||
const commands = execMock.mock.calls.map(([command]) => String(command));
|
||||
expect(commands.some((command) => command.includes(" wt"))).toBe(true);
|
||||
expect(commands.some((command) => command.includes(" worktrunk"))).toBe(false);
|
||||
});
|
||||
|
||||
it("installer metadata points at canonical upstream", () => {
|
||||
const serialized = JSON.stringify(WORKTRUNK_PINNED_RELEASE);
|
||||
const fabricatedUpstream = ["worktrunk", "worktrunk"].join("/");
|
||||
expect(serialized).not.toContain(fabricatedUpstream);
|
||||
for (const asset of Object.values(WORKTRUNK_PINNED_RELEASE.assets)) {
|
||||
expect(asset.url.startsWith("https://github.com/max-sixty/worktrunk/releases/")).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("auto-install fails closed without checksum", async () => {
|
||||
const { auditor, filesystemEvents } = makeAuditor();
|
||||
|
||||
await expect(
|
||||
installWorktrunk({ settings: makeSettings(), auditor, gateOverride: "pre-approved" }),
|
||||
).rejects.toMatchObject({
|
||||
name: "WorktrunkInstallFailedError",
|
||||
stage: "manifest-unverified",
|
||||
});
|
||||
expect(filesystemEvents.some((event) => event.type === "binary:install-success")).toBe(false);
|
||||
|
||||
mockExecSequence([
|
||||
{ error: new Error("not found") },
|
||||
{ error: new Error("not found") },
|
||||
]);
|
||||
await expect(resolveWorktrunkBinary({ settings: makeSettings() })).rejects.toMatchObject({
|
||||
name: "WorktrunkInstallFailedError",
|
||||
});
|
||||
});
|
||||
|
||||
it("external-tool integrations require a source-of-truth manifest", () => {
|
||||
const validation = validateWorktrunkManifest({} as any);
|
||||
expect(validation).toMatchObject({ ok: false });
|
||||
if (validation.ok) throw new Error("expected validation failure");
|
||||
expect(validation.missingFields).toEqual(expect.arrayContaining(["source", "assets", "verifiedAt"]));
|
||||
});
|
||||
|
||||
describe("install audit emission", () => {
|
||||
it("is a safe no-op when auditor is undefined", async () => {
|
||||
setVerifiedPinnedRelease();
|
||||
await expect(
|
||||
installWorktrunk({
|
||||
settings: makeSettings(),
|
||||
@@ -229,12 +304,13 @@ describe("worktrunk-installer", () => {
|
||||
runContext: { runId: "run-no-audit", agentId: "agent-1", taskId: "FN-4711" },
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
binaryPath: expect.stringContaining("worktrunk"),
|
||||
binaryPath: WORKTRUNK_INSTALL_PATH,
|
||||
source: "installed-release",
|
||||
});
|
||||
});
|
||||
|
||||
it("swallows install audit emitter failures and logs a warning", async () => {
|
||||
setVerifiedPinnedRelease();
|
||||
const { auditor } = makeAuditor();
|
||||
vi.mocked(auditor.git).mockRejectedValueOnce(new Error("audit write failed"));
|
||||
|
||||
@@ -246,7 +322,7 @@ describe("worktrunk-installer", () => {
|
||||
runContext: { runId: "run-audit-fail", agentId: "agent-1", taskId: "FN-4711" },
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
binaryPath: expect.stringContaining("worktrunk"),
|
||||
binaryPath: WORKTRUNK_INSTALL_PATH,
|
||||
source: "installed-release",
|
||||
});
|
||||
|
||||
@@ -256,6 +332,7 @@ describe("worktrunk-installer", () => {
|
||||
it.each([
|
||||
{ expectedSource: "release-binary" as const },
|
||||
])("emits worktree install audit event metadata for $expectedSource", async ({ expectedSource }) => {
|
||||
setVerifiedPinnedRelease();
|
||||
const { auditor, gitEvents } = makeAuditor();
|
||||
const runContext = { runId: "run-install-success", agentId: "agent-1", taskId: "FN-4711" };
|
||||
|
||||
@@ -263,11 +340,11 @@ describe("worktrunk-installer", () => {
|
||||
|
||||
const installEvent = gitEvents.find((event) => event.type === "worktree:worktrunk-install");
|
||||
expect(installEvent).toBeDefined();
|
||||
expect(installEvent?.target).toContain("worktrunk");
|
||||
expect(installEvent?.target).toContain("/wt");
|
||||
expect(installEvent?.metadata).toEqual(
|
||||
expect.objectContaining({
|
||||
op: "install",
|
||||
binaryPath: expect.stringContaining("worktrunk"),
|
||||
binaryPath: expect.stringContaining("/wt"),
|
||||
installSource: expectedSource,
|
||||
durationMs: expect.any(Number),
|
||||
taskId: "FN-4711",
|
||||
@@ -280,8 +357,8 @@ describe("worktrunk-installer", () => {
|
||||
it("does not emit worktree install audit for PATH cache-hit resolution", async () => {
|
||||
const { auditor, gitEvents } = makeAuditor();
|
||||
mockExecSequence([
|
||||
{ stdout: "/usr/bin/worktrunk\n" },
|
||||
{ stdout: "worktrunk 0.4.2\n" },
|
||||
{ stdout: "/usr/bin/wt\n" },
|
||||
{ stdout: "wt 0.4.2\n" },
|
||||
]);
|
||||
|
||||
await resolveWorktrunkBinary({ settings: makeSettings(), auditor });
|
||||
|
||||
@@ -137,9 +137,11 @@ export {
|
||||
clearWorktrunkResolveCache,
|
||||
requestWorktrunkInstallApproval,
|
||||
executeApprovedWorktrunkInstall,
|
||||
validateWorktrunkManifest,
|
||||
WorktrunkBinaryUnavailableError,
|
||||
WorktrunkInstallDeniedError,
|
||||
WorktrunkInstallFailedError,
|
||||
WORKTRUNK_BINARY_NAME,
|
||||
WORKTRUNK_INSTALL_DIR,
|
||||
WORKTRUNK_INSTALL_PATH,
|
||||
WORKTRUNK_PINNED_RELEASE,
|
||||
@@ -147,6 +149,10 @@ export {
|
||||
WORKTRUNK_DOWNLOAD_TIMEOUT_MS,
|
||||
WORKTRUNK_DOWNLOAD_MAX_BYTES,
|
||||
WORKTRUNK_CARGO_TIMEOUT_MS,
|
||||
type WorktrunkReleaseAsset,
|
||||
type WorktrunkReleaseManifest,
|
||||
type WorktrunkManifestValidationError,
|
||||
type WorktrunkManifestValidationResult,
|
||||
} from "./worktrunk-installer.js";
|
||||
export {
|
||||
handleWorktrunkOperationFailure,
|
||||
|
||||
@@ -15,16 +15,37 @@ export const WORKTRUNK_DOWNLOAD_TIMEOUT_MS = 60_000;
|
||||
export const WORKTRUNK_DOWNLOAD_MAX_BYTES = 50 * 1024 * 1024;
|
||||
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");
|
||||
export const WORKTRUNK_PINNED_RELEASE = {
|
||||
version: "0.4.2",
|
||||
assets: {
|
||||
unknown: {
|
||||
url: "https://github.com/worktrunk/worktrunk/releases/download/v0.4.2/worktrunk.tar.gz",
|
||||
sha256: "",
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
export const WORKTRUNK_BINARY_NAME = "wt";
|
||||
export const WORKTRUNK_INSTALL_PATH = path.join(WORKTRUNK_INSTALL_DIR, WORKTRUNK_BINARY_NAME);
|
||||
|
||||
export interface WorktrunkReleaseAsset {
|
||||
url: string;
|
||||
sha256: string;
|
||||
}
|
||||
|
||||
export interface WorktrunkReleaseManifest {
|
||||
source: "upstream-pending-verification" | "upstream-verified";
|
||||
version: string | null;
|
||||
verifiedAt: string | null;
|
||||
assets: Record<string, WorktrunkReleaseAsset>;
|
||||
}
|
||||
|
||||
export const WORKTRUNK_PINNED_RELEASE: WorktrunkReleaseManifest = {
|
||||
source: "upstream-pending-verification",
|
||||
version: null,
|
||||
verifiedAt: null,
|
||||
assets: {},
|
||||
};
|
||||
|
||||
export interface WorktrunkManifestValidationError {
|
||||
ok: false;
|
||||
missingFields: Array<"source" | "version" | "verifiedAt" | "assets" | `assets.${string}.url` | `assets.${string}.sha256`>;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export type WorktrunkManifestValidationResult =
|
||||
| { ok: true }
|
||||
| WorktrunkManifestValidationError;
|
||||
|
||||
const AUTO_INSTALL_DISABLED_MESSAGE =
|
||||
"worktrunk auto-install path disabled; set worktrunk.binaryPath or install worktrunk on PATH";
|
||||
@@ -45,6 +66,11 @@ export class WorktrunkInstallDeniedError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Known `stage` values on details:
|
||||
* - `auto-install-disabled`
|
||||
* - `manifest-unverified`
|
||||
*/
|
||||
export class WorktrunkInstallFailedError extends Error {
|
||||
constructor(message: string, details?: Record<string, unknown>) {
|
||||
super(message);
|
||||
@@ -55,6 +81,55 @@ export class WorktrunkInstallFailedError extends Error {
|
||||
|
||||
const resolveCache = new Map<string, { inputBinaryPath: string | null; path: string; resolvedAt: number }>();
|
||||
|
||||
function worktrunkInstallDedupeKey(): string {
|
||||
return WORKTRUNK_PINNED_RELEASE.version
|
||||
? `worktrunk_install:${WORKTRUNK_PINNED_RELEASE.version}`
|
||||
: "worktrunk_install:pending";
|
||||
}
|
||||
|
||||
function worktrunkVersionLabel(): string {
|
||||
return WORKTRUNK_PINNED_RELEASE.version ?? "pending";
|
||||
}
|
||||
|
||||
export function validateWorktrunkManifest(input: unknown): WorktrunkManifestValidationResult {
|
||||
const missingFields: WorktrunkManifestValidationError["missingFields"] = [];
|
||||
if (!input || typeof input !== "object") {
|
||||
return {
|
||||
ok: false,
|
||||
missingFields: ["source", "version", "verifiedAt", "assets"],
|
||||
reason: "Worktrunk release manifest must be an object with source, version, verifiedAt, and assets fields.",
|
||||
};
|
||||
}
|
||||
|
||||
const record = input as Record<string, unknown>;
|
||||
if (typeof record.source !== "string") missingFields.push("source");
|
||||
if (!(typeof record.version === "string" || record.version === null)) missingFields.push("version");
|
||||
if (!(typeof record.verifiedAt === "string" || record.verifiedAt === null)) missingFields.push("verifiedAt");
|
||||
if (!record.assets || typeof record.assets !== "object" || Array.isArray(record.assets)) {
|
||||
missingFields.push("assets");
|
||||
} else {
|
||||
for (const [assetName, assetValue] of Object.entries(record.assets as Record<string, unknown>)) {
|
||||
const assetRecord = assetValue as Record<string, unknown>;
|
||||
if (!assetValue || typeof assetValue !== "object" || Array.isArray(assetValue) || typeof assetRecord.url !== "string") {
|
||||
missingFields.push(`assets.${assetName}.url`);
|
||||
}
|
||||
if (!assetValue || typeof assetValue !== "object" || Array.isArray(assetValue) || typeof assetRecord.sha256 !== "string") {
|
||||
missingFields.push(`assets.${assetName}.sha256`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (missingFields.length > 0) {
|
||||
return {
|
||||
ok: false,
|
||||
missingFields,
|
||||
reason: `Worktrunk release manifest is missing required fields: ${missingFields.join(", ")}`,
|
||||
};
|
||||
}
|
||||
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
function homeKey(settings: WorktrunkSettings): string {
|
||||
return `${os.homedir()}::${settings.binaryPath ?? ""}`;
|
||||
}
|
||||
@@ -144,7 +219,7 @@ export async function resolveWorktrunkBinary(opts: {
|
||||
}
|
||||
|
||||
logger.log("resolve: checking PATH");
|
||||
const onPath = await lookupPath("worktrunk");
|
||||
const onPath = await lookupPath(WORKTRUNK_BINARY_NAME);
|
||||
if (onPath) {
|
||||
const probe = await probeWorktrunk(onPath);
|
||||
if (probe.ok) return { binaryPath: onPath, source: "path" };
|
||||
@@ -156,8 +231,15 @@ export async function resolveWorktrunkBinary(opts: {
|
||||
if (installProbe.ok) return { binaryPath: cachedInstallPath, source: "cached" };
|
||||
|
||||
logger.log("resolve: install path disabled; failing");
|
||||
await installWorktrunk(opts);
|
||||
throw new WorktrunkInstallFailedError(AUTO_INSTALL_DISABLED_MESSAGE, { stage: "auto-install-disabled" });
|
||||
try {
|
||||
const installed = await installWorktrunk(opts);
|
||||
return { binaryPath: installed.binaryPath, source: installed.source };
|
||||
} catch (error) {
|
||||
if (error instanceof WorktrunkInstallFailedError && (error as { stage?: string }).stage === "manifest-unverified") {
|
||||
throw error;
|
||||
}
|
||||
throw new WorktrunkInstallFailedError(AUTO_INSTALL_DISABLED_MESSAGE, { stage: "auto-install-disabled" });
|
||||
}
|
||||
}
|
||||
|
||||
export async function requestWorktrunkInstallApproval(opts: {
|
||||
@@ -165,7 +247,7 @@ export async function requestWorktrunkInstallApproval(opts: {
|
||||
actor: ApprovalRequestActorSnapshot;
|
||||
projectId?: string;
|
||||
}): Promise<{ approvalRequestId: string; status: "pending" | "approved" | "denied" | "completed" }> {
|
||||
const dedupeKey = `worktrunk_install:${WORKTRUNK_PINNED_RELEASE.version}`;
|
||||
const dedupeKey = worktrunkInstallDedupeKey();
|
||||
const existing = opts.approvalStore.findLatestByDedupeKey({
|
||||
requesterActorId: opts.actor.actorId,
|
||||
taskId: undefined,
|
||||
@@ -180,7 +262,7 @@ export async function requestWorktrunkInstallApproval(opts: {
|
||||
targetAction: {
|
||||
category: "network_api",
|
||||
action: "worktrunk_install",
|
||||
summary: `Install worktrunk v${WORKTRUNK_PINNED_RELEASE.version}`,
|
||||
summary: `Install worktrunk ${worktrunkVersionLabel() === "pending" ? "(pending verification)" : `v${worktrunkVersionLabel()}`}`,
|
||||
resourceType: "binary",
|
||||
resourceId: WORKTRUNK_INSTALL_PATH,
|
||||
context: {
|
||||
@@ -258,6 +340,36 @@ export async function installWorktrunk(opts: {
|
||||
}): Promise<{ binaryPath: string; source: "installed-release" | "installed-cargo" }> {
|
||||
const startedAt = Date.now();
|
||||
await applyInstallGate(opts);
|
||||
|
||||
const manifestValidation = validateWorktrunkManifest(WORKTRUNK_PINNED_RELEASE);
|
||||
if (!manifestValidation.ok) {
|
||||
throw new WorktrunkInstallFailedError(manifestValidation.reason, {
|
||||
stage: "manifest-unverified",
|
||||
missingFields: manifestValidation.missingFields,
|
||||
});
|
||||
}
|
||||
|
||||
const assets = Object.entries(WORKTRUNK_PINNED_RELEASE.assets);
|
||||
if (assets.length === 0) {
|
||||
throw new WorktrunkInstallFailedError("Worktrunk release manifest is missing assets for installation.", {
|
||||
stage: "manifest-unverified",
|
||||
missingFields: ["assets"],
|
||||
});
|
||||
}
|
||||
const [assetName, asset] = assets[0];
|
||||
if (!asset.url.trim()) {
|
||||
throw new WorktrunkInstallFailedError(`Worktrunk release manifest is missing assets.${assetName}.url.`, {
|
||||
stage: "manifest-unverified",
|
||||
missingFields: [`assets.${assetName}.url`],
|
||||
});
|
||||
}
|
||||
if (!asset.sha256.trim()) {
|
||||
throw new WorktrunkInstallFailedError(`Worktrunk release manifest is missing assets.${assetName}.sha256.`, {
|
||||
stage: "manifest-unverified",
|
||||
missingFields: [`assets.${assetName}.sha256`],
|
||||
});
|
||||
}
|
||||
|
||||
await emitBinaryAudit(opts.auditor, "binary:install-success", {
|
||||
source: "installed-release",
|
||||
binaryPath: WORKTRUNK_INSTALL_PATH,
|
||||
|
||||
Reference in New Issue
Block a user