mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
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:
+20
-4
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ==========
|
||||
|
||||
/**
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user