diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..ca5806c3 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,18 @@ +.git +.agents +.claude +.codex +# `**/` matters: a .dockerignore pattern is matched against the WHOLE +# context-relative path, so a bare `.env` excludes ONLY the root file and +# `COPY . .` would bake docker/.env -- CODEMAN_PASSWORD and any provider API +# keys -- into the published image at /opt/codeman/docker/.env (verified). +**/.env +**/.env.* +!**/.env.example +node_modules +dist +coverage +out +test-results +tmp +*.log diff --git a/CLAUDE.md b/CLAUDE.md index 60270bf8..d138605c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,7 +6,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co > > **This file is in `.prettierignore` on purpose.** Prettier's markdown printer escapes underscores inside the glob-heavy paths used throughout (`agent-*.jsonl` became `agent-\_.jsonl`, collapsing backtick spans and corrupting a whole paragraph). Do not remove the ignore entry, and do not run `prettier --write` on it. > -> **Repo root is kept short on purpose** (the README sits below the file listing on GitHub). Config lives in `config/` (`eslint.config.js`, `knip.json`, the vitest configs), Prettier's config is the `"prettier"` key in `package.json`, and `SECURITY.md` is under `.github/`. Root-only files are the ones tools genuinely require there: `CLAUDE.md` + `AGENTS.md` (loaded from the root by Claude Code / Codex), `CHANGELOG.md` (changesets writes it next to `package.json`), `tsconfig.json`, `.editorconfig`, `.nvmrc`/`.npmrc`, `.prettierignore` (resolved relative to cwd), `LICENSE` (GitHub detection) and `install.sh` (its raw URL is the published install one-liner). Don't relocate those. +> **Repo root is kept short on purpose** (the README sits below the file listing on GitHub). Config lives in `config/` (`eslint.config.js`, `knip.json`, the vitest configs), Prettier's config is the `"prettier"` key in `package.json`, and `SECURITY.md` is under `.github/`. Root-only files are the ones tools genuinely require there: `CLAUDE.md` + `AGENTS.md` (loaded from the root by Claude Code / Codex), `CHANGELOG.md` (changesets writes it next to `package.json`), `tsconfig.json`, `.editorconfig`, `.nvmrc`/`.npmrc`, `.prettierignore` (resolved relative to cwd), `LICENSE` (GitHub detection), `.dockerignore` (the build context is the repo root, so Docker resolves it there and nowhere else) and `install.sh` (its raw URL is the published install one-liner). Don't relocate those. ## Quick Reference @@ -211,6 +211,8 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **Docker cases**: a case can point at a **container**, with any of the CLI run modes running inside it. Like remote-SSH this is a **LOCATION OVERLAY on cases, never a `SessionMode` of its own**. Exactly one long-lived container **per case**, shared by all its sessions, so killing a session kills only that session's in-container tmux and **never** `docker stop` while siblings remain. The workspace is a real host dir bind-mounted at the **same absolute path**, which is what keeps file-routes/watchers on real host bytes and makes the in-container transcript projHash match the host. Credentials are **seeded** (RO mount, copied into the container once) rather than shared RW, so in-container CLIs never write refreshed tokens back to the host, and bind mounts are excluded from `docker commit` so exports stay secret-free. **NEVER a create-time `-e` for secrets, NEVER `--privileged`, NEVER the docker socket.** Config drift is detected via a label hash and a drifted launch is REFUSED rather than silently launched with stale config. ⚠️ On the loopback-only prod bind a container cannot reach 127.0.0.1, so in-container hooks need `CODEMAN_DOCKER_BRIDGE_HOOKS=1`; otherwise idle detection falls back to output-based. → [architecture-invariants#docker-cases](docs/architecture-invariants.md#docker-cases), `docs/docker-cases.md` (user guide), `docs/docker-cases-plan.md` (design) +**Docker Compose deployment** (`docker/`, contributed): Codeman itself runs in a container and spawns Docker cases as **SIBLING** containers through the mounted host socket (Docker-outside-of-Docker), never nested. That inverts one assumption the bare-host path takes for granted: the daemon no longer shares Codeman's filesystem, so a bind source valid *inside* Codeman means nothing to it. `resolveDockerDaemonMountSource()` translates sources under HOME into the daemon's namespace via `CODEMAN_DOCKER_HOST_HOME`, and `CODEMAN_CASES_PATH` points the cases dir at a host-absolute bind mount so a workspace resolves to the SAME absolute path on both sides (which is what keeps the transcript projHash matching, per Docker cases above). ⚠️ **`CODEMAN_CASES_PATH` must move every consumer or none**: it is resolved once in `config/cases-dir.ts` because `src/cli.ts` resolves case paths too, and when only the server's `CASES_DIR` learned the override, `codeman skill install --case ` reported "Case not found" on exactly the deployment the override exists for. ⚠️ **`.dockerignore` patterns match the WHOLE context-relative path**, so a bare `.env` line excludes only the ROOT file: `docker/.env` (which holds `CODEMAN_PASSWORD` and any provider keys) rode `COPY . .` into the image until `**/.env` was added — verified in both directions with a real build context. ⚠️ A Compose LONG-form bind (`type: bind`) **creates a missing host source directory ROOT-OWNED** rather than refusing, so any bind source the runtime user must write to has to be pre-created and chowned. `CODEMAN_DOCKER_DISABLE_SWAP_LIMIT=1` drops `--memory-swap` (and filters only that one kernel warning) for hosts without swap accounting; `--memory` still applies. `docs/docker-compose.md` + `docker/README.md` (user guides) + **External CLI modes (OpenCode, Codex, Gemini, Antigravity, Pi, Grok, DeepSeek, OMP)**: `isExternalCliMode()` in `session.ts` gates Claude-specific behavior off (Ralph tracker, BashToolParser, token/CLI-info parsing, ❯-prompt readiness); these CLIs render their own TUIs, so readiness is output stabilization instead. All eight **require tmux with no direct PTY fallback**, because secrets are injected via socket-scoped `tmux setenv` and never on the spawn command line. ⚠️ `run*()` in `session-ui.js` MUST unwrap the `{success,data}` envelope; reading the raw shape silently breaks the run. ⚠️ **Codex sessions use PREDICTIVE WRITE-THROUGH echo, never the buffer overlay** (`_localEchoPolicy` in `_updateLocalEchoState`, terminal-ui.js): codex's composer reacts per keystroke ("/" pops a live-filtering picker, arrows edit server-side state, the composer grows as it wraps), so buffer-until-Enter starved it into issues #218/#219/#220/#222 and stays disabled (`_localEchoEnabled` remains false for codex). Instead, `PredictiveEchoAddon` (separate `vendor/xterm-predictive-echo.js` bundle) paints each keystroke at the predicted cell while the wire path stays BYTE-IDENTICAL: the onData hook (`_predictHookOnData`) is a plain statement with no `return`, so control always falls through into the untouched send path — pinned by vm and E2E byte-identity tests. Predictions reconcile against the parsed buffer and only while the cursor sits on the measured composer row (`isCodexComposerRow`, `/^› /`). Codex also **drops keystrokes that share a PTY read with a bracketed paste**, so flushed text and the paste sequence must go out as separate delayed writes (mirroring the Enter branch's delayed `\r`). Tests: `test/local-echo-codex-gating.test.ts`, `test/codex-predictive-echo.test.ts` (E2E vs real codex), `packages/xterm-zerolag-input/test/codex-replay.test.ts`. ⚠️ **Pi is the opposite kind of CLI and needs the opposite instincts**: it has NO permission prompts and no sandbox, so there is no bypass flag to send and Codeman must not invent one; its privileged knob is the tri-state `approveProjectTrust` (`--approve`/`--no-approve`), which makes pi EXECUTE repo-local `.pi/extensions` TypeScript, so the multi-user clamp puts pi in the **materialize** branch (an absent config still yields `--no-approve` for a non-granted owner) and `--api-key` is never wired. Pi stays OUT of `isAltScreenStripMode()` (main-screen TUI, and its 0.84.0 fullscreen mode is runtime-switchable via `/settings`, where the alt screen is load-bearing), and lands on the `'buffer'` echo policy via the `_updateLocalEchoState` fallthrough. Pi's own tests: `test/pi-mode.test.ts`, `test/routes/external-cli-bypass-clamp.test.ts`; user guide `docs/pi-integration.md`. ⚠️ **Grok is codex-shaped on permissions but opencode-shaped on rendering**: its bypass switch is `alwaysApprove` (`--always-approve`, grok's `bypassPermissions` mode — the Run button sends it `true` like antigravity's, and the clamp's only-if-sent branch strips it for non-granted owners), while its fullscreen alt-screen TUI keeps it OUT of `isAltScreenStripMode()`; the resolver version-probes `grok --version` like pi's (npm squatters exist for the name — `GET /api/grok/status` surfaces path + version), and grok lands on the `'buffer'` echo policy via the fallthrough (UNMEASURED against a live authenticated session; if its composer turns out per-keystroke-reactive like codex, flip it to the `'off'` branch). Grok's own tests: `test/grok-mode.test.ts`, `test/grok-cli-resolver.test.ts`; user guide `docs/grok-integration.md`. ⚠️ **DeepSeek breaks three of this family's assumptions, so do not pattern-match it onto its siblings.** (1) The agent is a **PROFILE, not the binary**: `dsh` is a launcher over `$DSH_HOME/profiles/` and DeepSeek ships only `web`/`headless`/`base`, so the terminal front door is ALWAYS third-party and "installed" ≠ "runnable" — the Run button gates on `isDeepSeekRunnable()` (binary AND a pane-capable profile) while `isDeepSeekAvailable()` gates the "add a profile" affordance; a `web`/`headless` profile is refused at spawn because it cannot drive a pane. (2) The permission switch is the **`DSH_PERMISSION_MODE` env export, not a flag** (`read-only`/`workspace-write`/`danger-full-access`) — the harness has none, and this is the one legitimate exception to the effort-style env-var ban because it is read with `??` as a boot-time default, so it stays soft; absent = `workspace-write`, which asks, hence the only-if-sent clamp branch, clamping to `workspace-write` (never `read-only`, which would break the workspace). ⚠️ **That clamp needs a second half no other CLI needs**, because the switch is an env var and `DSH_*` is an allowlisted `envOverrides` prefix: `applyEnvOverrides()` runs AFTER `_configureDeepSeek()` in tmux-manager, so a non-granted owner sending `DSH_PERMISSION_MODE` on the SAME request would land last and hand back exactly the privilege the config clamp removed. `clampEnvOverridesForOwner()` (session-routes.ts) DROPS `DSH_PERMISSION_MODE`, `DSH_HOME` and `DEEPSEEK_BASE_URL` for a non-granted owner (the last because `_configureDeepSeek()` forwards the SERVER's own `DEEPSEEK_API_KEY` into the pane, so a redirected base URL would send it to a foreign host) (dropping falls through to what `_configureDeepSeek()` exports, which is the clamped value); `DSH_HOME` is there because it points the launcher at a profile tree whose plugin code runs at BOOT, before any approval row applies. Every OTHER CLI's bypass is a command-line flag reachable only through its config, which is why the config clamp alone is the whole gate for them. (3) It is the **only non-claude mode that passes `hooksAvailableForMode()`**, and for it alone that predicate is a per-SESSION question rather than a per-mode one (`deepSeekConfig.statusReporting: false` disarms the bridge, so every call site passes `sessionHookOptions(session)`; answering from the mode there re-creates the infinite-wait-dressed-as-a-timeout the guard exists to prevent). It passes because the terminal front door reports idle/working/blocked to a supervisor over a generic env-gated contract and `deepseek-status-shim.ts` makes Codeman that supervisor — real `stop`/`blocked` signals, real Approvals Inbox items, plus the `agent_working` event that clears an alert answered in the terminal. ⚠️ The resolver needs the strictest identity probe of the family (`dsh --help` must say `DeepSeek Harness`) because Debian ships an unrelated `dsh` (dancer's shell) that would pass a version probe. Model is NOT a session field (it is a profile composition entry). ⚠️ `hooksAvailableForMode()` is about hook SIGNALS and is not a stand-in for "is this a claude session": Read My Mind and intent capture read Claude's own transcript and compare `mode === 'claude'` directly, because when `deepseek` earned a yes the shared predicate silently widened both to a mode with no transcript to read (pinned by a static check in `test/deepseek-mode.test.ts`). ⚠️ **It is also the only external CLI whose answers are READ FROM DISK rather than scraped off the pane**: `deepseek-transcript.ts` reads `$DSH_HOME/sessions///session.jsonl.zstd` and backs the `last-response` route for dsh, because the pane segmenter served dsh-TUI's ASCII-art SPLASH as the worker's answer (measured), which anything polling for a first answer reads as an answer. Three traps live in that file: dsh appends **one zstd FRAME per write** and Node's `zlib` zstd decoder stops at the first (a real 56-line transcript decoded as 1 line, so the module walks frame headers itself; a Node older than 22.15 has no zstd and falls back to the pane); every turn also records a **plugin-sourced `user/message`** (the runtime-context snapshot) that must not render as the user's words; and a failed `turn/end` is surfaced as `Turn error: …` rather than as an empty string that reads as "still thinking". ⚠️ Session→transcript pairing is by the header's own `cwd` plus a ±60 s boot window, never by reproducing dsh's directory mangling (which has already changed form once) — and NEVER by newest-mtime alone, which handed a fresh worker its predecessor's answer in the same case dir. DeepSeek's own tests: `test/deepseek-mode.test.ts`, `test/deepseek-cli-resolver.test.ts`, `test/deepseek-transcript.test.ts`; user guide `docs/deepseek-integration.md`. OMP (`omp`) needs no bypass flag (the CLI's own `~/.omp` config governs trust/model routing, defaulting to `tools.approvalMode: yolo`), so `buildOmpCommand()` only ever passes `--model`/`--resume`/`--continue` — but the multi-user clamp is NOT a no-op for it: `OMP_*` is an allowlisted `envOverrides` prefix, and the two credential-resolution keys it admits, `OMP_AUTH_BROKER_URL`/`OMP_AUTH_BROKER_TOKEN`, are clamped in `clampEnvOverridesForOwner()` for a non-granted owner, the same shape as `DEEPSEEK_BASE_URL`. Separately, `PI_*` is already allowlisted (pi needs it) and omp reads several of its knobs too (`PI_CONFIG_DIR`, `PI_CODING_AGENT_DIR`, `PI_CODING_AGENT_SESSION_DIR`, `PI_SUBPROCESS_CMD`, `PI_SHELL_PREFIX`) — a redirected `PI_CONFIG_DIR` moves the `~/.omp` tree `omp-session-resolver.ts`/`omp-transcript.ts` hardcode, silently breaking pinning/history; this is a known gap shared with pi, not fixed here. → [architecture-invariants#external-cli-modes-opencode-codex-gemini-antigravity-pi-grok-deepseek-omp](docs/architecture-invariants.md#external-cli-modes-opencode-codex-gemini-antigravity-pi-grok-deepseek-omp) **DeepSeek web UI** (`POST`/`GET`/`DELETE /api/deepseek/web`, `deepseek-web-server.ts`): the Run menu's "DeepSeek web UI..." entry supervises ONE background `dsh web` child process, deliberately **NOT a shell session**. The session version worked and was still wrong in use: it put a terminal tab on screen next to the web tab the user actually asked for, every single time, and nothing about a long-lived HTTP server needs to be a tab. ⚠️ What a session gave for free now has to be paid for explicitly, and every piece is load-bearing: **exactly one** server (a second click REUSES it rather than racing it for a port, which two sessions structurally could not do), **restarted when the browser authority changes** (`--trusted-host` fences dsh's `/api` against the browser authority, and a Codeman reachable at both loopback and a tailnet name has two, so whoever asks last wins: the asker is by definition the origin about to load the page), **killed on shutdown** (`stopDeepSeekWeb()` in the server teardown, because the child is detached so its whole plugin tree can be signalled at once, which also means it would OUTLIVE Codeman and hold its port against the next start), and **failures returned to the caller**, since with no tab there is nowhere for a stack trace to land. ⚠️ The port search starts at dsh's own default 3080 and walks 40, never fixed: that default is precisely the port most likely to be taken already by the user's own `dsh web`, and hardcoding it killed this feature with EADDRINUSE once. Free-port detection BINDS rather than connects (a connect probe cannot tell "free" from "listening but not answering yet"), so it is racy by nature and the caller still waits for the server to really answer before reporting success. ⚠️ Both `POST` and `DELETE` sit at the **same privilege bar as the profile installer** (`canUsernameRunPrivilegedCommands`) even though the action reads as "open a page": booting a dsh profile executes the plugin code in it, and the server is a single shared instance, so stopping it in multi-user mode takes it out from under other users' tabs. diff --git a/README.md b/README.md index 2e58ee5e..683f226f 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,8 @@ codeman users add alice --admin # create the first admin account codeman web --multiuser # named logins + per-user case spaces ``` +**Prefer Docker Compose?** A local-image Compose deployment ships in `docker/`: copy `docker/.env.example` to `docker/.env`, set `CODEMAN_PASSWORD`, then run `bash docker/Start-Codeman.sh` on Linux. Codeman runs in a container and spawns Docker cases as sibling containers through the host socket. See the [Docker deployment guide](docker/README.md) for direct Compose commands, storage and networking options. + Details in [Multi-User Mode](#multi-user-mode-opt-in) below.
diff --git a/docker/.env.example b/docker/.env.example new file mode 100644 index 00000000..55000fc6 --- /dev/null +++ b/docker/.env.example @@ -0,0 +1,69 @@ +# ============================================================================= +# Codeman Docker Compose environment template +# Copy this file to .env and set the values for the Docker host. +# ============================================================================= + +TZ=Australia/Perth + +# Optional overrides for direct `docker compose` use. The Bash start script +# detects these values from CODEMAN_APPDATA_PATH automatically. Compose uses +# 1000:1000 when the variables are omitted. +# PUID=1000 +# PGID=1000 + +# Name of the account that runs Codeman and all local CLI sessions. Changing +# this value rebuilds the image with a matching account. +CODEMAN_RUNTIME_USER=opencode + +# Required. Persistent Codeman application data, CLI credentials, and session +# state are stored here on the host and mounted at the runtime account's home +# directory in the container. +CODEMAN_APPDATA_PATH=/mnt/user/appdata/Coding/codeman + +# Required for Docker cases. This must be an absolute path on the Docker host. +# Codeman and each isolated case use this same path, so it cannot be a +# container-only path such as /home/opencode/codeman-cases. +CODEMAN_CASES_PATH=/mnt/user/appdata/Coding/codeman/codeman-cases + +# Required. Network bind address, host port, and local image tag. +CODEMAN_HOST=0.0.0.0 +CODEMAN_PORT=3000 +CODEMAN_IMAGE=codeman:local + +# Required for any network-accessible Codeman instance. Use a unique, strong +# password. This file is safe to commit; copy it to .env and set the value. +CODEMAN_PASSWORD=changeme + +# Required. Username for Codeman HTTP Basic authentication. +CODEMAN_USERNAME=admin + +# Optional: authenticate Gemini CLI without an interactive login. +GEMINI_API_KEY= + +# Linux default. On Docker Desktop, use the socket path supported by your +# Docker installation when it differs from /var/run/docker.sock. +DOCKER_SOCKET=/var/run/docker.sock + +# Optional override for direct `docker compose` use. The Bash start script +# detects this from DOCKER_SOCKET automatically. The direct Compose default is +# 999, but the correct value depends on the Docker host. +# DOCKER_SOCKET_GID=999 + +# Set to 1 only when Docker-case hook callbacks are required. +CODEMAN_DOCKER_BRIDGE_HOOKS=0 + +# Set to 1 when `docker info` reports `SwapLimit=false`. The case memory limit +# remains active; Codeman omits --memory-swap and filters the daemon's exact +# unsupported-swap warning while preserving all other Docker create errors. +CODEMAN_DOCKER_DISABLE_SWAP_LIMIT=0 + +# Required only when applying the macvlan example in README.md. +CODEMAN_MACVLAN_NETWORK=br0.11 +CODEMAN_IPV4_ADDRESS=10.10.11.236 +CODEMAN_MAC_ADDRESS=02:10:11:00:00:EC + +# Required only when creating a new managed macvlan network, rather than using +# the external-network macvlan example. +CODEMAN_MACVLAN_PARENT=br0.11 +CODEMAN_MACVLAN_SUBNET=10.10.11.0/24 +CODEMAN_MACVLAN_GATEWAY=10.10.11.1 diff --git a/docker/README.md b/docker/README.md new file mode 100644 index 00000000..190896ab --- /dev/null +++ b/docker/README.md @@ -0,0 +1,96 @@ +# Codeman Docker deployment + +This folder contains the Compose configuration, server image Dockerfile, and environment template for a locally built Codeman server. + +## Start + +From the repository root, create the runtime environment file and set the required values, especially `CODEMAN_PASSWORD`. + +```sh +cp docker/.env.example docker/.env +bash docker/Start-Codeman.sh +``` + +On PowerShell, use the following command instead. + +```powershell +Copy-Item docker/.env.example docker/.env +docker compose --env-file docker/.env -f docker/docker-compose.yaml up --build -d +``` + +Every required value is defined and explained in `.env.example`. `GEMINI_API_KEY` is intentionally optional and may remain blank. + +On Linux, `Start-Codeman.sh` stops with an error when required paths are missing. It creates the application-data directory when safe, detects its numeric owner as `PUID:PGID`, and detects `DOCKER_SOCKET_GID` from the configured Docker socket. It rejects a root-owned application-data directory because Codeman and its local CLI sessions must remain unprivileged. + +Codeman, Claude, OpenCode, and other local sessions run as the unprivileged account named by `CODEMAN_RUNTIME_USER`, which defaults to `opencode`. When Compose is run directly, `PUID` and `PGID` default to `1000:1000`; set them in `.env` when the application-data directory has a different owner. The Bash start script determines them automatically instead. + +To retain Docker-case support without root when running Compose directly, set `DOCKER_SOCKET_GID` to the numeric group ID of the host socket. On a standard Linux Docker host, obtain it with `stat -c '%g' /var/run/docker.sock`. The Bash start script detects it automatically. + +## Application data storage + +The default configuration uses a host-folder bind mount: + +```yaml +volumes: + - type: bind + source: ${CODEMAN_APPDATA_PATH} + target: /home/${CODEMAN_RUNTIME_USER} +``` + +Set `CODEMAN_APPDATA_PATH` in `.env` to a directory that the Docker daemon can access. The example value is `/mnt/user/appdata/Coding/codeman`. + +`CODEMAN_CASES_PATH` is the separate host directory for managed case workspaces. It is mounted into Codeman at the same absolute path, allowing the host Docker daemon to bind it into an isolated case container. Set it to a child directory of `CODEMAN_APPDATA_PATH` unless you deliberately store workspaces elsewhere. + +Compose also exposes `CODEMAN_APPDATA_PATH` to Codeman as `CODEMAN_DOCKER_HOST_HOME`. This lets Docker case seed files, CLI credentials and the hook secret be mounted using paths that exist in the host daemon's filesystem. Direct host installations do not set this variable and retain their existing behaviour. + +Set `CODEMAN_DOCKER_DISABLE_SWAP_LIMIT=1` when `docker info` reports `SwapLimit=false`. Codeman continues to apply the configured case memory limit, omits Docker's unsupported `--memory-swap` option, and filters only the daemon's exact swap-capability warning. Every other Docker create error and its exit status remain visible. + +For an existing installation created by a root-running image, change ownership of the application-data directory before upgrading so the configured `PUID` and `PGID` can read the saved credentials and state: + +```sh +chown -R 99:100 /mnt/user/appdata/Coding/codeman +``` + +Replace `99:100` and the path with the values from your `.env` file. + +Do not replace this bind mount with a Docker-managed named volume when Docker cases are enabled. Codeman passes seed, credential, transcript and hook-secret bind sources to the host Docker daemon, so their source files must have stable paths in the daemon's filesystem. A named volume does not provide the required host path mapping. + +## Static macvlan networking + +The default configuration publishes a host port. It does not use `network_mode: host`. To attach Codeman directly to an existing external macvlan network with a static IP address and MAC address, remove the `ports:` section and add the following to the `codeman` service: + +```yaml +mac_address: ${CODEMAN_MAC_ADDRESS} +networks: + codeman_lan: + ipv4_address: ${CODEMAN_IPV4_ADDRESS} +``` + +Then add this top-level network declaration: + +```yaml +networks: + codeman_lan: + external: true + name: ${CODEMAN_MACVLAN_NETWORK} +``` + +Set `CODEMAN_MACVLAN_NETWORK`, `CODEMAN_IPV4_ADDRESS`, and `CODEMAN_MAC_ADDRESS` in `.env`. The values in `.env.example` match the supplied Unraid example network and should be changed for other hosts. + +### Create a managed macvlan network + +If an external macvlan network does not already exist, use this top-level declaration instead. Do not use it together with the external-network declaration. + +```yaml +networks: + codeman_lan: + driver: macvlan + driver_opts: + parent: ${CODEMAN_MACVLAN_PARENT} + ipam: + config: + - subnet: ${CODEMAN_MACVLAN_SUBNET} + gateway: ${CODEMAN_MACVLAN_GATEWAY} +``` + +Macvlan containers are ordinarily not reachable from their Docker host without additional host-network routing. Confirm the selected address, MAC address, parent interface, and subnet are reserved and valid for the target network before starting the stack. diff --git a/docker/Start-Codeman.sh b/docker/Start-Codeman.sh new file mode 100644 index 00000000..cb7a677e --- /dev/null +++ b/docker/Start-Codeman.sh @@ -0,0 +1,73 @@ +#!/usr/bin/env bash + +set -euo pipefail + +script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +env_file="$script_dir/.env" +compose_file="$script_dir/docker-compose.yaml" + +if [[ ! -f "$env_file" ]]; then + printf 'Error: Docker environment file is missing: %s\n' "$env_file" >&2 + printf 'Create it from %s/.env.example before starting Codeman.\n' "$script_dir" >&2 + exit 1 +fi + +compose_command=(docker compose --env-file "$env_file" -f "$compose_file") +appdata_path=$( + "${compose_command[@]}" config --environment | + awk -F= '$1 == "CODEMAN_APPDATA_PATH" { sub(/^[^=]*=/, ""); print; exit }' +) +docker_socket=$( + "${compose_command[@]}" config --environment | + awk -F= '$1 == "DOCKER_SOCKET" { sub(/^[^=]*=/, ""); print; exit }' +) + +if [[ -z "$appdata_path" ]]; then + printf 'Error: CODEMAN_APPDATA_PATH is not set in %s\n' "$env_file" >&2 + exit 1 +fi + +if [[ ! -d "$appdata_path" ]]; then + if [[ "$EUID" == '0' ]]; then + printf 'Error: Refusing to create CODEMAN_APPDATA_PATH as root: %s\n' "$appdata_path" >&2 + printf 'Create it as the unprivileged account that should run Codeman, then retry.\n' >&2 + exit 1 + fi + mkdir -p -- "$appdata_path" +fi + +if owner_ids=$(stat -c '%u:%g' -- "$appdata_path" 2>/dev/null); then + : +elif owner_ids=$(stat -f '%u:%g' "$appdata_path" 2>/dev/null); then + : +else + printf 'Error: Cannot determine the owner of CODEMAN_APPDATA_PATH: %s\n' "$appdata_path" >&2 + exit 1 +fi + +export PUID=${owner_ids%%:*} +export PGID=${owner_ids##*:} + +if [[ "$PUID" == '0' ]]; then + printf 'Error: CODEMAN_APPDATA_PATH is owned by root: %s\n' "$appdata_path" >&2 + printf 'Change the directory ownership to the unprivileged account that should run Codeman.\n' >&2 + exit 1 +fi + +if [[ -z "$docker_socket" || ! -S "$docker_socket" ]]; then + printf 'Error: DOCKER_SOCKET is not a Unix socket: %s\n' "${docker_socket:-}" >&2 + exit 1 +fi + +if socket_ids=$(stat -c '%u:%g' -- "$docker_socket" 2>/dev/null); then + : +elif socket_ids=$(stat -f '%u:%g' "$docker_socket" 2>/dev/null); then + : +else + printf 'Error: Cannot determine the owner of DOCKER_SOCKET: %s\n' "$docker_socket" >&2 + exit 1 +fi + +export DOCKER_SOCKET_GID=${socket_ids##*:} + +exec docker compose --env-file "$env_file" -f "$compose_file" up --build -d diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml new file mode 100644 index 00000000..2548ccb5 --- /dev/null +++ b/docker/docker-compose.yaml @@ -0,0 +1,65 @@ +name: codeman + +services: + codeman: + build: + context: .. + dockerfile: docker/server.Dockerfile + args: + CODEMAN_RUNTIME_USER: ${CODEMAN_RUNTIME_USER} + PGID: ${PGID:-1000} + PUID: ${PUID:-1000} + image: ${CODEMAN_IMAGE} + init: true + restart: unless-stopped + ports: + - "${CODEMAN_PORT}:${CODEMAN_PORT}" + environment: + CODEMAN_DOCKER_BRIDGE_HOOKS: ${CODEMAN_DOCKER_BRIDGE_HOOKS} + # Host-side equivalent of the runtime user's HOME. Docker case seed, + # credential and hook mounts are translated into the daemon namespace. + CODEMAN_DOCKER_HOST_HOME: ${CODEMAN_APPDATA_PATH} + CODEMAN_DOCKER_DISABLE_SWAP_LIMIT: ${CODEMAN_DOCKER_DISABLE_SWAP_LIMIT} + CODEMAN_CASES_PATH: ${CODEMAN_CASES_PATH} + CODEMAN_HOST: ${CODEMAN_HOST} + CODEMAN_PASSWORD: ${CODEMAN_PASSWORD} + CODEMAN_PORT: ${CODEMAN_PORT} + CODEMAN_USERNAME: ${CODEMAN_USERNAME} + GEMINI_API_KEY: ${GEMINI_API_KEY} + PGID: ${PGID:-1000} + PUID: ${PUID:-1000} + TZ: ${TZ} + group_add: + # Retain access to the host Docker socket without running as root. + - ${DOCKER_SOCKET_GID:-999} + volumes: + # Application data and CLI credentials persist on the configured host + # path, rather than in a Docker-managed volume. + - type: bind + source: ${CODEMAN_APPDATA_PATH} + target: /home/${CODEMAN_RUNTIME_USER} + # Docker cases are sibling containers on the host daemon. Their workspace + # must be visible to Codeman at the same absolute path used by that daemon. + - type: bind + source: ${CODEMAN_CASES_PATH} + target: ${CODEMAN_CASES_PATH} + # Codeman uses the host daemon to create isolated Docker cases. This is + # Docker-outside-of-Docker, not Docker-in-Docker. + - type: bind + source: ${DOCKER_SOCKET} + target: /var/run/docker.sock + extra_hosts: + - "host.docker.internal:host-gateway" + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + healthcheck: + test: + - CMD-SHELL + - >- + node -e "fetch('http://127.0.0.1:${CODEMAN_PORT}/api/status').then((response) => process.exit(response.status < 500 ? 0 : 1)).catch(() => process.exit(1))" + interval: 30s + timeout: 5s + retries: 3 + start_period: 30s diff --git a/docker/server.Dockerfile b/docker/server.Dockerfile new file mode 100644 index 00000000..bc6aaca7 --- /dev/null +++ b/docker/server.Dockerfile @@ -0,0 +1,94 @@ +# syntax=docker/dockerfile:1 + +# Build the application from the checkout supplied as the Docker build context. +# No published Codeman application image is required. +FROM node:22-bookworm-slim AS build + +RUN apt-get update \ + && apt-get install -y --no-install-recommends python3 make g++ \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /opt/codeman + +COPY . . + +RUN npm ci \ + && npm run build \ + && npm prune --omit=dev --ignore-scripts \ + && npm cache clean --force + +# The Docker CLI talks to the host daemon through the socket mounted by +# docker/docker-compose.yaml. It does not run a Docker daemon in this container. +FROM node:22-bookworm-slim + +ARG CODEMAN_RUNTIME_USER=opencode +ARG PUID=1000 +ARG PGID=1000 + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + ca-certificates \ + curl \ + docker.io \ + git \ + openssh-client \ + procps \ + ripgrep \ + tmux \ + && rm -rf /var/lib/apt/lists/* + +# Keep credentials out of the image. Users authenticate these CLIs at runtime +# through Codeman sessions, and the configured host bind mount retains state. +RUN npm install --global \ + @anthropic-ai/claude-code \ + @google/gemini-cli \ + @openai/codex \ + opencode-ai \ + && npm cache clean --force + +# Keep the web server and every local Codeman session unprivileged. PUID and +# PGID match the host-owned application-data directory mounted by Compose. The +# requested GID may not exist in the base image, and a host UID such as 1000 may +# already belong to the baked `node` account, so handle both cases explicitly. +RUN set -eux; \ + case "${PUID}" in ''|*[!0-9]*) echo "PUID must be numeric" >&2; exit 1;; esac; \ + case "${PGID}" in ''|*[!0-9]*) echo "PGID must be numeric" >&2; exit 1;; esac; \ + if [ "${PUID}" -eq 0 ]; then \ + echo "PUID must identify an unprivileged account, not root" >&2; \ + exit 1; \ + fi; \ + if ! getent group "${PGID}" >/dev/null; then \ + groupadd --gid "${PGID}" codeman-runtime; \ + fi; \ + existing_user="$(getent passwd "${PUID}" | cut -d: -f1 || true)"; \ + if [ -n "${existing_user}" ]; then \ + usermod \ + --login "${CODEMAN_RUNTIME_USER}" \ + --gid "${PGID}" \ + --home "/home/${CODEMAN_RUNTIME_USER}" \ + --move-home \ + --shell /bin/bash \ + "${existing_user}"; \ + else \ + useradd \ + --uid "${PUID}" \ + --gid "${PGID}" \ + --create-home \ + --home-dir "/home/${CODEMAN_RUNTIME_USER}" \ + --shell /bin/bash \ + "${CODEMAN_RUNTIME_USER}"; \ + fi + +WORKDIR /opt/codeman + +COPY --from=build /opt/codeman /opt/codeman + +ENV CODEMAN_PORT=3000 \ + HOME=/home/${CODEMAN_RUNTIME_USER} \ + NODE_ENV=production + +EXPOSE 3000 + +USER ${CODEMAN_RUNTIME_USER} + +CMD ["node", "dist/index.js", "web"] diff --git a/docs/docker-compose.md b/docs/docker-compose.md new file mode 100644 index 00000000..a86bdb00 --- /dev/null +++ b/docs/docker-compose.md @@ -0,0 +1,68 @@ +# Docker Compose deployment + +This configuration builds the Codeman application image locally from this checkout. It does not download or depend on a pre-built Codeman image. + +For the Compose configuration, environment settings, storage migration, and macvlan networking examples, see the [Docker deployment guide](../docker/README.md). + +The image includes Claude Code, Codex, Gemini CLI, and OpenCode. Authenticate a CLI from its Codeman session; credentials are never baked into the image. + +## Prerequisites + +- Docker Engine or Docker Desktop with Docker Compose v2 +- A reachable Docker daemon + +The application container mounts the Docker daemon socket so Codeman can create and manage its isolated Docker cases. Treat anyone who can administer this Compose project as having Docker-host-equivalent access. + +## Start + +Copy the environment template, set a strong password, and confirm `CODEMAN_APPDATA_PATH`. The example maps `/mnt/user/appdata/Coding/codeman` on the host to `/home/${CODEMAN_RUNTIME_USER}` in the container, preserving Codeman state and CLI credentials outside Docker-managed volumes. + +```sh +cp docker/.env.example docker/.env +``` + +On PowerShell, use the following command instead. + +```powershell +Copy-Item docker/.env.example docker/.env +``` + +On Linux, run the stack with the start script. It determines `PUID` and `PGID` from the owner of `CODEMAN_APPDATA_PATH`, and `DOCKER_SOCKET_GID` from the configured Docker socket, before invoking Compose. A root-owned application-data directory is rejected so the runtime account cannot become UID 0. + +```sh +bash docker/Start-Codeman.sh +``` + +On other platforms, run Compose directly. `PUID` and `PGID` default to `1000:1000`; set them in `docker/.env` when the application-data directory has a different owner. + +```sh +docker compose --env-file docker/.env -f docker/docker-compose.yaml up --build -d +``` + +Open `http://localhost:3000` and sign in with the username and password from `docker/.env`. + +## Operations + +The local image is tagged `codeman:local` by default. Change `CODEMAN_IMAGE` in `docker/.env` if a different local tag suits your environment. + +```sh +docker compose --env-file docker/.env -f docker/docker-compose.yaml logs -f codeman +bash docker/Start-Codeman.sh +docker compose --env-file docker/.env -f docker/docker-compose.yaml down +``` + +`CODEMAN_APPDATA_PATH` holds Codeman state and survives container recreation. Remove that host directory only when deliberately resetting the installation. + +`CODEMAN_CASES_PATH` must be an absolute path on the Docker host. Compose mounts it at the same path inside Codeman, so the host daemon can bind the managed workspace into isolated Docker cases. Do not set it to `/home/${CODEMAN_RUNTIME_USER}/codeman-cases`. + +Compose passes `CODEMAN_APPDATA_PATH` into Codeman as `CODEMAN_DOCKER_HOST_HOME`. Codeman uses that value to translate generated Docker seed, credential and hook-secret bind sources from the container's home path into paths visible to the host Docker daemon. + +If `docker info` reports `SwapLimit=false`, set `CODEMAN_DOCKER_DISABLE_SWAP_LIMIT=1`. Isolated cases retain their configured memory limit. Codeman omits the unsupported swap-limit option and filters only the daemon's exact swap-capability warning while retaining every other Docker create error. + +If that directory was created by an earlier root-running image, change its ownership to the configured `PUID:PGID` before starting this version. This preserves existing CLI credentials and session state while allowing the unprivileged runtime account to use them. + +## Docker cases + +The default socket path is `/var/run/docker.sock`, which works with a standard Linux Docker Engine. The Bash start script detects its numeric group ID. When running Compose directly, set `DOCKER_SOCKET_GID`, for example using `stat -c '%g' /var/run/docker.sock`, so the unprivileged `CODEMAN_RUNTIME_USER` account can create Docker cases. Docker Desktop users should set `DOCKER_SOCKET` in `docker/.env` only when their Docker installation exposes a different compatible socket path. + +Codeman Docker cases are sibling containers on the host daemon, not children of the application container. The Compose configuration handles their workspace bind mount through `CODEMAN_CASES_PATH`; the `/home/${CODEMAN_RUNTIME_USER}` application-data mapping is for Codeman state and ordinary in-container sessions, not sibling-case workspaces. diff --git a/src/cli.ts b/src/cli.ts index 7408f131..75a7e56b 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -15,6 +15,7 @@ import { existsSync, readFileSync } from 'node:fs'; import { isAbsolute, join } from 'node:path'; import { homedir } from 'node:os'; import { dataPath } from './config/instance.js'; +import { casePath } from './config/cases-dir.js'; import { installAgentSkillInto, removeAgentSkillFrom, type AgentSkillApplyResult } from './hooks-config.js'; import { getSessionManager } from './session-manager.js'; import { getTaskQueue } from './task-queue.js'; @@ -146,7 +147,9 @@ export function resolveCliCasePath(name: string): string { } catch { // no registry yet, or unreadable/invalid JSON: fall through to the cases dir } - return join(homedir(), 'codeman-cases', name); + // Same resolver the server uses, so CODEMAN_CASES_PATH (Docker Compose) moves + // the CLI's idea of a case with it instead of leaving it on the home default. + return casePath(name); } /** diff --git a/src/config/cases-dir.ts b/src/config/cases-dir.ts new file mode 100644 index 00000000..9cb8abaa --- /dev/null +++ b/src/config/cases-dir.ts @@ -0,0 +1,37 @@ +/** + * @fileoverview Where case (project) folders live. + * + * Deliberately NOT instance-scoped, unlike `dataPath()`: `~/codeman-cases` is + * shared by every Codeman on the machine, the same way `~/codeman-users/` + * user spaces are, so a beta instance sees the same projects as prod. + * + * `CODEMAN_CASES_PATH` overrides the location. Docker Compose deployments set + * it to a host-absolute bind mount so a Docker case's workspace resolves to the + * SAME absolute path inside Codeman and on the host daemon that mounts it. + * + * ⚠️ **One resolver, every caller.** This started life as three hardcoded + * `join(homedir(), 'codeman-cases')` copies. When only the web server's copy + * learned the override, `codeman skill install --case ` still looked in + * the home default and reported "Case not found" on exactly the deployment the + * override exists for. A new cases-dir consumer imports this; it does not + * rebuild the path. + * + * (`state-store.ts` keeps its own literal on purpose: that one migrates the + * historical `~/claudeman-cases` directory to `~/codeman-cases` by name, and is + * about the old default location rather than the active one.) + * + * @module config/cases-dir + */ + +import { homedir } from 'node:os'; +import { join } from 'node:path'; + +/** Absolute path to the shared cases directory. */ +export function getCasesDir(): string { + return process.env.CODEMAN_CASES_PATH || join(homedir(), 'codeman-cases'); +} + +/** Absolute path to one case folder inside it. */ +export function casePath(name: string): string { + return join(getCasesDir(), name); +} diff --git a/src/docker-hosts.ts b/src/docker-hosts.ts index ac4ffcd3..e78e0000 100644 --- a/src/docker-hosts.ts +++ b/src/docker-hosts.ts @@ -23,7 +23,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import fs from 'node:fs/promises'; -import { join, dirname } from 'node:path'; +import { dirname, isAbsolute, join, relative, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { homedir } from 'node:os'; import { createHash } from 'node:crypto'; @@ -277,6 +277,24 @@ export interface DockerMount { readonly?: boolean; } +/** + * Resolve a bind source into the Docker daemon's filesystem namespace. + * + * A bare-host Codeman process and its Docker daemon see the same HOME, so the + * source is returned unchanged. In Docker-outside-of-Docker deployments, + * `runtimeHome` is the path inside Codeman while `daemonHome` is the host path + * bind-mounted there. Sources beneath HOME must therefore be translated before + * they are sent through the Docker socket. + */ +export function resolveDockerDaemonMountSource(source: string, runtimeHome: string, daemonHome?: string): string { + const configuredDaemonHome = daemonHome?.trim(); + if (!configuredDaemonHome) return source; + + const relativeSource = relative(resolve(runtimeHome), resolve(source)); + if (relativeSource.startsWith('..') || isAbsolute(relativeSource)) return source; + return resolve(configuredDaemonHome, relativeSource); +} + /** * Resolved, IO-free context for buildDockerCreateArgs. The caller (tmux-manager) * resolves the environment-dependent bits (host uid, existing cred mounts, the @@ -300,6 +318,8 @@ export interface DockerCreateContext { addHostGateway: boolean; /** Engine host-gateway alias (host.docker.internal / host.containers.internal). */ gatewayAlias: string; + /** Omit --memory-swap when the host kernel cannot enforce swap limits. */ + disableSwapLimit?: boolean; } /** @@ -317,12 +337,15 @@ function mountSpec(m: DockerMount): string { return `type=bind,src=${m.src},dst=${m.dst}${m.readonly ? ',readonly' : ''}`; } -function resourceFlags(resources?: DockerResourceLimits): string[] { +function resourceFlags(resources?: DockerResourceLimits, disableSwapLimit = false): string[] { if (!resources) return []; const flags: string[] = []; if (resources.memory) { - // memory-swap == memory disables swap, making --memory a REAL OOM cap. - flags.push('--memory', resources.memory, '--memory-swap', resources.memory); + flags.push('--memory', resources.memory); + // memory-swap == memory disables swap where the daemon supports swap + // accounting. Some kernels, including the deployed Unraid host, do not; + // requesting it there emits a warning and Docker ignores the value. + if (!disableSwapLimit) flags.push('--memory-swap', resources.memory); } if (resources.cpus) flags.push('--cpus', resources.cpus); if (resources.pidsLimit) flags.push('--pids-limit', String(resources.pidsLimit)); @@ -387,7 +410,7 @@ export function buildDockerCreateArgs(ctx: DockerCreateContext): string[] { if (addHostGateway) args.push('--add-host', `${gatewayAlias}:host-gateway`); args.push( - ...resourceFlags(docker.resources), + ...resourceFlags(docker.resources, ctx.disableSwapLimit), // GPU passthrough (needs the NVIDIA container toolkit on the host). No storage // cap is set, so the container's writable layer + volumes grow elastically as // data flows in (bounded only by host disk). diff --git a/src/tmux-manager.ts b/src/tmux-manager.ts index 182db818..8d4d36be 100644 --- a/src/tmux-manager.ts +++ b/src/tmux-manager.ts @@ -75,6 +75,7 @@ import { hostGatewayAlias, resolveDockerClaudeArtifacts, resolveDockerCredentialArtifacts, + resolveDockerDaemonMountSource, type DockerCreateContext, type DockerMount, type DockerSeedCopy, @@ -1354,8 +1355,23 @@ export function buildDockerLaunchCommand(opts: DockerLaunchOptions): string { const startFailMsg = shellescape(`Codeman: container ${docker.containerName} failed to start (docker daemon down?)`); const imageCheck = `${base} image inspect ${image} >/dev/null 2>&1 || { echo ${imageMissingMsg}; exit 1; }`; - // create-if-missing (idempotent): reconnect / boot recovery re-runs this exact chain. - const ensure = `${base} inspect ${name} >/dev/null 2>&1 || ${base} ${createArgs}`; + // create-if-missing (idempotent): reconnect / boot recovery re-runs this exact + // chain. A daemon without swap accounting warns whenever --memory is present, + // even when --memory-swap is omitted. In compatibility mode, retain the memory + // cap and filter ONLY that exact warning; all other stdout/stderr and the real + // create exit status are preserved so mount/config failures remain visible. + // A session-unique file avoids shell variables and command substitution, both + // of which would be expanded too early by the nested bash/tmux launch layers. + const createOutputPath = shellescape(`/tmp/codeman-create-${sessionId}.log`); + const filteredCreateOutput = `sed '/^WARNING: Your kernel does not support swap limit capabilities or the cgroup is not mounted\\. Memory limited without swap\\.$/d' ${createOutputPath}`; + const removeCreateOutput = `rm -f ${createOutputPath}`; + const createCommand = createContext.disableSwapLimit + ? `{ if ${base} ${createArgs} >${createOutputPath} 2>&1; ` + + `then ${filteredCreateOutput}; ${removeCreateOutput}; ` + + `elif ${base} inspect ${name} >/dev/null 2>&1; then ${removeCreateOutput}; ` + + `else ${filteredCreateOutput} >&2; ${removeCreateOutput}; false; fi; }` + : `${base} ${createArgs}`; + const ensure = `${base} inspect ${name} >/dev/null 2>&1 || ${createCommand}`; const start = `${base} start ${name} >/dev/null 2>&1 || { echo ${startFailMsg}; exit 1; }`; // Seed writable credential config from read-only host mounts ONCE per container // (guarded by [ -e ] so reconnects never clobber in-container config; `cp -a` for @@ -1466,11 +1482,18 @@ export function resolveDockerLaunchOptions( sessionId, instance: CODEMAN_INSTANCE, userArgs, - credentialMounts, - extraMounts, + credentialMounts: credentialMounts.map((mount) => ({ + ...mount, + src: resolveDockerDaemonMountSource(mount.src, home, process.env.CODEMAN_DOCKER_HOST_HOME), + })), + extraMounts: extraMounts.map((mount) => ({ + ...mount, + src: resolveDockerDaemonMountSource(mount.src, home, process.env.CODEMAN_DOCKER_HOST_HOME), + })), envCreate, addHostGateway: !isDesktop, gatewayAlias, + disableSwapLimit: process.env.CODEMAN_DOCKER_DISABLE_SWAP_LIMIT === '1', }; const execEnv: Record = { diff --git a/src/web/route-helpers.ts b/src/web/route-helpers.ts index fb02fd61..43acfccd 100644 --- a/src/web/route-helpers.ts +++ b/src/web/route-helpers.ts @@ -8,7 +8,6 @@ import { join, resolve, relative, isAbsolute } from 'node:path'; import { realpathSync, existsSync, mkdirSync } from 'node:fs'; import fs from 'node:fs/promises'; -import { homedir } from 'node:os'; import type { z } from 'zod'; import type { FastifyReply, FastifyRequest } from 'fastify'; import { Session } from '../session.js'; @@ -21,12 +20,15 @@ import type { EventPort } from './ports/event-port.js'; import type { AuthSessionRecord } from './ports/auth-port.js'; import type { StaleExpirationMap } from '../utils/index.js'; import { dataPath } from '../config/instance.js'; +import { getCasesDir } from '../config/cases-dir.js'; import { isMultiUserMode, maxSessionsPerUser, userCasesDir } from '../config/multiuser.js'; import { SYNTHETIC_ADMIN, findUser } from '../user-store.js'; // Shared path constants used across route modules. CASES_DIR (project folders) // stays shared across instances; SETTINGS_PATH is per-instance runtime state. -export const CASES_DIR = join(homedir(), 'codeman-cases'); +// The cases dir is resolved in ONE place (config/cases-dir.ts) because the CLI +// resolves it too, and CODEMAN_CASES_PATH must move both or neither. +export const CASES_DIR = getCasesDir(); export const SETTINGS_PATH = dataPath('settings.json'); /** diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts index 9c9e7b40..84584990 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -3076,9 +3076,9 @@ export function registerSessionRoutes( casePath = dockerCase.hostWorkspacePath; // a REAL host dir (bind-mounted into the container) docker = sessionDocker; - // Seed resume so a relaunch resumes the case's last conversation from the - // bind-mounted transcript (decision: resume-on-start default ON). - if (sessionDocker.resumeOnStart && dockerCase.lastClaudeSessionId) { + // Seed only Claude's resume id. Codex, Gemini, and the other CLIs have + // separate conversation stores and must never receive a Claude UUID. + if (mode === 'claude' && sessionDocker.resumeOnStart && dockerCase.lastClaudeSessionId) { dockerResumeId = dockerCase.lastClaudeSessionId; } } else { diff --git a/test/cli-skill-target.test.ts b/test/cli-skill-target.test.ts index c25f5776..f3135116 100644 --- a/test/cli-skill-target.test.ts +++ b/test/cli-skill-target.test.ts @@ -19,6 +19,7 @@ import { homedir } from 'node:os'; import { join } from 'node:path'; import { dataPath } from '../src/config/instance.js'; import { program, resolveCliCasePath, resolveSkillTargetPath } from '../src/cli.js'; +import { getCasesDir } from '../src/config/cases-dir.js'; const LINKED_CASES_FILE = dataPath('linked-cases.json'); const CASES_DIR = join(homedir(), 'codeman-cases'); @@ -142,3 +143,36 @@ describe('skill command wiring', () => { } }); }); + +describe('cases dir override (CODEMAN_CASES_PATH)', () => { + // The Docker Compose deployment points Codeman at a host-absolute bind mount + // so a Docker case resolves to the same path inside the container and on the + // host daemon. The override shipped on the server's CASES_DIR only, which left + // the CLI looking in the home default: `codeman skill install --case ` + // then reported "Case not found" on exactly the deployment it exists for. + const saved = process.env.CODEMAN_CASES_PATH; + afterEach(() => { + if (saved === undefined) delete process.env.CODEMAN_CASES_PATH; + else process.env.CODEMAN_CASES_PATH = saved; + }); + + it('moves the CLI and the server together', () => { + process.env.CODEMAN_CASES_PATH = '/srv/codeman-cases'; + expect(getCasesDir()).toBe('/srv/codeman-cases'); + expect(resolveCliCasePath('demo')).toBe(join('/srv/codeman-cases', 'demo')); + }); + + it('falls back to the home default when unset', () => { + delete process.env.CODEMAN_CASES_PATH; + expect(getCasesDir()).toBe(CASES_DIR); + expect(resolveCliCasePath('demo')).toBe(join(CASES_DIR, 'demo')); + }); + + it('still lets a linked case win over the override', () => { + // The registry lookup runs first, so a case linked in from outside the cases + // dir keeps resolving to its real location under Compose too. + process.env.CODEMAN_CASES_PATH = '/srv/codeman-cases'; + writeLinkedCases(JSON.stringify({ linked: join(LINKED_ROOT, 'linked') })); + expect(resolveCliCasePath('linked')).toBe(join(LINKED_ROOT, 'linked')); + }); +}); diff --git a/test/docker-exec-options.test.ts b/test/docker-exec-options.test.ts index e89fc0ac..ad1c061d 100644 --- a/test/docker-exec-options.test.ts +++ b/test/docker-exec-options.test.ts @@ -80,6 +80,26 @@ describe('buildDockerLaunchCommand', () => { expect(cmd).toContain("docker start 'codeman-case-myproj'"); }); + it('avoids eager create expansion, tolerates a concurrent creator, and preserves real failures in compatibility mode', () => { + const opts = launchOpts(); + opts.createContext.disableSwapLimit = true; + const cmd = buildDockerLaunchCommand(opts); + // No command substitution or shell variables: either could expand eagerly + // before the inspect side of || short-circuits in a nested launch shell. + expect(cmd).not.toContain('$('); + expect(cmd).not.toContain('codeman_create_output'); + expect(cmd).toContain('if docker create'); + expect(cmd).toContain("'/tmp/codeman-create-1a2b3c4d5e6f.log'"); + // If another session created the case between inspect and create, re-inspect + // succeeds and the losing creator continues without printing the conflict. + expect(cmd).toContain("elif docker inspect 'codeman-case-myproj' >/dev/null 2>&1; then rm -f"); + expect(cmd).toContain('Your kernel does not support swap limit capabilities'); + expect(cmd).toContain('else sed'); + expect(cmd).toContain('>&2; rm -f'); + expect(cmd).toContain('; false; fi;'); + expect(cmd).not.toContain('--memory-swap'); + }); + it('execs a TTY into the durable in-container tmux', () => { const cmd = buildDockerLaunchCommand(launchOpts()); expect(cmd).toContain("exec docker exec -it --workdir '/home/arkon/cases/myproj'"); diff --git a/test/docker-hosts.test.ts b/test/docker-hosts.test.ts index f32afe76..ca2aee9d 100644 --- a/test/docker-hosts.test.ts +++ b/test/docker-hosts.test.ts @@ -32,6 +32,7 @@ import { resolveClaudeJsonSeedMount, resolveDockerClaudeArtifacts, resolveDockerCredentialArtifacts, + resolveDockerDaemonMountSource, toSessionDocker, writeDockerCases, writeDockerHosts, @@ -267,6 +268,32 @@ describe('buildDockerCreateArgs', () => { expect(s).not.toContain('--storage-opt'); expect(buildDockerCreateArgs(ctx()).join(' ')).not.toContain('--gpus'); }); + + it('omits the unsupported swap limit while retaining the memory limit when disabled', () => { + const s = buildDockerCreateArgs(ctx({ disableSwapLimit: true })).join(' '); + expect(s).toContain('--memory 4g'); + expect(s).not.toContain('--memory-swap'); + }); +}); + +describe('resolveDockerDaemonMountSource', () => { + const runtimeHome = join(tmpdir(), 'codeman-runtime-home'); + const daemonHome = join(tmpdir(), 'codeman-daemon-home'); + + it('maps paths beneath the runtime HOME into the daemon-visible HOME', () => { + const source = join(runtimeHome, '.codeman', 'docker-seeds', 'codeman-case-test1.json'); + expect(resolveDockerDaemonMountSource(source, runtimeHome, daemonHome)).toBe( + join(daemonHome, '.codeman', 'docker-seeds', 'codeman-case-test1.json') + ); + }); + + it('preserves direct-host and non-HOME sources', () => { + const source = join(runtimeHome, '.claude', 'settings.json'); + expect(resolveDockerDaemonMountSource(source, runtimeHome)).toBe(source); + + const outsideHome = join(tmpdir(), 'codeman-cases', 'test1'); + expect(resolveDockerDaemonMountSource(outsideHome, runtimeHome, daemonHome)).toBe(outsideHome); + }); }); describe('resolveDockerCredentialArtifacts (isolated codex/gemini/gcloud/opencode)', () => { diff --git a/test/routes/session-routes-workspace-hooks.test.ts b/test/routes/session-routes-workspace-hooks.test.ts index c6038aae..4957226d 100644 --- a/test/routes/session-routes-workspace-hooks.test.ts +++ b/test/routes/session-routes-workspace-hooks.test.ts @@ -19,19 +19,20 @@ * including the sweep's deleted-workspace guard. */ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import Fastify, { type FastifyInstance } from 'fastify'; import fastifyCookie from '@fastify/cookie'; import { mkdtemp, rm, readFile, mkdir, writeFile } from 'node:fs/promises'; import { existsSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; -import { createMockRouteContext } from '../mocks/index.js'; +import { createMockRouteContext, type MockRouteContext } from '../mocks/index.js'; import { installRouteErrorHandler } from '../../src/web/route-error-handler.js'; import { registerSessionRoutes } from '../../src/web/routes/session-routes.js'; import { generateHooksConfig, applyWorkspaceHooks } from '../../src/hooks-config.js'; import { getDataDir } from '../../src/config/instance.js'; import { CASES_DIR } from '../../src/web/route-helpers.js'; +import { Session } from '../../src/session.js'; interface HooksFile { hooks?: Record }>>; @@ -223,6 +224,7 @@ describe('POST /api/sessions workspace hooks', () => { describe('POST /api/quick-start workspace hooks', () => { let app: FastifyInstance; + let ctx: MockRouteContext; const quickStart = (payload: Record) => app.inject({ method: 'POST', url: '/api/quick-start', payload }); @@ -230,15 +232,19 @@ describe('POST /api/quick-start workspace hooks', () => { const hooksFileIn = (dir: string) => join(dir, '.claude', 'settings.local.json'); beforeEach(async () => { + vi.spyOn(Session.prototype, 'startInteractive').mockResolvedValue(undefined); + vi.spyOn(Session.prototype, 'startShell').mockResolvedValue(undefined); app = Fastify({ logger: false }); await app.register(fastifyCookie); - registerSessionRoutes(app, createMockRouteContext()); + ctx = createMockRouteContext(); + registerSessionRoutes(app, ctx); installRouteErrorHandler(app); await app.ready(); }); afterEach(async () => { await app.close(); + vi.restoreAllMocks(); // Docker fixtures + case dirs must not leak into the next test. await rm(join(getDataDir(), 'docker-hosts.json'), { force: true }); await rm(join(getDataDir(), 'docker-cases.json'), { force: true }); @@ -260,7 +266,7 @@ describe('POST /api/quick-start workspace hooks', () => { }); /** Minimal docker host + case fixtures (docker IO is no-op'd under vitest). */ - const writeDockerFixtures = async (caseName: string, hostWorkspacePath: string) => { + const writeDockerFixtures = async (caseName: string, hostWorkspacePath: string, lastClaudeSessionId?: string) => { await mkdir(getDataDir(), { recursive: true }); await writeFile( join(getDataDir(), 'docker-hosts.json'), @@ -268,7 +274,7 @@ describe('POST /api/quick-start workspace hooks', () => { ); await writeFile( join(getDataDir(), 'docker-cases.json'), - JSON.stringify([{ name: caseName, type: 'docker', hostId: 'd1', hostWorkspacePath }]) + JSON.stringify([{ name: caseName, type: 'docker', hostId: 'd1', hostWorkspacePath, lastClaudeSessionId }]) ); }; @@ -300,6 +306,35 @@ describe('POST /api/quick-start workspace hooks', () => { await rm(ws, { recursive: true, force: true }); } }); + + it.each(['codex', 'gemini'] as const)('does not pass a saved Claude conversation id to Docker %s', async (mode) => { + const ws = await mkdtemp(join(tmpdir(), `codeman-docker-${mode}-`)); + try { + await writeDockerFixtures('dockexternal', ws, 'e83a9063-3cb4-44d2-a9a0-df153b81721f'); + + const res = await quickStart({ caseName: 'dockexternal', mode }); + expect(res.statusCode).toBe(200); + const session = ctx.sessions.get(JSON.parse(res.body).sessionId); + expect(session?.toState().resumeSessionId).toBeUndefined(); + } finally { + await rm(ws, { recursive: true, force: true }); + } + }); + + it('passes a saved Claude conversation id only to Docker Claude', async () => { + const ws = await mkdtemp(join(tmpdir(), 'codeman-docker-resume-')); + const resumeId = 'e83a9063-3cb4-44d2-a9a0-df153b81721f'; + try { + await writeDockerFixtures('dockresume', ws, resumeId); + + const res = await quickStart({ caseName: 'dockresume', mode: 'claude' }); + expect(res.statusCode).toBe(200); + const session = ctx.sessions.get(JSON.parse(res.body).sessionId); + expect(session?.toState().resumeSessionId).toBe(resumeId); + } finally { + await rm(ws, { recursive: true, force: true }); + } + }); }); describe('applyWorkspaceHooks (the shared decision core in hooks-config)', () => {