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
+30
View File
@@ -133,6 +133,36 @@ describe('buildDockerLaunchCommand', () => {
const cmd = buildDockerLaunchCommand(launchOpts({ docker }));
expect(cmd).toContain('exec claude --model opus');
});
it('seeds writable config (guarded copies, mkdir -p parent) from the read-only seed mounts', () => {
const cmd = buildDockerLaunchCommand(
launchOpts({
seedCopies: [
{ from: '/home/agent/.codeman/claude.seed.json', to: '/home/agent/.claude.json' },
{ from: '/home/agent/.codeman/claude-creds.seed.json', to: '/home/agent/.claude/.credentials.json' },
// whole-dir credential seed → cp -a
{ from: '/home/agent/.codeman/cred-seeds/.gemini', to: '/home/agent/.gemini', recursive: true },
],
})
);
// Each copy mkdir -p's its parent then is guarded so a reconnect never clobbers config.
expect(cmd).toContain(
'mkdir -p /home/agent 2>/dev/null; [ -e /home/agent/.claude.json ] || cp /home/agent/.codeman/claude.seed.json /home/agent/.claude.json'
);
expect(cmd).toContain(
'mkdir -p /home/agent/.claude 2>/dev/null; [ -e /home/agent/.claude/.credentials.json ] || cp /home/agent/.codeman/claude-creds.seed.json /home/agent/.claude/.credentials.json'
);
// recursive whole-dir seed uses cp -a
expect(cmd).toContain(
'[ -e /home/agent/.gemini ] || cp -a /home/agent/.codeman/cred-seeds/.gemini /home/agent/.gemini'
);
expect(cmd.indexOf('.claude.json')).toBeLessThan(cmd.indexOf('tmux -L codeman-docker'));
});
it('omits the seed-copy step when there are no seedCopies', () => {
const cmd = buildDockerLaunchCommand(launchOpts());
expect(cmd).not.toContain('claude.seed.json');
});
});
describe('buildDockerKillCommand (multi-session safe)', () => {
+172 -8
View File
@@ -3,16 +3,19 @@
* (src/docker-hosts.ts). Mirrors test/remote-hosts.test.ts. All docker IO no-ops
* under VITEST, so probes return canned values and never spawn a real daemon.
*/
import { mkdtempSync, mkdirSync, rmSync } from 'node:fs';
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {
agentImageBuildArgs,
buildDockerBaseArgs,
buildDockerCreateArgs,
buildSeamlessClaudeConfig,
checkDockerAvailable,
checkDockerImagePresent,
checkDockerTmuxAvailable,
CLAUDE_JSON_SEED,
containerApiUrl,
DEFAULT_AGENT_IMAGE,
DEFAULT_DOCKER_RESOURCES,
@@ -20,11 +23,14 @@ import {
dockerContainerName,
dockerDisplayPath,
defaultDockerCommandForMode,
ensureAgentBaseImage,
hostGatewayAlias,
probeDockerCliVersion,
readDockerCases,
readDockerHosts,
resolveCredentialMounts,
resolveClaudeJsonSeedMount,
resolveDockerClaudeArtifacts,
resolveDockerCredentialArtifacts,
toSessionDocker,
writeDockerCases,
writeDockerHosts,
@@ -251,7 +257,7 @@ describe('buildDockerCreateArgs', () => {
});
});
describe('resolveCredentialMounts', () => {
describe('resolveDockerCredentialArtifacts (isolated codex/gemini/gcloud/opencode)', () => {
let home: string;
beforeEach(() => {
home = mkdtempSync(join(tmpdir(), 'codeman-home-'));
@@ -260,11 +266,169 @@ describe('resolveCredentialMounts', () => {
rmSync(home, { recursive: true, force: true });
});
it('only mounts credential paths that exist', () => {
mkdirSync(join(home, '.claude'), { recursive: true });
const mounts = resolveCredentialMounts(home);
expect(mounts).toContainEqual({ src: join(home, '.claude'), dst: '/home/agent/.claude' });
expect(mounts.find((m) => m.dst.endsWith('.codex'))).toBeUndefined();
it('NEVER whole-dir RW-mounts a credential store (no host pollution)', () => {
mkdirSync(join(home, '.codex'), { recursive: true });
mkdirSync(join(home, '.gemini'), { recursive: true });
const { mounts } = resolveDockerCredentialArtifacts(home);
// no wholesale RW mount at the store's HOME path
expect(mounts.some((m) => m.dst === '/home/agent/.codex' && !m.readonly)).toBe(false);
expect(mounts.some((m) => m.dst === '/home/agent/.gemini' && !m.readonly)).toBe(false);
});
it('codex: shares sessions/+history.jsonl RW, seeds auth.json/config.toml', () => {
mkdirSync(join(home, '.codex', 'sessions'), { recursive: true });
writeFileSync(join(home, '.codex', 'history.jsonl'), '');
writeFileSync(join(home, '.codex', 'auth.json'), '{}');
writeFileSync(join(home, '.codex', 'config.toml'), '');
const { mounts, seedCopies } = resolveDockerCredentialArtifacts(home);
expect(mounts).toContainEqual({ src: join(home, '.codex', 'sessions'), dst: '/home/agent/.codex/sessions' });
expect(mounts).toContainEqual({
src: join(home, '.codex', 'history.jsonl'),
dst: '/home/agent/.codex/history.jsonl',
});
const dests = seedCopies.map((s) => s.to);
expect(dests).toContain('/home/agent/.codex/auth.json');
expect(dests).toContain('/home/agent/.codex/config.toml');
// seed copies of individual files are NOT recursive
expect(seedCopies.filter((s) => s.to.startsWith('/home/agent/.codex')).every((s) => !s.recursive)).toBe(true);
});
it('gemini/gcloud/opencode: whole-dir seed-copy (cp -a, recursive)', () => {
mkdirSync(join(home, '.gemini'), { recursive: true });
mkdirSync(join(home, '.config', 'gcloud'), { recursive: true });
mkdirSync(join(home, '.config', 'opencode'), { recursive: true });
const { mounts, seedCopies } = resolveDockerCredentialArtifacts(home);
// each is mounted read-only at a seed staging path and cp -a'd into the container HOME
expect(seedCopies).toContainEqual({
from: '/home/agent/.codeman/cred-seeds/.gemini',
to: '/home/agent/.gemini',
recursive: true,
});
expect(seedCopies).toContainEqual({
from: '/home/agent/.codeman/cred-seeds/.config-gcloud',
to: '/home/agent/.config/gcloud',
recursive: true,
});
expect(mounts.filter((m) => m.readonly && m.dst.includes('cred-seeds')).length).toBeGreaterThanOrEqual(3);
});
it('gates every artifact on existsSync (absent stores contribute nothing)', () => {
const { mounts, seedCopies } = resolveDockerCredentialArtifacts(home);
expect(mounts).toEqual([]);
expect(seedCopies).toEqual([]);
});
});
describe('resolveDockerClaudeArtifacts (isolated claude state)', () => {
let home: string;
beforeEach(() => {
home = mkdtempSync(join(tmpdir(), 'codeman-home-'));
});
afterEach(() => {
rmSync(home, { recursive: true, force: true });
});
it('shares only projects (RW) and seeds .claude.json + credentials + settings + stats-cache', () => {
mkdirSync(join(home, '.claude', 'projects'), { recursive: true });
writeFileSync(join(home, '.claude.json'), JSON.stringify({ oauthAccount: { id: 1 } }));
writeFileSync(join(home, '.claude', '.credentials.json'), '{"claudeAiOauth":{}}');
writeFileSync(join(home, '.claude', 'settings.json'), JSON.stringify({ theme: 'dark' }));
writeFileSync(join(home, '.claude', 'stats-cache.json'), '{}');
const art = resolveDockerClaudeArtifacts(home, 'codeman-case-x', '/ws/x');
// transcripts shared RW (no readonly), whole ~/.claude never mounted
expect(art.mounts).toContainEqual({ src: join(home, '.claude', 'projects'), dst: '/home/agent/.claude/projects' });
expect(art.mounts.some((m) => m.dst === '/home/agent/.claude')).toBe(false);
// credentials + settings + stats-cache + .claude.json seeded (copied into the container's own HOME)
const dests = art.seedCopies.map((s) => s.to);
expect(dests).toContain('/home/agent/.claude.json');
expect(dests).toContain('/home/agent/.claude/.credentials.json');
expect(dests).toContain('/home/agent/.claude/settings.json');
expect(dests).toContain('/home/agent/.claude/stats-cache.json'); // restores the model/effort status indicator
// the seed mounts are read-only
expect(art.mounts.filter((m) => m.readonly).length).toBeGreaterThanOrEqual(3);
});
it('omits mounts/seeds for artifacts that do not exist', () => {
const art = resolveDockerClaudeArtifacts(home, 'codeman-case-y', '/ws/y');
expect(art.mounts).toEqual([]);
expect(art.seedCopies).toEqual([]);
});
});
describe('resolveClaudeJsonSeedMount', () => {
let home: string;
beforeEach(() => {
home = mkdtempSync(join(tmpdir(), 'codeman-home-'));
});
afterEach(() => {
rmSync(home, { recursive: true, force: true });
});
it('returns a read-only seed mount when ~/.claude.json exists', () => {
writeFileSync(join(home, '.claude.json'), '{}');
expect(resolveClaudeJsonSeedMount(home)).toEqual({
src: join(home, '.claude.json'),
dst: CLAUDE_JSON_SEED,
readonly: true,
});
});
it('returns null when ~/.claude.json is absent', () => {
expect(resolveClaudeJsonSeedMount(home)).toBeNull();
});
});
describe('buildSeamlessClaudeConfig', () => {
it('forces onboarding-complete + theme + workspace trust while preserving host fields', () => {
const merged = buildSeamlessClaudeConfig({ oauthAccount: { id: 1 }, projects: {} }, '/ws/proj');
expect(merged.hasCompletedOnboarding).toBe(true);
expect(merged.theme).toBe('dark');
expect(merged.oauthAccount).toEqual({ id: 1 }); // host auth account preserved
const proj = (merged.projects as Record<string, Record<string, unknown>>)['/ws/proj'];
expect(proj.hasTrustDialogAccepted).toBe(true);
expect(proj.hasCompletedProjectOnboarding).toBe(true);
expect(proj.projectOnboardingSeenCount).toBe(1);
});
it('keeps an existing theme and merges into an existing project entry', () => {
const merged = buildSeamlessClaudeConfig(
{ theme: 'light', projects: { '/ws/proj': { allowedTools: ['a'], projectOnboardingSeenCount: 5 } } },
'/ws/proj',
'dark'
);
expect(merged.theme).toBe('light'); // not overwritten when already set
const proj = (merged.projects as Record<string, Record<string, unknown>>)['/ws/proj'];
expect(proj.allowedTools).toEqual(['a']); // existing project fields kept
expect(proj.hasTrustDialogAccepted).toBe(true);
expect(proj.projectOnboardingSeenCount).toBe(5); // preserved, not reset to 1
});
});
describe('agentImageBuildArgs', () => {
it('builds the docker build argv in order', () => {
expect(agentImageBuildArgs('/repo/docker/agent.Dockerfile', 'codeman/agent:base', '/repo')).toEqual([
'build',
'-f',
'/repo/docker/agent.Dockerfile',
'-t',
'codeman/agent:base',
'/repo',
]);
});
it('adds --no-cache before the context dir when requested', () => {
const args = agentImageBuildArgs('/df', 'img', '/ctx', true);
expect(args).toContain('--no-cache');
expect(args.indexOf('--no-cache')).toBeLessThan(args.indexOf('/ctx'));
expect(args[args.length - 1]).toBe('/ctx');
});
});
describe('ensureAgentBaseImage (no-op under VITEST)', () => {
it('reports the image as already present without spawning a build', async () => {
const r = await ensureAgentBaseImage('docker', DEFAULT_AGENT_IMAGE);
expect(r).toEqual({ ok: true, built: false, alreadyPresent: true });
});
});
+5 -1
View File
@@ -225,7 +225,11 @@ describe('system-routes', () => {
harness.ctx._session.status = 'working';
harness.ctx.store.getDailyStats.mockReturnValue([
{
date: new Date().toISOString().split('T')[0],
// LOCAL date (not toISOString/UTC): away-digest's dayOverlapsRange parses
// the date as local midnight, so a UTC date near the local-midnight boundary
// (e.g. running at 01:xx CEST = prior-day UTC) would fall outside the 1h
// window and make this assertion TZ/hour-flaky.
date: new Date().toLocaleDateString('en-CA'),
inputTokens: 100,
outputTokens: 200,
estimatedCost: 0.02,
+26
View File
@@ -138,6 +138,8 @@ describe('Codex quick start settings', () => {
expect(requests.find((req) => req.url === '/api/quick-start')?.body).toMatchObject({
caseName: 'codex-case',
mode: 'codex',
// tabs follow the w<n>-<case> naming convention (quick-start would otherwise auto-name codeman-<id>)
sessionName: 'w1-codex-case',
codexConfig: { dangerouslyBypassApprovals: true, renderMode: 'hybrid' },
});
expect(selected).toEqual(['sess-1']);
@@ -179,6 +181,30 @@ describe('case selector refresh', () => {
expect(app.filterCasePickerOptions(options, 'plex').map((option: any) => option.name)).toEqual(['plex-previews']);
});
it('labels dockerized cases with a short "(docker)" tag (or the custom host id)', () => {
const CodemanApp = function CodemanApp(this: any) {};
const context = vm.createContext({
CodemanApp,
localStorage: { getItem: () => null, setItem: () => {} },
document: { getElementById: () => null },
console,
});
const sessionUi = readFileSync(resolve(import.meta.dirname, '../src/web/public/session-ui.js'), 'utf8');
vm.runInContext(sessionUi, context, { filename: 'session-ui.js' });
const app = new (CodemanApp as any)();
const label = (c: any) => app.formatCasePickerLabel(c);
// default one-click host, the 'local' Docker-tab default, and per-case override
// hosts all collapse to the short "(docker)" tag.
expect(
label({ name: 'sandbox', location: 'docker', docker: { hostId: 'default', container: 'codeman-case-sandbox' } })
).toBe('sandbox (docker)');
expect(label({ name: 'sandbox', location: 'docker', docker: { hostId: 'local' } })).toBe('sandbox (docker)');
expect(label({ name: 'sandbox', location: 'docker', docker: { hostId: 'q-sandbox' } })).toBe('sandbox (docker)');
// a user-named docker host shows its id
expect(label({ name: 'ml', location: 'docker', docker: { hostId: 'gpu-box' } })).toBe('ml (gpu-box)');
});
it('launches the highlighted case with the current run mode when pressing Enter in the picker', () => {
const elements: Record<string, any> = {};
const listeners: Record<string, (event: any) => void> = {};