mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-03 22:19:42 +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:
@@ -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 <name>`
|
||||
// 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'));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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'");
|
||||
|
||||
@@ -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)', () => {
|
||||
|
||||
Reference in New Issue
Block a user