feat(docker): add Compose deployment support

This commit is contained in:
Devvyn
2026-08-27 19:38:38 +08:00
parent a51563ce1f
commit b85f7659b7
16 changed files with 631 additions and 18 deletions
+13
View File
@@ -0,0 +1,13 @@
.git
.agents
.claude
.codex
.env
offload.md
node_modules
dist
coverage
out
test-results
tmp
*.log
+3
View File
@@ -77,6 +77,9 @@ tmp/
# added there). No trailing slash so it still matches the symlink, not just dirs.
/public
# Machine-specific Docker Compose handover notes
/offload.md
# Opt-in gesture overlay runtime assets: large MediaPipe wasm + model (~27 MB)
# fetched at build/install by scripts/fetch-gesture-assets.mjs, kept out of git.
# (The gesture bundle itself, gesture-codeman.js, IS tracked — built from
+2
View File
@@ -42,6 +42,8 @@ codeman web
The installer asks before every system change, and re-running the same line updates in place. Full details: [Quick Start - Installation](#quick-start---installation).
Prefer Docker Compose? This repository includes a local-image Compose deployment: copy `docker/.env.example` to `docker/.env`, set `CODEMAN_PASSWORD`, then run `bash docker/Start-Codeman.sh` on Linux. See the [Docker deployment guide](docker/README.md) for direct Compose commands, storage, and networking options.
- **One dashboard, seven CLIs** - run [Claude Code, OpenCode, Codex, Antigravity, Gemini, Pi, or Grok](#more-features) per session (plus plain shell), locally, [in Docker](#isolated-docker-sessions), or [over SSH](#remote-ssh-sessions)
- **Truly phone-friendly** - a [touch-optimized terminal](#mobile-optimized-web-ui) with instant local echo, QR login, swipe navigation, and push notifications
- **Runs while you sleep** - [idle detection + respawn cycling](#respawn-controller) and auto-resume when a subscription limit resets, for 24+ hour unattended runs
+69
View File
@@ -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
+96
View File
@@ -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.
+73
View File
@@ -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:-<unset>}" >&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
+65
View File
@@ -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
+94
View File
@@ -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"]
+68
View File
@@ -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.
+28 -5
View File
@@ -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';
@@ -276,6 +276,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
@@ -299,6 +317,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;
}
/**
@@ -316,12 +336,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));
@@ -386,7 +409,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).
+27 -4
View File
@@ -74,6 +74,7 @@ import {
hostGatewayAlias,
resolveDockerClaudeArtifacts,
resolveDockerCredentialArtifacts,
resolveDockerDaemonMountSource,
type DockerCreateContext,
type DockerMount,
type DockerSeedCopy,
@@ -1319,8 +1320,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
@@ -1431,11 +1447,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<string, string> = {
+3 -1
View File
@@ -26,7 +26,9 @@ 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');
// Docker Compose deployments set CODEMAN_CASES_PATH to a host-absolute bind
// mount so Docker cases can share the same workspace path with the host daemon.
export const CASES_DIR = process.env.CODEMAN_CASES_PATH || join(homedir(), 'codeman-cases');
export const SETTINGS_PATH = dataPath('settings.json');
/**
+3 -3
View File
@@ -2994,9 +2994,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 {
+20
View File
@@ -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'");
+27
View File
@@ -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)', () => {
@@ -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<string, Array<{ matcher?: string; hooks?: Array<{ command?: string }> }>>;
@@ -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<string, unknown>) =>
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)', () => {