feat(docker): harden session mode + File Viewer button (v1.4.1)

Docker cases: seamless Claude auth (seed ~/.claude.json instead of the
corruption-prone single-file mount), full credential-store isolation for
claude + codex/gemini/gcloud/opencode (share only transcripts/rollouts,
seed the rest), auto-build the base image on first use, C.UTF-8 locale
(fixes box-drawing), collapsed/shortened Create-Case UI + short "(docker)"
case-menu tags, and w<n>-<case> tab naming for docker/remote sessions.
Also: opt-in File Viewer header button; fix a TZ-boundary flaky test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-07-20 01:36:21 +02:00
parent 82825cbfb3
commit ca731c67b3
25 changed files with 2607 additions and 177 deletions
+85 -20
View File
@@ -11,7 +11,7 @@ import fs from 'node:fs/promises';
import { join, resolve, basename } from 'node:path';
import { fileURLToPath } from 'node:url';
import { homedir } from 'node:os';
import type { ApiResponse, CaseInfo, DockerHost } from '../../types.js';
import type { ApiResponse, CaseInfo, DockerHost, SessionDocker } from '../../types.js';
import { ApiErrorCode, createErrorResponse, getErrorMessage } from '../../types.js';
import {
CreateCaseSchema,
@@ -34,7 +34,9 @@ import type { EventPort, ConfigPort } from '../ports/index.js';
import { dataPath, getDataDir } from '../../config/instance.js';
import {
checkDockerAvailable,
checkDockerImagePresent,
checkDockerTmuxAvailable,
ensureAgentBaseImage,
DEFAULT_AGENT_IMAGE,
dockerContainerName,
dockerDisplayPath,
@@ -83,6 +85,48 @@ async function resolveCasePath(name: string): Promise<string> {
return join(CASES_DIR, name);
}
/**
* Gate a docker case on its base image, AUTO-BUILDING the default image on first
* use so a missing image is never a blocker (the user's ask: "create it when it's
* used for the first time"). Present image → verify tmux (hard prerequisite).
* Default image missing → kick off a BACKGROUND build with SSE progress and return
* `imageBuilding: true` (the case is created regardless; first launch awaits the
* same dedup'd build). Custom image missing → a real error (we can't build a
* foreign ref, and `--pull=never` forbids pulling).
*/
async function ensureCaseImage(
broadcast: EventPort['broadcast'],
sessionDocker: SessionDocker,
name: string
): Promise<{ ok: true; imageBuilding: boolean } | { ok: false; error: string }> {
if (await checkDockerImagePresent(sessionDocker.engine, sessionDocker.image)) {
const tmuxCheck = await checkDockerTmuxAvailable(sessionDocker);
if (!tmuxCheck.ok) return { ok: false, error: tmuxCheck.error || 'base image is missing tmux' };
return { ok: true, imageBuilding: false };
}
if (sessionDocker.image !== DEFAULT_AGENT_IMAGE) {
return {
ok: false,
error: `base image ${sessionDocker.image} not present; only ${DEFAULT_AGENT_IMAGE} is auto-built. Build or pull it first.`,
};
}
broadcast(SseEvent.DockerImageBuildStarted, { name, image: sessionDocker.image });
void ensureAgentBaseImage(sessionDocker.engine, sessionDocker.image, {
onProgress: (line) => broadcast(SseEvent.DockerImageBuildProgress, { name, line }),
})
.then((r) =>
broadcast(r.ok ? SseEvent.DockerImageBuildComplete : SseEvent.DockerImageBuildFailed, {
name,
image: sessionDocker.image,
error: r.error,
})
)
.catch((err) =>
broadcast(SseEvent.DockerImageBuildFailed, { name, image: sessionDocker.image, error: getErrorMessage(err) })
);
return { ok: true, imageBuilding: true };
}
export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & ConfigPort): void {
// ═══════════════════════════════════════════════════════════════
// Case CRUD (list, create, link, detail, fix-plan)
@@ -337,7 +381,11 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
app.post(
'/api/cases/docker-link',
async (req): Promise<ApiResponse<{ case: unknown; capsEnforced?: boolean; isDesktop?: boolean }>> => {
async (
req
): Promise<
ApiResponse<{ case: unknown; capsEnforced?: boolean; isDesktop?: boolean; imageBuilding?: boolean }>
> => {
const dockerCase = { ...parseBody(DockerCaseLinkSchema, req.body), type: 'docker' as const };
const hosts = await readDockerHosts(CODEMAN_CONFIG_DIR);
const host = hosts.find((item) => item.id === dockerCase.hostId);
@@ -367,9 +415,9 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
}
}
// Courtesy validation: docker daemon must be reachable AND the base image must
// contain tmux (a hard prerequisite for durable in-container sessions). Surfaces
// a clear error at link time instead of a dead pane on first launch.
// Courtesy validation: docker daemon must be reachable. The base image is
// auto-built on first use (default image) rather than being a link-time
// blocker, so a missing image kicks off a background build instead of erroring.
const availability = await checkDockerAvailable(host.engine);
if (!availability.ok) {
return createErrorResponse(
@@ -377,9 +425,9 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
availability.error || 'docker daemon is not available'
);
}
const tmuxCheck = await checkDockerTmuxAvailable(toSessionDocker(host, dockerCase));
if (!tmuxCheck.ok) {
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, tmuxCheck.error || 'base image is missing tmux');
const imageGate = await ensureCaseImage(ctx.broadcast, toSessionDocker(host, dockerCase), dockerCase.name);
if (!imageGate.ok) {
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, imageGate.error);
}
await writeDockerCases(CODEMAN_CONFIG_DIR, [...dockerCases, dockerCase]);
@@ -390,7 +438,12 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
});
return {
success: true,
data: { case: dockerCase, capsEnforced: availability.capsEnforced, isDesktop: availability.isDesktop },
data: {
case: dockerCase,
capsEnforced: availability.capsEnforced,
isDesktop: availability.isDesktop,
imageBuilding: imageGate.imageBuilding,
},
};
}
);
@@ -400,7 +453,11 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
// shared `default` docker host so the user never touches host/image/network fields.
app.post(
'/api/cases/docker-quickcreate',
async (req): Promise<ApiResponse<{ case: unknown; capsEnforced?: boolean; isDesktop?: boolean }>> => {
async (
req
): Promise<
ApiResponse<{ case: unknown; capsEnforced?: boolean; isDesktop?: boolean; imageBuilding?: boolean }>
> => {
const body = parseBody(DockerQuickCreateSchema, req.body);
const { name, description } = body;
const casePath = validatePathWithinBase(name, CASES_DIR);
@@ -458,8 +515,9 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
);
}
// Probe the daemon + base image BEFORE scaffolding, so a missing docker/image
// surfaces a clear error instead of leaving an orphaned case folder.
// Probe the daemon BEFORE scaffolding so a missing docker surfaces a clear
// error instead of leaving an orphaned case folder. The base image is NOT a
// blocker: a missing default image auto-builds in the background on first use.
const availability = await checkDockerAvailable(host.engine);
if (!availability.ok) {
return createErrorResponse(
@@ -468,12 +526,9 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
);
}
const dockerCase = { name, type: 'docker' as const, hostId: host.id, hostWorkspacePath: casePath };
const tmuxCheck = await checkDockerTmuxAvailable(toSessionDocker(host, dockerCase));
if (!tmuxCheck.ok) {
return createErrorResponse(
ApiErrorCode.OPERATION_FAILED,
tmuxCheck.error || 'base image is missing tmux (build it: node scripts/build-agent-image.mjs)'
);
const imageGate = await ensureCaseImage(ctx.broadcast, toSessionDocker(host, dockerCase), name);
if (!imageGate.ok) {
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, imageGate.error);
}
// Scaffold the case folder exactly like a normal case.
@@ -492,7 +547,12 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
ctx.broadcast(SseEvent.CaseLinked, { name, path: casePath, type: 'docker' });
return {
success: true,
data: { case: dockerCase, capsEnforced: availability.capsEnforced, isDesktop: availability.isDesktop },
data: {
case: dockerCase,
capsEnforced: availability.capsEnforced,
isDesktop: availability.isDesktop,
imageBuilding: imageGate.imageBuilding,
},
};
}
);
@@ -701,11 +761,16 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
// explicit teardown that removes it; the bind-mounted workspace survives).
const host = (await readDockerHosts(CODEMAN_CONFIG_DIR)).find((item) => item.id === dockerCase.hostId);
if (host) {
const sessionDocker = toSessionDocker(host, dockerCase);
try {
exec(buildDockerRemoveCommand(toSessionDocker(host, dockerCase)), { timeout: 15_000 }, () => {});
exec(buildDockerRemoveCommand(sessionDocker), { timeout: 15_000 }, () => {});
} catch {
/* best-effort — never blocks the unlink */
}
// Remove the per-container claude-config seed file (account metadata copy).
await fs
.rm(join(dataPath('docker-seeds'), `${sessionDocker.containerName}.json`), { force: true })
.catch(() => {});
}
ctx.broadcast(SseEvent.CaseDeleted, { name, type: 'docker-unlinked' });
return { success: true, data: { name } };
+23 -3
View File
@@ -77,6 +77,8 @@ import { checkRemoteTmuxAvailable, readRemoteCases, readRemoteHosts, toSessionRe
import {
checkDockerAvailable,
checkDockerTmuxAvailable,
ensureAgentBaseImage,
DEFAULT_AGENT_IMAGE,
readDockerCases,
readDockerHosts,
toSessionDocker,
@@ -1679,6 +1681,7 @@ export function registerSessionRoutes(
const {
caseName = 'testcase',
sessionName,
mode = 'claude',
openCodeConfig,
codexConfig,
@@ -1758,10 +1761,26 @@ export function registerSessionRoutes(
);
}
const sessionDocker = toSessionDocker(host, dockerCase);
// Ensure the base image exists, auto-building the default image on first use so
// it is never a blocker. Dedup'd with any build kicked off at case-create, so
// this awaits the SAME in-flight build rather than starting a second one.
const ensured = await ensureAgentBaseImage(sessionDocker.engine, sessionDocker.image, {
onProgress: (line) => ctx.broadcast(SseEvent.DockerImageBuildProgress, { name: dockerCase.name, line }),
});
if (!ensured.ok) {
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, ensured.error || 'base image not available');
}
if (ensured.built) {
ctx.broadcast(SseEvent.DockerImageBuildComplete, { name: dockerCase.name, image: sessionDocker.image });
}
// tmux is a hard prerequisite (the in-container tmux makes reconnect durable).
const tmuxCheck = await checkDockerTmuxAvailable(sessionDocker);
if (!tmuxCheck.ok) {
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, tmuxCheck.error || 'base image is missing tmux');
// Skip the extra container-run probe for our OWN default image (the baked
// Dockerfile always contains tmux); still verify a custom image.
if (sessionDocker.image !== DEFAULT_AGENT_IMAGE) {
const tmuxCheck = await checkDockerTmuxAvailable(sessionDocker);
if (!tmuxCheck.ok) {
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, tmuxCheck.error || 'base image is missing tmux');
}
}
casePath = dockerCase.hostWorkspacePath; // a REAL host dir (bind-mounted into the container)
@@ -1906,6 +1925,7 @@ export function registerSessionRoutes(
const qsTerminalHistoryConfig = await ctx.getTerminalHistoryConfig();
const session = new Session({
workingDir: resolvedCasePath,
name: sessionName ? sessionName.slice(0, MAX_SESSION_NAME_LENGTH) : '',
mux: ctx.mux,
useMux: true,
mode: mode,