diff --git a/AGENTS.md b/AGENTS.md index b2131ddfc7..fdd9b62572 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -152,6 +152,7 @@ - Should stay in sync with `DEFAULT_AGENT_SERVER_VERSION` in `dev-safe.mjs` for consistency between Docker and non-Docker dev modes - `OH_AGENT_SERVER_GIT_REF` — override to use a git ref-based tag (e.g., `main` → `main-python`, `abc1234` → `abc1234-python`) - Docker images are published from https://github.com/OpenHands/software-agent-sdk via the Agent Server workflow to `ghcr.io/openhands/agent-server` + - The container runs as the host UID/GID when Node exposes `process.getuid()` / `process.getgid()`. In the default isolated-home mode, `/home/openhands` is a writable tmpfs and persistence remains at `/home/openhands/.openhands`, so host-owned bind mounts remain writable without persisting ordinary home cache/config files. - Security: Both `scripts/dev-safe.mjs` and `scripts/dev-with-automation.mjs` auto-generate random API keys on each startup for better security isolation: - `SESSION_API_KEY` — 64-character hex (256-bit) for agent-server API authentication; auto-generated per session unless overridden via env var - `AUTOMATION_LOCAL_API_KEY` — 64-character hex for automation backend auth; auto-generated per session unless overridden diff --git a/README.md b/README.md index 500158ffcf..b456c21681 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ If you have questions or feedback, please open a GitHub issue or join the [#proj 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. Watch the video on how to run this on [Mac](https://www.youtube.com/watch?v=BenkkQmmFCg) or [Windows](https://www.youtube.com/watch?v=WAxf_RRIrB8). +By default the container runs as your host UID/GID so files written to bind mounts remain writable from your host account. The container is still kept isolated from your host home: its `/home/openhands` is a temporary writable home, and only `~/.openhands`, `~/.claude`, `~/.codex`, and `~/.ssh` are mounted individually under it (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. Watch the video on how to run this on [Mac](https://www.youtube.com/watch?v=BenkkQmmFCg) or [Windows](https://www.youtube.com/watch?v=WAxf_RRIrB8). ```sh export PROJECT_PATH=/path/to/your/projects diff --git a/__tests__/scripts/dev-docker.test.ts b/__tests__/scripts/dev-docker.test.ts index f920db9284..f4b5fb914f 100644 --- a/__tests__/scripts/dev-docker.test.ts +++ b/__tests__/scripts/dev-docker.test.ts @@ -1,7 +1,12 @@ import { describe, expect, it } from "vitest"; import { + CONTAINER_HOME_DIR, + CONTAINER_OPENHANDS_DIR, CONTAINER_WORKSPACES_DIR, + getDockerHomeTmpfsArgs, + getDockerUserArgs, + getHostDockerUserSpec, isDockerPermissionDenied, } from "../../scripts/dev-docker.mjs"; @@ -13,6 +18,40 @@ describe("CONTAINER_WORKSPACES_DIR", () => { }); }); +describe("docker host user", () => { + it("uses the current host uid/gid when the platform exposes them", () => { + if ( + typeof process.getuid !== "function" || + typeof process.getgid !== "function" + ) { + expect(getHostDockerUserSpec()).toBeNull(); + return; + } + + expect(getHostDockerUserSpec()).toBe( + `${process.getuid()}:${process.getgid()}`, + ); + }); + + it("keeps docker persistence under the standard agent home", () => { + expect(CONTAINER_HOME_DIR).toBe("/home/openhands"); + expect(CONTAINER_OPENHANDS_DIR).toBe("/home/openhands/.openhands"); + }); + + it("adds --user when a host uid/gid is available", () => { + expect(getDockerUserArgs("1000:1000")).toEqual(["--user", "1000:1000"]); + expect(getDockerUserArgs(null)).toEqual([]); + }); + + it("mounts a writable tmpfs home for the mapped host user", () => { + expect(getDockerHomeTmpfsArgs("1000:1000")).toEqual([ + "--tmpfs", + "/home/openhands:uid=1000,gid=1000,mode=700", + ]); + expect(getDockerHomeTmpfsArgs(null)).toEqual([]); + }); +}); + describe("isDockerPermissionDenied", () => { it("detects Linux docker socket permission failures", () => { expect( diff --git a/scripts/dev-docker.mjs b/scripts/dev-docker.mjs index f60b96b3e3..a33ce2e969 100644 --- a/scripts/dev-docker.mjs +++ b/scripts/dev-docker.mjs @@ -79,6 +79,13 @@ const AGENT_SERVER_REPO = "ghcr.io/openhands/agent-server"; const DEFAULT_AGENT_SERVER_TAG = "1.22.0-python"; const CONTAINER_NAME = "agent-canvas-dev-agent-server"; +// Keep the in-container home at the path advertised by the agent-server +// image. The default isolated-home launch overlays this path with tmpfs +// before mounting ~/.openhands below it, so OH_PERSISTENCE_DIR can stay at +// the conventional $HOME/.openhands instead of inventing a second home root. +const CONTAINER_HOME_DIR = "/home/openhands"; +const CONTAINER_OPENHANDS_DIR = `${CONTAINER_HOME_DIR}/.openhands`; + // Default secret key matches dev-safe.mjs so persisted settings stay // decryptable across docker / non-docker runs. const DEFAULT_SECRET_KEY = "openhands-dev-secret-key-change-in-prod"; @@ -89,8 +96,7 @@ const DEFAULT_SECRET_KEY = "openhands-dev-secret-key-change-in-prod"; // dir (which is `~/.openhands` on the host, mounted in below). The frontend // receives this via VITE_WORKING_DIR so the working_dir it sends to the // agent-server is one the container can actually mkdir. -const CONTAINER_WORKSPACES_DIR = - "/home/openhands/.openhands/agent-canvas/workspaces"; +const CONTAINER_WORKSPACES_DIR = `${CONTAINER_OPENHANDS_DIR}/agent-canvas/workspaces`; /** * Resolve the docker image to use based on environment. @@ -148,6 +154,49 @@ function logDockerInfoFailure(stderr) { logError("Start Docker (e.g. open Docker Desktop) and try again."); } +function getHostDockerUserSpec() { + if ( + typeof process.getuid !== "function" || + typeof process.getgid !== "function" + ) { + return null; + } + return `${process.getuid()}:${process.getgid()}`; +} + +function getDockerUserArgs(userSpec = getHostDockerUserSpec()) { + return userSpec ? ["--user", userSpec] : []; +} + +/** + * When `docker run --user :` is set, the process no + * longer runs as the image's `openhands` user. The image home directory is + * owned by that image user and has mode 0700, so the mapped host user cannot + * enter `/home/openhands` unless we replace or mutate it. + * + * We deliberately use a tmpfs overlay instead of: + * - chown/chmod: would require starting the container as root and adding a + * wrapper just to repair the image home before dropping privileges. + * - a custom home path: would make OH_PERSISTENCE_DIR stop looking like the + * normal $HOME/.openhands location and make future path reasoning harder. + * + * Cache/config writes that libraries place under $HOME stay ephemeral in this + * tmpfs. The only persisted default-home state is the explicit + * ~/.openhands -> /home/openhands/.openhands bind mount below. + */ +function getDockerHomeTmpfsArgs(userSpec = getHostDockerUserSpec()) { + if (!userSpec) { + return []; + } + + const [uid, gid] = userSpec.split(":"); + if (!uid || !gid) { + return []; + } + + return ["--tmpfs", `${CONTAINER_HOME_DIR}:uid=${uid},gid=${gid},mode=700`]; +} + /** * Check that the docker CLI is on PATH AND that the docker daemon is * actually responding. `commandExists("docker")` only verifies the binary is @@ -213,15 +262,10 @@ function startAgentServerDocker(config) { spawnSync("docker", ["rm", "-f", CONTAINER_NAME], { stdio: "ignore" }); const home = homedir(); - const dockerArgs = [ - "run", - "--rm", - "--name", - CONTAINER_NAME, - "--init", - "-v", - `${process.env.PROJECT_PATH}:/projects`, - ]; + const userSpec = getHostDockerUserSpec(); + const dockerArgs = ["run", "--rm", "--name", CONTAINER_NAME, "--init"]; + dockerArgs.push(...getDockerUserArgs(userSpec)); + dockerArgs.push("-v", `${process.env.PROJECT_PATH}:/projects`); // Bind-mount the local software-agent-sdk checkout if requested. Mounted // rw so editable installs can write their .dist-info into each package @@ -237,13 +281,15 @@ function startAgentServerDocker(config) { // 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`); + dockerArgs.push("-v", `${home}:${CONTAINER_HOME_DIR}`); } else { + dockerArgs.push(...getDockerHomeTmpfsArgs(userSpec)); + 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"], + [join(home, ".openhands"), CONTAINER_OPENHANDS_DIR], + [join(home, ".claude"), `${CONTAINER_HOME_DIR}/.claude`], + [join(home, ".codex"), `${CONTAINER_HOME_DIR}/.codex`], + [join(home, ".ssh"), `${CONTAINER_HOME_DIR}/.ssh`], ]; for (const [src, dest] of optionalMounts) { if (existsSync(src)) { @@ -260,10 +306,10 @@ function startAgentServerDocker(config) { // These mirror buildAgentServerEnv() from dev-safe.mjs but use paths // that exist inside the container (under the mounted ~/.openhands). const containerEnv = { - 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", + HOME: CONTAINER_HOME_DIR, + OH_CONVERSATIONS_PATH: `${CONTAINER_OPENHANDS_DIR}/agent-canvas/conversations`, + OH_PERSISTENCE_DIR: CONTAINER_OPENHANDS_DIR, + OH_BASH_EVENTS_DIR: `${CONTAINER_OPENHANDS_DIR}/agent-canvas/bash_events`, OH_SECRET_KEY: process.env.OH_SECRET_KEY || DEFAULT_SECRET_KEY, // Required so the secret-seeding PUT /api/settings/secrets call from // the host can authenticate against the agent-server in the container. @@ -326,11 +372,16 @@ if (isMainModule) { export { AGENT_SERVER_REPO, + CONTAINER_HOME_DIR, CONTAINER_LOCAL_SDK_DIR, CONTAINER_NAME, + CONTAINER_OPENHANDS_DIR, CONTAINER_WORKSPACES_DIR, DEFAULT_AGENT_SERVER_TAG, checkDockerPrereqs, + getDockerHomeTmpfsArgs, + getDockerUserArgs, + getHostDockerUserSpec, isDockerPermissionDenied, resolveAgentServerImage, startAgentServerDocker,