@dotsetlabs/dotclaw 1.1.0
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/.env.example +54 -0
- package/LICENSE +21 -0
- package/README.md +111 -0
- package/config-examples/groups/global/CLAUDE.md +21 -0
- package/config-examples/groups/main/CLAUDE.md +47 -0
- package/config-examples/mount-allowlist.json +25 -0
- package/config-examples/plugin-http.json +18 -0
- package/config-examples/runtime.json +30 -0
- package/config-examples/tool-budgets.json +24 -0
- package/config-examples/tool-policy.json +51 -0
- package/container/.dockerignore +6 -0
- package/container/Dockerfile +74 -0
- package/container/agent-runner/package-lock.json +92 -0
- package/container/agent-runner/package.json +20 -0
- package/container/agent-runner/src/agent-config.ts +295 -0
- package/container/agent-runner/src/container-protocol.ts +73 -0
- package/container/agent-runner/src/daemon.ts +91 -0
- package/container/agent-runner/src/index.ts +1428 -0
- package/container/agent-runner/src/ipc.ts +321 -0
- package/container/agent-runner/src/memory.ts +336 -0
- package/container/agent-runner/src/prompt-packs.ts +341 -0
- package/container/agent-runner/src/tools.ts +1720 -0
- package/container/agent-runner/tsconfig.json +19 -0
- package/container/build.sh +23 -0
- package/container/skills/agent-browser.md +159 -0
- package/dist/admin-commands.d.ts +7 -0
- package/dist/admin-commands.d.ts.map +1 -0
- package/dist/admin-commands.js +87 -0
- package/dist/admin-commands.js.map +1 -0
- package/dist/agent-context.d.ts +42 -0
- package/dist/agent-context.d.ts.map +1 -0
- package/dist/agent-context.js +92 -0
- package/dist/agent-context.js.map +1 -0
- package/dist/agent-execution.d.ts +68 -0
- package/dist/agent-execution.d.ts.map +1 -0
- package/dist/agent-execution.js +169 -0
- package/dist/agent-execution.js.map +1 -0
- package/dist/agent-semaphore.d.ts +2 -0
- package/dist/agent-semaphore.d.ts.map +1 -0
- package/dist/agent-semaphore.js +52 -0
- package/dist/agent-semaphore.js.map +1 -0
- package/dist/behavior-config.d.ts +14 -0
- package/dist/behavior-config.d.ts.map +1 -0
- package/dist/behavior-config.js +52 -0
- package/dist/behavior-config.js.map +1 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +626 -0
- package/dist/cli.js.map +1 -0
- package/dist/config.d.ts +31 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +38 -0
- package/dist/config.js.map +1 -0
- package/dist/container-protocol.d.ts +72 -0
- package/dist/container-protocol.d.ts.map +1 -0
- package/dist/container-protocol.js +3 -0
- package/dist/container-protocol.js.map +1 -0
- package/dist/container-runner.d.ts +59 -0
- package/dist/container-runner.d.ts.map +1 -0
- package/dist/container-runner.js +813 -0
- package/dist/container-runner.js.map +1 -0
- package/dist/cost.d.ts +9 -0
- package/dist/cost.d.ts.map +1 -0
- package/dist/cost.js +11 -0
- package/dist/cost.js.map +1 -0
- package/dist/dashboard.d.ts +58 -0
- package/dist/dashboard.d.ts.map +1 -0
- package/dist/dashboard.js +471 -0
- package/dist/dashboard.js.map +1 -0
- package/dist/db.d.ts +99 -0
- package/dist/db.d.ts.map +1 -0
- package/dist/db.js +423 -0
- package/dist/db.js.map +1 -0
- package/dist/error-messages.d.ts +17 -0
- package/dist/error-messages.d.ts.map +1 -0
- package/dist/error-messages.js +109 -0
- package/dist/error-messages.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2072 -0
- package/dist/index.js.map +1 -0
- package/dist/locks.d.ts +2 -0
- package/dist/locks.d.ts.map +1 -0
- package/dist/locks.js +26 -0
- package/dist/locks.js.map +1 -0
- package/dist/logger.d.ts +4 -0
- package/dist/logger.d.ts.map +1 -0
- package/dist/logger.js +15 -0
- package/dist/logger.js.map +1 -0
- package/dist/maintenance.d.ts +13 -0
- package/dist/maintenance.d.ts.map +1 -0
- package/dist/maintenance.js +151 -0
- package/dist/maintenance.js.map +1 -0
- package/dist/memory-embeddings.d.ts +13 -0
- package/dist/memory-embeddings.d.ts.map +1 -0
- package/dist/memory-embeddings.js +126 -0
- package/dist/memory-embeddings.js.map +1 -0
- package/dist/memory-recall.d.ts +8 -0
- package/dist/memory-recall.d.ts.map +1 -0
- package/dist/memory-recall.js +127 -0
- package/dist/memory-recall.js.map +1 -0
- package/dist/memory-store.d.ts +149 -0
- package/dist/memory-store.d.ts.map +1 -0
- package/dist/memory-store.js +787 -0
- package/dist/memory-store.js.map +1 -0
- package/dist/metrics.d.ts +12 -0
- package/dist/metrics.d.ts.map +1 -0
- package/dist/metrics.js +134 -0
- package/dist/metrics.js.map +1 -0
- package/dist/model-registry.d.ts +67 -0
- package/dist/model-registry.d.ts.map +1 -0
- package/dist/model-registry.js +230 -0
- package/dist/model-registry.js.map +1 -0
- package/dist/mount-security.d.ts +37 -0
- package/dist/mount-security.d.ts.map +1 -0
- package/dist/mount-security.js +284 -0
- package/dist/mount-security.js.map +1 -0
- package/dist/paths.d.ts +80 -0
- package/dist/paths.d.ts.map +1 -0
- package/dist/paths.js +149 -0
- package/dist/paths.js.map +1 -0
- package/dist/personalization.d.ts +6 -0
- package/dist/personalization.d.ts.map +1 -0
- package/dist/personalization.js +180 -0
- package/dist/personalization.js.map +1 -0
- package/dist/progress.d.ts +15 -0
- package/dist/progress.d.ts.map +1 -0
- package/dist/progress.js +92 -0
- package/dist/progress.js.map +1 -0
- package/dist/runtime-config.d.ts +227 -0
- package/dist/runtime-config.d.ts.map +1 -0
- package/dist/runtime-config.js +297 -0
- package/dist/runtime-config.js.map +1 -0
- package/dist/task-scheduler.d.ts +9 -0
- package/dist/task-scheduler.d.ts.map +1 -0
- package/dist/task-scheduler.js +195 -0
- package/dist/task-scheduler.js.map +1 -0
- package/dist/telegram-format.d.ts +3 -0
- package/dist/telegram-format.d.ts.map +1 -0
- package/dist/telegram-format.js +200 -0
- package/dist/telegram-format.js.map +1 -0
- package/dist/tool-budgets.d.ts +16 -0
- package/dist/tool-budgets.d.ts.map +1 -0
- package/dist/tool-budgets.js +83 -0
- package/dist/tool-budgets.js.map +1 -0
- package/dist/tool-policy.d.ts +18 -0
- package/dist/tool-policy.d.ts.map +1 -0
- package/dist/tool-policy.js +84 -0
- package/dist/tool-policy.js.map +1 -0
- package/dist/trace-writer.d.ts +39 -0
- package/dist/trace-writer.d.ts.map +1 -0
- package/dist/trace-writer.js +27 -0
- package/dist/trace-writer.js.map +1 -0
- package/dist/types.d.ts +81 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/utils.d.ts +4 -0
- package/dist/utils.d.ts.map +1 -0
- package/dist/utils.js +30 -0
- package/dist/utils.js.map +1 -0
- package/launchd/com.dotclaw.plist +32 -0
- package/package.json +89 -0
- package/scripts/autotune.js +53 -0
- package/scripts/bootstrap.js +348 -0
- package/scripts/configure.js +200 -0
- package/scripts/doctor.js +164 -0
- package/scripts/init.js +209 -0
- package/scripts/install.sh +219 -0
- package/systemd/dotclaw.service +22 -0
|
@@ -0,0 +1,813 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Container Runner for DotClaw
|
|
3
|
+
* Spawns agent execution in Docker container and handles IPC
|
|
4
|
+
*/
|
|
5
|
+
import { spawn, execSync, spawnSync } from 'child_process';
|
|
6
|
+
import fs from 'fs';
|
|
7
|
+
import path from 'path';
|
|
8
|
+
import { CONTAINER_IMAGE, CONTAINER_TIMEOUT, CONTAINER_MAX_OUTPUT_SIZE, CONTAINER_PIDS_LIMIT, CONTAINER_MEMORY, CONTAINER_CPUS, CONTAINER_READONLY_ROOT, CONTAINER_TMPFS_SIZE, CONTAINER_RUN_UID, CONTAINER_RUN_GID, CONTAINER_MODE, CONTAINER_DAEMON_POLL_MS, GROUPS_DIR, CONFIG_DIR, DATA_DIR, ENV_PATH, PROMPT_PACKS_DIR } from './config.js';
|
|
9
|
+
import { PACKAGE_ROOT } from './paths.js';
|
|
10
|
+
import { validateAdditionalMounts } from './mount-security.js';
|
|
11
|
+
import { loadRuntimeConfig } from './runtime-config.js';
|
|
12
|
+
import { OUTPUT_START_MARKER, OUTPUT_END_MARKER } from './container-protocol.js';
|
|
13
|
+
import { logger } from './logger.js';
|
|
14
|
+
const runtime = loadRuntimeConfig();
|
|
15
|
+
// Sentinel markers for robust output parsing (must match agent-runner)
|
|
16
|
+
const CONTAINER_ID_DIR = path.join(DATA_DIR, 'tmp');
|
|
17
|
+
function buildVolumeMounts(group, isMain) {
|
|
18
|
+
const mounts = [];
|
|
19
|
+
const envFile = ENV_PATH;
|
|
20
|
+
if (isMain) {
|
|
21
|
+
// Main gets the package root mounted (read-only for safety)
|
|
22
|
+
mounts.push({
|
|
23
|
+
hostPath: PACKAGE_ROOT,
|
|
24
|
+
containerPath: '/workspace/project',
|
|
25
|
+
readonly: true
|
|
26
|
+
});
|
|
27
|
+
// Mask .env inside the package root to avoid leaking secrets to the container
|
|
28
|
+
const packageEnvFile = path.join(PACKAGE_ROOT, '.env');
|
|
29
|
+
if (fs.existsSync(packageEnvFile)) {
|
|
30
|
+
const envMaskDir = path.join(DATA_DIR, 'env');
|
|
31
|
+
const envMaskFile = path.join(envMaskDir, '.env-mask');
|
|
32
|
+
fs.mkdirSync(envMaskDir, { recursive: true });
|
|
33
|
+
if (!fs.existsSync(envMaskFile)) {
|
|
34
|
+
fs.writeFileSync(envMaskFile, '');
|
|
35
|
+
}
|
|
36
|
+
mounts.push({
|
|
37
|
+
hostPath: envMaskFile,
|
|
38
|
+
containerPath: '/workspace/project/.env',
|
|
39
|
+
readonly: true
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
// Main also gets its group folder as the working directory
|
|
43
|
+
mounts.push({
|
|
44
|
+
hostPath: path.join(GROUPS_DIR, group.folder),
|
|
45
|
+
containerPath: '/workspace/group',
|
|
46
|
+
readonly: false
|
|
47
|
+
});
|
|
48
|
+
// Global memory/prompts directory (read-only)
|
|
49
|
+
const globalDir = path.join(GROUPS_DIR, 'global');
|
|
50
|
+
if (fs.existsSync(globalDir)) {
|
|
51
|
+
mounts.push({
|
|
52
|
+
hostPath: globalDir,
|
|
53
|
+
containerPath: '/workspace/global',
|
|
54
|
+
readonly: true
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
else {
|
|
59
|
+
// Other groups only get their own folder
|
|
60
|
+
mounts.push({
|
|
61
|
+
hostPath: path.join(GROUPS_DIR, group.folder),
|
|
62
|
+
containerPath: '/workspace/group',
|
|
63
|
+
readonly: false
|
|
64
|
+
});
|
|
65
|
+
// Global memory directory (read-only for non-main)
|
|
66
|
+
// Docker bind mounts work with both files and directories
|
|
67
|
+
const globalDir = path.join(GROUPS_DIR, 'global');
|
|
68
|
+
if (fs.existsSync(globalDir)) {
|
|
69
|
+
mounts.push({
|
|
70
|
+
hostPath: globalDir,
|
|
71
|
+
containerPath: '/workspace/global',
|
|
72
|
+
readonly: true
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
// Per-group OpenRouter sessions directory (isolated from other groups)
|
|
77
|
+
// Each group gets their own session store to prevent cross-group access
|
|
78
|
+
const groupSessionsDir = path.join(DATA_DIR, 'sessions', group.folder, 'openrouter');
|
|
79
|
+
fs.mkdirSync(groupSessionsDir, { recursive: true });
|
|
80
|
+
try {
|
|
81
|
+
fs.chmodSync(groupSessionsDir, 0o700);
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
logger.warn({ path: groupSessionsDir }, 'Could not chmod sessions directory');
|
|
85
|
+
}
|
|
86
|
+
mounts.push({
|
|
87
|
+
hostPath: groupSessionsDir,
|
|
88
|
+
containerPath: '/workspace/session',
|
|
89
|
+
readonly: false
|
|
90
|
+
});
|
|
91
|
+
// Per-group IPC namespace: each group gets its own IPC directory
|
|
92
|
+
// This prevents cross-group privilege escalation via IPC
|
|
93
|
+
const groupIpcDir = path.join(DATA_DIR, 'ipc', group.folder);
|
|
94
|
+
const messagesDir = path.join(groupIpcDir, 'messages');
|
|
95
|
+
const tasksDir = path.join(groupIpcDir, 'tasks');
|
|
96
|
+
const requestsDir = path.join(groupIpcDir, 'requests');
|
|
97
|
+
const responsesDir = path.join(groupIpcDir, 'responses');
|
|
98
|
+
const agentRequestsDir = path.join(groupIpcDir, 'agent_requests');
|
|
99
|
+
const agentResponsesDir = path.join(groupIpcDir, 'agent_responses');
|
|
100
|
+
fs.mkdirSync(messagesDir, { recursive: true });
|
|
101
|
+
fs.mkdirSync(tasksDir, { recursive: true });
|
|
102
|
+
fs.mkdirSync(requestsDir, { recursive: true });
|
|
103
|
+
fs.mkdirSync(responsesDir, { recursive: true });
|
|
104
|
+
fs.mkdirSync(agentRequestsDir, { recursive: true });
|
|
105
|
+
fs.mkdirSync(agentResponsesDir, { recursive: true });
|
|
106
|
+
// Ensure container user can write to IPC directories on Linux
|
|
107
|
+
// On macOS/Docker Desktop this is handled by file sharing, but on native Linux
|
|
108
|
+
// the container user needs explicit write permission to the mounted volume
|
|
109
|
+
// Use try/catch in case directories are owned by a different user (e.g., root)
|
|
110
|
+
try {
|
|
111
|
+
fs.chmodSync(groupIpcDir, 0o770);
|
|
112
|
+
fs.chmodSync(messagesDir, 0o770);
|
|
113
|
+
fs.chmodSync(tasksDir, 0o770);
|
|
114
|
+
fs.chmodSync(requestsDir, 0o770);
|
|
115
|
+
fs.chmodSync(responsesDir, 0o770);
|
|
116
|
+
fs.chmodSync(agentRequestsDir, 0o770);
|
|
117
|
+
fs.chmodSync(agentResponsesDir, 0o770);
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
// Permissions may already be correct, or user needs to fix ownership manually
|
|
121
|
+
logger.warn({ path: groupIpcDir, dataDir: DATA_DIR }, 'Could not chmod IPC directories - run: sudo chown -R $USER ~/.dotclaw/data/ipc');
|
|
122
|
+
}
|
|
123
|
+
mounts.push({
|
|
124
|
+
hostPath: groupIpcDir,
|
|
125
|
+
containerPath: '/workspace/ipc',
|
|
126
|
+
readonly: false
|
|
127
|
+
});
|
|
128
|
+
// Shared prompt packs directory (autotune output)
|
|
129
|
+
if (PROMPT_PACKS_DIR && fs.existsSync(PROMPT_PACKS_DIR)) {
|
|
130
|
+
mounts.push({
|
|
131
|
+
hostPath: PROMPT_PACKS_DIR,
|
|
132
|
+
containerPath: '/workspace/prompts',
|
|
133
|
+
readonly: true
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
// Config directory (read-only) - contains runtime.json and other config files
|
|
137
|
+
if (fs.existsSync(CONFIG_DIR)) {
|
|
138
|
+
mounts.push({
|
|
139
|
+
hostPath: CONFIG_DIR,
|
|
140
|
+
containerPath: '/workspace/config',
|
|
141
|
+
readonly: true
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
// Data directory (read-only) - contains databases, sessions, etc.
|
|
145
|
+
// Note: do NOT mount the full data directory into containers.
|
|
146
|
+
// This prevents cross-group leakage of sessions, IPC, and databases.
|
|
147
|
+
// Environment file directory (keeps credentials out of process listings)
|
|
148
|
+
// Inject only secrets from .env
|
|
149
|
+
const envDir = path.join(DATA_DIR, 'env');
|
|
150
|
+
fs.mkdirSync(envDir, { recursive: true });
|
|
151
|
+
try {
|
|
152
|
+
fs.chmodSync(envDir, 0o700);
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
logger.warn({ path: envDir }, 'Could not chmod env directory');
|
|
156
|
+
}
|
|
157
|
+
const envVars = new Map();
|
|
158
|
+
const setEnvVar = (key, value, source) => {
|
|
159
|
+
if (!/^[A-Z0-9_]+$/.test(key)) {
|
|
160
|
+
logger.warn({ key, source }, 'Skipping invalid env var name for container');
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
if (value.includes('\n')) {
|
|
164
|
+
logger.warn({ key, source }, 'Skipping env var with newline for container');
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
envVars.set(key, value);
|
|
168
|
+
};
|
|
169
|
+
if (fs.existsSync(envFile)) {
|
|
170
|
+
const envContent = fs.readFileSync(envFile, 'utf-8');
|
|
171
|
+
const secretVars = new Set([
|
|
172
|
+
'OPENROUTER_API_KEY',
|
|
173
|
+
'BRAVE_SEARCH_API_KEY'
|
|
174
|
+
]);
|
|
175
|
+
for (const line of envContent.split('\n')) {
|
|
176
|
+
const trimmed = line.trim();
|
|
177
|
+
if (!trimmed || trimmed.startsWith('#'))
|
|
178
|
+
continue;
|
|
179
|
+
const idx = trimmed.indexOf('=');
|
|
180
|
+
if (idx === -1)
|
|
181
|
+
continue;
|
|
182
|
+
const key = trimmed.slice(0, idx).trim();
|
|
183
|
+
if (!secretVars.has(key))
|
|
184
|
+
continue;
|
|
185
|
+
const value = trimmed.slice(idx + 1);
|
|
186
|
+
setEnvVar(key, value, 'dotenv');
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
if (group.containerConfig?.env) {
|
|
190
|
+
for (const [key, value] of Object.entries(group.containerConfig.env)) {
|
|
191
|
+
if (typeof value !== 'string')
|
|
192
|
+
continue;
|
|
193
|
+
setEnvVar(key, value, 'containerConfig');
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
if (envVars.size > 0) {
|
|
197
|
+
const mergedLines = Array.from(envVars.entries()).map(([key, value]) => `${key}=${value}`);
|
|
198
|
+
const envOutPath = path.join(envDir, 'env');
|
|
199
|
+
fs.writeFileSync(envOutPath, mergedLines.join('\n') + '\n');
|
|
200
|
+
try {
|
|
201
|
+
fs.chmodSync(envOutPath, 0o600);
|
|
202
|
+
}
|
|
203
|
+
catch {
|
|
204
|
+
logger.warn({ path: envOutPath }, 'Could not chmod env file');
|
|
205
|
+
}
|
|
206
|
+
mounts.push({
|
|
207
|
+
hostPath: envDir,
|
|
208
|
+
containerPath: '/workspace/env-dir',
|
|
209
|
+
readonly: true
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
// Additional mounts validated against external allowlist (tamper-proof from containers)
|
|
213
|
+
if (group.containerConfig?.additionalMounts) {
|
|
214
|
+
const validatedMounts = validateAdditionalMounts(group.containerConfig.additionalMounts, group.name, isMain);
|
|
215
|
+
mounts.push(...validatedMounts);
|
|
216
|
+
}
|
|
217
|
+
return mounts;
|
|
218
|
+
}
|
|
219
|
+
function buildContainerArgs(mounts, cidFile) {
|
|
220
|
+
const args = ['run', '-i', '--rm'];
|
|
221
|
+
// Security hardening
|
|
222
|
+
args.push('--cap-drop=ALL');
|
|
223
|
+
args.push('--security-opt=no-new-privileges');
|
|
224
|
+
args.push(`--pids-limit=${CONTAINER_PIDS_LIMIT}`);
|
|
225
|
+
if (cidFile) {
|
|
226
|
+
args.push('--cidfile', cidFile);
|
|
227
|
+
}
|
|
228
|
+
const runUid = CONTAINER_RUN_UID ? CONTAINER_RUN_UID.trim() : '';
|
|
229
|
+
const runGid = CONTAINER_RUN_GID ? CONTAINER_RUN_GID.trim() : '';
|
|
230
|
+
if (runUid) {
|
|
231
|
+
args.push('--user', runGid ? `${runUid}:${runGid}` : runUid);
|
|
232
|
+
}
|
|
233
|
+
args.push('--env', 'HOME=/tmp');
|
|
234
|
+
if (CONTAINER_MEMORY) {
|
|
235
|
+
args.push(`--memory=${CONTAINER_MEMORY}`);
|
|
236
|
+
}
|
|
237
|
+
if (CONTAINER_CPUS) {
|
|
238
|
+
args.push(`--cpus=${CONTAINER_CPUS}`);
|
|
239
|
+
}
|
|
240
|
+
if (CONTAINER_READONLY_ROOT) {
|
|
241
|
+
args.push('--read-only');
|
|
242
|
+
const tmpfsOptions = ['rw', 'noexec', 'nosuid', `size=${CONTAINER_TMPFS_SIZE}`];
|
|
243
|
+
if (runUid)
|
|
244
|
+
tmpfsOptions.push(`uid=${runUid}`);
|
|
245
|
+
if (runGid)
|
|
246
|
+
tmpfsOptions.push(`gid=${runGid}`);
|
|
247
|
+
args.push('--tmpfs', `/tmp:${tmpfsOptions.join(',')}`);
|
|
248
|
+
args.push('--tmpfs', `/home/node:${tmpfsOptions.join(',')}`);
|
|
249
|
+
}
|
|
250
|
+
// Docker: -v with :ro suffix for readonly
|
|
251
|
+
for (const mount of mounts) {
|
|
252
|
+
if (mount.readonly) {
|
|
253
|
+
args.push('-v', `${mount.hostPath}:${mount.containerPath}:ro`);
|
|
254
|
+
}
|
|
255
|
+
else {
|
|
256
|
+
args.push('-v', `${mount.hostPath}:${mount.containerPath}`);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
args.push(CONTAINER_IMAGE);
|
|
260
|
+
return args;
|
|
261
|
+
}
|
|
262
|
+
function buildDaemonArgs(mounts, containerName, groupFolder) {
|
|
263
|
+
const args = ['run', '-d', '--rm', '--name', containerName, '--label', `dotclaw.group=${groupFolder}`];
|
|
264
|
+
// Security hardening
|
|
265
|
+
args.push('--cap-drop=ALL');
|
|
266
|
+
args.push('--security-opt=no-new-privileges');
|
|
267
|
+
args.push(`--pids-limit=${CONTAINER_PIDS_LIMIT}`);
|
|
268
|
+
const runUid = CONTAINER_RUN_UID ? CONTAINER_RUN_UID.trim() : '';
|
|
269
|
+
const runGid = CONTAINER_RUN_GID ? CONTAINER_RUN_GID.trim() : '';
|
|
270
|
+
if (runUid) {
|
|
271
|
+
args.push('--user', runGid ? `${runUid}:${runGid}` : runUid);
|
|
272
|
+
}
|
|
273
|
+
args.push('--env', 'HOME=/tmp');
|
|
274
|
+
args.push('--env', 'DOTCLAW_DAEMON=1');
|
|
275
|
+
if (CONTAINER_MEMORY) {
|
|
276
|
+
args.push(`--memory=${CONTAINER_MEMORY}`);
|
|
277
|
+
}
|
|
278
|
+
if (CONTAINER_CPUS) {
|
|
279
|
+
args.push(`--cpus=${CONTAINER_CPUS}`);
|
|
280
|
+
}
|
|
281
|
+
if (CONTAINER_READONLY_ROOT) {
|
|
282
|
+
args.push('--read-only');
|
|
283
|
+
const tmpfsOptions = ['rw', 'noexec', 'nosuid', `size=${CONTAINER_TMPFS_SIZE}`];
|
|
284
|
+
if (runUid)
|
|
285
|
+
tmpfsOptions.push(`uid=${runUid}`);
|
|
286
|
+
if (runGid)
|
|
287
|
+
tmpfsOptions.push(`gid=${runGid}`);
|
|
288
|
+
args.push('--tmpfs', `/tmp:${tmpfsOptions.join(',')}`);
|
|
289
|
+
args.push('--tmpfs', `/home/node:${tmpfsOptions.join(',')}`);
|
|
290
|
+
}
|
|
291
|
+
for (const mount of mounts) {
|
|
292
|
+
if (mount.readonly) {
|
|
293
|
+
args.push('-v', `${mount.hostPath}:${mount.containerPath}:ro`);
|
|
294
|
+
}
|
|
295
|
+
else {
|
|
296
|
+
args.push('-v', `${mount.hostPath}:${mount.containerPath}`);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
args.push(CONTAINER_IMAGE);
|
|
300
|
+
return args;
|
|
301
|
+
}
|
|
302
|
+
function getDaemonContainerName(groupFolder) {
|
|
303
|
+
return `dotclaw-agent-${groupFolder}`;
|
|
304
|
+
}
|
|
305
|
+
function isContainerRunning(name) {
|
|
306
|
+
try {
|
|
307
|
+
const output = execSync(`docker ps --filter "name=${name}" --format "{{.ID}}"`, { stdio: 'pipe' })
|
|
308
|
+
.toString()
|
|
309
|
+
.trim();
|
|
310
|
+
return output.length > 0;
|
|
311
|
+
}
|
|
312
|
+
catch {
|
|
313
|
+
return false;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
const DAEMON_HEALTH_CHECK_INTERVAL_MS = 60_000; // Check every 60 seconds
|
|
317
|
+
const DAEMON_HEARTBEAT_MAX_AGE_MS = 30_000; // Consider unhealthy if heartbeat older than 30s
|
|
318
|
+
/**
|
|
319
|
+
* Check if a daemon container is healthy by reading its heartbeat file
|
|
320
|
+
*/
|
|
321
|
+
export function checkDaemonHealth(groupFolder) {
|
|
322
|
+
const heartbeatPath = path.join(DATA_DIR, 'ipc', groupFolder, 'heartbeat');
|
|
323
|
+
try {
|
|
324
|
+
if (!fs.existsSync(heartbeatPath)) {
|
|
325
|
+
return { healthy: false };
|
|
326
|
+
}
|
|
327
|
+
const content = fs.readFileSync(heartbeatPath, 'utf-8').trim();
|
|
328
|
+
const lastHeartbeat = parseInt(content, 10);
|
|
329
|
+
if (!Number.isFinite(lastHeartbeat)) {
|
|
330
|
+
return { healthy: false };
|
|
331
|
+
}
|
|
332
|
+
const ageMs = Date.now() - lastHeartbeat;
|
|
333
|
+
return {
|
|
334
|
+
healthy: ageMs < DAEMON_HEARTBEAT_MAX_AGE_MS,
|
|
335
|
+
lastHeartbeat,
|
|
336
|
+
ageMs
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
catch {
|
|
340
|
+
return { healthy: false };
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
/**
|
|
344
|
+
* Restart a daemon container if unhealthy
|
|
345
|
+
*/
|
|
346
|
+
export function restartDaemonContainer(group, isMain) {
|
|
347
|
+
const containerName = getDaemonContainerName(group.folder);
|
|
348
|
+
// Stop existing container
|
|
349
|
+
try {
|
|
350
|
+
execSync(`docker rm -f ${containerName}`, { stdio: 'ignore' });
|
|
351
|
+
}
|
|
352
|
+
catch {
|
|
353
|
+
// Ignore if container doesn't exist
|
|
354
|
+
}
|
|
355
|
+
// Start new container
|
|
356
|
+
const mounts = buildVolumeMounts(group, isMain);
|
|
357
|
+
ensureDaemonContainer(mounts, group.folder);
|
|
358
|
+
logger.info({ groupFolder: group.folder }, 'Daemon container restarted');
|
|
359
|
+
}
|
|
360
|
+
// Track daemon health check state
|
|
361
|
+
let healthCheckInterval = null;
|
|
362
|
+
const unhealthyDaemons = new Map(); // Track consecutive unhealthy checks
|
|
363
|
+
/**
|
|
364
|
+
* Perform health check on all daemon containers and restart if needed
|
|
365
|
+
*/
|
|
366
|
+
export function performDaemonHealthChecks(getRegisteredGroups, mainGroupFolder) {
|
|
367
|
+
if (CONTAINER_MODE !== 'daemon')
|
|
368
|
+
return;
|
|
369
|
+
const groups = getRegisteredGroups();
|
|
370
|
+
for (const [, group] of Object.entries(groups)) {
|
|
371
|
+
const containerName = getDaemonContainerName(group.folder);
|
|
372
|
+
// Skip if container isn't running (may be intentionally stopped)
|
|
373
|
+
if (!isContainerRunning(containerName)) {
|
|
374
|
+
unhealthyDaemons.delete(group.folder);
|
|
375
|
+
continue;
|
|
376
|
+
}
|
|
377
|
+
const health = checkDaemonHealth(group.folder);
|
|
378
|
+
if (health.healthy) {
|
|
379
|
+
unhealthyDaemons.delete(group.folder);
|
|
380
|
+
}
|
|
381
|
+
else {
|
|
382
|
+
const consecutiveFailures = (unhealthyDaemons.get(group.folder) || 0) + 1;
|
|
383
|
+
unhealthyDaemons.set(group.folder, consecutiveFailures);
|
|
384
|
+
logger.warn({
|
|
385
|
+
groupFolder: group.folder,
|
|
386
|
+
consecutiveFailures,
|
|
387
|
+
ageMs: health.ageMs
|
|
388
|
+
}, 'Daemon container unhealthy');
|
|
389
|
+
// Restart after 2 consecutive failures
|
|
390
|
+
if (consecutiveFailures >= 2) {
|
|
391
|
+
logger.info({ groupFolder: group.folder }, 'Restarting unhealthy daemon');
|
|
392
|
+
restartDaemonContainer(group, group.folder === mainGroupFolder);
|
|
393
|
+
unhealthyDaemons.delete(group.folder);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
/**
|
|
399
|
+
* Start the daemon health check loop
|
|
400
|
+
*/
|
|
401
|
+
export function startDaemonHealthCheckLoop(getRegisteredGroups, mainGroupFolder) {
|
|
402
|
+
if (CONTAINER_MODE !== 'daemon')
|
|
403
|
+
return;
|
|
404
|
+
if (healthCheckInterval)
|
|
405
|
+
return;
|
|
406
|
+
healthCheckInterval = setInterval(() => {
|
|
407
|
+
performDaemonHealthChecks(getRegisteredGroups, mainGroupFolder);
|
|
408
|
+
}, DAEMON_HEALTH_CHECK_INTERVAL_MS);
|
|
409
|
+
logger.info('Daemon health check loop started');
|
|
410
|
+
}
|
|
411
|
+
/**
|
|
412
|
+
* Stop the daemon health check loop
|
|
413
|
+
*/
|
|
414
|
+
export function stopDaemonHealthCheckLoop() {
|
|
415
|
+
if (healthCheckInterval) {
|
|
416
|
+
clearInterval(healthCheckInterval);
|
|
417
|
+
healthCheckInterval = null;
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
function ensureDaemonContainer(mounts, groupFolder) {
|
|
421
|
+
const containerName = getDaemonContainerName(groupFolder);
|
|
422
|
+
if (isContainerRunning(containerName))
|
|
423
|
+
return;
|
|
424
|
+
try {
|
|
425
|
+
execSync(`docker rm -f ${containerName}`, { stdio: 'ignore' });
|
|
426
|
+
}
|
|
427
|
+
catch {
|
|
428
|
+
// ignore if container doesn't exist
|
|
429
|
+
}
|
|
430
|
+
const args = buildDaemonArgs(mounts, containerName, groupFolder);
|
|
431
|
+
const result = spawnSync('docker', args, { stdio: 'ignore' });
|
|
432
|
+
if (result.status !== 0) {
|
|
433
|
+
logger.error({ groupFolder, status: result.status }, 'Failed to start daemon container');
|
|
434
|
+
throw new Error(`Failed to start daemon container for ${groupFolder}`);
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
export function warmGroupContainer(group, isMain) {
|
|
438
|
+
if (CONTAINER_MODE !== 'daemon')
|
|
439
|
+
return;
|
|
440
|
+
const mounts = buildVolumeMounts(group, isMain);
|
|
441
|
+
ensureDaemonContainer(mounts, group.folder);
|
|
442
|
+
}
|
|
443
|
+
function writeAgentRequest(groupFolder, payload) {
|
|
444
|
+
const id = `agent-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
445
|
+
const requestsDir = path.join(DATA_DIR, 'ipc', groupFolder, 'agent_requests');
|
|
446
|
+
const responsesDir = path.join(DATA_DIR, 'ipc', groupFolder, 'agent_responses');
|
|
447
|
+
fs.mkdirSync(requestsDir, { recursive: true });
|
|
448
|
+
fs.mkdirSync(responsesDir, { recursive: true });
|
|
449
|
+
const requestPath = path.join(requestsDir, `${id}.json`);
|
|
450
|
+
const responsePath = path.join(responsesDir, `${id}.json`);
|
|
451
|
+
const tempPath = `${requestPath}.tmp`;
|
|
452
|
+
fs.writeFileSync(tempPath, JSON.stringify({ id, input: payload }, null, 2));
|
|
453
|
+
fs.renameSync(tempPath, requestPath);
|
|
454
|
+
return { id, requestPath, responsePath };
|
|
455
|
+
}
|
|
456
|
+
async function waitForAgentResponse(responsePath, timeoutMs, abortSignal) {
|
|
457
|
+
const start = Date.now();
|
|
458
|
+
while (Date.now() - start < timeoutMs) {
|
|
459
|
+
if (abortSignal?.aborted) {
|
|
460
|
+
throw new Error('Agent run preempted');
|
|
461
|
+
}
|
|
462
|
+
if (fs.existsSync(responsePath)) {
|
|
463
|
+
const raw = fs.readFileSync(responsePath, 'utf-8');
|
|
464
|
+
fs.unlinkSync(responsePath);
|
|
465
|
+
try {
|
|
466
|
+
return JSON.parse(raw);
|
|
467
|
+
}
|
|
468
|
+
catch (err) {
|
|
469
|
+
throw new Error(`Failed to parse daemon response: ${err instanceof Error ? err.message : String(err)}`);
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
await new Promise(resolve => setTimeout(resolve, CONTAINER_DAEMON_POLL_MS));
|
|
473
|
+
}
|
|
474
|
+
throw new Error(`Daemon response timeout after ${timeoutMs}ms`);
|
|
475
|
+
}
|
|
476
|
+
function readContainerId(cidFile) {
|
|
477
|
+
try {
|
|
478
|
+
const id = fs.readFileSync(cidFile, 'utf-8').trim();
|
|
479
|
+
return id ? id : null;
|
|
480
|
+
}
|
|
481
|
+
catch {
|
|
482
|
+
return null;
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
function removeContainerById(containerId, reason) {
|
|
486
|
+
if (!containerId)
|
|
487
|
+
return;
|
|
488
|
+
logger.warn({ containerId, reason }, 'Removing container');
|
|
489
|
+
spawn('docker', ['rm', '-f', containerId], { stdio: 'ignore' });
|
|
490
|
+
}
|
|
491
|
+
export async function runContainerAgent(group, input, options) {
|
|
492
|
+
if (CONTAINER_MODE === 'daemon') {
|
|
493
|
+
return runContainerAgentDaemon(group, input, options);
|
|
494
|
+
}
|
|
495
|
+
const startTime = Date.now();
|
|
496
|
+
const groupDir = path.join(GROUPS_DIR, group.folder);
|
|
497
|
+
fs.mkdirSync(groupDir, { recursive: true });
|
|
498
|
+
const mounts = buildVolumeMounts(group, input.isMain);
|
|
499
|
+
fs.mkdirSync(CONTAINER_ID_DIR, { recursive: true });
|
|
500
|
+
const cidFile = path.join(CONTAINER_ID_DIR, `container-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.cid`);
|
|
501
|
+
try {
|
|
502
|
+
fs.rmSync(cidFile, { force: true });
|
|
503
|
+
}
|
|
504
|
+
catch {
|
|
505
|
+
// ignore cleanup failure
|
|
506
|
+
}
|
|
507
|
+
const containerArgs = buildContainerArgs(mounts, cidFile);
|
|
508
|
+
logger.debug({
|
|
509
|
+
group: group.name,
|
|
510
|
+
mounts: mounts.map(m => `${m.hostPath} -> ${m.containerPath}${m.readonly ? ' (ro)' : ''}`),
|
|
511
|
+
containerArgs: containerArgs.join(' ')
|
|
512
|
+
}, 'Container mount configuration');
|
|
513
|
+
logger.info({
|
|
514
|
+
group: group.name,
|
|
515
|
+
mountCount: mounts.length,
|
|
516
|
+
isMain: input.isMain
|
|
517
|
+
}, 'Spawning container agent');
|
|
518
|
+
const logsDir = path.join(GROUPS_DIR, group.folder, 'logs');
|
|
519
|
+
fs.mkdirSync(logsDir, { recursive: true });
|
|
520
|
+
return new Promise((resolve) => {
|
|
521
|
+
const container = spawn('docker', containerArgs, {
|
|
522
|
+
stdio: ['pipe', 'pipe', 'pipe']
|
|
523
|
+
});
|
|
524
|
+
let stdout = '';
|
|
525
|
+
let stderr = '';
|
|
526
|
+
let stdoutTruncated = false;
|
|
527
|
+
let stderrTruncated = false;
|
|
528
|
+
let resolved = false;
|
|
529
|
+
container.stdin.write(JSON.stringify(input));
|
|
530
|
+
container.stdin.end();
|
|
531
|
+
container.stdout.on('data', (data) => {
|
|
532
|
+
if (stdoutTruncated)
|
|
533
|
+
return;
|
|
534
|
+
const chunk = data.toString();
|
|
535
|
+
const remaining = CONTAINER_MAX_OUTPUT_SIZE - stdout.length;
|
|
536
|
+
if (chunk.length > remaining) {
|
|
537
|
+
stdout += chunk.slice(0, remaining);
|
|
538
|
+
stdoutTruncated = true;
|
|
539
|
+
logger.warn({ group: group.name, size: stdout.length }, 'Container stdout truncated due to size limit');
|
|
540
|
+
}
|
|
541
|
+
else {
|
|
542
|
+
stdout += chunk;
|
|
543
|
+
}
|
|
544
|
+
});
|
|
545
|
+
container.stderr.on('data', (data) => {
|
|
546
|
+
const chunk = data.toString();
|
|
547
|
+
const lines = chunk.trim().split('\n');
|
|
548
|
+
for (const line of lines) {
|
|
549
|
+
if (line)
|
|
550
|
+
logger.debug({ container: group.folder }, line);
|
|
551
|
+
}
|
|
552
|
+
if (stderrTruncated)
|
|
553
|
+
return;
|
|
554
|
+
const remaining = CONTAINER_MAX_OUTPUT_SIZE - stderr.length;
|
|
555
|
+
if (chunk.length > remaining) {
|
|
556
|
+
stderr += chunk.slice(0, remaining);
|
|
557
|
+
stderrTruncated = true;
|
|
558
|
+
logger.warn({ group: group.name, size: stderr.length }, 'Container stderr truncated due to size limit');
|
|
559
|
+
}
|
|
560
|
+
else {
|
|
561
|
+
stderr += chunk;
|
|
562
|
+
}
|
|
563
|
+
});
|
|
564
|
+
const cleanupCid = () => {
|
|
565
|
+
try {
|
|
566
|
+
fs.rmSync(cidFile, { force: true });
|
|
567
|
+
}
|
|
568
|
+
catch {
|
|
569
|
+
// ignore cleanup failure
|
|
570
|
+
}
|
|
571
|
+
};
|
|
572
|
+
const stopContainer = (reason) => {
|
|
573
|
+
const containerId = readContainerId(cidFile);
|
|
574
|
+
if (containerId) {
|
|
575
|
+
removeContainerById(containerId, reason);
|
|
576
|
+
}
|
|
577
|
+
};
|
|
578
|
+
const timeoutMs = group.containerConfig?.timeout || CONTAINER_TIMEOUT;
|
|
579
|
+
const timeout = setTimeout(() => {
|
|
580
|
+
logger.error({ group: group.name }, 'Container timeout, killing');
|
|
581
|
+
stopContainer('timeout');
|
|
582
|
+
container.kill('SIGKILL');
|
|
583
|
+
if (resolved)
|
|
584
|
+
return;
|
|
585
|
+
resolved = true;
|
|
586
|
+
resolve({
|
|
587
|
+
status: 'error',
|
|
588
|
+
result: null,
|
|
589
|
+
error: `Container timed out after ${timeoutMs}ms`
|
|
590
|
+
});
|
|
591
|
+
}, timeoutMs);
|
|
592
|
+
const abortSignal = options?.abortSignal;
|
|
593
|
+
const abortHandler = () => {
|
|
594
|
+
if (resolved)
|
|
595
|
+
return;
|
|
596
|
+
resolved = true;
|
|
597
|
+
logger.warn({ group: group.name }, 'Container run preempted');
|
|
598
|
+
stopContainer('preempted');
|
|
599
|
+
container.kill('SIGKILL');
|
|
600
|
+
clearTimeout(timeout);
|
|
601
|
+
cleanupCid();
|
|
602
|
+
resolve({
|
|
603
|
+
status: 'error',
|
|
604
|
+
result: null,
|
|
605
|
+
error: 'Container run preempted'
|
|
606
|
+
});
|
|
607
|
+
};
|
|
608
|
+
if (abortSignal) {
|
|
609
|
+
if (abortSignal.aborted) {
|
|
610
|
+
abortHandler();
|
|
611
|
+
}
|
|
612
|
+
else {
|
|
613
|
+
abortSignal.addEventListener('abort', abortHandler, { once: true });
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
container.on('close', (code) => {
|
|
617
|
+
if (resolved)
|
|
618
|
+
return;
|
|
619
|
+
resolved = true;
|
|
620
|
+
clearTimeout(timeout);
|
|
621
|
+
if (abortSignal) {
|
|
622
|
+
abortSignal.removeEventListener('abort', abortHandler);
|
|
623
|
+
}
|
|
624
|
+
cleanupCid();
|
|
625
|
+
const duration = Date.now() - startTime;
|
|
626
|
+
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
627
|
+
const logFile = path.join(logsDir, `container-${timestamp}.log`);
|
|
628
|
+
const isVerbose = runtime.host.logLevel === 'debug' || runtime.host.logLevel === 'trace';
|
|
629
|
+
const logLines = [
|
|
630
|
+
`=== Container Run Log ===`,
|
|
631
|
+
`Timestamp: ${new Date().toISOString()}`,
|
|
632
|
+
`Group: ${group.name}`,
|
|
633
|
+
`IsMain: ${input.isMain}`,
|
|
634
|
+
`Duration: ${duration}ms`,
|
|
635
|
+
`Exit Code: ${code}`,
|
|
636
|
+
`Stdout Truncated: ${stdoutTruncated}`,
|
|
637
|
+
`Stderr Truncated: ${stderrTruncated}`,
|
|
638
|
+
``
|
|
639
|
+
];
|
|
640
|
+
if (isVerbose) {
|
|
641
|
+
logLines.push(`=== Input ===`, JSON.stringify(input, null, 2), ``, `=== Container Args ===`, containerArgs.join(' '), ``, `=== Mounts ===`, mounts.map(m => `${m.hostPath} -> ${m.containerPath}${m.readonly ? ' (ro)' : ''}`).join('\n'), ``, `=== Stderr${stderrTruncated ? ' (TRUNCATED)' : ''} ===`, stderr, ``, `=== Stdout${stdoutTruncated ? ' (TRUNCATED)' : ''} ===`, stdout);
|
|
642
|
+
}
|
|
643
|
+
else {
|
|
644
|
+
logLines.push(`=== Input Summary ===`, `Prompt length: ${input.prompt.length} chars`, `Session ID: ${input.sessionId || 'new'}`, ``, `=== Mounts ===`, mounts.map(m => `${m.containerPath}${m.readonly ? ' (ro)' : ''}`).join('\n'), ``);
|
|
645
|
+
if (code !== 0) {
|
|
646
|
+
logLines.push(`=== Stderr (last 500 chars) ===`, stderr.slice(-500), ``);
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
fs.writeFileSync(logFile, logLines.join('\n'));
|
|
650
|
+
logger.debug({ logFile, verbose: isVerbose }, 'Container log written');
|
|
651
|
+
if (code !== 0) {
|
|
652
|
+
logger.error({
|
|
653
|
+
group: group.name,
|
|
654
|
+
code,
|
|
655
|
+
duration,
|
|
656
|
+
stderr: stderr.slice(-500),
|
|
657
|
+
logFile
|
|
658
|
+
}, 'Container exited with error');
|
|
659
|
+
resolve({
|
|
660
|
+
status: 'error',
|
|
661
|
+
result: null,
|
|
662
|
+
error: `Container exited with code ${code}: ${stderr.slice(-200)}`
|
|
663
|
+
});
|
|
664
|
+
return;
|
|
665
|
+
}
|
|
666
|
+
try {
|
|
667
|
+
// Extract JSON between sentinel markers for robust parsing
|
|
668
|
+
const startIdx = stdout.indexOf(OUTPUT_START_MARKER);
|
|
669
|
+
const endIdx = stdout.indexOf(OUTPUT_END_MARKER);
|
|
670
|
+
let jsonLine;
|
|
671
|
+
if (startIdx !== -1 && endIdx !== -1 && endIdx > startIdx) {
|
|
672
|
+
jsonLine = stdout.slice(startIdx + OUTPUT_START_MARKER.length, endIdx).trim();
|
|
673
|
+
}
|
|
674
|
+
else {
|
|
675
|
+
// Fallback: last non-empty line (backwards compatibility)
|
|
676
|
+
const lines = stdout.trim().split('\n');
|
|
677
|
+
jsonLine = lines[lines.length - 1];
|
|
678
|
+
}
|
|
679
|
+
const output = JSON.parse(jsonLine);
|
|
680
|
+
logger.info({
|
|
681
|
+
group: group.name,
|
|
682
|
+
duration,
|
|
683
|
+
status: output.status,
|
|
684
|
+
hasResult: !!output.result
|
|
685
|
+
}, 'Container completed');
|
|
686
|
+
resolve(output);
|
|
687
|
+
}
|
|
688
|
+
catch (err) {
|
|
689
|
+
logger.error({
|
|
690
|
+
group: group.name,
|
|
691
|
+
stdout: stdout.slice(-500),
|
|
692
|
+
error: err
|
|
693
|
+
}, 'Failed to parse container output');
|
|
694
|
+
resolve({
|
|
695
|
+
status: 'error',
|
|
696
|
+
result: null,
|
|
697
|
+
error: `Failed to parse container output: ${err instanceof Error ? err.message : String(err)}`
|
|
698
|
+
});
|
|
699
|
+
}
|
|
700
|
+
});
|
|
701
|
+
container.on('error', (err) => {
|
|
702
|
+
if (resolved)
|
|
703
|
+
return;
|
|
704
|
+
resolved = true;
|
|
705
|
+
clearTimeout(timeout);
|
|
706
|
+
if (abortSignal) {
|
|
707
|
+
abortSignal.removeEventListener('abort', abortHandler);
|
|
708
|
+
}
|
|
709
|
+
cleanupCid();
|
|
710
|
+
logger.error({ group: group.name, error: err }, 'Container spawn error');
|
|
711
|
+
resolve({
|
|
712
|
+
status: 'error',
|
|
713
|
+
result: null,
|
|
714
|
+
error: `Container spawn error: ${err.message}`
|
|
715
|
+
});
|
|
716
|
+
});
|
|
717
|
+
});
|
|
718
|
+
}
|
|
719
|
+
async function runContainerAgentDaemon(group, input, options) {
|
|
720
|
+
const startTime = Date.now();
|
|
721
|
+
const groupDir = path.join(GROUPS_DIR, group.folder);
|
|
722
|
+
fs.mkdirSync(groupDir, { recursive: true });
|
|
723
|
+
const mounts = buildVolumeMounts(group, input.isMain);
|
|
724
|
+
ensureDaemonContainer(mounts, group.folder);
|
|
725
|
+
const { responsePath, requestPath } = writeAgentRequest(group.folder, input);
|
|
726
|
+
const timeoutMs = group.containerConfig?.timeout || CONTAINER_TIMEOUT;
|
|
727
|
+
const abortSignal = options?.abortSignal;
|
|
728
|
+
const containerName = getDaemonContainerName(group.folder);
|
|
729
|
+
const abortHandler = () => {
|
|
730
|
+
logger.warn({ group: group.name }, 'Daemon run preempted');
|
|
731
|
+
try {
|
|
732
|
+
if (fs.existsSync(requestPath))
|
|
733
|
+
fs.unlinkSync(requestPath);
|
|
734
|
+
}
|
|
735
|
+
catch {
|
|
736
|
+
// ignore cleanup failure
|
|
737
|
+
}
|
|
738
|
+
spawn('docker', ['rm', '-f', containerName], { stdio: 'ignore' });
|
|
739
|
+
};
|
|
740
|
+
if (abortSignal) {
|
|
741
|
+
if (abortSignal.aborted) {
|
|
742
|
+
abortHandler();
|
|
743
|
+
return {
|
|
744
|
+
status: 'error',
|
|
745
|
+
result: null,
|
|
746
|
+
error: 'Daemon run preempted'
|
|
747
|
+
};
|
|
748
|
+
}
|
|
749
|
+
abortSignal.addEventListener('abort', abortHandler, { once: true });
|
|
750
|
+
}
|
|
751
|
+
try {
|
|
752
|
+
const output = await waitForAgentResponse(responsePath, timeoutMs, abortSignal);
|
|
753
|
+
return {
|
|
754
|
+
...output,
|
|
755
|
+
latency_ms: output.latency_ms ?? (Date.now() - startTime)
|
|
756
|
+
};
|
|
757
|
+
}
|
|
758
|
+
catch (err) {
|
|
759
|
+
const errorMessage = err instanceof Error ? err.message : String(err);
|
|
760
|
+
logger.error({ group: group.name, error: errorMessage }, 'Daemon agent error');
|
|
761
|
+
try {
|
|
762
|
+
if (fs.existsSync(requestPath))
|
|
763
|
+
fs.unlinkSync(requestPath);
|
|
764
|
+
}
|
|
765
|
+
catch {
|
|
766
|
+
// ignore cleanup failure
|
|
767
|
+
}
|
|
768
|
+
try {
|
|
769
|
+
spawn('docker', ['rm', '-f', containerName], { stdio: 'ignore' });
|
|
770
|
+
}
|
|
771
|
+
catch {
|
|
772
|
+
// ignore cleanup failure
|
|
773
|
+
}
|
|
774
|
+
return {
|
|
775
|
+
status: 'error',
|
|
776
|
+
result: null,
|
|
777
|
+
error: errorMessage
|
|
778
|
+
};
|
|
779
|
+
}
|
|
780
|
+
finally {
|
|
781
|
+
if (abortSignal) {
|
|
782
|
+
abortSignal.removeEventListener('abort', abortHandler);
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
export function writeTasksSnapshot(groupFolder, isMain, tasks) {
|
|
787
|
+
// Write filtered tasks to the group's IPC directory
|
|
788
|
+
const groupIpcDir = path.join(DATA_DIR, 'ipc', groupFolder);
|
|
789
|
+
fs.mkdirSync(groupIpcDir, { recursive: true });
|
|
790
|
+
// Main sees all tasks, others only see their own
|
|
791
|
+
const filteredTasks = isMain
|
|
792
|
+
? tasks
|
|
793
|
+
: tasks.filter(t => t.groupFolder === groupFolder);
|
|
794
|
+
const tasksFile = path.join(groupIpcDir, 'current_tasks.json');
|
|
795
|
+
fs.writeFileSync(tasksFile, JSON.stringify(filteredTasks, null, 2));
|
|
796
|
+
}
|
|
797
|
+
/**
|
|
798
|
+
* Write available groups snapshot for the container to read.
|
|
799
|
+
* Only main group can see all available groups (for activation).
|
|
800
|
+
* Non-main groups only see their own registration status.
|
|
801
|
+
*/
|
|
802
|
+
export function writeGroupsSnapshot(groupFolder, isMain, groups) {
|
|
803
|
+
const groupIpcDir = path.join(DATA_DIR, 'ipc', groupFolder);
|
|
804
|
+
fs.mkdirSync(groupIpcDir, { recursive: true });
|
|
805
|
+
// Main sees all groups; others see nothing (they can't activate groups)
|
|
806
|
+
const visibleGroups = isMain ? groups : [];
|
|
807
|
+
const groupsFile = path.join(groupIpcDir, 'available_groups.json');
|
|
808
|
+
fs.writeFileSync(groupsFile, JSON.stringify({
|
|
809
|
+
groups: visibleGroups,
|
|
810
|
+
lastSync: new Date().toISOString()
|
|
811
|
+
}, null, 2));
|
|
812
|
+
}
|
|
813
|
+
//# sourceMappingURL=container-runner.js.map
|