Compare commits

...

9 Commits

Author SHA1 Message Date
Serena Ruan ee98a1d75e test(ap-web): add useCreateHostDirectory to NewChatDialog hook mocks
E2E UI Tests / gate (push) Failing after 1s
E2E UI Tests / setup (push) Has been skipped
E2E UI Tests / E2E UI Tests (shard ${{ matrix.shard_id }}/${{ matrix.num_shards }}) (push) Has been skipped
NewChatDialog renders the real WorkspacePicker, which now calls
useCreateHostDirectory on mount. The test files mock
@/hooks/useHostFilesystem without that export, so the picker threw
"No useCreateHostDirectory export is defined on the mock". Add an idle
mutation to both mocks.

Co-authored-by: Isaac
2026-06-17 16:29:50 +08:00
Serena Ruan dc4a3a341c chore(openapi): regenerate spec for POST /v1/hosts/{id}/directories
The new create-directory route and CreateDirectoryRequest schema were
missing from the checked-in openapi.json, failing the drift guard
(tests/server/test_openapi_drift.py). Regenerated via
scripts/dump_openapi.py.

Co-authored-by: Isaac
2026-06-17 16:14:33 +08:00
Serena Ruan 7baf0a9ba3 Merge remote-tracking branch 'origin/main' into feat/workspace-picker-new-folder 2026-06-17 16:03:33 +08:00
Serena Ruan c63c53b362 fix(workspace-picker): allow creating the first folder in an empty home
The home view derives its absolute path from the first listing entry, so
an empty home (no entries) never resolves and left the "New folder"
button permanently disabled — even though the host expands ~. Fall back
to "~" as the create base once the listing has loaded, so the first
folder in an empty home can be created. Still disabled while loading.

Co-authored-by: Isaac
2026-06-17 16:03:08 +08:00
Serena Ruan ea232e754c style(ap-web): wrap useHostFilesystem.test import per Prettier
The rebase conflict resolution left a single-line import that exceeds
Prettier's print width, failing the pre-commit lint gate. Wrap it.

Co-authored-by: Isaac
2026-06-17 15:57:02 +08:00
Serena Ruan 137c7dea9e test(e2e-ui): cover create-folder → new session workspace
Drives the new-session picker: navigate into a folder, click "New
folder", name it, Create. Asserts the picker POSTs the joined path to
/v1/hosts/{id}/directories, drops into the new folder, and that the
created path reaches POST /v1/sessions as `workspace` — i.e. the agent's
working directory is the folder the user just made. Mirrors the existing
select-folder test (stubbed host filesystem + captured create).

Co-authored-by: Isaac
2026-06-17 15:47:33 +08:00
Serena Ruan 4610ab6eb5 fix(host): distinguish a file from a directory in create_dir conflict
os.makedirs raises FileExistsError whether the leaf path is an existing
directory or a regular file; the handler reported "directory already
exists" for both. Check os.path.isdir so a file in the way is labelled
accurately. Adds a test for the leaf-is-a-file case.

Co-authored-by: Isaac
2026-06-17 15:39:30 +08:00
Serena Ruan 3f9f82e386 style(workspace-picker): align new-folder form and simplify Create to an icon button
Match the new-folder form's padding/gap to the directory rows (px-3,
gap-2) so the folder icon and input line up with the entries below.
Replace the filled "Create" button with a borderless check-icon button
mirroring the cancel "X", so the two inline actions read as a matched
icon-button pair.

Co-authored-by: Isaac
2026-06-17 15:21:43 +08:00
Antonio 88ecb4efb6 feat(workspace-picker): add "New folder" action to the new-session picker
The workspace picker could only select existing directories — creating a
new folder meant leaving the UI for Finder or a terminal. This adds an
inline "New folder" action across the full stack:

- host frame protocol: new `host.create_dir` / `host.create_dir_result`
  frames (frames.py) + host-side `_handle_create_dir` using os.makedirs,
  reporting "already exists"/"permission denied" as expected errors
  rather than failures (connect.py).
- server: `pending_create_dirs` correlation map (host_registry.py),
  result routing (host_tunnel.py), and a `POST /v1/hosts/{id}/directories`
  endpoint that proxies the frame (hosts.py). Owner-scoped exactly like
  the existing filesystem-browse endpoints; the workspace-boundary check
  still runs at session-create time.
- web UI: `createHostDirectory` + `useCreateHostDirectory` hook and a
  "New folder" button + inline name input in WorkspacePicker, which on
  success navigates into the freshly created directory.

Tests: frame round-trips, host handler (create/parents/exists/tilde),
the new REST route end-to-end against a mock host tunnel, the path-join
helper, the hook's request/error handling, and the picker's create flow.

