Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
7 changes: 4 additions & 3 deletions docs/DEPLOYMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
32 changes: 32 additions & 0 deletions server/src/assistant/actions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
9 changes: 7 additions & 2 deletions server/src/assistant/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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.",
);
}
Expand Down
14 changes: 8 additions & 6 deletions server/src/assistant/context.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
13 changes: 6 additions & 7 deletions server/src/assistant/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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),
Expand Down
14 changes: 14 additions & 0 deletions server/src/assistant/errors.test.ts
Original file line number Diff line number Diff line change
@@ -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$/);
});
11 changes: 10 additions & 1 deletion server/src/assistant/errors.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand All @@ -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);
}
29 changes: 29 additions & 0 deletions server/src/assistant/tools.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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"');
});
9 changes: 5 additions & 4 deletions server/src/assistant/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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()),
Expand Down Expand Up @@ -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,
Expand Down
29 changes: 29 additions & 0 deletions server/src/connections/runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
});
52 changes: 33 additions & 19 deletions server/src/connections/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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(),
Expand Down
Loading
Loading