aicodeman 1.3.5 → 1.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +16 -0
- package/dist/docker-export.d.ts +107 -0
- package/dist/docker-export.d.ts.map +1 -0
- package/dist/docker-export.js +335 -0
- package/dist/docker-export.js.map +1 -0
- package/dist/docker-hosts.d.ts +264 -0
- package/dist/docker-hosts.d.ts.map +1 -0
- package/dist/docker-hosts.js +744 -0
- package/dist/docker-hosts.js.map +1 -0
- package/dist/mux-interface.d.ts +7 -1
- package/dist/mux-interface.d.ts.map +1 -1
- package/dist/session.d.ts +9 -2
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +48 -4
- package/dist/session.js.map +1 -1
- package/dist/tmux-manager.d.ts +58 -1
- package/dist/tmux-manager.d.ts.map +1 -1
- package/dist/tmux-manager.js +247 -7
- package/dist/tmux-manager.js.map +1 -1
- package/dist/types/api.d.ts +9 -1
- package/dist/types/api.d.ts.map +1 -1
- package/dist/types/api.js.map +1 -1
- package/dist/types/session.d.ts +102 -0
- package/dist/types/session.d.ts.map +1 -1
- package/dist/types/session.js.map +1 -1
- package/dist/web/network-auth-policy.d.ts +9 -0
- package/dist/web/network-auth-policy.d.ts.map +1 -1
- package/dist/web/network-auth-policy.js +12 -0
- package/dist/web/network-auth-policy.js.map +1 -1
- package/dist/web/public/api-client.c9b1cddc.js.gz +0 -0
- package/dist/web/public/{app.6890f3d0.js → app.6c4b9893.js} +1 -1
- package/dist/web/public/app.6c4b9893.js.br +0 -0
- package/dist/web/public/app.6c4b9893.js.gz +0 -0
- package/dist/web/public/{constants.5b18ed64.js → constants.48da7f03.js} +7 -0
- package/dist/web/public/constants.48da7f03.js.br +0 -0
- package/dist/web/public/{constants.5b18ed64.js.gz → constants.48da7f03.js.gz} +0 -0
- package/dist/web/public/cron-ui.js.gz +0 -0
- package/dist/web/public/image-input.ee16ad88.js.gz +0 -0
- package/dist/web/public/index.html +148 -11
- package/dist/web/public/index.html.br +0 -0
- package/dist/web/public/index.html.gz +0 -0
- package/dist/web/public/input-cjk.63794d0b.js.gz +0 -0
- package/dist/web/public/keyboard-accessory.3f91acef.js.gz +0 -0
- package/dist/web/public/mobile-handlers.b5a667ef.js.gz +0 -0
- package/dist/web/public/mobile.0644e3ac.css.gz +0 -0
- package/dist/web/public/notification-manager.ca9dc40d.js.gz +0 -0
- package/dist/web/public/orchestrator-panel.js.gz +0 -0
- package/dist/web/public/{panels-ui.ca03853f.js → panels-ui.3c9b0465.js} +53 -53
- package/dist/web/public/panels-ui.3c9b0465.js.br +0 -0
- package/dist/web/public/panels-ui.3c9b0465.js.gz +0 -0
- package/dist/web/public/ralph-panel.6de2d0f8.js.gz +0 -0
- package/dist/web/public/ralph-wizard.13a1831e.js.gz +0 -0
- package/dist/web/public/respawn-ui.2d249da9.js.gz +0 -0
- package/dist/web/public/sanitize-html.bc7078d6.js.gz +0 -0
- package/dist/web/public/session-ui.4851c61a.js +58 -0
- package/dist/web/public/session-ui.4851c61a.js.br +0 -0
- package/dist/web/public/session-ui.4851c61a.js.gz +0 -0
- package/dist/web/public/settings-ui.29214cf1.js +67 -0
- package/dist/web/public/settings-ui.29214cf1.js.br +0 -0
- package/dist/web/public/settings-ui.29214cf1.js.gz +0 -0
- package/dist/web/public/{styles.3d78fd0d.css → styles.ff43f80f.css} +1 -1
- package/dist/web/public/styles.ff43f80f.css.br +0 -0
- package/dist/web/public/styles.ff43f80f.css.gz +0 -0
- package/dist/web/public/subagent-windows.69b60c35.js.gz +0 -0
- package/dist/web/public/sw.js.gz +0 -0
- package/dist/web/public/terminal-ui.efda6083.js.gz +0 -0
- package/dist/web/public/ultracode-panel.js.gz +0 -0
- package/dist/web/public/ultracode-windows.js.gz +0 -0
- package/dist/web/public/upload.html.gz +0 -0
- package/dist/web/public/vendor/dompurify.min.js.gz +0 -0
- package/dist/web/public/vendor/marked.min.js.gz +0 -0
- package/dist/web/public/vendor/xterm-addon-fit.min.js.gz +0 -0
- package/dist/web/public/vendor/xterm-addon-serialize.min.js.gz +0 -0
- package/dist/web/public/vendor/xterm-addon-unicode11.min.js.gz +0 -0
- package/dist/web/public/vendor/xterm-addon-webgl.min.js.gz +0 -0
- package/dist/web/public/vendor/xterm-zerolag-input.137ad9f0.js.gz +0 -0
- package/dist/web/public/vendor/xterm.css.gz +0 -0
- package/dist/web/public/vendor/xterm.min.js.gz +0 -0
- package/dist/web/public/voice-input.085e9e73.js.gz +0 -0
- package/dist/web/routes/case-routes.d.ts.map +1 -1
- package/dist/web/routes/case-routes.js +422 -3
- package/dist/web/routes/case-routes.js.map +1 -1
- package/dist/web/routes/session-routes.d.ts.map +1 -1
- package/dist/web/routes/session-routes.js +85 -5
- package/dist/web/routes/session-routes.js.map +1 -1
- package/dist/web/schemas.d.ts +72 -1
- package/dist/web/schemas.d.ts.map +1 -1
- package/dist/web/schemas.js +158 -0
- package/dist/web/schemas.js.map +1 -1
- package/dist/web/server.d.ts +11 -0
- package/dist/web/server.d.ts.map +1 -1
- package/dist/web/server.js +81 -1
- package/dist/web/server.js.map +1 -1
- package/dist/web/sse-events.d.ts +21 -0
- package/dist/web/sse-events.d.ts.map +1 -1
- package/dist/web/sse-events.js +23 -0
- package/dist/web/sse-events.js.map +1 -1
- package/package.json +1 -1
- package/dist/web/public/app.6890f3d0.js.br +0 -0
- package/dist/web/public/app.6890f3d0.js.gz +0 -0
- package/dist/web/public/constants.5b18ed64.js.br +0 -0
- package/dist/web/public/panels-ui.ca03853f.js.br +0 -0
- package/dist/web/public/panels-ui.ca03853f.js.gz +0 -0
- package/dist/web/public/session-ui.c9094fe9.js +0 -49
- package/dist/web/public/session-ui.c9094fe9.js.br +0 -0
- package/dist/web/public/session-ui.c9094fe9.js.gz +0 -0
- package/dist/web/public/settings-ui.9538a0c1.js +0 -67
- package/dist/web/public/settings-ui.9538a0c1.js.br +0 -0
- package/dist/web/public/settings-ui.9538a0c1.js.gz +0 -0
- package/dist/web/public/styles.3d78fd0d.css.br +0 -0
- package/dist/web/public/styles.3d78fd0d.css.gz +0 -0
|
@@ -0,0 +1,744 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Docker cases: storage, pure command-arg builders, and daemon probes.
|
|
3
|
+
*
|
|
4
|
+
* Docker mode is a LOCATION OVERLAY on cases (not a 6th SessionMode), the direct
|
|
5
|
+
* analog of the remote-SSH feature in `remote-hosts.ts`. Instead of a local tmux
|
|
6
|
+
* pane running `ssh host` into a durable remote tmux server, a local tmux pane
|
|
7
|
+
* runs `docker exec -it` into a durable IN-CONTAINER tmux server. The container is
|
|
8
|
+
* scoped to the CASE (`codeman-case-<name>`), so multiple sessions can `docker
|
|
9
|
+
* exec` into the same long-lived container.
|
|
10
|
+
*
|
|
11
|
+
* This module mirrors `remote-hosts.ts`:
|
|
12
|
+
* - JSON storage for hosts (`docker-hosts.json`) and cases (`docker-cases.json`)
|
|
13
|
+
* - `toSessionDocker()` (mirror of `toSessionRemote`)
|
|
14
|
+
* - `buildDockerBaseArgs()` / `buildDockerCreateArgs()` (mirror of `buildSshConnectionArgs`)
|
|
15
|
+
* - `checkDockerAvailable()` / `checkDockerTmuxAvailable()` (mirror of `checkRemoteTmuxAvailable`)
|
|
16
|
+
*
|
|
17
|
+
* The launch/kill command orchestration (`buildDockerLaunchCommand`,
|
|
18
|
+
* `buildDockerKillCommand`, `dockerTmuxSessionName`) lives in `tmux-manager.ts`,
|
|
19
|
+
* mirroring where `buildRemoteLaunchCommand` lives.
|
|
20
|
+
*
|
|
21
|
+
* @module docker-hosts
|
|
22
|
+
*/
|
|
23
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
24
|
+
import fs from 'node:fs/promises';
|
|
25
|
+
import { join, dirname } from 'node:path';
|
|
26
|
+
import { fileURLToPath } from 'node:url';
|
|
27
|
+
import { homedir } from 'node:os';
|
|
28
|
+
import { createHash } from 'node:crypto';
|
|
29
|
+
import { execFile, spawn } from 'node:child_process';
|
|
30
|
+
import { promisify } from 'node:util';
|
|
31
|
+
import { dataPath } from './config/instance.js';
|
|
32
|
+
const execFileAsync = promisify(execFile);
|
|
33
|
+
/** Under vitest, all real `docker` invocations no-op (mirror of tmux-manager's IS_TEST_MODE). */
|
|
34
|
+
const IS_TEST_MODE = !!process.env.VITEST;
|
|
35
|
+
const DOCKER_HOSTS_FILE = 'docker-hosts.json';
|
|
36
|
+
const DOCKER_CASES_FILE = 'docker-cases.json';
|
|
37
|
+
/** Locally-built base image (see scripts/build-agent-image.mjs). */
|
|
38
|
+
export const DEFAULT_AGENT_IMAGE = 'codeman/agent:base';
|
|
39
|
+
/** HOME inside the base image (the `agent` user). Cred mounts + hook-secret land under it. */
|
|
40
|
+
export const CONTAINER_HOME = '/home/agent';
|
|
41
|
+
/** Per-case container name prefix. The `case` letters deliberately do NOT matter to
|
|
42
|
+
* tmux; this is a DOCKER name (`^[a-zA-Z0-9][a-zA-Z0-9_.-]+$`), and case names are
|
|
43
|
+
* already validated `^[a-zA-Z0-9_-]+$`, so `codeman-case-<name>` is always valid. */
|
|
44
|
+
const CONTAINER_NAME_PREFIX = 'codeman-case-';
|
|
45
|
+
/** Sensible resource defaults (all overridable per host). */
|
|
46
|
+
export const DEFAULT_DOCKER_RESOURCES = {
|
|
47
|
+
memory: '4g',
|
|
48
|
+
cpus: '2',
|
|
49
|
+
pidsLimit: 512,
|
|
50
|
+
nofile: '4096:8192',
|
|
51
|
+
};
|
|
52
|
+
// ========== Storage (mirror of remote-hosts.ts) ==========
|
|
53
|
+
export function dockerHostsPath(configDir) {
|
|
54
|
+
return join(configDir, DOCKER_HOSTS_FILE);
|
|
55
|
+
}
|
|
56
|
+
export function dockerCasesPath(configDir) {
|
|
57
|
+
return join(configDir, DOCKER_CASES_FILE);
|
|
58
|
+
}
|
|
59
|
+
async function readJsonArray(path) {
|
|
60
|
+
try {
|
|
61
|
+
const raw = await fs.readFile(path, 'utf-8');
|
|
62
|
+
const parsed = JSON.parse(raw);
|
|
63
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return [];
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
async function writeJsonArray(configDir, path, value) {
|
|
70
|
+
if (!existsSync(configDir))
|
|
71
|
+
mkdirSync(configDir, { recursive: true });
|
|
72
|
+
await fs.writeFile(path, JSON.stringify(value, null, 2));
|
|
73
|
+
}
|
|
74
|
+
export async function readDockerHosts(configDir) {
|
|
75
|
+
return readJsonArray(dockerHostsPath(configDir));
|
|
76
|
+
}
|
|
77
|
+
export async function writeDockerHosts(configDir, hosts) {
|
|
78
|
+
await writeJsonArray(configDir, dockerHostsPath(configDir), hosts);
|
|
79
|
+
}
|
|
80
|
+
export async function readDockerCases(configDir) {
|
|
81
|
+
return readJsonArray(dockerCasesPath(configDir));
|
|
82
|
+
}
|
|
83
|
+
export async function writeDockerCases(configDir, cases) {
|
|
84
|
+
await writeJsonArray(configDir, dockerCasesPath(configDir), cases);
|
|
85
|
+
}
|
|
86
|
+
// ========== Naming / display / defaults ==========
|
|
87
|
+
/** Per-case container name. Mirrors how remote derives a stable name from the case. */
|
|
88
|
+
export function dockerContainerName(caseName) {
|
|
89
|
+
return `${CONTAINER_NAME_PREFIX}${caseName}`;
|
|
90
|
+
}
|
|
91
|
+
/** Default pane command per CLI mode (mirror of defaultRemoteCommandForMode). */
|
|
92
|
+
export function defaultDockerCommandForMode(mode) {
|
|
93
|
+
const commands = {
|
|
94
|
+
shell: 'exec bash -l',
|
|
95
|
+
// Mirror the LOCAL claude default so the in-container agent runs non-interactively.
|
|
96
|
+
claude: 'exec claude --dangerously-skip-permissions',
|
|
97
|
+
opencode: 'exec opencode',
|
|
98
|
+
codex: 'exec codex',
|
|
99
|
+
gemini: 'exec gemini',
|
|
100
|
+
};
|
|
101
|
+
return commands[mode] || commands.shell;
|
|
102
|
+
}
|
|
103
|
+
/** `container:/workdir` display string (mirror of remoteDisplayPath's `user@host:path`). */
|
|
104
|
+
export function dockerDisplayPath(docker) {
|
|
105
|
+
if ('containerName' in docker)
|
|
106
|
+
return `${docker.containerName}:${docker.containerWorkdir}`;
|
|
107
|
+
return `${docker.container}:${docker.path}`;
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* The host-callback gateway alias is ENGINE-SPECIFIC: Docker exposes the host as
|
|
111
|
+
* `host.docker.internal`, Podman as `host.containers.internal`. Both are added to
|
|
112
|
+
* the host-guard allowlist so a mixed fleet keeps working.
|
|
113
|
+
*/
|
|
114
|
+
export function hostGatewayAlias(engine) {
|
|
115
|
+
return engine === 'podman' ? 'host.containers.internal' : 'host.docker.internal';
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Rewrite the server's own `CODEMAN_API_URL` to a container-reachable one by
|
|
119
|
+
* swapping ONLY the hostname for the engine's host-gateway alias, preserving
|
|
120
|
+
* scheme AND port (prod is HTTPS on 3000, so hardcoding http://…:3000 breaks
|
|
121
|
+
* every hook). Falls back to `https://<alias>:3000` when the input is absent or
|
|
122
|
+
* unparseable.
|
|
123
|
+
*/
|
|
124
|
+
export function containerApiUrl(processApiUrl, engine) {
|
|
125
|
+
const alias = hostGatewayAlias(engine);
|
|
126
|
+
if (!processApiUrl)
|
|
127
|
+
return `https://${alias}:3000`;
|
|
128
|
+
try {
|
|
129
|
+
const url = new URL(processApiUrl);
|
|
130
|
+
url.hostname = alias;
|
|
131
|
+
// origin drops any trailing path/slash and keeps scheme + (non-default) port
|
|
132
|
+
return url.origin;
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
return `https://${alias}:3000`;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Stable hash of the drift-relevant `docker create` inputs, stored on the
|
|
140
|
+
* container as the `codeman.confighash` label. On launch, a mismatch between the
|
|
141
|
+
* desired hash and the running container's label triggers the recreate-on-drift
|
|
142
|
+
* prompt (host config edits actually take effect).
|
|
143
|
+
*/
|
|
144
|
+
export function dockerConfigHash(docker) {
|
|
145
|
+
const normalized = JSON.stringify({
|
|
146
|
+
engine: docker.engine,
|
|
147
|
+
image: docker.image,
|
|
148
|
+
containerWorkdir: docker.containerWorkdir,
|
|
149
|
+
network: docker.network,
|
|
150
|
+
networkName: docker.networkName ?? null,
|
|
151
|
+
resources: docker.resources ?? null,
|
|
152
|
+
gpus: docker.gpus ?? null,
|
|
153
|
+
mountCredentials: docker.mountCredentials,
|
|
154
|
+
extraCreateArgs: docker.extraCreateArgs ?? null,
|
|
155
|
+
});
|
|
156
|
+
return createHash('sha256').update(normalized).digest('hex').slice(0, 12);
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Build the flattened per-session Docker metadata from a host profile + a case,
|
|
160
|
+
* resolving every default (mirror of toSessionRemote). The `configHash` is
|
|
161
|
+
* computed last over the resolved values.
|
|
162
|
+
*/
|
|
163
|
+
export function toSessionDocker(host, dockerCase) {
|
|
164
|
+
const engine = host.engine ?? 'docker';
|
|
165
|
+
const containerWorkdir = dockerCase.containerWorkdir ?? dockerCase.hostWorkspacePath;
|
|
166
|
+
const base = {
|
|
167
|
+
hostId: host.id,
|
|
168
|
+
label: host.label,
|
|
169
|
+
engine,
|
|
170
|
+
image: host.image || DEFAULT_AGENT_IMAGE,
|
|
171
|
+
containerName: dockerCase.container ?? dockerContainerName(dockerCase.name),
|
|
172
|
+
hostWorkspacePath: dockerCase.hostWorkspacePath,
|
|
173
|
+
containerWorkdir,
|
|
174
|
+
network: host.network ?? 'bridge',
|
|
175
|
+
networkName: host.networkName,
|
|
176
|
+
resources: host.resources ?? DEFAULT_DOCKER_RESOURCES,
|
|
177
|
+
gpus: host.gpus,
|
|
178
|
+
mountCredentials: host.mountCredentials ?? true,
|
|
179
|
+
hooksEnabled: host.hooksEnabled ?? true,
|
|
180
|
+
resumeOnStart: host.resumeOnStart ?? true,
|
|
181
|
+
daemonHost: host.daemonHost,
|
|
182
|
+
context: host.context,
|
|
183
|
+
commands: host.commands,
|
|
184
|
+
extraCreateArgs: host.extraCreateArgs,
|
|
185
|
+
extraExecArgs: host.extraExecArgs,
|
|
186
|
+
};
|
|
187
|
+
return { ...base, configHash: dockerConfigHash(base) };
|
|
188
|
+
}
|
|
189
|
+
// ========== Shell escaping ==========
|
|
190
|
+
/**
|
|
191
|
+
* POSIX single-quote shell-escaping (end-quote, escaped-quote, restart-quote).
|
|
192
|
+
* Mirror of the helper in remote-hosts.ts / tmux-manager.ts. Every dynamic value
|
|
193
|
+
* interpolated into the outer `bash -c "..."` launch layer is escaped through
|
|
194
|
+
* this so a path with spaces stays a single shell token. Operator-entered fields
|
|
195
|
+
* are ALSO schema-rejected for `$`/backtick (NO_SHELL_META) as defense in depth.
|
|
196
|
+
*/
|
|
197
|
+
export function shellescape(str) {
|
|
198
|
+
return "'" + str.replace(/'/g, "'\\''") + "'";
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Engine prefix tokens shared by every docker invocation (mirror of
|
|
202
|
+
* buildSshConnectionArgs). Returns e.g. ['docker'] or ['podman','--context','ctx'].
|
|
203
|
+
*/
|
|
204
|
+
export function buildDockerBaseArgs(docker) {
|
|
205
|
+
const parts = [docker.engine === 'podman' ? 'podman' : 'docker'];
|
|
206
|
+
if (docker.context)
|
|
207
|
+
parts.push('--context', shellescape(docker.context));
|
|
208
|
+
if (docker.daemonHost)
|
|
209
|
+
parts.push('-H', shellescape(docker.daemonHost));
|
|
210
|
+
return parts;
|
|
211
|
+
}
|
|
212
|
+
function mountSpec(m) {
|
|
213
|
+
return `type=bind,src=${m.src},dst=${m.dst}${m.readonly ? ',readonly' : ''}`;
|
|
214
|
+
}
|
|
215
|
+
function resourceFlags(resources) {
|
|
216
|
+
if (!resources)
|
|
217
|
+
return [];
|
|
218
|
+
const flags = [];
|
|
219
|
+
if (resources.memory) {
|
|
220
|
+
// memory-swap == memory disables swap, making --memory a REAL OOM cap.
|
|
221
|
+
flags.push('--memory', resources.memory, '--memory-swap', resources.memory);
|
|
222
|
+
}
|
|
223
|
+
if (resources.cpus)
|
|
224
|
+
flags.push('--cpus', resources.cpus);
|
|
225
|
+
if (resources.pidsLimit)
|
|
226
|
+
flags.push('--pids-limit', String(resources.pidsLimit));
|
|
227
|
+
if (resources.nofile)
|
|
228
|
+
flags.push('--ulimit', `nofile=${resources.nofile}`);
|
|
229
|
+
if (resources.shmSize)
|
|
230
|
+
flags.push('--shm-size', resources.shmSize);
|
|
231
|
+
return flags;
|
|
232
|
+
}
|
|
233
|
+
function networkArg(network, networkName) {
|
|
234
|
+
if (network === 'custom' && networkName)
|
|
235
|
+
return networkName;
|
|
236
|
+
return network; // 'bridge' | 'none'
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* Build the `docker create` token list (from `create` through the `sleep
|
|
240
|
+
* infinity` CMD) for a long-lived, hardened, per-case container. PURE: every
|
|
241
|
+
* dynamic value is shellescaped; the caller joins with spaces into the launch
|
|
242
|
+
* string. Security invariants baked in: --cap-drop ALL, --security-opt
|
|
243
|
+
* no-new-privileges, --pids-limit, --memory==--memory-swap, --init,
|
|
244
|
+
* --pull=never, --restart no, NEVER --privileged, NEVER the docker socket.
|
|
245
|
+
*/
|
|
246
|
+
export function buildDockerCreateArgs(ctx) {
|
|
247
|
+
const { docker, sessionId, instance, userArgs, credentialMounts, extraMounts, envCreate, addHostGateway, gatewayAlias, } = ctx;
|
|
248
|
+
const args = [
|
|
249
|
+
'create',
|
|
250
|
+
'--name',
|
|
251
|
+
shellescape(docker.containerName),
|
|
252
|
+
'--label',
|
|
253
|
+
'codeman.managed=1',
|
|
254
|
+
'--label',
|
|
255
|
+
shellescape(`codeman.instance=${instance}`),
|
|
256
|
+
'--label',
|
|
257
|
+
shellescape(`codeman.session=${sessionId.slice(0, 8)}`),
|
|
258
|
+
'--label',
|
|
259
|
+
shellescape(`codeman.confighash=${docker.configHash ?? dockerConfigHash(docker)}`),
|
|
260
|
+
'--pull=never',
|
|
261
|
+
'--init',
|
|
262
|
+
'--restart',
|
|
263
|
+
'no',
|
|
264
|
+
...userArgs,
|
|
265
|
+
'--workdir',
|
|
266
|
+
shellescape(docker.containerWorkdir),
|
|
267
|
+
// Workspace bind: mirror the host path inside the container so the transcript
|
|
268
|
+
// projHash correlates and file features read real host bytes.
|
|
269
|
+
'--mount',
|
|
270
|
+
shellescape(mountSpec({ src: docker.hostWorkspacePath, dst: docker.containerWorkdir })),
|
|
271
|
+
...credentialMounts.flatMap((m) => ['--mount', shellescape(mountSpec(m))]),
|
|
272
|
+
...extraMounts.flatMap((m) => ['--mount', shellescape(mountSpec(m))]),
|
|
273
|
+
];
|
|
274
|
+
if (addHostGateway)
|
|
275
|
+
args.push('--add-host', `${gatewayAlias}:host-gateway`);
|
|
276
|
+
args.push(...resourceFlags(docker.resources),
|
|
277
|
+
// GPU passthrough (needs the NVIDIA container toolkit on the host). No storage
|
|
278
|
+
// cap is set, so the container's writable layer + volumes grow elastically as
|
|
279
|
+
// data flows in (bounded only by host disk).
|
|
280
|
+
...(docker.gpus ? ['--gpus', shellescape(docker.gpus)] : []), '--cap-drop', 'ALL', '--security-opt', 'no-new-privileges', '--network', networkArg(docker.network, docker.networkName));
|
|
281
|
+
for (const [key, value] of Object.entries(envCreate)) {
|
|
282
|
+
args.push('--env', shellescape(`${key}=${value}`));
|
|
283
|
+
}
|
|
284
|
+
// Operator escape-hatch args (schema-validated NO_SHELL_INJECTION), escaped again here.
|
|
285
|
+
for (const extra of docker.extraCreateArgs ?? []) {
|
|
286
|
+
args.push(shellescape(extra));
|
|
287
|
+
}
|
|
288
|
+
args.push(shellescape(docker.image), 'sleep', 'infinity');
|
|
289
|
+
return args;
|
|
290
|
+
}
|
|
291
|
+
/**
|
|
292
|
+
* PURE argv for building the agent base image locally (the programmatic mirror of
|
|
293
|
+
* scripts/build-agent-image.mjs): `build -f <dockerfile> -t <image> [--no-cache]
|
|
294
|
+
* <contextDir>`. Kept pure + unit-testable; the caller prepends the engine binary.
|
|
295
|
+
*/
|
|
296
|
+
export function agentImageBuildArgs(dockerfile, image, contextDir, noCache = false) {
|
|
297
|
+
return ['build', '-f', dockerfile, '-t', image, ...(noCache ? ['--no-cache'] : []), contextDir];
|
|
298
|
+
}
|
|
299
|
+
// ========== Credential mount resolution (IO) ==========
|
|
300
|
+
/** Container Claude config dir (created gid-0 writable in the image). */
|
|
301
|
+
export const CONTAINER_CLAUDE_DIR = `${CONTAINER_HOME}/.claude`;
|
|
302
|
+
/** In-container path of the seeded (writable) `~/.claude.json`. */
|
|
303
|
+
export const CLAUDE_JSON_HOME = `${CONTAINER_HOME}/.claude.json`;
|
|
304
|
+
/** In-container path of the read-only host-seeded `~/.claude.json` (copied into HOME at launch). */
|
|
305
|
+
export const CLAUDE_JSON_SEED = `${CONTAINER_HOME}/.codeman/claude.seed.json`;
|
|
306
|
+
/** Read-only seed paths for the files copied into the container's `.claude`. */
|
|
307
|
+
const CLAUDE_CREDS_SEED = `${CONTAINER_HOME}/.codeman/claude-creds.seed.json`;
|
|
308
|
+
const CLAUDE_SETTINGS_SEED = `${CONTAINER_HOME}/.codeman/claude-settings.seed.json`;
|
|
309
|
+
const CLAUDE_STATS_SEED = `${CONTAINER_HOME}/.codeman/claude-stats.seed.json`;
|
|
310
|
+
/** Staging root for read-only host-cred seed mounts (codex/gemini/gcloud/opencode). */
|
|
311
|
+
const CRED_SEED_DIR = `${CONTAINER_HOME}/.codeman/cred-seeds`;
|
|
312
|
+
/**
|
|
313
|
+
* PURE: merge the host `~/.claude.json` into a config that makes an
|
|
314
|
+
* already-authenticated Claude skip its INTERACTIVE onboarding inside the container
|
|
315
|
+
* (the host file itself lacks these flags — the host install is grandfathered, so a
|
|
316
|
+
* verbatim copy still triggers the theme picker + login wizard + folder-trust
|
|
317
|
+
* prompt). Forces `hasCompletedOnboarding`, a `theme` (so the theme picker is
|
|
318
|
+
* skipped), and marks the workspace project trusted + onboarded. Auth still comes
|
|
319
|
+
* from the copied `oauthAccount` + the dir-mounted `~/.claude/.credentials.json`.
|
|
320
|
+
*/
|
|
321
|
+
export function buildSeamlessClaudeConfig(hostConfig, workspacePath, theme = 'dark') {
|
|
322
|
+
const merged = { ...hostConfig };
|
|
323
|
+
merged.hasCompletedOnboarding = true;
|
|
324
|
+
if (typeof merged.theme !== 'string')
|
|
325
|
+
merged.theme = theme;
|
|
326
|
+
const projects = { ...(merged.projects ?? {}) };
|
|
327
|
+
const existing = projects[workspacePath] ?? {};
|
|
328
|
+
const seenCount = existing.projectOnboardingSeenCount;
|
|
329
|
+
projects[workspacePath] = {
|
|
330
|
+
...existing,
|
|
331
|
+
hasTrustDialogAccepted: true,
|
|
332
|
+
hasCompletedProjectOnboarding: true,
|
|
333
|
+
projectOnboardingSeenCount: typeof seenCount === 'number' && seenCount > 0 ? seenCount : 1,
|
|
334
|
+
};
|
|
335
|
+
merged.projects = projects;
|
|
336
|
+
return merged;
|
|
337
|
+
}
|
|
338
|
+
/** Best-effort read of the host `~/.claude/settings.json` theme (drives the seed's theme). */
|
|
339
|
+
function readHostClaudeTheme(home) {
|
|
340
|
+
try {
|
|
341
|
+
const parsed = JSON.parse(readFileSync(join(home, '.claude', 'settings.json'), 'utf-8'));
|
|
342
|
+
return typeof parsed.theme === 'string' ? parsed.theme : undefined;
|
|
343
|
+
}
|
|
344
|
+
catch {
|
|
345
|
+
return undefined;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
/**
|
|
349
|
+
* Resolve the read-only seed mount for `~/.claude.json`. Reads the host file, merges
|
|
350
|
+
* in the seamless-onboarding flags + workspace trust (buildSeamlessClaudeConfig),
|
|
351
|
+
* writes the result to a per-container seed file under `~/.codeman/docker-seeds/`,
|
|
352
|
+
* and returns its mount. The launch chain copies it to `~/.claude.json` inside HOME
|
|
353
|
+
* once — giving Claude a NORMAL writable, already-onboarded config (no atomic-rename
|
|
354
|
+
* EBUSY, no re-auth, no theme/trust prompts). Falls back to the RAW host file when
|
|
355
|
+
* parse/write fails (auth still works; the wizard may show). Returns null when the
|
|
356
|
+
* host has no `~/.claude.json`. IO; under VITEST returns the raw mount (no write).
|
|
357
|
+
*/
|
|
358
|
+
export function resolveClaudeJsonSeedMount(home = homedir(), containerName, workspacePath) {
|
|
359
|
+
const src = join(home, '.claude.json');
|
|
360
|
+
if (!existsSync(src))
|
|
361
|
+
return null;
|
|
362
|
+
const rawMount = { src, dst: CLAUDE_JSON_SEED, readonly: true };
|
|
363
|
+
if (IS_TEST_MODE || !containerName || !workspacePath)
|
|
364
|
+
return rawMount;
|
|
365
|
+
try {
|
|
366
|
+
const hostConfig = JSON.parse(readFileSync(src, 'utf-8'));
|
|
367
|
+
const merged = buildSeamlessClaudeConfig(hostConfig, workspacePath, readHostClaudeTheme(home) ?? 'dark');
|
|
368
|
+
const seedsDir = dataPath('docker-seeds');
|
|
369
|
+
if (!existsSync(seedsDir))
|
|
370
|
+
mkdirSync(seedsDir, { recursive: true });
|
|
371
|
+
const seedFile = join(seedsDir, `${containerName}.json`);
|
|
372
|
+
writeFileSync(seedFile, JSON.stringify(merged), { mode: 0o600 });
|
|
373
|
+
return { src: seedFile, dst: CLAUDE_JSON_SEED, readonly: true };
|
|
374
|
+
}
|
|
375
|
+
catch {
|
|
376
|
+
return rawMount; // partial host write / unreadable — auth still carries, wizard may show
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
/**
|
|
380
|
+
* Resolve the ISOLATED Claude artifacts for a docker session (replaces the old
|
|
381
|
+
* whole-`~/.claude` RW mount that polluted the host). Shares ONLY what must cross
|
|
382
|
+
* the boundary and seeds the rest as writable copies:
|
|
383
|
+
* - `~/.claude/projects` → RW dir mount (transcripts: host watchers + `--resume`).
|
|
384
|
+
* - `~/.claude.json` → merged onboarding seed, copied to HOME (no re-auth/wizard).
|
|
385
|
+
* - `~/.claude/.credentials.json` + `~/.claude/settings.json` → read-only seeds
|
|
386
|
+
* copied into the container's own `~/.claude` (token + global prefs carry in;
|
|
387
|
+
* the container refreshes its own copy and never writes back to the host).
|
|
388
|
+
* Everything else Claude writes (backups, tasks, teams, session-env, history) stays
|
|
389
|
+
* container-local. IO (reads host files, writes the merged `.claude.json` seed).
|
|
390
|
+
*/
|
|
391
|
+
export function resolveDockerClaudeArtifacts(home, containerName, workspacePath) {
|
|
392
|
+
const mounts = [];
|
|
393
|
+
const seedCopies = [];
|
|
394
|
+
// The ONE genuinely-shared part: conversation transcripts (dir mount → renames work).
|
|
395
|
+
const projectsSrc = join(home, '.claude', 'projects');
|
|
396
|
+
if (existsSync(projectsSrc)) {
|
|
397
|
+
mounts.push({ src: projectsSrc, dst: `${CONTAINER_CLAUDE_DIR}/projects` });
|
|
398
|
+
}
|
|
399
|
+
// ~/.claude.json → merged, onboarding-complete seed at HOME root.
|
|
400
|
+
const jsonSeed = resolveClaudeJsonSeedMount(home, containerName, workspacePath);
|
|
401
|
+
if (jsonSeed) {
|
|
402
|
+
mounts.push(jsonSeed);
|
|
403
|
+
seedCopies.push({ from: CLAUDE_JSON_SEED, to: CLAUDE_JSON_HOME });
|
|
404
|
+
}
|
|
405
|
+
// credentials (token) + settings (theme/model/effort/permissions) + stats-cache
|
|
406
|
+
// (drives the model/effort status indicator) → writable copies inside the
|
|
407
|
+
// container's own ~/.claude (never a wholesale mount → no host pollution).
|
|
408
|
+
const files = [
|
|
409
|
+
['.credentials.json', CLAUDE_CREDS_SEED, `${CONTAINER_CLAUDE_DIR}/.credentials.json`],
|
|
410
|
+
['settings.json', CLAUDE_SETTINGS_SEED, `${CONTAINER_CLAUDE_DIR}/settings.json`],
|
|
411
|
+
['stats-cache.json', CLAUDE_STATS_SEED, `${CONTAINER_CLAUDE_DIR}/stats-cache.json`],
|
|
412
|
+
];
|
|
413
|
+
for (const [rel, seed, dest] of files) {
|
|
414
|
+
const src = join(home, '.claude', rel);
|
|
415
|
+
if (existsSync(src)) {
|
|
416
|
+
mounts.push({ src, dst: seed, readonly: true });
|
|
417
|
+
seedCopies.push({ from: seed, to: dest });
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
return { mounts, seedCopies };
|
|
421
|
+
}
|
|
422
|
+
const CRED_STORES = [
|
|
423
|
+
{ rel: '.codex', shareDirs: ['sessions'], shareFiles: ['history.jsonl'], seedFiles: ['auth.json', 'config.toml'] },
|
|
424
|
+
{ rel: '.gemini', seedWhole: true },
|
|
425
|
+
{ rel: '.config/gcloud', seedWhole: true },
|
|
426
|
+
{ rel: '.config/opencode', seedWhole: true },
|
|
427
|
+
];
|
|
428
|
+
/**
|
|
429
|
+
* Resolve the ISOLATED codex/gemini/gcloud/opencode artifacts (replaces the old
|
|
430
|
+
* whole-dir RW mounts that let each in-container CLI write its refreshed tokens +
|
|
431
|
+
* session state back into the host). Every path is existsSync-gated (on most hosts
|
|
432
|
+
* only a subset exists). Pure-ish IO (no writes; just existence checks + mount specs).
|
|
433
|
+
*/
|
|
434
|
+
export function resolveDockerCredentialArtifacts(home = homedir()) {
|
|
435
|
+
const mounts = [];
|
|
436
|
+
const seedCopies = [];
|
|
437
|
+
for (const store of CRED_STORES) {
|
|
438
|
+
const hostBase = join(home, store.rel);
|
|
439
|
+
if (!existsSync(hostBase))
|
|
440
|
+
continue;
|
|
441
|
+
const containerBase = `${CONTAINER_HOME}/${store.rel}`;
|
|
442
|
+
const seedName = store.rel.replace(/\//g, '-'); // '.config/gcloud' → '.config-gcloud'
|
|
443
|
+
if (store.seedWhole) {
|
|
444
|
+
const seed = `${CRED_SEED_DIR}/${seedName}`;
|
|
445
|
+
mounts.push({ src: hostBase, dst: seed, readonly: true });
|
|
446
|
+
seedCopies.push({ from: seed, to: containerBase, recursive: true });
|
|
447
|
+
continue;
|
|
448
|
+
}
|
|
449
|
+
for (const sub of store.shareDirs ?? []) {
|
|
450
|
+
const src = join(hostBase, sub);
|
|
451
|
+
if (existsSync(src))
|
|
452
|
+
mounts.push({ src, dst: `${containerBase}/${sub}` });
|
|
453
|
+
}
|
|
454
|
+
for (const file of store.shareFiles ?? []) {
|
|
455
|
+
const src = join(hostBase, file);
|
|
456
|
+
if (existsSync(src))
|
|
457
|
+
mounts.push({ src, dst: `${containerBase}/${file}` });
|
|
458
|
+
}
|
|
459
|
+
for (const file of store.seedFiles ?? []) {
|
|
460
|
+
const src = join(hostBase, file);
|
|
461
|
+
if (existsSync(src)) {
|
|
462
|
+
const seed = `${CRED_SEED_DIR}/${seedName}-${file}`;
|
|
463
|
+
mounts.push({ src, dst: seed, readonly: true });
|
|
464
|
+
seedCopies.push({ from: seed, to: `${containerBase}/${file}` });
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
return { mounts, seedCopies };
|
|
469
|
+
}
|
|
470
|
+
const DOCKER_PROBE_TIMEOUT_MS = 15_000;
|
|
471
|
+
async function runDockerInfo(engine) {
|
|
472
|
+
try {
|
|
473
|
+
const { stdout } = await execFileAsync(engine, ['info', '--format', '{{json .}}'], {
|
|
474
|
+
timeout: DOCKER_PROBE_TIMEOUT_MS,
|
|
475
|
+
});
|
|
476
|
+
return JSON.parse(stdout);
|
|
477
|
+
}
|
|
478
|
+
catch {
|
|
479
|
+
return null;
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
function classifyDockerInfo(engine, info) {
|
|
483
|
+
const security = info.SecurityOptions ?? [];
|
|
484
|
+
const rootless = security.some((opt) => opt.includes('rootless'));
|
|
485
|
+
const cgroupV2 = info.CgroupVersion === '2';
|
|
486
|
+
const os = `${info.OperatingSystem ?? ''}`.toLowerCase();
|
|
487
|
+
const isDesktop = os.includes('docker desktop') || os.includes('desktop');
|
|
488
|
+
// Under rootless, resource caps are only reliably enforced with cgroup v2 +
|
|
489
|
+
// systemd delegation. We can't detect delegation from `docker info`, so we
|
|
490
|
+
// treat rootless+cgroupv2 as "likely enforced" and rootless+cgroupv1 as not.
|
|
491
|
+
const capsEnforced = !rootless || cgroupV2;
|
|
492
|
+
return { ok: true, engine, rootless, isDesktop, cgroupV2, capsEnforced };
|
|
493
|
+
}
|
|
494
|
+
/**
|
|
495
|
+
* Probe the container engine: server up, cgroup version, rootless, Desktop, and
|
|
496
|
+
* whether resource caps are enforceable. Auto-detects docker then podman when no
|
|
497
|
+
* engine is given. No-op canned value under VITEST.
|
|
498
|
+
*/
|
|
499
|
+
export async function checkDockerAvailable(engine) {
|
|
500
|
+
if (IS_TEST_MODE) {
|
|
501
|
+
return {
|
|
502
|
+
ok: true,
|
|
503
|
+
engine: engine ?? 'docker',
|
|
504
|
+
rootless: false,
|
|
505
|
+
isDesktop: false,
|
|
506
|
+
cgroupV2: true,
|
|
507
|
+
capsEnforced: true,
|
|
508
|
+
};
|
|
509
|
+
}
|
|
510
|
+
const candidates = engine ? [engine] : ['docker', 'podman'];
|
|
511
|
+
for (const candidate of candidates) {
|
|
512
|
+
const info = await runDockerInfo(candidate);
|
|
513
|
+
if (info)
|
|
514
|
+
return classifyDockerInfo(candidate, info);
|
|
515
|
+
}
|
|
516
|
+
return {
|
|
517
|
+
ok: false,
|
|
518
|
+
engine: engine ?? 'docker',
|
|
519
|
+
rootless: false,
|
|
520
|
+
isDesktop: false,
|
|
521
|
+
cgroupV2: false,
|
|
522
|
+
capsEnforced: false,
|
|
523
|
+
error: 'Docker/Podman not available. Install docker (or podman) and ensure the daemon is running.',
|
|
524
|
+
};
|
|
525
|
+
}
|
|
526
|
+
/** Is the base image present locally? (never triggers an auto-pull). */
|
|
527
|
+
export async function checkDockerImagePresent(engine, image) {
|
|
528
|
+
if (IS_TEST_MODE)
|
|
529
|
+
return true;
|
|
530
|
+
try {
|
|
531
|
+
await execFileAsync(engine, ['image', 'inspect', '--format', '{{.Id}}', image], {
|
|
532
|
+
timeout: DOCKER_PROBE_TIMEOUT_MS,
|
|
533
|
+
});
|
|
534
|
+
return true;
|
|
535
|
+
}
|
|
536
|
+
catch {
|
|
537
|
+
return false;
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
/** In-flight builds keyed by `engine:image`, so concurrent callers share ONE build. */
|
|
541
|
+
const inFlightImageBuilds = new Map();
|
|
542
|
+
/**
|
|
543
|
+
* Resolve the repo's Dockerfile + build context. Works from BOTH src (dev/tsx) and
|
|
544
|
+
* dist/index.js (esbuild prod: dist sits at repo root), since both are one level
|
|
545
|
+
* under the repo root. Returns null when the Dockerfile is absent (npm-global
|
|
546
|
+
* installs don't ship docker/ — Docker cases are a git-clone feature).
|
|
547
|
+
*/
|
|
548
|
+
function resolveAgentDockerfile() {
|
|
549
|
+
const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
550
|
+
const dockerfile = join(repoRoot, 'docker', 'agent.Dockerfile');
|
|
551
|
+
return existsSync(dockerfile) ? { dockerfile, contextDir: repoRoot } : null;
|
|
552
|
+
}
|
|
553
|
+
/**
|
|
554
|
+
* Ensure the agent base image exists, BUILDING it locally on first use so a missing
|
|
555
|
+
* image is never a hard blocker (decision: "build locally on first use",
|
|
556
|
+
* docs/docker-cases-plan.md). Idempotent, concurrency-safe (one build per
|
|
557
|
+
* engine:image shared by concurrent callers), and a no-op under VITEST. Only the
|
|
558
|
+
* DEFAULT image is auto-built — we can never build a user's custom ref, and the
|
|
559
|
+
* `--pull=never` invariant forbids pulling. `onProgress` receives build output
|
|
560
|
+
* lines for SSE surfacing.
|
|
561
|
+
*/
|
|
562
|
+
export async function ensureAgentBaseImage(engine, image, opts = {}) {
|
|
563
|
+
if (IS_TEST_MODE)
|
|
564
|
+
return { ok: true, built: false, alreadyPresent: true };
|
|
565
|
+
if (await checkDockerImagePresent(engine, image)) {
|
|
566
|
+
return { ok: true, built: false, alreadyPresent: true };
|
|
567
|
+
}
|
|
568
|
+
if (image !== DEFAULT_AGENT_IMAGE) {
|
|
569
|
+
return {
|
|
570
|
+
ok: false,
|
|
571
|
+
built: false,
|
|
572
|
+
alreadyPresent: false,
|
|
573
|
+
error: `image ${image} is not present and only ${DEFAULT_AGENT_IMAGE} is auto-built. Build or pull ${image} yourself.`,
|
|
574
|
+
};
|
|
575
|
+
}
|
|
576
|
+
const key = `${engine}:${image}`;
|
|
577
|
+
const existing = inFlightImageBuilds.get(key);
|
|
578
|
+
if (existing)
|
|
579
|
+
return existing;
|
|
580
|
+
const build = buildAgentImage(engine, image, opts).finally(() => inFlightImageBuilds.delete(key));
|
|
581
|
+
inFlightImageBuilds.set(key, build);
|
|
582
|
+
return build;
|
|
583
|
+
}
|
|
584
|
+
function buildAgentImage(engine, image, opts) {
|
|
585
|
+
const resolved = resolveAgentDockerfile();
|
|
586
|
+
if (!resolved) {
|
|
587
|
+
return Promise.resolve({
|
|
588
|
+
ok: false,
|
|
589
|
+
built: false,
|
|
590
|
+
alreadyPresent: false,
|
|
591
|
+
error: `docker/agent.Dockerfile not found in this install; clone the repo or build ${image} manually`,
|
|
592
|
+
});
|
|
593
|
+
}
|
|
594
|
+
const args = agentImageBuildArgs(resolved.dockerfile, image, resolved.contextDir, opts.noCache);
|
|
595
|
+
return new Promise((resolve) => {
|
|
596
|
+
// async spawn (NEVER spawnSync) so a multi-minute build never wedges the event loop.
|
|
597
|
+
const child = spawn(engine, args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
598
|
+
const forward = (buf) => {
|
|
599
|
+
for (const line of buf.toString('utf-8').split('\n')) {
|
|
600
|
+
const trimmed = line.trimEnd();
|
|
601
|
+
if (trimmed)
|
|
602
|
+
opts.onProgress?.(trimmed);
|
|
603
|
+
}
|
|
604
|
+
};
|
|
605
|
+
child.stdout?.on('data', forward);
|
|
606
|
+
child.stderr?.on('data', forward);
|
|
607
|
+
child.on('error', (err) => {
|
|
608
|
+
resolve({
|
|
609
|
+
ok: false,
|
|
610
|
+
built: false,
|
|
611
|
+
alreadyPresent: false,
|
|
612
|
+
error: `could not spawn ${engine} build: ${err.message}`,
|
|
613
|
+
});
|
|
614
|
+
});
|
|
615
|
+
child.on('exit', (code) => {
|
|
616
|
+
if (code === 0)
|
|
617
|
+
resolve({ ok: true, built: true, alreadyPresent: false });
|
|
618
|
+
else
|
|
619
|
+
resolve({ ok: false, built: false, alreadyPresent: false, error: `${engine} build failed (exit ${code})` });
|
|
620
|
+
});
|
|
621
|
+
});
|
|
622
|
+
}
|
|
623
|
+
/**
|
|
624
|
+
* Verify the base image is present AND contains tmux (a HARD prerequisite: the
|
|
625
|
+
* in-container tmux is what makes reconnect durable). Never triggers a pull
|
|
626
|
+
* (`--pull=never`). No-op under VITEST. Mirror of checkRemoteTmuxAvailable.
|
|
627
|
+
*/
|
|
628
|
+
export async function checkDockerTmuxAvailable(docker) {
|
|
629
|
+
if (IS_TEST_MODE)
|
|
630
|
+
return { ok: true, tmuxPath: '/usr/bin/tmux' };
|
|
631
|
+
const engine = docker.engine;
|
|
632
|
+
if (!(await checkDockerImagePresent(engine, docker.image))) {
|
|
633
|
+
return {
|
|
634
|
+
ok: false,
|
|
635
|
+
imageMissing: true,
|
|
636
|
+
error: `image ${docker.image} not present (the default image is auto-built on first use; a custom image must be built or pulled first)`,
|
|
637
|
+
};
|
|
638
|
+
}
|
|
639
|
+
try {
|
|
640
|
+
const { stdout } = await execFileAsync(engine, ['run', '--rm', '--pull=never', docker.image, 'sh', '-lc', 'command -v tmux'], { timeout: DOCKER_PROBE_TIMEOUT_MS });
|
|
641
|
+
const tmuxPath = stdout.trim();
|
|
642
|
+
if (!tmuxPath) {
|
|
643
|
+
return { ok: false, error: `base image ${docker.image} is missing tmux (required for durable sessions)` };
|
|
644
|
+
}
|
|
645
|
+
return { ok: true, tmuxPath };
|
|
646
|
+
}
|
|
647
|
+
catch (err) {
|
|
648
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
649
|
+
return { ok: false, error: `could not verify tmux in ${docker.image}: ${msg}` };
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
/**
|
|
653
|
+
* Resolve the host's IP on the default docker bridge (the address a container
|
|
654
|
+
* reaches as `host.docker.internal`), so the server can bind a hooks-only listener
|
|
655
|
+
* there and in-container hooks can call back. Defaults to the conventional
|
|
656
|
+
* 172.17.0.1 when the inspect fails but docker is up; null when docker is absent.
|
|
657
|
+
* No-op canned value under VITEST.
|
|
658
|
+
*/
|
|
659
|
+
export async function detectDockerBridgeGateway(engine = 'docker') {
|
|
660
|
+
if (IS_TEST_MODE)
|
|
661
|
+
return '172.17.0.1';
|
|
662
|
+
const bin = engine === 'podman' ? 'podman' : 'docker';
|
|
663
|
+
try {
|
|
664
|
+
const { stdout } = await execFileAsync(bin, ['network', 'inspect', 'bridge', '--format', '{{(index .IPAM.Config 0).Gateway}}'], { timeout: DOCKER_PROBE_TIMEOUT_MS });
|
|
665
|
+
const ip = stdout.trim();
|
|
666
|
+
return /^\d{1,3}(\.\d{1,3}){3}$/.test(ip) ? ip : '172.17.0.1';
|
|
667
|
+
}
|
|
668
|
+
catch {
|
|
669
|
+
return null; // docker not available — nothing to bind
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
/**
|
|
673
|
+
* Instance-scoped boot reaper: `docker rm -f` any MANAGED container that belongs
|
|
674
|
+
* to THIS instance (by the `codeman.instance` label) but whose case is no longer
|
|
675
|
+
* in `docker-cases.json`. The instance scoping is what stops a beta from reaping
|
|
676
|
+
* prod's containers (the cross-instance hazard). No-op under VITEST. Best-effort.
|
|
677
|
+
*/
|
|
678
|
+
export async function reapOrphanedDockerContainers(configDir, instance, engine = 'docker') {
|
|
679
|
+
if (IS_TEST_MODE)
|
|
680
|
+
return [];
|
|
681
|
+
const bin = engine === 'podman' ? 'podman' : 'docker';
|
|
682
|
+
let rows = [];
|
|
683
|
+
try {
|
|
684
|
+
const { stdout } = await execFileAsync(bin, [
|
|
685
|
+
'ps',
|
|
686
|
+
'-a',
|
|
687
|
+
'--filter',
|
|
688
|
+
'label=codeman.managed=1',
|
|
689
|
+
'--format',
|
|
690
|
+
'{{.Names}}\t{{index .Labels "codeman.instance"}}',
|
|
691
|
+
], { timeout: DOCKER_PROBE_TIMEOUT_MS });
|
|
692
|
+
rows = stdout
|
|
693
|
+
.split('\n')
|
|
694
|
+
.filter(Boolean)
|
|
695
|
+
.map((line) => {
|
|
696
|
+
const [name, inst = ''] = line.split('\t');
|
|
697
|
+
return { name, inst };
|
|
698
|
+
});
|
|
699
|
+
}
|
|
700
|
+
catch {
|
|
701
|
+
return []; // daemon down / engine absent — nothing to reap
|
|
702
|
+
}
|
|
703
|
+
const cases = await readDockerCases(configDir);
|
|
704
|
+
const expected = new Set(cases.map((c) => c.container ?? dockerContainerName(c.name)));
|
|
705
|
+
const reaped = [];
|
|
706
|
+
for (const { name, inst } of rows) {
|
|
707
|
+
if (inst !== instance)
|
|
708
|
+
continue; // only THIS instance's containers
|
|
709
|
+
if (expected.has(name))
|
|
710
|
+
continue; // still referenced by a live case
|
|
711
|
+
try {
|
|
712
|
+
await execFileAsync(bin, ['rm', '-f', name], { timeout: DOCKER_PROBE_TIMEOUT_MS });
|
|
713
|
+
reaped.push(name);
|
|
714
|
+
}
|
|
715
|
+
catch {
|
|
716
|
+
/* best-effort */
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
return reaped;
|
|
720
|
+
}
|
|
721
|
+
/**
|
|
722
|
+
* Read the IN-CONTAINER Claude CLI version (`docker exec <container> claude
|
|
723
|
+
* --version`). Feeds Session.cliVersion for docker sessions (the LOCAL claude
|
|
724
|
+
* would report the wrong version and disable trackpad wheel-forwarding, #154).
|
|
725
|
+
* Returns undefined on any failure. No-op under VITEST.
|
|
726
|
+
*/
|
|
727
|
+
export async function probeDockerCliVersion(docker, mode) {
|
|
728
|
+
if (IS_TEST_MODE)
|
|
729
|
+
return undefined;
|
|
730
|
+
const bin = mode === 'shell' ? null : mode;
|
|
731
|
+
if (!bin)
|
|
732
|
+
return undefined;
|
|
733
|
+
try {
|
|
734
|
+
const { stdout } = await execFileAsync(docker.engine, ['exec', docker.containerName, bin, '--version'], {
|
|
735
|
+
timeout: DOCKER_PROBE_TIMEOUT_MS,
|
|
736
|
+
});
|
|
737
|
+
const match = stdout.trim().match(/\d+\.\d+\.\d+/);
|
|
738
|
+
return match ? match[0] : stdout.trim() || undefined;
|
|
739
|
+
}
|
|
740
|
+
catch {
|
|
741
|
+
return undefined;
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
//# sourceMappingURL=docker-hosts.js.map
|