feat(docker): resource templates, GPU, elastic disk, bridge-hooks listener

- One-click "Run in Docker" gains an expandable settings panel with a Template
  picker (Small 2G/1 · Medium 4G/2 default · Large 8G/4 · GPU 8G/4/all) plus
  memory/cpu/gpu/network/image/mount-creds overrides. Any tweak creates a dedicated
  per-case host; the plain checkbox keeps using the shared `default` host.
- GPU passthrough: `gpus` on DockerHost/SessionDocker -> `--gpus <value>` in create
  args (needs the NVIDIA container toolkit). Elastic disk: no `--storage-opt` cap,
  so container storage grows as data flows in.
- CODEMAN_DOCKER_BRIDGE_HOOKS=1: opt-in second listener on the docker bridge gateway
  (auto-detected 172.17.0.1, override CODEMAN_DOCKER_BRIDGE_HOST) that serves ONLY
  the hook endpoints and delegates into the secret-gated pipeline, so in-container
  hooks fire on a loopback-only server. Non-hook paths -> 403; host-internal, not LAN.

Verified live: Large template applies real 8GB/4CPU limits; a secret-authenticated
hook POST from inside a container now reaches the handler (was connection-refused);
non-hook paths return 403; template UI + GPU field verified via Playwright.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-07-19 21:35:38 +02:00
parent db6cd838b1
commit 3e1272a675
9 changed files with 310 additions and 24 deletions
+20 -4
View File
@@ -15,7 +15,22 @@ node scripts/build-agent-image.mjs # builds codeman/agent:base
The image is **secret-free**: credentials are delivered at runtime (bind mounts or `docker exec --env`), never baked in, so exports never leak them.
## Create a docker case
## Quickest path: one-click "Run in Docker"
On the **New case → Create New** tab there's a **🐳 Run in an isolated Docker container** checkbox. Checking it alone is enough: Codeman creates the case folder in `~/codeman-cases/<name>`, spins up a hardened container with sensible defaults (auto-provisioning a shared `default` host), and starts the session inside it. No host/image/network fields to fill in.
Click the checkbox's **Container settings** to optionally tweak the predefined defaults, including a **Template** picker:
| Template | Memory | CPUs | GPUs |
|----------|--------|------|------|
| Small | 2 GB | 1 | none |
| Medium (default) | 4 GB | 2 | none |
| Large | 8 GB | 4 | none |
| GPU | 8 GB | 4 | all (needs the NVIDIA container toolkit) |
**Disk is elastic** — the container's storage grows automatically as data flows in; there is no fixed cap (bounded only by host disk). Any tweaked setting creates a dedicated per-case host so it never changes the shared `default`.
## Create a docker case (full control)
App → **New case → Docker** tab:
@@ -64,10 +79,11 @@ The container is paused across the capture so the image and workspace are consis
In-container hooks (permission events, hook-based idle/stop/task notifications) POST to `CODEMAN_API_URL`, which is derived as `https://host.docker.internal:<port>` (`host.docker.internal` → the docker bridge gateway, e.g. `172.17.0.1`, via `--add-host …:host-gateway`). For that callback to succeed, the Codeman server must be **listening on an interface the container can reach**.
- If Codeman binds **loopback-only** (`127.0.0.1`, the default and the production systemd config), a container reaching `172.17.0.1:<port>` cannot connect, so **in-container hooks do not fire**. The session still works fully: idle/stop detection falls back to **output-based** detection through the `docker exec` PTY (which always works), and claude runs with `--dangerously-skip-permissions` so there are no permission prompts to forward anyway.
- To enable in-container hooks, run Codeman where the container can reach it: bind `0.0.0.0` **with `CODEMAN_PASSWORD` set** (`CODEMAN_HOST=0.0.0.0`), or otherwise make `172.17.0.1:<port>` reachable. The host guard already allowlists `host.docker.internal` / `host.containers.internal`, and the hook secret is mounted, so hooks work as soon as the callback is reachable.
- If Codeman binds **loopback-only** (`127.0.0.1`, the default and the production systemd config), a container reaching `172.17.0.1:<port>` cannot connect, so by default **in-container hooks do not fire**. The session still works fully: idle/stop detection falls back to **output-based** detection through the `docker exec` PTY (which always works), and claude runs with `--dangerously-skip-permissions` so there are no permission prompts to forward anyway.
- **To enable in-container hooks on a loopback-only server, set `CODEMAN_DOCKER_BRIDGE_HOOKS=1`** (env). Codeman then starts a SECOND listener bound to the docker bridge gateway (`172.17.0.1`, auto-detected; override with `CODEMAN_DOCKER_BRIDGE_HOST`) that serves **only the hook endpoints** (`/api/hook-event`, `/api/status-telemetry`) and delegates them into the same secret-gated pipeline. The bridge is host-internal (containers + host, not the LAN), and every other path returns `403`, so this does not widen your network exposure. Add `Environment=CODEMAN_DOCKER_BRIDGE_HOOKS=1` to the systemd unit and restart.
- Alternatively, bind `0.0.0.0` **with `CODEMAN_PASSWORD` set** (exposes on the LAN too).
This is an environmental constraint, not a code limitation: the host-gateway mapping, `CODEMAN_API_URL` derivation, host-guard allowlist, and hook-secret mount are all wired correctly.
The host-gateway mapping, `CODEMAN_API_URL` derivation, host-guard allowlist, and hook-secret mount are all wired correctly; `CODEMAN_DOCKER_BRIDGE_HOOKS` closes the last gap for loopback-only servers.
## Notes & limits
+30
View File
@@ -179,6 +179,7 @@ export function dockerConfigHash(
| 'network'
| 'networkName'
| 'resources'
| 'gpus'
| 'mountCredentials'
| 'extraCreateArgs'
>
@@ -190,6 +191,7 @@ export function dockerConfigHash(
network: docker.network,
networkName: docker.networkName ?? null,
resources: docker.resources ?? null,
gpus: docker.gpus ?? null,
mountCredentials: docker.mountCredentials,
extraCreateArgs: docker.extraCreateArgs ?? null,
});
@@ -215,6 +217,7 @@ export function toSessionDocker(host: DockerHost, dockerCase: DockerCase): Sessi
network: host.network ?? 'bridge',
networkName: host.networkName,
resources: host.resources ?? DEFAULT_DOCKER_RESOURCES,
gpus: host.gpus,
mountCredentials: host.mountCredentials ?? true,
hooksEnabled: host.hooksEnabled ?? true,
resumeOnStart: host.resumeOnStart ?? true,
@@ -360,6 +363,10 @@ export function buildDockerCreateArgs(ctx: DockerCreateContext): string[] {
args.push(
...resourceFlags(docker.resources),
// GPU passthrough (needs the NVIDIA container toolkit on the host). No storage
// cap is set, so the container's writable layer + volumes grow elastically as
// data flows in (bounded only by host disk).
...(docker.gpus ? ['--gpus', shellescape(docker.gpus)] : []),
'--cap-drop',
'ALL',
'--security-opt',
@@ -545,6 +552,29 @@ export async function checkDockerTmuxAvailable(
}
}
/**
* Resolve the host's IP on the default docker bridge (the address a container
* reaches as `host.docker.internal`), so the server can bind a hooks-only listener
* there and in-container hooks can call back. Defaults to the conventional
* 172.17.0.1 when the inspect fails but docker is up; null when docker is absent.
* No-op canned value under VITEST.
*/
export async function detectDockerBridgeGateway(engine: DockerEngine = 'docker'): Promise<string | null> {
if (IS_TEST_MODE) return '172.17.0.1';
const bin = engine === 'podman' ? 'podman' : 'docker';
try {
const { stdout } = await execFileAsync(
bin,
['network', 'inspect', 'bridge', '--format', '{{(index .IPAM.Config 0).Gateway}}'],
{ timeout: DOCKER_PROBE_TIMEOUT_MS }
);
const ip = stdout.trim();
return /^\d{1,3}(\.\d{1,3}){3}$/.test(ip) ? ip : '172.17.0.1';
} catch {
return null; // docker not available — nothing to bind
}
}
/**
* Instance-scoped boot reaper: `docker rm -f` any MANAGED container that belongs
* to THIS instance (by the `codeman.instance` label) but whose case is no longer
+4
View File
@@ -153,6 +153,8 @@ export interface DockerHost {
/** Custom bridge name when network === 'custom'. */
networkName?: string;
resources?: DockerResourceLimits;
/** GPU allocation, e.g. 'all' / '1' / 'device=0,1' -> `--gpus <value>` (needs the NVIDIA container toolkit). */
gpus?: string;
/** true (default) = convenient: bind-mount host cred dirs RW. false = sealed (blocks full-image export). */
mountCredentials?: boolean;
/** true (default) = wire in-container hooks (host-gateway callback + workspace scaffold). */
@@ -198,6 +200,8 @@ export interface SessionDocker {
network: DockerNetworkMode;
networkName?: string;
resources?: DockerResourceLimits;
/** GPU allocation ('all' / '1' / 'device=0,1'). */
gpus?: string;
mountCredentials: boolean;
hooksEnabled: boolean;
resumeOnStart: boolean;
+45 -2
View File
@@ -1865,9 +1865,52 @@
<input type="text" id="newCaseDescription" placeholder="A brief description..." autocomplete="off">
</div>
<div class="form-row">
<label class="checkbox-row"><input type="checkbox" id="newCaseDocker"> 🐳 Run in an isolated Docker container</label>
<span class="form-hint">One click: creates the case folder AND a hardened container with default settings, then starts the session inside it. Requires the base image (<code>node scripts/build-agent-image.mjs</code>).</span>
<label class="checkbox-row"><input type="checkbox" id="newCaseDocker" onchange="app.toggleDockerQuickSettings()"> 🐳 Run in an isolated Docker container</label>
<span class="form-hint">One checkbox is enough — it creates the case folder AND a hardened container with default settings, then starts the session inside it. Click to expand for optional presets. Requires the base image (<code>node scripts/build-agent-image.mjs</code>).</span>
</div>
<details class="advanced-options" id="dockerQuickSettings" style="display:none;">
<summary>Container settings (optional — sensible defaults)</summary>
<div class="advanced-options-content">
<div class="form-row">
<label>Template</label>
<select id="quickDockerTemplate" onchange="app.applyDockerTemplate()">
<option value="small">Small — 2 GB RAM, 1 CPU</option>
<option value="medium" selected>Medium — 4 GB RAM, 2 CPU (default)</option>
<option value="large">Large — 8 GB RAM, 4 CPU</option>
<option value="gpu">GPU — 8 GB RAM, 4 CPU, all GPUs</option>
<option value="custom">Custom</option>
</select>
<span class="form-hint">Disk is elastic — storage grows automatically as data flows in (no fixed cap).</span>
</div>
<div class="form-row">
<label>Memory</label>
<input type="text" id="quickDockerMemory" placeholder="4g" autocomplete="off" spellcheck="false">
</div>
<div class="form-row">
<label>CPUs</label>
<input type="text" id="quickDockerCpus" placeholder="2" autocomplete="off" spellcheck="false">
</div>
<div class="form-row">
<label>GPUs</label>
<input type="text" id="quickDockerGpus" placeholder="none (e.g. all, or 1)" autocomplete="off" spellcheck="false">
<span class="form-hint">Needs the NVIDIA container toolkit on the host.</span>
</div>
<div class="form-row">
<label>Network</label>
<select id="quickDockerNetwork">
<option value="bridge">bridge (internet on)</option>
<option value="none">none (fully isolated)</option>
</select>
</div>
<div class="form-row">
<label>Image</label>
<input type="text" id="quickDockerImage" placeholder="codeman/agent:base" autocomplete="off" spellcheck="false">
</div>
<div class="form-row">
<label class="checkbox-row"><input type="checkbox" id="quickDockerMountCreds" checked> Mount host credentials (~/.claude etc.)</label>
</div>
</div>
</details>
</div>
<!-- Link Existing Tab -->
<div class="modal-tab-content hidden" id="case-link">
+54 -3
View File
@@ -1642,16 +1642,19 @@ Object.assign(CodemanApp.prototype, {
return;
}
// One-click "Run in Docker": create the case folder AND a container (default
// settings), then start a session inside it.
// One-click "Run in Docker": create the case folder AND a container, then start
// a session inside it. Optional expandable settings override the defaults.
const inDocker = document.getElementById('newCaseDocker')?.checked;
const endpoint = inDocker ? '/api/cases/docker-quickcreate' : '/api/cases';
const payload = inDocker
? { name, description, ...this._collectDockerQuickSettings() }
: { name, description };
try {
const res = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, description })
body: JSON.stringify(payload)
});
const data = await res.json();
@@ -1678,6 +1681,54 @@ Object.assign(CodemanApp.prototype, {
}
},
// Show/hide the expandable container-settings section under the Docker checkbox.
toggleDockerQuickSettings() {
const on = document.getElementById('newCaseDocker')?.checked;
const el = document.getElementById('dockerQuickSettings');
if (el) el.style.display = on ? '' : 'none';
},
// Fill the memory/cpu/gpu fields from a resource template. `medium` clears them so
// the server uses its defaults (no per-case host); `custom` leaves them editable.
applyDockerTemplate() {
const t = document.getElementById('quickDockerTemplate')?.value;
const presets = {
small: { m: '2g', c: '1', g: '' },
medium: { m: '', c: '', g: '' },
large: { m: '8g', c: '4', g: '' },
gpu: { m: '8g', c: '4', g: 'all' },
};
const p = presets[t];
if (!p) return; // 'custom' — leave fields as-is
const set = (id, v) => {
const el = document.getElementById(id);
if (el) el.value = v;
};
set('quickDockerMemory', p.m);
set('quickDockerCpus', p.c);
set('quickDockerGpus', p.g);
},
// Collect only the non-default docker overrides (empty fields fall back to defaults
// server-side; sent as undefined, never null, per the Zod .optional() gotcha).
_collectDockerQuickSettings() {
const val = (id) => (document.getElementById(id)?.value || '').trim();
const o = {};
const mem = val('quickDockerMemory');
if (mem) o.memory = mem;
const cpus = val('quickDockerCpus');
if (cpus) o.cpus = cpus;
const gpus = val('quickDockerGpus');
if (gpus && gpus.toLowerCase() !== 'none') o.gpus = gpus;
const net = document.getElementById('quickDockerNetwork')?.value;
if (net && net !== 'bridge') o.network = net;
const img = val('quickDockerImage');
if (img) o.image = img;
const mc = document.getElementById('quickDockerMountCreds');
if (mc && !mc.checked) o.mountCredentials = false;
return o;
},
async linkCase() {
const name = document.getElementById('linkCaseName').value.trim();
const path = document.getElementById('linkCasePath').value.trim();
+40 -15
View File
@@ -11,7 +11,7 @@ import fs from 'node:fs/promises';
import { join, resolve, basename } from 'node:path';
import { fileURLToPath } from 'node:url';
import { homedir } from 'node:os';
import type { ApiResponse, CaseInfo } from '../../types.js';
import type { ApiResponse, CaseInfo, DockerHost } from '../../types.js';
import { ApiErrorCode, createErrorResponse, getErrorMessage } from '../../types.js';
import {
CreateCaseSchema,
@@ -23,6 +23,7 @@ import {
DockerHostSchema,
DockerExportSchema,
DockerImportSchema,
DockerQuickCreateSchema,
} from '../schemas.js';
import { exportDockerCase, importDockerBundle, listDockerExports, exportBundleName } from '../../docker-export.js';
import { generateClaudeMd } from '../../templates/claude-md.js';
@@ -400,7 +401,8 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
app.post(
'/api/cases/docker-quickcreate',
async (req): Promise<ApiResponse<{ case: unknown; capsEnforced?: boolean; isDesktop?: boolean }>> => {
const { name, description } = parseBody(CreateCaseSchema, req.body);
const body = parseBody(DockerQuickCreateSchema, req.body);
const { name, description } = body;
const casePath = validatePathWithinBase(name, CASES_DIR);
if (!casePath) return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case path');
@@ -417,20 +419,43 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
return createErrorResponse(ApiErrorCode.ALREADY_EXISTS, 'Case already exists');
}
// Auto-provision the shared `default` docker host (convenient, hardened defaults).
// The checkbox alone (no overrides) uses the shared `default` host; any tweaked
// setting gets a dedicated per-case host so it never mutates the shared default.
const hasOverrides = !!(
body.image ||
body.network ||
body.networkName ||
body.memory ||
body.cpus ||
body.gpus ||
body.mountCredentials !== undefined
);
const resources: { memory?: string; cpus?: string } = {};
if (body.memory) resources.memory = body.memory;
if (body.cpus) resources.cpus = body.cpus;
const desiredHost: DockerHost = {
id: hasOverrides ? `q-${name}` : DEFAULT_DOCKER_HOST_ID,
label: hasOverrides ? `Case: ${name}` : 'Default',
image: body.image || DEFAULT_AGENT_IMAGE,
network: body.network || 'bridge',
...(body.networkName ? { networkName: body.networkName } : {}),
...(Object.keys(resources).length ? { resources } : {}),
...(body.gpus ? { gpus: body.gpus } : {}),
mountCredentials: body.mountCredentials ?? true,
resumeOnStart: true,
hooksEnabled: true,
};
const hosts = await readDockerHosts(CODEMAN_CONFIG_DIR);
let host = hosts.find((item) => item.id === DEFAULT_DOCKER_HOST_ID);
if (!host) {
host = {
id: DEFAULT_DOCKER_HOST_ID,
label: 'Default',
image: DEFAULT_AGENT_IMAGE,
network: 'bridge',
mountCredentials: true,
resumeOnStart: true,
hooksEnabled: true,
};
await writeDockerHosts(CODEMAN_CONFIG_DIR, [...hosts, host]);
const existing = hosts.find((item) => item.id === desiredHost.id);
// Reuse the shared default if present; create/refresh a per-case host for overrides.
const host = existing && !hasOverrides ? existing : desiredHost;
if (!existing) {
await writeDockerHosts(CODEMAN_CONFIG_DIR, [...hosts, desiredHost]);
} else if (hasOverrides) {
await writeDockerHosts(
CODEMAN_CONFIG_DIR,
hosts.map((h) => (h.id === desiredHost.id ? desiredHost : h))
);
}
// Probe the daemon + base image BEFORE scaffolding, so a missing docker/image
+40
View File
@@ -413,6 +413,11 @@ export const DockerHostSchema = z.object({
.regex(/^[a-zA-Z0-9][a-zA-Z0-9_.-]+$/, 'Invalid network name')
.optional(),
resources: DockerResourceLimitsSchema.optional(),
gpus: z
.string()
.max(128)
.regex(/^(all|\d+|device=[a-zA-Z0-9,:._-]+)$/, 'GPUs must be all / a count / device=...')
.optional(),
mountCredentials: z.boolean().optional(),
hooksEnabled: z.boolean().optional(),
resumeOnStart: z.boolean().optional(),
@@ -485,6 +490,41 @@ export const DockerImportSchema = z.object({
.regex(NO_SHELL_META, 'Invalid characters in destination path'),
});
// One-click "Run in Docker" case creation. name/description behave like a normal
// case; the docker fields are OPTIONAL overrides of the predefined defaults (the
// checkbox alone, with no overrides, uses the shared `default` host).
export const DockerQuickCreateSchema = z.object({
name: z.string().regex(/^[a-zA-Z0-9_-]+$/, 'Invalid case name format'),
description: z.string().max(1000).optional(),
image: z
.string()
.min(1)
.max(512)
.regex(/^[a-zA-Z0-9][\w./:@-]*$/, 'Invalid image reference')
.regex(NO_SHELL_META, 'Invalid characters in image reference')
.optional(),
network: z.enum(['bridge', 'none', 'custom']).optional(),
networkName: z
.string()
.max(128)
.regex(/^[a-zA-Z0-9][a-zA-Z0-9_.-]+$/, 'Invalid network name')
.optional(),
memory: z
.string()
.regex(/^\d+[bkmg]?$/i, 'Memory must be like 512m / 4g')
.optional(),
cpus: z
.string()
.regex(/^\d+(\.\d+)?$/, 'CPUs must be a number')
.optional(),
gpus: z
.string()
.max(128)
.regex(/^(all|\d+|device=[a-zA-Z0-9,:._-]+)$/, 'GPUs must be all / a count / device=...')
.optional(),
mountCredentials: z.boolean().optional(),
});
// ========== Quick Start ==========
/**
+68
View File
@@ -288,6 +288,8 @@ export class WebServer extends EventEmitter {
private readonly allowUnauthenticatedNetwork: boolean;
private _pasteImageGcStop: (() => void) | null = null;
private _eventLoopMonitor: EventLoopMonitorHandle | null = null;
/** Opt-in hooks-only listener on the docker bridge gateway (CODEMAN_DOCKER_BRIDGE_HOOKS). */
private _dockerBridgeServer: import('node:http').Server | import('node:https').Server | null = null;
private teamWatcherHandlers: {
teamCreated: (config: unknown) => void;
teamUpdated: (config: unknown) => void;
@@ -1975,6 +1977,15 @@ export class WebServer extends EventEmitter {
const displayHost = this.host === '0.0.0.0' ? 'localhost' : this.host;
console.log(`Codeman web interface running at ${protocol}://${displayHost}:${this.port}`);
// Opt-in: also serve the HOOK endpoints on the docker bridge gateway so
// in-container hooks (permission/idle/stop callbacks) can reach a loopback-bound
// server. Hooks-only + secret-gated, and the bridge is host-internal (not the LAN).
if (!this.testMode) {
await this._startDockerBridgeHooksListener().catch((err) =>
console.error(`[Docker] bridge-hooks listener error: ${err?.message || err}`)
);
}
// Anti-DNS-rebinding Host allowlist is always on. Localhost, any bare IP, the
// bind host, *.ts.net / *.trycloudflare.com / *.cfargotunnel.com, and the active
// managed tunnel are accepted automatically; add any other domain you front this
@@ -2417,6 +2428,58 @@ export class WebServer extends EventEmitter {
return this._orchestratorLoop;
}
/**
* Opt-in (CODEMAN_DOCKER_BRIDGE_HOOKS=1): start a SECOND listener on the docker
* bridge gateway IP that serves ONLY the hook endpoints and delegates them into
* the main Fastify pipeline. This lets in-container hooks reach a loopback-bound
* server (they call back via host.docker.internal = the bridge gateway) without
* exposing the full API or the LAN. Bind IP is auto-detected (default bridge
* gateway) or set via CODEMAN_DOCKER_BRIDGE_HOST.
*/
private async _startDockerBridgeHooksListener(): Promise<void> {
if (!isExplicitlyEnabled(process.env.CODEMAN_DOCKER_BRIDGE_HOOKS)) return;
const { detectDockerBridgeGateway } = await import('../docker-hosts.js');
const bridgeHost = (process.env.CODEMAN_DOCKER_BRIDGE_HOST || '').trim() || (await detectDockerBridgeGateway());
if (!bridgeHost) {
console.log('[Docker] CODEMAN_DOCKER_BRIDGE_HOOKS set but no docker bridge gateway found — skipping');
return;
}
// Only the hook endpoints are served on the bridge — never the full API.
const HOOK_PATHS = new Set([
'/api/hook-event',
'/api/status-telemetry',
'/api/v1/hook-event',
'/api/v1/status-telemetry',
]);
const handler = (req: import('node:http').IncomingMessage, res: import('node:http').ServerResponse): void => {
const path = (req.url || '').split('?')[0];
if (!HOOK_PATHS.has(path)) {
res.statusCode = 403;
res.end('forbidden: the docker bridge listener serves hook endpoints only');
return;
}
// Delegate into Fastify (host-guard, Origin/CSRF, and hook-secret gate all apply).
(this.app as unknown as { routing: (r: unknown, s: unknown) => void }).routing(req, res);
};
let server: import('node:http').Server | import('node:https').Server;
if (this.https) {
const https = await import('node:https');
const { key, cert } = getOrCreateSelfSignedCert();
server = https.createServer({ key, cert }, handler);
} else {
const http = await import('node:http');
server = http.createServer(handler);
}
await new Promise<void>((resolve, reject) => {
server.once('error', reject);
server.listen(this.port, bridgeHost, () => resolve());
});
this._dockerBridgeServer = server;
console.log(
`[Docker] in-container hooks reachable at ${this.https ? 'https' : 'http'}://${bridgeHost}:${this.port} (hook endpoints only)`
);
}
async stop(): Promise<void> {
getLifecycleLog().log({ event: 'server_stopped', sessionId: '*' });
// Set stopping flag to prevent new timer creation during shutdown
@@ -2432,6 +2495,11 @@ export class WebServer extends EventEmitter {
this._eventLoopMonitor = null;
}
if (this._dockerBridgeServer) {
this._dockerBridgeServer.close();
this._dockerBridgeServer = null;
}
// Dispose all managed timers (intervals + resettable timeouts)
this.cleanup.dispose();
+9
View File
@@ -240,6 +240,15 @@ describe('buildDockerCreateArgs', () => {
const docker: SessionDocker = { ...toSessionDocker(HOST, CASE), network: 'custom', networkName: 'codeman-net-x' };
expect(buildDockerCreateArgs(ctx({ docker })).join(' ')).toContain('--network codeman-net-x');
});
it('emits --gpus only when GPUs are requested (and never a storage cap)', () => {
const withGpu: SessionDocker = { ...toSessionDocker(HOST, CASE), gpus: 'all' };
const s = buildDockerCreateArgs(ctx({ docker: withGpu })).join(' ');
expect(s).toContain("--gpus 'all'");
// elastic disk: no fixed storage cap is ever emitted
expect(s).not.toContain('--storage-opt');
expect(buildDockerCreateArgs(ctx()).join(' ')).not.toContain('--gpus');
});
});
describe('resolveCredentialMounts', () => {