start-command 0.35.4 → 0.37.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/CHANGELOG.md +14 -0
- package/README.md +16 -0
- package/package.json +1 -1
- package/src/bin/cli.js +48 -7
- package/src/lib/args-parser-queries.js +14 -0
- package/src/lib/args-parser.js +32 -1
- package/src/lib/atomic-write.js +34 -0
- package/src/lib/attached-diagnostics.js +90 -0
- package/src/lib/cgroup-memory.js +43 -11
- package/src/lib/command-builder.js +43 -0
- package/src/lib/cpu-penalty-monitor.js +226 -0
- package/src/lib/cpu-penalty.js +175 -0
- package/src/lib/detached-finalize.js +16 -0
- package/src/lib/docker-cleanup.js +35 -7
- package/src/lib/docker-command-handoff.js +75 -0
- package/src/lib/docker-labels.js +33 -0
- package/src/lib/docker-resource-limits.js +28 -0
- package/src/lib/docker-resource-options.js +180 -0
- package/src/lib/docker-runtime-args.js +23 -0
- package/src/lib/docker-snapshot-safety.js +164 -0
- package/src/lib/execution-recovery.js +153 -30
- package/src/lib/execution-resume-all.js +11 -0
- package/src/lib/execution-resume.js +314 -54
- package/src/lib/execution-store.js +25 -121
- package/src/lib/exit-evidence.js +123 -0
- package/src/lib/exit-reason.js +24 -6
- package/src/lib/failure-handler.js +15 -5
- package/src/lib/isolation.js +48 -7
- package/src/lib/launch-owner.js +25 -0
- package/src/lib/launch-persistence.js +54 -0
- package/src/lib/log-sanitizer.js +267 -0
- package/src/lib/log-uploader.js +38 -19
- package/src/lib/query-commands.js +18 -6
- package/src/lib/resume-resources.js +88 -0
- package/src/lib/shell-utils.js +67 -17
- package/src/lib/status-formatter.js +28 -0
- package/src/lib/store-lock.js +133 -0
- package/src/lib/usage.js +16 -1
- package/test/ci-workflow-invariants.js +15 -14
- package/test/docker-command-handoff.js +107 -0
- package/test/docker-runtime-options.js +10 -0
- package/test/docker-snapshot-safety.js +106 -0
- package/test/execution-resume.js +19 -11
- package/test/exit-reason.js +6 -1
- package/test/failure-handler.js +11 -0
- package/test/isolation.js +5 -4
- package/test/javascript-first-ci.js +216 -0
- package/test/log-sanitizer.js +306 -0
- package/test/regression-174.js +3 -1
- package/test/regression-176.js +7 -0
- package/test/regression-180.js +17 -10
- package/test/regression-182.js +7 -3
- package/test/regression-185.js +4 -9
- package/test/regression-187.js +11 -2
- package/test/regression-189.js +224 -0
- package/test/regression-190.js +111 -0
- package/test/regression-191.js +160 -0
- package/test/regression-192.js +66 -0
- package/test/regression-193.js +251 -0
- package/test/regression-194.js +182 -0
- package/test/regression-198.js +281 -0
- package/test/regression-199.js +79 -0
- package/test/regression-201.js +25 -0
- package/test/regression-202.js +92 -0
- package/test/regression-91.js +23 -20
- package/test/shared-golden.js +54 -0
- package/test/status-query.js +97 -15
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/** Populate before publishing: a failed write must never poison the shared lock. */
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
const os = require('os');
|
|
4
|
+
const crypto = require('crypto');
|
|
5
|
+
const LOCK_TIMEOUT_MS = 30000;
|
|
6
|
+
const LOCK_STALE_MS = 60000;
|
|
7
|
+
const MALFORMED_LOCK_GRACE_MS = 3000;
|
|
8
|
+
|
|
9
|
+
class LockManager {
|
|
10
|
+
constructor(lockFilePath, filesystem = fs) {
|
|
11
|
+
this.lockFilePath = lockFilePath;
|
|
12
|
+
this.fs = filesystem;
|
|
13
|
+
this.lockAcquired = false;
|
|
14
|
+
this.identity = null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
acquire(timeout = LOCK_TIMEOUT_MS) {
|
|
18
|
+
const started = Date.now();
|
|
19
|
+
const debug = (message) => {
|
|
20
|
+
if (process.env.START_DEBUG === '1') {
|
|
21
|
+
console.error(`[DEBUG] Store lock ${this.lockFilePath}: ${message}`);
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
while (Date.now() - started < timeout) {
|
|
25
|
+
const temp = `${this.lockFilePath}.${process.pid}.${crypto.randomUUID()}`;
|
|
26
|
+
try {
|
|
27
|
+
try {
|
|
28
|
+
const stat = this.fs.statSync(this.lockFilePath);
|
|
29
|
+
if (this.isLockStale(this.readLockFile(), stat)) {
|
|
30
|
+
const current = this.fs.statSync(this.lockFilePath);
|
|
31
|
+
if (current.ino === stat.ino && current.mtimeMs === stat.mtimeMs) {
|
|
32
|
+
debug('reclaiming stale or malformed lock');
|
|
33
|
+
this.fs.unlinkSync(this.lockFilePath);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
} catch (error) {
|
|
37
|
+
if (error.code !== 'ENOENT') {
|
|
38
|
+
throw error;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
this.fs.writeFileSync(
|
|
42
|
+
temp,
|
|
43
|
+
JSON.stringify({
|
|
44
|
+
pid: process.pid,
|
|
45
|
+
timestamp: Date.now(),
|
|
46
|
+
hostname: os.hostname(),
|
|
47
|
+
}),
|
|
48
|
+
{ flag: 'wx' }
|
|
49
|
+
);
|
|
50
|
+
const handle = this.fs.openSync(temp, 'r+');
|
|
51
|
+
try {
|
|
52
|
+
this.fs.fsyncSync(handle);
|
|
53
|
+
} finally {
|
|
54
|
+
this.fs.closeSync(handle);
|
|
55
|
+
}
|
|
56
|
+
const identity = this.fs.statSync(temp);
|
|
57
|
+
this.fs.linkSync(temp, this.lockFilePath);
|
|
58
|
+
this.identity = identity;
|
|
59
|
+
this.lockAcquired = true;
|
|
60
|
+
debug(`acquired after ${Date.now() - started}ms`);
|
|
61
|
+
return true;
|
|
62
|
+
} catch (error) {
|
|
63
|
+
if (error.code !== 'EEXIST') {
|
|
64
|
+
throw error;
|
|
65
|
+
}
|
|
66
|
+
this.sleep(100);
|
|
67
|
+
} finally {
|
|
68
|
+
try {
|
|
69
|
+
this.fs.unlinkSync(temp);
|
|
70
|
+
} catch {
|
|
71
|
+
/* May not have been created. */
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
debug('acquisition timed out');
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
release() {
|
|
80
|
+
if (!this.lockAcquired) {
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
try {
|
|
84
|
+
const current = this.fs.statSync(this.lockFilePath);
|
|
85
|
+
if (
|
|
86
|
+
current.dev === this.identity.dev &&
|
|
87
|
+
current.ino === this.identity.ino
|
|
88
|
+
) {
|
|
89
|
+
this.fs.unlinkSync(this.lockFilePath);
|
|
90
|
+
}
|
|
91
|
+
} catch {
|
|
92
|
+
/* The lock may already be gone. */
|
|
93
|
+
}
|
|
94
|
+
this.lockAcquired = false;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
readLockFile() {
|
|
98
|
+
try {
|
|
99
|
+
return JSON.parse(this.fs.readFileSync(this.lockFilePath, 'utf8'));
|
|
100
|
+
} catch {
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
isLockStale(data, stat) {
|
|
106
|
+
if (
|
|
107
|
+
!data ||
|
|
108
|
+
!Number.isFinite(data.timestamp) ||
|
|
109
|
+
!Number.isInteger(data.pid) ||
|
|
110
|
+
data.pid <= 0 ||
|
|
111
|
+
data.pid > 2147483647
|
|
112
|
+
) {
|
|
113
|
+
return Boolean(
|
|
114
|
+
stat && Date.now() - stat.mtimeMs >= MALFORMED_LOCK_GRACE_MS
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
if (data.hostname === os.hostname()) {
|
|
118
|
+
try {
|
|
119
|
+
process.kill(data.pid, 0);
|
|
120
|
+
return false;
|
|
121
|
+
} catch (error) {
|
|
122
|
+
return error.code === 'ESRCH';
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return Date.now() - data.timestamp > LOCK_STALE_MS;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
sleep(ms) {
|
|
129
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
module.exports = { LockManager, LOCK_TIMEOUT_MS, MALFORMED_LOCK_GRACE_MS };
|
package/src/lib/usage.js
CHANGED
|
@@ -3,6 +3,7 @@ function printUsage() {
|
|
|
3
3
|
console.log(`Usage: $ [options] [--] <command> | $ --status <uuid> [--output-format <fmt>] | $ --list [--running] [--output-format <fmt>] | $ --upload-log <id> | $ --stop <id> | $ --terminate <id> | $ --attach <id> [--read-only] | $ --resume <id> [-- <command>] | $ --resume-all
|
|
4
4
|
|
|
5
5
|
Options:
|
|
6
|
+
--help, -h Show this usage and exit successfully
|
|
6
7
|
--isolated, --isolation, -i <env> Run in isolated environment (screen, tmux, docker, ssh)
|
|
7
8
|
--attached, -a Run in attached mode (foreground)
|
|
8
9
|
--detached, -d Run in detached mode (background)
|
|
@@ -13,12 +14,24 @@ Options:
|
|
|
13
14
|
--volume, -v <spec> Docker bind mount/volume host:container[:mode] (repeatable, docker only)
|
|
14
15
|
--mount <spec> Docker --mount spec (repeatable, docker only)
|
|
15
16
|
--env, -e <KEY=VALUE> Environment variable for docker container (repeatable, docker only)
|
|
17
|
+
--label <KEY=VALUE> Container attribution (repeatable, docker only; start-command.* reserved)
|
|
16
18
|
--privileged Run docker container in privileged mode (docker only)
|
|
17
19
|
--network <name> Connect to a named network (repeatable, docker only)
|
|
18
20
|
--network-alias <alias> Add alias to the first network (repeatable, docker only)
|
|
19
21
|
--on-kill-resume <N> Resume a killed (exit 137 / OOM) detached docker session up to N times
|
|
20
22
|
--recovery-command <cmd> Command to run in the same container on such a resume
|
|
21
23
|
--on-kill-resume-delay <min[-max]> Wait a random number of seconds before each such resume (default 0)
|
|
24
|
+
--memory <size|N%|MIN%-MAX%> Docker RAM cap; percentages use daemon capacity
|
|
25
|
+
--memory-swap <spec> Combined memory+swap cap (defaults to memory, no extra swap)
|
|
26
|
+
--cpus <n|N%|MIN%-MAX%> Docker CPU cap; ranges draw once per creation
|
|
27
|
+
--on-kill-resume-memory <spec> New memory cap before a qualifying OOM recovery
|
|
28
|
+
--cpu-penalty Enable delayed Docker CPU penalty (default off)
|
|
29
|
+
--cpu-penalty-cpus <n> Penalty cap (default 2 CPUs)
|
|
30
|
+
--cpu-penalty-trigger <p%> Busy threshold (default 95% of usable CPUs)
|
|
31
|
+
--cpu-penalty-trigger-window <d> Fully covered busy window (default 15m)
|
|
32
|
+
--cpu-penalty-release <p%> Quiet threshold (default 65% of penalty cap)
|
|
33
|
+
--cpu-penalty-release-window <d> Minimum quiet/capped window (default 15m)
|
|
34
|
+
Durations accept ms, s, m, h; --status reports penalty state
|
|
22
35
|
--endpoint <endpoint> SSH endpoint (required for ssh isolation, e.g., user@host)
|
|
23
36
|
--isolated-user, -u [name] Create isolated user with same permissions
|
|
24
37
|
--keep-user Keep isolated user after command completes
|
|
@@ -32,13 +45,15 @@ Options:
|
|
|
32
45
|
--status <id> Show status of execution by UUID or session name (--output-format: links-notation|json|text)
|
|
33
46
|
--list List all tracked executions (--output-format: links-notation|json|text)
|
|
34
47
|
--running With --list, only report executions that are still running
|
|
35
|
-
--upload-log <id> Upload
|
|
48
|
+
--upload-log <id> Upload a sanitized copy of the stored log privately
|
|
49
|
+
--no-sanitize With --upload-log, explicitly upload without secret redaction
|
|
36
50
|
--stop <id> Ask a detached isolated execution to stop gracefully
|
|
37
51
|
--terminate <id> Terminate a detached isolated execution immediately
|
|
38
52
|
--attach <id> Attach to a running detached isolated execution
|
|
39
53
|
--read-only With --attach, follow output without sending input
|
|
40
54
|
--resume <id> Restart a stopped detached execution in the same environment
|
|
41
55
|
(append -- <command> to run a different command there)
|
|
56
|
+
--remove-original With legacy snapshot resume, remove the stopped original after launch
|
|
42
57
|
--resume-all Re-attach or reconcile every execution still marked running
|
|
43
58
|
--cleanup Clean up stale "executing" records (crashed/killed processes)
|
|
44
59
|
--cleanup-dry-run Show stale records that would be cleaned up (without cleaning)
|
|
@@ -156,6 +156,10 @@ describe('CI workflow invariants', () => {
|
|
|
156
156
|
it('gives every job a timeout', () => {
|
|
157
157
|
for (const name of workflows) {
|
|
158
158
|
for (const job of parseJobs(readWorkflow(name))) {
|
|
159
|
+
if (jobKey(job.body, 'uses')) {
|
|
160
|
+
// Reusable-workflow callers cannot declare a timeout; their jobs do.
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
159
163
|
assert.ok(
|
|
160
164
|
jobKey(job.body, 'timeout-minutes'),
|
|
161
165
|
`${name}: job "${job.name}" has no timeout-minutes`
|
|
@@ -186,7 +190,7 @@ describe('CI workflow invariants', () => {
|
|
|
186
190
|
}
|
|
187
191
|
});
|
|
188
192
|
|
|
189
|
-
it('
|
|
193
|
+
it('protects main and the gated Rust stage from cancellation', () => {
|
|
190
194
|
for (const name of workflows) {
|
|
191
195
|
for (const job of parseJobs(readWorkflow(name))) {
|
|
192
196
|
const group = concurrencyKey(job.body, 'group');
|
|
@@ -212,8 +216,10 @@ describe('CI workflow invariants', () => {
|
|
|
212
216
|
);
|
|
213
217
|
assert.strictEqual(
|
|
214
218
|
cancel,
|
|
215
|
-
'
|
|
216
|
-
|
|
219
|
+
name === 'rust.yml' || job.name === 'rust-stage'
|
|
220
|
+
? 'false'
|
|
221
|
+
: "${{ github.ref != 'refs/heads/main' }}",
|
|
222
|
+
`${name}: checks must preserve main and the gated Rust stage`
|
|
217
223
|
);
|
|
218
224
|
}
|
|
219
225
|
}
|
|
@@ -223,7 +229,7 @@ describe('CI workflow invariants', () => {
|
|
|
223
229
|
it('uses !cancelled() rather than always() outside the status job', () => {
|
|
224
230
|
for (const name of workflows) {
|
|
225
231
|
for (const job of parseJobs(readWorkflow(name))) {
|
|
226
|
-
if (job.name === 'pipeline-status') {
|
|
232
|
+
if (job.name === 'pipeline-status' || job.name === 'rust-stage') {
|
|
227
233
|
continue;
|
|
228
234
|
}
|
|
229
235
|
assert.doesNotMatch(
|
|
@@ -246,7 +252,7 @@ describe('CI workflow invariants', () => {
|
|
|
246
252
|
`${name}: pipeline-status must run even when jobs are cancelled`
|
|
247
253
|
);
|
|
248
254
|
for (const job of jobs) {
|
|
249
|
-
if (job.name === 'pipeline-status') {
|
|
255
|
+
if (job.name === 'pipeline-status' || job.name === 'rust-stage') {
|
|
250
256
|
continue;
|
|
251
257
|
}
|
|
252
258
|
assert.match(
|
|
@@ -395,15 +401,10 @@ describe('CI workflow invariants', () => {
|
|
|
395
401
|
|
|
396
402
|
it('audits both dependency graphs for advisories', () => {
|
|
397
403
|
const security = readWorkflow('security.yml');
|
|
398
|
-
const
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
assert.match(
|
|
403
|
-
security,
|
|
404
|
-
/cargo audit/,
|
|
405
|
-
'cargo-audit job must run cargo audit'
|
|
406
|
-
);
|
|
404
|
+
const rust = readWorkflow('rust.yml');
|
|
405
|
+
assert.ok(parseJobs(security).some((job) => job.name === 'npm-audit'));
|
|
406
|
+
assert.ok(parseJobs(rust).some((job) => job.name === 'cargo-audit'));
|
|
407
|
+
assert.match(rust, /cargo audit/, 'cargo-audit job must run cargo audit');
|
|
407
408
|
assert.match(security, /npm audit/, 'npm-audit job must run npm audit');
|
|
408
409
|
});
|
|
409
410
|
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
const { test } = require('node:test');
|
|
2
|
+
const assert = require('node:assert/strict');
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const os = require('node:os');
|
|
5
|
+
const path = require('node:path');
|
|
6
|
+
const { spawnSync } = require('node:child_process');
|
|
7
|
+
const {
|
|
8
|
+
buildCommandHandoffArgs,
|
|
9
|
+
buildCommandHandoffScript,
|
|
10
|
+
commandHandoffPath,
|
|
11
|
+
writeCommandHandoff,
|
|
12
|
+
} = require('../src/lib/docker-command-handoff');
|
|
13
|
+
const { buildResumePlan, ResumeMode } = require('../src/lib/execution-resume');
|
|
14
|
+
const { SessionState } = require('../src/lib/session-probe');
|
|
15
|
+
|
|
16
|
+
test('new stopped containers choose a copy-free command handoff', () => {
|
|
17
|
+
const plan = buildResumePlan(
|
|
18
|
+
{
|
|
19
|
+
uuid: 'uuid',
|
|
20
|
+
command: 'old-command',
|
|
21
|
+
options: {
|
|
22
|
+
isolated: 'docker',
|
|
23
|
+
isolationMode: 'detached',
|
|
24
|
+
sessionName: 'box',
|
|
25
|
+
commandHandoff: true,
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
'printf new-command',
|
|
29
|
+
{ state: SessionState.STOPPED, alive: false }
|
|
30
|
+
);
|
|
31
|
+
assert.equal(plan.mode, ResumeMode.DOCKER_START);
|
|
32
|
+
assert.equal(plan.handoffCommand, 'printf new-command');
|
|
33
|
+
assert.deepEqual(
|
|
34
|
+
plan.steps.map((step) => step.args),
|
|
35
|
+
[['start', 'box']]
|
|
36
|
+
);
|
|
37
|
+
assert.equal(plan.snapshotImage, undefined);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('handoff preserves argv and accepts a replacement without executing it on the host', () => {
|
|
41
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'handoff-test-'));
|
|
42
|
+
const marker = path.join(dir, 'command');
|
|
43
|
+
try {
|
|
44
|
+
const args = buildCommandHandoffArgs(
|
|
45
|
+
['sh', '-c', 'printf original'],
|
|
46
|
+
'command'
|
|
47
|
+
);
|
|
48
|
+
const shellOptions = { cwd: dir, encoding: 'utf8' };
|
|
49
|
+
assert.equal(
|
|
50
|
+
spawnSync(args[0], args.slice(1), shellOptions).stdout,
|
|
51
|
+
'original'
|
|
52
|
+
);
|
|
53
|
+
let script;
|
|
54
|
+
const copied = writeCommandHandoff(
|
|
55
|
+
'box',
|
|
56
|
+
"printf '%s' 'new $value'",
|
|
57
|
+
(_bin, argv) => {
|
|
58
|
+
assert.equal(argv[0], 'cp');
|
|
59
|
+
assert.equal(argv[2], `box:${commandHandoffPath('box')}`);
|
|
60
|
+
script = fs.readFileSync(argv[1], 'utf8');
|
|
61
|
+
fs.copyFileSync(argv[1], marker);
|
|
62
|
+
fs.accessSync(argv[1], fs.constants.R_OK);
|
|
63
|
+
// Windows chmod supports write permission, not Unix mode bits.
|
|
64
|
+
if (process.platform !== 'win32') {
|
|
65
|
+
assert.equal(fs.statSync(argv[1]).mode & 0o777, 0o644);
|
|
66
|
+
}
|
|
67
|
+
return { success: true };
|
|
68
|
+
},
|
|
69
|
+
{ shell: 'sh' }
|
|
70
|
+
);
|
|
71
|
+
assert.equal(copied.success, true);
|
|
72
|
+
assert.ok(script.includes('new $value'));
|
|
73
|
+
assert.equal(
|
|
74
|
+
spawnSync(args[0], args.slice(1), shellOptions).stdout,
|
|
75
|
+
'new $value'
|
|
76
|
+
);
|
|
77
|
+
// docker start without another replacement runs the current stored command.
|
|
78
|
+
assert.equal(
|
|
79
|
+
spawnSync(args[0], args.slice(1), shellOptions).stdout,
|
|
80
|
+
'new $value'
|
|
81
|
+
);
|
|
82
|
+
} finally {
|
|
83
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test('handoff preserves direct shell positional argv and evaluates compound commands as scripts', () => {
|
|
88
|
+
for (const [command, expected] of [
|
|
89
|
+
['sh -c \'printf "%s:%s" "$0" "$1"\' tag \'two words\'', 'tag:two words'],
|
|
90
|
+
["sh -c 'exit 7'; printf 'after%s' \"$?\"", 'after7'],
|
|
91
|
+
]) {
|
|
92
|
+
const script = buildCommandHandoffScript(command, { shell: 'sh' });
|
|
93
|
+
const result = spawnSync('sh', ['-c', script], { encoding: 'utf8' });
|
|
94
|
+
assert.equal(result.status, 0, result.stderr);
|
|
95
|
+
assert.equal(result.stdout, expected);
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test('auto handoff chooses an installed shell inside the existing container', () => {
|
|
100
|
+
const script = buildCommandHandoffScript('printf auto');
|
|
101
|
+
assert.ok(
|
|
102
|
+
script.includes('command -v bash || command -v zsh || command -v sh')
|
|
103
|
+
);
|
|
104
|
+
const result = spawnSync('sh', ['-c', script], { encoding: 'utf8' });
|
|
105
|
+
assert.equal(result.status, 0, result.stderr);
|
|
106
|
+
assert.equal(result.stdout, 'auto');
|
|
107
|
+
});
|
|
@@ -300,11 +300,16 @@ describe('buildDockerRuntimeMetadata', () => {
|
|
|
300
300
|
volumes: null,
|
|
301
301
|
mounts: null,
|
|
302
302
|
env: null,
|
|
303
|
+
labels: null,
|
|
304
|
+
commandHandoff: null,
|
|
303
305
|
privileged: null,
|
|
304
306
|
network: null,
|
|
305
307
|
networks: null,
|
|
306
308
|
networkAliases: null,
|
|
307
309
|
resourceLimits: null,
|
|
310
|
+
resolvedLimits: null,
|
|
311
|
+
resourceLimitSpecs: null,
|
|
312
|
+
onKillResumeMemory: null,
|
|
308
313
|
onKillResume: null,
|
|
309
314
|
recoveryCommand: null,
|
|
310
315
|
onKillResumeDelay: null,
|
|
@@ -331,6 +336,11 @@ describe('buildDockerRuntimeMetadata', () => {
|
|
|
331
336
|
networks: ['hive-formal-ai', 'public-egress'],
|
|
332
337
|
networkAliases: ['formal-ai', 'checker'],
|
|
333
338
|
resourceLimits: null,
|
|
339
|
+
labels: null,
|
|
340
|
+
commandHandoff: null,
|
|
341
|
+
resolvedLimits: null,
|
|
342
|
+
resourceLimitSpecs: null,
|
|
343
|
+
onKillResumeMemory: null,
|
|
334
344
|
onKillResume: null,
|
|
335
345
|
recoveryCommand: null,
|
|
336
346
|
onKillResumeDelay: null,
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
const { test } = require('node:test');
|
|
2
|
+
const assert = require('node:assert/strict');
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const os = require('node:os');
|
|
5
|
+
const path = require('node:path');
|
|
6
|
+
const { spawnSync } = require('node:child_process');
|
|
7
|
+
const {
|
|
8
|
+
preflightSnapshot,
|
|
9
|
+
acquireSnapshotLock,
|
|
10
|
+
containerdRoot,
|
|
11
|
+
} = require('../src/lib/docker-snapshot-safety');
|
|
12
|
+
|
|
13
|
+
const GiB = 1024 ** 3;
|
|
14
|
+
function runner(size, containerd = false) {
|
|
15
|
+
return (_bin, args) => ({
|
|
16
|
+
success: true,
|
|
17
|
+
stdout:
|
|
18
|
+
args[0] === 'info'
|
|
19
|
+
? JSON.stringify({
|
|
20
|
+
DockerRootDir: '/docker',
|
|
21
|
+
DriverStatus: containerd
|
|
22
|
+
? [['driver-type', 'io.containerd.snapshotter.v1']]
|
|
23
|
+
: [],
|
|
24
|
+
})
|
|
25
|
+
: String(size),
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
test('preflight refuses insufficient space before any snapshot', () => {
|
|
30
|
+
assert.throws(
|
|
31
|
+
() =>
|
|
32
|
+
preflightSnapshot('box', runner(12 * GiB), {
|
|
33
|
+
freeBytes: () => 20 * GiB,
|
|
34
|
+
}),
|
|
35
|
+
/Insufficient disk.*34\.00 GiB/
|
|
36
|
+
);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test('containerd image store checks Docker and containerd filesystems separately', () => {
|
|
40
|
+
const checked = [];
|
|
41
|
+
assert.throws(
|
|
42
|
+
() =>
|
|
43
|
+
preflightSnapshot('box', runner(8 * GiB, true), {
|
|
44
|
+
containerdRoot: '/containerd',
|
|
45
|
+
freeBytes: (root) => {
|
|
46
|
+
checked.push(root);
|
|
47
|
+
return root === '/docker' ? 100 * GiB : 25 * GiB;
|
|
48
|
+
},
|
|
49
|
+
}),
|
|
50
|
+
/\/containerd/
|
|
51
|
+
);
|
|
52
|
+
assert.deepEqual(checked, ['/docker', '/containerd']);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('preflight logs the layer size and rejects unknown sizes or roots', () => {
|
|
56
|
+
assert.match(
|
|
57
|
+
preflightSnapshot('box', runner(GiB), { freeBytes: () => 100 * GiB })
|
|
58
|
+
.message,
|
|
59
|
+
/snapshotting 1\.00 GiB/
|
|
60
|
+
);
|
|
61
|
+
assert.throws(
|
|
62
|
+
() => preflightSnapshot('box', runner('unknown')),
|
|
63
|
+
/writable layer/
|
|
64
|
+
);
|
|
65
|
+
assert.equal(
|
|
66
|
+
containerdRoot('version = 2\nroot = "/custom"\n[plugins]\nroot = "/wrong"'),
|
|
67
|
+
'/custom'
|
|
68
|
+
);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('snapshot lock is separate from store locks and excludes concurrent processes', () => {
|
|
72
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'snapshot-lock-'));
|
|
73
|
+
const lockPath = path.join(dir, 'snapshot.lock');
|
|
74
|
+
try {
|
|
75
|
+
const lock = acquireSnapshotLock({ lockPath });
|
|
76
|
+
assert.throws(
|
|
77
|
+
() => acquireSnapshotLock({ lockPath }),
|
|
78
|
+
/snapshot.*in progress/
|
|
79
|
+
);
|
|
80
|
+
const child = spawnSync(
|
|
81
|
+
process.execPath,
|
|
82
|
+
[
|
|
83
|
+
'-e',
|
|
84
|
+
`
|
|
85
|
+
try {
|
|
86
|
+
require(${JSON.stringify(require.resolve('../src/lib/docker-snapshot-safety'))}).acquireSnapshotLock();
|
|
87
|
+
process.exitCode = 3;
|
|
88
|
+
} catch (error) {
|
|
89
|
+
console.log(error.message);
|
|
90
|
+
process.exitCode = 2;
|
|
91
|
+
}
|
|
92
|
+
`,
|
|
93
|
+
],
|
|
94
|
+
{
|
|
95
|
+
encoding: 'utf8',
|
|
96
|
+
env: { ...process.env, START_SNAPSHOT_LOCK: lockPath },
|
|
97
|
+
}
|
|
98
|
+
);
|
|
99
|
+
assert.equal(child.status, 2, child.stderr);
|
|
100
|
+
assert.match(child.stdout, /snapshot.*in progress/);
|
|
101
|
+
lock.release();
|
|
102
|
+
acquireSnapshotLock({ lockPath }).release();
|
|
103
|
+
} finally {
|
|
104
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
105
|
+
}
|
|
106
|
+
});
|
package/test/execution-resume.js
CHANGED
|
@@ -101,12 +101,8 @@ describe('buildResumePlan', () => {
|
|
|
101
101
|
'--name',
|
|
102
102
|
'box-resume-1',
|
|
103
103
|
]);
|
|
104
|
-
assert.
|
|
105
|
-
|
|
106
|
-
'sh',
|
|
107
|
-
'-c',
|
|
108
|
-
'npm run build',
|
|
109
|
-
]);
|
|
104
|
+
assert.ok(runArgs.includes('start-command-resume/box:1'));
|
|
105
|
+
assert.deepStrictEqual(runArgs.slice(-3), ['sh', '-c', 'npm run build']);
|
|
110
106
|
});
|
|
111
107
|
|
|
112
108
|
test('carries stored docker runtime options into the snapshot run', () => {
|
|
@@ -270,11 +266,14 @@ describe('resumeExecution', () => {
|
|
|
270
266
|
watchers.push([name, policy, logPath]),
|
|
271
267
|
});
|
|
272
268
|
assert.strictEqual(result.success, true, result.error);
|
|
273
|
-
assert.deepStrictEqual(
|
|
269
|
+
assert.deepStrictEqual(
|
|
270
|
+
calls.filter((call) => call[1] !== 'inspect'),
|
|
271
|
+
[['docker', 'start', 'box']]
|
|
272
|
+
);
|
|
274
273
|
assert.strictEqual(watchers.length, 1);
|
|
275
274
|
assert.strictEqual(watchers[0][0], 'box');
|
|
276
275
|
assert.strictEqual(watchers[0][2], '/tmp/session.log');
|
|
277
|
-
assert.strictEqual(store.saved.length,
|
|
276
|
+
assert.strictEqual(store.saved.length, 3);
|
|
278
277
|
assert.strictEqual(store.saved[0].uuid, record.uuid);
|
|
279
278
|
assert.strictEqual(store.saved[0].status, 'executing');
|
|
280
279
|
assert.strictEqual(store.saved[0].options.resumeCount, 1);
|
|
@@ -286,9 +285,15 @@ describe('resumeExecution', () => {
|
|
|
286
285
|
const result = await resumeExecution(store, 'box', {
|
|
287
286
|
command: 'npm run build',
|
|
288
287
|
probe: () => probeWith(SessionState.STOPPED, 'exited'),
|
|
289
|
-
|
|
288
|
+
snapshotOptions: { freeBytes: () => 100 * 1024 ** 3 },
|
|
289
|
+
runner: (_bin, args) => ({
|
|
290
290
|
success: true,
|
|
291
|
-
stdout:
|
|
291
|
+
stdout:
|
|
292
|
+
args[0] === 'info'
|
|
293
|
+
? JSON.stringify({ DockerRootDir: '/docker' })
|
|
294
|
+
: args.includes('--size')
|
|
295
|
+
? '1024'
|
|
296
|
+
: 'deadbeef\n',
|
|
292
297
|
stderr: '',
|
|
293
298
|
status: 0,
|
|
294
299
|
}),
|
|
@@ -320,7 +325,10 @@ describe('resumeExecution', () => {
|
|
|
320
325
|
});
|
|
321
326
|
assert.strictEqual(result.success, false);
|
|
322
327
|
assert.match(result.error, /no such container/);
|
|
323
|
-
assert.strictEqual(store.saved.length,
|
|
328
|
+
assert.strictEqual(store.saved.length, 2);
|
|
329
|
+
assert.strictEqual(store.saved.at(-1).status, 'executing');
|
|
330
|
+
assert.strictEqual(store.saved.at(-1).options.sessionName, 'box');
|
|
331
|
+
assert.strictEqual(store.saved.at(-1).options.launchPending, undefined);
|
|
324
332
|
});
|
|
325
333
|
|
|
326
334
|
test('relaunches through the isolation backend when the container is gone', async () => {
|
package/test/exit-reason.js
CHANGED
|
@@ -111,7 +111,11 @@ describe('resolveExitReason', () => {
|
|
|
111
111
|
});
|
|
112
112
|
|
|
113
113
|
it('should report the cgroup OOM observation when there is no marker', () => {
|
|
114
|
-
const reason = resolveExitReason({
|
|
114
|
+
const reason = resolveExitReason({
|
|
115
|
+
exitCode: 137,
|
|
116
|
+
oomKilled: true,
|
|
117
|
+
exitEvidence: { mainOom: true },
|
|
118
|
+
});
|
|
115
119
|
assert.strictEqual(reason, 'memory-exhaustion (cgroup-oom-killer)');
|
|
116
120
|
});
|
|
117
121
|
|
|
@@ -173,6 +177,7 @@ describe('resolveMemoryExhaustion', () => {
|
|
|
173
177
|
resolveMemoryExhaustion({
|
|
174
178
|
exitCode: 137,
|
|
175
179
|
logTail: 'killed\n',
|
|
180
|
+
exitEvidence: { mainOom: true },
|
|
176
181
|
oomKilled: true,
|
|
177
182
|
}),
|
|
178
183
|
{
|
package/test/failure-handler.js
CHANGED
|
@@ -175,6 +175,17 @@ describe('failure-handler', () => {
|
|
|
175
175
|
assert.ok(!body.includes('\\"'), 'body must not contain escaped quotes');
|
|
176
176
|
});
|
|
177
177
|
|
|
178
|
+
it('sanitizes credentials in the issue title and body', () => {
|
|
179
|
+
if (process.platform === 'win32') {
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
const token = `gh${'p_'}${'a'.repeat(36)}`;
|
|
183
|
+
const { argv } = createIssueWithFakeGh(`echo ${token}`);
|
|
184
|
+
assert.ok(!argv[5].includes(token));
|
|
185
|
+
assert.ok(!argv[7].includes(token));
|
|
186
|
+
assert.ok(argv[7].includes('[REDACTED]'));
|
|
187
|
+
});
|
|
188
|
+
|
|
178
189
|
it('writes real newlines into the issue body', () => {
|
|
179
190
|
if (process.platform === 'win32') {
|
|
180
191
|
return;
|
package/test/isolation.js
CHANGED
|
@@ -16,6 +16,7 @@ const {
|
|
|
16
16
|
supportsLogfileOption,
|
|
17
17
|
resetScreenVersionCache,
|
|
18
18
|
getDefaultDockerImage,
|
|
19
|
+
canRunLinuxDockerImages,
|
|
19
20
|
} = require('../src/lib/isolation');
|
|
20
21
|
|
|
21
22
|
describe('Isolation Module', () => {
|
|
@@ -665,8 +666,8 @@ describe('detectShellInEnvironment', () => {
|
|
|
665
666
|
});
|
|
666
667
|
|
|
667
668
|
it('should auto-detect shell in docker if docker is available', () => {
|
|
668
|
-
if (!
|
|
669
|
-
console.log(' Skipping:
|
|
669
|
+
if (!canRunLinuxDockerImages()) {
|
|
670
|
+
console.log(' Skipping: Docker cannot run Linux container images');
|
|
670
671
|
return;
|
|
671
672
|
}
|
|
672
673
|
// Use alpine:latest which is commonly available and has sh
|
|
@@ -703,8 +704,8 @@ describe('Shell option forwarding to isolation runners', () => {
|
|
|
703
704
|
|
|
704
705
|
describe('runInDocker shell option', () => {
|
|
705
706
|
it('should accept shell option in options object', async () => {
|
|
706
|
-
if (!
|
|
707
|
-
console.log(' Skipping:
|
|
707
|
+
if (!canRunLinuxDockerImages()) {
|
|
708
|
+
console.log(' Skipping: Docker cannot run Linux container images');
|
|
708
709
|
return;
|
|
709
710
|
}
|
|
710
711
|
|