mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-06 15:39:41 +02:00
feat(docker): Docker session mode foundation (types, storage, tmux builders)
Phase 0-2 of the Docker cases feature (docs/docker-cases-plan.md). Docker is a LOCATION OVERLAY on cases (not a 6th SessionMode), mirroring the remote-SSH feature: a local tmux pane runs `docker exec -it` into a durable in-container tmux server. The container is per-CASE, so multiple sessions share it. - types: DockerHost/DockerCase/SessionDocker + docker? on SessionState/MuxSession - src/docker-hosts.ts: storage, toSessionDocker, pure buildDockerBaseArgs/ buildDockerCreateArgs (cap-drop, no-new-privileges, --pull=never, mem==swap, never privileged/socket), containerApiUrl, hostGatewayAlias, config-hash, credential-mount resolution, daemon probes (VITEST no-op) - schemas: DockerHostSchema + DockerCaseLinkSchema (NO_SHELL_META guards) - tmux-manager: buildDockerLaunchCommand (image-check -> ensure -> start -> exec, resume-aware), buildDockerKillCommand (in-container tmux only, multi-session safe), stop/remove; wired into createSession/respawnPane/killSession - 40 unit tests (docker-hosts + docker-exec-options), typecheck clean Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,570 @@
|
||||
/**
|
||||
* @fileoverview Docker cases: storage, pure command-arg builders, and daemon probes.
|
||||
*
|
||||
* Docker mode is a LOCATION OVERLAY on cases (not a 6th SessionMode), the direct
|
||||
* analog of the remote-SSH feature in `remote-hosts.ts`. Instead of a local tmux
|
||||
* pane running `ssh host` into a durable remote tmux server, a local tmux pane
|
||||
* runs `docker exec -it` into a durable IN-CONTAINER tmux server. The container is
|
||||
* scoped to the CASE (`codeman-case-<name>`), so multiple sessions can `docker
|
||||
* exec` into the same long-lived container.
|
||||
*
|
||||
* This module mirrors `remote-hosts.ts`:
|
||||
* - JSON storage for hosts (`docker-hosts.json`) and cases (`docker-cases.json`)
|
||||
* - `toSessionDocker()` (mirror of `toSessionRemote`)
|
||||
* - `buildDockerBaseArgs()` / `buildDockerCreateArgs()` (mirror of `buildSshConnectionArgs`)
|
||||
* - `checkDockerAvailable()` / `checkDockerTmuxAvailable()` (mirror of `checkRemoteTmuxAvailable`)
|
||||
*
|
||||
* The launch/kill command orchestration (`buildDockerLaunchCommand`,
|
||||
* `buildDockerKillCommand`, `dockerTmuxSessionName`) lives in `tmux-manager.ts`,
|
||||
* mirroring where `buildRemoteLaunchCommand` lives.
|
||||
*
|
||||
* @module docker-hosts
|
||||
*/
|
||||
|
||||
import { existsSync, mkdirSync } from 'node:fs';
|
||||
import fs from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { execFile } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
import type {
|
||||
DockerCase,
|
||||
DockerCommandMode,
|
||||
DockerEngine,
|
||||
DockerHost,
|
||||
DockerNetworkMode,
|
||||
DockerResourceLimits,
|
||||
SessionDocker,
|
||||
SessionMode,
|
||||
} from './types.js';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
/** Under vitest, all real `docker` invocations no-op (mirror of tmux-manager's IS_TEST_MODE). */
|
||||
const IS_TEST_MODE = !!process.env.VITEST;
|
||||
|
||||
const DOCKER_HOSTS_FILE = 'docker-hosts.json';
|
||||
const DOCKER_CASES_FILE = 'docker-cases.json';
|
||||
|
||||
/** Locally-built base image (see scripts/build-agent-image.mjs). */
|
||||
export const DEFAULT_AGENT_IMAGE = 'codeman/agent:base';
|
||||
|
||||
/** HOME inside the base image (the `agent` user). Cred mounts + hook-secret land under it. */
|
||||
export const CONTAINER_HOME = '/home/agent';
|
||||
|
||||
/** Per-case container name prefix. The `case` letters deliberately do NOT matter to
|
||||
* tmux; this is a DOCKER name (`^[a-zA-Z0-9][a-zA-Z0-9_.-]+$`), and case names are
|
||||
* already validated `^[a-zA-Z0-9_-]+$`, so `codeman-case-<name>` is always valid. */
|
||||
const CONTAINER_NAME_PREFIX = 'codeman-case-';
|
||||
|
||||
/** Sensible resource defaults (all overridable per host). */
|
||||
export const DEFAULT_DOCKER_RESOURCES: DockerResourceLimits = {
|
||||
memory: '4g',
|
||||
cpus: '2',
|
||||
pidsLimit: 512,
|
||||
nofile: '4096:8192',
|
||||
};
|
||||
|
||||
// ========== Storage (mirror of remote-hosts.ts) ==========
|
||||
|
||||
export function dockerHostsPath(configDir: string): string {
|
||||
return join(configDir, DOCKER_HOSTS_FILE);
|
||||
}
|
||||
|
||||
export function dockerCasesPath(configDir: string): string {
|
||||
return join(configDir, DOCKER_CASES_FILE);
|
||||
}
|
||||
|
||||
async function readJsonArray<T>(path: string): Promise<T[]> {
|
||||
try {
|
||||
const raw = await fs.readFile(path, 'utf-8');
|
||||
const parsed = JSON.parse(raw);
|
||||
return Array.isArray(parsed) ? (parsed as T[]) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function writeJsonArray<T>(configDir: string, path: string, value: T[]): Promise<void> {
|
||||
if (!existsSync(configDir)) mkdirSync(configDir, { recursive: true });
|
||||
await fs.writeFile(path, JSON.stringify(value, null, 2));
|
||||
}
|
||||
|
||||
export async function readDockerHosts(configDir: string): Promise<DockerHost[]> {
|
||||
return readJsonArray<DockerHost>(dockerHostsPath(configDir));
|
||||
}
|
||||
|
||||
export async function writeDockerHosts(configDir: string, hosts: DockerHost[]): Promise<void> {
|
||||
await writeJsonArray(configDir, dockerHostsPath(configDir), hosts);
|
||||
}
|
||||
|
||||
export async function readDockerCases(configDir: string): Promise<DockerCase[]> {
|
||||
return readJsonArray<DockerCase>(dockerCasesPath(configDir));
|
||||
}
|
||||
|
||||
export async function writeDockerCases(configDir: string, cases: DockerCase[]): Promise<void> {
|
||||
await writeJsonArray(configDir, dockerCasesPath(configDir), cases);
|
||||
}
|
||||
|
||||
// ========== Naming / display / defaults ==========
|
||||
|
||||
/** Per-case container name. Mirrors how remote derives a stable name from the case. */
|
||||
export function dockerContainerName(caseName: string): string {
|
||||
return `${CONTAINER_NAME_PREFIX}${caseName}`;
|
||||
}
|
||||
|
||||
/** Default pane command per CLI mode (mirror of defaultRemoteCommandForMode). */
|
||||
export function defaultDockerCommandForMode(mode: SessionMode): string {
|
||||
const commands: Record<DockerCommandMode, string> = {
|
||||
shell: 'exec bash -l',
|
||||
// Mirror the LOCAL claude default so the in-container agent runs non-interactively.
|
||||
claude: 'exec claude --dangerously-skip-permissions',
|
||||
opencode: 'exec opencode',
|
||||
codex: 'exec codex',
|
||||
gemini: 'exec gemini',
|
||||
};
|
||||
return commands[mode as DockerCommandMode] || commands.shell;
|
||||
}
|
||||
|
||||
/** `container:/workdir` display string (mirror of remoteDisplayPath's `user@host:path`). */
|
||||
export function dockerDisplayPath(
|
||||
docker: Pick<SessionDocker, 'containerName' | 'containerWorkdir'> | { container: string; path: string }
|
||||
): string {
|
||||
if ('containerName' in docker) return `${docker.containerName}:${docker.containerWorkdir}`;
|
||||
return `${docker.container}:${docker.path}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The host-callback gateway alias is ENGINE-SPECIFIC: Docker exposes the host as
|
||||
* `host.docker.internal`, Podman as `host.containers.internal`. Both are added to
|
||||
* the host-guard allowlist so a mixed fleet keeps working.
|
||||
*/
|
||||
export function hostGatewayAlias(engine: DockerEngine): string {
|
||||
return engine === 'podman' ? 'host.containers.internal' : 'host.docker.internal';
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrite the server's own `CODEMAN_API_URL` to a container-reachable one by
|
||||
* swapping ONLY the hostname for the engine's host-gateway alias, preserving
|
||||
* scheme AND port (prod is HTTPS on 3000, so hardcoding http://…:3000 breaks
|
||||
* every hook). Falls back to `https://<alias>:3000` when the input is absent or
|
||||
* unparseable.
|
||||
*/
|
||||
export function containerApiUrl(processApiUrl: string | undefined, engine: DockerEngine): string {
|
||||
const alias = hostGatewayAlias(engine);
|
||||
if (!processApiUrl) return `https://${alias}:3000`;
|
||||
try {
|
||||
const url = new URL(processApiUrl);
|
||||
url.hostname = alias;
|
||||
// origin drops any trailing path/slash and keeps scheme + (non-default) port
|
||||
return url.origin;
|
||||
} catch {
|
||||
return `https://${alias}:3000`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stable hash of the drift-relevant `docker create` inputs, stored on the
|
||||
* container as the `codeman.confighash` label. On launch, a mismatch between the
|
||||
* desired hash and the running container's label triggers the recreate-on-drift
|
||||
* prompt (host config edits actually take effect).
|
||||
*/
|
||||
export function dockerConfigHash(
|
||||
docker: Pick<
|
||||
SessionDocker,
|
||||
| 'engine'
|
||||
| 'image'
|
||||
| 'containerWorkdir'
|
||||
| 'network'
|
||||
| 'networkName'
|
||||
| 'resources'
|
||||
| 'mountCredentials'
|
||||
| 'extraCreateArgs'
|
||||
>
|
||||
): string {
|
||||
const normalized = JSON.stringify({
|
||||
engine: docker.engine,
|
||||
image: docker.image,
|
||||
containerWorkdir: docker.containerWorkdir,
|
||||
network: docker.network,
|
||||
networkName: docker.networkName ?? null,
|
||||
resources: docker.resources ?? null,
|
||||
mountCredentials: docker.mountCredentials,
|
||||
extraCreateArgs: docker.extraCreateArgs ?? null,
|
||||
});
|
||||
return createHash('sha256').update(normalized).digest('hex').slice(0, 12);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the flattened per-session Docker metadata from a host profile + a case,
|
||||
* resolving every default (mirror of toSessionRemote). The `configHash` is
|
||||
* computed last over the resolved values.
|
||||
*/
|
||||
export function toSessionDocker(host: DockerHost, dockerCase: DockerCase): SessionDocker {
|
||||
const engine: DockerEngine = host.engine ?? 'docker';
|
||||
const containerWorkdir = dockerCase.containerWorkdir ?? dockerCase.hostWorkspacePath;
|
||||
const base: Omit<SessionDocker, 'configHash'> = {
|
||||
hostId: host.id,
|
||||
label: host.label,
|
||||
engine,
|
||||
image: host.image || DEFAULT_AGENT_IMAGE,
|
||||
containerName: dockerCase.container ?? dockerContainerName(dockerCase.name),
|
||||
hostWorkspacePath: dockerCase.hostWorkspacePath,
|
||||
containerWorkdir,
|
||||
network: host.network ?? 'bridge',
|
||||
networkName: host.networkName,
|
||||
resources: host.resources ?? DEFAULT_DOCKER_RESOURCES,
|
||||
mountCredentials: host.mountCredentials ?? true,
|
||||
hooksEnabled: host.hooksEnabled ?? true,
|
||||
resumeOnStart: host.resumeOnStart ?? true,
|
||||
daemonHost: host.daemonHost,
|
||||
context: host.context,
|
||||
commands: host.commands,
|
||||
extraCreateArgs: host.extraCreateArgs,
|
||||
extraExecArgs: host.extraExecArgs,
|
||||
};
|
||||
return { ...base, configHash: dockerConfigHash(base) };
|
||||
}
|
||||
|
||||
// ========== Shell escaping ==========
|
||||
|
||||
/**
|
||||
* POSIX single-quote shell-escaping (end-quote, escaped-quote, restart-quote).
|
||||
* Mirror of the helper in remote-hosts.ts / tmux-manager.ts. Every dynamic value
|
||||
* interpolated into the outer `bash -c "..."` launch layer is escaped through
|
||||
* this so a path with spaces stays a single shell token. Operator-entered fields
|
||||
* are ALSO schema-rejected for `$`/backtick (NO_SHELL_META) as defense in depth.
|
||||
*/
|
||||
export function shellescape(str: string): string {
|
||||
return "'" + str.replace(/'/g, "'\\''") + "'";
|
||||
}
|
||||
|
||||
// ========== Pure command-arg builders ==========
|
||||
|
||||
/** A resolved bind mount (source existence already checked by the caller). */
|
||||
export interface DockerMount {
|
||||
src: string;
|
||||
dst: string;
|
||||
readonly?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolved, IO-free context for buildDockerCreateArgs. The caller (tmux-manager)
|
||||
* resolves the environment-dependent bits (host uid, existing cred mounts, the
|
||||
* derived api url, Desktop detection) so this builder stays pure and unit-testable.
|
||||
*/
|
||||
export interface DockerCreateContext {
|
||||
docker: SessionDocker;
|
||||
/** Codeman session id (only the first 8 chars are used, for the codeman.session label). */
|
||||
sessionId: string;
|
||||
/** CODEMAN_INSTANCE ('' for prod) — scopes the boot reaper so a beta never reaps prod. */
|
||||
instance: string;
|
||||
/** Pre-resolved uid/userns tokens: ['--user','1000:0'] | ['--userns','keep-id'] | []. */
|
||||
userArgs: string[];
|
||||
/** Existing host credential bind mounts (convenient mode). Empty in sealed mode. */
|
||||
credentialMounts: DockerMount[];
|
||||
/** Extra bind mounts (e.g. the read-only hook-secret file). */
|
||||
extraMounts: DockerMount[];
|
||||
/** Create-time env (NON-secret, committed-safe): HOME, TERM, COLORTERM, CODEMAN_API_URL, CODEMAN_HOOK_SECRET_FILE. */
|
||||
envCreate: Record<string, string>;
|
||||
/** Whether to add `--add-host <alias>:host-gateway` (skipped on Docker Desktop, where the alias is native). */
|
||||
addHostGateway: boolean;
|
||||
/** Engine host-gateway alias (host.docker.internal / host.containers.internal). */
|
||||
gatewayAlias: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Engine prefix tokens shared by every docker invocation (mirror of
|
||||
* buildSshConnectionArgs). Returns e.g. ['docker'] or ['podman','--context','ctx'].
|
||||
*/
|
||||
export function buildDockerBaseArgs(docker: Pick<SessionDocker, 'engine' | 'context' | 'daemonHost'>): string[] {
|
||||
const parts: string[] = [docker.engine === 'podman' ? 'podman' : 'docker'];
|
||||
if (docker.context) parts.push('--context', shellescape(docker.context));
|
||||
if (docker.daemonHost) parts.push('-H', shellescape(docker.daemonHost));
|
||||
return parts;
|
||||
}
|
||||
|
||||
function mountSpec(m: DockerMount): string {
|
||||
return `type=bind,src=${m.src},dst=${m.dst}${m.readonly ? ',readonly' : ''}`;
|
||||
}
|
||||
|
||||
function resourceFlags(resources?: DockerResourceLimits): 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);
|
||||
}
|
||||
if (resources.cpus) flags.push('--cpus', resources.cpus);
|
||||
if (resources.pidsLimit) flags.push('--pids-limit', String(resources.pidsLimit));
|
||||
if (resources.nofile) flags.push('--ulimit', `nofile=${resources.nofile}`);
|
||||
if (resources.shmSize) flags.push('--shm-size', resources.shmSize);
|
||||
return flags;
|
||||
}
|
||||
|
||||
function networkArg(network: DockerNetworkMode, networkName?: string): string {
|
||||
if (network === 'custom' && networkName) return networkName;
|
||||
return network; // 'bridge' | 'none'
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the `docker create` token list (from `create` through the `sleep
|
||||
* infinity` CMD) for a long-lived, hardened, per-case container. PURE: every
|
||||
* dynamic value is shellescaped; the caller joins with spaces into the launch
|
||||
* string. Security invariants baked in: --cap-drop ALL, --security-opt
|
||||
* no-new-privileges, --pids-limit, --memory==--memory-swap, --init,
|
||||
* --pull=never, --restart no, NEVER --privileged, NEVER the docker socket.
|
||||
*/
|
||||
export function buildDockerCreateArgs(ctx: DockerCreateContext): string[] {
|
||||
const {
|
||||
docker,
|
||||
sessionId,
|
||||
instance,
|
||||
userArgs,
|
||||
credentialMounts,
|
||||
extraMounts,
|
||||
envCreate,
|
||||
addHostGateway,
|
||||
gatewayAlias,
|
||||
} = ctx;
|
||||
|
||||
const args: string[] = [
|
||||
'create',
|
||||
'--name',
|
||||
shellescape(docker.containerName),
|
||||
'--label',
|
||||
'codeman.managed=1',
|
||||
'--label',
|
||||
shellescape(`codeman.instance=${instance}`),
|
||||
'--label',
|
||||
shellescape(`codeman.session=${sessionId.slice(0, 8)}`),
|
||||
'--label',
|
||||
shellescape(`codeman.confighash=${docker.configHash ?? dockerConfigHash(docker)}`),
|
||||
'--pull=never',
|
||||
'--init',
|
||||
'--restart',
|
||||
'no',
|
||||
...userArgs,
|
||||
'--workdir',
|
||||
shellescape(docker.containerWorkdir),
|
||||
// Workspace bind: mirror the host path inside the container so the transcript
|
||||
// projHash correlates and file features read real host bytes.
|
||||
'--mount',
|
||||
shellescape(mountSpec({ src: docker.hostWorkspacePath, dst: docker.containerWorkdir })),
|
||||
...credentialMounts.flatMap((m) => ['--mount', shellescape(mountSpec(m))]),
|
||||
...extraMounts.flatMap((m) => ['--mount', shellescape(mountSpec(m))]),
|
||||
];
|
||||
|
||||
if (addHostGateway) args.push('--add-host', `${gatewayAlias}:host-gateway`);
|
||||
|
||||
args.push(
|
||||
...resourceFlags(docker.resources),
|
||||
'--cap-drop',
|
||||
'ALL',
|
||||
'--security-opt',
|
||||
'no-new-privileges',
|
||||
'--network',
|
||||
networkArg(docker.network, docker.networkName)
|
||||
);
|
||||
|
||||
for (const [key, value] of Object.entries(envCreate)) {
|
||||
args.push('--env', shellescape(`${key}=${value}`));
|
||||
}
|
||||
|
||||
// Operator escape-hatch args (schema-validated NO_SHELL_INJECTION), escaped again here.
|
||||
for (const extra of docker.extraCreateArgs ?? []) {
|
||||
args.push(shellescape(extra));
|
||||
}
|
||||
|
||||
args.push(shellescape(docker.image), 'sleep', 'infinity');
|
||||
return args;
|
||||
}
|
||||
|
||||
// ========== Credential mount resolution (IO) ==========
|
||||
|
||||
/** Host cred paths mapped to their in-container HOME location. */
|
||||
const CREDENTIAL_PATHS: Array<{ rel: string }> = [
|
||||
{ rel: '.claude' },
|
||||
{ rel: '.claude.json' },
|
||||
{ rel: '.codex' },
|
||||
{ rel: '.gemini' },
|
||||
{ rel: '.config/gcloud' },
|
||||
{ rel: '.config/opencode' },
|
||||
];
|
||||
|
||||
/**
|
||||
* Resolve which host credential dirs/files EXIST and map them to their container
|
||||
* HOME location. Only-existing avoids docker auto-creating root-owned empty dirs
|
||||
* in the user's home. `~/.claude` also carries the transcripts (bind-mounted so
|
||||
* host watchers + `--resume` see them) and is therefore mounted read-WRITE.
|
||||
*/
|
||||
export function resolveCredentialMounts(home: string = homedir()): DockerMount[] {
|
||||
const mounts: DockerMount[] = [];
|
||||
for (const { rel } of CREDENTIAL_PATHS) {
|
||||
const src = join(home, rel);
|
||||
if (existsSync(src)) {
|
||||
mounts.push({ src, dst: `${CONTAINER_HOME}/${rel}` });
|
||||
}
|
||||
}
|
||||
return mounts;
|
||||
}
|
||||
|
||||
// ========== Daemon probes (IO; no-op under VITEST) ==========
|
||||
|
||||
export interface DockerAvailability {
|
||||
ok: boolean;
|
||||
engine: DockerEngine;
|
||||
rootless: boolean;
|
||||
isDesktop: boolean;
|
||||
cgroupV2: boolean;
|
||||
/** Best-effort: are --memory/--cpus/--pids-limit actually enforced on this engine? */
|
||||
capsEnforced: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
const DOCKER_PROBE_TIMEOUT_MS = 15_000;
|
||||
|
||||
interface DockerInfoJson {
|
||||
ServerVersion?: string;
|
||||
CgroupVersion?: string;
|
||||
SecurityOptions?: string[];
|
||||
OperatingSystem?: string;
|
||||
OSType?: string;
|
||||
Name?: string;
|
||||
}
|
||||
|
||||
async function runDockerInfo(engine: DockerEngine): Promise<DockerInfoJson | null> {
|
||||
try {
|
||||
const { stdout } = await execFileAsync(engine, ['info', '--format', '{{json .}}'], {
|
||||
timeout: DOCKER_PROBE_TIMEOUT_MS,
|
||||
});
|
||||
return JSON.parse(stdout) as DockerInfoJson;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function classifyDockerInfo(engine: DockerEngine, info: DockerInfoJson): DockerAvailability {
|
||||
const security = info.SecurityOptions ?? [];
|
||||
const rootless = security.some((opt) => opt.includes('rootless'));
|
||||
const cgroupV2 = info.CgroupVersion === '2';
|
||||
const os = `${info.OperatingSystem ?? ''}`.toLowerCase();
|
||||
const isDesktop = os.includes('docker desktop') || os.includes('desktop');
|
||||
// Under rootless, resource caps are only reliably enforced with cgroup v2 +
|
||||
// systemd delegation. We can't detect delegation from `docker info`, so we
|
||||
// treat rootless+cgroupv2 as "likely enforced" and rootless+cgroupv1 as not.
|
||||
const capsEnforced = !rootless || cgroupV2;
|
||||
return { ok: true, engine, rootless, isDesktop, cgroupV2, capsEnforced };
|
||||
}
|
||||
|
||||
/**
|
||||
* Probe the container engine: server up, cgroup version, rootless, Desktop, and
|
||||
* whether resource caps are enforceable. Auto-detects docker then podman when no
|
||||
* engine is given. No-op canned value under VITEST.
|
||||
*/
|
||||
export async function checkDockerAvailable(engine?: DockerEngine): Promise<DockerAvailability> {
|
||||
if (IS_TEST_MODE) {
|
||||
return {
|
||||
ok: true,
|
||||
engine: engine ?? 'docker',
|
||||
rootless: false,
|
||||
isDesktop: false,
|
||||
cgroupV2: true,
|
||||
capsEnforced: true,
|
||||
};
|
||||
}
|
||||
const candidates: DockerEngine[] = engine ? [engine] : ['docker', 'podman'];
|
||||
for (const candidate of candidates) {
|
||||
const info = await runDockerInfo(candidate);
|
||||
if (info) return classifyDockerInfo(candidate, info);
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
engine: engine ?? 'docker',
|
||||
rootless: false,
|
||||
isDesktop: false,
|
||||
cgroupV2: false,
|
||||
capsEnforced: false,
|
||||
error: 'Docker/Podman not available. Install docker (or podman) and ensure the daemon is running.',
|
||||
};
|
||||
}
|
||||
|
||||
/** Is the base image present locally? (never triggers an auto-pull). */
|
||||
export async function checkDockerImagePresent(engine: DockerEngine, image: string): Promise<boolean> {
|
||||
if (IS_TEST_MODE) return true;
|
||||
try {
|
||||
await execFileAsync(engine, ['image', 'inspect', '--format', '{{.Id}}', image], {
|
||||
timeout: DOCKER_PROBE_TIMEOUT_MS,
|
||||
});
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export interface DockerTmuxCheckResult {
|
||||
ok: boolean;
|
||||
tmuxPath?: string;
|
||||
/** Distinguishes "image missing" (build it) from "tmux missing in image" (rebuild it). */
|
||||
imageMissing?: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify the base image is present AND contains tmux (a HARD prerequisite: the
|
||||
* in-container tmux is what makes reconnect durable). Never triggers a pull
|
||||
* (`--pull=never`). No-op under VITEST. Mirror of checkRemoteTmuxAvailable.
|
||||
*/
|
||||
export async function checkDockerTmuxAvailable(
|
||||
docker: Pick<SessionDocker, 'engine' | 'image'>
|
||||
): Promise<DockerTmuxCheckResult> {
|
||||
if (IS_TEST_MODE) return { ok: true, tmuxPath: '/usr/bin/tmux' };
|
||||
const engine = docker.engine;
|
||||
if (!(await checkDockerImagePresent(engine, docker.image))) {
|
||||
return {
|
||||
ok: false,
|
||||
imageMissing: true,
|
||||
error: `base image ${docker.image} not present: build it with 'node scripts/build-agent-image.mjs' (or pull it)`,
|
||||
};
|
||||
}
|
||||
try {
|
||||
const { stdout } = await execFileAsync(
|
||||
engine,
|
||||
['run', '--rm', '--pull=never', docker.image, 'sh', '-lc', 'command -v tmux'],
|
||||
{ timeout: DOCKER_PROBE_TIMEOUT_MS }
|
||||
);
|
||||
const tmuxPath = stdout.trim();
|
||||
if (!tmuxPath) {
|
||||
return { ok: false, error: `base image ${docker.image} is missing tmux (required for durable sessions)` };
|
||||
}
|
||||
return { ok: true, tmuxPath };
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
return { ok: false, error: `could not verify tmux in ${docker.image}: ${msg}` };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the IN-CONTAINER Claude CLI version (`docker exec <container> claude
|
||||
* --version`). Feeds Session.cliVersion for docker sessions (the LOCAL claude
|
||||
* would report the wrong version and disable trackpad wheel-forwarding, #154).
|
||||
* Returns undefined on any failure. No-op under VITEST.
|
||||
*/
|
||||
export async function probeDockerCliVersion(
|
||||
docker: Pick<SessionDocker, 'engine' | 'containerName'>,
|
||||
mode: SessionMode
|
||||
): Promise<string | undefined> {
|
||||
if (IS_TEST_MODE) return undefined;
|
||||
const bin = mode === 'shell' ? null : mode;
|
||||
if (!bin) return undefined;
|
||||
try {
|
||||
const { stdout } = await execFileAsync(docker.engine, ['exec', docker.containerName, bin, '--version'], {
|
||||
timeout: DOCKER_PROBE_TIMEOUT_MS,
|
||||
});
|
||||
const match = stdout.trim().match(/\d+\.\d+\.\d+/);
|
||||
return match ? match[0] : stdout.trim() || undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import type {
|
||||
EffortLevel,
|
||||
GeminiConfig,
|
||||
SessionRemote,
|
||||
SessionDocker,
|
||||
} from './types.js';
|
||||
|
||||
/**
|
||||
@@ -36,6 +37,8 @@ export interface MuxSession {
|
||||
workingDir: string;
|
||||
/** Remote execution metadata for local tmux sessions wrapping SSH */
|
||||
remote?: SessionRemote;
|
||||
/** Docker execution metadata for local tmux sessions wrapping `docker exec` */
|
||||
docker?: SessionDocker;
|
||||
/** Session mode */
|
||||
mode: SessionMode;
|
||||
/** Whether webserver is attached to this session */
|
||||
@@ -79,6 +82,8 @@ export interface CreateSessionOptions {
|
||||
historyLimit?: number;
|
||||
/** Remote execution metadata for local tmux sessions wrapping SSH */
|
||||
remote?: SessionRemote;
|
||||
/** Docker execution metadata for local tmux sessions wrapping `docker exec` */
|
||||
docker?: SessionDocker;
|
||||
}
|
||||
|
||||
/** Options for respawning a dead pane. */
|
||||
@@ -103,6 +108,8 @@ export interface RespawnPaneOptions {
|
||||
historyLimit?: number;
|
||||
/** Remote execution metadata for local tmux sessions wrapping SSH */
|
||||
remote?: SessionRemote;
|
||||
/** Docker execution metadata for local tmux sessions wrapping `docker exec` */
|
||||
docker?: SessionDocker;
|
||||
}
|
||||
|
||||
/** Options for pane buffer capture (COD-47 full-history mode). */
|
||||
|
||||
+257
-5
@@ -29,7 +29,8 @@ const execAsync = promisify(exec);
|
||||
import { existsSync, readFileSync, mkdirSync } from 'node:fs';
|
||||
import { writeFile, rename } from 'node:fs/promises';
|
||||
import { dirname } from 'node:path';
|
||||
import { dataPath, DEFAULT_TMUX_SOCKET } from './config/instance.js';
|
||||
import { homedir } from 'node:os';
|
||||
import { dataPath, DEFAULT_TMUX_SOCKET, CODEMAN_INSTANCE } from './config/instance.js';
|
||||
import {
|
||||
ProcessStats,
|
||||
PersistedRespawnConfig,
|
||||
@@ -43,9 +44,22 @@ import {
|
||||
type EffortLevel,
|
||||
type GeminiConfig,
|
||||
type SessionRemote,
|
||||
type SessionDocker,
|
||||
type DockerCommandMode,
|
||||
} from './types.js';
|
||||
import { buildEffortCliArgs } from './session-cli-builder.js';
|
||||
import { buildSshConnectionArgs, defaultRemoteCommandForMode, remoteSshTarget } from './remote-hosts.js';
|
||||
import {
|
||||
buildDockerBaseArgs,
|
||||
buildDockerCreateArgs,
|
||||
containerApiUrl,
|
||||
CONTAINER_HOME,
|
||||
defaultDockerCommandForMode,
|
||||
hostGatewayAlias,
|
||||
resolveCredentialMounts,
|
||||
type DockerCreateContext,
|
||||
type DockerMount,
|
||||
} from './docker-hosts.js';
|
||||
import {
|
||||
wrapWithNice,
|
||||
SAFE_PATH_PATTERN,
|
||||
@@ -812,6 +826,220 @@ export function buildRemoteKillCommand(options: { remote: SessionRemote; session
|
||||
return [ssh, ...connectionArgs, remoteSshTarget(remote), shellescape(killCmd)].join(' ');
|
||||
}
|
||||
|
||||
// ========== Docker cases (COD-Docker) ==========
|
||||
//
|
||||
// The docker analog of the remote-SSH launch above. Instead of a local tmux pane
|
||||
// running `ssh -t host 'tmux new-session …'`, it runs `docker exec -it <container>
|
||||
// sh -lc 'tmux new-session …'` into a DURABLE in-container tmux server. The
|
||||
// container is per-CASE, so many sessions `docker exec` into the same one. See
|
||||
// docs/docker-cases-plan.md.
|
||||
|
||||
/**
|
||||
* DEDICATED in-container tmux socket. A Codeman running INSIDE the container uses
|
||||
* `-L codeman`; ours is `-L codeman-docker` with a `codeman-dkr-*` session name
|
||||
* that deliberately FAILS SAFE_MUX_NAME_PATTERN, so an in-container Codeman never
|
||||
* adopts/resizes/respawns our session (same defence as the remote socket).
|
||||
*/
|
||||
const DOCKER_TMUX_SOCKET = 'codeman-docker';
|
||||
|
||||
/**
|
||||
* Deterministic, reattach-stable in-container tmux session name. Derived from the
|
||||
* same stable field the local muxName uses (first 8 chars of the sessionId), so a
|
||||
* reconnect re-issues the exact same `new-session -A` and lands back in the SAME
|
||||
* in-container session. The `dkr` letters make it fail SAFE_MUX_NAME_PATTERN.
|
||||
*/
|
||||
export function dockerTmuxSessionName(sessionId: string): string {
|
||||
return `codeman-dkr-${sessionId.slice(0, 8)}`;
|
||||
}
|
||||
|
||||
/** Resume ids are UUID-ish; reject anything with shell metacharacters (defensive). */
|
||||
const RESUME_ID_SAFE = /^[A-Za-z0-9._-]+$/;
|
||||
|
||||
/**
|
||||
* Append the CLI-specific resume flag to a pane command. Only fires when the
|
||||
* in-container tmux is RE-CREATED (`new-session -A` makes the flag inert on a
|
||||
* live reattach), i.e. exactly when the previous live agent was lost and we want
|
||||
* to resume the conversation from the bind-mounted transcript.
|
||||
*/
|
||||
function appendResumeFlag(modeCommand: string, mode: SessionMode, resumeId: string): string {
|
||||
if (!RESUME_ID_SAFE.test(resumeId)) return modeCommand;
|
||||
switch (mode) {
|
||||
case 'claude':
|
||||
case 'gemini':
|
||||
return `${modeCommand} --resume ${resumeId}`;
|
||||
case 'codex':
|
||||
return `${modeCommand} resume ${resumeId}`;
|
||||
default:
|
||||
return modeCommand; // shell / opencode: no resume
|
||||
}
|
||||
}
|
||||
|
||||
/** Fully-resolved inputs for buildDockerLaunchCommand (pure). */
|
||||
export interface DockerLaunchOptions {
|
||||
mode: SessionMode;
|
||||
docker: SessionDocker;
|
||||
sessionId: string;
|
||||
resumeSessionId?: string;
|
||||
createContext: DockerCreateContext;
|
||||
/** exec-time inline env (non-secret): TERM, COLORTERM, CODEMAN_SESSION_ID, CODEMAN_MUX */
|
||||
execEnv: Record<string, string>;
|
||||
/** exec-time NAME-ONLY env forwarded from Codeman's process env (codex/gemini keys) */
|
||||
execEnvNames: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the ONE `bash -c` launch string for a docker session: image-check ->
|
||||
* ensure (inspect-or-create) -> start -> `exec docker exec -it` into the durable
|
||||
* in-container tmux (resume-aware). PURE and unit-testable. The escaping survives
|
||||
* four layers: outer `bash -c "…"` (JSON.stringify at respawn-pane) -> the joined
|
||||
* command -> `docker exec … sh -lc '<tmux>'` -> tmux `'<paneCommand>'`.
|
||||
*/
|
||||
export function buildDockerLaunchCommand(opts: DockerLaunchOptions): string {
|
||||
const { mode, docker, sessionId, resumeSessionId, createContext, execEnv, execEnvNames } = opts;
|
||||
const base = buildDockerBaseArgs(docker).join(' ');
|
||||
const createArgs = buildDockerCreateArgs(createContext).join(' ');
|
||||
const name = shellescape(docker.containerName);
|
||||
const workdir = shellescape(docker.containerWorkdir);
|
||||
const image = shellescape(docker.image);
|
||||
const dkrName = dockerTmuxSessionName(sessionId);
|
||||
const sid = sessionId.slice(0, 8);
|
||||
|
||||
let modeCommand = docker.commands?.[mode as DockerCommandMode] || defaultDockerCommandForMode(mode);
|
||||
if (resumeSessionId) modeCommand = appendResumeFlag(modeCommand, mode, resumeSessionId);
|
||||
// Run by tmux via /bin/sh -c, so the path is shell-quoted here. `exec` makes the
|
||||
// pane PID the agent itself.
|
||||
const paneCommand = `cd ${workdir} && ${modeCommand}`;
|
||||
|
||||
// `setenv -g` primes the session id so reattaches / newly-created panes inherit
|
||||
// it. `new-session -A` = attach-or-create (idempotent + resume-aware). Options
|
||||
// are scoped per-session (`set -t`) or server (`set -s`), never `-g`, so a shared
|
||||
// in-container tmux server's other sessions keep their own prefix/mouse.
|
||||
const tmuxInvocation = [
|
||||
`tmux -L ${DOCKER_TMUX_SOCKET} setenv -g CODEMAN_SESSION_ID ${shellescape(sid)}`,
|
||||
'setenv -g CODEMAN_MUX 1',
|
||||
`new-session -A -s ${dkrName} -c ${workdir} ${shellescape(paneCommand)}`,
|
||||
`set -t ${dkrName} status off`,
|
||||
`set -t ${dkrName} mouse off`,
|
||||
`set -t ${dkrName} prefix C-q`,
|
||||
'set -s escape-time 0',
|
||||
].join(' \\; ');
|
||||
|
||||
const execEnvFlags: string[] = [];
|
||||
for (const [k, v] of Object.entries(execEnv)) execEnvFlags.push('--env', shellescape(`${k}=${v}`));
|
||||
// NAME-ONLY forwards: docker reads the VALUE from Codeman's own process env, so
|
||||
// the secret never appears in argv (no `ps` leak) and is not committed.
|
||||
for (const n of execEnvNames) execEnvFlags.push('--env', n);
|
||||
for (const extra of docker.extraExecArgs ?? []) execEnvFlags.push(shellescape(extra));
|
||||
|
||||
const imageMissingMsg = shellescape(
|
||||
`Codeman: base image ${docker.image} not present (build: node scripts/build-agent-image.mjs)`
|
||||
);
|
||||
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}`;
|
||||
const start = `${base} start ${name} >/dev/null 2>&1 || { echo ${startFailMsg}; exit 1; }`;
|
||||
const execCmd = `exec ${base} exec -it --workdir ${workdir} ${execEnvFlags.join(' ')} ${name} sh -lc ${shellescape(tmuxInvocation)}`;
|
||||
|
||||
return [imageCheck, ensure, start, execCmd].join(' ; ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Kill ONLY this session's in-container tmux session. The container is shared by
|
||||
* the case's other sessions, so this NEVER `docker stop`s it — stopping/removing
|
||||
* the container is an explicit teardown (buildDockerStopCommand) or case-delete
|
||||
* (buildDockerRemoveCommand). Fired best-effort on session kill.
|
||||
*/
|
||||
export function buildDockerKillCommand(options: { docker: SessionDocker; sessionId: string }): string {
|
||||
const { docker, sessionId } = options;
|
||||
const base = buildDockerBaseArgs(docker).join(' ');
|
||||
const dkrName = dockerTmuxSessionName(sessionId);
|
||||
return `${base} exec ${shellescape(docker.containerName)} tmux -L ${DOCKER_TMUX_SOCKET} kill-session -t ${shellescape(dkrName)}`;
|
||||
}
|
||||
|
||||
/** Explicit container stop (frees RAM/CPU; conversation resumes on next launch via --resume). */
|
||||
export function buildDockerStopCommand(docker: SessionDocker): string {
|
||||
return `${buildDockerBaseArgs(docker).join(' ')} stop -t 10 ${shellescape(docker.containerName)}`;
|
||||
}
|
||||
|
||||
/** Explicit container removal (case-delete). Destroys in-image state; bind mounts survive. */
|
||||
export function buildDockerRemoveCommand(docker: SessionDocker): string {
|
||||
return `${buildDockerBaseArgs(docker).join(' ')} rm -f ${shellescape(docker.containerName)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the environment-dependent bits of a docker launch (host uid, existing
|
||||
* credential mounts, derived api url, hook-secret mount, Desktop detection) into
|
||||
* the pure buildDockerLaunchCommand inputs. IO; only ever called from the real
|
||||
* launch path (createSession/respawnPane no-op under VITEST).
|
||||
*/
|
||||
export function resolveDockerLaunchOptions(
|
||||
mode: SessionMode,
|
||||
docker: SessionDocker,
|
||||
sessionId: string,
|
||||
resumeSessionId?: string
|
||||
): DockerLaunchOptions {
|
||||
const home = homedir();
|
||||
const isDesktop = process.platform === 'darwin'; // Docker Desktop translates uids + native host.docker.internal
|
||||
const uid = typeof process.getuid === 'function' ? process.getuid() : 1000;
|
||||
const userArgs: string[] =
|
||||
docker.engine === 'podman'
|
||||
? ['--userns=keep-id'] // rootless podman: map host uid to the image `agent` uid
|
||||
: isDesktop
|
||||
? [] // Desktop: run as the image's baked uid (a mac uid wouldn't own /home/agent)
|
||||
: ['--user', `${uid}:0`]; // Linux: host uid + GID 0 (OpenShift arbitrary-uid writable HOME)
|
||||
const gatewayAlias = hostGatewayAlias(docker.engine);
|
||||
|
||||
const credentialMounts: DockerMount[] = docker.mountCredentials ? resolveCredentialMounts(home) : [];
|
||||
const extraMounts: DockerMount[] = [];
|
||||
const envCreate: Record<string, string> = {
|
||||
HOME: CONTAINER_HOME,
|
||||
TERM: 'xterm-256color',
|
||||
COLORTERM: 'truecolor',
|
||||
};
|
||||
if (docker.hooksEnabled) {
|
||||
// Derive a container-reachable API url (scheme + port preserved; host swapped
|
||||
// for the engine gateway alias). Prod is HTTPS on 3000.
|
||||
envCreate.CODEMAN_API_URL = containerApiUrl(process.env.CODEMAN_API_URL, docker.engine);
|
||||
const hookSecretPath = dataPath('hook-secret');
|
||||
if (existsSync(hookSecretPath)) {
|
||||
const dst = `${CONTAINER_HOME}/.codeman/hook-secret`;
|
||||
extraMounts.push({ src: hookSecretPath, dst, readonly: true });
|
||||
envCreate.CODEMAN_HOOK_SECRET_FILE = dst; // a path is non-secret; the bytes ride the bind mount
|
||||
}
|
||||
}
|
||||
|
||||
const createContext: DockerCreateContext = {
|
||||
docker,
|
||||
sessionId,
|
||||
instance: CODEMAN_INSTANCE,
|
||||
userArgs,
|
||||
credentialMounts,
|
||||
extraMounts,
|
||||
envCreate,
|
||||
addHostGateway: !isDesktop,
|
||||
gatewayAlias,
|
||||
};
|
||||
|
||||
const execEnv: Record<string, string> = {
|
||||
TERM: 'xterm-256color',
|
||||
COLORTERM: 'truecolor',
|
||||
CODEMAN_SESSION_ID: sessionId.slice(0, 8),
|
||||
CODEMAN_MUX: '1',
|
||||
};
|
||||
// NAME-ONLY exec env forwarded from Codeman's process env (the docker client
|
||||
// inherits it), so API-key CLIs get their key without it appearing in argv.
|
||||
const execEnvNames =
|
||||
mode === 'codex'
|
||||
? ['OPENAI_API_KEY', 'CODEX_API_KEY']
|
||||
: mode === 'gemini'
|
||||
? ['GEMINI_API_KEY', 'GOOGLE_API_KEY']
|
||||
: [];
|
||||
|
||||
return { mode, docker, sessionId, resumeSessionId, createContext, execEnv, execEnvNames };
|
||||
}
|
||||
|
||||
/**
|
||||
* Set sensitive environment variables on a tmux session via setenv.
|
||||
* These are inherited by panes but not visible in ps output or tmux history.
|
||||
@@ -1209,6 +1437,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
effort,
|
||||
historyLimit = DEFAULT_TMUX_HISTORY_LIMIT,
|
||||
remote,
|
||||
docker,
|
||||
} = options;
|
||||
const muxName = `codeman-${sessionId.slice(0, 8)}`;
|
||||
|
||||
@@ -1228,6 +1457,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
createdAt: Date.now(),
|
||||
workingDir,
|
||||
remote,
|
||||
docker,
|
||||
mode,
|
||||
attached: false,
|
||||
name,
|
||||
@@ -1273,7 +1503,11 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
try {
|
||||
// Build the full command to run inside tmux
|
||||
const localFullCmd = `${buildNofileLimitCommand()} && ${pathExport}${envExportsStr} && ${cmd}`;
|
||||
const fullCmd = remote ? buildRemoteLaunchCommand({ mode, remote, sessionId }) : localFullCmd;
|
||||
const fullCmd = docker
|
||||
? buildDockerLaunchCommand(resolveDockerLaunchOptions(mode, docker, sessionId, resumeSessionId))
|
||||
: remote
|
||||
? buildRemoteLaunchCommand({ mode, remote, sessionId })
|
||||
: localFullCmd;
|
||||
|
||||
// Create tmux session in three steps to handle cold-start (no server running)
|
||||
// and avoid the race where the command exits before remain-on-exit is set:
|
||||
@@ -1324,7 +1558,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
|
||||
// Replace the shell with the actual command (no echo in terminal). Keep
|
||||
// pane launch in /tmp, then cd inside bash against the current mount table.
|
||||
const launchCmd = remote ? fullCmd : `cd ${JSON.stringify(workingDir)} && ${fullCmd}`;
|
||||
const launchCmd = remote || docker ? fullCmd : `cd ${JSON.stringify(workingDir)} && ${fullCmd}`;
|
||||
execSync(
|
||||
`${this.tmux()} respawn-pane -k -c ${TMUX_LAUNCH_CWD} -t "${muxName}" bash -c ${JSON.stringify(launchCmd)}`,
|
||||
{
|
||||
@@ -1399,6 +1633,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
createdAt: Date.now(),
|
||||
workingDir,
|
||||
remote,
|
||||
docker,
|
||||
mode,
|
||||
attached: false,
|
||||
name,
|
||||
@@ -1484,6 +1719,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
effort,
|
||||
historyLimit = DEFAULT_TMUX_HISTORY_LIMIT,
|
||||
remote,
|
||||
docker,
|
||||
} = options;
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) return null;
|
||||
@@ -1521,7 +1757,11 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
const config = niceConfig || DEFAULT_NICE_CONFIG;
|
||||
const cmd = wrapWithNice(baseCmd, config);
|
||||
const localFullCmd = `${buildNofileLimitCommand()} && ${pathExport}${envExportsStr} && ${cmd}`;
|
||||
const fullCmd = remote ? buildRemoteLaunchCommand({ mode, remote, sessionId }) : localFullCmd;
|
||||
const fullCmd = docker
|
||||
? buildDockerLaunchCommand(resolveDockerLaunchOptions(mode, docker, sessionId, resumeSessionId))
|
||||
: remote
|
||||
? buildRemoteLaunchCommand({ mode, remote, sessionId })
|
||||
: localFullCmd;
|
||||
|
||||
try {
|
||||
// For OpenCode: set sensitive env vars via tmux setenv before respawn
|
||||
@@ -1539,7 +1779,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
this.applyEnvOverrides(muxName, envOverrides);
|
||||
|
||||
// -c /tmp + cd bounce — see createSession() for rationale (stale FUSE state).
|
||||
const launchCmd = remote ? fullCmd : `cd ${JSON.stringify(workingDir)} && ${fullCmd}`;
|
||||
const launchCmd = remote || docker ? fullCmd : `cd ${JSON.stringify(workingDir)} && ${fullCmd}`;
|
||||
await execAsync(
|
||||
`${this.tmux()} respawn-pane -k -c ${TMUX_LAUNCH_CWD} -t "${muxName}" bash -c ${JSON.stringify(launchCmd)}`,
|
||||
{
|
||||
@@ -1725,6 +1965,18 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
}
|
||||
}
|
||||
|
||||
// Strategy 3c: Docker sessions run a DURABLE in-container tmux session. Kill
|
||||
// ONLY this session's in-container tmux session (best-effort). The container is
|
||||
// PER-CASE and shared by the case's other sessions, so we deliberately do NOT
|
||||
// `docker stop` it here — stopping/removing is an explicit teardown/case-delete.
|
||||
if (session.docker && !IS_TEST_MODE) {
|
||||
try {
|
||||
exec(buildDockerKillCommand({ docker: session.docker, sessionId }), { timeout: EXEC_TIMEOUT_MS }, () => {});
|
||||
} catch {
|
||||
// Best-effort — never affects the local kill result.
|
||||
}
|
||||
}
|
||||
|
||||
// Strategy 4: Direct kill by PID as final fallback
|
||||
if (this.isProcessAlive(currentPid)) {
|
||||
try {
|
||||
|
||||
@@ -98,6 +98,118 @@ export interface SessionRemote extends RemoteSshOptions {
|
||||
commands?: Partial<Record<RemoteCommandMode, string>>;
|
||||
}
|
||||
|
||||
// ========== Docker cases (COD-Docker) ==========
|
||||
//
|
||||
// Docker mode is a LOCATION OVERLAY on cases (never a 6th SessionMode), the exact
|
||||
// analog of the remote-SSH feature above: instead of a local tmux pane running
|
||||
// `ssh host` into a durable remote tmux server, a local tmux pane runs
|
||||
// `docker exec -it` into a durable in-container tmux server. The container is
|
||||
// scoped to the CASE (not the session), so multiple sessions can `docker exec`
|
||||
// into the same long-lived container. See `docs/docker-cases-plan.md`.
|
||||
|
||||
/** Which CLI backends a Docker case can run (same set as remote). */
|
||||
export type DockerCommandMode = Extract<SessionMode, 'shell' | 'claude' | 'opencode' | 'codex' | 'gemini'>;
|
||||
|
||||
/** Container engine. Docker and Podman differ in the uid/userns + host-gateway alias. */
|
||||
export type DockerEngine = 'docker' | 'podman';
|
||||
|
||||
/**
|
||||
* Container network mode. `host` and any inbound `-p` publish are deliberately
|
||||
* unrepresentable (never in this union, never emitted by the flag builder).
|
||||
* - `bridge`: own netns, NAT egress, no inbound (default — every API CLI needs egress)
|
||||
* - `none`: fully offline sandbox (breaks API CLIs; reserved for `shell`)
|
||||
* - `custom`: a user-defined bridge `codeman-net-<slug>` (future egress-allowlist chokepoint)
|
||||
*/
|
||||
export type DockerNetworkMode = 'bridge' | 'none' | 'custom';
|
||||
|
||||
/** Per-container resource caps. Advisory under non-delegated rootless (see `capsEnforced`). */
|
||||
export interface DockerResourceLimits {
|
||||
/** e.g. '4g' -> --memory 4g --memory-swap 4g (swap==memory: a real OOM cap) */
|
||||
memory?: string;
|
||||
/** e.g. '2' -> --cpus 2 */
|
||||
cpus?: string;
|
||||
/** e.g. 512 -> --pids-limit 512 (fork-bomb guard) */
|
||||
pidsLimit?: number;
|
||||
/** e.g. '4096:8192' -> --ulimit nofile=4096:8192 */
|
||||
nofile?: string;
|
||||
/** e.g. '256m' -> --shm-size (only when a tool needs /dev/shm) */
|
||||
shmSize?: string;
|
||||
}
|
||||
|
||||
/** A reusable Docker engine/image/network/resource profile (mirror of RemoteHost). */
|
||||
export interface DockerHost {
|
||||
id: string;
|
||||
label: string;
|
||||
/** Engine; when absent the availability probe resolves it (docker, else podman). */
|
||||
engine?: DockerEngine;
|
||||
/** Base image ref (built locally by scripts/build-agent-image.mjs, e.g. codeman/agent:base). */
|
||||
image: string;
|
||||
/** Advanced: remote daemon (-H ssh://user@host or a DOCKER_HOST value). */
|
||||
daemonHost?: string;
|
||||
/** Advanced: docker `--context` name. */
|
||||
context?: string;
|
||||
/** Network mode (default 'bridge'). */
|
||||
network?: DockerNetworkMode;
|
||||
/** Custom bridge name when network === 'custom'. */
|
||||
networkName?: string;
|
||||
resources?: DockerResourceLimits;
|
||||
/** true (default) = convenient: bind-mount host cred dirs RW. false = sealed (blocks full-image export). */
|
||||
mountCredentials?: boolean;
|
||||
/** true (default) = wire in-container hooks (host-gateway callback + workspace scaffold). */
|
||||
hooksEnabled?: boolean;
|
||||
/** true (default) = a relaunch resumes the last conversation from the bind-mounted transcript. */
|
||||
resumeOnStart?: boolean;
|
||||
/** Per-mode command overrides (mirror RemoteHost.commands). */
|
||||
commands?: Partial<Record<DockerCommandMode, string>>;
|
||||
/** Escape hatch: extra `docker create` args (validated like extraSshOptions). */
|
||||
extraCreateArgs?: string[];
|
||||
/** Escape hatch: extra `docker exec` args. */
|
||||
extraExecArgs?: string[];
|
||||
}
|
||||
|
||||
/** A case linked to a Docker container (mirror of RemoteCase). */
|
||||
export interface DockerCase {
|
||||
name: string;
|
||||
type: 'docker';
|
||||
hostId: string;
|
||||
/** Absolute HOST directory: the bind-mount source AND Session.workingDir (real host bytes). */
|
||||
hostWorkspacePath: string;
|
||||
/** Container path (default = hostWorkspacePath: mirror -> transcript projHash correlates). */
|
||||
containerWorkdir?: string;
|
||||
/** Container name (default codeman-case-<slug>). */
|
||||
container?: string;
|
||||
/** Last captured Claude conversation id, replayed via --resume on a fresh launch. */
|
||||
lastClaudeSessionId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Flattened Docker execution metadata carried on a live session (mirror of
|
||||
* SessionRemote). Round-trips through MuxSession/SessionState/mux-sessions.json.
|
||||
*/
|
||||
export interface SessionDocker {
|
||||
hostId: string;
|
||||
label: string;
|
||||
engine: DockerEngine;
|
||||
image: string;
|
||||
/** Per-CASE container name (shared by all sessions of the case). */
|
||||
containerName: string;
|
||||
hostWorkspacePath: string;
|
||||
containerWorkdir: string;
|
||||
network: DockerNetworkMode;
|
||||
networkName?: string;
|
||||
resources?: DockerResourceLimits;
|
||||
mountCredentials: boolean;
|
||||
hooksEnabled: boolean;
|
||||
resumeOnStart: boolean;
|
||||
daemonHost?: string;
|
||||
context?: string;
|
||||
commands?: Partial<Record<DockerCommandMode, string>>;
|
||||
extraCreateArgs?: string[];
|
||||
extraExecArgs?: string[];
|
||||
/** Stable hash of the drift-relevant create args (recreate-on-drift detection). */
|
||||
configHash?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Valid Claude CLI effort levels (claude >= 2.1.154).
|
||||
* `ultracode` = xhigh effort + standing dynamic-workflow orchestration; it is a
|
||||
@@ -217,6 +329,8 @@ export interface SessionState {
|
||||
workingDir: string;
|
||||
/** Remote execution metadata, present when this session runs over SSH through local tmux */
|
||||
remote?: SessionRemote;
|
||||
/** Docker execution metadata, present when this session runs inside a container via local tmux + docker exec */
|
||||
docker?: SessionDocker;
|
||||
/** ID of currently assigned task, null if none */
|
||||
currentTaskId: string | null;
|
||||
/** Timestamp when session was created */
|
||||
|
||||
@@ -359,6 +359,112 @@ export const RemoteCaseLinkSchema = z.object({
|
||||
.regex(NO_SHELL_META, 'Invalid characters in remote path'),
|
||||
});
|
||||
|
||||
// ========== Docker cases ==========
|
||||
//
|
||||
// Docker mode is a location overlay on cases (see docs/docker-cases-plan.md),
|
||||
// the analog of the remote-SSH schemas above. `image`, `hostWorkspacePath`,
|
||||
// `containerWorkdir`, and `container` all reach the outer `bash -c "..."` launch
|
||||
// layer, so they carry NO_SHELL_META (rejects `$`/backtick that survive the
|
||||
// double-quote layer) exactly like remotePath/identityFile. `--privileged` and
|
||||
// any docker-socket mount are structurally unrepresentable (never accepted).
|
||||
|
||||
const DockerResourceLimitsSchema = z
|
||||
.object({
|
||||
memory: z
|
||||
.string()
|
||||
.regex(/^\d+[bkmg]?$/i, 'Memory must be like 512m / 4g')
|
||||
.optional(),
|
||||
cpus: z
|
||||
.string()
|
||||
.regex(/^\d+(\.\d+)?$/, 'CPUs must be a number')
|
||||
.optional(),
|
||||
pidsLimit: z.number().int().positive().max(100000).optional(),
|
||||
nofile: z
|
||||
.string()
|
||||
.regex(/^\d+:\d+$/, 'nofile must be soft:hard')
|
||||
.optional(),
|
||||
shmSize: z
|
||||
.string()
|
||||
.regex(/^\d+[bkmg]?$/i, 'shm-size must be like 256m')
|
||||
.optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const DockerHostSchema = z.object({
|
||||
id: z.string().regex(/^[a-zA-Z0-9_-]+$/, 'Invalid docker host id'),
|
||||
label: z.string().min(1).max(100),
|
||||
engine: z.enum(['docker', 'podman']).optional(),
|
||||
image: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(512)
|
||||
.regex(/^[a-zA-Z0-9][\w./:@-]*$/, 'Invalid image reference')
|
||||
.regex(NO_SHELL_META, 'Invalid characters in image reference'),
|
||||
daemonHost: z.string().max(512).regex(NO_SHELL_META, 'Invalid daemon host').optional(),
|
||||
context: z
|
||||
.string()
|
||||
.max(128)
|
||||
.regex(/^[a-zA-Z0-9._-]+$/, 'Invalid docker context')
|
||||
.optional(),
|
||||
network: z.enum(['bridge', 'none', 'custom']).optional(),
|
||||
networkName: z
|
||||
.string()
|
||||
.max(128)
|
||||
.regex(/^[a-zA-Z0-9][a-zA-Z0-9_.-]+$/, 'Invalid network name')
|
||||
.optional(),
|
||||
resources: DockerResourceLimitsSchema.optional(),
|
||||
mountCredentials: z.boolean().optional(),
|
||||
hooksEnabled: z.boolean().optional(),
|
||||
resumeOnStart: z.boolean().optional(),
|
||||
commands: RemoteCommandOverridesSchema, // same shell/claude/opencode/codex/gemini shape
|
||||
extraCreateArgs: z
|
||||
.array(
|
||||
z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(1024)
|
||||
.regex(NO_SHELL_INJECTION, 'Invalid characters in create arg')
|
||||
.refine(noCommandSubstitution, 'Invalid characters in create arg')
|
||||
)
|
||||
.max(32)
|
||||
.optional(),
|
||||
extraExecArgs: z
|
||||
.array(
|
||||
z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(1024)
|
||||
.regex(NO_SHELL_INJECTION, 'Invalid characters in exec arg')
|
||||
.refine(noCommandSubstitution, 'Invalid characters in exec arg')
|
||||
)
|
||||
.max(32)
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export const DockerCaseLinkSchema = z.object({
|
||||
name: z.string().regex(/^[a-zA-Z0-9_-]+$/, 'Invalid case name format'),
|
||||
hostId: z.string().regex(/^[a-zA-Z0-9_-]+$/, 'Invalid docker host id'),
|
||||
hostWorkspacePath: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(2000)
|
||||
.regex(/^\//, 'Workspace path must be absolute')
|
||||
.regex(NO_SHELL_META, 'Invalid characters in workspace path'),
|
||||
containerWorkdir: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(2000)
|
||||
.regex(/^\//, 'Container workdir must be absolute')
|
||||
.regex(NO_SHELL_META, 'Invalid characters in container workdir')
|
||||
.optional(),
|
||||
container: z
|
||||
.string()
|
||||
.min(2)
|
||||
.max(128)
|
||||
.regex(/^[a-zA-Z0-9][a-zA-Z0-9_.-]+$/, 'Invalid container name')
|
||||
.optional(),
|
||||
});
|
||||
|
||||
// ========== Quick Start ==========
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user