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 093ad420..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 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 1c83fe0d..812dd65a 100644 --- a/server/src/assistant/actions.test.ts +++ b/server/src/assistant/actions.test.ts @@ -1631,3 +1631,35 @@ 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(); + // 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([]); + }); + + 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..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, @@ -592,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); @@ -1073,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,