fix(frontend): make Add Workspace modal dynamic and host-aware (#306)

* fix: make Add Workspace modal dynamic and host-aware

* fix: make OH_MOUNT_HOST_HOME opt-in and surface it from the modal
This commit is contained in:
Hiep Le
2026-05-11 19:57:56 +07:00
committed by GitHub
parent ccefb6cb04
commit 2212906fd5
7 changed files with 227 additions and 55 deletions
+4
View File
@@ -25,12 +25,15 @@ If you have questions or feedback, please open a GitHub issue or join the [#proj
### With Docker (recommended)
**Prerequisites**:
- Node.js 22.12.x or later
- `npm`
- Docker
Set `$PROJECT_PATH` to the directory on your machine where your projects live (e.g. `/path/to/your/projects`). The agent server will mount this directory so the agent can read and edit your code.
By default the container is kept isolated from your host home — only `~/.openhands`, `~/.claude`, `~/.codex`, and `~/.ssh` are mounted individually (and only if they exist). If you want the **Add Workspace** dialog to browse your real host filesystem, set `OH_MOUNT_HOST_HOME=1` before `npm run dev:docker` to bind-mount your entire host home onto `/home/openhands` in the container. The Add Workspace modal also shows this hint inline when it detects the mount is off.
```sh
export PROJECT_PATH=/path/to/your/projects
git clone https://github.com/OpenHands/agent-canvas.git
@@ -51,6 +54,7 @@ especially with respect to security hardening. Notably, you can run the backend
them from the same Agent Canvas frontend!
**Prerequisites**:
- Node.js 22.12.x or later
- `npm`
- `uv` (for running the agent server via `uvx`)
@@ -96,7 +96,11 @@ describe("WorkspaceSelectionForm", () => {
});
it("Add Workspace adds only the chosen folder (not its subfolders) and dedupes on repeat", async () => {
vi.spyOn(FilesService, "getHome").mockResolvedValue({ home: "/Users/me" });
vi.spyOn(FilesService, "getHome").mockResolvedValue({
home: "/Users/me",
favorites: [],
locations: [{ label: "/", path: "/" }],
});
const searchSpy = vi
.spyOn(FilesService, "searchSubdirs")
.mockImplementation(async (path: string) => {
@@ -258,9 +262,10 @@ describe("WorkspaceSelectionForm", () => {
.spyOn(FilesService, "searchSubdirs")
.mockResolvedValue({ items: [], next_page_id: null });
renderForm([], [
{ id: "custom-projects", name: "My Projects", path: "/projects" },
]);
renderForm(
[],
[{ id: "custom-projects", name: "My Projects", path: "/projects" }],
);
await waitFor(() => expect(searchSpy).toHaveBeenCalledTimes(1));
expect(searchSpy).toHaveBeenCalledWith("/projects");
@@ -330,7 +335,11 @@ describe("WorkspaceSelectionForm", () => {
});
it("Add all subdirectories saves a workspace parent and lists its children dynamically", async () => {
vi.spyOn(FilesService, "getHome").mockResolvedValue({ home: "/Users/me" });
vi.spyOn(FilesService, "getHome").mockResolvedValue({
home: "/Users/me",
favorites: [],
locations: [{ label: "/", path: "/" }],
});
const searchSpy = vi
.spyOn(FilesService, "searchSubdirs")
.mockImplementation(async (path: string) => {
@@ -457,4 +466,45 @@ describe("WorkspaceSelectionForm", () => {
within(refreshedDropdown).queryByText("repoB"),
).not.toBeInTheDocument();
});
it("Add Workspace sidebar renders backend-provided favorites dynamically and navigates into them on click", async () => {
// Arrange: backend reports a home with a custom favorite that did NOT
// exist in the old hardcoded list (Documents / Desktop / Downloads).
// This is the regression guard for the original 404-on-navigate bug.
vi.spyOn(FilesService, "getHome").mockResolvedValue({
home: "/Users/me",
favorites: [{ label: "projects", path: "/Users/me/projects" }],
locations: [{ label: "/", path: "/" }],
});
const searchSpy = vi
.spyOn(FilesService, "searchSubdirs")
.mockImplementation(async (path: string) => {
if (path === "/Users/me/projects") {
return {
items: [{ name: "repo1", path: "/Users/me/projects/repo1" }],
next_page_id: null,
};
}
return { items: [], next_page_id: null };
});
renderForm();
const user = userEvent.setup();
// Act: open the modal and click the dynamic favorite.
await user.click(screen.getByTestId("workspace-dropdown"));
await user.click(await screen.findByTestId("add-workspaces-button"));
await screen.findByTestId("folder-browser-modal");
await user.click(
await screen.findByTestId("folder-browser-sidebar-projects"),
);
// Assert: the dynamic favorite drove the navigation, and the previously
// hardcoded names are no longer present in the sidebar.
await screen.findByTestId("folder-browser-entry-repo1");
expect(searchSpy).toHaveBeenCalledWith("/Users/me/projects");
expect(
screen.queryByTestId("folder-browser-sidebar-documents"),
).not.toBeInTheDocument();
});
});
+90 -17
View File
@@ -18,6 +18,13 @@
* - OH_AGENT_SERVER_GIT_REF: Git ref (branch/tag/SHA) of the agent-server
* to use. Translates to the docker tag `${ref}-python`, e.g.
* `main` -> `ghcr.io/openhands/agent-server:main-python`.
* - OH_AGENT_SERVER_LOCAL_PATH: Absolute host path to a software-agent-sdk
* checkout. When set, mounts the checkout at /agent-server-src inside the
* container and reinstalls the four workspace packages
* (openhands-{sdk,tools,workspace,agent-server}) as editable installs on
* top of the image's pre-built venv before starting the server. Source
* edits on the host are reflected in the running container on module
* reload / process restart, matching the non-Docker dev loop.
*
* Optional credential mounts (only mounted when the host path exists):
* - ~/.openhands -> /home/openhands/.openhands (persistence)
@@ -25,6 +32,14 @@
* - ~/.codex -> /home/openhands/.codex (Codex credentials)
* - ~/.ssh -> /home/openhands/.ssh (git/ssh access)
*
* Optional host home mount (opt-in):
* Set `OH_MOUNT_HOST_HOME=1` to bind-mount your entire host home onto
* the container user's home at `/home/openhands`. This lets the
* "Add Workspace" file browser navigate your real host filesystem
* (and credentials/persistence dirs above are picked up automatically
* as subpaths). Off by default so the container stays isolated from
* the host home unless you opt in.
*
* Usage:
* PROJECT_PATH=/path/to/your/projects npm run dev:docker
* OH_AGENT_SERVER_GIT_REF=main PROJECT_PATH=... npm run dev:docker
@@ -46,6 +61,10 @@ import {
main,
spawnService,
} from "./dev-with-automation.mjs";
import { validateLocalAgentServerPath } from "./dev-safe.mjs";
// Path inside the container where OH_AGENT_SERVER_LOCAL_PATH is bind-mounted.
const CONTAINER_LOCAL_SDK_DIR = "/agent-server-src";
// Docker image for the agent-server.
const AGENT_SERVER_REPO = "ghcr.io/openhands/agent-server";
@@ -85,9 +104,7 @@ function suggestDockerless() {
"If you'd rather not use Docker, you can run the agent-server directly with:",
);
logError(" npm run dev:dangerously-dockerless");
logError(
"Note: this runs the agent with full access to your filesystem.",
);
logError("Note: this runs the agent with full access to your filesystem.");
}
/**
@@ -112,7 +129,9 @@ function checkDockerPrereqs(config) {
timeout: 10_000,
});
if (info.status !== 0) {
logError("docker is installed but the daemon does not appear to be running.");
logError(
"docker is installed but the daemon does not appear to be running.",
);
const stderr = info.stderr ? info.stderr.toString().trim() : "";
if (stderr) {
logError(` ${stderr.split("\n")[0]}`);
@@ -134,11 +153,26 @@ function checkDockerPrereqs(config) {
function startAgentServerDocker(config) {
const image = resolveAgentServerImage();
const localSdkPath = process.env.OH_AGENT_SERVER_LOCAL_PATH;
// Validate up-front so we fail fast before touching docker if the user
// pointed at a missing / incomplete checkout.
if (localSdkPath) {
validateLocalAgentServerPath(localSdkPath);
}
logService(
"agent-server",
`Starting in Docker on port ${config.agentServerPort} (image: ${image})...`,
c.blue,
);
if (localSdkPath) {
logService(
"agent-server",
`Using local SDK source: ${localSdkPath} (mounted at ${CONTAINER_LOCAL_SDK_DIR})`,
c.blue,
);
}
// Best-effort cleanup of any leftover container from a previous run.
spawnSync("docker", ["rm", "-f", CONTAINER_NAME], { stdio: "ignore" });
@@ -154,17 +188,32 @@ function startAgentServerDocker(config) {
`${process.env.PROJECT_PATH}:/projects`,
];
// Optional credential / state mounts. Only mount when the host path
// exists so docker doesn't auto-create empty directories on the host.
const optionalMounts = [
[join(home, ".openhands"), "/home/openhands/.openhands"],
[join(home, ".claude"), "/home/openhands/.claude"],
[join(home, ".codex"), "/home/openhands/.codex"],
[join(home, ".ssh"), "/home/openhands/.ssh"],
];
for (const [src, dest] of optionalMounts) {
if (existsSync(src)) {
dockerArgs.push("-v", `${src}:${dest}`);
// Bind-mount the local software-agent-sdk checkout if requested. Mounted
// rw so editable installs can write their .dist-info into each package
// (matches the side effect of the non-Docker uvx --with-editable path).
if (localSdkPath) {
dockerArgs.push("-v", `${localSdkPath}:${CONTAINER_LOCAL_SDK_DIR}`);
}
// Mount credentials / state individually by default so the container
// stays isolated from the host home. Opt in to bind-mounting the
// entire host home with OH_MOUNT_HOST_HOME=1 — useful when you want
// the Add Workspace file browser to navigate your real host
// filesystem (those credential subpaths come along automatically as
// part of the same mount).
if (process.env.OH_MOUNT_HOST_HOME === "1") {
dockerArgs.push("-v", `${home}:/home/openhands`);
} else {
const optionalMounts = [
[join(home, ".openhands"), "/home/openhands/.openhands"],
[join(home, ".claude"), "/home/openhands/.claude"],
[join(home, ".codex"), "/home/openhands/.codex"],
[join(home, ".ssh"), "/home/openhands/.ssh"],
];
for (const [src, dest] of optionalMounts) {
if (existsSync(src)) {
dockerArgs.push("-v", `${src}:${dest}`);
}
}
}
@@ -179,8 +228,7 @@ function startAgentServerDocker(config) {
OH_CONVERSATIONS_PATH:
"/home/openhands/.openhands/agent-canvas/conversations",
OH_PERSISTENCE_DIR: "/home/openhands/.openhands",
OH_BASH_EVENTS_DIR:
"/home/openhands/.openhands/agent-canvas/bash_events",
OH_BASH_EVENTS_DIR: "/home/openhands/.openhands/agent-canvas/bash_events",
TMUX_TMPDIR: "/home/openhands/.openhands/agent-canvas/tmux",
OH_SECRET_KEY: process.env.OH_SECRET_KEY || DEFAULT_SECRET_KEY,
// Required so the secret-seeding PUT /api/settings/secrets call from
@@ -191,8 +239,32 @@ function startAgentServerDocker(config) {
dockerArgs.push("-e", `${k}=${v}`);
}
// When using a local SDK checkout, override the image's entrypoint to
// reinstall the four workspace packages as editable on top of the baked-in
// venv, then exec the server. Reusing the image's venv avoids
// redownloading transitive deps; editable installs make host-side edits
// visible on the next module load (or container restart).
if (localSdkPath) {
dockerArgs.push("--entrypoint", "/bin/sh");
}
dockerArgs.push(image);
if (localSdkPath) {
const installCmd = [
"uv pip install",
"--python /agent-server/.venv/bin/python",
"--reinstall",
`-e ${CONTAINER_LOCAL_SDK_DIR}/openhands-sdk`,
`-e ${CONTAINER_LOCAL_SDK_DIR}/openhands-tools`,
`-e ${CONTAINER_LOCAL_SDK_DIR}/openhands-workspace`,
`-e ${CONTAINER_LOCAL_SDK_DIR}/openhands-agent-server`,
].join(" ");
const runCmd =
"exec /agent-server/.venv/bin/python -m openhands.agent_server --host 0.0.0.0 --port 8000";
dockerArgs.push("-c", `${installCmd} && ${runCmd}`);
}
spawnService("agent-server", "docker", dockerArgs, {
color: c.blue,
});
@@ -218,6 +290,7 @@ if (isMainModule) {
export {
AGENT_SERVER_REPO,
CONTAINER_LOCAL_SDK_DIR,
CONTAINER_NAME,
CONTAINER_WORKSPACES_DIR,
DEFAULT_AGENT_SERVER_TAG,
@@ -10,8 +10,15 @@ export interface SubdirectoryPage {
next_page_id: string | null;
}
export interface FileBrowserEntry {
label: string;
path: string;
}
export interface HomeResponse {
home: string;
favorites: FileBrowserEntry[];
locations: FileBrowserEntry[];
}
export interface SearchSubdirsOptions {
@@ -9,6 +9,7 @@ import {
useHomeDirectory,
useSearchSubdirs,
} from "#/hooks/query/use-search-subdirs";
import { useActiveBackend } from "#/contexts/active-backend-context";
import { cn } from "#/utils/utils";
import FolderIcon from "#/icons/folder.svg?react";
import ChevronLeft from "#/icons/chevron-left-small.svg?react";
@@ -79,25 +80,6 @@ function getParentPath(path: string): string | null {
return trimmed.slice(0, idx);
}
function buildSidebar(home: string | null): {
favorites: SidebarEntry[];
locations: SidebarEntry[];
} {
if (!home) {
return { favorites: [], locations: [{ label: "/", path: "/" }] };
}
const trimmed = home.replace(/\/+$/, "");
return {
favorites: [
{ label: "Home", path: trimmed },
{ label: "Desktop", path: `${trimmed}/Desktop` },
{ label: "Documents", path: `${trimmed}/Documents` },
{ label: "Downloads", path: `${trimmed}/Downloads` },
],
locations: [{ label: "/", path: "/" }],
};
}
export function FolderBrowserModal({
isOpen,
onClose,
@@ -106,6 +88,7 @@ export function FolderBrowserModal({
}: FolderBrowserModalProps) {
const { t } = useTranslation("openhands");
const [currentPath, setCurrentPath] = useState<string | null>(null);
const active = useActiveBackend();
const { data: homeData } = useHomeDirectory();
@@ -119,6 +102,12 @@ export function FolderBrowserModal({
}
}, [isOpen, homeData?.home, currentPath]);
// A backend switch invalidates the previous path — clear it so the
// open/close effect can re-seed from the new backend's homeData.home.
useEffect(() => {
setCurrentPath(null);
}, [active.backend.id, active.orgId]);
const {
data: listing,
isLoading,
@@ -126,16 +115,34 @@ export function FolderBrowserModal({
error,
} = useSearchSubdirs(isOpen ? currentPath : null);
const sidebar = useMemo(
() => buildSidebar(homeData?.home ?? null),
[homeData?.home],
);
const favorites: SidebarEntry[] = useMemo(() => {
if (!homeData?.home) return [];
const trimmed = homeData.home.replace(/[\\/]+$/, "") || homeData.home;
return [{ label: "Home", path: trimmed }, ...(homeData.favorites ?? [])];
}, [homeData]);
const locations: SidebarEntry[] = homeData?.locations ?? [];
if (!isOpen) return null;
const subdirs = listing?.items ?? [];
const parent = currentPath ? getParentPath(currentPath) : null;
// Signal that we're inside the dev:docker container without the host
// home mounted: the agent server reports `/home/openhands` as home and
// returns no favorites (the only contents are hidden credential dirs).
// In that case there's nothing useful for the user to browse, so we
// surface the OH_MOUNT_HOST_HOME=1 opt-in instead of the generic empty
// state. Off in production / non-Docker dev because favorites are
// populated there.
const showHostHomeHint =
homeData?.home === "/home/openhands" &&
(homeData?.favorites?.length ?? 0) === 0 &&
currentPath === homeData?.home &&
!isLoading &&
!isError &&
subdirs.length === 0;
const getBasename = (path: string): string => {
const trimmed = path.replace(/\/+$/, "");
if (!trimmed) return "/";
@@ -194,13 +201,13 @@ export function FolderBrowserModal({
>
<SidebarSection
label={t(I18nKey.HOME$FAVORITES)}
entries={sidebar.favorites}
entries={favorites}
currentPath={currentPath}
onPick={setCurrentPath}
/>
<SidebarSection
label={t(I18nKey.HOME$LOCATIONS)}
entries={sidebar.locations}
entries={locations}
currentPath={currentPath}
onPick={setCurrentPath}
/>
@@ -253,8 +260,17 @@ export function FolderBrowserModal({
</li>
)}
{!isLoading && !isError && subdirs.length === 0 && (
<li className="px-4 py-2 text-sm text-[#B7BDC2]">
{t(I18nKey.HOME$NO_WORKSPACES)}
<li
className="px-4 py-2 text-sm text-[#B7BDC2]"
data-testid={
showHostHomeHint
? "folder-browser-host-home-hint"
: "folder-browser-empty"
}
>
{showHostHomeHint
? t(I18nKey.HOME$HOST_HOME_NOT_MOUNTED_HINT)
: t(I18nKey.HOME$NO_WORKSPACES)}
</li>
)}
{subdirs.map((entry) => (
+11 -6
View File
@@ -1,20 +1,25 @@
import { useQuery } from "@tanstack/react-query";
import FilesService from "#/api/files-service/files-service.api";
import { useActiveBackend } from "#/contexts/active-backend-context";
export const useSearchSubdirs = (path: string | null) =>
useQuery({
queryKey: ["file", "search_subdirs", path],
export const useSearchSubdirs = (path: string | null) => {
const active = useActiveBackend();
return useQuery({
queryKey: ["file", "search_subdirs", path, active.backend.id, active.orgId],
queryFn: () => FilesService.searchSubdirs(path as string),
enabled: !!path,
retry: false,
meta: { disableToast: true },
});
};
export const useHomeDirectory = () =>
useQuery({
queryKey: ["file", "home"],
export const useHomeDirectory = () => {
const active = useActiveBackend();
return useQuery({
queryKey: ["file", "home", active.backend.id, active.orgId],
queryFn: () => FilesService.getHome(),
retry: false,
meta: { disableToast: true },
staleTime: Infinity,
});
};
+17
View File
@@ -16506,6 +16506,23 @@
"uk": "Робочих областей ще немає",
"ca": "Encara no hi ha espais de treball"
},
"HOME$HOST_HOME_NOT_MOUNTED_HINT": {
"en": "Your host filesystem isn't visible to the agent server. Set OH_MOUNT_HOST_HOME=1 before npm run dev:docker to browse it from here.",
"ja": "ホストのファイルシステムはエージェントサーバーから見えません。ここから参照するには、npm run dev:docker の前に OH_MOUNT_HOST_HOME=1 を設定してください。",
"zh-CN": "代理服务器看不到您的宿主机文件系统。请在运行 npm run dev:docker 之前设置 OH_MOUNT_HOST_HOME=1,即可在此浏览。",
"zh-TW": "代理伺服器看不到您的主機檔案系統。請在執行 npm run dev:docker 之前設定 OH_MOUNT_HOST_HOME=1,即可在此瀏覽。",
"ko-KR": "에이전트 서버에서 호스트 파일 시스템이 보이지 않습니다. 여기서 탐색하려면 npm run dev:docker 전에 OH_MOUNT_HOST_HOME=1을 설정하세요.",
"no": "Vertens filsystem er ikke synlig for agent-serveren. Sett OH_MOUNT_HOST_HOME=1 før npm run dev:docker for å bla gjennom det herfra.",
"it": "Il filesystem dell'host non è visibile al server dell'agente. Imposta OH_MOUNT_HOST_HOME=1 prima di npm run dev:docker per esplorarlo da qui.",
"pt": "O sistema de arquivos do host não está visível para o servidor do agente. Defina OH_MOUNT_HOST_HOME=1 antes de npm run dev:docker para navegá-lo daqui.",
"es": "El sistema de archivos del anfitrión no es visible para el servidor del agente. Establece OH_MOUNT_HOST_HOME=1 antes de npm run dev:docker para explorarlo desde aquí.",
"ar": "نظام ملفات المضيف غير مرئي لخادم الوكيل. اضبط OH_MOUNT_HOST_HOME=1 قبل npm run dev:docker لتصفحه من هنا.",
"fr": "Le système de fichiers de l'hôte n'est pas visible par le serveur de l'agent. Définissez OH_MOUNT_HOST_HOME=1 avant npm run dev:docker pour le parcourir d'ici.",
"tr": "Ana makine dosya sistemi ajan sunucusu tarafından görülmüyor. Buradan göz atmak için npm run dev:docker öncesinde OH_MOUNT_HOST_HOME=1 ayarlayın.",
"de": "Das Host-Dateisystem ist für den Agenten-Server nicht sichtbar. Setze OH_MOUNT_HOST_HOME=1 vor npm run dev:docker, um es hier zu durchsuchen.",
"uk": "Файлова система хоста не видима для сервера агента. Встановіть OH_MOUNT_HOST_HOME=1 перед npm run dev:docker, щоб переглядати її звідси.",
"ca": "El sistema de fitxers de l'amfitrió no és visible per al servidor de l'agent. Establiu OH_MOUNT_HOST_HOME=1 abans de npm run dev:docker per navegar-hi des d'aquí."
},
"HOME$NO_WORKSPACE_OPTION": {
"en": "No workspace",
"ja": "ワークスペースなし",