mirror of
https://github.com/OpenHands/OpenHands.git
synced 2026-10-07 13:38:55 +08:00
Run Docker dev server with host user and tmpfs home (#443)
* Run Docker dev server as host user * Use tmpfs for Docker agent home * Document Docker tmpfs home rationale
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
+71
-20
@@ -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 <host uid>:<host gid>` 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,
|
||||
|
||||
Reference in New Issue
Block a user