mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-03 05:59:43 +02:00
Merge pull request #349 from opticon454/feature/docker-compose
Docker Compose deployment: Codeman runs in a container and spawns Docker cases as SIBLING containers through the mounted host socket (Docker-outside-of-Docker). Resolved the README conflict (master had grown to eight CLIs since the branch was cut) and moved the Compose blurb out of the feature bullets into Quick Start, next to the other ways of starting Codeman. Three review findings from the PR discussion are fixed here rather than left for a follow-up, because two of them are shipped-image problems: - `.dockerignore` excluded `.env` only at the ROOT. A pattern is matched against the whole context-relative path, so `docker/.env` — which the deployment's own README tells the user to fill with CODEMAN_PASSWORD and provider API keys — was picked up by `COPY . .` and baked into the image at /opt/codeman/docker/.env. Verified in both directions against a real build context: with a canary secret in docker/.env, the unfixed ignore file lets /ctx/docker/.env through, and `**/.env` (plus `**/.env.*` and a negation for the checked-in .env.example) leaves only the example behind. - `CODEMAN_CASES_PATH` moved the server's CASES_DIR but not the CLI's, which still hardcoded ~/codeman-cases, so `codeman skill install --case <name>` reported "Case not found" on exactly the deployment the override exists for. Both now resolve through config/cases-dir.ts. state-store.ts keeps its own literal on purpose: that one migrates the historical ~/claudeman-cases directory by name and is about the old default, not the active location. - CLAUDE.md gained the Compose paragraph (the sibling-container inversion, the three env vars, the .dockerignore and root-owned-bind traps) and .dockerignore joins the documented list of files that genuinely belong in the repo root. The PR's `mode === 'claude'` guard on dockerResumeId is an unrelated master bug fix riding along: appendResumeFlag() maps a resume id onto codex/gemini/pi/grok/ deepseek/omp/antigravity and RESUME_ID_SAFE accepts a UUID, so a Docker case's lastClaudeSessionId was handed to every non-claude CLI. Full gate green in a merge worktree: 6360 tests, lint, format, frontend syntax, public assets, lockfile.
This commit is contained in:
+4
-1
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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/<u>`
|
||||
* 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 <name>` 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);
|
||||
}
|
||||
+28
-5
@@ -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).
|
||||
|
||||
+27
-4
@@ -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<string, string> = {
|
||||
|
||||
@@ -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');
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user