Co-authored-by: Isaac
Signed-off-by: Antonio <antonio.pinheirofilho@databricks.com>
2026-06-17 14:17:41 +08:00
16 changed files with 1467 additions and 5 deletions
+52 -1
View File
@@ -10,7 +10,11 @@ import { createElement } from "react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { HostFilesystemEntry } from "./useHostFilesystem";
import { buildHostFilesystemUrl, useHostFilesystem } from "./useHostFilesystem";
import {
buildHostFilesystemUrl,
createHostDirectory,
useHostFilesystem,
} from "./useHostFilesystem";
describe("buildHostFilesystemUrl", () => {
it("returns the no-path endpoint when absolutePath is empty", () => {
@@ -190,3 +194,50 @@ describe("useHostFilesystem", () => {
expect(err?.message).toContain("HTTP 404");
});
});
// ---------------------------------------------------------------------------
// createHostDirectory — POSTs to /v1/hosts/{id}/directories and surfaces the
// server's error detail. Shares the same seam as the useHostFilesystem suite:
// authenticatedFetch ultimately calls the global fetch, so we stub that.
// ---------------------------------------------------------------------------
describe("createHostDirectory", () => {
const fetchMock = vi.fn();
beforeEach(() => {
fetchMock.mockReset();
vi.stubGlobal("fetch", fetchMock);
});
afterEach(() => {
vi.unstubAllGlobals();
});
it("POSTs the path and returns the created absolute path", async () => {
fetchMock.mockResolvedValue(
new Response(JSON.stringify({ object: "directory", path: "/Users/me/new" }), {
status: 200,
}),
);
const created = await createHostDirectory("host_abc", "/Users/me/new");
expect(created).toBe("/Users/me/new");
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe("/v1/hosts/host_abc/directories");
expect(init?.method).toBe("POST");
expect(JSON.parse(init?.body as string)).toEqual({ path: "/Users/me/new" });
});
it("throws the server's detail message on a non-OK response", async () => {
// A 409 with a detail must surface the human message ("directory
// already exists") so the picker shows it instead of a bare code.
fetchMock.mockResolvedValue(
new Response(JSON.stringify({ detail: "directory already exists" }), { status: 409 }),
);
await expect(createHostDirectory("host_abc", "/Users/me/dup")).rejects.toThrow(
"directory already exists",
);
});
});
+69 -1
View File
@@ -1,4 +1,4 @@
import { useQuery } from "@tanstack/react-query";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { authenticatedFetch } from "@/lib/identity";
@@ -176,3 +176,71 @@ export function useHostFilesystem(hostId: string | null, path: string | null) {
placeholderData: (prev) => prev,
});
}
/** Shape returned by ``POST /v1/hosts/{id}/directories``. */
interface CreateHostDirectoryResponse {
object: string;
/** Absolute path of the created directory, e.g. ``"/Users/me/new"``. */
path: string;
}
/**
* Create a directory on a host via ``POST /v1/hosts/{id}/directories``.
*
* The server forwards a ``host.create_dir`` frame to the host, which
* runs ``os.makedirs`` (parents included) and returns the created
* absolute path. A non-OK response carries the host's error message
* (e.g. "directory already exists" as a 409) so the picker can show it
* inline.
*
* @param hostId Host identifier, e.g. ``"host_a1b2..."``.
* @param path Absolute (or ``~``-prefixed) directory path to create.
* @returns The created directory's absolute path.
* @throws FetchError carrying the HTTP status and the server's detail
* message on a non-OK response.
*/
export async function createHostDirectory(hostId: string, path: string): Promise<string> {
const res = await authenticatedFetch(`/v1/hosts/${encodeURIComponent(hostId)}/directories`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path }),
});
if (!res.ok) {
// Surface the server's detail (e.g. "directory already exists") so
// the user sees why creation failed rather than a bare status code.
let detail: string | null = null;
try {
const body = (await res.json()) as { detail?: string };
detail = typeof body.detail === "string" ? body.detail : null;
} catch {
detail = null;
}
const err: FetchError = new Error(detail ?? `create directory failed: HTTP ${res.status}`);
err.status = res.status;
throw err;
}
const body = (await res.json()) as CreateHostDirectoryResponse;
return body.path;
}
/**
* React Query mutation: create a directory on a host, then refresh any
* cached listings for that host so the new folder appears.
*
* Invalidates every ``["host-filesystem", hostId, *]`` query rather
* than just the parent's, because the picker keys listings by its raw
* path state ("" for home, absolute otherwise) and the caller may not
* know which key the new directory's parent maps to.
*
* @returns A React Query mutation; call ``mutateAsync({ hostId, path })``.
*/
export function useCreateHostDirectory() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ hostId, path }: { hostId: string; path: string }) =>
createHostDirectory(hostId, path),
onSuccess: (_createdPath, { hostId }) => {
void queryClient.invalidateQueries({ queryKey: ["host-filesystem", hostId] });
},
});
}
@@ -49,6 +49,9 @@ vi.mock("@/hooks/useAvailableAgents", () => ({ useAvailableAgents: vi.fn() }));
// always set here, so keep this inert (returns no listing).
vi.mock("@/hooks/useHostFilesystem", () => ({
useHostFilesystem: () => ({ data: undefined }),
// WorkspacePicker reads this on mount when the file browser opens;
// an idle mutation keeps it inert for these tests.
useCreateHostDirectory: () => ({ mutateAsync: vi.fn(), isPending: false }),
}));
// No other sessions in scope — keep the conflict hooks inert so they don't
// issue their own /health fetch or surface a warning. The warning is covered
+6 -1
View File
@@ -36,7 +36,12 @@ vi.mock("@/lib/identity", async (importOriginal) => ({
}));
vi.mock("@/hooks/useHosts", () => ({ useHosts: vi.fn() }));
vi.mock("@/hooks/useAvailableAgents", () => ({ useAvailableAgents: vi.fn() }));
vi.mock("@/hooks/useHostFilesystem", () => ({ useHostFilesystem: vi.fn() }));
vi.mock("@/hooks/useHostFilesystem", () => ({
useHostFilesystem: vi.fn(),
// WorkspacePicker (rendered by the file browser) reads this on mount;
// an idle mutation keeps it inert for these tests.
useCreateHostDirectory: vi.fn(() => ({ mutateAsync: vi.fn(), isPending: false })),
}));
vi.mock("@/hooks/useDirectorySessions", () => ({
useDirectorySessions: vi.fn(),
}));
+139 -1
View File
@@ -12,18 +12,28 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
basename,
joinPath,
listingFilter,
normalizeTypedPath,
parentOf,
WorkspacePicker,
} from "./WorkspacePicker";
import { useHostFilesystem, type HostFilesystemEntry } from "@/hooks/useHostFilesystem";
import {
useCreateHostDirectory,
useHostFilesystem,
type HostFilesystemEntry,
} from "@/hooks/useHostFilesystem";
vi.mock("@/hooks/useHostFilesystem", () => ({
useHostFilesystem: vi.fn(),
// Default to an idle mutation; tests that exercise creation override
// mutateAsync. The component only reads this when the new-folder form
// is open, so the default is harmless for the other suites.
useCreateHostDirectory: vi.fn(() => ({ mutateAsync: vi.fn(), isPending: false })),
}));
const useHostFilesystemMock = vi.mocked(useHostFilesystem);
const useCreateHostDirectoryMock = vi.mocked(useCreateHostDirectory);
function dir(name: string, path: string): HostFilesystemEntry {
return { name, path, type: "directory", bytes: null, modified_at: 0 };
@@ -435,3 +445,131 @@ describe("WorkspacePicker listing filter", () => {
expect(screen.queryByTestId("workspace-picker-entry-src")).toBeNull();
});
});
describe("joinPath", () => {
it("joins a nested directory and a child name", () => {
expect(joinPath("/Users/me", "new-app")).toBe("/Users/me/new-app");
});
it("does not double the slash at the filesystem root", () => {
// "/" + "foo" must be "/foo", not "//foo" — the latter would
// confuse the host's path resolution.
expect(joinPath("/", "foo")).toBe("/foo");
});
it("ignores a trailing slash on the parent", () => {
expect(joinPath("/Users/me/", "foo")).toBe("/Users/me/foo");
});
it("trims surrounding whitespace from the child name", () => {
expect(joinPath("/Users/me", " foo ")).toBe("/Users/me/foo");
});
});
// The "New folder" action lets a user create a directory inline rather
// than dropping to a terminal. It only makes sense once the picker has
// resolved a real absolute directory to create in.
describe("WorkspacePicker new folder", () => {
beforeEach(() => {
useHostFilesystemMock.mockReset();
useCreateHostDirectoryMock.mockReset();
useCreateHostDirectoryMock.mockReturnValue({
mutateAsync: vi.fn(),
isPending: false,
} as unknown as ReturnType<typeof useCreateHostDirectory>);
});
afterEach(() => {
cleanup();
});
function listingWith(entries: HostFilesystemEntry[]) {
useHostFilesystemMock.mockReturnValue(
result({ data: { entries, truncated: false }, isLoading: false, isPlaceholderData: false }),
);
}
it("creates a folder under the current directory and navigates into it", async () => {
listingWith([dir("app", "/Users/corey/projects/app")]);
const mutateAsync = vi.fn().mockResolvedValue("/Users/corey/projects/fresh");
useCreateHostDirectoryMock.mockReturnValue({
mutateAsync,
isPending: false,
} as unknown as ReturnType<typeof useCreateHostDirectory>);
render(<WorkspacePicker hostId="host_1" initialPath="/Users/corey/projects" />);
fireEvent.click(screen.getByTestId("workspace-picker-new-folder"));
fireEvent.change(screen.getByTestId("workspace-picker-new-folder-input"), {
target: { value: "fresh" },
});
fireEvent.click(screen.getByTestId("workspace-picker-new-folder-create"));
await Promise.resolve();
expect(mutateAsync).toHaveBeenCalledWith({
hostId: "host_1",
path: "/Users/corey/projects/fresh",
});
});
it("shows the server error inline when creation fails", async () => {
listingWith([dir("app", "/Users/corey/projects/app")]);
const mutateAsync = vi.fn().mockRejectedValue(new Error("directory already exists"));
useCreateHostDirectoryMock.mockReturnValue({
mutateAsync,
isPending: false,
} as unknown as ReturnType<typeof useCreateHostDirectory>);
render(<WorkspacePicker hostId="host_1" initialPath="/Users/corey/projects" />);
fireEvent.click(screen.getByTestId("workspace-picker-new-folder"));
fireEvent.change(screen.getByTestId("workspace-picker-new-folder-input"), {
target: { value: "app" },
});
fireEvent.click(screen.getByTestId("workspace-picker-new-folder-create"));
// Let the rejected mutation settle and the error state render.
await screen.findByTestId("workspace-picker-new-folder-error");
expect(screen.getByTestId("workspace-picker-new-folder-error").textContent).toContain(
"already exists",
);
});
it("disables the New folder button until an absolute directory resolves", () => {
// Home view ("") with no listing yet — currentAbsolute is "", so the
// button is disabled (there is no real directory to create in).
useHostFilesystemMock.mockReturnValue(
result({ data: undefined, isLoading: true, isPlaceholderData: false }),
);
render(<WorkspacePicker hostId="host_1" />);
const btn = screen.getByTestId("workspace-picker-new-folder") as HTMLButtonElement;
expect(btn.disabled).toBe(true);
});
it("creates under ~ when home is empty (no entry to resolve the absolute path)", async () => {
// An empty home has no entries, so the absolute home path can't be
// derived — but the listing HAS loaded. The button must still enable
// and create under "~" (the host expands it), otherwise the first
// folder in an empty home could never be made.
listingWith([]);
const mutateAsync = vi.fn().mockResolvedValue("/home/e2e/fresh");
useCreateHostDirectoryMock.mockReturnValue({
mutateAsync,
isPending: false,
} as unknown as ReturnType<typeof useCreateHostDirectory>);
render(<WorkspacePicker hostId="host_1" />);
const btn = screen.getByTestId("workspace-picker-new-folder") as HTMLButtonElement;
expect(btn.disabled).toBe(false);
fireEvent.click(btn);
fireEvent.change(screen.getByTestId("workspace-picker-new-folder-input"), {
target: { value: "fresh" },
});
fireEvent.click(screen.getByTestId("workspace-picker-new-folder-create"));
await Promise.resolve();
expect(mutateAsync).toHaveBeenCalledWith({ hostId: "host_1", path: "~/fresh" });
});
});
+151 -1
View File
@@ -1,5 +1,6 @@
import {
FolderIcon,
FolderPlusIcon,
FileIcon,
ArrowUpIcon,
HomeIcon,
@@ -12,7 +13,29 @@ import {
import { useEffect, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { useHostFilesystem } from "@/hooks/useHostFilesystem";
import { useCreateHostDirectory, useHostFilesystem } from "@/hooks/useHostFilesystem";
/**
* Join a directory path and a new child name into an absolute path.
*
* Handles the filesystem root (``"/"`` + ``"foo"`` → ``"/foo"`` rather
* than ``"//foo"``) and trims a trailing slash off the parent so a
* typed ``"/Users/me/"`` still produces ``"/Users/me/foo"``. The child
* name is trimmed; surrounding/duplicate slashes in it are left to the
* host to resolve.
*
* @param dir Absolute parent directory, e.g. ``"/Users/me"`` or ``"/"``.
* @param name New child name, e.g. ``"new-app"``.
* @returns The joined absolute path, e.g. ``"/Users/me/new-app"``.
*/
export function joinPath(dir: string, name: string): string {
const trimmedName = name.trim();
if (dir === "/") {
return `/${trimmedName}`;
}
const base = dir.endsWith("/") ? dir.slice(0, -1) : dir;
return `${base}/${trimmedName}`;
}
/**
* Compute the parent directory of an absolute path.
@@ -243,6 +266,12 @@ export function WorkspacePicker({
// True while the user is editing the path bar, so a late listing
// (e.g. home resolving) can't overwrite what they're typing.
const userEditedRef = useRef(false);
// "New folder" inline form: null when closed, otherwise the in-progress
// folder name. A separate error string holds the last create failure
// (e.g. "directory already exists") so it shows inline by the input.
const [newFolderName, setNewFolderName] = useState<string | null>(null);
const [createError, setCreateError] = useState<string | null>(null);
const createDir = useCreateHostDirectory();
// Reset to home when the host *changes* — a path from the old host
// is meaningless on the new one. Compare the previous hostId rather
@@ -256,6 +285,8 @@ export function WorkspacePicker({
setPathInput("");
setResolvedHome(null);
userEditedRef.current = false;
setNewFolderName(null);
setCreateError(null);
}, [hostId]);
const { data, isLoading, error, isPlaceholderData } = useHostFilesystem(hostId, path);
@@ -378,6 +409,49 @@ export function WorkspacePicker({
onSelect?.(currentAbsolute);
}
// Directory the "New folder" action creates in. A resolved absolute
// path is used as-is. At the home view the absolute path is derived
// from the first listing entry, so an *empty* home yields no entry and
// never resolves — fall back to "~" (the host expands it) once the
// listing has loaded, otherwise creating the first folder in an empty
// home would be impossible. Stays null while loading so the button is
// disabled until we know what home resolves to.
const createBaseDir = currentAbsolute.startsWith("/")
? currentAbsolute
: path === "" && !isLoading && !isPlaceholderData
? "~"
: null;
const canCreateFolder = hostId !== null && createBaseDir !== null;
function openNewFolder() {
setCreateError(null);
setNewFolderName("");
}
function cancelNewFolder() {
setNewFolderName(null);
setCreateError(null);
}
async function commitNewFolder() {
const name = (newFolderName ?? "").trim();
if (name === "" || hostId === null || createBaseDir === null) {
return;
}
const target = joinPath(createBaseDir, name);
try {
const created = await createDir.mutateAsync({ hostId, path: target });
// Drop into the freshly created folder so the user can pick it
// straight away (the reason they made it). The listing refresh is
// handled by the mutation's onSuccess invalidation.
setNewFolderName(null);
setCreateError(null);
navigateTo(created);
} catch (err) {
setCreateError(err instanceof Error ? err.message : "Failed to create folder");
}
}
return (
<div
className="flex max-h-80 min-h-0 flex-col rounded-md border"
@@ -437,6 +511,17 @@ export function WorkspacePicker({
>
{showHidden ? <EyeIcon className="size-4" /> : <EyeOffIcon className="size-4" />}
</button>
<button
type="button"
onClick={openNewFolder}
disabled={!canCreateFolder}
aria-label="New folder"
title="New folder"
className="shrink-0 rounded p-1 text-muted-foreground hover:bg-accent hover:text-accent-foreground disabled:opacity-30"
data-testid="workspace-picker-new-folder"
>
<FolderPlusIcon className="size-4" />
</button>
{onSelect && (
<Button
type="button"
@@ -464,6 +549,71 @@ export function WorkspacePicker({
</button>
)}
</div>
{newFolderName !== null && (
<div
className="flex shrink-0 flex-col gap-1 border-b px-3 py-1.5"
data-testid="workspace-picker-new-folder-form"
>
<div className="flex items-center gap-2">
<FolderPlusIcon className="size-4 shrink-0 text-muted-foreground" />
<input
type="text"
// eslint-disable-next-line jsx-a11y/no-autofocus -- focus belongs on
// the field the user just opened; the picker is already a focus trap.
autoFocus
value={newFolderName}
onChange={(e) => {
setNewFolderName(e.target.value);
if (createError !== null) setCreateError(null);
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
void commitNewFolder();
} else if (e.key === "Escape") {
e.preventDefault();
cancelNewFolder();
}
}}
placeholder="New folder name"
spellCheck={false}
autoCapitalize="off"
autoCorrect="off"
className="min-w-0 flex-1 bg-transparent text-xs text-foreground focus:outline-none"
data-testid="workspace-picker-new-folder-input"
/>
<button
type="button"
disabled={newFolderName.trim() === "" || createDir.isPending}
onClick={() => void commitNewFolder()}
aria-label="Create folder"
title="Create folder"
className="shrink-0 rounded p-1 text-muted-foreground hover:bg-accent hover:text-accent-foreground disabled:opacity-30"
data-testid="workspace-picker-new-folder-create"
>
<CheckIcon className="size-4" />
</button>
<button
type="button"
onClick={cancelNewFolder}
aria-label="Cancel new folder"
title="Cancel"
className="shrink-0 rounded p-1 text-muted-foreground hover:bg-accent hover:text-accent-foreground"
data-testid="workspace-picker-new-folder-cancel"
>
<XIcon className="size-4" />
</button>
</div>
{createError !== null && (
<span
className="text-xs text-destructive"
data-testid="workspace-picker-new-folder-error"
>
{createError}
</span>
)}
</div>
)}
{occupiedCount > 0 && (
<div
className="flex shrink-0 items-start gap-1.5 border-b bg-warning/10 px-3 py-2 text-xs text-warning"
+76
View File
@@ -22,6 +22,8 @@ from websockets.exceptions import InvalidStatus, InvalidURI
from omnigent.host.frames import (
HARNESS_NOT_CONFIGURED_ERROR_CODE,
HostCreateDirFrame,
HostCreateDirResultFrame,
HostCreateWorktreeFrame,
HostCreateWorktreeResultFrame,
HostHelloFrame,
@@ -1050,6 +1052,78 @@ class HostProcess:
before=frame.before,
)
def _handle_create_dir(self, frame: HostCreateDirFrame) -> HostCreateDirResultFrame:
"""Handle a ``host.create_dir`` request from the server.
Creates the directory (and any missing parents) with
``os.makedirs``. ``~`` expands against the host process
owner's home, same rules as ``host.list_dir``. Expected
filesystem errors (the directory already exists, permission
denied, a parent component is a file) return ``status: "ok"``
with a descriptive ``error`` so the route layer can map them
to a 409 rather than a 500 — mirroring how ``_handle_list_dir``
reports a missing path. Only unexpected I/O errors surface as
``status: "failed"``.
:param frame: The create-dir request frame. ``frame.path`` may
be absolute or tilde-prefixed.
:returns: Result frame carrying the created absolute path on
success, or an ``error`` describing why it was not created.
"""
try:
expanded = os.path.expanduser(frame.path)
except (TypeError, ValueError) as exc:
return HostCreateDirResultFrame(
request_id=frame.request_id,
status="failed",
error=f"path expansion failed: {exc}",
)
try:
# exist_ok=False so creating an existing folder is a clear
# "already exists" rather than a silent no-op — the picker
# should tell the user the name is taken.
os.makedirs(expanded, exist_ok=False)
except FileExistsError:
# makedirs raises FileExistsError whether the leaf is an
# existing directory or a regular file. Distinguish the two
# so "name is taken by a file" isn't mislabelled as an
# existing directory.
error = (
"directory already exists"
if os.path.isdir(expanded)
else "a file already exists at that path"
)
return HostCreateDirResultFrame(
request_id=frame.request_id,
status="ok",
error=error,
)
except NotADirectoryError:
return HostCreateDirResultFrame(
request_id=frame.request_id,
status="ok",
error="a parent path component is not a directory",
)
except PermissionError:
return HostCreateDirResultFrame(
request_id=frame.request_id,
status="ok",
error="permission denied",
)
except OSError as exc:
return HostCreateDirResultFrame(
request_id=frame.request_id,
status="failed",
error=f"mkdir failed: {exc.strerror or str(exc)}",
)
created = os.path.abspath(expanded)
_logger.info("Created directory %s", created)
return HostCreateDirResultFrame(
request_id=frame.request_id,
status="ok",
path=created,
)
async def _handle_create_worktree(
self,
frame: HostCreateWorktreeFrame,
@@ -1412,6 +1486,8 @@ class HostProcess:
await ws.send(encode_host_frame(self._handle_stat(frame)))
elif isinstance(frame, HostListDirFrame):
await ws.send(encode_host_frame(self._handle_list_dir(frame)))
elif isinstance(frame, HostCreateDirFrame):
await ws.send(encode_host_frame(self._handle_create_dir(frame)))
elif isinstance(frame, HostCreateWorktreeFrame):
await ws.send(encode_host_frame(await self._handle_create_worktree(frame)))
elif isinstance(frame, HostRemoveWorktreeFrame):
+96
View File
@@ -49,6 +49,8 @@ class HostFrameKind(str, Enum):
CREATE_WORKTREE_RESULT = "host.create_worktree_result"
REMOVE_WORKTREE = "host.remove_worktree"
REMOVE_WORKTREE_RESULT = "host.remove_worktree_result"
CREATE_DIR = "host.create_dir"
CREATE_DIR_RESULT = "host.create_dir_result"
# ── Frame dataclasses ────────────────────────────────────
@@ -426,6 +428,50 @@ class HostRemoveWorktreeResultFrame:
error: str | None = None
@dataclass
class HostCreateDirFrame:
"""Server → host: create a new directory on the host.
Backs ``POST /v1/hosts/{id}/directories``, used by the Web UI's
workspace picker so a user can make a fresh folder to start a
session in without dropping to a terminal. The host owns ``~``
resolution, same rules as ``host.list_dir`` / ``host.stat``.
:param request_id: Correlates the result, e.g. ``"req_mkdir_1"``.
:param path: Absolute or tilde-prefixed directory path to create,
e.g. ``"/Users/corey/projects/new-app"`` or ``"~/scratch"``.
Missing parent directories are created (``os.makedirs``).
"""
request_id: str
path: str
@dataclass
class HostCreateDirResultFrame:
"""Host → server: outcome of a create-dir request.
:param request_id: Correlates to the
:class:`HostCreateDirFrame`, e.g. ``"req_mkdir_1"``.
:param status: ``"ok"`` or ``"failed"``. ``"failed"`` is reserved
for unexpected I/O errors; an expected filesystem error (the
directory already exists, permission denied, a parent path
component is a file) collapses to ``"ok"`` with a descriptive
``error`` so the route layer can map it to a 409 rather than a
500 — same posture as ``host.list_dir`` for a missing path.
:param path: Absolute path of the created directory, e.g.
``"/Users/corey/projects/new-app"``. ``None`` when the
directory was not created.
:param error: Filesystem error, e.g. ``"directory already
exists"`` or ``"permission denied"``. ``None`` on success.
"""
request_id: str
status: str
path: str | None = None
error: str | None = None
HostFrame = (
HostHelloFrame
| HostLaunchRunnerFrame
@@ -441,6 +487,8 @@ HostFrame = (
| HostCreateWorktreeResultFrame
| HostRemoveWorktreeFrame
| HostRemoveWorktreeResultFrame
| HostCreateDirFrame
| HostCreateDirResultFrame
)
@@ -602,6 +650,24 @@ def encode_host_frame(frame: HostFrame) -> str:
"error": frame.error,
}
)
if isinstance(frame, HostCreateDirFrame):
return json.dumps(
{
"kind": HostFrameKind.CREATE_DIR.value,
"request_id": frame.request_id,
"path": frame.path,
}
)
if isinstance(frame, HostCreateDirResultFrame):
return json.dumps(
{
"kind": HostFrameKind.CREATE_DIR_RESULT.value,
"request_id": frame.request_id,
"status": frame.status,
"path": frame.path,
"error": frame.error,
}
)
raise TypeError(f"unknown host frame type: {type(frame).__name__}")
@@ -690,6 +756,10 @@ def _decode_known_host_frame(
return _decode_remove_worktree(msg)
case HostFrameKind.REMOVE_WORKTREE_RESULT:
return _decode_remove_worktree_result(msg)
case HostFrameKind.CREATE_DIR:
return _decode_create_dir(msg)
case HostFrameKind.CREATE_DIR_RESULT:
return _decode_create_dir_result(msg)
raise ValueError(f"unhandled host frame kind: {kind.value!r}") # pragma: no cover
@@ -936,6 +1006,32 @@ def _decode_remove_worktree_result(
)
def _decode_create_dir(msg: dict[str, Any]) -> HostCreateDirFrame:
"""Decode a host.create_dir request frame.
:param msg: Decoded frame object.
:returns: Typed host.create_dir frame.
"""
return HostCreateDirFrame(
request_id=_required_str(msg, "request_id"),
path=_required_str(msg, "path"),
)
def _decode_create_dir_result(msg: dict[str, Any]) -> HostCreateDirResultFrame:
"""Decode a host.create_dir_result frame.
:param msg: Decoded frame object.
:returns: Typed host.create_dir_result frame.
"""
return HostCreateDirResultFrame(
request_id=_required_str(msg, "request_id"),
status=_required_str(msg, "status"),
path=_optional_nullable_str(msg, "path"),
error=_optional_nullable_str(msg, "error"),
)
# ── Field validators ─────────────────────────────────────
+8
View File
@@ -180,6 +180,11 @@ class HostConnection:
in-flight ``host.remove_worktree`` requests. Resolved when
the host sends ``host.remove_worktree_result``. Values
carry ``status`` and ``error``.
:param pending_create_dirs: Per-``request_id`` futures for
in-flight ``host.create_dir`` requests. Resolved when the
host sends ``host.create_dir_result``. Values carry the
result fields (``status``, ``path``, ``error``). Same
``Any`` typing rationale as ``pending_stats``.
"""
host_id: str
@@ -207,6 +212,9 @@ class HostConnection:
pending_remove_worktrees: dict[str, asyncio.Future[dict[str, Any]]] = field(
default_factory=dict,
)
pending_create_dirs: dict[str, asyncio.Future[dict[str, Any]]] = field(
default_factory=dict,
)
class HostRegistry:
+13
View File
@@ -26,6 +26,7 @@ from collections.abc import Awaitable, Callable
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from omnigent.host.frames import (
HostCreateDirResultFrame,
HostCreateWorktreeResultFrame,
HostHelloFrame,
HostLaunchRunnerResultFrame,
@@ -464,6 +465,18 @@ async def _receive_loop(
)
continue
if isinstance(frame, HostCreateDirResultFrame):
create_dir_future = conn.pending_create_dirs.pop(frame.request_id, None)
if create_dir_future is not None and not create_dir_future.done():
create_dir_future.set_result(
{
"status": frame.status,
"path": frame.path,
"error": frame.error,
}
)
continue
_logger.debug(
"Host %s sent unexpected frame type: %s",
host_id,
+161
View File
@@ -30,6 +30,7 @@ from omnigent.errors import ErrorCode, OmnigentError
from omnigent.harness_aliases import canonicalize_harness
from omnigent.host.frames import (
HARNESS_NOT_CONFIGURED_ERROR_CODE,
HostCreateDirFrame,
HostLaunchRunnerFrame,
HostListDirFrame,
encode_host_frame,
@@ -54,6 +55,10 @@ _LAUNCH_RESULT_TIMEOUT_S = 30.0
_LIST_DIR_TIMEOUT_S = 5.0
_LIST_DIR_DEFAULT_LIMIT = 20
_LIST_DIR_MAX_LIMIT = 1000
# Per-call timeout for host.create_dir round-trips. mkdir is a single
# fast syscall on the host side; 5s matches list_dir and is generous
# for transient network slowness without making the picker feel hung.
_CREATE_DIR_TIMEOUT_S = 5.0
async def _proxy_list_dir(
@@ -128,6 +133,78 @@ async def _proxy_list_dir(
host_conn.pending_list_dirs.pop(request_id, None)
async def _proxy_create_dir(
*,
host_registry: HostRegistry,
host_conn: HostConnection,
path: str,
) -> dict[str, Any]:
"""
Send a ``host.create_dir`` frame and await the result.
Mirrors :func:`_proxy_list_dir`: enqueue the frame, register a
future on the host connection's ``pending_create_dirs`` map, await
with a timeout, and clean up in a finally block. The host's WS
receive loop in ``host_tunnel.py`` resolves the future when the
result frame arrives.
:param host_registry: Server-side registry; used to enqueue the
outbound frame on the host's send queue.
:param host_conn: Live host connection.
:param path: Absolute or tilde-prefixed directory to create. The
host expands ``~`` itself.
:returns: Dict with the result fields: ``status`` (``"ok"`` or
``"failed"``), ``path`` (created absolute path or ``None``),
``error`` (string or ``None``).
:raises HTTPException: 504 on timeout, 502 on connection drop.
"""
request_id = secrets.token_hex(8)
loop = asyncio.get_running_loop()
future: asyncio.Future[dict[str, Any]] = loop.create_future()
host_conn.pending_create_dirs[request_id] = future
frame = encode_host_frame(
HostCreateDirFrame(
request_id=request_id,
path=path,
)
)
try:
try:
host_registry.send_text(host_conn, frame)
except ConnectionError as exc:
raise HTTPException(
status_code=502,
detail=f"host '{host_conn.host_id}' connection lost",
) from exc
try:
return await asyncio.wait_for(future, timeout=_CREATE_DIR_TIMEOUT_S)
except asyncio.TimeoutError as exc:
raise HTTPException(
status_code=504,
detail=(
f"host '{host_conn.host_id}' did not respond to create_dir "
f"within {_CREATE_DIR_TIMEOUT_S:.0f}s"
),
) from exc
finally:
# Cleanup runs on every path so a cancelled caller doesn't
# leave an orphan in the pending dict.
host_conn.pending_create_dirs.pop(request_id, None)
class CreateDirectoryRequest(BaseModel):
"""Request body for ``POST /v1/hosts/{host_id}/directories``.
:param path: Absolute path of the directory to create on the host
machine, e.g. ``"/Users/corey/projects/new-app"``, or a
tilde-prefixed path (``"~/scratch"``) the host expands against
its own process owner. Missing parents are created.
"""
path: str
class LaunchRunnerRequest(BaseModel):
"""Request body for ``POST /v1/hosts/{host_id}/runners``.
@@ -756,4 +833,88 @@ def create_hosts_router(
"has_more": bool(result.get("has_more", False)),
}
@router.post("/hosts/{host_id}/directories")
async def create_host_directory(
request: Request,
host_id: str,
body: CreateDirectoryRequest,
) -> dict[str, Any]:
"""
Create a new directory on a host.
Backs the Web UI workspace picker's "New folder" action so a
user can make a fresh directory to start a session in without
dropping to a terminal. Owner-scoped exactly like the
filesystem browse endpoints (``GET /v1/hosts/{id}/filesystem``):
only the host owner can create directories, and — like browse —
this is NOT scoped to a session. The workspace-boundary check
still runs at session-create time, so creating a directory here
does not by itself grant an agent access to it.
:param request: FastAPI request (for auth).
:param host_id: Host identifier, e.g. ``"host_a1b2c3d4..."``.
:param body: Request body carrying the absolute (or
tilde-prefixed) ``path`` to create.
:returns: ``{"object": "directory", "path": "<created abs path>"}``.
:raises HTTPException: 404 if host not found, 403 if not owned
by caller, 409 if host is offline or the directory could not
be created (already exists / permission denied), 400 on path
validation, 504 on host timeout, 502 on host I/O failure.
"""
# require_user: unauthenticated callers 401 instead of slipping
# past the owner check below as None.
user_id = require_user(request, auth_provider)
host = await asyncio.to_thread(host_store.get_host, host_id)
if host is None:
raise HTTPException(status_code=404, detail="host not found")
if user_id is not None and host.owner != user_id:
raise HTTPException(status_code=403, detail="not your host")
path = body.path
if not path.strip():
raise HTTPException(status_code=400, detail="path must not be empty")
if "\x00" in path:
raise HTTPException(
status_code=400,
detail="path must not contain NUL bytes",
)
# Absolute or tilde-prefixed only — the host needs a path it can
# resolve on its own; a relative path has no stable meaning here.
if not path.startswith(("/", "~")):
raise HTTPException(
status_code=400,
detail="path must be absolute or tilde-prefixed",
)
conn = host_registry.get(host.host_id)
if conn is None:
raise HTTPException(status_code=409, detail="host is offline")
result = await _proxy_create_dir(
host_registry=host_registry,
host_conn=conn,
path=path,
)
if result.get("status") == "failed":
# Unexpected I/O failure on the host.
raise HTTPException(
status_code=502,
detail=f"host create_dir failed: {result.get('error') or 'unknown error'}",
)
# Expected filesystem error (already exists / permission denied /
# parent is a file) → 409 Conflict with the host's message, so
# the picker can show "directory already exists" inline.
if result.get("error"):
raise HTTPException(
status_code=409,
detail=str(result.get("error")),
)
return {
"object": "directory",
"path": result.get("path"),
}
return router
+69
View File
@@ -413,6 +413,20 @@
"title": "CreateDefaultPolicyRequest",
"type": "object"
},
"CreateDirectoryRequest": {
"description": "Request body for ``POST /v1/hosts/{host_id}/directories``.\n\n:param path: Absolute path of the directory to create on the host\n machine, e.g. ``\"/Users/corey/projects/new-app\"``, or a\n tilde-prefixed path (``\"~/scratch\"``) the host expands against\n its own process owner. Missing parents are created.",
"properties": {
"path": {
"title": "Path",
"type": "string"
}
},
"required": [
"path"
],
"title": "CreateDirectoryRequest",
"type": "object"
},
"CreateSessionPolicyRequest": {
"description": "Request body for ``POST /v1/sessions/{session_id}/policies``.\n\n:param name: Human-readable policy name. Must be unique\n within the session, e.g.\n ``\"block_non_feature_branch_push\"``.\n:param type: Handler discriminator: ``\"python\"`` or\n ``\"url\"``.\n:param handler: Dotted import path (python) or HTTPS URL\n (url), e.g.\n ``\"github_mcp_policy.block_non_misc_push\"``\n or ``\"https://example.com/policies/eval\"``.\n:param factory_params: Optional dict of kwargs passed to the\n handler when it is a factory function. Only valid for\n ``type=\"python\"``, e.g. ``{\"limit\": 10}``.",
"properties": {
@@ -3849,6 +3863,61 @@
]
}
},
"/v1/hosts/{host_id}/directories": {
"post": {
"description": "Create a new directory on a host.\n\nBacks the Web UI workspace picker's \"New folder\" action so a\nuser can make a fresh directory to start a session in without\ndropping to a terminal. Owner-scoped exactly like the\nfilesystem browse endpoints (``GET /v1/hosts/{id}/filesystem``):\nonly the host owner can create directories, and \u2014 like browse \u2014\nthis is NOT scoped to a session. The workspace-boundary check\nstill runs at session-create time, so creating a directory here\ndoes not by itself grant an agent access to it.\n\n:param request: FastAPI request (for auth).\n:param host_id: Host identifier, e.g. ``\"host_a1b2c3d4...\"``.\n:param body: Request body carrying the absolute (or\n tilde-prefixed) ``path`` to create.\n:returns: ``{\"object\": \"directory\", \"path\": \"<created abs path>\"}``.\n:raises HTTPException: 404 if host not found, 403 if not owned\n by caller, 409 if host is offline or the directory could not\n be created (already exists / permission denied), 400 on path\n validation, 504 on host timeout, 502 on host I/O failure.",
"operationId": "create_host_directory_v1_hosts__host_id__directories_post",
"parameters": [
{
"in": "path",
"name": "host_id",
"required": true,
"schema": {
"title": "Host Id",
"type": "string"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CreateDirectoryRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"additionalProperties": true,
"title": "Response Create Host Directory V1 Hosts Host Id Directories Post",
"type": "object"
}
}
},
"description": "Successful Response"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
},
"description": "Validation Error"
}
},
"summary": "Create Host Directory",
"tags": [
"hosts"
]
}
},
"/v1/hosts/{host_id}/filesystem": {
"get": {
"description": "List the contents of the host daemon's home directory.\n\nEmpty trailing path \u2192 forward ``~`` to the host (the host\nexpands against its own process owner). Used by the\nWeb UI's directory picker to show the \"root\" view.\n\n:param request: FastAPI request (for auth).\n:param host_id: Host identifier, e.g.\n ``\"host_a1b2c3d4...\"``.\n:param limit: Max entries per page.\n:param after: Optional forward pagination cursor (entry\n path), e.g. ``\"/Users/corey/projects/m\"``.\n:param before: Optional backward pagination cursor.\n:returns: ``{\"object\": \"list\", \"data\": [...], \"has_more\": bool}``\n mirroring the existing session-scoped filesystem\n endpoint shape.\n:raises HTTPException: 404 if host not found, 403 if not\n owned by caller, 409 if host is offline, 504 on host\n timeout, 502 on host I/O failure.",
@@ -713,6 +713,127 @@ async def _drive_folder_selection(base_url: str, session_id: str) -> None:
await browser.close()
def test_start_session_create_folder(seeded_session: tuple[str, str]) -> None:
"""Creating a folder in the picker makes it the session's workspace.
The user opens the file browser, navigates into a folder, clicks "New
folder", names it, and confirms. The picker POSTs
``/v1/hosts/{id}/directories``, drops into the freshly created
directory, and the working-directory chip follows. On Send the new
folder's path must reach ``POST /v1/sessions`` as ``workspace`` — i.e.
the agent's working directory is the folder the user just made.
Like the other tests here, the tunneled runner registers no host, so
``/v1/hosts/{id}/directories`` is faked: the handler captures the
requested path and echoes it back as the created absolute path (the
real ``os.makedirs`` never runs in this harness).
"""
base_url, session_id = seeded_session
_run_in_fresh_loop(_drive_create_folder(base_url, session_id))
async def _drive_create_folder(base_url: str, session_id: str) -> None:
async with async_playwright() as pw:
browser = await pw.chromium.launch()
page = await browser.new_page()
try:
create_bodies: list[dict[str, Any]] = []
await _register_common_routes(
page, created_session_id=session_id, create_bodies=create_bodies
)
async def handle_filesystem(route: Route) -> None:
# Home shows "projects"; "/home/e2e/projects" shows its child;
# the freshly created "/home/e2e/projects/new-app" lists empty.
# Deepest match first so the new folder isn't shadowed.
path_part = route.request.url.split("?")[0]
if path_part.endswith("/filesystem/home/e2e/projects/new-app"):
entries: list[dict[str, Any]] = []
elif path_part.endswith("/filesystem/home/e2e/projects"):
entries = [
{
"name": "src",
"path": "/home/e2e/projects/src",
"type": "directory",
"bytes": None,
"modified_at": 0,
}
]
else:
entries = [
{
"name": "projects",
"path": "/home/e2e/projects",
"type": "directory",
"bytes": None,
"modified_at": 0,
}
]
await route.fulfill(
status=200,
content_type="application/json",
body=json.dumps({"object": "list", "data": entries, "has_more": False}),
)
create_dir_bodies: list[dict[str, Any]] = []
async def handle_create_dir(route: Route) -> None:
# Mirror the server's success shape: echo the requested path
# back as the created absolute path. Capturing the body lets
# the test assert the picker sent the joined parent + name.
body = json.loads(route.request.post_data or "{}")
create_dir_bodies.append(body)
await route.fulfill(
status=200,
content_type="application/json",
body=json.dumps({"object": "directory", "path": body["path"]}),
)
# Registered after the broad globs so these win for their URLs.
await page.route(_FILESYSTEM_RE, handle_filesystem)
await page.route(re.compile(r"/v1/hosts/[^/]+/directories$"), handle_create_dir)
await page.goto(f"{base_url}/")
await page.get_by_test_id("new-chat-landing-input").wait_for(
state="visible", timeout=30_000
)
await expect(page.get_by_test_id("new-chat-landing-workspace-chip")).to_contain_text(
"e2e"
)
# Open the picker and navigate into "projects" so the new folder
# has a resolved absolute parent to be created under.
await page.get_by_test_id("new-chat-landing-workspace-chip").click()
await expect(page.get_by_test_id("workspace-picker")).to_be_visible()
await page.get_by_test_id("workspace-picker-entry-projects").click()
await expect(page.get_by_test_id("workspace-picker-entry-src")).to_be_visible()
# Create a new folder under /home/e2e/projects.
await page.get_by_test_id("workspace-picker-new-folder").click()
await page.get_by_test_id("workspace-picker-new-folder-input").fill("new-app")
await page.get_by_test_id("workspace-picker-new-folder-create").click()
# The picker POSTs the joined path and drops into the new folder.
await _wait_until(lambda: len(create_dir_bodies) == 1)
assert create_dir_bodies[0]["path"] == "/home/e2e/projects/new-app", create_dir_bodies
# Filling the message closes the popover; the chip now shows the
# folder we just created.
await page.get_by_test_id("new-chat-landing-input").fill("set up the project")
await expect(page.get_by_test_id("new-chat-landing-workspace-chip")).to_contain_text(
"new-app"
)
await page.get_by_test_id("new-chat-landing-submit").click()
await _wait_until(lambda: len(create_bodies) == 1)
body = create_bodies[0]
assert body["host_id"] == _HOST_ID, body
assert body["workspace"] == "/home/e2e/projects/new-app", body
finally:
await browser.close()
def test_start_session_add_worktree(seeded_session: tuple[str, str]) -> None:
"""Naming a branch attaches a git worktree spec to the create call.
+116
View File
@@ -22,6 +22,8 @@ from omnigent.host.connect import (
)
from omnigent.host.frames import (
HARNESS_NOT_CONFIGURED_ERROR_CODE,
HostCreateDirFrame,
HostCreateDirResultFrame,
HostHelloFrame,
HostLaunchRunnerFrame,
HostLaunchRunnerResultFrame,
@@ -1463,6 +1465,120 @@ def test_handle_list_dir_pagination_last_page_has_more_false(
assert result.has_more is False
# ── host.create_dir handler ─────────────────────────────
def test_handle_create_dir_creates_directory(tmp_path: Path) -> None:
"""
Verify ``_handle_create_dir`` makes the directory and returns its
absolute path.
This is the picker's "New folder" happy path — the returned path
is what the picker navigates into afterward.
"""
host = _make_host_process()
target = tmp_path / "new-app"
result = host._handle_create_dir(HostCreateDirFrame(request_id="m1", path=str(target)))
assert isinstance(result, HostCreateDirResultFrame)
assert result.status == "ok"
assert result.error is None
assert result.path == str(target)
assert target.is_dir()
def test_handle_create_dir_creates_missing_parents(tmp_path: Path) -> None:
"""
Verify missing parent directories are created (``os.makedirs``).
Lets the picker accept a nested name like ``a/b/c`` in one go
rather than forcing the user to create each level.
"""
host = _make_host_process()
target = tmp_path / "a" / "b" / "c"
result = host._handle_create_dir(HostCreateDirFrame(request_id="m2", path=str(target)))
assert result.status == "ok"
assert target.is_dir()
def test_handle_create_dir_existing_returns_error_not_failed(tmp_path: Path) -> None:
"""
Verify creating an existing directory returns ``status: "ok"`` with
an "already exists" error rather than ``status: "failed"``.
The route maps a non-empty ``error`` to a 409 so the picker shows
"directory already exists" inline; surfacing ``failed`` would 502
instead.
"""
host = _make_host_process()
existing = tmp_path / "dup"
existing.mkdir()
result = host._handle_create_dir(HostCreateDirFrame(request_id="m3", path=str(existing)))
assert result.status == "ok"
assert result.error == "directory already exists"
assert result.path is None
def test_handle_create_dir_leaf_is_file_reports_file_not_directory(tmp_path: Path) -> None:
"""
Verify a regular file at the target path reports a file, not a
directory.
``os.makedirs`` raises ``FileExistsError`` for both an existing
directory and an existing file; the handler must distinguish them
so the picker doesn't mislabel "a file is in the way" as
"directory already exists".
"""
host = _make_host_process()
a_file = tmp_path / "taken"
a_file.write_text("hi")
result = host._handle_create_dir(HostCreateDirFrame(request_id="m3b", path=str(a_file)))
assert result.status == "ok"
assert result.error == "a file already exists at that path"
assert result.path is None
def test_handle_create_dir_parent_is_file_returns_error(tmp_path: Path) -> None:
"""
Verify creating under a path whose parent is a regular file returns
a clean error rather than crashing.
"""
host = _make_host_process()
a_file = tmp_path / "file.txt"
a_file.write_text("hi")
target = a_file / "child"
result = host._handle_create_dir(HostCreateDirFrame(request_id="m4", path=str(target)))
assert result.status == "ok"
assert "not a directory" in (result.error or "")
assert result.path is None
def test_handle_create_dir_expands_tilde(tmp_path: Path, monkeypatch) -> None:
"""
Verify ``~`` expands against the host process owner's home.
The host owns ``~`` resolution; without expansion ``~/scratch``
would become a literal ``~`` subdir of the process cwd.
"""
monkeypatch.setenv("HOME", str(tmp_path))
host = _make_host_process()
result = host._handle_create_dir(HostCreateDirFrame(request_id="m5", path="~/scratch"))
assert result.status == "ok"
assert (tmp_path / "scratch").is_dir()
assert result.path == str(tmp_path / "scratch")
# --- Fail-loud on permanent tunnel failures ----------------------------
#
# Before the fix, HostProcess.run() caught every connection exception and
+84
View File
@@ -8,6 +8,8 @@ import pytest
from omnigent.host.frames import (
HARNESS_NOT_CONFIGURED_ERROR_CODE,
HostCreateDirFrame,
HostCreateDirResultFrame,
HostCreateWorktreeFrame,
HostCreateWorktreeResultFrame,
HostHelloFrame,
@@ -796,3 +798,85 @@ def test_remove_worktree_result_frame_round_trip() -> None:
decoded = decode_host_frame(encode_host_frame(original))
assert isinstance(decoded, HostRemoveWorktreeResultFrame)
assert decoded == original
# ── host.create_dir frames ──────────────────────────────
def test_create_dir_frame_round_trip() -> None:
"""
Verify HostCreateDirFrame request frame survives encode → decode.
Pins the wire shape used by the picker's "New folder" action:
``request_id`` plus the directory ``path`` to create.
"""
original = HostCreateDirFrame(
request_id="req_mkdir_1",
path="/Users/corey/projects/new-app",
)
decoded = decode_host_frame(encode_host_frame(original))
assert isinstance(decoded, HostCreateDirFrame)
assert decoded == original
def test_create_dir_frame_accepts_tilde_path() -> None:
"""
Verify a tilde-prefixed path round-trips verbatim.
The host (not the server) expands ``~``, same rules as
``host.list_dir`` — so the tilde must survive the wire.
"""
original = HostCreateDirFrame(request_id="req_mkdir_tilde", path="~/scratch")
decoded = decode_host_frame(encode_host_frame(original))
assert isinstance(decoded, HostCreateDirFrame)
assert decoded.path == "~/scratch"
def test_create_dir_request_missing_path_raises() -> None:
"""
Verify decoding a create_dir without ``path`` raises ValueError.
Without ``path`` the host has nothing to create; failing loud
beats silently creating something under the process cwd.
"""
with pytest.raises(ValueError, match="missing required string field"):
decode_host_frame('{"kind": "host.create_dir", "request_id": "r"}')
def test_create_dir_result_success_round_trip() -> None:
"""
Verify a successful create-dir result round-trips with the created
absolute path intact.
The picker navigates into ``path`` after creating it; a dropped
field would leave the user staring at the old directory.
"""
original = HostCreateDirResultFrame(
request_id="req_mkdir_2",
status="ok",
path="/Users/corey/projects/new-app",
)
decoded = decode_host_frame(encode_host_frame(original))
assert isinstance(decoded, HostCreateDirResultFrame)
assert decoded == original
def test_create_dir_result_error_round_trip() -> None:
"""
Verify an expected filesystem error round-trips with the message
intact and ``path`` left ``None``.
The route maps a non-empty ``error`` to a 409 so the picker can
show "directory already exists" — that hinges on the message
surviving the wire.
"""
original = HostCreateDirResultFrame(
request_id="req_mkdir_3",
status="ok",
error="directory already exists",
)
decoded = decode_host_frame(encode_host_frame(original))
assert isinstance(decoded, HostCreateDirResultFrame)
assert decoded.status == "ok"
assert decoded.path is None
assert decoded.error == "directory already exists"
@@ -0,0 +1,303 @@
"""
Integration tests for ``POST /v1/hosts/{id}/directories``.
Wires up a real host tunnel + REST router pair, drives a fake host
that auto-replies to ``host.create_dir`` frames, and exercises the
endpoint's contract end-to-end. Mirrors the structure of
``test_hosts_filesystem.py`` (the browse endpoint) — the create-folder
action shares the same owner-scoped, host-forwarded design.
"""
from __future__ import annotations
import asyncio
from collections.abc import AsyncIterator
from typing import Any
import pytest
from asgiref.testing import ApplicationCommunicator
from fastapi import FastAPI
from httpx import ASGITransport, AsyncClient
from omnigent.host.frames import (
HostCreateDirFrame,
HostCreateDirResultFrame,
HostHelloFrame,
decode_host_frame,
encode_host_frame,
)
from omnigent.server.host_registry import HostRegistry
from omnigent.server.routes.host_tunnel import create_host_tunnel_router
from omnigent.server.routes.hosts import create_hosts_router
from omnigent.stores.conversation_store.sqlalchemy_store import (
SqlAlchemyConversationStore,
)
from omnigent.stores.host_store import HostStore
# Same liveness-race flake guard as test_hosts_filesystem.py: the mock
# WS host can be starved + deregistered under parallel CI load. Tests
# are sub-second; rerun rather than fail.
pytestmark = [
pytest.mark.asyncio,
pytest.mark.flaky(reruns=2, reruns_delay=1),
]
_HOST_ID = "host_mkdir_test"
_HOST_NAME = "mkdir-test-laptop"
def _websocket_scope(path: str) -> dict[str, object]:
"""Build a minimal ASGI WebSocket scope.
:param path: WebSocket path, e.g. ``"/v1/hosts/X/tunnel"``.
:returns: ASGI scope dict.
"""
return {
"type": "websocket",
"asgi": {"version": "3.0"},
"scheme": "ws",
"path": path,
"raw_path": path.encode("ascii"),
"query_string": b"",
"headers": [],
"client": ("127.0.0.1", 50000),
"server": ("testserver", 80),
"subprotocols": [],
}
def _hello_text(name: str = _HOST_NAME) -> str:
"""Encode a hello frame for tests.
:param name: Host name reported in the hello frame.
:returns: JSON-encoded hello frame.
"""
return encode_host_frame(
HostHelloFrame(
version="0.1.0-test",
frame_protocol_version=1,
name=name,
)
)
@pytest.fixture()
def mkdir_app(
db_uri: str,
) -> tuple[FastAPI, HostRegistry, HostStore, SqlAlchemyConversationStore]:
"""
App with host tunnel + REST routes for create-directory tests.
:param db_uri: SQLite URI fixture.
:returns: (app, registry, host_store, conv_store).
"""
registry = HostRegistry()
host_store = HostStore(db_uri)
conv_store = SqlAlchemyConversationStore(db_uri)
app = FastAPI()
app.include_router(
create_host_tunnel_router(registry, host_store),
prefix="/v1",
)
app.include_router(
create_hosts_router(registry, host_store, conv_store),
prefix="/v1",
)
return app, registry, host_store, conv_store
@pytest.fixture()
async def mkdir_setup(
mkdir_app: tuple[FastAPI, HostRegistry, HostStore, SqlAlchemyConversationStore],
) -> AsyncIterator[
tuple[
FastAPI,
HostRegistry,
ApplicationCommunicator,
dict[str, dict[str, Any]],
asyncio.Task[None],
]
]:
"""
Connect a mock host and start an auto-replier for create_dir frames.
Tests register fake replies in ``replies`` (path → reply dict)
before calling the REST endpoint. The auto-replier consumes the
``host.create_dir`` frames the route pushes through the registry,
decodes them, and feeds the configured result back — mirroring what
``host_tunnel.py`` does in production. An unregistered path defaults
to a successful create echoing the requested path.
:param mkdir_app: The fixture above.
:returns: Async iterator yielding the wired-up state.
"""
app, registry, _hs, _cs = mkdir_app
path = f"/v1/hosts/{_HOST_ID}/tunnel"
comm = ApplicationCommunicator(app, _websocket_scope(path))
await comm.send_input({"type": "websocket.connect"})
accepted = await comm.receive_output(timeout=1.0)
assert accepted["type"] == "websocket.accept"
await comm.send_input({"type": "websocket.receive", "text": _hello_text()})
while registry.get(_HOST_ID) is None:
await asyncio.sleep(0.01)
conn = registry.get(_HOST_ID)
assert conn is not None
replies: dict[str, dict[str, Any]] = {}
stop_drain = asyncio.Event()
async def _drain() -> None:
"""Drain outbound WS frames and reply to create_dir frames.
:returns: None when ``stop_drain`` is set or no events arrive
within the per-iteration timeout.
"""
while not stop_drain.is_set():
try:
output = await comm.receive_output(timeout=0.5)
except asyncio.TimeoutError:
continue
if output.get("type") != "websocket.send":
continue
text = output.get("text")
if not isinstance(text, str):
continue
frame = decode_host_frame(text)
if not isinstance(frame, HostCreateDirFrame):
continue
reply = replies.get(frame.path)
if reply is None:
# Default: success, echoing the requested path (the host
# would return the created absolute path).
reply_frame = HostCreateDirResultFrame(
request_id=frame.request_id,
status="ok",
path=frame.path,
)
else:
reply_frame = HostCreateDirResultFrame(
request_id=frame.request_id,
status=reply.get("status", "ok"),
path=reply.get("path"),
error=reply.get("error"),
)
await comm.send_input(
{
"type": "websocket.receive",
"text": encode_host_frame(reply_frame),
}
)
drain_task = asyncio.create_task(_drain())
try:
yield app, registry, comm, replies, drain_task
finally:
stop_drain.set()
try:
await asyncio.wait_for(drain_task, timeout=1.0)
except asyncio.TimeoutError:
drain_task.cancel()
# ── Happy path ──────────────────────────────────────────
async def test_create_directory_returns_created_path(
mkdir_setup: tuple[
FastAPI,
HostRegistry,
ApplicationCommunicator,
dict[str, dict[str, Any]],
asyncio.Task[None],
],
) -> None:
"""
A valid create request returns the created absolute path.
This is what the picker navigates into after creating the folder,
so the path must round-trip through the endpoint intact.
"""
app, _reg, _comm, replies, _drain = mkdir_setup
target = "/Users/corey/projects/new-app"
replies[target] = {"status": "ok", "path": target}
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
resp = await client.post(
f"/v1/hosts/{_HOST_ID}/directories",
json={"path": target},
)
assert resp.status_code == 200
body = resp.json()
assert body["object"] == "directory"
assert body["path"] == target
async def test_create_directory_already_exists_returns_409(
mkdir_setup: tuple[
FastAPI,
HostRegistry,
ApplicationCommunicator,
dict[str, dict[str, Any]],
asyncio.Task[None],
],
) -> None:
"""
An "already exists" host result maps to 409 with the message.
The picker shows this inline so the user knows the name is taken
rather than seeing a generic failure.
"""
app, _reg, _comm, replies, _drain = mkdir_setup
target = "/Users/corey/projects/dup"
replies[target] = {"status": "ok", "error": "directory already exists"}
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
resp = await client.post(
f"/v1/hosts/{_HOST_ID}/directories",
json={"path": target},
)
assert resp.status_code == 409
assert "already exists" in resp.json()["detail"]
async def test_create_directory_relative_path_rejected(
mkdir_setup: tuple[
FastAPI,
HostRegistry,
ApplicationCommunicator,
dict[str, dict[str, Any]],
asyncio.Task[None],
],
) -> None:
"""
A relative path is rejected with 400 before reaching the host.
The host needs a path it can resolve on its own; a relative path
has no stable meaning across host process cwds.
"""
app, _reg, _comm, _replies, _drain = mkdir_setup
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
resp = await client.post(
f"/v1/hosts/{_HOST_ID}/directories",
json={"path": "relative/dir"},
)
assert resp.status_code == 400
async def test_create_directory_unknown_host_returns_404(
mkdir_app: tuple[FastAPI, HostRegistry, HostStore, SqlAlchemyConversationStore],
) -> None:
"""
Creating under an unknown host returns 404 (don't leak existence).
"""
app, _reg, _hs, _cs = mkdir_app
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
resp = await client.post(
"/v1/hosts/host_does_not_exist/directories",
json={"path": "/tmp/x"},
)
assert resp.status_code == 404