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:
Fusion (runfusion.ai)
2026-05-20 07:44:09 -07:00
committed by gsxdsm
parent 232a9fea86
commit 9011c2107e
14 changed files with 321 additions and 75 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Fix the Worktrunk integration to probe the canonical `wt` binary, point release metadata at the real `max-sixty/worktrunk` upstream, and fail closed when install metadata is still unverified. This preserves default-off behavior when `worktrunk.enabled=false` and hardens enabled setups that rely on an explicit `worktrunk.binaryPath`.

View File

@@ -696,11 +696,13 @@ Guardrails: this routine does **not** retry merges, does **not** apply to mixed/
### Worktree and naming helpers
- `WorktreePool` (`worktree-pool.ts`) — idle worktree reuse
- `WorktreeBackend` (`worktree-backend.ts`) — abstraction for worktree operations used by `acquireTaskWorktree`. `native` (default) preserves existing `git worktree` behavior (including sibling-branch retry semantics), while `resolveWorktreeBackend(settings)` selects `worktrunk` when `settings.worktrunk?.enabled === true`.
- `WorktreeBackend` (`worktree-backend.ts`) — abstraction for worktree operations used by `acquireTaskWorktree`. `native` (default) preserves existing `git worktree` behavior (including sibling-branch retry semantics), while `resolveWorktreeBackend(settings)` selects [worktrunk](https://github.com/max-sixty/worktrunk) when `settings.worktrunk?.enabled === true`.
- Worktrunk path delegates five decisions with per-op timeouts: `create` (120s), `sync` (180s), `prune` (60s), `remove` (60s), and layout resolution (5s).
- Direct worktrunk CLI delegates: `create` → `wt switch --create ... --no-hooks --no-cd`, `remove` → `wt remove --foreground`.
- Fusion probes the canonical `wt` binary on `$PATH`; explicit `worktrunk.binaryPath` overrides still win when operators pin a different location.
- Worktrunk-aware fallback implementations where worktrunk lacks a dedicated primitive: `sync` uses git fetch+rebase semantics, and `prune` uses `git worktree list --porcelain` plus per-branch `remove` calls.
- Layout precedence: when `worktrunk.enabled=true`, `resolveTaskWorktreePathForBackend(...)` defers to backend `resolveWorktreePath(...)` (using `wt config show --format json` template data with default `{{ repo_path }}/.worktrees/{{ branch | sanitize }}` fallback); otherwise it remains byte-identical to FN-4606 `resolveTaskWorktreePath(...)` behavior.
- Auto-install remains fail-closed while the pinned release manifest is `upstream-pending-verification`: the pre-approved install path now rejects missing asset URLs/checksums instead of fabricating a local binary. This preserves the FN-4704/FN-4705 disabled-install contract until a human verifies a real upstream release manifest.
- `worktrunk.onFailure` controls fail-hard vs fallback-native create behavior and emits `worktree:worktrunk-*` run-audit events for create/fallback paths.
- `WorktreeNames` (`worktree-names.ts`) — deterministic worktree/branch naming
@@ -1534,7 +1536,7 @@ The GitHub tracking state listener now attaches to every registered project stor
#### WorktreeBackend abstraction
- Backend contract: `WorktreeBackend` (`packages/engine/src/worktree-backend.ts`, re-exported via `packages/engine/src/worktree-pool.ts`).
- Implementations: `NativeWorktreeBackend` (Fusion-managed `git worktree` flow) and `WorktrunkWorktreeBackend` (delegates to external `worktrunk` CLI).
- Implementations: `NativeWorktreeBackend` (Fusion-managed `git worktree` flow) and `WorktrunkWorktreeBackend` (delegates to the external `wt` CLI from [max-sixty/worktrunk](https://github.com/max-sixty/worktrunk)).
- Backend selection is driven by `worktrunk.enabled`; when enabled, worktrunk-managed layout overrides `worktreesDir` for delegated operations.
- Worktrunk layout is authoritative on create: after `wt switch --create`, Fusion resolves the actual registered worktree path via `git worktree list --porcelain` and uses that path (instead of assuming `resolveTaskWorktreePath` alignment).
- Delegated operation surface in the interface: `create`, `sync`, `prune`, `remove` (plus backend path resolution via `resolveWorktreePath`).
@@ -1542,6 +1544,7 @@ The GitHub tracking state listener now attaches to every registered project stor
- Worktree removal is backend-mediated across merger, self-healing, worktree-pool, executor, and step-session cleanup paths via `removeWorktree(...)` (`WorktreeBackend.remove()`).
- Self-healing is worktrunk-aware for failure recovery: tasks paused with `pausedReason: "worktrunk_operation_failed"` are explicitly skipped in reclaim sweeps (`self-healing.ts`) until operator intervention.
- Failure contract: delegated worktrunk errors preserve stderr context (`WorktrunkOperationError`) and are handled by `worktrunk.onFailure` — `"fail"` pauses the task, while `"fallback-native"` retries on the native backend and emits one-shot fallback telemetry.
- Install contract: Fusion only auto-installs from a source-of-truth manifest. The shipped placeholder manifest intentionally stays in `upstream-pending-verification` until a human verifies upstream asset URLs and checksums, so install attempts fail closed rather than guessing release metadata.
#### Stale `index.lock` recovery on worktree create
- Native worktree create paths now classify `git worktree add` failures containing `.../index.lock: File exists` before falling back to generic branch-conflict handling.

View File

@@ -273,8 +273,8 @@ Sandbox backend precedence is:
| Setting | Type | Default | Description |
| --- | --- | --- | --- |
| `worktreesDir` | `string` | `undefined` | Optional container directory for task worktrees. Supports absolute paths, project-relative paths, `~` expansion, and `{repo}` token substitution (project root basename). Defaults to `<projectRoot>/.worktrees` when unset and applies to newly-created worktrees/pool scans. When `worktrunk.enabled` is `true`, worktrunk-managed layout takes precedence and this directory is ignored until worktrunk is disabled. |
| `worktrunk.enabled` | `boolean` | `false` | Enables the worktrunk backend (`WorktreeBackend`) for worktree operations. When enabled, worktrunk layout supersedes Fusion’s `.worktrees/<task-id>` and `worktreesDir` behavior. This key exists in global and project settings; project values override global values for matching fields. Setting this to `true` is rejected by the settings API and CLI until the pinned `worktrunk` binary resolves and probe-verifies. Install first via Settings → Worktrunk integration (or `GET /api/worktrunk/status` + `POST /api/worktrunk/install-request`). See [Architecture: WorktreeBackend abstraction](./architecture.md#worktreebackend-abstraction). |
| `worktrunk.binaryPath` | `string \| undefined` | `undefined` | Optional absolute override for the `worktrunk` binary. When unset, Fusion resolves from `$PATH`/cached install path and then falls through to the auto-install flow on first use (guarded by `network_api` action-gate approval for `worktrunk_install`; currently disabled by default). When enabling `worktrunk.enabled`, this resolved/overridden path is still probe-verified before the setting is accepted. |
| `worktrunk.enabled` | `boolean` | `false` | Enables the worktrunk backend (`WorktreeBackend`) for worktree operations. When enabled, worktrunk layout supersedes Fusion’s `.worktrees/<task-id>` and `worktreesDir` behavior. This key exists in global and project settings; project values override global values for matching fields. Setting this to `true` is rejected by the settings API and CLI until the pinned `wt` binary resolves and probe-verifies. Install first via Settings → Worktrunk integration (or `GET /api/worktrunk/status` + `POST /api/worktrunk/install-request`). Auto-install remains fail-closed until the upstream manifest is human-verified, so the default placeholder manifest will not fabricate a binary. See [Architecture: WorktreeBackend abstraction](./architecture.md#worktreebackend-abstraction). |
| `worktrunk.binaryPath` | `string \| undefined` | `undefined` | Optional absolute override for the `wt` binary. When unset, Fusion probes `wt` on `$PATH`, then checks the cached install path, and only then considers the gated auto-install flow. Auto-install is currently fail-closed until the upstream manifest is human-verified, so operators who enable `worktrunk.enabled` should set `worktrunk.binaryPath` or install `wt` themselves. When enabling `worktrunk.enabled`, this resolved/overridden path is still probe-verified before the setting is accepted. |
| `worktrunk.onFailure` | `"fail" \| "fallback-native"` | `"fail"` | Failure behavior for delegated worktrunk operations. `"fail"` (default) pauses the task with `pausedReason: "worktrunk_operation_failed"` and surfaces worktrunk stderr via `task.worktrunkFailure`. `"fallback-native"` switches to the native backend and emits a one-shot dashboard fallback alert per task (`task.worktrunkFallbackAlertedAt`). |
Default notes:

View File

@@ -82,7 +82,7 @@ vi.mock("@fusion/core", () => ({
AgentStore: MockAgentStore,
}));
const executeApprovedWorktrunkInstall = vi.fn(async () => ({ binaryPath: "~/.fusion/bin/worktrunk", source: "installed-release" }));
const executeApprovedWorktrunkInstall = vi.fn(async () => ({ binaryPath: "~/.fusion/bin/wt", source: "installed-release" }));
vi.mock("@fusion/engine", () => ({
executeApprovedAgentProvisioning,
@@ -221,7 +221,7 @@ describe("approval routes", async () => {
summary: "Install worktrunk",
action: "worktrunk_install",
resourceType: "binary",
resourceId: "~/.fusion/bin/worktrunk",
resourceId: "~/.fusion/bin/wt",
},
taskId: "FN-1",
runId: "run-4",

View File

@@ -11,7 +11,7 @@ const state = {
const requestWorktrunkInstallApproval = vi.fn(async () => ({ approvalRequestId: state.pendingApprovalId, status: "pending" as const }));
const resolveWorktrunkBinary = vi.fn(async () => {
if (!state.installed) throw new Error("missing");
return { binaryPath: "~/.fusion/bin/worktrunk", source: "cached" as const };
return { binaryPath: "~/.fusion/bin/wt", source: "cached" as const };
});
const probeWorktrunk = vi.fn(async () => ({ ok: true, version: "0.4.2" }));
@@ -26,8 +26,13 @@ class MockApprovalRequestStore {
}
vi.mock("@fusion/engine", () => ({
WORKTRUNK_INSTALL_PATH: "~/.fusion/bin/worktrunk",
WORKTRUNK_PINNED_RELEASE: { version: "0.4.2", assets: { unknown: { url: "u", sha256: "s" } } },
WORKTRUNK_INSTALL_PATH: "~/.fusion/bin/wt",
WORKTRUNK_PINNED_RELEASE: {
source: "upstream-pending-verification",
version: null,
verifiedAt: null,
assets: {},
},
requestWorktrunkInstallApproval,
resolveWorktrunkBinary,
probeWorktrunk,
@@ -67,6 +72,8 @@ describe("worktrunk routes", async () => {
state.installed = false;
state.requests = new Map();
requestWorktrunkInstallApproval.mockClear();
resolveWorktrunkBinary.mockClear();
probeWorktrunk.mockClear();
});
it("returns installed from status when binary exists", async () => {
@@ -74,7 +81,11 @@ describe("worktrunk routes", async () => {
const app = createApp();
const res = await get(app, "/api/worktrunk/status");
expect(res.status).toBe(200);
expect(res.body.status).toBe("installed");
expect(res.body).toEqual({
status: "installed",
version: "0.4.2",
installPath: "~/.fusion/bin/wt",
});
});
it("returns pending-approval from status when pending request exists", async () => {
@@ -82,7 +93,11 @@ describe("worktrunk routes", async () => {
const app = createApp();
const res = await get(app, "/api/worktrunk/status");
expect(res.status).toBe(200);
expect(res.body.status).toBe("pending-approval");
expect(res.body).toEqual({
status: "pending-approval",
pendingApprovalId: "apr-worktrunk",
installPath: "~/.fusion/bin/wt",
});
});
it("creates install request when missing", async () => {
@@ -101,4 +116,17 @@ describe("worktrunk routes", async () => {
expect(res.body.status).toBe("pending-approval");
expect(requestWorktrunkInstallApproval).toHaveBeenCalledTimes(1);
});
it("returns pending version label for installed POST route fallback", async () => {
state.installed = true;
probeWorktrunk.mockResolvedValueOnce({ ok: true, version: undefined });
const app = createApp();
const res = await request(app, "POST", "/api/worktrunk/install-request", JSON.stringify({}), { "content-type": "application/json" });
expect(res.status).toBe(200);
expect(res.body).toEqual({
status: "installed",
installPath: "~/.fusion/bin/wt",
version: "pending",
});
});
});

View File

@@ -84,7 +84,7 @@ describe("register-settings-memory-routes worktrunk gate", () => {
it("accepts worktrunk.enabled=true when binary resolves and probes", async () => {
const { app, scopedStore } = createApp();
resolveWorktrunkBinaryMock.mockResolvedValueOnce({ binaryPath: "/tmp/worktrunk" });
resolveWorktrunkBinaryMock.mockResolvedValueOnce({ binaryPath: "/tmp/wt" });
probeWorktrunkMock.mockResolvedValueOnce({ ok: true, version: "1.0.0" });
const res = await patchSettings(app, { worktrunk: { enabled: true } });

View File

@@ -16,6 +16,12 @@ const DEFAULT_ACTOR: ApprovalRequestActorSnapshot = {
actorName: "User",
};
function worktrunkInstallDedupeKey(): string {
return WORKTRUNK_PINNED_RELEASE.version
? `worktrunk_install:${WORKTRUNK_PINNED_RELEASE.version}`
: "worktrunk_install:pending";
}
export function registerWorktrunkRoutes(ctx: ApiRoutesContext): void {
const { router, getProjectContext, rethrowAsApiError } = ctx;
@@ -31,7 +37,7 @@ export function registerWorktrunkRoutes(ctx: ApiRoutesContext): void {
const probe = await probeWorktrunk(resolved.binaryPath);
res.json({
status: "installed",
version: probe.version ?? WORKTRUNK_PINNED_RELEASE.version,
version: probe.version ?? WORKTRUNK_PINNED_RELEASE.version ?? "pending",
installPath: resolved.binaryPath,
});
return;
@@ -41,7 +47,7 @@ export function registerWorktrunkRoutes(ctx: ApiRoutesContext): void {
const pending = approvalStore.findLatestByDedupeKey({
requesterActorId: DEFAULT_ACTOR.actorId,
dedupeKey: `worktrunk_install:${WORKTRUNK_PINNED_RELEASE.version}`,
dedupeKey: worktrunkInstallDedupeKey(),
});
if (pending?.status === "pending") {
@@ -79,7 +85,11 @@ export function registerWorktrunkRoutes(ctx: ApiRoutesContext): void {
try {
const resolved = await resolveWorktrunkBinary({ settings: worktrunkSettings });
res.json({ status: "installed", installPath: resolved.binaryPath, version: WORKTRUNK_PINNED_RELEASE.version });
res.json({
status: "installed",
installPath: resolved.binaryPath,
version: WORKTRUNK_PINNED_RELEASE.version ?? "pending",
});
return;
} catch {
// proceed with approval request

View File

@@ -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,

View File

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

View File

@@ -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 () => {

View File

@@ -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,

View File

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

View File

@@ -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,

View File

@@ -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,