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
10 changes: 10 additions & 0 deletions FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -411,6 +411,16 @@ after fetch, and abort conflicts. They never push.
or long-press. Upload/delete stay checkout-scoped. Operations work over SSH.
Desktop layout downloads directly, including installed web apps. Mobile
layout keeps native iOS sharing and its browser fallback behavior.
- Create folders in the checkout root with **New folder**, or inside a folder
through its actions menu. Right-click the file tree background to create a
folder in the checkout root or refresh files. Select files with Cmd/Ctrl-click
or Shift-click, then drag them onto a folder (or empty tree space for the
checkout root) to move them. Cmd/Ctrl+A selects visible files; Space selects
the focused file.
Moves never overwrite existing names; failed files remain selected. Moves
require source and destination to be on the same filesystem; SSH moves also
require Python 3 on the remote host. Read-only host browsing does not allow
creating folders or moving files.
- With [host file reveal enabled](docs/DEPLOYMENT.md#host-file-reveal), local
profiles over loopback offer **Reveal on host** and **Open folder on host**
in explorer and Changes menus. These open the Roamgate host's desktop, not
Expand Down
14 changes: 14 additions & 0 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,20 @@ view. Search filters loaded entries only.

Absolute previews use `scope=filesystem` download URLs; relative Markdown links
and images resolve beside their source. Upload/delete remain checkout-scoped.
`file.mkdir` takes `workspace_id`, a relative `directory` (empty for the root),
and a single-component `name`. `file.move` takes `workspace_id`, a relative
source `path`, and a relative destination `directory`. Both reject filesystem
scope and absolute paths, and confine resolved parent directories to the
checkout. Moves retain the source filename, accept files and symlinks, and
never overwrite existing entries. Moves use the host's atomic exclusive rename
primitive (`renamex_np`, `renameat2`, or `MoveFileExW` without replacement or
copy flags), so concurrent saves are never removed by a later source deletion.
Cross-filesystem moves and unsupported primitives fail without a copying
fallback. SSH moves require Python 3 to call the primitive through its standard
library. The client submits one move request at a time and reports partial
failures, then refreshes source and destination listings sequentially and
retires moved source previews.

`file.reveal` opens the host file manager with a fixed per-platform argv
(`explorer.exe`, `open`, `xdg-open`), never a shell. It is disabled unless the
host opts in with `ROAMGATE_ALLOW_FILE_REVEAL=1`, and still refuses SSH profiles
Expand Down
4 changes: 4 additions & 0 deletions docs/DEPLOYMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -747,6 +747,10 @@ passwords/keys/passphrases/options. Verify host keys and noninteractive service-
authentication first. The Roamgate bridge must run on Linux/macOS; a Windows
bridge supports native local profiles only.

Moving files on Linux/macOS SSH hosts requires `python3` on the remote user's
PATH and a filesystem supporting atomic exclusive renames. Unsupported hosts
or cross-filesystem destinations fail without copying or deleting the source.

For a **Windows SSH host**, select **Windows** as the remote host OS. Install
Herdr 0.9.1 or a later compatible release on that host, including the `conpty/`
directory from its Windows archive, and start the desired Herdr session first.
Expand Down
41 changes: 41 additions & 0 deletions server/src/connections/profile-process.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import {
chmodSync,
mkdirSync,
realpathSync,
readFileSync,
existsSync,
rmSync,
writeFileSync,
} from "node:fs";
Expand Down Expand Up @@ -473,6 +475,45 @@ test("production dispatcher isolates two local profiles and profile CRUD", async
);
expect(initialAlpha.connection_generation).toBe(oldAlphaGeneration);
expect(initialAlpha.result.workspaces[0].name).toBe("from-alpha");
expect(
await rpc(
"file.mkdir",
{ workspace_id: "shared-workspace", name: "files-target" },
"alpha",
oldAlphaGeneration,
),
).toMatchObject({ path: "files-target" });
writeFileSync(join(root, "move-me.txt"), "file contents");
expect(
await rpc(
"file.move",
{
workspace_id: "shared-workspace",
path: "move-me.txt",
directory: "files-target",
},
"alpha",
oldAlphaGeneration,
),
).toMatchObject({ path: "files-target/move-me.txt" });
expect(existsSync(join(root, "move-me.txt"))).toBe(false);
expect(readFileSync(join(root, "files-target/move-me.txt"), "utf8")).toBe(
"file contents",
);
writeFileSync(join(root, "move-me.txt"), "keep source");
await expect(
rpc(
"file.move",
{
workspace_id: "shared-workspace",
path: "move-me.txt",
directory: "files-target",
},
"alpha",
oldAlphaGeneration,
),
).rejects.toThrow();
expect(readFileSync(join(root, "move-me.txt"), "utf8")).toBe("keep source");
// A native teardown failure must stop before dispatching removal to Herdr.
const hookPath = join(root, "roamgate.json");
writeFileSync(join(root, "paseo.json"), '{"worktree":{"teardown":"true"}}');
Expand Down
13 changes: 13 additions & 0 deletions server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -942,6 +942,8 @@ async function handleRpc(ws: ServerWebSocket<unknown>, raw: string) {
readWorkspaceFile,
searchWorkspaceFiles,
revealWorkspaceFile,
createWorkspaceDirectory,
moveWorkspaceFile,
readGitDiffSummary,
readWorkspacePullRequest,
readGitDiffFile,
Expand Down Expand Up @@ -1082,6 +1084,17 @@ async function handleRpc(ws: ServerWebSocket<unknown>, raw: string) {
}
return;
}
if (method === "file.mkdir" || method === "file.move") {
try {
const result = await (method === "file.mkdir"
? createWorkspaceDirectory
: moveWorkspaceFile)(params ?? {});
sendReply({ id, result }, method);
} catch (e) {
sendError(`${method}-error`, e);
}
return;
}
if (method === "file.read") {
try {
const result = await readWorkspaceFile(params ?? {});
Expand Down
110 changes: 110 additions & 0 deletions server/src/workspace/atomic-file-move.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { dlopen, FFIType, ptr, read } from "bun:ffi";
import { getSystemErrorName } from "node:util";

function moveError(errno: number, source: string, target: string) {
const code = getSystemErrorName(-errno);
return Object.assign(
new Error(`${code}: cannot move ${source} to ${target}`),
{ code, errno, path: source, dest: target },
);
}

function openExclusiveRename() {
if (process.platform === "win32") {
const { symbols } = dlopen("kernel32.dll", {
MoveFileExW: {
args: [FFIType.ptr, FFIType.ptr, FFIType.u32],
returns: FFIType.i32,
},
GetLastError: { args: [], returns: FFIType.u32 },
});
return (source: string, target: string) => {
const from = Buffer.from(`${source}\0`, "utf16le");
const to = Buffer.from(`${target}\0`, "utf16le");
// Zero flags prohibit both replacement and cross-filesystem copying.
if (symbols.MoveFileExW(ptr(from), ptr(to), 0) === 0) {
throw new Error(
`Cannot move file: Windows error ${symbols.GetLastError()}`,
);
}
};
}
if (process.platform === "darwin") {
const { symbols } = dlopen("/usr/lib/libSystem.B.dylib", {
renamex_np: {
args: [FFIType.ptr, FFIType.ptr, FFIType.u32],
returns: FFIType.i32,
},
__error: { args: [], returns: FFIType.ptr },
});
return (source: string, target: string) => {
const from = Buffer.from(`${source}\0`);
const to = Buffer.from(`${target}\0`);
if (symbols.renamex_np(ptr(from), ptr(to), 4) !== 0) {
throw moveError(read.i32(symbols.__error()!), source, target);
}
};
}
if (process.platform === "linux") {
const definitions = {
renameat2: {
args: [FFIType.i32, FFIType.ptr, FFIType.i32, FFIType.ptr, FFIType.u32],
returns: FFIType.i32,
},
__errno_location: { args: [], returns: FFIType.ptr },
} as const;
let library;
try {
library = dlopen("libc.so.6", definitions);
} catch {
library = dlopen(
`/lib/ld-musl-${process.arch === "arm64" ? "aarch64" : "x86_64"}.so.1`,
definitions,
);
}
const { symbols } = library;
return (source: string, target: string) => {
const from = Buffer.from(`${source}\0`);
const to = Buffer.from(`${target}\0`);
if (symbols.renameat2(-100, ptr(from), -100, ptr(to), 1) !== 0) {
throw moveError(read.i32(symbols.__errno_location()!), source, target);
}
};
}
throw new Error("Atomic file moves are unsupported on this host");
}

let exclusiveRename: ReturnType<typeof openExclusiveRename> | undefined;

export function renameFileExclusive(source: string, target: string) {
if (source === target)
throw new Error("Source and destination are identical");
exclusiveRename ??= openExclusiveRename();
exclusiveRename(source, target);
}

// Python's standard library exposes the host's atomic primitive without installing a helper.
export const REMOTE_EXCLUSIVE_RENAME = `
import ctypes, os, sys
source, target = sys.argv[1:]
if source == target:
sys.exit("Source and destination are identical")
libc = ctypes.CDLL(None, use_errno=True)
try:
if sys.platform == "darwin":
rename = libc.renamex_np
rename.argtypes = [ctypes.c_char_p, ctypes.c_char_p, ctypes.c_uint]
args = [os.fsencode(source), os.fsencode(target), 4]
elif sys.platform.startswith("linux"):
rename = libc.renameat2
rename.argtypes = [ctypes.c_int, ctypes.c_char_p, ctypes.c_int, ctypes.c_char_p, ctypes.c_uint]
args = [-100, os.fsencode(source), -100, os.fsencode(target), 1]
else:
raise AttributeError()
except AttributeError:
sys.exit("Atomic file moves are unsupported on this host")
rename.restype = ctypes.c_int
result = rename(*args)
if result != 0:
sys.exit(os.strerror(ctypes.get_errno()))
`;
Loading
Loading