create-claude-workspace 1.1.49 → 1.1.51
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/dist/scripts/autonomous.mjs +2 -1
- package/dist/scripts/docker-run.mjs +1 -1
- package/dist/scripts/lib/claude-runner.mjs +1 -1
- package/dist/scripts/lib/claude-runner.spec.js +109 -1
- package/dist/template/.claude/docker/Dockerfile +17 -2
- package/dist/template/.claude/docker/docker-compose.yml +2 -0
- package/dist/template/.claude/docker/docker-entrypoint.sh +3 -0
- package/package.json +1 -1
|
@@ -216,9 +216,10 @@ function setupSignals(opts, log, checkpoint) {
|
|
|
216
216
|
finalCleanup(opts, checkpoint, log);
|
|
217
217
|
process.exit(1);
|
|
218
218
|
}
|
|
219
|
-
log.info(`${label}.
|
|
219
|
+
log.info(`${label}. Killing child process and stopping (Ctrl+C again to force exit)...`);
|
|
220
220
|
stopping = true;
|
|
221
221
|
stoppingRef.value = true;
|
|
222
|
+
currentChild?.kill();
|
|
222
223
|
};
|
|
223
224
|
process.on('SIGINT', () => gracefulStop('SIGINT'));
|
|
224
225
|
process.on('SIGTERM', () => gracefulStop('SIGTERM'));
|
|
@@ -340,7 +340,7 @@ function main() {
|
|
|
340
340
|
info('Autonomous mode — isolated in Docker, --skip-permissions is safe.');
|
|
341
341
|
info('Press Ctrl+C to stop after current iteration.');
|
|
342
342
|
console.log('');
|
|
343
|
-
compose(['run', '--rm', '-T', 'claude', '-c', `npx create-claude-workspace run ${escaped.join(' ')}`]);
|
|
343
|
+
compose(['run', '--rm', '-T', 'claude', '-c', `exec npx create-claude-workspace run ${escaped.join(' ')}`]);
|
|
344
344
|
}
|
|
345
345
|
}
|
|
346
346
|
export { main as runDockerLoop, parseArgs, printHelp };
|
|
@@ -144,7 +144,7 @@ function parseStreamEvent(json) {
|
|
|
144
144
|
}
|
|
145
145
|
}
|
|
146
146
|
// ─── Process tree kill (cross-platform) ───
|
|
147
|
-
function killProcessTree(pid, isWin) {
|
|
147
|
+
export function killProcessTree(pid, isWin) {
|
|
148
148
|
if (isWin) {
|
|
149
149
|
try {
|
|
150
150
|
execSync(`taskkill /PID ${pid} /T /F`, { stdio: 'ignore' });
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
|
-
import { resetStreamState, validateResult, formatStreamEvent } from './claude-runner.mjs';
|
|
2
|
+
import { resetStreamState, validateResult, formatStreamEvent, killProcessTree } from './claude-runner.mjs';
|
|
3
|
+
import { spawn, execSync } from 'node:child_process';
|
|
3
4
|
describe('validateResult', () => {
|
|
4
5
|
it('returns valid result for complete object', () => {
|
|
5
6
|
const input = { status: 'completed', action: 'commit', message: 'Done task #1' };
|
|
@@ -236,3 +237,110 @@ describe('resetStreamState', () => {
|
|
|
236
237
|
resetStreamState(); // should not throw
|
|
237
238
|
});
|
|
238
239
|
});
|
|
240
|
+
// ─── killProcessTree integration tests ───
|
|
241
|
+
// Verifies that Ctrl+C (killProcessTree) actually kills child processes.
|
|
242
|
+
const isWin = process.platform === 'win32';
|
|
243
|
+
function isProcessAlive(pid) {
|
|
244
|
+
try {
|
|
245
|
+
process.kill(pid, 0); // signal 0 = existence check
|
|
246
|
+
return true;
|
|
247
|
+
}
|
|
248
|
+
catch {
|
|
249
|
+
return false;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
function spawnLongRunning() {
|
|
253
|
+
const script = 'setTimeout(()=>{},60000)';
|
|
254
|
+
if (isWin) {
|
|
255
|
+
return spawn(process.env.ComSpec ?? 'cmd.exe', ['/c', 'node', '-e', script], {
|
|
256
|
+
stdio: 'ignore',
|
|
257
|
+
detached: true,
|
|
258
|
+
windowsHide: true,
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
return spawn('node', ['-e', script], {
|
|
262
|
+
stdio: 'ignore',
|
|
263
|
+
detached: true,
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
/** Spawn a nested process tree: parent node → child node (simulates cmd.exe → claude) */
|
|
267
|
+
function spawnNestedTree() {
|
|
268
|
+
// Parent spawns a child that also sleeps — simulates cmd.exe → claude process tree
|
|
269
|
+
const innerScript = `
|
|
270
|
+
const { spawn } = require('child_process');
|
|
271
|
+
const child = spawn('node', ['-e', 'setTimeout(()=>{},60000)'], { stdio: 'ignore' });
|
|
272
|
+
child.unref();
|
|
273
|
+
setTimeout(()=>{},60000);
|
|
274
|
+
`.replace(/\n/g, ' ');
|
|
275
|
+
if (isWin) {
|
|
276
|
+
return spawn(process.env.ComSpec ?? 'cmd.exe', ['/c', 'node', '-e', innerScript], {
|
|
277
|
+
stdio: 'ignore',
|
|
278
|
+
detached: true,
|
|
279
|
+
windowsHide: true,
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
return spawn('node', ['-e', innerScript], {
|
|
283
|
+
stdio: 'ignore',
|
|
284
|
+
detached: true,
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
describe('killProcessTree', () => {
|
|
288
|
+
it('kills a simple child process', async () => {
|
|
289
|
+
const child = spawnLongRunning();
|
|
290
|
+
const pid = child.pid;
|
|
291
|
+
expect(pid).toBeGreaterThan(0);
|
|
292
|
+
// Give process time to start
|
|
293
|
+
await new Promise(r => setTimeout(r, 500));
|
|
294
|
+
expect(isProcessAlive(pid)).toBe(true);
|
|
295
|
+
// Kill it
|
|
296
|
+
killProcessTree(pid, isWin);
|
|
297
|
+
// Wait for process to die
|
|
298
|
+
await new Promise(r => setTimeout(r, isWin ? 2000 : 1000));
|
|
299
|
+
expect(isProcessAlive(pid)).toBe(false);
|
|
300
|
+
}, 10_000);
|
|
301
|
+
it('kills a nested process tree (simulates cmd.exe → claude)', async () => {
|
|
302
|
+
const parent = spawnNestedTree();
|
|
303
|
+
const parentPid = parent.pid;
|
|
304
|
+
expect(parentPid).toBeGreaterThan(0);
|
|
305
|
+
// Give tree time to start
|
|
306
|
+
await new Promise(r => setTimeout(r, 1500));
|
|
307
|
+
expect(isProcessAlive(parentPid)).toBe(true);
|
|
308
|
+
// Kill the tree
|
|
309
|
+
killProcessTree(parentPid, isWin);
|
|
310
|
+
// Wait for tree to die
|
|
311
|
+
await new Promise(r => setTimeout(r, isWin ? 3000 : 2000));
|
|
312
|
+
expect(isProcessAlive(parentPid)).toBe(false);
|
|
313
|
+
// On Windows, verify no orphan node.exe children from our tree
|
|
314
|
+
if (isWin) {
|
|
315
|
+
// tasklist can't reliably check by parent PID after parent is dead,
|
|
316
|
+
// but the /T flag in taskkill should have killed all children
|
|
317
|
+
try {
|
|
318
|
+
const taskInfo = execSync(`tasklist /FI "PID eq ${parentPid}" /NH`, {
|
|
319
|
+
encoding: 'utf-8', stdio: 'pipe', timeout: 3000,
|
|
320
|
+
});
|
|
321
|
+
expect(taskInfo).not.toContain(String(parentPid));
|
|
322
|
+
}
|
|
323
|
+
catch { /* process dead, expected */ }
|
|
324
|
+
}
|
|
325
|
+
}, 15_000);
|
|
326
|
+
it('does not throw when killing already-dead process', () => {
|
|
327
|
+
// PID 999999 is almost certainly not a real process
|
|
328
|
+
expect(() => killProcessTree(999999, isWin)).not.toThrow();
|
|
329
|
+
});
|
|
330
|
+
it('kills process fast enough for interactive use (< 3s)', async () => {
|
|
331
|
+
const child = spawnLongRunning();
|
|
332
|
+
const pid = child.pid;
|
|
333
|
+
await new Promise(r => setTimeout(r, 500));
|
|
334
|
+
const start = Date.now();
|
|
335
|
+
killProcessTree(pid, isWin);
|
|
336
|
+
// Poll until dead or timeout
|
|
337
|
+
let alive = true;
|
|
338
|
+
while (alive && Date.now() - start < 3000) {
|
|
339
|
+
await new Promise(r => setTimeout(r, 100));
|
|
340
|
+
alive = isProcessAlive(pid);
|
|
341
|
+
}
|
|
342
|
+
const elapsed = Date.now() - start;
|
|
343
|
+
expect(alive).toBe(false);
|
|
344
|
+
expect(elapsed).toBeLessThan(3000);
|
|
345
|
+
}, 10_000);
|
|
346
|
+
});
|
|
@@ -6,14 +6,29 @@ RUN apt-get update && \
|
|
|
6
6
|
|
|
7
7
|
|
|
8
8
|
# Non-root user — Claude Code refuses --dangerously-skip-permissions as root
|
|
9
|
-
|
|
10
|
-
RUN apt-get update && apt-get install -y --no-install-recommends sudo && \
|
|
9
|
+
RUN apt-get update && apt-get install -y --no-install-recommends sudo gpg && \
|
|
11
10
|
apt-get clean && rm -rf /var/lib/apt/lists/* && \
|
|
12
11
|
useradd -m -s /bin/bash claude && \
|
|
13
12
|
echo 'claude ALL=(ALL) NOPASSWD:ALL' > /etc/sudoers.d/claude && \
|
|
14
13
|
mkdir -p /home/claude/.claude && \
|
|
15
14
|
chown -R claude:claude /home/claude
|
|
16
15
|
|
|
16
|
+
# Install gh (GitHub CLI) from official apt repo
|
|
17
|
+
RUN curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \
|
|
18
|
+
| gpg --dearmor -o /usr/share/keyrings/githubcli-archive-keyring.gpg && \
|
|
19
|
+
echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \
|
|
20
|
+
> /etc/apt/sources.list.d/github-cli.list && \
|
|
21
|
+
apt-get update && apt-get install -y --no-install-recommends gh && \
|
|
22
|
+
apt-get clean && rm -rf /var/lib/apt/lists/*
|
|
23
|
+
|
|
24
|
+
# Install glab (GitLab CLI) — latest .deb from GitLab releases
|
|
25
|
+
RUN GLAB_VER=$(curl -fsSL "https://gitlab.com/api/v4/projects/34675721/releases/permalink/latest" \
|
|
26
|
+
| node -pe 'JSON.parse(require("fs").readFileSync("/dev/stdin","utf8")).tag_name.slice(1)') && \
|
|
27
|
+
DEB_ARCH=$(dpkg --print-architecture) && \
|
|
28
|
+
curl -fsSL "https://gitlab.com/gitlab-org/cli/-/releases/v${GLAB_VER}/downloads/glab_${GLAB_VER}_linux_${DEB_ARCH}.deb" \
|
|
29
|
+
-o /tmp/glab.deb && \
|
|
30
|
+
dpkg -i /tmp/glab.deb && rm /tmp/glab.deb
|
|
31
|
+
|
|
17
32
|
# Install Claude Code natively as the claude user
|
|
18
33
|
RUN gosu claude bash -c 'curl -fsSL https://claude.ai/install.sh | bash'
|
|
19
34
|
ENV PATH="/home/claude/.local/bin:$PATH"
|
|
@@ -36,6 +36,9 @@ if [[ -n "${GITLAB_TOKEN:-}" ]]; then
|
|
|
36
36
|
git config --global credential.helper store
|
|
37
37
|
echo "https://oauth2:${GITLAB_TOKEN}@gitlab.com" >> /home/claude/.git-credentials
|
|
38
38
|
chown claude:claude /home/claude/.git-credentials
|
|
39
|
+
# Configure glab auth (glab reads GITLAB_TOKEN env var automatically,
|
|
40
|
+
# but explicit login ensures 'glab auth status' reports correctly)
|
|
41
|
+
gosu claude glab auth login --hostname gitlab.com --token "$GITLAB_TOKEN" 2>/dev/null || true
|
|
39
42
|
fi
|
|
40
43
|
|
|
41
44
|
# Auth pre-check — fail fast if no credentials available
|