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
3 changes: 3 additions & 0 deletions FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ A Web/PWA client for a running [Herdr](https://herdr.dev) server.
- Create, rename, pin, switch, and close workspaces/tabs. Linked Git worktrees
group under their repository; pins and collapsed groups stay in this browser.
Double-click a tab to rename it, or use its context menu.
- Workspace creation expands `~/` on the connected host, checks that the
directory exists, and suggests subdirectories as you type. Use an absolute
path or `~/`; leave the field blank to preserve Herdr's default cwd policy.
- Pinned tabs lead the tab strip and cannot be closed from Roamgate; their
panes still close while another pane remains. Herdr keeps tab ids across
restarts, so pins persist until the tab closes elsewhere, such as in the TUI.
Expand Down
8 changes: 8 additions & 0 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,14 @@ serialized focus/scroll lane without another endpoint or focus call. Omitting
synthetic cwd preserves Herdr's `terminal.new_cwd`; explicit cwd wins. Shared
same-tab focus still controls the `follow` source.

`workspace.path` expands `~`/`~/` on the selected connection's host, validates
directory access, and returns up to 50 directory suggestions for a directory
or partial basename. Explicit workspace creation paths must be absolute or
home-relative; the bridge resolves and checks them again before dispatch, so
invalid paths fail instead of silently falling back to Herdr's home directory.
Empty cwd still preserves Herdr's configured policy. These checks use native
filesystem access locally and quoted Bash/PowerShell commands over SSH.

Missing source attachments fail. Only first-workspace bootstrap uses control
creation, serializing an empty-topology check per runtime. Competing requests
retry when topology becomes nonempty. A 20-second admission deadline covers
Expand Down
128 changes: 108 additions & 20 deletions server/src/assistant/actions.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,24 @@
import { describe, expect, jest, mock, test } from "bun:test";
import { afterAll, describe, expect, jest, mock, spyOn, test } from "bun:test";
import {
mkdirSync,
mkdtempSync,
realpathSync,
rmSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { AssistantActionKind } from "../../../shared/assistant";
import type { LegacyConnectionRuntime } from "../connections/runtime";
import * as processUtils from "../utils/process-utils";
import { createWorkspaceWorktree } from "../worktree/create";
import { createAssistantContext } from "./context";

const fixtureDirectory = realpathSync(
mkdtempSync(join(tmpdir(), "ranger-actions-")),
);
afterAll(() => rmSync(fixtureDirectory, { recursive: true, force: true }));

const ref = { connection_id: "local", workspace_id: "w1" };
function deferred<T>() {
let resolve!: (value: T) => void;
Expand All @@ -24,7 +39,7 @@ function fixture(paneId = "w1:p1") {
{
workspace_id: ref.workspace_id,
label: "Source",
cwd: "/ranger-actions-fixture",
cwd: fixtureDirectory,
},
];
const panes: Record<string, any>[] = [
Expand All @@ -33,7 +48,7 @@ function fixture(paneId = "w1:p1") {
pane_id: paneId,
tab_id: `${ref.workspace_id}:t1`,
terminal_id: "term1",
cwd: "/ranger-actions-fixture",
cwd: fixtureDirectory,
focused: true,
agent: null,
agent_session: null,
Expand Down Expand Up @@ -213,12 +228,12 @@ function fixture(paneId = "w1:p1") {
? null
: {
config: { setup },
path: "/ranger-actions-fixture/roamgate.json",
path: join(fixtureDirectory, "roamgate.json"),
source: "roamgate" as const,
},
);
const resolveWorkspaceGitRoot = mock(async () => ({
root: "/ranger-actions-fixture",
root: fixtureDirectory,
workspace: workspaces[0],
}));
const runtime = {
Expand Down Expand Up @@ -515,7 +530,7 @@ describe("confirmed Ranger action targets", () => {
const prepared = await f.prepare("create_tab", {});
expect(f.mutations).toEqual([]);
expect(prepared.preview.params).toEqual({
cwd: "/ranger-actions-fixture",
cwd: fixtureDirectory,
});
const [first, repeated] = await Promise.all([
prepared.execute(),
Expand All @@ -528,7 +543,7 @@ describe("confirmed Ranger action targets", () => {
expect(f.mutations).toEqual(["tab.create"]);
expect(f.call).toHaveBeenCalledWith(
"tab.create",
{ workspace_id: "w1", cwd: "/ranger-actions-fixture", focus: false },
{ workspace_id: "w1", cwd: fixtureDirectory, focus: false },
30_000,
expect.any(Function),
);
Expand All @@ -553,7 +568,7 @@ describe("confirmed Ranger action targets", () => {
delete f.workspaces[0]!.cwd;
const prepared = await f.prepare("create_tab", {});
expect(prepared.preview.params).toEqual({
cwd: "/ranger-actions-fixture",
cwd: fixtureDirectory,
pane_id: "w1:p1",
tab_id: "w1:t1",
terminal_id: "term1",
Expand All @@ -570,7 +585,7 @@ describe("confirmed Ranger action targets", () => {
expect((await prepared.execute()).status).toBe("succeeded");
expect(f.call).toHaveBeenCalledWith(
"tab.create",
{ workspace_id: "w1", cwd: "/ranger-actions-fixture", focus: false },
{ workspace_id: "w1", cwd: fixtureDirectory, focus: false },
30_000,
expect.any(Function),
);
Expand All @@ -589,7 +604,7 @@ describe("confirmed Ranger action targets", () => {
pane_id: "w1:p1",
tab_id: "w1:t1",
terminal_id: "term1",
cwd: "/ranger-actions-fixture",
cwd: fixtureDirectory,
direction,
});
const result = await prepared.execute();
Expand All @@ -602,7 +617,7 @@ describe("confirmed Ranger action targets", () => {
{
target_pane_id: "w1:p1",
direction,
cwd: "/ranger-actions-fixture",
cwd: fixtureDirectory,
focus: false,
},
30_000,
Expand Down Expand Up @@ -741,7 +756,7 @@ describe("confirmed Ranger action targets", () => {
...ref,
runtime_generation: 7,
workspace_label: "Source",
params: { label: "New workspace", cwd: "/ranger-actions-fixture" },
params: { label: "New workspace", cwd: fixtureDirectory },
});
expect(Object.isFrozen(prepared)).toBe(true);
expect(Object.isFrozen(prepared.preview.params)).toBe(true);
Expand All @@ -757,7 +772,7 @@ describe("confirmed Ranger action targets", () => {
"workspace.create",
{
label: "New workspace",
cwd: "/ranger-actions-fixture",
cwd: fixtureDirectory,
source_workspace_id: "w1",
focus: false,
},
Expand All @@ -766,6 +781,79 @@ describe("confirmed Ranger action targets", () => {
);
});

test.each(["explicit", "inferred", "removed", "file"] as const)(
"workspace creation checks directories before dispatch: %s",
async (kind) => {
const f = fixture();
const root = mkdtempSync(join(fixtureDirectory, "cwd-"));
const cwd = join(root, "project");
try {
if (kind === "removed") mkdirSync(cwd);
if (kind === "file") writeFileSync(cwd, "not a directory");
if (kind === "inferred") f.workspaces[0]!.cwd = cwd;
const prepared = await f.prepare("create_workspace", {
label: "New",
...(kind === "inferred" ? {} : { cwd }),
});
if (kind === "removed") rmSync(cwd, { recursive: true });
const result = await prepared.execute();
expect(result.status).toBe("failed");
expect(result.detail).toContain(
kind === "file"
? "Path is not a directory."
: "Directory does not exist.",
);
expect(f.mutations).toEqual([]);
expect(await prepared.execute()).toBe(result);
} finally {
rmSync(root, { recursive: true, force: true });
}
},
);

test.each(["valid", "missing", "retired", "unreachable"] as const)(
"workspace creation validates the selected Windows SSH host: %s",
async (state) => {
const f = fixture();
Object.assign(f.runtime, {
sshHost: () => "windows-host",
remotePlatform: "windows",
});
const cwd = "C:\\Users\\remote\\project";
const command = spyOn(
processUtils,
"runProcessWithCodeTimeout",
).mockImplementation(async (argv) => {
expect(argv.at(-2)).toBe("windows-host");
expect(argv.at(-1)).toStartWith("powershell.exe ");
if (state === "retired") f.retire();
if (state === "unreachable") throw new Error("SECRET /private/socket");
const error = state === "missing" ? "Directory does not exist." : "";
return {
code: 0,
stdout: `PATH\t${Buffer.from(cwd).toString("base64")}\nERROR\t${Buffer.from(error).toString("base64")}\n`,
stderr: "",
};
});
try {
const prepared = await f.prepare("create_workspace", {
label: "New",
cwd,
});
const result = await prepared.execute();
expect(result.status).toBe(state === "valid" ? "succeeded" : "failed");
expect(f.mutations).toEqual(
state === "valid" ? ["workspace.create"] : [],
);
expect(command).toHaveBeenCalledTimes(1);
expect(JSON.stringify(result)).not.toContain("SECRET");
expect(await prepared.execute()).toBe(result);
} finally {
command.mockRestore();
}
},
);

test("a completed preview survives disposal of the model tool signal", async () => {
const f = fixture();
const controller = new AbortController();
Expand Down Expand Up @@ -918,7 +1006,7 @@ describe("confirmed Ranger action targets", () => {
focus: false,
expected_setup_hook: "bun install",
expected_hooks_enabled: true,
expected_source_root: "/ranger-actions-fixture",
expected_source_root: fixtureDirectory,
});
expect(
f.call.mock.calls.some(([method]) => method === "worktree.create"),
Expand Down Expand Up @@ -1259,7 +1347,7 @@ describe("confirmed Ranger close actions", () => {
const closeParams = (kind: (typeof closeKinds)[number]) =>
kind === "close_workspace" ? {} : { pane_id: "w1:p1" };
const addSibling = (f: ReturnType<typeof fixture>) =>
f.createPane({ cwd: "/ranger-actions-fixture" }, "w1:t1");
f.createPane({ cwd: fixtureDirectory }, "w1:t1");

test.each(["close_pane", "close_agent"] as const)(
"%s verifies a successful close after the native automatic label changes",
Expand Down Expand Up @@ -1319,7 +1407,7 @@ describe("confirmed Ranger close actions", () => {
const f = fixture();
f.useAgent();
addSibling(f);
f.worktrees.push({ branch: "feature", path: "/ranger-actions-fixture" });
f.worktrees.push({ branch: "feature", path: fixtureDirectory });
const prepared = await f.prepare(kind, closeParams(kind));
expect(prepared.preview.kind).toBe(kind);
expect(prepared.preview.summary).toContain(
Expand Down Expand Up @@ -1377,8 +1465,8 @@ describe("confirmed Ranger close actions", () => {
addSibling(f);
f.workspaces[0]!.worktree = {
repo_key: "repo",
repo_root: "/ranger-actions-fixture",
checkout_path: "/ranger-actions-fixture",
repo_root: fixtureDirectory,
checkout_path: fixtureDirectory,
is_linked_worktree: linked,
};
if (linked === true) {
Expand All @@ -1399,8 +1487,8 @@ describe("confirmed Ranger close actions", () => {
addSibling(f);
f.workspaces[0]!.worktree = {
repo_key: "repo",
repo_root: "/ranger-actions-fixture",
checkout_path: "/ranger-actions-fixture",
repo_root: fixtureDirectory,
checkout_path: fixtureDirectory,
is_linked_worktree: true,
};
const prepared = await f.prepare(kind, closeParams(kind));
Expand Down
13 changes: 13 additions & 0 deletions server/src/assistant/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
import { validateConnectionId } from "../connections/protocol";
import type { LegacyConnectionRuntime } from "../connections/runtime";
import { assertUnixHost } from "../connections/unix-only";
import { inspectWorkspacePath } from "../workspace/path";
import { checkoutPath } from "../workspace/utils";
import { surfaceAssistantUserError } from "./errors";

Expand Down Expand Up @@ -379,6 +380,18 @@ export async function prepareAssistantAction(args: {
detail:
"A workspace with this label already exists. No duplicate was created.",
};
const directory = await read(() =>
inspectWorkspacePath(
cwd,
{ host: runtime.sshHost(), remotePlatform: runtime.remotePlatform },
false,
),
);
if (directory.error)
return {
status: "failed",
detail: `${directory.error} Nothing was sent; prepare a new preview.`,
};
const previousIds = new Set(before.map((item) => item.workspace_id));
let response: unknown;
try {
Expand Down
57 changes: 57 additions & 0 deletions server/src/bridge/endpoint-creation.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import { expect, test } from "bun:test";
import { mkdirSync, mkdtempSync, realpathSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { inspectWorkspacePath } from "../workspace/path";
import {
assertEndpointCreationSource,
createEmptyWorkspaceCreator,
Expand Down Expand Up @@ -96,3 +100,56 @@ test("empty bootstrap rejects malformed and nonempty topology without mutation",
expect(calls).toEqual(["workspace.list"]);
}
});

test("empty bootstrap rechecks the directory after waiting for topology", async () => {
const root = realpathSync(mkdtempSync(join(tmpdir(), "bootstrap-path-")));
const cwd = join(root, "project");
mkdirSync(cwd);
const entered = Promise.withResolvers<void>();
const held = Promise.withResolvers<void>();
const calls: string[] = [];
const create = createEmptyWorkspaceCreator(async (method) => {
calls.push(method);
if (method === "workspace.list") {
entered.resolve();
await held.promise;
}
return { type: "workspace_list", workspaces: [] };
});
try {
expect((await inspectWorkspacePath(cwd, {}, false)).error).toBeNull();
const checkDirectory = async () => {
const info = await inspectWorkspacePath(cwd, {}, false);
if (info.error) throw new Error(info.error);
};
const pending = create(
{ cwd, browser_source: null },
() => true,
new EndpointCreationDeadline(),
checkDirectory,
).then(
() => null,
(error: Error) => error,
);
await entered.promise;
rmSync(cwd, { recursive: true });
held.resolve();
expect((await pending)?.message).toContain("Directory does not exist.");
expect(calls).toEqual(["workspace.list"]);
mkdirSync(cwd);
await create(
{ cwd, browser_source: null },
() => true,
new EndpointCreationDeadline(),
checkDirectory,
);
expect(calls).toEqual([
"workspace.list",
"workspace.list",
"workspace.create",
]);
} finally {
held.resolve();
rmSync(root, { recursive: true, force: true });
}
});
2 changes: 2 additions & 0 deletions server/src/bridge/endpoint-creation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ export function createEmptyWorkspaceCreator(
params: Record<string, unknown>,
isCurrent: () => boolean,
deadline: EndpointCreationDeadline,
validatePath: () => Promise<void> = async () => {},
) => {
const check = () => {
deadline.assertBeforeDispatch();
Expand All @@ -143,6 +144,7 @@ export function createEmptyWorkspaceCreator(
throw new Error(
"Session is no longer empty. Open a terminal in the desired workspace and retry creation.",
);
await validatePath();
check();
const creationParams: Record<string, unknown> = {
...params,
Expand Down
Loading
Loading