mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-03 14:09:42 +02:00
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:
+325
-22
@@ -21,13 +21,15 @@
|
||||
* @module docker-hosts
|
||||
*/
|
||||
|
||||
import { existsSync, mkdirSync } from 'node:fs';
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import fs from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { homedir } from 'node:os';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { execFile } from 'node:child_process';
|
||||
import { execFile, spawn } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
import { dataPath } from './config/instance.js';
|
||||
import type {
|
||||
DockerCase,
|
||||
DockerCommandMode,
|
||||
@@ -388,33 +390,236 @@ export function buildDockerCreateArgs(ctx: DockerCreateContext): string[] {
|
||||
return args;
|
||||
}
|
||||
|
||||
/**
|
||||
* PURE argv for building the agent base image locally (the programmatic mirror of
|
||||
* scripts/build-agent-image.mjs): `build -f <dockerfile> -t <image> [--no-cache]
|
||||
* <contextDir>`. Kept pure + unit-testable; the caller prepends the engine binary.
|
||||
*/
|
||||
export function agentImageBuildArgs(dockerfile: string, image: string, contextDir: string, noCache = false): string[] {
|
||||
return ['build', '-f', dockerfile, '-t', image, ...(noCache ? ['--no-cache'] : []), contextDir];
|
||||
}
|
||||
|
||||
// ========== 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' },
|
||||
/** Container Claude config dir (created gid-0 writable in the image). */
|
||||
export const CONTAINER_CLAUDE_DIR = `${CONTAINER_HOME}/.claude`;
|
||||
/** In-container path of the seeded (writable) `~/.claude.json`. */
|
||||
export const CLAUDE_JSON_HOME = `${CONTAINER_HOME}/.claude.json`;
|
||||
/** In-container path of the read-only host-seeded `~/.claude.json` (copied into HOME at launch). */
|
||||
export const CLAUDE_JSON_SEED = `${CONTAINER_HOME}/.codeman/claude.seed.json`;
|
||||
/** Read-only seed paths for the files copied into the container's `.claude`. */
|
||||
const CLAUDE_CREDS_SEED = `${CONTAINER_HOME}/.codeman/claude-creds.seed.json`;
|
||||
const CLAUDE_SETTINGS_SEED = `${CONTAINER_HOME}/.codeman/claude-settings.seed.json`;
|
||||
const CLAUDE_STATS_SEED = `${CONTAINER_HOME}/.codeman/claude-stats.seed.json`;
|
||||
/** Staging root for read-only host-cred seed mounts (codex/gemini/gcloud/opencode). */
|
||||
const CRED_SEED_DIR = `${CONTAINER_HOME}/.codeman/cred-seeds`;
|
||||
|
||||
/**
|
||||
* PURE: merge the host `~/.claude.json` into a config that makes an
|
||||
* already-authenticated Claude skip its INTERACTIVE onboarding inside the container
|
||||
* (the host file itself lacks these flags — the host install is grandfathered, so a
|
||||
* verbatim copy still triggers the theme picker + login wizard + folder-trust
|
||||
* prompt). Forces `hasCompletedOnboarding`, a `theme` (so the theme picker is
|
||||
* skipped), and marks the workspace project trusted + onboarded. Auth still comes
|
||||
* from the copied `oauthAccount` + the dir-mounted `~/.claude/.credentials.json`.
|
||||
*/
|
||||
export function buildSeamlessClaudeConfig(
|
||||
hostConfig: Record<string, unknown>,
|
||||
workspacePath: string,
|
||||
theme = 'dark'
|
||||
): Record<string, unknown> {
|
||||
const merged: Record<string, unknown> = { ...hostConfig };
|
||||
merged.hasCompletedOnboarding = true;
|
||||
if (typeof merged.theme !== 'string') merged.theme = theme;
|
||||
const projects = { ...((merged.projects as Record<string, Record<string, unknown>> | undefined) ?? {}) };
|
||||
const existing = (projects[workspacePath] as Record<string, unknown> | undefined) ?? {};
|
||||
const seenCount = existing.projectOnboardingSeenCount;
|
||||
projects[workspacePath] = {
|
||||
...existing,
|
||||
hasTrustDialogAccepted: true,
|
||||
hasCompletedProjectOnboarding: true,
|
||||
projectOnboardingSeenCount: typeof seenCount === 'number' && seenCount > 0 ? seenCount : 1,
|
||||
};
|
||||
merged.projects = projects;
|
||||
return merged;
|
||||
}
|
||||
|
||||
/** Best-effort read of the host `~/.claude/settings.json` theme (drives the seed's theme). */
|
||||
function readHostClaudeTheme(home: string): string | undefined {
|
||||
try {
|
||||
const parsed = JSON.parse(readFileSync(join(home, '.claude', 'settings.json'), 'utf-8')) as { theme?: unknown };
|
||||
return typeof parsed.theme === 'string' ? parsed.theme : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the read-only seed mount for `~/.claude.json`. Reads the host file, merges
|
||||
* in the seamless-onboarding flags + workspace trust (buildSeamlessClaudeConfig),
|
||||
* writes the result to a per-container seed file under `~/.codeman/docker-seeds/`,
|
||||
* and returns its mount. The launch chain copies it to `~/.claude.json` inside HOME
|
||||
* once — giving Claude a NORMAL writable, already-onboarded config (no atomic-rename
|
||||
* EBUSY, no re-auth, no theme/trust prompts). Falls back to the RAW host file when
|
||||
* parse/write fails (auth still works; the wizard may show). Returns null when the
|
||||
* host has no `~/.claude.json`. IO; under VITEST returns the raw mount (no write).
|
||||
*/
|
||||
export function resolveClaudeJsonSeedMount(
|
||||
home: string = homedir(),
|
||||
containerName?: string,
|
||||
workspacePath?: string
|
||||
): DockerMount | null {
|
||||
const src = join(home, '.claude.json');
|
||||
if (!existsSync(src)) return null;
|
||||
const rawMount: DockerMount = { src, dst: CLAUDE_JSON_SEED, readonly: true };
|
||||
if (IS_TEST_MODE || !containerName || !workspacePath) return rawMount;
|
||||
try {
|
||||
const hostConfig = JSON.parse(readFileSync(src, 'utf-8')) as Record<string, unknown>;
|
||||
const merged = buildSeamlessClaudeConfig(hostConfig, workspacePath, readHostClaudeTheme(home) ?? 'dark');
|
||||
const seedsDir = dataPath('docker-seeds');
|
||||
if (!existsSync(seedsDir)) mkdirSync(seedsDir, { recursive: true });
|
||||
const seedFile = join(seedsDir, `${containerName}.json`);
|
||||
writeFileSync(seedFile, JSON.stringify(merged), { mode: 0o600 });
|
||||
return { src: seedFile, dst: CLAUDE_JSON_SEED, readonly: true };
|
||||
} catch {
|
||||
return rawMount; // partial host write / unreadable — auth still carries, wizard may show
|
||||
}
|
||||
}
|
||||
|
||||
/** A file (or dir, when `recursive`) copied into the container HOME once at launch
|
||||
* (`[ -e to ] || cp [-a] from to`). */
|
||||
export interface DockerSeedCopy {
|
||||
from: string;
|
||||
to: string;
|
||||
/** `cp -a` for whole-directory credential seeds (gemini/gcloud/opencode). */
|
||||
recursive?: boolean;
|
||||
}
|
||||
|
||||
export interface DockerClaudeArtifacts {
|
||||
/** Bind mounts to add: the shared `projects/` transcripts (RW) + read-only seed files. */
|
||||
mounts: DockerMount[];
|
||||
/** Files copied into the container's writable HOME/.claude (+ HOME/.claude.json) at launch. */
|
||||
seedCopies: DockerSeedCopy[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the ISOLATED Claude artifacts for a docker session (replaces the old
|
||||
* whole-`~/.claude` RW mount that polluted the host). Shares ONLY what must cross
|
||||
* the boundary and seeds the rest as writable copies:
|
||||
* - `~/.claude/projects` → RW dir mount (transcripts: host watchers + `--resume`).
|
||||
* - `~/.claude.json` → merged onboarding seed, copied to HOME (no re-auth/wizard).
|
||||
* - `~/.claude/.credentials.json` + `~/.claude/settings.json` → read-only seeds
|
||||
* copied into the container's own `~/.claude` (token + global prefs carry in;
|
||||
* the container refreshes its own copy and never writes back to the host).
|
||||
* Everything else Claude writes (backups, tasks, teams, session-env, history) stays
|
||||
* container-local. IO (reads host files, writes the merged `.claude.json` seed).
|
||||
*/
|
||||
export function resolveDockerClaudeArtifacts(
|
||||
home: string,
|
||||
containerName: string,
|
||||
workspacePath: string
|
||||
): DockerClaudeArtifacts {
|
||||
const mounts: DockerMount[] = [];
|
||||
const seedCopies: DockerSeedCopy[] = [];
|
||||
|
||||
// The ONE genuinely-shared part: conversation transcripts (dir mount → renames work).
|
||||
const projectsSrc = join(home, '.claude', 'projects');
|
||||
if (existsSync(projectsSrc)) {
|
||||
mounts.push({ src: projectsSrc, dst: `${CONTAINER_CLAUDE_DIR}/projects` });
|
||||
}
|
||||
|
||||
// ~/.claude.json → merged, onboarding-complete seed at HOME root.
|
||||
const jsonSeed = resolveClaudeJsonSeedMount(home, containerName, workspacePath);
|
||||
if (jsonSeed) {
|
||||
mounts.push(jsonSeed);
|
||||
seedCopies.push({ from: CLAUDE_JSON_SEED, to: CLAUDE_JSON_HOME });
|
||||
}
|
||||
|
||||
// credentials (token) + settings (theme/model/effort/permissions) + stats-cache
|
||||
// (drives the model/effort status indicator) → writable copies inside the
|
||||
// container's own ~/.claude (never a wholesale mount → no host pollution).
|
||||
const files: Array<[rel: string, seed: string, dest: string]> = [
|
||||
['.credentials.json', CLAUDE_CREDS_SEED, `${CONTAINER_CLAUDE_DIR}/.credentials.json`],
|
||||
['settings.json', CLAUDE_SETTINGS_SEED, `${CONTAINER_CLAUDE_DIR}/settings.json`],
|
||||
['stats-cache.json', CLAUDE_STATS_SEED, `${CONTAINER_CLAUDE_DIR}/stats-cache.json`],
|
||||
];
|
||||
for (const [rel, seed, dest] of files) {
|
||||
const src = join(home, '.claude', rel);
|
||||
if (existsSync(src)) {
|
||||
mounts.push({ src, dst: seed, readonly: true });
|
||||
seedCopies.push({ from: seed, to: dest });
|
||||
}
|
||||
}
|
||||
|
||||
return { mounts, seedCopies };
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-CLI credential-store isolation policy (the codex/gemini/gcloud/opencode analog
|
||||
* of resolveDockerClaudeArtifacts). Codex is the direct Claude-analog: its
|
||||
* `sessions/` rollouts + `history.jsonl` are read HOST-SIDE (response-viewer +
|
||||
* `codex resume`), so they are SHARED (RW), while `auth.json`/`config.toml` are
|
||||
* seeded. The other three have no host-read/resume dependency and are fully
|
||||
* seed-copied (writable copy in the container, no write-back to the host).
|
||||
*/
|
||||
interface CredStorePolicy {
|
||||
/** Path relative to HOME (host + container), e.g. '.codex' or '.config/gcloud'. */
|
||||
rel: string;
|
||||
/** Subdirs bind-mounted RW (shared: resume + host reads). */
|
||||
shareDirs?: string[];
|
||||
/** Files bind-mounted RW (append-only, e.g. codex history.jsonl — never renamed). */
|
||||
shareFiles?: string[];
|
||||
/** Files seeded (RO mount → cp) into the container's own copy. */
|
||||
seedFiles?: string[];
|
||||
/** Seed the WHOLE dir (RO mount → cp -a) — for stores with no shared/host-read state. */
|
||||
seedWhole?: boolean;
|
||||
}
|
||||
|
||||
const CRED_STORES: CredStorePolicy[] = [
|
||||
{ rel: '.codex', shareDirs: ['sessions'], shareFiles: ['history.jsonl'], seedFiles: ['auth.json', 'config.toml'] },
|
||||
{ rel: '.gemini', seedWhole: true },
|
||||
{ rel: '.config/gcloud', seedWhole: true },
|
||||
{ rel: '.config/opencode', seedWhole: true },
|
||||
];
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Resolve the ISOLATED codex/gemini/gcloud/opencode artifacts (replaces the old
|
||||
* whole-dir RW mounts that let each in-container CLI write its refreshed tokens +
|
||||
* session state back into the host). Every path is existsSync-gated (on most hosts
|
||||
* only a subset exists). Pure-ish IO (no writes; just existence checks + mount specs).
|
||||
*/
|
||||
export function resolveCredentialMounts(home: string = homedir()): DockerMount[] {
|
||||
export function resolveDockerCredentialArtifacts(home: string = homedir()): DockerClaudeArtifacts {
|
||||
const mounts: DockerMount[] = [];
|
||||
for (const { rel } of CREDENTIAL_PATHS) {
|
||||
const src = join(home, rel);
|
||||
if (existsSync(src)) {
|
||||
mounts.push({ src, dst: `${CONTAINER_HOME}/${rel}` });
|
||||
const seedCopies: DockerSeedCopy[] = [];
|
||||
for (const store of CRED_STORES) {
|
||||
const hostBase = join(home, store.rel);
|
||||
if (!existsSync(hostBase)) continue;
|
||||
const containerBase = `${CONTAINER_HOME}/${store.rel}`;
|
||||
const seedName = store.rel.replace(/\//g, '-'); // '.config/gcloud' → '.config-gcloud'
|
||||
if (store.seedWhole) {
|
||||
const seed = `${CRED_SEED_DIR}/${seedName}`;
|
||||
mounts.push({ src: hostBase, dst: seed, readonly: true });
|
||||
seedCopies.push({ from: seed, to: containerBase, recursive: true });
|
||||
continue;
|
||||
}
|
||||
for (const sub of store.shareDirs ?? []) {
|
||||
const src = join(hostBase, sub);
|
||||
if (existsSync(src)) mounts.push({ src, dst: `${containerBase}/${sub}` });
|
||||
}
|
||||
for (const file of store.shareFiles ?? []) {
|
||||
const src = join(hostBase, file);
|
||||
if (existsSync(src)) mounts.push({ src, dst: `${containerBase}/${file}` });
|
||||
}
|
||||
for (const file of store.seedFiles ?? []) {
|
||||
const src = join(hostBase, file);
|
||||
if (existsSync(src)) {
|
||||
const seed = `${CRED_SEED_DIR}/${seedName}-${file}`;
|
||||
mounts.push({ src, dst: seed, readonly: true });
|
||||
seedCopies.push({ from: seed, to: `${containerBase}/${file}` });
|
||||
}
|
||||
}
|
||||
}
|
||||
return mounts;
|
||||
return { mounts, seedCopies };
|
||||
}
|
||||
|
||||
// ========== Daemon probes (IO; no-op under VITEST) ==========
|
||||
@@ -510,6 +715,104 @@ export async function checkDockerImagePresent(engine: DockerEngine, image: strin
|
||||
}
|
||||
}
|
||||
|
||||
export interface EnsureImageResult {
|
||||
ok: boolean;
|
||||
/** true when this call actually ran a build (vs. the image already existing). */
|
||||
built: boolean;
|
||||
alreadyPresent: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/** In-flight builds keyed by `engine:image`, so concurrent callers share ONE build. */
|
||||
const inFlightImageBuilds = new Map<string, Promise<EnsureImageResult>>();
|
||||
|
||||
/**
|
||||
* Resolve the repo's Dockerfile + build context. Works from BOTH src (dev/tsx) and
|
||||
* dist/index.js (esbuild prod: dist sits at repo root), since both are one level
|
||||
* under the repo root. Returns null when the Dockerfile is absent (npm-global
|
||||
* installs don't ship docker/ — Docker cases are a git-clone feature).
|
||||
*/
|
||||
function resolveAgentDockerfile(): { dockerfile: string; contextDir: string } | null {
|
||||
const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const dockerfile = join(repoRoot, 'docker', 'agent.Dockerfile');
|
||||
return existsSync(dockerfile) ? { dockerfile, contextDir: repoRoot } : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the agent base image exists, BUILDING it locally on first use so a missing
|
||||
* image is never a hard blocker (decision: "build locally on first use",
|
||||
* docs/docker-cases-plan.md). Idempotent, concurrency-safe (one build per
|
||||
* engine:image shared by concurrent callers), and a no-op under VITEST. Only the
|
||||
* DEFAULT image is auto-built — we can never build a user's custom ref, and the
|
||||
* `--pull=never` invariant forbids pulling. `onProgress` receives build output
|
||||
* lines for SSE surfacing.
|
||||
*/
|
||||
export async function ensureAgentBaseImage(
|
||||
engine: DockerEngine,
|
||||
image: string,
|
||||
opts: { onProgress?: (line: string) => void; noCache?: boolean } = {}
|
||||
): Promise<EnsureImageResult> {
|
||||
if (IS_TEST_MODE) return { ok: true, built: false, alreadyPresent: true };
|
||||
if (await checkDockerImagePresent(engine, image)) {
|
||||
return { ok: true, built: false, alreadyPresent: true };
|
||||
}
|
||||
if (image !== DEFAULT_AGENT_IMAGE) {
|
||||
return {
|
||||
ok: false,
|
||||
built: false,
|
||||
alreadyPresent: false,
|
||||
error: `image ${image} is not present and only ${DEFAULT_AGENT_IMAGE} is auto-built. Build or pull ${image} yourself.`,
|
||||
};
|
||||
}
|
||||
const key = `${engine}:${image}`;
|
||||
const existing = inFlightImageBuilds.get(key);
|
||||
if (existing) return existing;
|
||||
const build = buildAgentImage(engine, image, opts).finally(() => inFlightImageBuilds.delete(key));
|
||||
inFlightImageBuilds.set(key, build);
|
||||
return build;
|
||||
}
|
||||
|
||||
function buildAgentImage(
|
||||
engine: DockerEngine,
|
||||
image: string,
|
||||
opts: { onProgress?: (line: string) => void; noCache?: boolean }
|
||||
): Promise<EnsureImageResult> {
|
||||
const resolved = resolveAgentDockerfile();
|
||||
if (!resolved) {
|
||||
return Promise.resolve({
|
||||
ok: false,
|
||||
built: false,
|
||||
alreadyPresent: false,
|
||||
error: `docker/agent.Dockerfile not found in this install; clone the repo or build ${image} manually`,
|
||||
});
|
||||
}
|
||||
const args = agentImageBuildArgs(resolved.dockerfile, image, resolved.contextDir, opts.noCache);
|
||||
return new Promise<EnsureImageResult>((resolve) => {
|
||||
// async spawn (NEVER spawnSync) so a multi-minute build never wedges the event loop.
|
||||
const child = spawn(engine, args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
const forward = (buf: Buffer) => {
|
||||
for (const line of buf.toString('utf-8').split('\n')) {
|
||||
const trimmed = line.trimEnd();
|
||||
if (trimmed) opts.onProgress?.(trimmed);
|
||||
}
|
||||
};
|
||||
child.stdout?.on('data', forward);
|
||||
child.stderr?.on('data', forward);
|
||||
child.on('error', (err) => {
|
||||
resolve({
|
||||
ok: false,
|
||||
built: false,
|
||||
alreadyPresent: false,
|
||||
error: `could not spawn ${engine} build: ${err.message}`,
|
||||
});
|
||||
});
|
||||
child.on('exit', (code) => {
|
||||
if (code === 0) resolve({ ok: true, built: true, alreadyPresent: false });
|
||||
else resolve({ ok: false, built: false, alreadyPresent: false, error: `${engine} build failed (exit ${code})` });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export interface DockerTmuxCheckResult {
|
||||
ok: boolean;
|
||||
tmuxPath?: string;
|
||||
@@ -532,7 +835,7 @@ export async function checkDockerTmuxAvailable(
|
||||
return {
|
||||
ok: false,
|
||||
imageMissing: true,
|
||||
error: `base image ${docker.image} not present: build it with 'node scripts/build-agent-image.mjs' (or pull it)`,
|
||||
error: `image ${docker.image} not present (the default image is auto-built on first use; a custom image must be built or pulled first)`,
|
||||
};
|
||||
}
|
||||
try {
|
||||
|
||||
+49
-6
@@ -56,9 +56,11 @@ import {
|
||||
CONTAINER_HOME,
|
||||
defaultDockerCommandForMode,
|
||||
hostGatewayAlias,
|
||||
resolveCredentialMounts,
|
||||
resolveDockerClaudeArtifacts,
|
||||
resolveDockerCredentialArtifacts,
|
||||
type DockerCreateContext,
|
||||
type DockerMount,
|
||||
type DockerSeedCopy,
|
||||
} from './docker-hosts.js';
|
||||
import {
|
||||
wrapWithNice,
|
||||
@@ -885,6 +887,14 @@ export interface DockerLaunchOptions {
|
||||
execEnv: Record<string, string>;
|
||||
/** exec-time NAME-ONLY env forwarded from Codeman's process env (codex/gemini keys) */
|
||||
execEnvNames: string[];
|
||||
/**
|
||||
* Files to copy from read-only seed mounts into the container's writable HOME once
|
||||
* before launch (guarded so reconnects never clobber). Isolates Claude state: the
|
||||
* merged `~/.claude.json`, plus `~/.claude/.credentials.json` + `settings.json`,
|
||||
* are writable copies (not host mounts), so the container never re-auths and never
|
||||
* writes its runtime state back into the host `~/.claude`.
|
||||
*/
|
||||
seedCopies?: DockerSeedCopy[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -895,7 +905,7 @@ export interface DockerLaunchOptions {
|
||||
* command -> `docker exec … sh -lc '<tmux>'` -> tmux `'<paneCommand>'`.
|
||||
*/
|
||||
export function buildDockerLaunchCommand(opts: DockerLaunchOptions): string {
|
||||
const { mode, docker, sessionId, resumeSessionId, createContext, execEnv, execEnvNames } = opts;
|
||||
const { mode, docker, sessionId, resumeSessionId, createContext, execEnv, execEnvNames, seedCopies } = opts;
|
||||
const base = buildDockerBaseArgs(docker).join(' ');
|
||||
const createArgs = buildDockerCreateArgs(createContext).join(' ');
|
||||
const name = shellescape(docker.containerName);
|
||||
@@ -932,7 +942,7 @@ export function buildDockerLaunchCommand(opts: DockerLaunchOptions): string {
|
||||
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)`
|
||||
`Codeman: base image ${docker.image} not present (it is normally auto-built on first use)`
|
||||
);
|
||||
const startFailMsg = shellescape(`Codeman: container ${docker.containerName} failed to start (docker daemon down?)`);
|
||||
|
||||
@@ -940,7 +950,18 @@ export function buildDockerLaunchCommand(opts: DockerLaunchOptions): string {
|
||||
// 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)}`;
|
||||
// Seed writable credential config from read-only host mounts ONCE per container
|
||||
// (guarded by [ -e ] so reconnects never clobber in-container config; `cp -a` for
|
||||
// whole-dir credential seeds). mkdir -p the parent so a file seed works even when
|
||||
// no sibling share-mount pre-created the dir. Paths are fixed CONTAINER_HOME
|
||||
// constants (no shell metachars), so the whole inner command is shell-quoted once.
|
||||
const seedSteps = (seedCopies ?? []).map((s) => {
|
||||
const cp = s.recursive ? 'cp -a' : 'cp';
|
||||
const parent = s.to.slice(0, s.to.lastIndexOf('/'));
|
||||
return `mkdir -p ${parent} 2>/dev/null; [ -e ${s.to} ] || ${cp} ${s.from} ${s.to} 2>/dev/null || true`;
|
||||
});
|
||||
const innerCmd = seedSteps.length ? `${seedSteps.join(' ; ')} ; ${tmuxInvocation}` : tmuxInvocation;
|
||||
const execCmd = `exec ${base} exec -it --workdir ${workdir} ${execEnvFlags.join(' ')} ${name} sh -lc ${shellescape(innerCmd)}`;
|
||||
|
||||
return [imageCheck, ensure, start, execCmd].join(' ; ');
|
||||
}
|
||||
@@ -991,12 +1012,29 @@ export function resolveDockerLaunchOptions(
|
||||
: ['--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 credentialMounts: DockerMount[] = [];
|
||||
const extraMounts: DockerMount[] = [];
|
||||
// Isolated credential state (Claude + codex/gemini/gcloud/opencode): each store
|
||||
// shares ONLY what a host feature / --resume needs (Claude projects/, codex
|
||||
// sessions/+history) and seeds everything else (tokens, settings, configs) as
|
||||
// writable copies, so the container is authed WITHOUT re-auth and WITHOUT writing
|
||||
// its runtime state back into the host dirs. Only when credentials are mounted.
|
||||
let seedCopies: DockerSeedCopy[] = [];
|
||||
if (docker.mountCredentials) {
|
||||
const claudeArtifacts = resolveDockerClaudeArtifacts(home, docker.containerName, docker.containerWorkdir);
|
||||
const credArtifacts = resolveDockerCredentialArtifacts(home);
|
||||
extraMounts.push(...claudeArtifacts.mounts, ...credArtifacts.mounts);
|
||||
seedCopies = [...claudeArtifacts.seedCopies, ...credArtifacts.seedCopies];
|
||||
}
|
||||
const envCreate: Record<string, string> = {
|
||||
HOME: CONTAINER_HOME,
|
||||
TERM: 'xterm-256color',
|
||||
COLORTERM: 'truecolor',
|
||||
// Force a UTF-8 locale (the base image defaults to POSIX/C). Without this, tmux
|
||||
// runs in non-UTF-8 mode and renders Claude's Unicode box-drawing (─│┌┐) as raw
|
||||
// VT100 ACS glyphs (`qqqq…`). `C.UTF-8` is built into glibc (no locale-gen).
|
||||
LANG: 'C.UTF-8',
|
||||
LC_ALL: 'C.UTF-8',
|
||||
// Give claude a temp dir it will own inside HOME. Its default `/tmp/claude-<uid>`
|
||||
// is refused when that path pre-exists root-owned — which happens when the
|
||||
// workspace bind-mount path traverses it (e.g. a workspace under /tmp/claude-<uid>).
|
||||
@@ -1031,6 +1069,11 @@ export function resolveDockerLaunchOptions(
|
||||
const execEnv: Record<string, string> = {
|
||||
TERM: 'xterm-256color',
|
||||
COLORTERM: 'truecolor',
|
||||
// UTF-8 at exec time too, so the tmux CLIENT this exec launches is UTF-8 and
|
||||
// renders box-drawing correctly even when reattaching to a container created
|
||||
// before this fix (client_utf8 is per-client, resolved from the exec's locale).
|
||||
LANG: 'C.UTF-8',
|
||||
LC_ALL: 'C.UTF-8',
|
||||
CODEMAN_SESSION_ID: sessionId.slice(0, 8),
|
||||
CODEMAN_MUX: '1',
|
||||
};
|
||||
@@ -1043,7 +1086,7 @@ export function resolveDockerLaunchOptions(
|
||||
? ['GEMINI_API_KEY', 'GOOGLE_API_KEY']
|
||||
: [];
|
||||
|
||||
return { mode, docker, sessionId, resumeSessionId, createContext, execEnv, execEnvNames };
|
||||
return { mode, docker, sessionId, resumeSessionId, createContext, execEnv, execEnvNames, seedCopies };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1452,6 +1452,30 @@ class CodemanApp {
|
||||
console.error('[SSE] docker export failed:', err);
|
||||
}
|
||||
});
|
||||
// Base image auto-build on first Docker case (build-on-first-use). A single
|
||||
// multi-minute event; surface start/finish so the Run spinner is explained.
|
||||
addListener(SSE_EVENTS.DOCKER_IMAGE_BUILD_STARTED, () => {
|
||||
this.showToast('Building the Codeman agent image (first Docker case, a few minutes)...', 'info', {
|
||||
duration: 8000,
|
||||
});
|
||||
});
|
||||
addListener(SSE_EVENTS.DOCKER_IMAGE_BUILD_COMPLETE, (e) => {
|
||||
try {
|
||||
const d = e.data ? JSON.parse(e.data) : {};
|
||||
if (d.error) this.showToast(`Agent image build failed: ${d.error}`, 'error');
|
||||
else this.showToast('Agent image ready. Starting the container...', 'success');
|
||||
} catch (err) {
|
||||
console.error('[SSE] docker image build complete:', err);
|
||||
}
|
||||
});
|
||||
addListener(SSE_EVENTS.DOCKER_IMAGE_BUILD_FAILED, (e) => {
|
||||
try {
|
||||
const d = e.data ? JSON.parse(e.data) : {};
|
||||
this.showToast(`Agent image build failed: ${d.error || 'unknown error'}`, 'error');
|
||||
} catch (err) {
|
||||
console.error('[SSE] docker image build failed:', err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -477,6 +477,10 @@ const SSE_EVENTS = {
|
||||
DOCKER_EXPORT_COMPLETE: 'docker:exportComplete',
|
||||
DOCKER_EXPORT_FAILED: 'docker:exportFailed',
|
||||
DOCKER_IMPORT_COMPLETE: 'docker:importComplete',
|
||||
DOCKER_IMAGE_BUILD_STARTED: 'docker:imageBuildStarted',
|
||||
DOCKER_IMAGE_BUILD_PROGRESS: 'docker:imageBuildProgress',
|
||||
DOCKER_IMAGE_BUILD_COMPLETE: 'docker:imageBuildComplete',
|
||||
DOCKER_IMAGE_BUILD_FAILED: 'docker:imageBuildFailed',
|
||||
};
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -124,6 +124,7 @@
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>
|
||||
<span class="attachment-history-badge" id="attachmentHistoryBadge" style="display:none;">0</span>
|
||||
</button>
|
||||
<button class="btn-icon-header btn-file-viewer btn-file-viewer--hidden" onclick="app.toggleFileBrowserButton()" title="File Viewer" aria-label="Open file viewer" aria-expanded="false"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg></button>
|
||||
<button class="btn-icon-header btn-multimonitor btn-multimonitor--hidden" onclick="app.launchMultiMonitor()" title="Open Codeman across all displays" aria-label="Open Codeman across all displays"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="4" width="13" height="9" rx="1.5"/><rect x="11" y="9" width="11" height="8" rx="1.5"/></svg></button>
|
||||
<button class="btn-icon-header btn-ultracode-agents btn-ultracode-agents--hidden" onclick="app.toggleUltracodeAgentsPanel()" title="Ultracode / Workflow agents" aria-label="Open ultracode workflow agents"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="6" cy="6" r="2.5"/><circle cx="6" cy="18" r="2.5"/><circle cx="18" cy="12" r="2.5"/><path d="M8.2 7.2 15.6 11M8.2 16.8 15.6 13"/></svg></button>
|
||||
<div class="header-plan-usage header-plan-usage--hidden" id="planUsageChip" title="Claude plan usage limits">—</div>
|
||||
@@ -1268,6 +1269,13 @@
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="settings-item" title="Show the file viewer button in header (opens the file browser panel for the active session)">
|
||||
<span class="settings-item-label">File Viewer</span>
|
||||
<label class="switch switch-sm">
|
||||
<input type="checkbox" id="appSettingsShowFileViewerButton">
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="settings-item" title="Show the attachments button in header (opens the attachment history drawer)">
|
||||
<span class="settings-item-label">Attachments Button</span>
|
||||
<label class="switch switch-sm">
|
||||
@@ -1864,12 +1872,12 @@
|
||||
<label>Description (optional)</label>
|
||||
<input type="text" id="newCaseDescription" placeholder="A brief description..." autocomplete="off">
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label class="checkbox-row"><input type="checkbox" id="newCaseDocker" onchange="app.toggleDockerQuickSettings()"> 🐳 Run in an isolated Docker container</label>
|
||||
<span class="form-hint">One checkbox is enough — it creates the case folder AND a hardened container with default settings, then starts the session inside it. Click to expand for optional presets. Requires the base image (<code>node scripts/build-agent-image.mjs</code>).</span>
|
||||
<div class="form-row docker-quick-row">
|
||||
<label class="checkbox-row"><input type="checkbox" id="newCaseDocker"> 🐳 Run in an isolated Docker container</label>
|
||||
<span class="form-hint">Runs this case in a hardened, isolated container. The base image is built automatically on first use.</span>
|
||||
</div>
|
||||
<details class="advanced-options" id="dockerQuickSettings" style="display:none;">
|
||||
<summary>Container settings (optional — sensible defaults)</summary>
|
||||
<details class="advanced-options docker-quick-settings" id="dockerQuickSettings">
|
||||
<summary>Container settings (optional, sensible defaults)</summary>
|
||||
<div class="advanced-options-content">
|
||||
<div class="form-row">
|
||||
<label>Template</label>
|
||||
@@ -1880,7 +1888,7 @@
|
||||
<option value="gpu">GPU — 8 GB RAM, 4 CPU, all GPUs</option>
|
||||
<option value="custom">Custom</option>
|
||||
</select>
|
||||
<span class="form-hint">Disk is elastic — storage grows automatically as data flows in (no fixed cap).</span>
|
||||
<span class="form-hint">Disk is elastic: storage grows automatically as data flows in (no fixed cap).</span>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>Memory</label>
|
||||
|
||||
@@ -3098,6 +3098,32 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
},
|
||||
|
||||
// Header "File Viewer" button (opt-in via App Settings → Header Displays →
|
||||
// File Viewer). Toggles the file browser panel open/closed without a trip
|
||||
// through settings. Persists via the same `showFileBrowser` flag the Panels
|
||||
// section + the panel's own close (X) use, so the three stay in sync.
|
||||
toggleFileBrowserButton() {
|
||||
const panel = this.$('fileBrowserPanel');
|
||||
const isOpen = panel?.classList.contains('visible');
|
||||
const btn = document.querySelector('.btn-file-viewer');
|
||||
if (isOpen) {
|
||||
this.closeFileBrowserPanel();
|
||||
if (btn) btn.setAttribute('aria-expanded', 'false');
|
||||
return;
|
||||
}
|
||||
if (!this.activeSessionId) {
|
||||
this.showToast('Open a session to browse its files', 'info');
|
||||
return;
|
||||
}
|
||||
const settings = this.loadAppSettingsFromStorage();
|
||||
settings.showFileBrowser = true;
|
||||
this.saveAppSettingsToStorage(settings);
|
||||
const checkbox = document.getElementById('appSettingsShowFileBrowser');
|
||||
if (checkbox) checkbox.checked = true;
|
||||
this.applyMonitorVisibility();
|
||||
if (btn) btn.setAttribute('aria-expanded', 'true');
|
||||
},
|
||||
|
||||
closeFileBrowserPanel() {
|
||||
const panel = this.$('fileBrowserPanel');
|
||||
if (panel) {
|
||||
@@ -3130,6 +3156,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
const settings = this.loadAppSettingsFromStorage();
|
||||
settings.showFileBrowser = false;
|
||||
this.saveAppSettingsToStorage(settings);
|
||||
const checkbox = document.getElementById('appSettingsShowFileBrowser');
|
||||
if (checkbox) checkbox.checked = false;
|
||||
const headerBtn = document.querySelector('.btn-file-viewer');
|
||||
if (headerBtn) headerBtn.setAttribute('aria-expanded', 'false');
|
||||
},
|
||||
|
||||
async openFilePreview(filePath, sessionId = this.activeSessionId, attachmentId = null) {
|
||||
|
||||
@@ -46,10 +46,19 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
formatCasePickerLabel(c) {
|
||||
if (c?.location === 'remote' && c.remote?.hostId) return `${c.name} @ ${c.remote.hostId}`;
|
||||
if (c?.location === 'docker' && c.docker?.container) return `${c.name} @ ${c.docker.container}`;
|
||||
if (c?.location === 'docker') return `${c.name} (${this.dockerCaseTag(c.docker?.hostId)})`;
|
||||
return c?.name || '';
|
||||
},
|
||||
|
||||
// Short parenthetical tag for a dockerized case: '(docker)' for the default /
|
||||
// auto-provisioned host (one-click "Run in Docker", the Docker-tab 'local'
|
||||
// default, or a per-case 'q-<name>' resource-override host), otherwise the custom
|
||||
// docker host id the user named (e.g. '(gpu-box)'). Keeps the case name short.
|
||||
dockerCaseTag(hostId) {
|
||||
if (!hostId || hostId === 'default' || hostId === 'local' || /^q-/.test(hostId)) return 'docker';
|
||||
return hostId;
|
||||
},
|
||||
|
||||
buildCasePickerOptions(cases = []) {
|
||||
const normalized = [];
|
||||
const seen = new Set();
|
||||
@@ -485,6 +494,22 @@ Object.assign(CodemanApp.prototype, {
|
||||
input.value = Math.max(1, current - 1);
|
||||
},
|
||||
|
||||
// Next free <prefix><n> index for a case's session tabs (e.g. w1-<case>,
|
||||
// w2-<case> for agents, s1-<case> for shells), shared by the local and
|
||||
// remote/docker launch paths so all tabs follow the same naming convention.
|
||||
_nextCaseSessionStartNumber(caseName, prefix = 'w') {
|
||||
const re = new RegExp(`^${prefix}(\\d+)-([a-zA-Z0-9_-]+)`);
|
||||
let startNumber = 1;
|
||||
for (const [, session] of this.sessions || []) {
|
||||
const match = session.name && session.name.match(re);
|
||||
if (match && match[2] === caseName) {
|
||||
const num = parseInt(match[1]);
|
||||
if (num >= startNumber) startNumber = num + 1;
|
||||
}
|
||||
}
|
||||
return startNumber;
|
||||
},
|
||||
|
||||
async runClaude() {
|
||||
const caseName = document.getElementById('quickStartCase').value || 'testcase';
|
||||
const tabCount = Math.min(20, Math.max(1, parseInt(document.getElementById('tabCount').value) || 1));
|
||||
@@ -523,12 +548,15 @@ Object.assign(CodemanApp.prototype, {
|
||||
// the LOCAL fs (a remote user@host:/path never exists locally), so route them
|
||||
// through /api/quick-start, which resolves the remote case + launches via ssh.
|
||||
if (caseData.location === 'remote' || caseData.location === 'docker') {
|
||||
// Name remote/docker tabs with the same w<n>-<case> convention as local
|
||||
// sessions (quick-start would otherwise auto-generate codeman-<id>).
|
||||
const startNumber = this._nextCaseSessionStartNumber(caseName);
|
||||
const remoteIds = [];
|
||||
for (let i = 0; i < tabCount; i++) {
|
||||
const res = await fetch('/api/quick-start', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ caseName, mode: 'claude' })
|
||||
body: JSON.stringify({ caseName, mode: 'claude', sessionName: `w${startNumber + i}-${caseName}` })
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!data.success) throw new Error(data.error || 'Failed to start remote Claude session');
|
||||
@@ -546,16 +574,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
let firstSessionId = null;
|
||||
|
||||
// Find the highest existing w-number for THIS case to avoid duplicates
|
||||
let startNumber = 1;
|
||||
for (const [, session] of this.sessions) {
|
||||
const match = session.name && session.name.match(/^w(\d+)-([a-zA-Z0-9_-]+)/);
|
||||
if (match && match[2] === caseName) {
|
||||
const num = parseInt(match[1]);
|
||||
if (num >= startNumber) {
|
||||
startNumber = num + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
const startNumber = this._nextCaseSessionStartNumber(caseName);
|
||||
|
||||
// Get global Ralph tracker setting
|
||||
const ralphEnabled = this.isRalphTrackerEnabledByDefault();
|
||||
@@ -709,12 +728,13 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
// Remote cases run over ssh — route through /api/quick-start (see runClaude).
|
||||
if (caseData.location === 'remote' || caseData.location === 'docker') {
|
||||
const startNumber = this._nextCaseSessionStartNumber(caseName, 's');
|
||||
const remoteIds = [];
|
||||
for (let i = 0; i < shellCount; i++) {
|
||||
const res = await fetch('/api/quick-start', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ caseName, mode: 'shell' })
|
||||
body: JSON.stringify({ caseName, mode: 'shell', sessionName: `s${startNumber + i}-${caseName}` })
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!data.success) throw new Error(data.error || 'Failed to start remote shell session');
|
||||
@@ -730,16 +750,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
|
||||
// Find the highest existing s-number for THIS case to avoid duplicates
|
||||
let startNumber = 1;
|
||||
for (const [, session] of this.sessions) {
|
||||
const match = session.name && session.name.match(/^s(\d+)-([a-zA-Z0-9_-]+)/);
|
||||
if (match && match[2] === caseName) {
|
||||
const num = parseInt(match[1]);
|
||||
if (num >= startNumber) {
|
||||
startNumber = num + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
const startNumber = this._nextCaseSessionStartNumber(caseName, 's');
|
||||
|
||||
// Create all shell sessions in parallel
|
||||
const sessionNames = [];
|
||||
@@ -828,6 +839,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
body: JSON.stringify({
|
||||
caseName,
|
||||
mode: 'opencode',
|
||||
sessionName: `w${this._nextCaseSessionStartNumber(caseName)}-${caseName}`,
|
||||
...(isRemote ? {} : {
|
||||
openCodeConfig: { autoAllowTools: true },
|
||||
...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}),
|
||||
@@ -880,6 +892,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
body: JSON.stringify({
|
||||
caseName,
|
||||
mode: 'codex',
|
||||
sessionName: `w${this._nextCaseSessionStartNumber(caseName)}-${caseName}`,
|
||||
...(isRemote ? {} : {
|
||||
codexConfig: {
|
||||
dangerouslyBypassApprovals: globalSettings.codexDangerouslyBypassApprovals ?? false,
|
||||
@@ -934,6 +947,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
body: JSON.stringify({
|
||||
caseName,
|
||||
mode: 'gemini',
|
||||
sessionName: `w${this._nextCaseSessionStartNumber(caseName)}-${caseName}`,
|
||||
...(isRemote ? {} : {
|
||||
geminiConfig: { approvalMode: 'yolo' },
|
||||
...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}),
|
||||
@@ -1681,13 +1695,6 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
},
|
||||
|
||||
// Show/hide the expandable container-settings section under the Docker checkbox.
|
||||
toggleDockerQuickSettings() {
|
||||
const on = document.getElementById('newCaseDocker')?.checked;
|
||||
const el = document.getElementById('dockerQuickSettings');
|
||||
if (el) el.style.display = on ? '' : 'none';
|
||||
},
|
||||
|
||||
// Fill the memory/cpu/gpu fields from a resource template. `medium` clears them so
|
||||
// the server uses its defaults (no per-case host); `custom` leaves them editable.
|
||||
applyDockerTemplate() {
|
||||
|
||||
@@ -307,6 +307,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
document.getElementById('appSettingsShowSystemStats').checked = settings.showSystemStats ?? defaults.showSystemStats ?? true;
|
||||
document.getElementById('appSettingsShowLifecycleLog').checked = settings.showLifecycleLog ?? defaults.showLifecycleLog ?? true;
|
||||
document.getElementById('appSettingsShowResponseViewer').checked = settings.showResponseViewer ?? defaults.showResponseViewer ?? false;
|
||||
document.getElementById('appSettingsShowFileViewerButton').checked = settings.showFileViewerButton ?? defaults.showFileViewerButton ?? false;
|
||||
document.getElementById('appSettingsShowAttachmentsButton').checked = settings.showAttachmentsButton ?? defaults.showAttachmentsButton ?? false;
|
||||
document.getElementById('appSettingsSkin').value = settings.skin ?? defaults.skin ?? 'daylight-blue';
|
||||
// WebGL renderer (desktop only — mobile always uses the DOM renderer, so hide
|
||||
@@ -1426,6 +1427,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
showSystemStats: document.getElementById('appSettingsShowSystemStats').checked,
|
||||
showLifecycleLog: document.getElementById('appSettingsShowLifecycleLog').checked,
|
||||
showResponseViewer: document.getElementById('appSettingsShowResponseViewer').checked,
|
||||
showFileViewerButton: document.getElementById('appSettingsShowFileViewerButton').checked,
|
||||
showAttachmentsButton: document.getElementById('appSettingsShowAttachmentsButton').checked,
|
||||
showMonitor: document.getElementById('appSettingsShowMonitor').checked,
|
||||
showProjectInsights: document.getElementById('appSettingsShowProjectInsights').checked,
|
||||
@@ -1618,6 +1620,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
skin: _skin,
|
||||
showPlanUsageLimits: _pul,
|
||||
showAttachmentsButton: _ahb,
|
||||
showFileViewerButton: _fvb,
|
||||
webglRendererEnabled: _wgl,
|
||||
terminalWheelLocalScrollback: _twls,
|
||||
// Per-device header/toolbar button toggles — client-only, and absent from
|
||||
@@ -1781,6 +1784,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
showMultiMonitorButton: false,
|
||||
showPlanUsageLimits: false,
|
||||
showAttachmentsButton: false,
|
||||
showFileViewerButton: false,
|
||||
showRedrawButton: false,
|
||||
showSessionButton: false,
|
||||
showAwayDigestButton: false,
|
||||
@@ -1897,6 +1901,14 @@ Object.assign(CodemanApp.prototype, {
|
||||
attachmentsBtn.classList.toggle('btn-attachments-history--hidden', !showAttachmentsButton);
|
||||
}
|
||||
|
||||
// File Viewer header button — opt-in, default OFF. Marker class (base is
|
||||
// display:inline-flex !important); clicking it toggles the file browser panel.
|
||||
const showFileViewerButton = settings.showFileViewerButton ?? defaults.showFileViewerButton ?? false;
|
||||
const fileViewerBtn = document.querySelector('.btn-file-viewer');
|
||||
if (fileViewerBtn) {
|
||||
fileViewerBtn.classList.toggle('btn-file-viewer--hidden', !showFileViewerButton);
|
||||
}
|
||||
|
||||
// Multi-monitor button — hidden by default (App Settings → Display → "Header
|
||||
// Displays"). The server renders the correct initial state on every reload;
|
||||
// this handles a live toggle from a settings save (no reload). Toggle the
|
||||
@@ -2191,7 +2203,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
'showLifecycleLog', 'showResponseViewer', 'showRedrawButton',
|
||||
'showMonitor', 'showProjectInsights', 'showFileBrowser', 'showSubagents',
|
||||
'subagentActiveTabOnly', 'tabTwoRows', 'localEchoEnabled', 'cjkInputEnabled', 'extendedKeyboardBar',
|
||||
'skin', 'showPlanUsageLimits', 'showAttachmentsButton', 'webglRendererEnabled',
|
||||
'skin', 'showPlanUsageLimits', 'showAttachmentsButton', 'showFileViewerButton', 'webglRendererEnabled',
|
||||
'terminalWheelLocalScrollback',
|
||||
'showSessionButton', 'showAwayDigestButton', 'showCronButton',
|
||||
]);
|
||||
|
||||
@@ -9447,6 +9447,24 @@ kbd {
|
||||
padding-left: 0.5rem;
|
||||
}
|
||||
|
||||
/* "Run in Docker" quick option — the primary one-click entry point, so its
|
||||
label + hint read larger and brighter than the standard form label/hint. */
|
||||
.docker-quick-row .checkbox-row {
|
||||
font-size: 0.9rem;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.docker-quick-row .form-hint {
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
/* The container-settings panel is always shown + expanded (no longer gated on
|
||||
the checkbox), so its summary header reads a touch larger too. */
|
||||
.docker-quick-settings > summary {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════
|
||||
Response Viewer — native-scroll overlay for reading Claude responses
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
@@ -9489,6 +9507,17 @@ kbd {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* "File Viewer" header button — opt-in (App Settings → Header Displays), hidden
|
||||
by default. Clicking it toggles the file browser panel. Same marker pattern as
|
||||
the response viewer: a base inline-flex !important so an inline style can't
|
||||
override it, and a more-specific marker rule to hide. */
|
||||
.btn-file-viewer {
|
||||
display: inline-flex !important;
|
||||
}
|
||||
.btn-file-viewer.btn-file-viewer--hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* "Cron" footer-toolbar button — shown by default (App Settings → Display can
|
||||
hide it). Toolbar button, not a header icon, so only the hide marker is
|
||||
needed; out-specify any base .btn-toolbar display. */
|
||||
|
||||
@@ -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 } };
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -536,6 +536,9 @@ export const QuickStartSchema = z.object({
|
||||
.string()
|
||||
.regex(/^[a-zA-Z0-9_-]+$/, 'Invalid case name format. Use only letters, numbers, hyphens, underscores.')
|
||||
.optional(),
|
||||
/** Display name for the created session tab (e.g. w1-mycase). Cosmetic; the durable
|
||||
* mux/container names derive from the session id, not this. Defaults server-side. */
|
||||
sessionName: z.string().max(128).optional(),
|
||||
mode: z.enum(['claude', 'shell', 'opencode', 'codex', 'gemini']).optional(),
|
||||
openCodeConfig: OpenCodeConfigSchema,
|
||||
codexConfig: CodexConfigSchema,
|
||||
|
||||
@@ -377,6 +377,14 @@ export const DockerExportComplete = 'docker:exportComplete' as const;
|
||||
export const DockerExportFailed = 'docker:exportFailed' as const;
|
||||
/** A docker bundle was imported into a new case. */
|
||||
export const DockerImportComplete = 'docker:importComplete' as const;
|
||||
/** The agent base image started building (first Docker case; auto-build on first use). */
|
||||
export const DockerImageBuildStarted = 'docker:imageBuildStarted' as const;
|
||||
/** A line of agent base-image build output (progress surfacing). */
|
||||
export const DockerImageBuildProgress = 'docker:imageBuildProgress' as const;
|
||||
/** The agent base image finished building successfully. */
|
||||
export const DockerImageBuildComplete = 'docker:imageBuildComplete' as const;
|
||||
/** The agent base image build failed. */
|
||||
export const DockerImageBuildFailed = 'docker:imageBuildFailed' as const;
|
||||
|
||||
// ─── Namespace Re-export ─────────────────────────────────────────────────────
|
||||
|
||||
@@ -564,4 +572,8 @@ export const SseEvent = {
|
||||
DockerExportComplete,
|
||||
DockerExportFailed,
|
||||
DockerImportComplete,
|
||||
DockerImageBuildStarted,
|
||||
DockerImageBuildProgress,
|
||||
DockerImageBuildComplete,
|
||||
DockerImageBuildFailed,
|
||||
} as const;
|
||||
|
||||
Reference in New Issue
Block a user