mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
chore: version packages
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,27 @@
|
|||||||
# aicodeman
|
# aicodeman
|
||||||
|
|
||||||
|
## 1.4.2
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- Docker session-mode deep-review fixes (all e2e-verified against a real daemon), a new Auto permission mode, and docs sync.
|
||||||
|
|
||||||
|
**Docker resume actually works now.** `DockerCase.lastClaudeSessionId` was read at quick-start but never written anywhere, so the documented resume-after-container-stop never fired. Claude-mode docker panes now pin a deterministic conversation id (`claudeDockerPaneCommand()` in tmux-manager.ts): fresh launches run `claude --session-id <sessionId> || claude --resume <sessionId>` (a duplicate `--session-id` exits 1 "already in use", so the fallback resumes after a container stop/reboot; verified CLI behavior), explicit resumes run `--resume <rid> || --session-id <sid>` so a stale id never dead-panes; the leading `exec ` is stripped so the fallback can run. The id is persisted via `persistDockerCaseClaudeSessionId()` at quick-start launch and again on hook / last-response conversation-id adoption (post-`/clear` switches track). Verified end-to-end: a conversation survives `docker stop` + relaunch AND a full container recreate.
|
||||||
|
|
||||||
|
**Config drift detection + recreate (was documented but entirely missing).** The `codeman.confighash` label was stamped but never read, so docker-host config edits silently never applied. Quick-start now compares via `checkDockerConfigDrift()` and refuses a drifted launch with `CONFLICT`; the UI shows a confirm and calls the new `POST /api/docker-cases/:name/recreate` (refused while sessions of the case are live), then relaunches — verified e2e that an edited `--memory` cap is applied after recreate and the conversation resumes. New SSE event `docker:containerRecreated` (both registries).
|
||||||
|
|
||||||
|
**Model picker now applies to docker sessions.** `modelOverride` was absent from `QuickStartSchema`, so the App Settings Claude Model choice was silently inert for docker runs. It is now accepted, applied via `updateCaseModel` for local and docker quick-starts, sent by the frontend docker run path, and explicitly rejected for remote quick-starts (the settings file would land on the wrong machine).
|
||||||
|
|
||||||
|
**Import hardening.** `importDockerBundle` now validates the (cross-machine, untrusted) manifest before trusting any field (`validateImportManifest`: engine/image/containerWorkdir/network/caseName/schemaVersion — a hostile `engine` could previously select the probe binary); the outer bundle tar gets the same member traversal guard as the inner workspace tar; the quarantine image tag derives from the schema-validated `newCaseName` instead of the manifest's; re-importing a case name now refreshes the auto-created `imported-<name>` host instead of leaving it pinned to the previous import's image tag.
|
||||||
|
|
||||||
|
**Remote-daemon correctness.** All docker probes and the base-image auto-build now honor a host's `context`/`daemonHost` (`dockerEngineArgv`); previously they always probed the local daemon.
|
||||||
|
|
||||||
|
**Smaller fixes:** commas are now rejected in docker workspace/workdir/destination paths (a comma corrupts the `--mount type=bind,src=...` CSV spec, which shell escaping cannot protect); `refreshDockerExports` used a never-defined `this.escapeHtml` (dead escaping, now the real `escapeHtml`); `docker:importComplete` and `docker:containerRecreated` now have frontend SSE listeners so other open tabs refresh; the opt-in File Viewer header button is hidden on phone headers like its siblings; the Run-in-Docker hint notes that Docker/Podman must be installed; `docs/docker-cases.md` documents the resume + drift-recreate lifecycle.
|
||||||
|
|
||||||
|
**New: Auto permission mode.** App Settings → Startup Mode gains `auto` (`claude --permission-mode auto`, Claude Code 2.1.207+): no routine prompts, with the background safety classifier guarding destructive actions. Threaded through both spawn paths (direct PTY and tmux) with tests; the default stays `--dangerously-skip-permissions`.
|
||||||
|
|
||||||
|
**Docs:** multi-user mode design plan added; CLAUDE.md + READMEs (incl. zh-CN full re-translation) synced with the 1.4.1 feature set.
|
||||||
|
|
||||||
## 1.4.1
|
## 1.4.1
|
||||||
|
|
||||||
### Patch Changes
|
### Patch Changes
|
||||||
|
|||||||
@@ -52,8 +52,9 @@ curl -X POST localhost:3000/api/quick-start -d '{"caseName":"sandbox","mode":"cl
|
|||||||
## Lifecycle
|
## Lifecycle
|
||||||
|
|
||||||
- **Reconnect after a Codeman restart** lands back in the same live agent (the in-container tmux survives).
|
- **Reconnect after a Codeman restart** lands back in the same live agent (the in-container tmux survives).
|
||||||
- **Container stop / host reboot** recreates the container and, when a resume id was captured, **resumes** the last conversation from the bind-mounted transcript.
|
- **Container stop / host reboot** restarts the container and **resumes** the last conversation from the bind-mounted transcript. Claude sessions launch with a pinned conversation id (`--session-id <sessionId>`, with a `--resume` fallback when the transcript already exists), and the case remembers its last conversation (`lastClaudeSessionId`), so a relaunch after the container was stopped, rebooted, or recreated continues where it left off.
|
||||||
- **Killing one session** only kills that session's in-container tmux session; the shared container stays up for sibling sessions.
|
- **Killing one session** only kills that session's in-container tmux session; the shared container stays up for sibling sessions.
|
||||||
|
- **Editing the docker host config** (image, memory, network, ...) is detected on the next launch: the desired config hash is compared against the container's `codeman.confighash` label, and a mismatch refuses the launch with a "config changed, recreate?" confirm. Confirming calls `POST /api/docker-cases/:name/recreate` (refused while sessions of the case are live), which removes the container so the next launch recreates it with the new config; the workspace and the conversation survive.
|
||||||
- **Deleting the case** `docker rm -f`s the container (the bind-mounted workspace on the host survives). An instance-scoped boot reaper removes containers whose case is gone.
|
- **Deleting the case** `docker rm -f`s the container (the bind-mounted workspace on the host survives). An instance-scoped boot reaper removes containers whose case is gone.
|
||||||
|
|
||||||
## Isolation & security
|
## Isolation & security
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "aicodeman",
|
"name": "aicodeman",
|
||||||
"version": "1.4.1",
|
"version": "1.4.2",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "aicodeman",
|
"name": "aicodeman",
|
||||||
"version": "1.4.1",
|
"version": "1.4.2",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "aicodeman",
|
"name": "aicodeman",
|
||||||
"version": "1.4.1",
|
"version": "1.4.2",
|
||||||
"description": "Mission control for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
|
"description": "Mission control for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
|
|||||||
+57
-8
@@ -105,6 +105,45 @@ export function parseLoadedImageRef(loadOutput: string): string | null {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate an imported bundle's manifest BEFORE any of its fields are trusted.
|
||||||
|
* A bundle is cross-machine input (potentially authored by someone else), and its
|
||||||
|
* fields flow into stored host/case config that the schema layer never sees:
|
||||||
|
* `engine` becomes the probe/launch binary selector, `image`/`containerWorkdir`
|
||||||
|
* reach the shellescaped launch string, `network` is a create arg. Mirror the
|
||||||
|
* DockerHostSchema/DockerCaseLinkSchema constraints here (throwing, since this is
|
||||||
|
* not a web-layer module). Exported for unit tests.
|
||||||
|
*/
|
||||||
|
export function validateImportManifest(manifest: DockerExportManifest): void {
|
||||||
|
const fail = (msg: string): never => {
|
||||||
|
throw new Error(`invalid bundle manifest: ${msg}`);
|
||||||
|
};
|
||||||
|
if (manifest.schemaVersion !== DOCKER_EXPORT_SCHEMA) {
|
||||||
|
fail(`unsupported export schema version ${manifest.schemaVersion} (expected ${DOCKER_EXPORT_SCHEMA})`);
|
||||||
|
}
|
||||||
|
if (manifest.mode !== 'full' && manifest.mode !== 'workspace') fail(`unknown mode ${String(manifest.mode)}`);
|
||||||
|
if (manifest.engine !== 'docker' && manifest.engine !== 'podman') fail(`unknown engine ${String(manifest.engine)}`);
|
||||||
|
if (typeof manifest.caseName !== 'string' || !/^[a-zA-Z0-9_-]+$/.test(manifest.caseName)) fail('bad caseName');
|
||||||
|
if (
|
||||||
|
typeof manifest.image !== 'string' ||
|
||||||
|
manifest.image.length > 512 ||
|
||||||
|
!/^[a-zA-Z0-9][\w./:@-]*$/.test(manifest.image)
|
||||||
|
) {
|
||||||
|
fail('bad image reference');
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
typeof manifest.containerWorkdir !== 'string' ||
|
||||||
|
manifest.containerWorkdir.length > 2000 ||
|
||||||
|
!manifest.containerWorkdir.startsWith('/') ||
|
||||||
|
// comma: --mount specs are comma-delimited CSV; shell escaping cannot protect it
|
||||||
|
/[`$\\"'\n\r;&|<>,]/.test(manifest.containerWorkdir)
|
||||||
|
) {
|
||||||
|
fail('bad containerWorkdir');
|
||||||
|
}
|
||||||
|
if (!['bridge', 'none', 'custom'].includes(manifest.network)) fail(`unknown network ${String(manifest.network)}`);
|
||||||
|
if (typeof manifest.checksums !== 'object' || manifest.checksums === null) fail('missing checksums');
|
||||||
|
}
|
||||||
|
|
||||||
// ========== IO helpers ==========
|
// ========== IO helpers ==========
|
||||||
|
|
||||||
function run(
|
function run(
|
||||||
@@ -338,25 +377,34 @@ export async function importDockerBundle(params: {
|
|||||||
destWorkspace: string;
|
destWorkspace: string;
|
||||||
engine: DockerEngine;
|
engine: DockerEngine;
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
|
/** Schema-validated destination case name; the quarantine tag derives from THIS,
|
||||||
|
* never from the (attacker-authored) manifest.caseName. */
|
||||||
|
newCaseName: string;
|
||||||
}): Promise<ImportResult> {
|
}): Promise<ImportResult> {
|
||||||
const { bundlePath, destWorkspace, engine, timestamp } = params;
|
const { bundlePath, destWorkspace, engine, timestamp, newCaseName } = params;
|
||||||
const argv: string[] = [engine === 'podman' ? 'podman' : 'docker'];
|
const argv: string[] = [engine === 'podman' ? 'podman' : 'docker'];
|
||||||
|
|
||||||
if (IS_TEST_MODE) {
|
if (IS_TEST_MODE) {
|
||||||
const raw = await fs.readFile(bundlePath, 'utf-8').catch(() => '{}');
|
const raw = await fs.readFile(bundlePath, 'utf-8').catch(() => '{}');
|
||||||
return { manifest: JSON.parse(raw) as DockerExportManifest, workspacePath: destWorkspace };
|
const manifest = JSON.parse(raw) as DockerExportManifest;
|
||||||
|
validateImportManifest(manifest);
|
||||||
|
return { manifest, workspacePath: destWorkspace };
|
||||||
}
|
}
|
||||||
|
|
||||||
const stageDir = `${destWorkspace}.import-stage-${timestamp}`;
|
const stageDir = `${destWorkspace}.import-stage-${timestamp}`;
|
||||||
mkdirSync(stageDir, { recursive: true });
|
mkdirSync(stageDir, { recursive: true });
|
||||||
try {
|
try {
|
||||||
await run('tar', ['-xzf', bundlePath, '-C', stageDir], { timeout: 300_000 });
|
// Outer-bundle traversal guard (defense in depth: GNU/bsd tar already refuse
|
||||||
|
// `..`/absolute members by default, but the bundle is cross-machine input).
|
||||||
|
const { stdout: bundleMembers } = await run('tar', ['-tzf', bundlePath], { timeout: 60_000 });
|
||||||
|
for (const member of bundleMembers.split('\n').filter(Boolean)) {
|
||||||
|
if (!isSafeTarMember(member)) throw new Error(`unsafe path in bundle archive: ${member}`);
|
||||||
|
}
|
||||||
|
await run('tar', ['--no-same-owner', '-xzf', bundlePath, '-C', stageDir], { timeout: 300_000 });
|
||||||
|
|
||||||
const manifestRaw = await fs.readFile(join(stageDir, 'manifest.json'), 'utf-8');
|
const manifestRaw = await fs.readFile(join(stageDir, 'manifest.json'), 'utf-8');
|
||||||
const manifest = JSON.parse(manifestRaw) as DockerExportManifest;
|
const manifest = JSON.parse(manifestRaw) as DockerExportManifest;
|
||||||
if (manifest.schemaVersion !== DOCKER_EXPORT_SCHEMA) {
|
validateImportManifest(manifest);
|
||||||
throw new Error(`unsupported export schema version ${manifest.schemaVersion} (expected ${DOCKER_EXPORT_SCHEMA})`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Integrity: verify checksums before trusting any member.
|
// Integrity: verify checksums before trusting any member.
|
||||||
const workspaceTar = join(stageDir, 'workspace.tar');
|
const workspaceTar = join(stageDir, 'workspace.tar');
|
||||||
@@ -385,8 +433,9 @@ export async function importDockerBundle(params: {
|
|||||||
const { stdout } = await run(argv[0], [...argv.slice(1), 'load', '-i', imageTar], { timeout: 300_000 });
|
const { stdout } = await run(argv[0], [...argv.slice(1), 'load', '-i', imageTar], { timeout: 300_000 });
|
||||||
const loadedRef = parseLoadedImageRef(stdout);
|
const loadedRef = parseLoadedImageRef(stdout);
|
||||||
if (!loadedRef) throw new Error('could not determine loaded image ref');
|
if (!loadedRef) throw new Error('could not determine loaded image ref');
|
||||||
// Quarantine: re-tag by the loaded ref/id, never trusting the bundle's original tag.
|
// Quarantine: re-tag by the loaded ref/id, never trusting the bundle's original
|
||||||
importedImage = importedImageTag(manifest.caseName, timestamp);
|
// tag; the tag name derives from the caller's schema-validated newCaseName.
|
||||||
|
importedImage = importedImageTag(newCaseName, timestamp);
|
||||||
await run(argv[0], [...argv.slice(1), 'tag', loadedRef, importedImage], { timeout: 60_000 });
|
await run(argv[0], [...argv.slice(1), 'tag', loadedRef, importedImage], { timeout: 60_000 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+120
-21
@@ -109,6 +109,24 @@ export async function writeDockerCases(configDir: string, cases: DockerCase[]):
|
|||||||
await writeJsonArray(configDir, dockerCasesPath(configDir), cases);
|
await writeJsonArray(configDir, dockerCasesPath(configDir), cases);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persist the case's last Claude conversation id (the `--resume` seed for the
|
||||||
|
* container-recreated relaunch, docs/docker-cases-plan.md two-layer durability).
|
||||||
|
* Keyed by container name so callers that only hold a SessionDocker can update it.
|
||||||
|
* No-op when the id is unchanged or the case is gone.
|
||||||
|
*/
|
||||||
|
export async function persistDockerCaseClaudeSessionId(
|
||||||
|
configDir: string,
|
||||||
|
containerName: string,
|
||||||
|
claudeSessionId: string
|
||||||
|
): Promise<void> {
|
||||||
|
const cases = await readDockerCases(configDir);
|
||||||
|
const idx = cases.findIndex((c) => (c.container ?? dockerContainerName(c.name)) === containerName);
|
||||||
|
if (idx === -1 || cases[idx].lastClaudeSessionId === claudeSessionId) return;
|
||||||
|
cases[idx] = { ...cases[idx], lastClaudeSessionId: claudeSessionId };
|
||||||
|
await writeDockerCases(configDir, cases);
|
||||||
|
}
|
||||||
|
|
||||||
// ========== Naming / display / defaults ==========
|
// ========== Naming / display / defaults ==========
|
||||||
|
|
||||||
/** Per-case container name. Mirrors how remote derives a stable name from the case. */
|
/** Per-case container name. Mirrors how remote derives a stable name from the case. */
|
||||||
@@ -624,6 +642,73 @@ export function resolveDockerCredentialArtifacts(home: string = homedir()): Dock
|
|||||||
|
|
||||||
// ========== Daemon probes (IO; no-op under VITEST) ==========
|
// ========== Daemon probes (IO; no-op under VITEST) ==========
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UNESCAPED argv prefix for execFile-based probes. The shellescaped
|
||||||
|
* buildDockerBaseArgs variant is for interpolation into the `bash -c` launch
|
||||||
|
* string; argv arrays must NOT carry literal quotes (mirror of docker-export's
|
||||||
|
* dockerArgv).
|
||||||
|
*/
|
||||||
|
function dockerEngineArgv(docker: Pick<SessionDocker, 'engine' | 'context' | 'daemonHost'>): string[] {
|
||||||
|
const argv: string[] = [docker.engine === 'podman' ? 'podman' : 'docker'];
|
||||||
|
if (docker.context) argv.push('--context', docker.context);
|
||||||
|
if (docker.daemonHost) argv.push('-H', docker.daemonHost);
|
||||||
|
return argv;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DockerDriftStatus {
|
||||||
|
/** Container exists (daemon reachable AND a container with this name is present). */
|
||||||
|
exists: boolean;
|
||||||
|
running: boolean;
|
||||||
|
/** The desired configHash no longer matches the container's codeman.confighash label. */
|
||||||
|
drifted: boolean;
|
||||||
|
currentHash?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drift check (docs/docker-cases-plan.md §4): compare the DESIRED configHash
|
||||||
|
* against the existing container's `codeman.confighash` label so docker-host
|
||||||
|
* config edits actually take effect instead of being silently ignored by the
|
||||||
|
* idempotent inspect-or-create launch chain. `exists:false` (no container /
|
||||||
|
* daemon down) means there is nothing to drift. No-op under VITEST.
|
||||||
|
*/
|
||||||
|
export async function checkDockerConfigDrift(
|
||||||
|
docker: Pick<SessionDocker, 'engine' | 'context' | 'daemonHost' | 'containerName' | 'configHash'>
|
||||||
|
): Promise<DockerDriftStatus> {
|
||||||
|
if (IS_TEST_MODE) return { exists: false, running: false, drifted: false };
|
||||||
|
const argv = dockerEngineArgv(docker);
|
||||||
|
try {
|
||||||
|
const { stdout } = await execFileAsync(
|
||||||
|
argv[0],
|
||||||
|
[
|
||||||
|
...argv.slice(1),
|
||||||
|
'inspect',
|
||||||
|
'-f',
|
||||||
|
'{{.State.Running}}\t{{index .Config.Labels "codeman.confighash"}}',
|
||||||
|
docker.containerName,
|
||||||
|
],
|
||||||
|
{ timeout: DOCKER_PROBE_TIMEOUT_MS }
|
||||||
|
);
|
||||||
|
const [running = '', hash = ''] = stdout.trim().split('\t');
|
||||||
|
return { exists: true, running: running === 'true', drifted: hash !== docker.configHash, currentHash: hash };
|
||||||
|
} catch {
|
||||||
|
return { exists: false, running: false, drifted: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `docker rm -f` the case container (the recreate-on-drift confirm action; the
|
||||||
|
* launch chain recreates it with the new config on next start). Workspace +
|
||||||
|
* transcripts ride bind mounts and survive; the conversation resumes via the
|
||||||
|
* case's lastClaudeSessionId. No-op under VITEST.
|
||||||
|
*/
|
||||||
|
export async function removeDockerContainer(
|
||||||
|
docker: Pick<SessionDocker, 'engine' | 'context' | 'daemonHost' | 'containerName'>
|
||||||
|
): Promise<void> {
|
||||||
|
if (IS_TEST_MODE) return;
|
||||||
|
const argv = dockerEngineArgv(docker);
|
||||||
|
await execFileAsync(argv[0], [...argv.slice(1), 'rm', '-f', docker.containerName], { timeout: 30_000 });
|
||||||
|
}
|
||||||
|
|
||||||
export interface DockerAvailability {
|
export interface DockerAvailability {
|
||||||
ok: boolean;
|
ok: boolean;
|
||||||
engine: DockerEngine;
|
engine: DockerEngine;
|
||||||
@@ -702,11 +787,16 @@ export async function checkDockerAvailable(engine?: DockerEngine): Promise<Docke
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Is the base image present locally? (never triggers an auto-pull). */
|
/** Is the base image present on the host's daemon? (never triggers an auto-pull).
|
||||||
export async function checkDockerImagePresent(engine: DockerEngine, image: string): Promise<boolean> {
|
* Honors context/daemonHost so a remote-daemon host is probed on the RIGHT daemon. */
|
||||||
|
export async function checkDockerImagePresent(
|
||||||
|
docker: Pick<SessionDocker, 'engine' | 'context' | 'daemonHost'>,
|
||||||
|
image: string
|
||||||
|
): Promise<boolean> {
|
||||||
if (IS_TEST_MODE) return true;
|
if (IS_TEST_MODE) return true;
|
||||||
|
const argv = dockerEngineArgv(docker);
|
||||||
try {
|
try {
|
||||||
await execFileAsync(engine, ['image', 'inspect', '--format', '{{.Id}}', image], {
|
await execFileAsync(argv[0], [...argv.slice(1), 'image', 'inspect', '--format', '{{.Id}}', image], {
|
||||||
timeout: DOCKER_PROBE_TIMEOUT_MS,
|
timeout: DOCKER_PROBE_TIMEOUT_MS,
|
||||||
});
|
});
|
||||||
return true;
|
return true;
|
||||||
@@ -748,12 +838,12 @@ function resolveAgentDockerfile(): { dockerfile: string; contextDir: string } |
|
|||||||
* lines for SSE surfacing.
|
* lines for SSE surfacing.
|
||||||
*/
|
*/
|
||||||
export async function ensureAgentBaseImage(
|
export async function ensureAgentBaseImage(
|
||||||
engine: DockerEngine,
|
docker: Pick<SessionDocker, 'engine' | 'context' | 'daemonHost'>,
|
||||||
image: string,
|
image: string,
|
||||||
opts: { onProgress?: (line: string) => void; noCache?: boolean } = {}
|
opts: { onProgress?: (line: string) => void; noCache?: boolean } = {}
|
||||||
): Promise<EnsureImageResult> {
|
): Promise<EnsureImageResult> {
|
||||||
if (IS_TEST_MODE) return { ok: true, built: false, alreadyPresent: true };
|
if (IS_TEST_MODE) return { ok: true, built: false, alreadyPresent: true };
|
||||||
if (await checkDockerImagePresent(engine, image)) {
|
if (await checkDockerImagePresent(docker, image)) {
|
||||||
return { ok: true, built: false, alreadyPresent: true };
|
return { ok: true, built: false, alreadyPresent: true };
|
||||||
}
|
}
|
||||||
if (image !== DEFAULT_AGENT_IMAGE) {
|
if (image !== DEFAULT_AGENT_IMAGE) {
|
||||||
@@ -764,16 +854,16 @@ export async function ensureAgentBaseImage(
|
|||||||
error: `image ${image} is not present and only ${DEFAULT_AGENT_IMAGE} is auto-built. Build or pull ${image} yourself.`,
|
error: `image ${image} is not present and only ${DEFAULT_AGENT_IMAGE} is auto-built. Build or pull ${image} yourself.`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const key = `${engine}:${image}`;
|
const key = `${docker.engine}:${image}`;
|
||||||
const existing = inFlightImageBuilds.get(key);
|
const existing = inFlightImageBuilds.get(key);
|
||||||
if (existing) return existing;
|
if (existing) return existing;
|
||||||
const build = buildAgentImage(engine, image, opts).finally(() => inFlightImageBuilds.delete(key));
|
const build = buildAgentImage(docker, image, opts).finally(() => inFlightImageBuilds.delete(key));
|
||||||
inFlightImageBuilds.set(key, build);
|
inFlightImageBuilds.set(key, build);
|
||||||
return build;
|
return build;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildAgentImage(
|
function buildAgentImage(
|
||||||
engine: DockerEngine,
|
docker: Pick<SessionDocker, 'engine' | 'context' | 'daemonHost'>,
|
||||||
image: string,
|
image: string,
|
||||||
opts: { onProgress?: (line: string) => void; noCache?: boolean }
|
opts: { onProgress?: (line: string) => void; noCache?: boolean }
|
||||||
): Promise<EnsureImageResult> {
|
): Promise<EnsureImageResult> {
|
||||||
@@ -786,10 +876,14 @@ function buildAgentImage(
|
|||||||
error: `docker/agent.Dockerfile not found in this install; clone the repo or build ${image} manually`,
|
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);
|
const argv = dockerEngineArgv(docker);
|
||||||
|
const args = [
|
||||||
|
...argv.slice(1),
|
||||||
|
...agentImageBuildArgs(resolved.dockerfile, image, resolved.contextDir, opts.noCache),
|
||||||
|
];
|
||||||
return new Promise<EnsureImageResult>((resolve) => {
|
return new Promise<EnsureImageResult>((resolve) => {
|
||||||
// async spawn (NEVER spawnSync) so a multi-minute build never wedges the event loop.
|
// async spawn (NEVER spawnSync) so a multi-minute build never wedges the event loop.
|
||||||
const child = spawn(engine, args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
const child = spawn(argv[0], args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||||
const forward = (buf: Buffer) => {
|
const forward = (buf: Buffer) => {
|
||||||
for (const line of buf.toString('utf-8').split('\n')) {
|
for (const line of buf.toString('utf-8').split('\n')) {
|
||||||
const trimmed = line.trimEnd();
|
const trimmed = line.trimEnd();
|
||||||
@@ -803,12 +897,12 @@ function buildAgentImage(
|
|||||||
ok: false,
|
ok: false,
|
||||||
built: false,
|
built: false,
|
||||||
alreadyPresent: false,
|
alreadyPresent: false,
|
||||||
error: `could not spawn ${engine} build: ${err.message}`,
|
error: `could not spawn ${argv[0]} build: ${err.message}`,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
child.on('exit', (code) => {
|
child.on('exit', (code) => {
|
||||||
if (code === 0) resolve({ ok: true, built: true, alreadyPresent: false });
|
if (code === 0) resolve({ ok: true, built: true, alreadyPresent: false });
|
||||||
else resolve({ ok: false, built: false, alreadyPresent: false, error: `${engine} build failed (exit ${code})` });
|
else resolve({ ok: false, built: false, alreadyPresent: false, error: `${argv[0]} build failed (exit ${code})` });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -827,21 +921,21 @@ export interface DockerTmuxCheckResult {
|
|||||||
* (`--pull=never`). No-op under VITEST. Mirror of checkRemoteTmuxAvailable.
|
* (`--pull=never`). No-op under VITEST. Mirror of checkRemoteTmuxAvailable.
|
||||||
*/
|
*/
|
||||||
export async function checkDockerTmuxAvailable(
|
export async function checkDockerTmuxAvailable(
|
||||||
docker: Pick<SessionDocker, 'engine' | 'image'>
|
docker: Pick<SessionDocker, 'engine' | 'image' | 'context' | 'daemonHost'>
|
||||||
): Promise<DockerTmuxCheckResult> {
|
): Promise<DockerTmuxCheckResult> {
|
||||||
if (IS_TEST_MODE) return { ok: true, tmuxPath: '/usr/bin/tmux' };
|
if (IS_TEST_MODE) return { ok: true, tmuxPath: '/usr/bin/tmux' };
|
||||||
const engine = docker.engine;
|
if (!(await checkDockerImagePresent(docker, docker.image))) {
|
||||||
if (!(await checkDockerImagePresent(engine, docker.image))) {
|
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
imageMissing: true,
|
imageMissing: true,
|
||||||
error: `image ${docker.image} not present (the default image is auto-built on first use; a custom image must be built or pulled first)`,
|
error: `image ${docker.image} not present (the default image is auto-built on first use; a custom image must be built or pulled first)`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
const argv = dockerEngineArgv(docker);
|
||||||
try {
|
try {
|
||||||
const { stdout } = await execFileAsync(
|
const { stdout } = await execFileAsync(
|
||||||
engine,
|
argv[0],
|
||||||
['run', '--rm', '--pull=never', docker.image, 'sh', '-lc', 'command -v tmux'],
|
[...argv.slice(1), 'run', '--rm', '--pull=never', docker.image, 'sh', '-lc', 'command -v tmux'],
|
||||||
{ timeout: DOCKER_PROBE_TIMEOUT_MS }
|
{ timeout: DOCKER_PROBE_TIMEOUT_MS }
|
||||||
);
|
);
|
||||||
const tmuxPath = stdout.trim();
|
const tmuxPath = stdout.trim();
|
||||||
@@ -938,16 +1032,21 @@ export async function reapOrphanedDockerContainers(
|
|||||||
* Returns undefined on any failure. No-op under VITEST.
|
* Returns undefined on any failure. No-op under VITEST.
|
||||||
*/
|
*/
|
||||||
export async function probeDockerCliVersion(
|
export async function probeDockerCliVersion(
|
||||||
docker: Pick<SessionDocker, 'engine' | 'containerName'>,
|
docker: Pick<SessionDocker, 'engine' | 'containerName' | 'context' | 'daemonHost'>,
|
||||||
mode: SessionMode
|
mode: SessionMode
|
||||||
): Promise<string | undefined> {
|
): Promise<string | undefined> {
|
||||||
if (IS_TEST_MODE) return undefined;
|
if (IS_TEST_MODE) return undefined;
|
||||||
const bin = mode === 'shell' ? null : mode;
|
const bin = mode === 'shell' ? null : mode;
|
||||||
if (!bin) return undefined;
|
if (!bin) return undefined;
|
||||||
|
const argv = dockerEngineArgv(docker);
|
||||||
try {
|
try {
|
||||||
const { stdout } = await execFileAsync(docker.engine, ['exec', docker.containerName, bin, '--version'], {
|
const { stdout } = await execFileAsync(
|
||||||
timeout: DOCKER_PROBE_TIMEOUT_MS,
|
argv[0],
|
||||||
});
|
[...argv.slice(1), 'exec', docker.containerName, bin, '--version'],
|
||||||
|
{
|
||||||
|
timeout: DOCKER_PROBE_TIMEOUT_MS,
|
||||||
|
}
|
||||||
|
);
|
||||||
const match = stdout.trim().match(/\d+\.\d+\.\d+/);
|
const match = stdout.trim().match(/\d+\.\d+\.\d+/);
|
||||||
return match ? match[0] : stdout.trim() || undefined;
|
return match ? match[0] : stdout.trim() || undefined;
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ function buildPermissionArgs(claudeMode: ClaudeMode, allowedTools?: string): str
|
|||||||
switch (claudeMode) {
|
switch (claudeMode) {
|
||||||
case 'dangerously-skip-permissions':
|
case 'dangerously-skip-permissions':
|
||||||
return ['--dangerously-skip-permissions'];
|
return ['--dangerously-skip-permissions'];
|
||||||
|
case 'auto':
|
||||||
|
return ['--permission-mode', 'auto'];
|
||||||
case 'allowedTools':
|
case 'allowedTools':
|
||||||
if (allowedTools) {
|
if (allowedTools) {
|
||||||
return ['--allowedTools', allowedTools];
|
return ['--allowedTools', allowedTools];
|
||||||
|
|||||||
+1
-1
@@ -1523,7 +1523,7 @@ export class Session extends EventEmitter {
|
|||||||
|
|
||||||
// === Auto-accept workspace trust dialog ===
|
// === Auto-accept workspace trust dialog ===
|
||||||
// Claude CLI 2.x shows "Yes, I trust this folder" prompt on first launch per directory.
|
// Claude CLI 2.x shows "Yes, I trust this folder" prompt on first launch per directory.
|
||||||
// Codeman sessions always use --dangerously-skip-permissions, so auto-accept.
|
// Codeman sessions run permission-skipping or classifier-guarded (auto) modes, so auto-accept.
|
||||||
if (!this._trustDialogAccepted && data.includes('trust this folder')) {
|
if (!this._trustDialogAccepted && data.includes('trust this folder')) {
|
||||||
this._trustDialogAccepted = true;
|
this._trustDialogAccepted = true;
|
||||||
console.log(`[Session] Auto-accepting workspace trust dialog for: ${this.id}`);
|
console.log(`[Session] Auto-accepting workspace trust dialog for: ${this.id}`);
|
||||||
|
|||||||
+37
-7
@@ -563,6 +563,8 @@ function buildClaudePermissionFlags(claudeMode?: ClaudeMode, allowedTools?: stri
|
|||||||
switch (mode) {
|
switch (mode) {
|
||||||
case 'dangerously-skip-permissions':
|
case 'dangerously-skip-permissions':
|
||||||
return ' --dangerously-skip-permissions';
|
return ' --dangerously-skip-permissions';
|
||||||
|
case 'auto':
|
||||||
|
return ' --permission-mode auto';
|
||||||
case 'allowedTools':
|
case 'allowedTools':
|
||||||
if (allowedTools) {
|
if (allowedTools) {
|
||||||
// Sanitize: allow tool names with patterns like Bash(git:*), space/comma-separated
|
// Sanitize: allow tool names with patterns like Bash(git:*), space/comma-separated
|
||||||
@@ -674,7 +676,7 @@ function buildEffortSettingsFlag(effort?: EffortLevel): string {
|
|||||||
return flag && value ? ` ${flag} '${value}'` : '';
|
return flag && value ? ` ${flag} '${value}'` : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildSpawnCommand(options: {
|
export function buildSpawnCommand(options: {
|
||||||
mode: SessionMode;
|
mode: SessionMode;
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
model?: string;
|
model?: string;
|
||||||
@@ -858,15 +860,15 @@ export function dockerTmuxSessionName(sessionId: string): string {
|
|||||||
const RESUME_ID_SAFE = /^[A-Za-z0-9._-]+$/;
|
const RESUME_ID_SAFE = /^[A-Za-z0-9._-]+$/;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Append the CLI-specific resume flag to a pane command. Only fires when the
|
* Append the CLI-specific resume flag to a pane command (codex/gemini). Only fires
|
||||||
* in-container tmux is RE-CREATED (`new-session -A` makes the flag inert on a
|
* when the in-container tmux is RE-CREATED (`new-session -A` makes the flag inert
|
||||||
* live reattach), i.e. exactly when the previous live agent was lost and we want
|
* on a live reattach), i.e. exactly when the previous live agent was lost and we
|
||||||
* to resume the conversation from the bind-mounted transcript.
|
* want to resume the conversation from the bind-mounted transcript. Claude mode
|
||||||
|
* uses claudeDockerPaneCommand instead.
|
||||||
*/
|
*/
|
||||||
function appendResumeFlag(modeCommand: string, mode: SessionMode, resumeId: string): string {
|
function appendResumeFlag(modeCommand: string, mode: SessionMode, resumeId: string): string {
|
||||||
if (!RESUME_ID_SAFE.test(resumeId)) return modeCommand;
|
if (!RESUME_ID_SAFE.test(resumeId)) return modeCommand;
|
||||||
switch (mode) {
|
switch (mode) {
|
||||||
case 'claude':
|
|
||||||
case 'gemini':
|
case 'gemini':
|
||||||
return `${modeCommand} --resume ${resumeId}`;
|
return `${modeCommand} --resume ${resumeId}`;
|
||||||
case 'codex':
|
case 'codex':
|
||||||
@@ -876,6 +878,30 @@ function appendResumeFlag(modeCommand: string, mode: SessionMode, resumeId: stri
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Claude-mode pane command with a DETERMINISTIC conversation id (the docker analog
|
||||||
|
* of buildSpawnCommand's --resume/--session-id logic). A fresh launch passes
|
||||||
|
* `--session-id <sessionId>`, so the in-container conversation id is knowable
|
||||||
|
* host-side (resume-id capture + subagent/workflow correlation) WITHOUT relying on
|
||||||
|
* hook reachability. When the in-container tmux was re-created after a container
|
||||||
|
* stop/reboot, the same command re-runs against the surviving transcript:
|
||||||
|
* `--session-id` exits 1 ("already in use") and the `||` fallback RESUMES that
|
||||||
|
* conversation (verified CLI behavior). An explicit resumeId gets the local
|
||||||
|
* builder's shape — resume first, session-id fallback — so a stale id never
|
||||||
|
* dead-panes. The leading `exec ` is stripped: an exec'd first branch could never
|
||||||
|
* fall back.
|
||||||
|
*/
|
||||||
|
function claudeDockerPaneCommand(modeCommand: string, sessionId: string, resumeId?: string): string {
|
||||||
|
if (!RESUME_ID_SAFE.test(sessionId)) return modeCommand; // defensive — ids are server-minted uuids
|
||||||
|
const cmd = modeCommand.replace(/^exec\s+/, '');
|
||||||
|
const rid = resumeId && RESUME_ID_SAFE.test(resumeId) ? resumeId : undefined;
|
||||||
|
if (rid && rid !== sessionId) {
|
||||||
|
return `${cmd} --resume ${rid} || ${cmd} --session-id ${sessionId}`;
|
||||||
|
}
|
||||||
|
const cid = rid ?? sessionId;
|
||||||
|
return `${cmd} --session-id ${cid} || ${cmd} --resume ${cid}`;
|
||||||
|
}
|
||||||
|
|
||||||
/** Fully-resolved inputs for buildDockerLaunchCommand (pure). */
|
/** Fully-resolved inputs for buildDockerLaunchCommand (pure). */
|
||||||
export interface DockerLaunchOptions {
|
export interface DockerLaunchOptions {
|
||||||
mode: SessionMode;
|
mode: SessionMode;
|
||||||
@@ -915,7 +941,11 @@ export function buildDockerLaunchCommand(opts: DockerLaunchOptions): string {
|
|||||||
const sid = sessionId.slice(0, 8);
|
const sid = sessionId.slice(0, 8);
|
||||||
|
|
||||||
let modeCommand = docker.commands?.[mode as DockerCommandMode] || defaultDockerCommandForMode(mode);
|
let modeCommand = docker.commands?.[mode as DockerCommandMode] || defaultDockerCommandForMode(mode);
|
||||||
if (resumeSessionId) modeCommand = appendResumeFlag(modeCommand, mode, resumeSessionId);
|
if (mode === 'claude') {
|
||||||
|
modeCommand = claudeDockerPaneCommand(modeCommand, sessionId, resumeSessionId);
|
||||||
|
} else if (resumeSessionId) {
|
||||||
|
modeCommand = appendResumeFlag(modeCommand, mode, resumeSessionId);
|
||||||
|
}
|
||||||
// Run by tmux via /bin/sh -c, so the path is shell-quoted here. `exec` makes the
|
// Run by tmux via /bin/sh -c, so the path is shell-quoted here. `exec` makes the
|
||||||
// pane PID the agent itself.
|
// pane PID the agent itself.
|
||||||
const paneCommand = `cd ${workdir} && ${modeCommand}`;
|
const paneCommand = `cd ${workdir} && ${modeCommand}`;
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
* - SessionOutput — captured stdout/stderr/exitCode
|
* - SessionOutput — captured stdout/stderr/exitCode
|
||||||
* - SessionStatus — 'idle' | 'busy' | 'stopped' | 'error'
|
* - SessionStatus — 'idle' | 'busy' | 'stopped' | 'error'
|
||||||
* - SessionMode — 'claude' | 'shell' | 'opencode' | 'codex' | 'gemini' (which CLI backend)
|
* - SessionMode — 'claude' | 'shell' | 'opencode' | 'codex' | 'gemini' (which CLI backend)
|
||||||
* - ClaudeMode — CLI permission mode ('dangerously-skip-permissions' | 'normal' | 'allowedTools')
|
* - ClaudeMode — CLI permission mode ('dangerously-skip-permissions' | 'auto' | 'normal' | 'allowedTools')
|
||||||
* - SessionColor — visual differentiation color
|
* - SessionColor — visual differentiation color
|
||||||
* - OpenCodeConfig — OpenCode-specific settings (model, autoAllowTools, continueSession)
|
* - OpenCodeConfig — OpenCode-specific settings (model, autoAllowTools, continueSession)
|
||||||
* - CodexConfig — Codex (OpenAI CLI)-specific settings (model, resumeSessionId)
|
* - CodexConfig — Codex (OpenAI CLI)-specific settings (model, resumeSessionId)
|
||||||
@@ -35,10 +35,12 @@ export type SessionStatus = 'idle' | 'busy' | 'stopped' | 'error';
|
|||||||
/**
|
/**
|
||||||
* Claude CLI startup permission mode.
|
* Claude CLI startup permission mode.
|
||||||
* - `'dangerously-skip-permissions'`: Bypass all permission prompts (default)
|
* - `'dangerously-skip-permissions'`: Bypass all permission prompts (default)
|
||||||
|
* - `'auto'`: `--permission-mode auto`: no routine prompts, background safety
|
||||||
|
* classifier blocks destructive actions (Claude Code 2.1.207+, recent models)
|
||||||
* - `'normal'`: Standard mode with permission prompts
|
* - `'normal'`: Standard mode with permission prompts
|
||||||
* - `'allowedTools'`: Only allow specific tools (requires allowedTools list)
|
* - `'allowedTools'`: Only allow specific tools (requires allowedTools list)
|
||||||
*/
|
*/
|
||||||
export type ClaudeMode = 'dangerously-skip-permissions' | 'normal' | 'allowedTools';
|
export type ClaudeMode = 'dangerously-skip-permissions' | 'auto' | 'normal' | 'allowedTools';
|
||||||
|
|
||||||
/** Session mode: which CLI backend a session runs */
|
/** Session mode: which CLI backend a session runs */
|
||||||
export type SessionMode = 'claude' | 'shell' | 'opencode' | 'codex' | 'gemini';
|
export type SessionMode = 'claude' | 'shell' | 'opencode' | 'codex' | 'gemini';
|
||||||
|
|||||||
@@ -1452,6 +1452,26 @@ class CodemanApp {
|
|||||||
console.error('[SSE] docker export failed:', err);
|
console.error('[SSE] docker export failed:', err);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
// Import + drift-recreate completions: refresh case lists in EVERY open tab
|
||||||
|
// (the initiating tab already refreshes via its own fetch response).
|
||||||
|
addListener(SSE_EVENTS.DOCKER_IMPORT_COMPLETE, (e) => {
|
||||||
|
try {
|
||||||
|
const d = e.data ? JSON.parse(e.data) : {};
|
||||||
|
this.showToast(`Docker bundle imported as case "${d.name}"`, 'success');
|
||||||
|
this.loadQuickStartCases?.();
|
||||||
|
this.refreshDockerExports?.();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[SSE] docker import complete:', err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
addListener(SSE_EVENTS.DOCKER_CONTAINER_RECREATED, (e) => {
|
||||||
|
try {
|
||||||
|
const d = e.data ? JSON.parse(e.data) : {};
|
||||||
|
this.showToast(`Container for "${d.name}" removed — next launch recreates it with the new config`, 'info');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[SSE] docker container recreated:', err);
|
||||||
|
}
|
||||||
|
});
|
||||||
// Base image auto-build on first Docker case (build-on-first-use). A single
|
// 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.
|
// multi-minute event; surface start/finish so the Run spinner is explained.
|
||||||
addListener(SSE_EVENTS.DOCKER_IMAGE_BUILD_STARTED, () => {
|
addListener(SSE_EVENTS.DOCKER_IMAGE_BUILD_STARTED, () => {
|
||||||
|
|||||||
@@ -481,6 +481,7 @@ const SSE_EVENTS = {
|
|||||||
DOCKER_IMAGE_BUILD_PROGRESS: 'docker:imageBuildProgress',
|
DOCKER_IMAGE_BUILD_PROGRESS: 'docker:imageBuildProgress',
|
||||||
DOCKER_IMAGE_BUILD_COMPLETE: 'docker:imageBuildComplete',
|
DOCKER_IMAGE_BUILD_COMPLETE: 'docker:imageBuildComplete',
|
||||||
DOCKER_IMAGE_BUILD_FAILED: 'docker:imageBuildFailed',
|
DOCKER_IMAGE_BUILD_FAILED: 'docker:imageBuildFailed',
|
||||||
|
DOCKER_CONTAINER_RECREATED: 'docker:containerRecreated',
|
||||||
};
|
};
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
|||||||
@@ -1449,10 +1449,11 @@
|
|||||||
<label>Startup Mode</label>
|
<label>Startup Mode</label>
|
||||||
<select id="appSettingsClaudeMode" class="form-select">
|
<select id="appSettingsClaudeMode" class="form-select">
|
||||||
<option value="dangerously-skip-permissions">Skip Permissions (default)</option>
|
<option value="dangerously-skip-permissions">Skip Permissions (default)</option>
|
||||||
|
<option value="auto">Auto Mode (safety classifier, recommended by Anthropic)</option>
|
||||||
<option value="normal">Normal (with prompts)</option>
|
<option value="normal">Normal (with prompts)</option>
|
||||||
<option value="allowedTools">Allowed Tools Only</option>
|
<option value="allowedTools">Allowed Tools Only</option>
|
||||||
</select>
|
</select>
|
||||||
<span class="form-hint">How Claude CLI is started in screen sessions</span>
|
<span class="form-hint">How Claude CLI is started in screen sessions. Auto Mode runs without routine prompts behind a background safety classifier (needs Claude Code 2.1.207+ and Opus 4.6+/Sonnet 4.6+/Fable 5)</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-row" id="allowedToolsRow" style="display: none;">
|
<div class="form-row" id="allowedToolsRow" style="display: none;">
|
||||||
<label>Allowed Tools</label>
|
<label>Allowed Tools</label>
|
||||||
@@ -1874,7 +1875,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="form-row docker-quick-row">
|
<div class="form-row docker-quick-row">
|
||||||
<label class="checkbox-row"><input type="checkbox" id="newCaseDocker"> 🐳 Run in an isolated Docker container</label>
|
<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>
|
<span class="form-hint">Runs this case in a hardened, isolated container. The base image is built automatically on first use. Docker/Podman must be installed.</span>
|
||||||
</div>
|
</div>
|
||||||
<details class="advanced-options docker-quick-settings" id="dockerQuickSettings">
|
<details class="advanced-options docker-quick-settings" id="dockerQuickSettings">
|
||||||
<summary>Container settings (optional, sensible defaults)</summary>
|
<summary>Container settings (optional, sensible defaults)</summary>
|
||||||
|
|||||||
@@ -459,16 +459,18 @@ html.mobile-init .file-browser-panel {
|
|||||||
height: 12px;
|
height: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Hide header settings gear, lifecycle log, away digest, and session manager on
|
/* Hide header settings gear, lifecycle log, away digest, session manager, and
|
||||||
mobile - settings moved to toolbar; away digest and the session manager are
|
file viewer on mobile - settings moved to toolbar; the others are secondary /
|
||||||
secondary controls that don't belong on the cramped phone header (the session
|
desktop-oriented controls that don't belong on the cramped phone header (the
|
||||||
manager stays reachable via the Ctrl+K palette's "Browse all sessions" item).
|
session manager stays reachable via the Ctrl+K palette's "Browse all sessions"
|
||||||
|
item; the file viewer button is opt-in but its panel is desktop-sized).
|
||||||
(The attachments button is opt-in / default-hidden everywhere via its own
|
(The attachments button is opt-in / default-hidden everywhere via its own
|
||||||
--hidden marker, so it needs no mobile-specific rule here.) */
|
--hidden marker, so it needs no mobile-specific rule here.) */
|
||||||
.btn-icon-header.btn-settings,
|
.btn-icon-header.btn-settings,
|
||||||
.btn-icon-header.btn-lifecycle-log,
|
.btn-icon-header.btn-lifecycle-log,
|
||||||
.btn-icon-header.btn-away-digest,
|
.btn-icon-header.btn-away-digest,
|
||||||
.btn-icon-header.btn-session-manager {
|
.btn-icon-header.btn-session-manager,
|
||||||
|
.btn-icon-header.btn-file-viewer {
|
||||||
display: none !important;
|
display: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -551,14 +551,50 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
// Name remote/docker tabs with the same w<n>-<case> convention as local
|
// Name remote/docker tabs with the same w<n>-<case> convention as local
|
||||||
// sessions (quick-start would otherwise auto-generate codeman-<id>).
|
// sessions (quick-start would otherwise auto-generate codeman-<id>).
|
||||||
const startNumber = this._nextCaseSessionStartNumber(caseName);
|
const startNumber = this._nextCaseSessionStartNumber(caseName);
|
||||||
|
// Docker (NOT remote): the App Settings Claude Model choice applies — the
|
||||||
|
// workspace is a real host dir, so quick-start writes it to the case's
|
||||||
|
// .claude/settings.local.json and the in-container claude reads it.
|
||||||
|
// Remote quick-starts REJECT modelOverride (the file would land on the
|
||||||
|
// wrong machine), so never send it there.
|
||||||
|
let dockerModelOverride;
|
||||||
|
if (caseData.location === 'docker') {
|
||||||
|
const dockerGlobalSettings = this.loadAppSettingsFromStorage();
|
||||||
|
const dockerCaseSettings = this.getCaseSettings(caseName);
|
||||||
|
const dockerUseOpus1m = dockerCaseSettings.opusContext1m || dockerGlobalSettings.opusContext1mEnabled;
|
||||||
|
dockerModelOverride = dockerGlobalSettings.claudeModel || (dockerUseOpus1m ? 'opus[1m]' : '');
|
||||||
|
}
|
||||||
const remoteIds = [];
|
const remoteIds = [];
|
||||||
|
let driftHandled = false;
|
||||||
for (let i = 0; i < tabCount; i++) {
|
for (let i = 0; i < tabCount; i++) {
|
||||||
const res = await fetch('/api/quick-start', {
|
const quickStartBody = JSON.stringify({
|
||||||
method: 'POST',
|
caseName, mode: 'claude', sessionName: `w${startNumber + i}-${caseName}`,
|
||||||
headers: { 'Content-Type': 'application/json' },
|
...(dockerModelOverride !== undefined ? { modelOverride: dockerModelOverride } : {})
|
||||||
body: JSON.stringify({ caseName, mode: 'claude', sessionName: `w${startNumber + i}-${caseName}` })
|
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
const doQuickStart = async () => {
|
||||||
|
const res = await fetch('/api/quick-start', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: quickStartBody
|
||||||
|
});
|
||||||
|
return res.json();
|
||||||
|
};
|
||||||
|
let data = await doQuickStart();
|
||||||
|
// Docker config drift: the host config changed since the container was
|
||||||
|
// created (CONFLICT from quick-start). Confirm once, recreate, retry.
|
||||||
|
if (!data.success && data.errorCode === 'CONFLICT' && caseData.location === 'docker' && !driftHandled) {
|
||||||
|
driftHandled = true;
|
||||||
|
const recreate = confirm(
|
||||||
|
`Container config for "${caseName}" changed since its container was created.\n\n` +
|
||||||
|
'Recreate the container to apply the new config? Workspace files and the ' +
|
||||||
|
'conversation survive (the conversation resumes on launch).'
|
||||||
|
);
|
||||||
|
if (recreate) {
|
||||||
|
const recRes = await fetch(`/api/docker-cases/${encodeURIComponent(caseName)}/recreate`, { method: 'POST' });
|
||||||
|
const recData = await recRes.json();
|
||||||
|
if (!recData.success) throw new Error(recData.error || 'Failed to recreate container');
|
||||||
|
data = await doQuickStart();
|
||||||
|
}
|
||||||
|
}
|
||||||
if (!data.success) throw new Error(data.error || 'Failed to start remote Claude session');
|
if (!data.success) throw new Error(data.error || 'Failed to start remote Claude session');
|
||||||
remoteIds.push(data.data.sessionId);
|
remoteIds.push(data.data.sessionId);
|
||||||
}
|
}
|
||||||
@@ -1958,7 +1994,8 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
listEl.innerHTML = exports
|
listEl.innerHTML = exports
|
||||||
.map(e => {
|
.map(e => {
|
||||||
const mb = (e.sizeBytes / 1e6).toFixed(1);
|
const mb = (e.sizeBytes / 1e6).toFixed(1);
|
||||||
const nm = this.escapeHtml ? this.escapeHtml(e.name) : e.name;
|
// escapeHtml is the free function from constants.js (never a method on `this`)
|
||||||
|
const nm = escapeHtml(e.name);
|
||||||
return `<div class="case-manage-item" style="display:flex; align-items:center; gap:8px; justify-content:space-between;">
|
return `<div class="case-manage-item" style="display:flex; align-items:center; gap:8px; justify-content:space-between;">
|
||||||
<span style="overflow:hidden; text-overflow:ellipsis; white-space:nowrap;" title="${nm}">${nm} <span class="form-hint">(${mb} MB)</span></span>
|
<span style="overflow:hidden; text-overflow:ellipsis; white-space:nowrap;" title="${nm}">${nm} <span class="form-hint">(${mb} MB)</span></span>
|
||||||
<span style="flex-shrink:0;">
|
<span style="flex-shrink:0;">
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ import { generateClaudeMd } from '../../templates/claude-md.js';
|
|||||||
import { writeHooksConfig } from '../../hooks-config.js';
|
import { writeHooksConfig } from '../../hooks-config.js';
|
||||||
import { CASES_DIR, SETTINGS_PATH, validatePathWithinBase, parseBody, readJsonConfig } from '../route-helpers.js';
|
import { CASES_DIR, SETTINGS_PATH, validatePathWithinBase, parseBody, readJsonConfig } from '../route-helpers.js';
|
||||||
import { SseEvent } from '../sse-events.js';
|
import { SseEvent } from '../sse-events.js';
|
||||||
import type { EventPort, ConfigPort } from '../ports/index.js';
|
import type { EventPort, ConfigPort, SessionPort } from '../ports/index.js';
|
||||||
import { dataPath, getDataDir } from '../../config/instance.js';
|
import { dataPath, getDataDir } from '../../config/instance.js';
|
||||||
import {
|
import {
|
||||||
checkDockerAvailable,
|
checkDockerAvailable,
|
||||||
@@ -42,6 +42,7 @@ import {
|
|||||||
dockerDisplayPath,
|
dockerDisplayPath,
|
||||||
readDockerCases,
|
readDockerCases,
|
||||||
readDockerHosts,
|
readDockerHosts,
|
||||||
|
removeDockerContainer,
|
||||||
toSessionDocker,
|
toSessionDocker,
|
||||||
writeDockerCases,
|
writeDockerCases,
|
||||||
writeDockerHosts,
|
writeDockerHosts,
|
||||||
@@ -99,7 +100,7 @@ async function ensureCaseImage(
|
|||||||
sessionDocker: SessionDocker,
|
sessionDocker: SessionDocker,
|
||||||
name: string
|
name: string
|
||||||
): Promise<{ ok: true; imageBuilding: boolean } | { ok: false; error: string }> {
|
): Promise<{ ok: true; imageBuilding: boolean } | { ok: false; error: string }> {
|
||||||
if (await checkDockerImagePresent(sessionDocker.engine, sessionDocker.image)) {
|
if (await checkDockerImagePresent(sessionDocker, sessionDocker.image)) {
|
||||||
const tmuxCheck = await checkDockerTmuxAvailable(sessionDocker);
|
const tmuxCheck = await checkDockerTmuxAvailable(sessionDocker);
|
||||||
if (!tmuxCheck.ok) return { ok: false, error: tmuxCheck.error || 'base image is missing tmux' };
|
if (!tmuxCheck.ok) return { ok: false, error: tmuxCheck.error || 'base image is missing tmux' };
|
||||||
return { ok: true, imageBuilding: false };
|
return { ok: true, imageBuilding: false };
|
||||||
@@ -111,7 +112,7 @@ async function ensureCaseImage(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
broadcast(SseEvent.DockerImageBuildStarted, { name, image: sessionDocker.image });
|
broadcast(SseEvent.DockerImageBuildStarted, { name, image: sessionDocker.image });
|
||||||
void ensureAgentBaseImage(sessionDocker.engine, sessionDocker.image, {
|
void ensureAgentBaseImage(sessionDocker, sessionDocker.image, {
|
||||||
onProgress: (line) => broadcast(SseEvent.DockerImageBuildProgress, { name, line }),
|
onProgress: (line) => broadcast(SseEvent.DockerImageBuildProgress, { name, line }),
|
||||||
})
|
})
|
||||||
.then((r) =>
|
.then((r) =>
|
||||||
@@ -127,7 +128,7 @@ async function ensureCaseImage(
|
|||||||
return { ok: true, imageBuilding: true };
|
return { ok: true, imageBuilding: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & ConfigPort): void {
|
export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & ConfigPort & SessionPort): void {
|
||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
// Case CRUD (list, create, link, detail, fix-plan)
|
// Case CRUD (list, create, link, detail, fix-plan)
|
||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
@@ -652,29 +653,39 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
|||||||
const timestamp = Date.now();
|
const timestamp = Date.now();
|
||||||
let result;
|
let result;
|
||||||
try {
|
try {
|
||||||
result = await importDockerBundle({ bundlePath, destWorkspace: destWorkspacePath, engine: 'docker', timestamp });
|
result = await importDockerBundle({
|
||||||
|
bundlePath,
|
||||||
|
destWorkspace: destWorkspacePath,
|
||||||
|
engine: 'docker',
|
||||||
|
timestamp,
|
||||||
|
newCaseName,
|
||||||
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Import failed: ${getErrorMessage(err)}`);
|
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Import failed: ${getErrorMessage(err)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create a dedicated docker host pointing at the quarantined imported image
|
// Create (or REFRESH) the dedicated docker host pointing at the quarantined
|
||||||
// (full mode) or the manifest's base image (workspace-only).
|
// imported image (full mode) or the manifest's base image (workspace-only).
|
||||||
|
// Refresh matters: after a case-delete + re-import of the same name, a stale
|
||||||
|
// `imported-<name>` host would silently pin the PREVIOUS import's image tag.
|
||||||
const hostId = `imported-${newCaseName}`;
|
const hostId = `imported-${newCaseName}`;
|
||||||
const hosts = await readDockerHosts(CODEMAN_CONFIG_DIR);
|
const hosts = await readDockerHosts(CODEMAN_CONFIG_DIR);
|
||||||
if (!hosts.some((h) => h.id === hostId)) {
|
const importedHost = {
|
||||||
await writeDockerHosts(CODEMAN_CONFIG_DIR, [
|
id: hostId,
|
||||||
...hosts,
|
label: `Imported: ${newCaseName}`,
|
||||||
{
|
engine: result.manifest.engine,
|
||||||
id: hostId,
|
image: result.importedImage ?? result.manifest.image,
|
||||||
label: `Imported: ${newCaseName}`,
|
network: (['bridge', 'none', 'custom'].includes(result.manifest.network) ? result.manifest.network : 'bridge') as
|
||||||
engine: result.manifest.engine,
|
| 'bridge'
|
||||||
image: result.importedImage ?? result.manifest.image,
|
| 'none'
|
||||||
network: (['bridge', 'none', 'custom'].includes(result.manifest.network)
|
| 'custom',
|
||||||
? result.manifest.network
|
};
|
||||||
: 'bridge') as 'bridge' | 'none' | 'custom',
|
await writeDockerHosts(
|
||||||
},
|
CODEMAN_CONFIG_DIR,
|
||||||
]);
|
hosts.some((h) => h.id === hostId)
|
||||||
}
|
? hosts.map((h) => (h.id === hostId ? { ...h, ...importedHost } : h))
|
||||||
|
: [...hosts, importedHost]
|
||||||
|
);
|
||||||
const newCase = {
|
const newCase = {
|
||||||
name: newCaseName,
|
name: newCaseName,
|
||||||
type: 'docker' as const,
|
type: 'docker' as const,
|
||||||
@@ -687,6 +698,47 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
|||||||
return { success: true, data: { case: newCase } };
|
return { success: true, data: { case: newCase } };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Recreate-on-drift confirm (docs/docker-cases-plan.md §4): remove the case
|
||||||
|
// container so the next launch recreates it with the CURRENT host config. The
|
||||||
|
// workspace + transcripts ride bind mounts and survive; the conversation resumes
|
||||||
|
// via the case's lastClaudeSessionId. Refused while sessions of the case are live
|
||||||
|
// (removal would yank the container out from under their panes).
|
||||||
|
app.post(
|
||||||
|
'/api/docker-cases/:name/recreate',
|
||||||
|
async (req): Promise<ApiResponse<{ name: string; container: string }>> => {
|
||||||
|
const { name } = req.params as { name: string };
|
||||||
|
const dockerCase = (await readDockerCases(CODEMAN_CONFIG_DIR)).find((item) => item.name === name);
|
||||||
|
if (!dockerCase) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Docker case not found');
|
||||||
|
const host = (await readDockerHosts(CODEMAN_CONFIG_DIR)).find((item) => item.id === dockerCase.hostId);
|
||||||
|
if (!host) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Docker host not found');
|
||||||
|
const sessionDocker = toSessionDocker(host, dockerCase);
|
||||||
|
|
||||||
|
for (const session of ctx.sessions.values()) {
|
||||||
|
if (session.docker?.containerName === sessionDocker.containerName && session.pid) {
|
||||||
|
return createErrorResponse(
|
||||||
|
ApiErrorCode.CONFLICT,
|
||||||
|
`Sessions of case "${name}" are still running — stop them first, then recreate the container.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await removeDockerContainer(sessionDocker);
|
||||||
|
} catch (err) {
|
||||||
|
return createErrorResponse(
|
||||||
|
ApiErrorCode.OPERATION_FAILED,
|
||||||
|
`Failed to remove container: ${getErrorMessage(err)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Drop the per-container claude-config seed; it is regenerated at next launch.
|
||||||
|
await fs
|
||||||
|
.rm(join(dataPath('docker-seeds'), `${sessionDocker.containerName}.json`), { force: true })
|
||||||
|
.catch(() => {});
|
||||||
|
ctx.broadcast(SseEvent.DockerContainerRecreated, { name, container: sessionDocker.containerName });
|
||||||
|
return { success: true, data: { name, container: sessionDocker.containerName } };
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
// Link an existing folder as a case
|
// Link an existing folder as a case
|
||||||
app.post('/api/cases/link', async (req): Promise<ApiResponse<{ case: { name: string; path: string } }>> => {
|
app.post('/api/cases/link', async (req): Promise<ApiResponse<{ case: { name: string; path: string } }>> => {
|
||||||
const { name, path: folderPath } = parseBody(LinkCaseSchema, req.body, 'Invalid request body');
|
const { name, path: folderPath } = parseBody(LinkCaseSchema, req.body, 'Invalid request body');
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import { FastifyInstance } from 'fastify';
|
|||||||
import { ApiErrorCode, createErrorResponse } from '../../types.js';
|
import { ApiErrorCode, createErrorResponse } from '../../types.js';
|
||||||
import { HookEventSchema, isValidWorkingDir } from '../schemas.js';
|
import { HookEventSchema, isValidWorkingDir } from '../schemas.js';
|
||||||
import { sanitizeHookData, parseBody } from '../route-helpers.js';
|
import { sanitizeHookData, parseBody } from '../route-helpers.js';
|
||||||
|
import { persistDockerCaseClaudeSessionId } from '../../docker-hosts.js';
|
||||||
|
import { getDataDir } from '../../config/instance.js';
|
||||||
import type { SessionPort, EventPort, RespawnPort, ConfigPort, InfraPort } from '../ports/index.js';
|
import type { SessionPort, EventPort, RespawnPort, ConfigPort, InfraPort } from '../ports/index.js';
|
||||||
|
|
||||||
export function registerHookEventRoutes(
|
export function registerHookEventRoutes(
|
||||||
@@ -48,7 +50,18 @@ export function registerHookEventRoutes(
|
|||||||
// the user ran `/clear` (which spins up a new conversation jsonl).
|
// the user ran `/clear` (which spins up a new conversation jsonl).
|
||||||
if (data && typeof data.session_id === 'string' && data.session_id) {
|
if (data && typeof data.session_id === 'string' && data.session_id) {
|
||||||
const session = ctx.sessions.get(sessionId);
|
const session = ctx.sessions.get(sessionId);
|
||||||
|
const prevClaudeSessionId = session?.claudeSessionId;
|
||||||
session?.adoptClaudeSessionId(data.session_id);
|
session?.adoptClaudeSessionId(data.session_id);
|
||||||
|
// Docker sessions: keep the case's resume seed following the LIVE
|
||||||
|
// conversation (post-/clear id switches), so a container stop/reboot
|
||||||
|
// relaunch resumes the right transcript.
|
||||||
|
if (session?.docker && session.claudeSessionId && session.claudeSessionId !== prevClaudeSessionId) {
|
||||||
|
void persistDockerCaseClaudeSessionId(
|
||||||
|
getDataDir(),
|
||||||
|
session.docker.containerName,
|
||||||
|
session.claudeSessionId
|
||||||
|
).catch(() => {});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sanitize forwarded data: only include known safe fields, limit size
|
// Sanitize forwarded data: only include known safe fields, limit size
|
||||||
|
|||||||
@@ -76,9 +76,11 @@ import { dataPath, getDataDir } from '../../config/instance.js';
|
|||||||
import { checkRemoteTmuxAvailable, readRemoteCases, readRemoteHosts, toSessionRemote } from '../../remote-hosts.js';
|
import { checkRemoteTmuxAvailable, readRemoteCases, readRemoteHosts, toSessionRemote } from '../../remote-hosts.js';
|
||||||
import {
|
import {
|
||||||
checkDockerAvailable,
|
checkDockerAvailable,
|
||||||
|
checkDockerConfigDrift,
|
||||||
checkDockerTmuxAvailable,
|
checkDockerTmuxAvailable,
|
||||||
ensureAgentBaseImage,
|
ensureAgentBaseImage,
|
||||||
DEFAULT_AGENT_IMAGE,
|
DEFAULT_AGENT_IMAGE,
|
||||||
|
persistDockerCaseClaudeSessionId,
|
||||||
readDockerCases,
|
readDockerCases,
|
||||||
readDockerHosts,
|
readDockerHosts,
|
||||||
toSessionDocker,
|
toSessionDocker,
|
||||||
@@ -936,6 +938,12 @@ export function registerSessionRoutes(
|
|||||||
const activeId = await resolveActiveClaudeSessionIdFromHistory(session, projectsDir);
|
const activeId = await resolveActiveClaudeSessionIdFromHistory(session, projectsDir);
|
||||||
if (activeId && activeId !== session.claudeSessionId) {
|
if (activeId && activeId !== session.claudeSessionId) {
|
||||||
session.adoptClaudeSessionId(activeId);
|
session.adoptClaudeSessionId(activeId);
|
||||||
|
// Docker sessions: keep the case's resume seed following the live conversation.
|
||||||
|
if (session.docker) {
|
||||||
|
void persistDockerCaseClaudeSessionId(CODEMAN_CONFIG_DIR, session.docker.containerName, activeId).catch(
|
||||||
|
() => {}
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The Claude conversation ID (used as JSONL filename)
|
// The Claude conversation ID (used as JSONL filename)
|
||||||
@@ -1683,6 +1691,7 @@ export function registerSessionRoutes(
|
|||||||
caseName = 'testcase',
|
caseName = 'testcase',
|
||||||
sessionName,
|
sessionName,
|
||||||
mode = 'claude',
|
mode = 'claude',
|
||||||
|
modelOverride,
|
||||||
openCodeConfig,
|
openCodeConfig,
|
||||||
codexConfig,
|
codexConfig,
|
||||||
geminiConfig,
|
geminiConfig,
|
||||||
@@ -1713,13 +1722,14 @@ export function registerSessionRoutes(
|
|||||||
if (
|
if (
|
||||||
(envOverrides && Object.keys(envOverrides).length > 0) ||
|
(envOverrides && Object.keys(envOverrides).length > 0) ||
|
||||||
effort ||
|
effort ||
|
||||||
|
modelOverride !== undefined ||
|
||||||
codexConfig ||
|
codexConfig ||
|
||||||
geminiConfig ||
|
geminiConfig ||
|
||||||
openCodeConfig
|
openCodeConfig
|
||||||
) {
|
) {
|
||||||
return createErrorResponse(
|
return createErrorResponse(
|
||||||
ApiErrorCode.INVALID_INPUT,
|
ApiErrorCode.INVALID_INPUT,
|
||||||
'envOverrides, effort, and per-CLI config are not supported for remote cases (they do not cross ssh). Configure the remote command via the host command override instead.'
|
'envOverrides, effort, modelOverride, and per-CLI config are not supported for remote cases (they do not cross ssh). Configure the remote command via the host command override instead.'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1764,7 +1774,7 @@ export function registerSessionRoutes(
|
|||||||
// Ensure the base image exists, auto-building the default image on first use so
|
// 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
|
// 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.
|
// this awaits the SAME in-flight build rather than starting a second one.
|
||||||
const ensured = await ensureAgentBaseImage(sessionDocker.engine, sessionDocker.image, {
|
const ensured = await ensureAgentBaseImage(sessionDocker, sessionDocker.image, {
|
||||||
onProgress: (line) => ctx.broadcast(SseEvent.DockerImageBuildProgress, { name: dockerCase.name, line }),
|
onProgress: (line) => ctx.broadcast(SseEvent.DockerImageBuildProgress, { name: dockerCase.name, line }),
|
||||||
});
|
});
|
||||||
if (!ensured.ok) {
|
if (!ensured.ok) {
|
||||||
@@ -1783,6 +1793,19 @@ export function registerSessionRoutes(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Config drift (docs/docker-cases-plan.md §4): the desired create-config no
|
||||||
|
// longer matches the existing container's codeman.confighash label. Refuse to
|
||||||
|
// silently launch into the stale container — the frontend confirms a recreate
|
||||||
|
// (POST /api/docker-cases/:name/recreate; workspace + transcripts ride bind
|
||||||
|
// mounts and the conversation resumes), or the user reverts the host edit.
|
||||||
|
const drift = await checkDockerConfigDrift(sessionDocker);
|
||||||
|
if (drift.exists && drift.drifted) {
|
||||||
|
return createErrorResponse(
|
||||||
|
ApiErrorCode.CONFLICT,
|
||||||
|
`Container config for case "${dockerCase.name}" changed since the container was created. Recreate the container to apply it (workspace and conversation survive), or revert the docker host edit.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
casePath = dockerCase.hostWorkspacePath; // a REAL host dir (bind-mounted into the container)
|
casePath = dockerCase.hostWorkspacePath; // a REAL host dir (bind-mounted into the container)
|
||||||
docker = sessionDocker;
|
docker = sessionDocker;
|
||||||
// Seed resume so a relaunch resumes the case's last conversation from the
|
// Seed resume so a relaunch resumes the case's last conversation from the
|
||||||
@@ -1894,6 +1917,13 @@ export function registerSessionRoutes(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Model override → <case>/.claude/settings.local.json (claude-mode; local AND
|
||||||
|
// docker — the docker workspace is a real host dir, so the settings file crosses
|
||||||
|
// the bind mount and the in-container claude reads it). Remote was rejected above.
|
||||||
|
if (mode === 'claude' && modelOverride !== undefined) {
|
||||||
|
await updateCaseModel(resolvedCasePath, modelOverride || null);
|
||||||
|
}
|
||||||
|
|
||||||
// Strip stale disk entries for keys this request is actively setting (Claude only —
|
// Strip stale disk entries for keys this request is actively setting (Claude only —
|
||||||
// see POST /api/sessions for full rationale).
|
// see POST /api/sessions for full rationale).
|
||||||
if (
|
if (
|
||||||
@@ -1990,6 +2020,19 @@ export function registerSessionRoutes(
|
|||||||
}
|
}
|
||||||
ctx.broadcast(SseEvent.SessionUpdated, { session: ctx.getSessionStateWithRespawn(session) });
|
ctx.broadcast(SseEvent.SessionUpdated, { session: ctx.getSessionStateWithRespawn(session) });
|
||||||
|
|
||||||
|
// Docker + claude: the pane command pins the conversation id (--session-id /
|
||||||
|
// --resume, claudeDockerPaneCommand), so persist it as the case's resume seed
|
||||||
|
// NOW — a later container stop/reboot relaunch resumes this conversation even
|
||||||
|
// if no in-container hook ever reaches the host (loopback bind, no bridge
|
||||||
|
// listener). Hook/last-response adoption updates it again after /clear.
|
||||||
|
if (docker && mode === 'claude') {
|
||||||
|
void persistDockerCaseClaudeSessionId(
|
||||||
|
CODEMAN_CONFIG_DIR,
|
||||||
|
docker.containerName,
|
||||||
|
session.claudeSessionId || session.id
|
||||||
|
).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
// Save lastUsedCase to settings for TUI/web sync
|
// Save lastUsedCase to settings for TUI/web sync
|
||||||
try {
|
try {
|
||||||
const settingsFilePath = SETTINGS_PATH;
|
const settingsFilePath = SETTINGS_PATH;
|
||||||
|
|||||||
@@ -449,17 +449,22 @@ export const DockerHostSchema = z.object({
|
|||||||
export const DockerCaseLinkSchema = z.object({
|
export const DockerCaseLinkSchema = z.object({
|
||||||
name: z.string().regex(/^[a-zA-Z0-9_-]+$/, 'Invalid case name format'),
|
name: z.string().regex(/^[a-zA-Z0-9_-]+$/, 'Invalid case name format'),
|
||||||
hostId: z.string().regex(/^[a-zA-Z0-9_-]+$/, 'Invalid docker host id'),
|
hostId: z.string().regex(/^[a-zA-Z0-9_-]+$/, 'Invalid docker host id'),
|
||||||
|
// No commas: the path is embedded in a `--mount type=bind,src=<path>,dst=<path>`
|
||||||
|
// CSV spec, and docker's --mount parser splits fields on commas (shell escaping
|
||||||
|
// cannot protect it). Spaces are fine.
|
||||||
hostWorkspacePath: z
|
hostWorkspacePath: z
|
||||||
.string()
|
.string()
|
||||||
.min(1)
|
.min(1)
|
||||||
.max(2000)
|
.max(2000)
|
||||||
.regex(/^\//, 'Workspace path must be absolute')
|
.regex(/^\//, 'Workspace path must be absolute')
|
||||||
|
.regex(/^[^,]*$/, 'Workspace path must not contain commas (docker --mount is comma-delimited)')
|
||||||
.regex(NO_SHELL_META, 'Invalid characters in workspace path'),
|
.regex(NO_SHELL_META, 'Invalid characters in workspace path'),
|
||||||
containerWorkdir: z
|
containerWorkdir: z
|
||||||
.string()
|
.string()
|
||||||
.min(1)
|
.min(1)
|
||||||
.max(2000)
|
.max(2000)
|
||||||
.regex(/^\//, 'Container workdir must be absolute')
|
.regex(/^\//, 'Container workdir must be absolute')
|
||||||
|
.regex(/^[^,]*$/, 'Container workdir must not contain commas (docker --mount is comma-delimited)')
|
||||||
.regex(NO_SHELL_META, 'Invalid characters in container workdir')
|
.regex(NO_SHELL_META, 'Invalid characters in container workdir')
|
||||||
.optional(),
|
.optional(),
|
||||||
container: z
|
container: z
|
||||||
@@ -487,6 +492,7 @@ export const DockerImportSchema = z.object({
|
|||||||
.min(1)
|
.min(1)
|
||||||
.max(2000)
|
.max(2000)
|
||||||
.regex(/^\//, 'Destination path must be absolute')
|
.regex(/^\//, 'Destination path must be absolute')
|
||||||
|
.regex(/^[^,]*$/, 'Destination path must not contain commas (docker --mount is comma-delimited)')
|
||||||
.regex(NO_SHELL_META, 'Invalid characters in destination path'),
|
.regex(NO_SHELL_META, 'Invalid characters in destination path'),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -539,6 +545,11 @@ export const QuickStartSchema = z.object({
|
|||||||
/** Display name for the created session tab (e.g. w1-mycase). Cosmetic; the durable
|
/** 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. */
|
* mux/container names derive from the session id, not this. Defaults server-side. */
|
||||||
sessionName: z.string().max(128).optional(),
|
sessionName: z.string().max(128).optional(),
|
||||||
|
/** Model override written to <case>/.claude/settings.local.json (e.g. "opus[1m]").
|
||||||
|
* Empty string clears. Applied for local AND docker cases (the docker workspace is
|
||||||
|
* a real host dir, so the settings file crosses the bind mount); rejected for
|
||||||
|
* remote cases (the file would be written on the WRONG machine). */
|
||||||
|
modelOverride: z.string().max(50).optional(),
|
||||||
mode: z.enum(['claude', 'shell', 'opencode', 'codex', 'gemini']).optional(),
|
mode: z.enum(['claude', 'shell', 'opencode', 'codex', 'gemini']).optional(),
|
||||||
openCodeConfig: OpenCodeConfigSchema,
|
openCodeConfig: OpenCodeConfigSchema,
|
||||||
codexConfig: CodexConfigSchema,
|
codexConfig: CodexConfigSchema,
|
||||||
|
|||||||
+6
-1
@@ -1514,7 +1514,12 @@ export class WebServer extends EventEmitter {
|
|||||||
const claudeMode = settings.claudeMode as string | undefined;
|
const claudeMode = settings.claudeMode as string | undefined;
|
||||||
const allowedTools = settings.allowedTools as string | undefined;
|
const allowedTools = settings.allowedTools as string | undefined;
|
||||||
// Only return valid modes
|
// Only return valid modes
|
||||||
if (claudeMode === 'dangerously-skip-permissions' || claudeMode === 'normal' || claudeMode === 'allowedTools') {
|
if (
|
||||||
|
claudeMode === 'dangerously-skip-permissions' ||
|
||||||
|
claudeMode === 'auto' ||
|
||||||
|
claudeMode === 'normal' ||
|
||||||
|
claudeMode === 'allowedTools'
|
||||||
|
) {
|
||||||
return { claudeMode, allowedTools };
|
return { claudeMode, allowedTools };
|
||||||
}
|
}
|
||||||
return {};
|
return {};
|
||||||
|
|||||||
@@ -385,6 +385,8 @@ export const DockerImageBuildProgress = 'docker:imageBuildProgress' as const;
|
|||||||
export const DockerImageBuildComplete = 'docker:imageBuildComplete' as const;
|
export const DockerImageBuildComplete = 'docker:imageBuildComplete' as const;
|
||||||
/** The agent base image build failed. */
|
/** The agent base image build failed. */
|
||||||
export const DockerImageBuildFailed = 'docker:imageBuildFailed' as const;
|
export const DockerImageBuildFailed = 'docker:imageBuildFailed' as const;
|
||||||
|
/** A case container was removed after a config-drift confirm (recreated with the new config on next launch). */
|
||||||
|
export const DockerContainerRecreated = 'docker:containerRecreated' as const;
|
||||||
|
|
||||||
// ─── Namespace Re-export ─────────────────────────────────────────────────────
|
// ─── Namespace Re-export ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -576,4 +578,5 @@ export const SseEvent = {
|
|||||||
DockerImageBuildProgress,
|
DockerImageBuildProgress,
|
||||||
DockerImageBuildComplete,
|
DockerImageBuildComplete,
|
||||||
DockerImageBuildFailed,
|
DockerImageBuildFailed,
|
||||||
|
DockerContainerRecreated,
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Tests for Claude CLI startup permission modes, focused on the
|
||||||
|
* 'auto' mode (`--permission-mode auto`, Anthropic's recommended low-prompt mode)
|
||||||
|
* added alongside the default `--dangerously-skip-permissions`.
|
||||||
|
*
|
||||||
|
* Covers BOTH spawn paths, which build the permission flags independently:
|
||||||
|
* - session-cli-builder.buildInteractiveArgs (direct PTY, non-mux fallback)
|
||||||
|
* - tmux-manager.buildSpawnCommand (tmux pane command string)
|
||||||
|
* The default must stay 'dangerously-skip-permissions' when the setting is unset.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { buildInteractiveArgs } from '../src/session-cli-builder.js';
|
||||||
|
import { buildSpawnCommand } from '../src/tmux-manager.js';
|
||||||
|
|
||||||
|
describe('buildInteractiveArgs permission modes (direct PTY path)', () => {
|
||||||
|
it('keeps --dangerously-skip-permissions as the skip-mode flag', () => {
|
||||||
|
const args = buildInteractiveArgs('sid-1', 'dangerously-skip-permissions');
|
||||||
|
expect(args).toContain('--dangerously-skip-permissions');
|
||||||
|
expect(args).not.toContain('--permission-mode');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('auto mode emits --permission-mode auto and never the skip flag', () => {
|
||||||
|
const args = buildInteractiveArgs('sid-1', 'auto');
|
||||||
|
const idx = args.indexOf('--permission-mode');
|
||||||
|
expect(idx).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(args[idx + 1]).toBe('auto');
|
||||||
|
expect(args).not.toContain('--dangerously-skip-permissions');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normal mode emits no permission flag at all', () => {
|
||||||
|
const args = buildInteractiveArgs('sid-1', 'normal');
|
||||||
|
expect(args).not.toContain('--dangerously-skip-permissions');
|
||||||
|
expect(args).not.toContain('--permission-mode');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allowedTools mode is unchanged by the auto addition', () => {
|
||||||
|
const args = buildInteractiveArgs('sid-1', 'allowedTools', undefined, 'Read,Grep');
|
||||||
|
expect(args).toEqual(expect.arrayContaining(['--allowedTools', 'Read,Grep']));
|
||||||
|
expect(args).not.toContain('--permission-mode');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('auto mode composes with model and effort flags', () => {
|
||||||
|
const args = buildInteractiveArgs('sid-1', 'auto', 'opus', undefined, 'high');
|
||||||
|
expect(args).toEqual(expect.arrayContaining(['--permission-mode', 'auto', '--model', 'opus', '--effort', 'high']));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('buildSpawnCommand permission modes (tmux path)', () => {
|
||||||
|
it('unset claudeMode defaults to --dangerously-skip-permissions', () => {
|
||||||
|
const cmd = buildSpawnCommand({ mode: 'claude', sessionId: 'sid-1' });
|
||||||
|
expect(cmd).toContain('claude --dangerously-skip-permissions --session-id "sid-1"');
|
||||||
|
expect(cmd).not.toContain('--permission-mode');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('auto mode emits --permission-mode auto and never the skip flag', () => {
|
||||||
|
const cmd = buildSpawnCommand({ mode: 'claude', sessionId: 'sid-1', claudeMode: 'auto' });
|
||||||
|
expect(cmd).toContain('claude --permission-mode auto --session-id "sid-1"');
|
||||||
|
expect(cmd).not.toContain('--dangerously-skip-permissions');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('auto mode carries into BOTH legs of the resume fallback command', () => {
|
||||||
|
const cmd = buildSpawnCommand({
|
||||||
|
mode: 'claude',
|
||||||
|
sessionId: 'sid-1',
|
||||||
|
claudeMode: 'auto',
|
||||||
|
resumeSessionId: 'abc-123',
|
||||||
|
});
|
||||||
|
const [resumeLeg, fallbackLeg] = cmd.split('||');
|
||||||
|
expect(resumeLeg).toContain('--permission-mode auto');
|
||||||
|
expect(resumeLeg).toContain('--resume "abc-123"');
|
||||||
|
expect(fallbackLeg).toContain('--permission-mode auto');
|
||||||
|
expect(fallbackLeg).toContain('--session-id "sid-1"');
|
||||||
|
expect(cmd).not.toContain('--dangerously-skip-permissions');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normal mode emits no permission flag', () => {
|
||||||
|
const cmd = buildSpawnCommand({ mode: 'claude', sessionId: 'sid-1', claudeMode: 'normal' });
|
||||||
|
expect(cmd).toContain('claude --session-id "sid-1"');
|
||||||
|
expect(cmd).not.toContain('--permission-mode');
|
||||||
|
expect(cmd).not.toContain('--dangerously-skip-permissions');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -88,11 +88,25 @@ describe('buildDockerLaunchCommand', () => {
|
|||||||
expect(cmd).toContain("sh -lc '");
|
expect(cmd).toContain("sh -lc '");
|
||||||
});
|
});
|
||||||
|
|
||||||
it('injects the resume flag ONLY when a resume id is passed', () => {
|
it('pins a deterministic conversation id with a reboot-surviving fallback (fresh launch)', () => {
|
||||||
|
const cmd = buildDockerLaunchCommand(launchOpts());
|
||||||
|
// --session-id first (fresh start), || --resume so a container stop/reboot
|
||||||
|
// relaunch of the SAME session resumes instead of dead-paning on
|
||||||
|
// "Session ID already in use".
|
||||||
|
expect(cmd).toContain(
|
||||||
|
'claude --dangerously-skip-permissions --session-id 1a2b3c4d5e6f || ' +
|
||||||
|
'claude --dangerously-skip-permissions --resume 1a2b3c4d5e6f'
|
||||||
|
);
|
||||||
|
// exec is stripped from the claude pane command — an exec'd first branch could never fall back.
|
||||||
|
expect(cmd).not.toContain('exec claude');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resumes an explicit id with a --session-id fallback (stale id never dead-panes)', () => {
|
||||||
const withResume = buildDockerLaunchCommand(launchOpts({ resumeSessionId: 'abc-123-def' }));
|
const withResume = buildDockerLaunchCommand(launchOpts({ resumeSessionId: 'abc-123-def' }));
|
||||||
expect(withResume).toContain('exec claude --dangerously-skip-permissions --resume abc-123-def');
|
expect(withResume).toContain(
|
||||||
const without = buildDockerLaunchCommand(launchOpts());
|
'claude --dangerously-skip-permissions --resume abc-123-def || ' +
|
||||||
expect(without).not.toContain('--resume');
|
'claude --dangerously-skip-permissions --session-id 1a2b3c4d5e6f'
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('uses codex resume syntax and drops an unsafe resume id', () => {
|
it('uses codex resume syntax and drops an unsafe resume id', () => {
|
||||||
@@ -101,8 +115,10 @@ describe('buildDockerLaunchCommand', () => {
|
|||||||
);
|
);
|
||||||
expect(codex).toContain('exec codex resume 01H-codex-id');
|
expect(codex).toContain('exec codex resume 01H-codex-id');
|
||||||
const unsafe = buildDockerLaunchCommand(launchOpts({ resumeSessionId: 'x; rm -rf /' }));
|
const unsafe = buildDockerLaunchCommand(launchOpts({ resumeSessionId: 'x; rm -rf /' }));
|
||||||
expect(unsafe).not.toContain('--resume');
|
expect(unsafe).not.toContain('x; rm'); // unsafe id dropped entirely
|
||||||
expect(unsafe).not.toContain('rm -rf');
|
expect(unsafe).not.toContain('rm -rf');
|
||||||
|
// falls back to the deterministic fresh-launch chain on the session's own id
|
||||||
|
expect(unsafe).toContain('--session-id 1a2b3c4d5e6f');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('forwards codex/gemini keys NAME-ONLY (no value in argv)', () => {
|
it('forwards codex/gemini keys NAME-ONLY (no value in argv)', () => {
|
||||||
@@ -128,10 +144,13 @@ describe('buildDockerLaunchCommand', () => {
|
|||||||
expect(cmd).toContain('/home/arkon/my cases/proj');
|
expect(cmd).toContain('/home/arkon/my cases/proj');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('honors a per-host command override', () => {
|
it('honors a per-host command override (exec stripped for the session-id chain)', () => {
|
||||||
const docker = { ...toSessionDocker(HOST, CASE), commands: { claude: 'exec claude --model opus' } };
|
const docker = { ...toSessionDocker(HOST, CASE), commands: { claude: 'exec claude --model opus' } };
|
||||||
const cmd = buildDockerLaunchCommand(launchOpts({ docker }));
|
const cmd = buildDockerLaunchCommand(launchOpts({ docker }));
|
||||||
expect(cmd).toContain('exec claude --model opus');
|
expect(cmd).toContain('claude --model opus --session-id 1a2b3c4d5e6f');
|
||||||
|
const shellOverride = { ...toSessionDocker(HOST, CASE), commands: { shell: 'exec zsh -l' } };
|
||||||
|
const shellCmd = buildDockerLaunchCommand(launchOpts({ mode: 'shell' as SessionMode, docker: shellOverride }));
|
||||||
|
expect(shellCmd).toContain('exec zsh -l'); // non-claude overrides keep their exec
|
||||||
});
|
});
|
||||||
|
|
||||||
it('seeds writable config (guarded copies, mkdir -p parent) from the read-only seed mounts', () => {
|
it('seeds writable config (guarded copies, mkdir -p parent) from the read-only seed mounts', () => {
|
||||||
|
|||||||
@@ -12,7 +12,9 @@ import {
|
|||||||
isSafeTarMember,
|
isSafeTarMember,
|
||||||
parseLoadedImageRef,
|
parseLoadedImageRef,
|
||||||
exportDockerCase,
|
exportDockerCase,
|
||||||
|
validateImportManifest,
|
||||||
DOCKER_EXPORT_SCHEMA,
|
DOCKER_EXPORT_SCHEMA,
|
||||||
|
type DockerExportManifest,
|
||||||
} from '../src/docker-export.js';
|
} from '../src/docker-export.js';
|
||||||
import { toSessionDocker } from '../src/docker-hosts.js';
|
import { toSessionDocker } from '../src/docker-hosts.js';
|
||||||
import type { DockerCase, DockerHost } from '../src/types.js';
|
import type { DockerCase, DockerHost } from '../src/types.js';
|
||||||
@@ -63,6 +65,43 @@ describe('isSafeTarMember (import traversal guard)', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('validateImportManifest (untrusted cross-machine input)', () => {
|
||||||
|
const good = (): DockerExportManifest => ({
|
||||||
|
schemaVersion: DOCKER_EXPORT_SCHEMA,
|
||||||
|
caseName: 'myproj',
|
||||||
|
mode: 'full',
|
||||||
|
engine: 'docker',
|
||||||
|
image: 'codeman/agent:base',
|
||||||
|
containerWorkdir: '/home/arkon/cases/myproj',
|
||||||
|
network: 'bridge',
|
||||||
|
createdAt: 1,
|
||||||
|
codemanVersion: '1.4.1',
|
||||||
|
mountCredentials: true,
|
||||||
|
secretFree: true,
|
||||||
|
checksums: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts a well-formed manifest', () => {
|
||||||
|
expect(() => validateImportManifest(good())).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a hostile engine (would select the probe/launch binary)', () => {
|
||||||
|
expect(() => validateImportManifest({ ...good(), engine: 'rm' as never })).toThrow(/engine/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects shell metacharacters in containerWorkdir', () => {
|
||||||
|
expect(() => validateImportManifest({ ...good(), containerWorkdir: '/w; rm -rf ~' })).toThrow(/containerWorkdir/);
|
||||||
|
expect(() => validateImportManifest({ ...good(), containerWorkdir: 'relative/path' })).toThrow(/containerWorkdir/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects bad image refs, case names, networks, and schema versions', () => {
|
||||||
|
expect(() => validateImportManifest({ ...good(), image: '-bad$(x)' })).toThrow(/image/);
|
||||||
|
expect(() => validateImportManifest({ ...good(), caseName: '../evil' })).toThrow(/caseName/);
|
||||||
|
expect(() => validateImportManifest({ ...good(), network: 'host' })).toThrow(/network/);
|
||||||
|
expect(() => validateImportManifest({ ...good(), schemaVersion: 99 })).toThrow(/schema version/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('parseLoadedImageRef', () => {
|
describe('parseLoadedImageRef', () => {
|
||||||
it('parses "Loaded image ID: sha256:..."', () => {
|
it('parses "Loaded image ID: sha256:..."', () => {
|
||||||
expect(parseLoadedImageRef('Loaded image ID: sha256:abc123def')).toBe('sha256:abc123def');
|
expect(parseLoadedImageRef('Loaded image ID: sha256:abc123def')).toBe('sha256:abc123def');
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
defaultDockerCommandForMode,
|
defaultDockerCommandForMode,
|
||||||
ensureAgentBaseImage,
|
ensureAgentBaseImage,
|
||||||
hostGatewayAlias,
|
hostGatewayAlias,
|
||||||
|
persistDockerCaseClaudeSessionId,
|
||||||
probeDockerCliVersion,
|
probeDockerCliVersion,
|
||||||
readDockerCases,
|
readDockerCases,
|
||||||
readDockerHosts,
|
readDockerHosts,
|
||||||
@@ -67,6 +68,17 @@ describe('docker-hosts storage', () => {
|
|||||||
expect(await readDockerHosts(dir)).toEqual([]);
|
expect(await readDockerHosts(dir)).toEqual([]);
|
||||||
expect(await readDockerCases(dir)).toEqual([]);
|
expect(await readDockerCases(dir)).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('persists the last Claude conversation id keyed by container name', async () => {
|
||||||
|
await writeDockerCases(dir, [CASE, { ...CASE, name: 'other', container: 'custom-name' }]);
|
||||||
|
await persistDockerCaseClaudeSessionId(dir, dockerContainerName(CASE.name), 'conv-1');
|
||||||
|
await persistDockerCaseClaudeSessionId(dir, 'custom-name', 'conv-2');
|
||||||
|
await persistDockerCaseClaudeSessionId(dir, 'no-such-container', 'conv-3'); // no-op
|
||||||
|
const cases = await readDockerCases(dir);
|
||||||
|
expect(cases.find((c) => c.name === 'myproj')?.lastClaudeSessionId).toBe('conv-1');
|
||||||
|
expect(cases.find((c) => c.name === 'other')?.lastClaudeSessionId).toBe('conv-2');
|
||||||
|
expect(cases.some((c) => c.lastClaudeSessionId === 'conv-3')).toBe(false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('naming / display / defaults', () => {
|
describe('naming / display / defaults', () => {
|
||||||
@@ -427,7 +439,7 @@ describe('agentImageBuildArgs', () => {
|
|||||||
|
|
||||||
describe('ensureAgentBaseImage (no-op under VITEST)', () => {
|
describe('ensureAgentBaseImage (no-op under VITEST)', () => {
|
||||||
it('reports the image as already present without spawning a build', async () => {
|
it('reports the image as already present without spawning a build', async () => {
|
||||||
const r = await ensureAgentBaseImage('docker', DEFAULT_AGENT_IMAGE);
|
const r = await ensureAgentBaseImage({ engine: 'docker' }, DEFAULT_AGENT_IMAGE);
|
||||||
expect(r).toEqual({ ok: true, built: false, alreadyPresent: true });
|
expect(r).toEqual({ ok: true, built: false, alreadyPresent: true });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -442,7 +454,7 @@ describe('daemon probes (no-op under VITEST)', () => {
|
|||||||
|
|
||||||
it('checkDockerTmuxAvailable + image present are canned-true', async () => {
|
it('checkDockerTmuxAvailable + image present are canned-true', async () => {
|
||||||
expect((await checkDockerTmuxAvailable({ engine: 'docker', image: DEFAULT_AGENT_IMAGE })).ok).toBe(true);
|
expect((await checkDockerTmuxAvailable({ engine: 'docker', image: DEFAULT_AGENT_IMAGE })).ok).toBe(true);
|
||||||
expect(await checkDockerImagePresent('docker', DEFAULT_AGENT_IMAGE)).toBe(true);
|
expect(await checkDockerImagePresent({ engine: 'docker' }, DEFAULT_AGENT_IMAGE)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('probeDockerCliVersion is undefined under test', async () => {
|
it('probeDockerCliVersion is undefined under test', async () => {
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ const MOBILE_VISIBLE_ALLOWLIST = new Set<string>([]);
|
|||||||
// that removes a hide rule fails loudly (not silently). The attachments button is
|
// that removes a hide rule fails loudly (not silently). The attachments button is
|
||||||
// NOT here: it's opt-in (default-hidden everywhere via its own --hidden marker), so
|
// NOT here: it's opt-in (default-hidden everywhere via its own --hidden marker), so
|
||||||
// it's excluded from the default-visible enumeration rather than mobile-hidden.
|
// it's excluded from the default-visible enumeration rather than mobile-hidden.
|
||||||
const KNOWN_PHONE_HIDDEN = ['btn-settings', 'btn-lifecycle-log', 'btn-session-manager'];
|
const KNOWN_PHONE_HIDDEN = ['btn-settings', 'btn-lifecycle-log', 'btn-session-manager', 'btn-file-viewer'];
|
||||||
|
|
||||||
function attrOf(openTag: string, name: string): string {
|
function attrOf(openTag: string, name: string): string {
|
||||||
const m = openTag.match(new RegExp(`${name}="([^"]*)"`));
|
const m = openTag.match(new RegExp(`${name}="([^"]*)"`));
|
||||||
|
|||||||
Reference in New Issue
Block a user