From ce89bce5f0ca3651e7900c125eb69b5168f95942 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 05:16:11 +0000 Subject: [PATCH 1/2] Reject Ranger worktree creation on Windows SSH hosts Ranger's create_worktree action called the Unix-only Git root and worktree hook helpers directly, bypassing the Windows guard that the ordinary RPC path applies. It now fails before any resource helper or Herdr call runs; native actions such as create_tab are unchanged. Fixes #402 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019MY2e8XVKiGDxDV3mXeV7f --- docs/DEPLOYMENT.md | 4 ++-- server/src/assistant/actions.test.ts | 29 ++++++++++++++++++++++++++++ server/src/assistant/actions.ts | 5 +++++ 3 files changed, 36 insertions(+), 2 deletions(-) diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 093ad420..c856b171 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -547,8 +547,8 @@ grants for deleted workspaces. High mode must be turned off before restricting its all-workspace access with individual manual grants. On Windows SSH workspaces, Ranger supports native status and terminal reads. -Agent history and Git diff reads return an unsupported-operation error before -running Unix-only resource helpers. +Agent history and Git diff reads and worktree creation return an +unsupported-operation error before running Unix-only resource helpers. ## Web Push notifications diff --git a/server/src/assistant/actions.test.ts b/server/src/assistant/actions.test.ts index 1c83fe0d..d95d4cc5 100644 --- a/server/src/assistant/actions.test.ts +++ b/server/src/assistant/actions.test.ts @@ -1631,3 +1631,32 @@ describe("confirmed Ranger close actions", () => { }, ); }); + +describe("Windows Ranger actions", () => { + test("create_worktree refuses Unix resource helpers", async () => { + const f = fixture(); + Object.assign(f.runtime, { remotePlatform: "windows" }); + const scope = await f.context.captureScope([ref]); + f.call.mockClear(); + await expect( + f.context.prepareAction("create_worktree", scope, { + ...ref, + branch: "feature", + }), + ).rejects.toThrow("unavailable on Windows"); + expect(f.resolveWorkspaceGitRoot).not.toHaveBeenCalled(); + expect(f.readWorktreeHooks).not.toHaveBeenCalled(); + expect(f.createWorktree).not.toHaveBeenCalled(); + expect(f.call).not.toHaveBeenCalled(); + expect(f.mutations).toEqual([]); + }); + + test("native create_tab keeps working", async () => { + const f = fixture(); + Object.assign(f.runtime, { remotePlatform: "windows" }); + const prepared = await f.prepare("create_tab", {}); + expect((await prepared.execute()).status).toBe("succeeded"); + expect(f.resolveWorkspaceGitRoot).not.toHaveBeenCalled(); + expect(f.readWorktreeHooks).not.toHaveBeenCalled(); + }); +}); diff --git a/server/src/assistant/actions.ts b/server/src/assistant/actions.ts index d2e43f7e..72196991 100644 --- a/server/src/assistant/actions.ts +++ b/server/src/assistant/actions.ts @@ -205,6 +205,11 @@ export async function prepareAssistantAction(args: { params.workspace_id !== target.workspace_id ) throw new Error("Action is outside the approved workspace scope"); + // Worktree creation uses Unix-only Git root and hook helpers, unlike native Herdr RPCs. + if (kind === "create_worktree" && runtime.remotePlatform === "windows") + throw new Error( + "Ranger worktree creation requires a Unix SSH host and is unavailable on Windows.", + ); function check() { if (preparing) signal?.throwIfAborted(); From fcab9c040032a9c974cffa6af302939157f04ab4 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 06:29:59 +0000 Subject: [PATCH 2/2] Guard Unix-only runtime helpers on Windows SSH hosts Address review findings on the Windows worktree fix: - The connection runtime now makes its Unix-only file, Git, agent-history, and worktree-hook helpers throw on Windows hosts, so any caller that skips its own check still cannot run Unix shell commands there. createWorkspaceWorktree also checks up front. - Replace the per-caller messages with a shared assertUnixHost helper and UnsupportedPlatformError. Ranger tool wrappers surface that error to the model verbatim instead of the generic failure, and the worktree check now runs after the lease and abort checks. - workspace_status reports remote_platform "windows", and the history, diff, and worktree tool descriptions say they are unavailable there. - Document the contract in ARCHITECTURE.md and link to it from DEPLOYMENT.md with clearer wording. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019MY2e8XVKiGDxDV3mXeV7f --- docs/ARCHITECTURE.md | 8 ++++ docs/DEPLOYMENT.md | 7 ++-- server/src/assistant/actions.test.ts | 5 ++- server/src/assistant/actions.ts | 14 +++---- server/src/assistant/context.test.ts | 14 ++++--- server/src/assistant/context.ts | 13 +++---- server/src/assistant/errors.test.ts | 14 +++++++ server/src/assistant/errors.ts | 11 +++++- server/src/assistant/tools.test.ts | 29 ++++++++++++++ server/src/assistant/tools.ts | 9 +++-- server/src/connections/runtime.test.ts | 29 ++++++++++++++ server/src/connections/runtime.ts | 52 ++++++++++++++++---------- server/src/connections/unix-only.ts | 43 +++++++++++++++++++++ server/src/index.ts | 5 +-- server/src/worktree/create.test.ts | 15 ++++++++ server/src/worktree/create.ts | 2 + 16 files changed, 219 insertions(+), 51 deletions(-) create mode 100644 server/src/assistant/errors.test.ts create mode 100644 server/src/connections/unix-only.ts diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index affb0dd3..c89c67fe 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -465,6 +465,14 @@ The model receives `workspace_status`, `workspace_history`, `workspace_diff`, and `workspace_terminal`, plus proposal tools for workspace creation, worktree creation, tab creation, pane splitting, agent startup, agent prompts, and closing agents, panes, and workspaces. +On Windows-host profiles, `workspace_status` reports `remote_platform: "windows"`. +Status and terminal reads and the other proposals use only native Herdr RPCs +there. History reads, diff reads, and worktree creation fail with an error that +names the operation and states that it requires a Unix SSH host; tool wrappers +return it to the model verbatim instead of the generic failure. The connection +runtime also makes its Unix-only file, Git, agent-history, and worktree-hook +helpers throw that error on Windows hosts, so a caller that skips its own check +cannot run Unix shell commands there. By default, proposal tools prepare previews for user confirmation. In explicitly enabled high-permission mode, the service executes the same validated operations and returns their actual receipts to the model. Built-in tools and discovered extensions, skills, diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index c856b171..8b907dec 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -546,9 +546,10 @@ other grants, then save settings. A complete authoritative listing still removes grants for deleted workspaces. High mode must be turned off before restricting its all-workspace access with individual manual grants. -On Windows SSH workspaces, Ranger supports native status and terminal reads. -Agent history and Git diff reads and worktree creation return an -unsupported-operation error before running Unix-only resource helpers. +On Windows SSH workspaces, Ranger can read workspace status and terminal output. +Agent history reads, Git diff reads, and worktree creation fail with an error +stating that they require a Unix SSH host. See +[Ranger tool contracts](ARCHITECTURE.md#ranger). ## Web Push notifications diff --git a/server/src/assistant/actions.test.ts b/server/src/assistant/actions.test.ts index d95d4cc5..812dd65a 100644 --- a/server/src/assistant/actions.test.ts +++ b/server/src/assistant/actions.test.ts @@ -1647,7 +1647,10 @@ describe("Windows Ranger actions", () => { expect(f.resolveWorkspaceGitRoot).not.toHaveBeenCalled(); expect(f.readWorktreeHooks).not.toHaveBeenCalled(); expect(f.createWorktree).not.toHaveBeenCalled(); - expect(f.call).not.toHaveBeenCalled(); + // Only the native lease and workspace check runs before the refusal. + expect(f.call.mock.calls.map(([method]) => method)).toEqual([ + "workspace.get", + ]); expect(f.mutations).toEqual([]); }); diff --git a/server/src/assistant/actions.ts b/server/src/assistant/actions.ts index 72196991..69b27f9a 100644 --- a/server/src/assistant/actions.ts +++ b/server/src/assistant/actions.ts @@ -12,7 +12,9 @@ import { } from "../config/gui-settings"; import { validateConnectionId } from "../connections/protocol"; import type { LegacyConnectionRuntime } from "../connections/runtime"; +import { assertUnixHost } from "../connections/unix-only"; import { checkoutPath } from "../workspace/utils"; +import { surfaceAssistantUserError } from "./errors"; export type AssistantActionPreview = Omit< AssistantAction, @@ -205,11 +207,6 @@ export async function prepareAssistantAction(args: { params.workspace_id !== target.workspace_id ) throw new Error("Action is outside the approved workspace scope"); - // Worktree creation uses Unix-only Git root and hook helpers, unlike native Herdr RPCs. - if (kind === "create_worktree" && runtime.remotePlatform === "windows") - throw new Error( - "Ranger worktree creation requires a Unix SSH host and is unavailable on Windows.", - ); function check() { if (preparing) signal?.throwIfAborted(); @@ -597,6 +594,8 @@ export async function prepareAssistantAction(args: { }; }; } else if (kind === "create_worktree") { + // Git root and hook helpers are Unix-only, unlike native Herdr RPCs. + assertUnixHost(runtime, "Ranger worktree creation"); if (!args.createWorktree) throw new Error("Worktree creation is unavailable"); const branch = text(params.branch); @@ -1078,8 +1077,9 @@ export async function prepareAssistantAction(args: { return execution; }, }); - } catch { - throw new Error( + } catch (error) { + surfaceAssistantUserError( + error, "Action preview is unavailable, outside the approved scope, or the target changed.", ); } diff --git a/server/src/assistant/context.test.ts b/server/src/assistant/context.test.ts index 3a0f5aca..54756d8c 100644 --- a/server/src/assistant/context.test.ts +++ b/server/src/assistant/context.test.ts @@ -1698,12 +1698,14 @@ test.each(["status", "terminal"] as const)( const f = fixture(); Object.assign(f.runtime, { remotePlatform: "windows" }); const captured = await f.context.captureScope([first]); - await expect( - f.context.read(kind, captured, { - ...first, - ...(kind === "terminal" ? { pane_id: "w1:p1" } : {}), - }), - ).resolves.toHaveProperty("text"); + const result = await f.context.read(kind, captured, { + ...first, + ...(kind === "terminal" ? { pane_id: "w1:p1" } : {}), + }); + // Status tells the model which workspaces cannot use Unix-only tools. + expect(JSON.parse(result.text)[0].remote_platform).toBe( + kind === "status" ? "windows" : undefined, + ); expect(f.readHistory).not.toHaveBeenCalled(); expect(f.readGitDiffSummary).not.toHaveBeenCalled(); expect(f.readGitDiffFile).not.toHaveBeenCalled(); diff --git a/server/src/assistant/context.ts b/server/src/assistant/context.ts index 532ff994..b8a07231 100644 --- a/server/src/assistant/context.ts +++ b/server/src/assistant/context.ts @@ -16,6 +16,7 @@ import { import { isRecord } from "../agent/session-utils"; import { validateConnectionId } from "../connections/protocol"; import type { LegacyConnectionRuntime } from "../connections/runtime"; +import { assertUnixHost } from "../connections/unix-only"; import { sanitizeExplorerPath } from "../workspace/file-paths"; import { prepareAssistantAction, @@ -814,13 +815,8 @@ export function createAssistantContext(args: { for (const target of selected) { const lease = leases.get(target.connection_id)!; assertCurrent(lease, signal); - if ( - lease.runtime.remotePlatform === "windows" && - (kind === "history" || kind === "diff") - ) - throw new Error( - "Ranger diff and history reads require a Unix SSH host and are unavailable on Windows.", - ); + if (kind === "history" || kind === "diff") + assertUnixHost(lease.runtime, `Ranger ${kind} read`); const currentWorkspace = await workspace( lease, target.workspace_id, @@ -844,6 +840,9 @@ export function createAssistantContext(args: { (pane) => pane.workspace_id === target.workspace_id, ); data = { + ...(lease.runtime.remotePlatform === "windows" + ? { remote_platform: "windows" } + : {}), agent_status: limitedText(currentWorkspace.agent_status), pane_count: count(currentWorkspace.pane_count), tab_count: count(currentWorkspace.tab_count), diff --git a/server/src/assistant/errors.test.ts b/server/src/assistant/errors.test.ts new file mode 100644 index 00000000..eeb18ea8 --- /dev/null +++ b/server/src/assistant/errors.test.ts @@ -0,0 +1,14 @@ +import { expect, test } from "bun:test"; +import { UnsupportedPlatformError } from "../connections/unix-only"; +import { AssistantUserError, surfaceAssistantUserError } from "./errors"; + +test("user-facing and platform errors surface while others stay masked", () => { + for (const error of [ + new AssistantUserError("Invalid task input"), + new UnsupportedPlatformError("Ranger diff read"), + ]) + expect(() => surfaceAssistantUserError(error, "masked")).toThrow(error); + expect(() => + surfaceAssistantUserError(new Error("ssh: /private/socket"), "masked"), + ).toThrow(/^masked$/); +}); diff --git a/server/src/assistant/errors.ts b/server/src/assistant/errors.ts index a31f46af..e188f64f 100644 --- a/server/src/assistant/errors.ts +++ b/server/src/assistant/errors.ts @@ -1,3 +1,5 @@ +import { UnsupportedPlatformError } from "../connections/unix-only"; + /** * An assistant error whose message is safe and useful to show the model and * the user. Tool wrappers surface these messages verbatim and keep masking @@ -6,6 +8,13 @@ */ export class AssistantUserError extends Error {} +function surfaced(error: unknown) { + return ( + error instanceof AssistantUserError || + error instanceof UnsupportedPlatformError + ); +} + /** * Rethrow user-facing errors verbatim and mask any other failure behind the * given generic message. Masked errors intentionally carry neither the @@ -15,6 +24,6 @@ export function surfaceAssistantUserError( error: unknown, masked: string, ): never { - if (error instanceof AssistantUserError) throw error; + if (surfaced(error)) throw error; throw new Error(masked); } diff --git a/server/src/assistant/tools.test.ts b/server/src/assistant/tools.test.ts index 840983e9..8fceffab 100644 --- a/server/src/assistant/tools.test.ts +++ b/server/src/assistant/tools.test.ts @@ -1,5 +1,6 @@ import { expect, test } from "bun:test"; import { ASSISTANT_MAX_WORKSPACES } from "../../../shared/assistant"; +import { UnsupportedPlatformError } from "../connections/unix-only"; import { AssistantUserError } from "./errors"; import { type ActionToolProposer, @@ -887,3 +888,31 @@ test("task tool scope schema shares the bounded workspace limit", async () => { ).rejects.toThrow("Invalid task tool parameters"); expect(calls).toBe(1); }); + +test("Windows platform refusals reach the model instead of the generic mask", async () => { + const target = { connection_id: "windows", workspace_id: "w1" }; + const refusal = new UnsupportedPlatformError("Ranger worktree creation"); + await expect( + callActionTool( + "propose_worktree_create", + { ...target, branch: "feature" }, + async () => { + throw refusal; + }, + ), + ).rejects.toThrow(refusal); + await expect( + callWorkspaceTool("workspace_diff", target, async () => { + throw new UnsupportedPlatformError("Ranger diff read"); + }), + ).rejects.toThrow("unavailable on Windows"); + for (const name of [ + "workspace_history", + "workspace_diff", + "propose_worktree_create", + ]) + expect( + [...workspaceTools, ...actionTools].find((tool) => tool.name === name) + ?.description, + ).toContain('remote_platform "windows"'); +}); diff --git a/server/src/assistant/tools.ts b/server/src/assistant/tools.ts index 41598fb2..031828b0 100644 --- a/server/src/assistant/tools.ts +++ b/server/src/assistant/tools.ts @@ -9,6 +9,8 @@ import { AssistantUserError, surfaceAssistantUserError } from "./errors"; export const ASSISTANT_DEFAULT_TERMINAL_LINES = 120; export const ASSISTANT_MAX_TERMINAL_LINES = 1000; +const UNIX_ONLY_NOTE = + 'Unavailable for workspaces that workspace_status reports with remote_platform "windows".'; export type WorkspaceToolKind = (typeof workspaceTools)[number]["kind"]; export type WorkspaceToolResult = { @@ -49,8 +51,8 @@ export const workspaceTools = ( : kind === "terminal" ? `Read recent terminal output from an authorized workspace. Use identifiers from workspace_status. Choose lines from 1 to ${ASSISTANT_MAX_TERMINAL_LINES} (default ${ASSISTANT_DEFAULT_TERMINAL_LINES}); request more when earlier context is needed. Available output depends on Herdr support and agent state; application-owned history may remain unavailable. Results retain at most 32,000 newest characters and indicate truncation.` : kind === "diff" - ? "Read working-tree changes from an authorized workspace. Use identifiers from workspace_status. Omit path to list changed files, then pass an entry's path and kind to read its diff. Supported kinds are staged, unstaged, untracked and conflicted; omitting kind reads unstaged changes." - : `Read ${kind} from an authorized workspace. Use identifiers from workspace_status.`, + ? `Read working-tree changes from an authorized workspace. Use identifiers from workspace_status. Omit path to list changed files, then pass an entry's path and kind to read its diff. Supported kinds are staged, unstaged, untracked and conflicted; omitting kind reads unstaged changes. ${UNIX_ONLY_NOTE}` + : `Read ${kind} from an authorized workspace. Use identifiers from workspace_status. ${UNIX_ONLY_NOTE}`, parameters: Type.Object( { connection_id: Type.Optional(Type.String()), @@ -225,8 +227,7 @@ export const actionTools = [ name: "propose_worktree_create", kind: "create_worktree", label: "Propose worktree creation", - description: - "Propose creating a Git worktree from an authorized source workspace. Returns a pending proposal for the user to confirm; does not create it.", + description: `Propose creating a Git worktree from an authorized source workspace. Returns a pending proposal for the user to confirm; does not create it. ${UNIX_ONLY_NOTE}`, parameters: Type.Object( { ...actionTarget, diff --git a/server/src/connections/runtime.test.ts b/server/src/connections/runtime.test.ts index 3270d19a..08ff65c9 100644 --- a/server/src/connections/runtime.test.ts +++ b/server/src/connections/runtime.test.ts @@ -428,3 +428,32 @@ test("herdr notification source falls back to agent status on legacy Herdr", asy await runtime.stop(); } }); + +test("Windows runtimes refuse Unix-only helper groups", async () => { + const runtime = createLegacyConnectionRuntime({ + config: { + socketPath: "/tmp/m3-runtime-windows-control.sock", + clientSocketPath: "/tmp/m3-runtime-windows-client.sock", + sshHost: "windows-host", + session: undefined, + hasExplicitSocketPath: true, + hasExplicitClientSocketPath: true, + remotePlatform: "windows", + }, + safeSend: () => true, + clientLabel: () => "browser", + markRpcError: () => undefined, + onEvent: () => undefined, + }); + try { + for (const call of [ + () => runtime.files.resolveWorkspaceGitRoot({ workspace_id: "w1" }), + () => runtime.files.readGitDiffSummary({ workspace_id: "w1" }), + () => runtime.worktreeHooks.readWorktreeHooks("/repo"), + () => runtime.agentSessions.readHistory({ pane_id: "w1:p1" }), + ]) + expect(call).toThrow("unavailable on Windows"); + } finally { + await runtime.stop(); + } +}); diff --git a/server/src/connections/runtime.ts b/server/src/connections/runtime.ts index 73efd785..65ce2a68 100644 --- a/server/src/connections/runtime.ts +++ b/server/src/connections/runtime.ts @@ -69,6 +69,7 @@ import { createAgentStatusSubscriptionLoop } from "./agent-status-subscription"; import { sanitizeConnectionError } from "./manager"; import { createEventSubscriptionLoop } from "./subscription-loop"; import { type ConnectionIdentity, LEGACY_DEFAULT_CONNECTION } from "./types"; +import { unixOnlyHandlers } from "./unix-only"; const DEFAULT_EVENTS = [ "workspace.created", @@ -139,10 +140,15 @@ export function createLegacyConnectionRuntime(args: { runBinaryProcessWithTimeout, shQuote, }); - const agentSessions = createAgentSessionHandlers({ - herdrCall: (method, params) => herdr.call(method, params), - files: agentSessionFiles, - }); + // These helper groups run Unix shell commands over SSH. + const agentSessions = unixOnlyHandlers( + remotePlatform, + "Agent history", + createAgentSessionHandlers({ + herdrCall: (method, params) => herdr.call(method, params), + files: agentSessionFiles, + }), + ); const worktreeParents = createWorktreeParentStore({ connectionId: identity.id, herdr, @@ -158,13 +164,17 @@ export function createLegacyConnectionRuntime(args: { runProcessWithCodeTimeout, shQuote, }); - const files = createFileHandlers({ - herdr, - sshHost, - runProcessWithCodeTimeout, - shQuote, - lastStepBaselines, - }); + const files = unixOnlyHandlers( + remotePlatform, + "Workspace file and Git access", + createFileHandlers({ + herdr, + sshHost, + runProcessWithCodeTimeout, + shQuote, + lastStepBaselines, + }), + ); const status = createStatusEnricher({ connectionId: identity.id, sshHost, @@ -183,14 +193,18 @@ export function createLegacyConnectionRuntime(args: { resolveWorkspaceGitRoot: async (workspaceId) => files.resolveWorkspaceGitRoot({ workspace_id: workspaceId }), }); - const worktreeHooks = createWorktreeHookRunner({ - connectionId: identity.id, - herdr, - sshHost, - runProcess, - runProcessWithCode, - shQuote, - }); + const worktreeHooks = unixOnlyHandlers( + remotePlatform, + "Worktree hook access", + createWorktreeHookRunner({ + connectionId: identity.id, + herdr, + sshHost, + runProcess, + runProcessWithCode, + shQuote, + }), + ); const worktreeRemovalCoordinator = createWorktreeRemovalCoordinator(); const worktreeRemovalRuntime = createWorktreeRemovalRuntime({ host: sshHost(), diff --git a/server/src/connections/unix-only.ts b/server/src/connections/unix-only.ts new file mode 100644 index 00000000..cadb7f36 --- /dev/null +++ b/server/src/connections/unix-only.ts @@ -0,0 +1,43 @@ +export type RemotePlatform = "windows" | undefined; + +export function unixOnlyMessage(operation: string) { + return `${operation} requires a Unix SSH host and is unavailable on Windows.`; +} + +/** Carries only the operation name, so callers may show it verbatim. */ +export class UnsupportedPlatformError extends Error { + constructor(operation: string) { + super(unixOnlyMessage(operation)); + } +} + +export function assertUnixHost( + runtime: { remotePlatform?: RemotePlatform }, + operation: string, +) { + if (runtime.remotePlatform === "windows") + throw new UnsupportedPlatformError(operation); +} + +/** + * Replaces every helper in a group that runs Unix shell commands over SSH with + * one that throws on Windows hosts, so a caller that skips its own guard still + * cannot reach a PowerShell host. + */ +export function unixOnlyHandlers( + remotePlatform: RemotePlatform, + operation: string, + handlers: T, +): T { + if (remotePlatform !== "windows") return handlers; + return Object.fromEntries( + Object.entries(handlers).map(([name, value]) => [ + name, + typeof value === "function" + ? () => { + throw new UnsupportedPlatformError(operation); + } + : value, + ]), + ) as T; +} diff --git a/server/src/index.ts b/server/src/index.ts index 132c4b07..15cfff8e 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -81,6 +81,7 @@ import { import { createShutdownController } from "./connections/shutdown"; import { bindListenerBeforeConnectionStart } from "./connections/startup"; import { LEGACY_DEFAULT_CONNECTION_ID } from "./connections/types"; +import { unixOnlyMessage } from "./connections/unix-only"; import { createAuthHandlers, unauthenticatedLoginRedirect } from "./http/auth"; import { serveStatic } from "./http/static-files"; import { handleInstanceSettings } from "./http/instance-settings"; @@ -922,9 +923,7 @@ async function handleRpc(ws: ServerWebSocket, raw: string) { ) { sendError( "unsupported-windows-operation", - new Error( - "This operation requires a Unix SSH host and is unavailable on Windows.", - ), + new Error(unixOnlyMessage("This operation")), ); return; } diff --git a/server/src/worktree/create.test.ts b/server/src/worktree/create.test.ts index 6c89e201..77b8deee 100644 --- a/server/src/worktree/create.test.ts +++ b/server/src/worktree/create.test.ts @@ -419,3 +419,18 @@ describe("shared worktree creation pipeline", () => { }, ); }); + +test("Windows runtimes refuse worktree creation before any helper runs", async () => { + const fixture = creationFixture(); + Object.assign(fixture.runtime, { remotePlatform: "windows" }); + await expect( + createWorkspaceWorktree( + fixture.runtime, + { workspace_id: "source", branch: "feature" }, + fixture.isCurrent, + undefined, + fixture.syncBase, + ), + ).rejects.toThrow("unavailable on Windows"); + expect(fixture.calls).toEqual([]); +}); diff --git a/server/src/worktree/create.ts b/server/src/worktree/create.ts index afa48302..ca18e60b 100644 --- a/server/src/worktree/create.ts +++ b/server/src/worktree/create.ts @@ -3,6 +3,7 @@ import { fetchOriginDefaultBranch } from "../workspace/default-branch"; import { GIT_PULL_TIMEOUT_MS } from "../workspace/file-constants"; import type { RunProcessWithCodeTimeout } from "../workspace/file-types"; import type { LegacyConnectionRuntime } from "../connections/runtime"; +import { assertUnixHost } from "../connections/unix-only"; import { runProcessWithCodeTimeout, shQuote } from "../utils/process-utils"; import type { WorktreeHookExpectation } from "./worktree-hooks"; @@ -55,6 +56,7 @@ export async function createWorkspaceWorktree( beforeDispatch?.(); }; assertCurrent(); + assertUnixHost(runtime, "Worktree creation"); const { expected_setup_hook: expectedCommand, expected_hooks_enabled: expectedEnabled,