@covibes/zeroshot 5.3.0 → 5.4.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/README.md +94 -14
- package/cli/commands/providers.js +8 -9
- package/cli/index.js +3032 -2409
- package/cli/message-formatters-normal.js +28 -6
- package/cluster-templates/base-templates/debug-workflow.json +1 -1
- package/cluster-templates/base-templates/full-workflow.json +72 -188
- package/cluster-templates/base-templates/worker-validator.json +59 -3
- package/cluster-templates/conductor-bootstrap.json +4 -4
- package/lib/docker-config.js +8 -0
- package/lib/git-remote-utils.js +165 -0
- package/lib/id-detector.js +10 -7
- package/lib/provider-defaults.js +62 -0
- package/lib/provider-names.js +2 -1
- package/lib/settings/claude-auth.js +78 -0
- package/lib/settings.js +161 -63
- package/package.json +7 -2
- package/scripts/setup-merge-queue.sh +170 -0
- package/src/agent/agent-config.js +135 -82
- package/src/agent/agent-context-builder.js +297 -188
- package/src/agent/agent-hook-executor.js +310 -113
- package/src/agent/agent-lifecycle.js +385 -325
- package/src/agent/agent-stuck-detector.js +7 -7
- package/src/agent/agent-task-executor.js +824 -565
- package/src/agent/output-extraction.js +41 -24
- package/src/agent/output-reformatter.js +1 -1
- package/src/agent/schema-utils.js +108 -73
- package/src/agent-wrapper.js +10 -1
- package/src/agents/git-pusher-template.js +285 -0
- package/src/claude-task-runner.js +85 -34
- package/src/config-validator.js +922 -657
- package/src/input-helpers.js +65 -0
- package/src/isolation-manager.js +289 -199
- package/src/issue-providers/README.md +305 -0
- package/src/issue-providers/azure-devops-provider.js +273 -0
- package/src/issue-providers/base-provider.js +232 -0
- package/src/issue-providers/github-provider.js +179 -0
- package/src/issue-providers/gitlab-provider.js +241 -0
- package/src/issue-providers/index.js +196 -0
- package/src/issue-providers/jira-provider.js +239 -0
- package/src/ledger.js +22 -5
- package/src/lib/safe-exec.js +88 -0
- package/src/orchestrator.js +1107 -811
- package/src/preflight.js +313 -159
- package/src/process-metrics.js +98 -56
- package/src/providers/anthropic/cli-builder.js +53 -25
- package/src/providers/anthropic/index.js +72 -2
- package/src/providers/anthropic/output-parser.js +32 -14
- package/src/providers/base-provider.js +70 -0
- package/src/providers/capabilities.js +9 -0
- package/src/providers/google/output-parser.js +47 -38
- package/src/providers/index.js +19 -3
- package/src/providers/openai/cli-builder.js +14 -3
- package/src/providers/openai/index.js +1 -0
- package/src/providers/openai/output-parser.js +44 -30
- package/src/providers/opencode/cli-builder.js +42 -0
- package/src/providers/opencode/index.js +103 -0
- package/src/providers/opencode/models.js +55 -0
- package/src/providers/opencode/output-parser.js +122 -0
- package/src/schemas/sub-cluster.js +68 -39
- package/src/status-footer.js +94 -48
- package/src/sub-cluster-wrapper.js +76 -35
- package/src/template-resolver.js +12 -9
- package/src/tui/data-poller.js +123 -99
- package/src/tui/formatters.js +4 -3
- package/src/tui/keybindings.js +259 -318
- package/src/tui/renderer.js +11 -21
- package/task-lib/attachable-watcher.js +118 -81
- package/task-lib/claude-recovery.js +61 -24
- package/task-lib/commands/episodes.js +105 -0
- package/task-lib/commands/list.js +2 -2
- package/task-lib/commands/logs.js +231 -189
- package/task-lib/commands/schedules.js +111 -61
- package/task-lib/config.js +0 -2
- package/task-lib/runner.js +84 -27
- package/task-lib/scheduler.js +1 -1
- package/task-lib/store.js +467 -168
- package/task-lib/tui/formatters.js +94 -45
- package/task-lib/tui/renderer.js +13 -3
- package/task-lib/tui.js +73 -32
- package/task-lib/watcher.js +128 -90
- package/src/agents/git-pusher-agent.json +0 -20
- package/src/github.js +0 -139
package/src/isolation-manager.js
CHANGED
|
@@ -8,13 +8,15 @@
|
|
|
8
8
|
* - Container cleanup on stop/kill
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
const { spawn
|
|
11
|
+
const { spawn } = require('child_process');
|
|
12
|
+
const { execSync } = require('./lib/safe-exec'); // Enforces timeouts - prevents infinite hangs
|
|
12
13
|
const { Worker } = require('worker_threads');
|
|
13
14
|
const crypto = require('crypto');
|
|
14
15
|
const path = require('path');
|
|
15
16
|
const os = require('os');
|
|
16
17
|
const fs = require('fs');
|
|
17
18
|
const { loadSettings } = require('../lib/settings');
|
|
19
|
+
const { CLAUDE_AUTH_ENV_VARS, resolveClaudeAuth } = require('../lib/settings/claude-auth');
|
|
18
20
|
const { normalizeProviderName } = require('../lib/provider-names');
|
|
19
21
|
const { resolveMounts, resolveEnvs, expandEnvPatterns } = require('../lib/docker-config');
|
|
20
22
|
const { getProvider } = require('./providers');
|
|
@@ -93,173 +95,207 @@ class IsolationManager {
|
|
|
93
95
|
const containerName = `zeroshot-cluster-${clusterId}`;
|
|
94
96
|
const reuseExisting = config.reuseExistingWorkspace || false;
|
|
95
97
|
|
|
96
|
-
|
|
97
|
-
if (
|
|
98
|
-
|
|
99
|
-
if (this._isContainerRunning(existingId)) {
|
|
100
|
-
return existingId;
|
|
101
|
-
}
|
|
98
|
+
const runningContainerId = this._getRunningContainerId(clusterId);
|
|
99
|
+
if (runningContainerId) {
|
|
100
|
+
return runningContainerId;
|
|
102
101
|
}
|
|
103
102
|
|
|
104
|
-
// Clean up any existing container with same name
|
|
105
103
|
this._removeContainerByName(containerName);
|
|
106
104
|
|
|
107
|
-
|
|
108
|
-
// No worktrees - cleaner, no host path dependencies
|
|
109
|
-
// EXCEPTION: On resume (reuseExisting=true), skip copy and use existing workspace
|
|
110
|
-
if (this._isGitRepo(workDir)) {
|
|
111
|
-
const isolatedPath = path.join(os.tmpdir(), 'zeroshot-isolated', clusterId);
|
|
112
|
-
|
|
113
|
-
if (reuseExisting && fs.existsSync(isolatedPath)) {
|
|
114
|
-
// Resume mode: reuse existing isolated workspace (contains agent's work)
|
|
115
|
-
console.log(`[IsolationManager] Reusing existing isolated workspace at ${isolatedPath}`);
|
|
116
|
-
this.isolatedDirs = this.isolatedDirs || new Map();
|
|
117
|
-
this.isolatedDirs.set(clusterId, {
|
|
118
|
-
path: isolatedPath,
|
|
119
|
-
originalDir: workDir,
|
|
120
|
-
});
|
|
121
|
-
workDir = isolatedPath;
|
|
122
|
-
} else {
|
|
123
|
-
// Fresh start: create new isolated copy
|
|
124
|
-
const isolatedDir = await this._createIsolatedCopy(clusterId, workDir);
|
|
125
|
-
this.isolatedDirs = this.isolatedDirs || new Map();
|
|
126
|
-
this.isolatedDirs.set(clusterId, {
|
|
127
|
-
path: isolatedDir,
|
|
128
|
-
originalDir: workDir,
|
|
129
|
-
});
|
|
130
|
-
workDir = isolatedDir;
|
|
131
|
-
console.log(`[IsolationManager] Created isolated copy at ${workDir}`);
|
|
132
|
-
}
|
|
133
|
-
}
|
|
105
|
+
workDir = await this._prepareIsolatedWorkspace(clusterId, workDir, reuseExisting);
|
|
134
106
|
|
|
135
|
-
// Resolve container home directory EARLY - needed for Claude config mount and hooks
|
|
136
107
|
const settings = loadSettings();
|
|
137
108
|
const providerName = normalizeProviderName(
|
|
138
109
|
config.provider || settings.defaultProvider || 'claude'
|
|
139
110
|
);
|
|
140
111
|
const containerHome = config.containerHome || settings.dockerContainerHome || '/root';
|
|
141
112
|
|
|
142
|
-
// Create fresh Claude config dir for this cluster (avoids permission issues from host)
|
|
143
113
|
const clusterConfigDir = this._createClusterConfigDir(clusterId, containerHome);
|
|
144
114
|
console.log(`[IsolationManager] Created cluster config dir at ${clusterConfigDir}`);
|
|
145
115
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
116
|
+
const args = this._buildBaseDockerArgs({
|
|
117
|
+
containerName,
|
|
118
|
+
workDir,
|
|
119
|
+
containerHome,
|
|
120
|
+
clusterConfigDir,
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
const mountedHosts = this._applyCredentialMounts(args, config, settings, containerHome);
|
|
124
|
+
this._warnMissingProviderCredentials(providerName, mountedHosts, config, containerHome);
|
|
125
|
+
|
|
126
|
+
args.push('-w', '/workspace', image, 'tail', '-f', '/dev/null');
|
|
127
|
+
|
|
128
|
+
return this._spawnContainer(clusterId, args, workDir);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
_getRunningContainerId(clusterId) {
|
|
132
|
+
const existingId = this.containers.get(clusterId);
|
|
133
|
+
if (!existingId) {
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return this._isContainerRunning(existingId) ? existingId : null;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async _prepareIsolatedWorkspace(clusterId, workDir, reuseExisting) {
|
|
141
|
+
if (!this._isGitRepo(workDir)) {
|
|
142
|
+
return workDir;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
this.isolatedDirs = this.isolatedDirs || new Map();
|
|
146
|
+
const isolatedPath = path.join(os.tmpdir(), 'zeroshot-isolated', clusterId);
|
|
147
|
+
|
|
148
|
+
if (reuseExisting && fs.existsSync(isolatedPath)) {
|
|
149
|
+
console.log(`[IsolationManager] Reusing existing isolated workspace at ${isolatedPath}`);
|
|
150
|
+
this.isolatedDirs.set(clusterId, {
|
|
151
|
+
path: isolatedPath,
|
|
152
|
+
originalDir: workDir,
|
|
153
|
+
});
|
|
154
|
+
return isolatedPath;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const isolatedDir = await this._createIsolatedCopy(clusterId, workDir);
|
|
158
|
+
this.isolatedDirs.set(clusterId, {
|
|
159
|
+
path: isolatedDir,
|
|
160
|
+
originalDir: workDir,
|
|
161
|
+
});
|
|
162
|
+
console.log(`[IsolationManager] Created isolated copy at ${isolatedDir}`);
|
|
163
|
+
return isolatedDir;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
_buildBaseDockerArgs({ containerName, workDir, containerHome, clusterConfigDir }) {
|
|
167
|
+
return [
|
|
149
168
|
'run',
|
|
150
|
-
'-d',
|
|
169
|
+
'-d',
|
|
151
170
|
'--name',
|
|
152
171
|
containerName,
|
|
153
|
-
// Mount workspace
|
|
154
172
|
'-v',
|
|
155
173
|
`${workDir}:/workspace`,
|
|
156
|
-
// Mount Docker socket for Docker-in-Docker (e2e tests need docker compose)
|
|
157
174
|
'-v',
|
|
158
175
|
'/var/run/docker.sock:/var/run/docker.sock',
|
|
159
|
-
// Add node user to host's docker group (fixes permission denied)
|
|
160
|
-
// CRITICAL: Without this, agent can't run docker commands inside container
|
|
161
176
|
'--group-add',
|
|
162
177
|
this._getDockerGid(),
|
|
163
|
-
// Mount fresh Claude config to container user's home (read-write - Claude CLI writes settings, todos, etc.)
|
|
164
178
|
'-v',
|
|
165
179
|
`${clusterConfigDir}:${containerHome}/.claude`,
|
|
166
180
|
];
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
_resolveMountConfig(config, settings) {
|
|
184
|
+
if (config.mounts) {
|
|
185
|
+
return config.mounts;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (process.env.ZEROSHOT_DOCKER_MOUNTS) {
|
|
189
|
+
try {
|
|
190
|
+
return JSON.parse(process.env.ZEROSHOT_DOCKER_MOUNTS);
|
|
191
|
+
} catch {
|
|
192
|
+
console.warn('[IsolationManager] Invalid ZEROSHOT_DOCKER_MOUNTS JSON, using settings');
|
|
193
|
+
return settings.dockerMounts;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return settings.dockerMounts;
|
|
198
|
+
}
|
|
167
199
|
|
|
200
|
+
_applyCredentialMounts(args, config, settings, containerHome) {
|
|
168
201
|
const mountedHosts = [];
|
|
202
|
+
if (config.noMounts) {
|
|
203
|
+
return mountedHosts;
|
|
204
|
+
}
|
|
169
205
|
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
let mountConfig;
|
|
206
|
+
const mountConfig = this._resolveMountConfig(config, settings);
|
|
207
|
+
const mounts = resolveMounts(mountConfig, { containerHome });
|
|
208
|
+
const claudeContainerPath = path.posix.join(containerHome, '.claude');
|
|
174
209
|
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
} catch {
|
|
183
|
-
console.warn('[IsolationManager] Invalid ZEROSHOT_DOCKER_MOUNTS JSON, using settings');
|
|
184
|
-
mountConfig = settings.dockerMounts;
|
|
185
|
-
}
|
|
186
|
-
} else {
|
|
187
|
-
// User settings
|
|
188
|
-
mountConfig = settings.dockerMounts;
|
|
210
|
+
for (const mount of mounts) {
|
|
211
|
+
if (mount.container === claudeContainerPath) {
|
|
212
|
+
console.warn(
|
|
213
|
+
`[IsolationManager] Skipping mount for ${mount.host} -> ${mount.container} ` +
|
|
214
|
+
'(Claude config is managed by zeroshot).'
|
|
215
|
+
);
|
|
216
|
+
continue;
|
|
189
217
|
}
|
|
190
218
|
|
|
191
|
-
|
|
192
|
-
const mounts = resolveMounts(mountConfig, { containerHome });
|
|
193
|
-
const claudeContainerPath = path.posix.join(containerHome, '.claude');
|
|
219
|
+
const hostPath = expandHomePath(mount.host);
|
|
194
220
|
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
`[IsolationManager] Skipping mount for ${mount.host} -> ${mount.container} ` +
|
|
199
|
-
'(Claude config is managed by zeroshot).'
|
|
200
|
-
);
|
|
221
|
+
try {
|
|
222
|
+
const stat = fs.statSync(hostPath);
|
|
223
|
+
if (hostPath.endsWith('config') && !stat.isFile()) {
|
|
201
224
|
continue;
|
|
202
225
|
}
|
|
226
|
+
} catch {
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
203
229
|
|
|
204
|
-
|
|
230
|
+
const mountSpec = mount.readonly
|
|
231
|
+
? `${hostPath}:${mount.container}:ro`
|
|
232
|
+
: `${hostPath}:${mount.container}`;
|
|
233
|
+
args.push('-v', mountSpec);
|
|
234
|
+
mountedHosts.push(hostPath);
|
|
235
|
+
}
|
|
205
236
|
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
if (hostPath.endsWith('config') && !stat.isFile()) {
|
|
211
|
-
continue;
|
|
212
|
-
}
|
|
213
|
-
} catch {
|
|
214
|
-
// Path doesn't exist - skip silently
|
|
215
|
-
continue;
|
|
216
|
-
}
|
|
237
|
+
const envToPass = this._collectDockerEnvVars(mountConfig, settings);
|
|
238
|
+
for (const [key, value] of Object.entries(envToPass)) {
|
|
239
|
+
args.push('-e', `${key}=${value}`);
|
|
240
|
+
}
|
|
217
241
|
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
: `${hostPath}:${mount.container}`;
|
|
221
|
-
args.push('-v', mountSpec);
|
|
222
|
-
mountedHosts.push(hostPath);
|
|
223
|
-
}
|
|
242
|
+
return mountedHosts;
|
|
243
|
+
}
|
|
224
244
|
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
}
|
|
245
|
+
_collectDockerEnvVars(mountConfig, settings) {
|
|
246
|
+
const envToPass = {};
|
|
247
|
+
const envSpecs = expandEnvPatterns(resolveEnvs(mountConfig, settings.dockerEnvPassthrough));
|
|
248
|
+
|
|
249
|
+
for (const spec of envSpecs) {
|
|
250
|
+
if (spec.forced) {
|
|
251
|
+
envToPass[spec.name] = spec.value;
|
|
252
|
+
} else if (process.env[spec.name]) {
|
|
253
|
+
envToPass[spec.name] = process.env[spec.name];
|
|
235
254
|
}
|
|
236
255
|
}
|
|
237
256
|
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
const hasCredentialMount = mountedHosts.some((hostPath) =>
|
|
244
|
-
expandedCreds.some(
|
|
245
|
-
(credPath) => pathContains(hostPath, credPath) || pathContains(credPath, hostPath)
|
|
246
|
-
)
|
|
247
|
-
);
|
|
257
|
+
for (const envVar of CLAUDE_AUTH_ENV_VARS) {
|
|
258
|
+
if (process.env[envVar]) {
|
|
259
|
+
envToPass[envVar] = process.env[envVar];
|
|
260
|
+
}
|
|
261
|
+
}
|
|
248
262
|
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
console.warn(
|
|
254
|
-
`[IsolationManager] ⚠️ ${mountNote}No credential mounts found for ${provider.displayName}. ` +
|
|
255
|
-
`Add one with --mount ${exampleHost}:${exampleContainer}:ro`
|
|
256
|
-
);
|
|
263
|
+
const authEnv = resolveClaudeAuth(settings);
|
|
264
|
+
for (const [key, value] of Object.entries(authEnv)) {
|
|
265
|
+
if (!(key in envToPass)) {
|
|
266
|
+
envToPass[key] = value;
|
|
257
267
|
}
|
|
258
268
|
}
|
|
259
269
|
|
|
260
|
-
|
|
261
|
-
|
|
270
|
+
return envToPass;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
_warnMissingProviderCredentials(providerName, mountedHosts, config, containerHome) {
|
|
274
|
+
if (providerName === 'claude') {
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
262
277
|
|
|
278
|
+
const provider = getProvider(providerName);
|
|
279
|
+
const credentialPaths = provider.getCredentialPaths ? provider.getCredentialPaths() : [];
|
|
280
|
+
const expandedCreds = credentialPaths.map((cred) => expandHomePath(cred));
|
|
281
|
+
const hasCredentialMount = mountedHosts.some((hostPath) =>
|
|
282
|
+
expandedCreds.some(
|
|
283
|
+
(credPath) => pathContains(hostPath, credPath) || pathContains(credPath, hostPath)
|
|
284
|
+
)
|
|
285
|
+
);
|
|
286
|
+
|
|
287
|
+
if (!hasCredentialMount && expandedCreds.length > 0) {
|
|
288
|
+
const exampleHost = credentialPaths[0];
|
|
289
|
+
const exampleContainer = exampleHost.replace(/^~(?=\/|$)/, containerHome);
|
|
290
|
+
const mountNote = config.noMounts ? 'Credential mounts are disabled. ' : '';
|
|
291
|
+
console.warn(
|
|
292
|
+
`[IsolationManager] ⚠️ ${mountNote}No credential mounts found for ${provider.displayName}. ` +
|
|
293
|
+
`Add one with --mount ${exampleHost}:${exampleContainer}:ro`
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
_spawnContainer(clusterId, args, workDir) {
|
|
263
299
|
return new Promise((resolve, reject) => {
|
|
264
300
|
const proc = spawn('docker', args, { stdio: ['pipe', 'pipe', 'pipe'] });
|
|
265
301
|
|
|
@@ -274,29 +310,26 @@ class IsolationManager {
|
|
|
274
310
|
});
|
|
275
311
|
|
|
276
312
|
proc.on('close', async (code) => {
|
|
277
|
-
if (code
|
|
278
|
-
const containerId = stdout.trim().substring(0, 12);
|
|
279
|
-
this.containers.set(clusterId, containerId);
|
|
280
|
-
|
|
281
|
-
// Install dependencies if package.json exists
|
|
282
|
-
// This enables e2e tests and other npm-based tools to run
|
|
283
|
-
// OPTIMIZATION: Use pre-baked deps when possible (30-40% faster startup)
|
|
284
|
-
// See: GitHub issue #20
|
|
285
|
-
try {
|
|
286
|
-
console.log(`[IsolationManager] Checking for package.json in ${workDir}...`);
|
|
287
|
-
if (fs.existsSync(path.join(workDir, 'package.json'))) {
|
|
288
|
-
await this._installDependenciesWithRetry(clusterId);
|
|
289
|
-
}
|
|
290
|
-
} catch (err) {
|
|
291
|
-
console.warn(
|
|
292
|
-
`[IsolationManager] ⚠️ Failed to install dependencies (non-fatal): ${err.message}`
|
|
293
|
-
);
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
resolve(containerId);
|
|
297
|
-
} else {
|
|
313
|
+
if (code !== 0) {
|
|
298
314
|
reject(new Error(`Failed to create container: ${stderr}`));
|
|
315
|
+
return;
|
|
299
316
|
}
|
|
317
|
+
|
|
318
|
+
const containerId = stdout.trim().substring(0, 12);
|
|
319
|
+
this.containers.set(clusterId, containerId);
|
|
320
|
+
|
|
321
|
+
try {
|
|
322
|
+
console.log(`[IsolationManager] Checking for package.json in ${workDir}...`);
|
|
323
|
+
if (fs.existsSync(path.join(workDir, 'package.json'))) {
|
|
324
|
+
await this._installDependenciesWithRetry(clusterId);
|
|
325
|
+
}
|
|
326
|
+
} catch (err) {
|
|
327
|
+
console.warn(
|
|
328
|
+
`[IsolationManager] ⚠️ Failed to install dependencies (non-fatal): ${err.message}`
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
resolve(containerId);
|
|
300
333
|
});
|
|
301
334
|
|
|
302
335
|
proc.on('error', (err) => {
|
|
@@ -387,6 +420,7 @@ class IsolationManager {
|
|
|
387
420
|
* @param {object} [options] - Exec options
|
|
388
421
|
* @param {boolean} [options.interactive] - Use -it flags
|
|
389
422
|
* @param {object} [options.env] - Environment variables
|
|
423
|
+
* @param {number} [options.timeout=30000] - Timeout in ms (0 = no timeout). Prevents infinite hangs.
|
|
390
424
|
* @returns {Promise<{stdout: string, stderr: string, code: number}>}
|
|
391
425
|
*/
|
|
392
426
|
execInContainer(clusterId, command, options = {}) {
|
|
@@ -410,6 +444,9 @@ class IsolationManager {
|
|
|
410
444
|
|
|
411
445
|
args.push(containerId, ...command);
|
|
412
446
|
|
|
447
|
+
// Default timeout: 30 seconds (prevents infinite hangs)
|
|
448
|
+
const timeout = options.timeout ?? 30000;
|
|
449
|
+
|
|
413
450
|
return new Promise((resolve, reject) => {
|
|
414
451
|
const proc = spawn('docker', args, {
|
|
415
452
|
stdio: options.interactive ? 'inherit' : ['pipe', 'pipe', 'pipe'],
|
|
@@ -417,6 +454,16 @@ class IsolationManager {
|
|
|
417
454
|
|
|
418
455
|
let stdout = '';
|
|
419
456
|
let stderr = '';
|
|
457
|
+
let timedOut = false;
|
|
458
|
+
let timeoutId = null;
|
|
459
|
+
|
|
460
|
+
// Set up timeout if specified (0 = no timeout)
|
|
461
|
+
if (timeout > 0) {
|
|
462
|
+
timeoutId = setTimeout(() => {
|
|
463
|
+
timedOut = true;
|
|
464
|
+
proc.kill('SIGKILL');
|
|
465
|
+
}, timeout);
|
|
466
|
+
}
|
|
420
467
|
|
|
421
468
|
if (!options.interactive) {
|
|
422
469
|
proc.stdout.on('data', (data) => {
|
|
@@ -428,10 +475,16 @@ class IsolationManager {
|
|
|
428
475
|
}
|
|
429
476
|
|
|
430
477
|
proc.on('close', (code) => {
|
|
431
|
-
|
|
478
|
+
if (timeoutId) clearTimeout(timeoutId);
|
|
479
|
+
if (timedOut) {
|
|
480
|
+
reject(new Error(`Docker exec timed out after ${timeout}ms`));
|
|
481
|
+
} else {
|
|
482
|
+
resolve({ stdout, stderr, code });
|
|
483
|
+
}
|
|
432
484
|
});
|
|
433
485
|
|
|
434
486
|
proc.on('error', (err) => {
|
|
487
|
+
if (timeoutId) clearTimeout(timeoutId);
|
|
435
488
|
reject(new Error(`Docker exec error: ${err.message}`));
|
|
436
489
|
});
|
|
437
490
|
});
|
|
@@ -690,58 +743,80 @@ class IsolationManager {
|
|
|
690
743
|
const files = [];
|
|
691
744
|
const directories = new Set();
|
|
692
745
|
|
|
693
|
-
const
|
|
694
|
-
|
|
746
|
+
const shouldIgnoreFsError = (err) =>
|
|
747
|
+
err.code === 'EACCES' || err.code === 'EPERM' || err.code === 'ENOENT';
|
|
748
|
+
|
|
749
|
+
const shouldExcludeEntry = (entryName) => {
|
|
750
|
+
return exclude.some((pattern) => {
|
|
751
|
+
if (pattern.startsWith('*.')) {
|
|
752
|
+
return entryName.endsWith(pattern.slice(1));
|
|
753
|
+
}
|
|
754
|
+
return entryName === pattern;
|
|
755
|
+
});
|
|
756
|
+
};
|
|
757
|
+
|
|
758
|
+
const ensureParentDirTracked = (relativePath) => {
|
|
759
|
+
if (relativePath) {
|
|
760
|
+
directories.add(relativePath);
|
|
761
|
+
}
|
|
762
|
+
};
|
|
763
|
+
|
|
764
|
+
const readEntries = (currentSrc) => {
|
|
695
765
|
try {
|
|
696
|
-
|
|
766
|
+
return fs.readdirSync(currentSrc, { withFileTypes: true });
|
|
697
767
|
} catch (err) {
|
|
698
|
-
if (err
|
|
699
|
-
return;
|
|
768
|
+
if (shouldIgnoreFsError(err)) {
|
|
769
|
+
return [];
|
|
700
770
|
}
|
|
701
771
|
throw err;
|
|
702
772
|
}
|
|
773
|
+
};
|
|
774
|
+
|
|
775
|
+
function handleEntry(entry, srcPath, relPath, relativePath) {
|
|
776
|
+
if (entry.isSymbolicLink()) {
|
|
777
|
+
const targetStats = fs.statSync(srcPath);
|
|
778
|
+
if (targetStats.isDirectory()) {
|
|
779
|
+
directories.add(relPath);
|
|
780
|
+
collectFiles(srcPath, relPath);
|
|
781
|
+
return;
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
files.push(relPath);
|
|
785
|
+
ensureParentDirTracked(relativePath);
|
|
786
|
+
return;
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
if (entry.isDirectory()) {
|
|
790
|
+
directories.add(relPath);
|
|
791
|
+
collectFiles(srcPath, relPath);
|
|
792
|
+
return;
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
files.push(relPath);
|
|
796
|
+
ensureParentDirTracked(relativePath);
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
function collectFiles(currentSrc, relativePath = '') {
|
|
800
|
+
const entries = readEntries(currentSrc);
|
|
703
801
|
|
|
704
802
|
for (const entry of entries) {
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
return entry.name.endsWith(pattern.slice(1));
|
|
709
|
-
}
|
|
710
|
-
return entry.name === pattern;
|
|
711
|
-
});
|
|
712
|
-
if (shouldExclude) continue;
|
|
803
|
+
if (shouldExcludeEntry(entry.name)) {
|
|
804
|
+
continue;
|
|
805
|
+
}
|
|
713
806
|
|
|
714
807
|
const srcPath = path.join(currentSrc, entry.name);
|
|
715
808
|
const relPath = relativePath ? path.join(relativePath, entry.name) : entry.name;
|
|
716
809
|
|
|
717
810
|
try {
|
|
718
|
-
|
|
719
|
-
if (entry.isSymbolicLink()) {
|
|
720
|
-
const targetStats = fs.statSync(srcPath);
|
|
721
|
-
if (targetStats.isDirectory()) {
|
|
722
|
-
directories.add(relPath);
|
|
723
|
-
collectFiles(srcPath, relPath);
|
|
724
|
-
} else {
|
|
725
|
-
files.push(relPath);
|
|
726
|
-
// Ensure parent directory is tracked
|
|
727
|
-
if (relativePath) directories.add(relativePath);
|
|
728
|
-
}
|
|
729
|
-
} else if (entry.isDirectory()) {
|
|
730
|
-
directories.add(relPath);
|
|
731
|
-
collectFiles(srcPath, relPath);
|
|
732
|
-
} else {
|
|
733
|
-
files.push(relPath);
|
|
734
|
-
// Ensure parent directory is tracked
|
|
735
|
-
if (relativePath) directories.add(relativePath);
|
|
736
|
-
}
|
|
811
|
+
handleEntry(entry, srcPath, relPath, relativePath);
|
|
737
812
|
} catch (err) {
|
|
738
|
-
if (err
|
|
813
|
+
if (shouldIgnoreFsError(err)) {
|
|
739
814
|
continue;
|
|
740
815
|
}
|
|
741
816
|
throw err;
|
|
742
817
|
}
|
|
743
818
|
}
|
|
744
|
-
}
|
|
819
|
+
}
|
|
745
820
|
|
|
746
821
|
collectFiles(src);
|
|
747
822
|
|
|
@@ -948,33 +1023,48 @@ class IsolationManager {
|
|
|
948
1023
|
_preserveTerraformState(clusterId, isolatedPath) {
|
|
949
1024
|
const stateFiles = ['terraform.tfstate', 'terraform.tfstate.backup', 'tfplan'];
|
|
950
1025
|
const checkDirs = [isolatedPath, path.join(isolatedPath, 'terraform')];
|
|
1026
|
+
const stateDir = path.join(os.homedir(), '.zeroshot', 'terraform-state', clusterId);
|
|
1027
|
+
|
|
1028
|
+
const hasStateFiles = (checkDir) => {
|
|
1029
|
+
if (!fs.existsSync(checkDir)) {
|
|
1030
|
+
return false;
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
return stateFiles.some((file) => fs.existsSync(path.join(checkDir, file)));
|
|
1034
|
+
};
|
|
1035
|
+
|
|
1036
|
+
const copyStateFiles = (checkDir) => {
|
|
1037
|
+
let copied = false;
|
|
1038
|
+
|
|
1039
|
+
for (const file of stateFiles) {
|
|
1040
|
+
const srcPath = path.join(checkDir, file);
|
|
1041
|
+
if (!fs.existsSync(srcPath)) {
|
|
1042
|
+
continue;
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
const destPath = path.join(stateDir, file);
|
|
1046
|
+
try {
|
|
1047
|
+
fs.copyFileSync(srcPath, destPath);
|
|
1048
|
+
console.log(`[IsolationManager] Preserved Terraform state: ${file} → ${stateDir}`);
|
|
1049
|
+
copied = true;
|
|
1050
|
+
} catch (err) {
|
|
1051
|
+
console.warn(`[IsolationManager] Failed to preserve ${file}: ${err.message}`);
|
|
1052
|
+
}
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
return copied;
|
|
1056
|
+
};
|
|
951
1057
|
|
|
952
1058
|
let foundState = false;
|
|
953
1059
|
|
|
954
1060
|
for (const checkDir of checkDirs) {
|
|
955
|
-
if (!
|
|
956
|
-
|
|
957
|
-
const hasStateFiles = stateFiles.some((file) => fs.existsSync(path.join(checkDir, file)));
|
|
958
|
-
|
|
959
|
-
if (hasStateFiles) {
|
|
960
|
-
const stateDir = path.join(os.homedir(), '.zeroshot', 'terraform-state', clusterId);
|
|
961
|
-
fs.mkdirSync(stateDir, { recursive: true });
|
|
962
|
-
|
|
963
|
-
for (const file of stateFiles) {
|
|
964
|
-
const srcPath = path.join(checkDir, file);
|
|
965
|
-
if (fs.existsSync(srcPath)) {
|
|
966
|
-
const destPath = path.join(stateDir, file);
|
|
967
|
-
try {
|
|
968
|
-
fs.copyFileSync(srcPath, destPath);
|
|
969
|
-
console.log(`[IsolationManager] Preserved Terraform state: ${file} → ${stateDir}`);
|
|
970
|
-
foundState = true;
|
|
971
|
-
} catch (err) {
|
|
972
|
-
console.warn(`[IsolationManager] Failed to preserve ${file}: ${err.message}`);
|
|
973
|
-
}
|
|
974
|
-
}
|
|
975
|
-
}
|
|
976
|
-
break; // Only backup from first dir with state files
|
|
1061
|
+
if (!hasStateFiles(checkDir)) {
|
|
1062
|
+
continue;
|
|
977
1063
|
}
|
|
1064
|
+
|
|
1065
|
+
fs.mkdirSync(stateDir, { recursive: true });
|
|
1066
|
+
foundState = copyStateFiles(checkDir);
|
|
1067
|
+
break;
|
|
978
1068
|
}
|
|
979
1069
|
|
|
980
1070
|
if (!foundState) {
|