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:
Codeman maintainer
2026-07-19 15:09:48 +02:00
parent 6f4b2b8a17
commit 828b1664f7
8 changed files with 1908 additions and 5 deletions
+106
View File
@@ -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 ==========
/**